/** * @typedef {DocumentSheetOptions} JournalSheetOptions * @property {string|null} [sheetMode] The current display mode of the journal. Either 'text' or 'image'. */ /** * The Application responsible for displaying and editing a single JournalEntry document. * @extends {DocumentSheet} * @param {JournalEntry} object The JournalEntry instance which is being edited * @param {JournalSheetOptions} [options] Application options */ class JournalSheet extends DocumentSheet { /** * @override * @returns {JournalSheetOptions} */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sheet", "journal-sheet", "journal-entry"], template: "templates/journal/sheet.html", width: 960, height: 800, resizable: true, submitOnChange: true, submitOnClose: true, closeOnSubmit: false, viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE, scrollY: [".scrollable"], filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}], dragDrop: [{dragSelector: ".directory-item, .heading-link", dropSelector: ".directory-list"}], pageIndex: undefined, pageId: undefined }); } /* -------------------------------------------- */ /** * The cached list of processed page entries. * This array is populated in the getData method. * @type {object[]} * @protected */ _pages; /** * Track which page IDs are currently displayed due to a search filter * @type {Set} * @private */ #filteredPages = new Set(); /** * The pages that are currently scrolled into view and marked as 'active' in the sidebar. * @type {HTMLElement[]} * @private */ #pagesInView = []; /** * The index of the currently viewed page. * @type {number} * @private */ #pageIndex = 0; /** * Has the player been granted temporary ownership of this journal entry or its pages? * @type {boolean} * @private */ #tempOwnership = false; /** * A mapping of page IDs to {@link JournalPageSheet} instances used for rendering the pages inside the journal entry. * @type {Record} */ #sheets = {}; /** * Store a flag to restore ToC positions after a render. * @type {boolean} */ #restoreTOCPositions = false; /** * Store transient sidebar state so it can be restored after context menus are closed. * @type {{position: number, active: boolean, collapsed: boolean}} */ #sidebarState = {collapsed: false}; /** * Store a reference to the currently active IntersectionObserver. * @type {IntersectionObserver} */ #observer; /** * Store a special set of heading intersections so that we can quickly compute the top-most heading in the viewport. * @type {Map} */ #headingIntersections = new Map(); /** * Store the journal entry's current view mode. * @type {number|null} */ #mode = null; /* -------------------------------------------- */ /** * Get the journal entry's current view mode. * @see {@link JournalSheet.VIEW_MODES} * @returns {number} */ get mode() { return this.#mode ?? this.document.getFlag("core", "viewMode") ?? this.constructor.VIEW_MODES.SINGLE; } /* -------------------------------------------- */ /** * The current search mode for this journal * @type {string} */ get searchMode() { return this.document.getFlag("core", "searchMode") || CONST.DIRECTORY_SEARCH_MODES.NAME; } /** * Toggle the search mode for this journal between "name" and "full" text search */ toggleSearchMode() { const updatedSearchMode = this.document.getFlag("core", "searchMode") === CONST.DIRECTORY_SEARCH_MODES.NAME ? CONST.DIRECTORY_SEARCH_MODES.FULL : CONST.DIRECTORY_SEARCH_MODES.NAME; this.document.setFlag("core", "searchMode", updatedSearchMode); } /* -------------------------------------------- */ /** * The pages that are currently scrolled into view and marked as 'active' in the sidebar. * @type {HTMLElement[]} */ get pagesInView() { return this.#pagesInView; } /* -------------------------------------------- */ /** * The index of the currently viewed page. * @type {number} */ get pageIndex() { return this.#pageIndex; } /* -------------------------------------------- */ /** * The currently active IntersectionObserver. * @type {IntersectionObserver} */ get observer() { return this.#observer; } /* -------------------------------------------- */ /** * Is the table-of-contents sidebar currently collapsed? * @type {boolean} */ get sidebarCollapsed() { return this.#sidebarState.collapsed; } /* -------------------------------------------- */ /** * Available view modes for journal entries. * @enum {number} */ static VIEW_MODES = { SINGLE: 1, MULTIPLE: 2 }; /* -------------------------------------------- */ /** * The minimum amount of content that must be visible before the next page is marked as in view. Cannot be less than * 25% without also modifying the IntersectionObserver threshold. * @type {number} */ static INTERSECTION_RATIO = .25; /* -------------------------------------------- */ /** * Icons for page ownership. * @enum {string} */ static OWNERSHIP_ICONS = { [CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: "fa-solid fa-eye-slash", [CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: "fa-solid fa-eye", [CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: "fa-solid fa-feather-pointed" }; /* -------------------------------------------- */ /** @inheritdoc */ get title() { const folder = game.folders.get(this.object.folder?.id); const name = `${folder ? `${folder.name}: ` : ""}${this.object.name}`; return this.object.permission ? name : ""; } /* -------------------------------------------- */ /** @inheritdoc */ _getHeaderButtons() { const buttons = super._getHeaderButtons(); // Share Entry if ( game.user.isGM ) { buttons.unshift({ label: "JOURNAL.ActionShow", class: "share-image", icon: "fas fa-eye", onclick: ev => this._onShowPlayers(ev) }); } return buttons; } /* -------------------------------------------- */ /** @inheritdoc */ getData(options={}) { const context = super.getData(options); context.mode = this.mode; context.toc = this._pages = this._getPageData(); this._getCurrentPage(options); context.viewMode = {}; // Viewing single page if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) { context.pages = [context.toc[this.pageIndex]]; context.viewMode = {label: "JOURNAL.ViewMultiple", icon: "fa-solid fa-note", cls: "single-page"}; } // Viewing multiple pages else { context.pages = context.toc; context.viewMode = {label: "JOURNAL.ViewSingle", icon: "fa-solid fa-notes", cls: "multi-page"}; } // Sidebar collapsed mode context.sidebarClass = this.sidebarCollapsed ? "collapsed" : ""; context.collapseMode = this.sidebarCollapsed ? {label: "JOURNAL.ViewExpand", icon: "fa-solid fa-caret-left"} : {label: "JOURNAL.ViewCollapse", icon: "fa-solid fa-caret-right"}; // Search mode context.searchIcon = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" : "fa-file-magnifying-glass"; context.searchTooltip = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" : "SIDEBAR.SearchModeFull"; return context; } /* -------------------------------------------- */ /** * Prepare pages for display. * @returns {JournalEntryPage[]} The sorted list of pages. * @protected */ _getPageData() { const hasFilterQuery = !!this._searchFilters[0].query; return this.object.pages.contents.sort((a, b) => a.sort - b.sort).reduce((arr, page) => { if ( !this.isPageVisible(page) ) return arr; const p = page.toObject(); const sheet = this.getPageSheet(page.id); // Page CSS classes const cssClasses = [p.type, `level${p.title.level}`]; if ( hasFilterQuery && !this.#filteredPages.has(page.id) ) cssClasses.push("hidden"); p.tocClass = p.cssClass = cssClasses.join(" "); cssClasses.push(...(sheet.options.viewClasses || [])); p.viewClass = cssClasses.join(" "); // Other page data p.editable = page.isOwner; if ( page.parent.pack ) p.editable &&= !game.packs.get(page.parent.pack)?.locked; p.number = arr.length; p.icon = this.constructor.OWNERSHIP_ICONS[page.ownership.default]; const levels = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS); const [ownership] = levels.find(([, level]) => level === page.ownership.default); p.ownershipCls = ownership.toLowerCase(); arr.push(p); return arr; }, []); } /* -------------------------------------------- */ /** * Identify which page of the journal sheet should be currently rendered. * This can be controlled by options passed into the render method or by a subclass override. * @param {object} options Sheet rendering options * @param {number} [options.pageIndex] A numbered index of page to render * @param {string} [options.pageId] The ID of a page to render * @returns {number} The currently displayed page index * @protected */ _getCurrentPage({pageIndex, pageId}={}) { let newPageIndex; if ( typeof pageIndex === "number" ) newPageIndex = pageIndex; if ( pageId ) newPageIndex = this._pages.findIndex(p => p._id === pageId); if ( (newPageIndex != null) && (newPageIndex !== this.pageIndex) ) { if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) this.#callCloseHooks(this.pageIndex); this.#pageIndex = newPageIndex; } this.options.pageIndex = this.options.pageId = undefined; return this.#pageIndex = Math.clamp(this.pageIndex, 0, this._pages.length - 1); } /* -------------------------------------------- */ /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); html.on("click", "img:not(.nopopout)", this._onClickImage.bind(this)); html.find("button[data-action], a[data-action]").click(this._onAction.bind(this)); this._contextMenu(html); } /* -------------------------------------------- */ /** * Activate listeners after page content has been injected. * @protected */ _activatePageListeners() { const html = this.element; html.find(".editor-edit").click(this._onEditPage.bind(this)); html.find(".page-heading").click(this._onClickPageLink.bind(this)); } /* -------------------------------------------- */ /** * @inheritdoc * @param {number} [options.mode] Render the sheet in a given view mode, see {@link JournalSheet.VIEW_MODES}. * @param {string} [options.pageId] Render the sheet with the page with the given ID in view. * @param {number} [options.pageIndex] Render the sheet with the page at the given index in view. * @param {string} [options.anchor] Render the sheet with the given anchor for the given page in view. * @param {boolean} [options.tempOwnership] Whether the journal entry or one of its pages is being shown to players * who might otherwise not have permission to view it. * @param {boolean} [options.collapsed] Render the sheet with the TOC sidebar collapsed? */ async _render(force, options={}) { // Temporary override of ownership if ( "tempOwnership" in options ) this.#tempOwnership = options.tempOwnership; // Override the view mode const modeChange = ("mode" in options) && (options.mode !== this.mode); if ( modeChange ) { if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.#callCloseHooks(); this.#mode = options.mode; } if ( "collapsed" in options ) this.#sidebarState.collapsed = options.collapsed; // Render the application await super._render(force, options); if ( !this.rendered ) return; await this._renderPageViews(); this._activatePageListeners(); // Re-sync the TOC scroll position to the new view const pageChange = ("pageIndex" in options) || ("pageId" in options); if ( modeChange || pageChange ) { const pageId = this._pages[this.pageIndex]?._id; if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.goToPage(pageId, options.anchor); else if ( options.anchor ) { this.getPageSheet(pageId)?.toc[options.anchor]?.element?.scrollIntoView(); this.#restoreTOCPositions = true; } } else this._restoreScrollPositions(this.element); } /* -------------------------------------------- */ /** * Update child views inside the main sheet. * @returns {Promise} * @protected */ async _renderPageViews() { for ( const pageNode of this.element[0].querySelectorAll(".journal-entry-page") ) { const id = pageNode.dataset.pageId; if ( !id ) continue; const edit = pageNode.querySelector(":scope > .edit-container"); const sheet = this.getPageSheet(id); const data = await sheet.getData(); const view = await sheet._renderInner(data); pageNode.replaceChildren(...view.get()); if ( edit ) pageNode.appendChild(edit); sheet._activateCoreListeners(view.parent()); sheet.activateListeners(view); await this._renderHeadings(pageNode, sheet.toc); sheet._callHooks("render", view, data); } this._observePages(); this._observeHeadings(); } /* -------------------------------------------- */ /** * Call close hooks for individual pages. * @param {number} [pageIndex] Calls the hook for this page only, otherwise calls for all pages. */ #callCloseHooks(pageIndex) { if ( !this._pages?.length || (pageIndex < 0) ) return; const pages = pageIndex != null ? [this._pages[pageIndex]] : this._pages; for ( const page of pages ) { const sheet = this.getPageSheet(page._id); sheet._callHooks("close", sheet.element); sheet._closeView(); } } /* -------------------------------------------- */ /** * Add headings to the table of contents for the given page node. * @param {HTMLElement} pageNode The HTML node of the page's rendered contents. * @param {Record} toc The page's table of contents. * @protected */ async _renderHeadings(pageNode, toc) { const pageId = pageNode.dataset.pageId; const page = this.object.pages.get(pageId); const tocNode = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`); if ( !tocNode || !toc ) return; const headings = Object.values(toc); headings.sort((a, b) => a.order - b.order); if ( page.title.show ) headings.shift(); const minLevel = Math.min(...headings.map(node => node.level)); tocNode.querySelector(":scope > ol")?.remove(); const tocHTML = await renderTemplate("templates/journal/journal-page-toc.html", { headings: headings.reduce((arr, {text, level, slug, element}) => { if ( element ) element.dataset.anchor = slug; if ( level < minLevel + 2 ) arr.push({text, slug, level: level - minLevel + 2}); return arr; }, []) }); tocNode.innerHTML += tocHTML; tocNode.querySelectorAll(".heading-link").forEach(el => el.addEventListener("click", this._onClickPageLink.bind(this))); this._dragDrop.forEach(d => d.bind(tocNode)); } /* -------------------------------------------- */ /** * Create an intersection observer to maintain a list of pages that are in view. * @protected */ _observePages() { this.#pagesInView = []; this.#observer = new IntersectionObserver((entries, observer) => { this._onPageScroll(entries, observer); this._activatePagesInView(); this._updateButtonState(); }, { root: this.element.find(".journal-entry-pages .scrollable")[0], threshold: [0, .25, .5, .75, 1] }); this.element.find(".journal-entry-page").each((i, el) => this.#observer.observe(el)); } /* -------------------------------------------- */ /** * Create an intersection observer to maintain a list of headings that are in view. This is much more performant than * calling getBoundingClientRect on all headings whenever we want to determine this list. * @protected */ _observeHeadings() { const element = this.element[0]; this.#headingIntersections = new Map(); const headingObserver = new IntersectionObserver(entries => entries.forEach(entry => { if ( entry.isIntersecting ) this.#headingIntersections.set(entry.target, entry); else this.#headingIntersections.delete(entry.target); }), { root: element.querySelector(".journal-entry-pages .scrollable"), threshold: 1 }); const headings = Array.fromRange(6, 1).map(n => `h${n}`).join(","); element.querySelectorAll(`.journal-entry-page :is(${headings})`).forEach(el => headingObserver.observe(el)); } /* -------------------------------------------- */ /** @inheritdoc */ async close(options={}) { // Reset any temporarily-granted ownership. if ( this.#tempOwnership ) { this.object.ownership = foundry.utils.deepClone(this.object._source.ownership); this.object.pages.forEach(p => p.ownership = foundry.utils.deepClone(p._source.ownership)); this.#tempOwnership = false; } return super.close(options); } /* -------------------------------------------- */ /** * Handle clicking the previous and next page buttons. * @param {JQuery.TriggeredEvent} event The button click event. * @protected */ _onAction(event) { event.preventDefault(); const button = event.currentTarget; const action = button.dataset.action; switch (action) { case "previous": return this.previousPage(); case "next": return this.nextPage(); case "createPage": return this.createPage(); case "toggleView": const modes = this.constructor.VIEW_MODES; const mode = this.mode === modes.SINGLE ? modes.MULTIPLE : modes.SINGLE; this.#mode = mode; return this.render(true, {mode}); case "toggleCollapse": return this.toggleSidebar(event); case "toggleSearch": this.toggleSearchMode(); return this.render(); } } /* -------------------------------------------- */ /** * Prompt the user with a Dialog for creation of a new JournalEntryPage */ createPage() { const bounds = this.element[0].getBoundingClientRect(); const options = {parent: this.object, width: 320, top: bounds.bottom - 200, left: bounds.left + 10}; const sort = (this._pages.at(-1)?.sort ?? 0) + CONST.SORT_INTEGER_DENSITY; return JournalEntryPage.implementation.createDialog({sort}, options); } /* -------------------------------------------- */ /** * Turn to the previous page. */ previousPage() { if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex - 1}); this.pagesInView[0]?.previousElementSibling?.scrollIntoView(); } /* -------------------------------------------- */ /** * Turn to the next page. */ nextPage() { if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex + 1}); if ( this.pagesInView.length ) this.pagesInView.at(-1).nextElementSibling?.scrollIntoView(); else this.element[0].querySelector(".journal-entry-page")?.scrollIntoView(); } /* -------------------------------------------- */ /** * Turn to a specific page. * @param {string} pageId The ID of the page to turn to. * @param {string} [anchor] Optionally an anchor slug to focus within that page. */ goToPage(pageId, anchor) { if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) { const currentPageId = this._pages[this.pageIndex]?._id; if ( currentPageId !== pageId ) return this.render(true, {pageId, anchor}); } const page = this.element[0].querySelector(`.journal-entry-page[data-page-id="${pageId}"]`); if ( anchor ) { const element = this.getPageSheet(pageId)?.toc[anchor]?.element; if ( element ) { element.scrollIntoView(); return; } } page?.scrollIntoView(); } /* -------------------------------------------- */ /** * Retrieve the sheet instance for rendering this page inline. * @param {string} pageId The ID of the page. * @returns {JournalPageSheet} */ getPageSheet(pageId) { const page = this.object.pages.get(pageId); const sheetClass = page._getSheetClass(); let sheet = this.#sheets[pageId]; if ( sheet?.constructor !== sheetClass ) { sheet = new sheetClass(page, {editable: false}); this.#sheets[pageId] = sheet; } return sheet; } /* -------------------------------------------- */ /** * Determine whether a page is visible to the current user. * @param {JournalEntryPage} page The page. * @returns {boolean} */ isPageVisible(page) { return this.getPageSheet(page.id)._canUserView(game.user); } /* -------------------------------------------- */ /** * Toggle the collapsed or expanded state of the Journal Entry table-of-contents sidebar. */ toggleSidebar() { const app = this.element[0]; const sidebar = app.querySelector(".sidebar"); const button = sidebar.querySelector(".collapse-toggle"); this.#sidebarState.collapsed = !this.sidebarCollapsed; // Disable application interaction temporarily app.style.pointerEvents = "none"; // Configure CSS transitions for the application window app.classList.add("collapsing"); app.addEventListener("transitionend", () => { app.style.pointerEvents = ""; app.classList.remove("collapsing"); }, {once: true}); // Learn the configure sidebar widths const style = getComputedStyle(sidebar); const expandedWidth = Number(style.getPropertyValue("--sidebar-width-expanded").trim().replace("px", "")); const collapsedWidth = Number(style.getPropertyValue("--sidebar-width-collapsed").trim().replace("px", "")); // Change application position const delta = expandedWidth - collapsedWidth; this.setPosition({ left: this.position.left + (this.sidebarCollapsed ? delta : -delta), width: this.position.width + (this.sidebarCollapsed ? -delta : delta) }); // Toggle display of the sidebar sidebar.classList.toggle("collapsed", this.sidebarCollapsed); // Update icons and labels button.dataset.tooltip = this.sidebarCollapsed ? "JOURNAL.ViewExpand" : "JOURNAL.ViewCollapse"; const i = button.children[0]; i.setAttribute("class", `fa-solid ${this.sidebarCollapsed ? "fa-caret-left" : "fa-caret-right"}`); game.tooltip.deactivate(); } /* -------------------------------------------- */ /** * Update the disabled state of the previous and next page buttons. * @protected */ _updateButtonState() { if ( !this.element?.length ) return; const previous = this.element[0].querySelector('[data-action="previous"]'); const next = this.element[0].querySelector('[data-action="next"]'); if ( !next || !previous ) return; if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) { previous.disabled = this.pageIndex < 1; next.disabled = this.pageIndex >= (this._pages.length - 1); } else { previous.disabled = !this.pagesInView[0]?.previousElementSibling; next.disabled = this.pagesInView.length && !this.pagesInView.at(-1).nextElementSibling; } } /* -------------------------------------------- */ /** * Edit one of this JournalEntry's JournalEntryPages. * @param {JQuery.TriggeredEvent} event The originating page edit event. * @protected */ _onEditPage(event) { event.preventDefault(); const button = event.currentTarget; const pageId = button.closest("[data-page-id]").dataset.pageId; const page = this.object.pages.get(pageId); return page?.sheet.render(true); } /* -------------------------------------------- */ /** * Handle clicking an entry in the sidebar to scroll that heading into view. * @param {JQuery.TriggeredEvent} event The originating click event. * @protected */ _onClickPageLink(event) { const target = event.currentTarget; const pageId = target.closest("[data-page-id]").dataset.pageId; const anchor = target.closest("[data-anchor]")?.dataset.anchor; this.goToPage(pageId, anchor); } /* -------------------------------------------- */ /** * Handle clicking an image to pop it out for fullscreen view. * @param {MouseEvent} event The click event. * @protected */ _onClickImage(event) { const target = event.currentTarget; const imagePage = target.closest(".journal-entry-page.image"); const page = this.object.pages.get(imagePage?.dataset.pageId); const title = page?.name ?? target.title; const ip = new ImagePopout(target.getAttribute("src"), {title, caption: page?.image.caption}); if ( page ) ip.shareImage = () => Journal.showDialog(page); ip.render(true); } /* -------------------------------------------- */ /** * Handle new pages scrolling into view. * @param {IntersectionObserverEntry[]} entries An Array of elements that have scrolled into or out of view. * @param {IntersectionObserver} observer The IntersectionObserver that invoked this callback. * @protected */ _onPageScroll(entries, observer) { if ( !entries.length ) return; // This has been triggered by an old IntersectionObserver from the previous render and is no longer relevant. if ( observer !== this.observer ) return; // Case 1 - We are in single page mode. if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) { const entry = entries[0]; // There can be only one entry in single page mode. if ( entry.isIntersecting ) this.#pagesInView = [entry.target]; return; } const minRatio = this.constructor.INTERSECTION_RATIO; const intersecting = entries .filter(entry => entry.isIntersecting && (entry.intersectionRatio >= minRatio)) .sort((a, b) => a.intersectionRect.y - b.intersectionRect.y); // Special case where the page is so large that any portion of visible content is less than 25% of the whole page. if ( !intersecting.length ) { const isIntersecting = entries.find(entry => entry.isIntersecting); if ( isIntersecting ) intersecting.push(isIntersecting); } // Case 2 - We are in multiple page mode and this is the first render. if ( !this.pagesInView.length ) { this.#pagesInView = intersecting.map(entry => entry.target); return; } // Case 3 - The user is scrolling normally through pages in multiple page mode. const byTarget = new Map(entries.map(entry => [entry.target, entry])); const inView = [...this.pagesInView]; // Remove pages that have scrolled out of view. for ( const el of this.pagesInView ) { const entry = byTarget.get(el); if ( entry && (entry.intersectionRatio < minRatio) ) inView.findSplice(p => p === el); } // Add pages that have scrolled into view. for ( const entry of intersecting ) { if ( !inView.includes(entry.target) ) inView.push(entry.target); } this.#pagesInView = inView.sort((a, b) => { const pageA = this.object.pages.get(a.dataset.pageId); const pageB = this.object.pages.get(b.dataset.pageId); return pageA.sort - pageB.sort; }); } /* -------------------------------------------- */ /** * Highlights the currently viewed page in the sidebar. * @protected */ _activatePagesInView() { // Update the pageIndex to the first page in view for when the mode is switched to single view. if ( this.pagesInView.length ) { const pageId = this.pagesInView[0].dataset.pageId; this.#pageIndex = this._pages.findIndex(p => p._id === pageId); } let activeChanged = false; const pageIds = new Set(this.pagesInView.map(p => p.dataset.pageId)); this.element.find(".directory-item").each((i, el) => { activeChanged ||= (el.classList.contains("active") !== pageIds.has(el.dataset.pageId)); el.classList.toggle("active", pageIds.has(el.dataset.pageId)); }); if ( activeChanged ) this._synchronizeSidebar(); } /* -------------------------------------------- */ /** * If the set of active pages has changed, various elements in the sidebar will expand and collapse. For particularly * long ToCs, this can leave the scroll position of the sidebar in a seemingly random state. We try to do our best to * sync the sidebar scroll position with the current journal viewport. * @protected */ _synchronizeSidebar() { const entries = Array.from(this.#headingIntersections.values()).sort((a, b) => { return a.intersectionRect.y - b.intersectionRect.y; }); for ( const entry of entries ) { const pageId = entry.target.closest("[data-page-id]")?.dataset.pageId; const anchor = entry.target.dataset.anchor; let toc = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`); if ( anchor ) toc = toc.querySelector(`li[data-anchor="${anchor}"]`); if ( toc ) { toc.scrollIntoView(); break; } } } /* -------------------------------------------- */ /** @inheritdoc */ _contextMenu(html) { ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions(), { onOpen: this._onContextMenuOpen.bind(this), onClose: this._onContextMenuClose.bind(this) }); } /* -------------------------------------------- */ /** * Handle opening the context menu. * @param {HTMLElement} target The element the context menu has been triggered for. * @protected */ _onContextMenuOpen(target) { this.#sidebarState = { position: this.element.find(".directory-list.scrollable").scrollTop(), active: target.classList.contains("active") }; target.classList.remove("active"); } /* -------------------------------------------- */ /** * Handle closing the context menu. * @param {HTMLElement} target The element the context menu has been triggered for. * @protected */ _onContextMenuClose(target) { if ( this.#sidebarState.active ) target.classList.add("active"); this.element.find(".directory-list.scrollable").scrollTop(this.#sidebarState.position); } /* -------------------------------------------- */ /** * Get the set of ContextMenu options which should be used for JournalEntryPages in the sidebar. * @returns {ContextMenuEntry[]} The Array of context options passed to the ContextMenu instance. * @protected */ _getEntryContextOptions() { const getPage = li => this.object.pages.get(li.data("page-id")); return [{ name: "SIDEBAR.Edit", icon: '', condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"), callback: li => getPage(li)?.sheet.render(true) }, { name: "SIDEBAR.Delete", icon: '', condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"), callback: li => { const bounds = li[0].getBoundingClientRect(); return getPage(li)?.deleteDialog({top: bounds.top, left: bounds.right}); } }, { name: "SIDEBAR.Duplicate", icon: '', condition: this.isEditable, callback: li => { const page = getPage(li); return page.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: page.name})}, { save: true, addSource: true }); } }, { name: "OWNERSHIP.Configure", icon: '', condition: () => game.user.isGM, callback: li => { const page = getPage(li); const bounds = li[0].getBoundingClientRect(); new DocumentOwnershipConfig(page, {top: bounds.top, left: bounds.right}).render(true); } }, { name: "JOURNAL.ActionShow", icon: '', condition: li => getPage(li)?.isOwner, callback: li => { const page = getPage(li); if ( page ) return Journal.showDialog(page); } }, { name: "SIDEBAR.JumpPin", icon: '', condition: li => { const page = getPage(li); return !!page?.sceneNote; }, callback: li => { const page = getPage(li); if ( page?.sceneNote ) return canvas.notes.panToNote(page.sceneNote); } }]; } /* -------------------------------------------- */ /** @inheritdoc */ async _updateObject(event, formData) { // Remove
tags which will break the display of the sheet. if ( formData.content ) formData.content = formData.content.replace(/<\s*\/?\s*form(\s+[^>]*)?>/g, ""); return super._updateObject(event, formData); } /* -------------------------------------------- */ /** * Handle requests to show the referenced Journal Entry to other Users * Save the form before triggering the show request, in case content has changed * @param {Event} event The triggering click event */ async _onShowPlayers(event) { event.preventDefault(); await this.submit(); return Journal.showDialog(this.object); } /* -------------------------------------------- */ /** @inheritdoc */ _canDragStart(selector) { return this.object.testUserPermission(game.user, "OBSERVER"); } /* -------------------------------------------- */ /** @inheritdoc */ _canDragDrop(selector) { return this.isEditable; } /* -------------------------------------------- */ /** @inheritdoc */ _onDragStart(event) { if ( ui.context ) ui.context.close({animate: false}); const target = event.currentTarget; const pageId = target.closest("[data-page-id]").dataset.pageId; const anchor = target.closest("[data-anchor]")?.dataset.anchor; const page = this.object.pages.get(pageId); const dragData = { ...page.toDragData(), anchor: { slug: anchor, name: target.innerText } }; event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); } /* -------------------------------------------- */ /** @inheritdoc */ async _onDrop(event) { // Retrieve the dropped Journal Entry Page const data = TextEditor.getDragEventData(event); if ( data.type !== "JournalEntryPage" ) return; const page = await JournalEntryPage.implementation.fromDropData(data); if ( !page ) return; // Determine the target that was dropped const target = event.target.closest("[data-page-id]"); const sortTarget = target ? this.object.pages.get(target?.dataset.pageId) : null; // Prevent dropping a page on itself. if ( page === sortTarget ) return; // Case 1 - Sort Pages if ( page.parent === this.document ) return page.sortRelative({ sortKey: "sort", target: sortTarget, siblings: this.object.pages.filter(p => p.id !== page.id) }); // Case 2 - Create Pages const pageData = page.toObject(); if ( this.object.pages.has(page.id) ) delete pageData._id; pageData.sort = sortTarget ? sortTarget.sort : this.object.pages.reduce((max, p) => p.sort > max ? p.sort : max, 0); return this.document.createEmbeddedDocuments("JournalEntryPage", [pageData], {keepId: true}); } /* -------------------------------------------- */ /** @inheritdoc */ _onSearchFilter(event, query, rgx, html) { this.#filteredPages.clear(); const nameOnlySearch = (this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME); // Match Pages let results = []; if ( !nameOnlySearch ) results = this.object.pages.search({query: query}); for ( const el of html.querySelectorAll(".directory-item") ) { const page = this.object.pages.get(el.dataset.pageId); let match = !query; if ( !match && nameOnlySearch ) match = rgx.test(SearchFilter.cleanQuery(page.name)); else if ( !match ) match = !!results.find(r => r._id === page._id); if ( match ) this.#filteredPages.add(page._id); el.classList.toggle("hidden", !match); } // Restore TOC Positions if ( this.#restoreTOCPositions && this._scrollPositions ) { this.#restoreTOCPositions = false; const position = this._scrollPositions[this.options.scrollY[0]]?.[0]; const toc = this.element[0].querySelector(".pages-list .scrollable"); if ( position && toc ) toc.scrollTop = position; } } }