Files
Foundry-VTT-Docker/resources/app/client/data/documents/journal-entry-page.js
2025-01-04 00:34:03 +01:00

319 lines
12 KiB
JavaScript

/**
* The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
* @extends foundry.documents.BaseJournalEntryPage
* @mixes ClientDocumentMixin
*
* @see {@link JournalEntry} The JournalEntry document type which contains JournalEntryPage embedded documents.
*/
class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) {
/**
* @typedef {object} JournalEntryPageHeading
* @property {number} level The heading level, 1-6.
* @property {string} text The raw heading text with any internal tags omitted.
* @property {string} slug The generated slug for this heading.
* @property {HTMLHeadingElement} [element] The currently rendered element for this heading, if it exists.
* @property {string[]} children Any child headings of this one.
* @property {number} order The linear ordering of the heading in the table of contents.
*/
/**
* The cached table of contents for this JournalEntryPage.
* @type {Record<string, JournalEntryPageHeading>}
* @protected
*/
_toc;
/* -------------------------------------------- */
/**
* The table of contents for this JournalEntryPage.
* @type {Record<string, JournalEntryPageHeading>}
*/
get toc() {
if ( this.type !== "text" ) return {};
if ( this._toc ) return this._toc;
const renderTarget = document.createElement("template");
renderTarget.innerHTML = this.text.content;
this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false});
return this._toc;
}
/* -------------------------------------------- */
/** @inheritdoc */
get permission() {
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
return this.getUserLevel(game.user);
}
/* -------------------------------------------- */
/**
* Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any.
* If multiple notes are placed for this Journal Entry, only the first will be returned.
* @type {Note|null}
*/
get sceneNote() {
if ( !canvas.ready ) return null;
return canvas.notes.placeables.find(n => {
return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id);
}) || null;
}
/* -------------------------------------------- */
/* Table of Contents */
/* -------------------------------------------- */
/**
* Convert a heading into slug suitable for use as an identifier.
* @param {HTMLHeadingElement|string} heading The heading element or some text content.
* @returns {string}
*/
static slugifyHeading(heading) {
if ( heading instanceof HTMLElement ) heading = heading.textContent;
return heading.slugify().replace(/["']/g, "").substring(0, 64);
}
/* -------------------------------------------- */
/**
* Build a table of contents for the given HTML content.
* @param {HTMLElement[]} html The HTML content to generate a ToC outline for.
* @param {object} [options] Additional options to configure ToC generation.
* @param {boolean} [options.includeElement=true] Include references to the heading DOM elements in the returned ToC.
* @returns {Record<string, JournalEntryPageHeading>}
*/
static buildTOC(html, {includeElement=true}={}) {
// A pseudo root heading element to start at.
const root = {level: 0, children: []};
// Perform a depth-first-search down the DOM to locate heading nodes.
const stack = [root];
const searchHeadings = element => {
if ( element instanceof HTMLHeadingElement ) {
const node = this._makeHeadingNode(element, {includeElement});
let parent = stack.at(-1);
if ( node.level <= parent.level ) {
stack.pop();
parent = stack.at(-1);
}
parent.children.push(node);
stack.push(node);
}
for ( const child of (element.children || []) ) {
searchHeadings(child);
}
};
html.forEach(searchHeadings);
return this._flattenTOC(root.children);
}
/* -------------------------------------------- */
/**
* Flatten the tree structure into a single object with each node's slug as the key.
* @param {JournalEntryPageHeading[]} nodes The root ToC nodes.
* @returns {Record<string, JournalEntryPageHeading>}
* @protected
*/
static _flattenTOC(nodes) {
let order = 0;
const toc = {};
const addNode = node => {
if ( toc[node.slug] ) {
let i = 1;
while ( toc[`${node.slug}$${i}`] ) i++;
node.slug = `${node.slug}$${i}`;
}
node.order = order++;
toc[node.slug] = node;
return node.slug;
};
const flattenNode = node => {
const slug = addNode(node);
while ( node.children.length ) {
if ( typeof node.children[0] === "string" ) break;
const child = node.children.shift();
node.children.push(flattenNode(child));
}
return slug;
};
nodes.forEach(flattenNode);
return toc;
}
/* -------------------------------------------- */
/**
* Construct a table of contents node from a heading element.
* @param {HTMLHeadingElement} heading The heading element.
* @param {object} [options] Additional options to configure the returned node.
* @param {boolean} [options.includeElement=true] Whether to include the DOM element in the returned ToC node.
* @returns {JournalEntryPageHeading}
* @protected
*/
static _makeHeadingNode(heading, {includeElement=true}={}) {
const node = {
text: heading.innerText,
level: Number(heading.tagName[1]),
slug: heading.id || this.slugifyHeading(heading),
children: []
};
if ( includeElement ) node.element = heading;
return node;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_createDocumentLink(eventData, {relativeTo, label}={}) {
const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid;
if ( eventData.anchor?.slug ) {
label ??= eventData.anchor.name;
return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
}
return super._createDocumentLink(eventData, {relativeTo, label});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickDocumentLink(event) {
const target = event.currentTarget;
return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null;
if ( !canvas.ready ) return;
if ( ["name", "ownership"].some(k => k in changed) ) {
canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw());
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _buildEmbedHTML(config, options={}) {
const embed = await super._buildEmbedHTML(config, options);
if ( !embed ) {
if ( this.type === "text" ) return this._embedTextPage(config, options);
else if ( this.type === "image" ) return this._embedImagePage(config, options);
}
return embed;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _createFigureEmbed(content, config, options) {
const figure = await super._createFigureEmbed(content, config, options);
if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) {
const caption = figure.querySelector("figcaption > .embed-caption");
if ( caption ) caption.innerText = this.image.caption;
}
return figure;
}
/* -------------------------------------------- */
/**
* Embed text page content.
* @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config Configuration for embedding behavior. This can include
* enrichment options to override those passed as part of
* the root enrichment process.
* @param {EnrichmentOptions} [options] The original enrichment options to propagate to the embedded text page's
* enrichment.
* @returns {Promise<HTMLElement|HTMLCollection|null>}
* @protected
*
* @example Embed the content of the Journal Entry Page as a figure.
* ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}```
* becomes
* ```html
* <figure class="content-embed small right" data-content-embed
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
* <p>The contents of the page</p>
* <figcaption>
* <strong class="embed-caption">Special caption</strong>
* <cite>
* <a class="content-link" draggable="true" data-link
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y"
* data-id="yDbDF1ThSfeinh3Y" data-type="JournalEntryPage" data-tooltip="Text Page">
* <i class="fas fa-file-lines"></i> Text Page
* </a>
* </cite>
* <figcaption>
* </figure>
* ```
*
* @example Embed the content of the Journal Entry Page into the main content flow.
* ```@Embed[.yDbDF1ThSfeinh3Y inline]```
* becomes
* ```html
* <section class="content-embed" data-content-embed
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
* <p>The contents of the page</p>
* </section>
* ```
*/
async _embedTextPage(config, options={}) {
options = { ...options, relativeTo: this };
const {
secrets=options.secrets,
documents=options.documents,
links=options.links,
rolls=options.rolls,
embeds=options.embeds
} = config;
foundry.utils.mergeObject(options, { secrets, documents, links, rolls, embeds });
const enrichedPage = await TextEditor.enrichHTML(this.text.content, options);
const container = document.createElement("div");
container.innerHTML = enrichedPage;
return container.children;
}
/* -------------------------------------------- */
/**
* Embed image page content.
* @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior.
* @param {string} [config.alt] Alt text for the image, otherwise the caption will be used.
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
* also contains text that must be enriched.
* @returns {Promise<HTMLElement|HTMLCollection|null>}
* @protected
*
* @example Create an embedded image from a sibling journal entry page.
* ```@Embed[.QnH8yGIHy4pmFBHR classes="small right"]{Special caption}```
* becomes
* ```html
* <figure class="content-embed small right" data-content-embed
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR">
* <img src="path/to/image.webp" alt="Special caption">
* <figcaption>
* <strong class="embed-caption">Special caption</strong>
* <cite>
* <a class="content-link" draggable="true" data-link
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR"
* data-id="QnH8yGIHy4pmFBHR" data-type="JournalEntryPage" data-tooltip="Image Page">
* <i class="fas fa-file-image"></i> Image Page
* </a>
* </cite>
* </figcaption>
* </figure>
* ```
*/
async _embedImagePage({ alt, label }, options={}) {
const img = document.createElement("img");
img.src = this.src;
img.alt = alt || label || this.image.caption || this.name;
return img;
}
}