discourse-shared-edits/assets/javascripts/discourse/lib/shared-edits-prosemirror-extension.js
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
Major overhaul of the shared edits plugin to improve reliability, robustness,
and developer experience:

**Backend**
- Extract Revise service for cleaner controller orchestration
- Add StateValidator for base64/Yjs safety, corruption detection, and recovery
- Centralize protocol constants (Ruby + JS) to avoid hardcoded strings
- Add state hash sync verification and vector validation endpoints
- Harden security (guardian checks), error handling, and resource cleanup
- Resize shared edit columns migration; add state_hash column

**Frontend**
- Decompose shared-edit-manager into focused modules: yjs-document,
  markdown-sync, rich-mode-sync, network-manager, encoding-utils
- Add cursor overlay and caret coordinate tracking for selection sharing
- Add ProseMirror extension for rich-mode collaborative editing
- Cache-busted Yjs bundle loading via hashed filenames
- Fix scroll drift during sync

**Testing & Tooling**
- Extensive new specs: state_validator, revision_controller, model, revise service
- New Ember acceptance tests: cursor, lifecycle, sync flows
- Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery
- Add support/lint wrapper for full CI lint suite
- Update dependencies and rebuild Yjs/y-prosemirror bundles
2026-02-13 11:34:52 +11:00

233 lines
6.3 KiB
JavaScript

import {
capturePM,
clearPM,
clearRichModeSerializers,
ensureYjsProsemirrorLoaded,
setCapturedMarkdown,
setConvertToMarkdown,
setProsemirrorViewGetter,
} from "../services/shared-edit-manager";
import { debugError } from "./shared-edits/debug";
let sharedEditYjsState = null;
let stateIdCounter = 0;
function createCursorBuilder(editorContainer) {
return (user) => {
const cursor = document.createElement("span");
cursor.classList.add("ProseMirror-yjs-cursor");
cursor.style.borderColor = user.color;
const label = document.createElement("div");
label.textContent = user.name;
label.style.backgroundColor = user.color;
cursor.appendChild(label);
requestAnimationFrame(() => {
if (!cursor.isConnected) {
return;
}
const cursorRect = cursor.getBoundingClientRect();
const containerRect = editorContainer?.getBoundingClientRect();
if (containerRect && cursorRect.top - containerRect.top < 20) {
label.classList.add("ProseMirror-yjs-cursor__label--below");
}
});
return cursor;
};
}
export function setSharedEditYjsState(state) {
stateIdCounter += 1;
state._stateId = stateIdCounter;
state.configured = false;
sharedEditYjsState = state;
}
export function clearSharedEditYjsState() {
sharedEditYjsState = null;
clearPM();
clearRichModeSerializers();
}
// Test support: reset module state between tests
// Note: We intentionally do NOT reset stateIdCounter. It must be ever-increasing
// to ensure isStillValid() checks work correctly when async operations from a
// previous test are still in flight when the next test starts.
export function resetProsemirrorExtensionState() {
sharedEditYjsState = null;
}
export function getSharedEditYjsState() {
return sharedEditYjsState;
}
const sharedEditsProsemirrorExtension = {
// IMPORTANT: Plugin order matters - ySyncPlugin MUST be first
plugins(params) {
capturePM(params);
const convertToMarkdown = params.utils?.convertToMarkdown;
if (convertToMarkdown) {
setConvertToMarkdown(convertToMarkdown);
}
if (!sharedEditYjsState) {
return [];
}
const { Plugin } = params.pmState;
const { xmlFragment, awareness, seedXmlFromView, _stateId, onError } =
sharedEditYjsState;
const capturedStateId = _stateId;
const currentState = sharedEditYjsState;
// Captures markdown on view destroy before commit() can serialize it
const capturePlugin = new Plugin({
view(view) {
return {
destroy() {
if (convertToMarkdown && view.state?.doc) {
try {
const markdown = convertToMarkdown(view.state.doc);
setCapturedMarkdown(markdown);
} catch {}
}
},
};
},
});
const loaderPlugin = new Plugin({
view(view) {
let destroyed = false;
let configurationStarted = false;
const isStillValid = () => {
if (destroyed) {
return false;
}
const activeState = getSharedEditYjsState();
if (!activeState || activeState._stateId !== capturedStateId) {
return false;
}
if (currentState.configured) {
return false;
}
// Check both parentNode and isConnected for robust detection of
// detached views (e.g., rapid close after open)
if (
view.isDestroyed ||
!view.dom?.parentNode ||
!view.dom.isConnected
) {
return false;
}
return true;
};
const reportError = (error) => {
if (!isStillValid()) {
return;
}
onError?.(error);
};
if (!configurationStarted) {
configurationStarted = true;
ensureYjsProsemirrorLoaded()
.then(() => {
if (!isStillValid()) {
return;
}
const SharedEditsYjs = window.SharedEditsYjs;
if (!SharedEditsYjs?.ySyncPlugin) {
reportError(
new Error("y-prosemirror plugins not available after load")
);
debugError("y-prosemirror plugins not available after load");
return;
}
if (!isStillValid()) {
return;
}
if (
seedXmlFromView &&
xmlFragment.length === 0 &&
SharedEditsYjs.prosemirrorToYXmlFragment
) {
SharedEditsYjs.prosemirrorToYXmlFragment(
view.state.doc,
xmlFragment
);
}
let syncPlugin, cursorPlugin, undoPlugin;
try {
syncPlugin = SharedEditsYjs.ySyncPlugin(xmlFragment);
const cursorBuilder = createCursorBuilder(view.dom);
cursorPlugin = SharedEditsYjs.yCursorPlugin(awareness, {
cursorBuilder,
});
undoPlugin = SharedEditsYjs.yUndoPlugin();
} catch (e) {
debugError("Error creating y-prosemirror plugins:", e);
reportError(e);
return;
}
const remainingPlugins = view.state.plugins.filter(
(plugin) => plugin !== loaderPlugin
);
currentState.configured = true;
setProsemirrorViewGetter(() => view);
try {
view.updateState(
view.state.reconfigure({
plugins: [
syncPlugin,
cursorPlugin,
undoPlugin,
...remainingPlugins,
],
})
);
} catch (e) {
debugError("Error updating view state:", e);
reportError(e);
}
})
.catch((error) => {
reportError(error);
debugError("Failed to load y-prosemirror plugins:", error);
});
}
return {
destroy() {
destroyed = true;
},
};
},
});
return [capturePlugin, loaderPlugin];
},
};
export default sharedEditsProsemirrorExtension;