/** * A collection of helper functions and utility methods related to the rich text editor */ class TextEditor { /** * A singleton text area used for HTML decoding. * @type {HTMLTextAreaElement} */ static #decoder = document.createElement("textarea"); /** * Create a Rich Text Editor. The current implementation uses TinyMCE * @param {object} options Configuration options provided to the Editor init * @param {string} [options.engine=tinymce] Which rich text editor engine to use, "tinymce" or "prosemirror". TinyMCE * is deprecated and will be removed in a later version. * @param {string} content Initial HTML or text content to populate the editor with * @returns {Promise} The editor instance. */ static async create({engine="tinymce", ...options}={}, content="") { if ( engine === "prosemirror" ) { const {target, ...rest} = options; return ProseMirrorEditor.create(target, content, rest); } if ( engine === "tinymce" ) return this._createTinyMCE(options, content); throw new Error(`Provided engine '${engine}' is not a valid TextEditor engine.`); } /** * A list of elements that are retained when truncating HTML. * @type {Set} * @private */ static _PARAGRAPH_ELEMENTS = new Set([ "header", "main", "section", "article", "div", "footer", // Structural Elements "h1", "h2", "h3", "h4", "h5", "h6", // Headers "p", "blockquote", "summary", "span", "a", "mark", // Text Types "strong", "em", "b", "i", "u" // Text Styles ]); /* -------------------------------------------- */ /** * Create a TinyMCE editor instance. * @param {object} [options] Configuration options passed to the editor. * @param {string} [content=""] Initial HTML or text content to populate the editor with. * @returns {Promise} The TinyMCE editor instance. * @protected */ static async _createTinyMCE(options={}, content="") { const mceConfig = foundry.utils.mergeObject(CONFIG.TinyMCE, options, {inplace: false}); mceConfig.target = options.target; mceConfig.file_picker_callback = function (pickerCallback, value, meta) { let filePicker = new FilePicker({ type: "image", callback: path => { pickerCallback(path); // Reset our z-index for next open $(".tox-tinymce-aux").css({zIndex: ''}); }, }); filePicker.render(); // Set the TinyMCE dialog to be below the FilePicker $(".tox-tinymce-aux").css({zIndex: Math.min(++_maxZ, 9999)}); }; if ( mceConfig.content_css instanceof Array ) { mceConfig.content_css = mceConfig.content_css.map(c => foundry.utils.getRoute(c)).join(","); } mceConfig.init_instance_callback = editor => { const window = editor.getWin(); editor.focus(); if ( content ) editor.resetContent(content); editor.selection.setCursorLocation(editor.getBody(), editor.getBody().childElementCount); window.addEventListener("wheel", event => { if ( event.ctrlKey ) event.preventDefault(); }, {passive: false}); editor.off("drop dragover"); // Remove the default TinyMCE dragdrop handlers. editor.on("drop", event => this._onDropEditorData(event, editor)); }; const [editor] = await tinyMCE.init(mceConfig); editor.document = options.document; return editor; } /* -------------------------------------------- */ /* HTML Manipulation Helpers /* -------------------------------------------- */ /** * Safely decode an HTML string, removing invalid tags and converting entities back to unicode characters. * @param {string} html The original encoded HTML string * @returns {string} The decoded unicode string */ static decodeHTML(html) { const d = TextEditor.#decoder; d.innerHTML = html; const decoded = d.value; d.innerHTML = ""; return decoded; } /* -------------------------------------------- */ /** * @typedef {object} EnrichmentOptions * @property {boolean} [secrets=false] Include unrevealed secret tags in the final HTML? If false, unrevealed * secret blocks will be removed. * @property {boolean} [documents=true] Replace dynamic document links? * @property {boolean} [links=true] Replace hyperlink content? * @property {boolean} [rolls=true] Replace inline dice rolls? * @property {boolean} [embeds=true] Replace embedded content? * @property {object|Function} [rollData] The data object providing context for inline rolls, or a function that * produces it. * @property {ClientDocument} [relativeTo] A document to resolve relative UUIDs against. */ /** * Enrich HTML content by replacing or augmenting components of it * @param {string} content The original HTML content (as a string) * @param {EnrichmentOptions} [options={}] Additional options which configure how HTML is enriched * @returns {Promise} The enriched HTML content */ static async enrichHTML(content, options={}) { let {secrets=false, documents=true, links=true, embeds=true, rolls=true, rollData} = options; if ( !content?.length ) return ""; // Create the HTML element const html = document.createElement("div"); html.innerHTML = String(content || ""); // Remove unrevealed secret blocks if ( !secrets ) html.querySelectorAll("section.secret:not(.revealed)").forEach(secret => secret.remove()); // Increment embedded content depth recursion counter. options._embedDepth = (options._embedDepth ?? -1) + 1; // Plan text content replacements const fns = []; if ( documents ) fns.push(this._enrichContentLinks.bind(this)); if ( links ) fns.push(this._enrichHyperlinks.bind(this)); if ( rolls ) fns.push(this._enrichInlineRolls.bind(this, rollData)); if ( embeds ) fns.push(this._enrichEmbeds.bind(this)); for ( const config of CONFIG.TextEditor.enrichers ) { fns.push(this._applyCustomEnrichers.bind(this, config)); } // Perform enrichment let text = this._getTextNodes(html); await this._primeCompendiums(text); let updateTextArray = false; for ( const fn of fns ) { if ( updateTextArray ) text = this._getTextNodes(html); updateTextArray = await fn(text, options); } return html.innerHTML; } /* -------------------------------------------- */ /** * Scan for compendium UUIDs and retrieve Documents in batches so that they are in cache when enrichment proceeds. * @param {Text[]} text The text nodes to scan. * @protected */ static async _primeCompendiums(text) { // Scan for any UUID that looks like a compendium UUID. This should catch content links as well as UUIDs appearing // in embeds. const rgx = /Compendium\.[\w-]+\.[^.]+\.[a-zA-Z\d.]+/g; const packs = new Map(); for ( const t of text ) { for ( const [uuid] of t.textContent.matchAll(rgx) ) { const { collection, documentId } = foundry.utils.parseUuid(uuid); if ( !collection || collection.has(documentId) ) continue; if ( !packs.has(collection) ) packs.set(collection, []); packs.get(collection).push(documentId); } } for ( const [pack, ids] of packs.entries() ) { await pack.getDocuments({ _id__in: ids }); } } /* -------------------------------------------- */ /** * Convert text of the form @UUID[uuid]{name} to anchor elements. * @param {Text[]} text The existing text content * @param {EnrichmentOptions} [options] Options provided to customize text enrichment * @param {Document} [options.relativeTo] A document to resolve relative UUIDs against. * @returns {Promise} Whether any content links were replaced and the text nodes need to be * updated. * @protected */ static async _enrichContentLinks(text, {relativeTo}={}) { const documentTypes = CONST.DOCUMENT_LINK_TYPES.concat(["Compendium", "UUID"]); const rgx = new RegExp(`@(${documentTypes.join("|")})\\[([^#\\]]+)(?:#([^\\]]+))?](?:{([^}]+)})?`, "g"); return this._replaceTextContent(text, rgx, match => this._createContentLink(match, {relativeTo})); } /* -------------------------------------------- */ /** * Handle embedding Document content with @Embed[uuid]{label} text. * @param {Text[]} text The existing text content. * @param {EnrichmentOptions} [options] Options provided to customize text enrichment. * @returns {Promise} Whether any embeds were replaced and the text nodes need to be updated. * @protected */ static async _enrichEmbeds(text, options={}) { const rgx = /@Embed\[(?[^\]]+)](?:{(?