Files
Foundry-VTT-Docker/resources/app/client/ui/prosemirror.js

360 lines
14 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
/**
* @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<string, ProseMirrorEditor>}
*/
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 `
<span class="scene-player" style="background: ${user.color}; border: 1px solid ${user.border.css};">
${user.name[0]}
</span>
`;
}).join("");
const collaborating = editor.querySelector("menu .concurrent-users");
collaborating.dataset.tooltip = users.map(id => game.users.get(id)?.name).join(", ");
collaborating.innerHTML = `
<i class="fa-solid fa-user-group"></i>
${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<string, Plugin>} [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<ProseMirrorEditor>}
*/
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<EditorView>}
* @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));
}
}