360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
/**
|
|
* @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));
|
|
}
|
|
}
|