/** * @typedef {object} ProseMirrorHistory * @property {string} userId The ID of the user who submitted the step. * @property {Step} step The step that was submitted. */ /** * A class responsible for managing state and collaborative editing of a single ProseMirror instance. */ class ProseMirrorEditor { /** * @param {string} uuid A string that uniquely identifies this ProseMirror instance. * @param {EditorView} view The ProseMirror EditorView. * @param {Plugin} isDirtyPlugin The plugin to track the dirty state of the editor. * @param {boolean} collaborate Whether this is a collaborative editor. * @param {object} [options] Additional options. * @param {ClientDocument} [options.document] A document associated with this editor. */ constructor(uuid, view, isDirtyPlugin, collaborate, options={}) { /** * A string that uniquely identifies this ProseMirror instance. * @type {string} */ Object.defineProperty(this, "uuid", {value: uuid, writable: false}); /** * The ProseMirror EditorView. * @type {EditorView} */ Object.defineProperty(this, "view", {value: view, writable: false}); /** * Whether this is a collaborative editor. * @type {boolean} */ Object.defineProperty(this, "collaborate", {value: collaborate, writable: false}); this.options = options; this.#isDirtyPlugin = isDirtyPlugin; } /* -------------------------------------------- */ /** * A list of active editor instances by their UUIDs. * @type {Map} */ static #editors = new Map(); /* -------------------------------------------- */ /** * The plugin to track the dirty state of the editor. * @type {Plugin} */ #isDirtyPlugin; /* -------------------------------------------- */ /** * Retire this editor instance and clean up. */ destroy() { ProseMirrorEditor.#editors.delete(this.uuid); this.view.destroy(); if ( this.collaborate ) game.socket.emit("pm.endSession", this.uuid); } /* -------------------------------------------- */ /** * Have the contents of the editor been edited by the user? * @returns {boolean} */ isDirty() { return this.#isDirtyPlugin.getState(this.view.state); } /* -------------------------------------------- */ /** * Handle new editing steps supplied by the server. * @param {string} offset The offset into the history, representing the point at which it was last * truncated. * @param {ProseMirrorHistory[]} history The entire edit history. * @protected */ _onNewSteps(offset, history) { this._disableSourceCodeEditing(); this.options.document?.sheet?.onNewSteps?.(); const version = ProseMirror.collab.getVersion(this.view.state); const newSteps = history.slice(version - offset); // Flatten out the data into a format that ProseMirror.collab.receiveTransaction can understand. const [steps, ids] = newSteps.reduce(([steps, ids], entry) => { steps.push(ProseMirror.Step.fromJSON(ProseMirror.defaultSchema, entry.step)); ids.push(entry.userId); return [steps, ids]; }, [[], []]); const tr = ProseMirror.collab.receiveTransaction(this.view.state, steps, ids); this.view.dispatch(tr); } /* -------------------------------------------- */ /** * Disable source code editing if the user was editing it when new steps arrived. * @protected */ _disableSourceCodeEditing() { const textarea = this.view.dom.closest(".editor")?.querySelector(":scope > textarea"); if ( !textarea ) return; textarea.disabled = true; ui.notifications.warn("EDITOR.EditingHTMLWarning", {localize: true, permanent: true}); } /* -------------------------------------------- */ /** * The state of this ProseMirror editor has fallen too far behind the central authority's and must be re-synced. * @protected */ _resync() { // Copy the editor's current state to the clipboard to avoid data loss. const existing = this.view.dom; existing.contentEditable = false; const selection = document.getSelection(); selection.removeAllRanges(); const range = document.createRange(); range.selectNode(existing); selection.addRange(range); // We cannot use navigator.clipboard.write here as it is disabled or not fully implemented in some browsers. // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard document.execCommand("copy"); ui.notifications.warn("EDITOR.Resync", {localize: true, permanent: true}); this.destroy(); this.options.document?.sheet?.render(true, {resync: true}); } /* -------------------------------------------- */ /** * Handle users joining or leaving collaborative editing. * @param {string[]} users The IDs of users currently editing (including ourselves). * @protected */ _updateUserDisplay(users) { const editor = this.view.dom.closest(".editor"); editor.classList.toggle("collaborating", users.length > 1); const pips = users.map(id => { const user = game.users.get(id); if ( !user ) return ""; return ` ${user.name[0]} `; }).join(""); const collaborating = editor.querySelector("menu .concurrent-users"); collaborating.dataset.tooltip = users.map(id => game.users.get(id)?.name).join(", "); collaborating.innerHTML = ` ${pips} `; } /* -------------------------------------------- */ /** * Handle an autosave update for an already-open editor. * @param {string} html The updated editor contents. * @protected */ _handleAutosave(html) { this.options.document?.sheet?.onAutosave?.(html); } /* -------------------------------------------- */ /** * Create a ProseMirror editor instance. * @param {HTMLElement} target An HTML element to mount the editor to. * @param {string} [content=""] Content to populate the editor with. * @param {object} [options] Additional options to configure the ProseMirror instance. * @param {string} [options.uuid] A string to uniquely identify this ProseMirror instance. Ignored * for a collaborative editor. * @param {ClientDocument} [options.document] A Document whose content is being edited. Required for * collaborative editing and relative UUID generation. * @param {string} [options.fieldName] The field within the Document that is being edited. Required for * collaborative editing. * @param {Record} [options.plugins] Plugins to include with the editor. * @param {boolean} [options.relativeLinks=false] Whether to generate relative UUID links to Documents that are * dropped on the editor. * @param {boolean} [options.collaborate=false] Whether to enable collaborative editing for this editor. * @returns {Promise} */ static async create(target, content="", {uuid, document, fieldName, plugins={}, collaborate=false, relativeLinks=false}={}) { if ( collaborate && (!document || !fieldName) ) { throw new Error("A document and fieldName must be provided when creating an editor with collaborative editing."); } uuid = collaborate ? `${document.uuid}#${fieldName}` : uuid ?? `ProseMirror.${foundry.utils.randomID()}`; const state = ProseMirror.EditorState.create({doc: ProseMirror.dom.parseString(content)}); plugins = Object.assign({}, ProseMirror.defaultPlugins, plugins); plugins.contentLinks = ProseMirror.ProseMirrorContentLinkPlugin.build(ProseMirror.defaultSchema, { document, relativeLinks }); if ( document ) { plugins.images = ProseMirror.ProseMirrorImagePlugin.build(ProseMirror.defaultSchema, {document}); } const options = {state}; Hooks.callAll("createProseMirrorEditor", uuid, plugins, options); const view = collaborate ? await this._createCollaborativeEditorView(uuid, target, options.state, Object.values(plugins)) : this._createLocalEditorView(target, options.state, Object.values(plugins)); const editor = new ProseMirrorEditor(uuid, view, plugins.isDirty, collaborate, {document}); ProseMirrorEditor.#editors.set(uuid, editor); return editor; } /* -------------------------------------------- */ /** * Create an EditorView with collaborative editing enabled. * @param {string} uuid The ProseMirror instance UUID. * @param {HTMLElement} target An HTML element to mount the editor view to. * @param {EditorState} state The ProseMirror editor state. * @param {Plugin[]} plugins The editor plugins to load. * @returns {Promise} * @protected */ static async _createCollaborativeEditorView(uuid, target, state, plugins) { const authority = await new Promise((resolve, reject) => { game.socket.emit("pm.editDocument", uuid, state, authority => { if ( authority.state ) resolve(authority); else reject(); }); }); return new ProseMirror.EditorView({mount: target}, { state: ProseMirror.EditorState.fromJSON({ schema: ProseMirror.defaultSchema, plugins: [ ...plugins, ProseMirror.collab.collab({version: authority.version, clientID: game.userId}) ] }, authority.state), dispatchTransaction(tr) { const newState = this.state.apply(tr); this.updateState(newState); const sendable = ProseMirror.collab.sendableSteps(newState); if ( sendable ) game.socket.emit("pm.receiveSteps", uuid, sendable.version, sendable.steps); } }); } /* -------------------------------------------- */ /** * Create a plain EditorView without collaborative editing. * @param {HTMLElement} target An HTML element to mount the editor view to. * @param {EditorState} state The ProseMirror editor state. * @param {Plugin[]} plugins The editor plugins to load. * @returns {EditorView} * @protected */ static _createLocalEditorView(target, state, plugins) { return new ProseMirror.EditorView({mount: target}, { state: ProseMirror.EditorState.create({doc: state.doc, plugins}) }); } /* -------------------------------------------- */ /* Socket Handlers */ /* -------------------------------------------- */ /** * Handle new editing steps supplied by the server. * @param {string} uuid The UUID that uniquely identifies the ProseMirror instance. * @param {number} offset The offset into the history, representing the point at which it was last * truncated. * @param {ProseMirrorHistory[]} history The entire edit history. * @protected */ static _onNewSteps(uuid, offset, history) { const editor = ProseMirrorEditor.#editors.get(uuid); if ( editor ) editor._onNewSteps(offset, history); else { console.warn(`New steps were received for UUID '${uuid}' which is not a ProseMirror editor instance.`); } } /* -------------------------------------------- */ /** * Our client is too far behind the central authority's state and must be re-synced. * @param {string} uuid The UUID that uniquely identifies the ProseMirror instance. * @protected */ static _onResync(uuid) { const editor = ProseMirrorEditor.#editors.get(uuid); if ( editor ) editor._resync(); else { console.warn(`A resync request was received for UUID '${uuid}' which is not a ProseMirror editor instance.`); } } /* -------------------------------------------- */ /** * Handle users joining or leaving collaborative editing. * @param {string} uuid The UUID that uniquely identifies the ProseMirror instance. * @param {string[]} users The IDs of the users editing (including ourselves). * @protected */ static _onUsersEditing(uuid, users) { const editor = ProseMirrorEditor.#editors.get(uuid); if ( editor ) editor._updateUserDisplay(users); else { console.warn(`A user update was received for UUID '${uuid}' which is not a ProseMirror editor instance.`); } } /* -------------------------------------------- */ /** * Update client state when the editor contents are autosaved server-side. * @param {string} uuid The UUID that uniquely identifies the ProseMirror instance. * @param {string} html The updated editor contents. * @protected */ static async _onAutosave(uuid, html) { const editor = ProseMirrorEditor.#editors.get(uuid); const [docUUID, field] = uuid.split("#"); const doc = await fromUuid(docUUID); if ( doc ) doc.updateSource({[field]: html}); if ( editor ) editor._handleAutosave(html); else doc.render(false); } /* -------------------------------------------- */ /** * Listen for ProseMirror collaboration events. * @param {Socket} socket The open websocket. * @internal */ static _activateSocketListeners(socket) { socket.on("pm.newSteps", this._onNewSteps.bind(this)); socket.on("pm.resync", this._onResync.bind(this)); socket.on("pm.usersEditing", this._onUsersEditing.bind(this)); socket.on("pm.autosave", this._onAutosave.bind(this)); } }