1065 lines
36 KiB
JavaScript
1065 lines
36 KiB
JavaScript
/**
|
|
* @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<string>}
|
|
* @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<string, JournalPageSheet>}
|
|
*/
|
|
#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<HTMLHeadingElement, IntersectionObserverEntry>}
|
|
*/
|
|
#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<void>}
|
|
* @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<string, JournalEntryPageHeading>} 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: '<i class="fas fa-edit"></i>',
|
|
condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
|
|
callback: li => getPage(li)?.sheet.render(true)
|
|
}, {
|
|
name: "SIDEBAR.Delete",
|
|
icon: '<i class="fas fa-trash"></i>',
|
|
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: '<i class="far fa-copy"></i>',
|
|
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: '<i class="fas fa-lock"></i>',
|
|
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: '<i class="fas fa-eye"></i>',
|
|
condition: li => getPage(li)?.isOwner,
|
|
callback: li => {
|
|
const page = getPage(li);
|
|
if ( page ) return Journal.showDialog(page);
|
|
}
|
|
}, {
|
|
name: "SIDEBAR.JumpPin",
|
|
icon: '<i class="fa-solid fa-crosshairs"></i>',
|
|
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 <form> 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;
|
|
}
|
|
}
|
|
}
|