Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 0 additions & 45 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -964,51 +964,6 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
presentationEditor: editor.value instanceof PresentationEditor ? editor.value : null,
});

// Upgrade visual-readiness signal: during upgradeToCollaboration, SuperDoc
// threads this callback so it knows when the rebuilt runtime has actually
// painted AND collaboration is ready, not just when editors are created.
// For collaborative remounts the provider is already synced so the
// collaboration extension will emit collaborationReady after a 250ms delay.
// We must wait for BOTH that event AND the first layout paint before
// signalling that the upgrade transition can reveal the new runtime.
const onUpgradeVisualReady = props.options?.onUpgradeVisualReady;
if (typeof onUpgradeVisualReady === 'function') {
const hasCollabProvider = Boolean(props.options?.collaborationProvider);
const isPresentationEditor = editor.value instanceof PresentationEditor;

let collabReady = !hasCollabProvider; // no provider → already satisfied
let layoutReady = !isPresentationEditor; // no layout engine → already satisfied

const tryFire = () => {
if (collabReady && layoutReady) {
nextTick(() => onUpgradeVisualReady());
}
};

if (!collabReady) {
editor.value.once('collaborationReady', () => {
collabReady = true;
tryFire();
});
}

if (!layoutReady) {
const pe = editor.value;
if (pe.getPages().length > 0) {
layoutReady = true;
} else {
const onFirstLayout = () => {
pe.off('layoutUpdated', onFirstLayout);
layoutReady = true;
tryFire();
};
pe.on('layoutUpdated', onFirstLayout);
}
}

tryFire();
}

// Attach layout-engine specific image selection listeners
if (editor.value instanceof PresentationEditor) {
const presentationEditor = editor.value;
Expand Down
84 changes: 83 additions & 1 deletion packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { AnnotatorHelpers } from '@helpers/annotator.js';
import { prepareCommentsForExport, prepareCommentsForImport } from '@extensions/comment/comments-helpers.js';
import DocxZipper from '@core/DocxZipper.js';
import { generateCollaborationData } from '@extensions/collaboration/collaboration.js';
import { generateCollaborationData, cleanupCollaborationSideEffects } from '@extensions/collaboration/collaboration.js';
import { seedPartsFromEditor } from '@extensions/collaboration/part-sync/seed-parts.js';
import { onCollaborationProviderSynced } from './helpers/collaboration-provider-sync.js';
import { useHighContrastMode } from '../composables/use-high-contrast-mode.js';
Expand Down Expand Up @@ -1855,6 +1855,88 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.view?.updateState(this._state);
}

/**
* Late-attach collaboration to a running editor instance.
*
* Updates editor options so the Collaboration, CollaborationCursor, and
* History extensions produce their collaborative plugins on the next
* `extensionService.plugins` access, then reconfigures the PM state in place.
*
* Prerequisites:
* - The ydoc must already be seeded with this editor's current state
* - The provider must already be synced
* - Editor must be mounted (not headless, not destroyed)
*
* @param options.ydoc The Y.Doc to bind
* @param options.collaborationProvider The synced collaboration provider
*/
attachCollaboration({
ydoc,
collaborationProvider,
}: {
ydoc: YDoc;
collaborationProvider: NonNullable<EditorOptions['collaborationProvider']>;
}): void {
if (this.isDestroyed) {
throw new Error('[super-editor] Cannot attach collaboration to a destroyed editor');
}
if (this.options.ydoc) {
throw new Error('[super-editor] Editor already has collaboration attached');
}
if (this.options.isHeadless) {
throw new Error('[super-editor] attachCollaboration is not supported in headless mode');
}

// Snapshot mutable state so we can restore on failure.
const prevProvider = this.options.collaborationProvider;
const prevShouldLoadComments = this.options.shouldLoadComments;
const prevCollaborationIsReady = this.options.collaborationIsReady;
const prevState = this._state;

const rollback = () => {
cleanupCollaborationSideEffects(this);
this.options.ydoc = undefined;
this.options.collaborationProvider = prevProvider;
this.options.shouldLoadComments = prevShouldLoadComments;
this.options.collaborationIsReady = prevCollaborationIsReady;
this._state = prevState;
this.view?.updateState(prevState);
};

// 1. Update options so extensions see ydoc/provider on next plugin generation.
this.options.ydoc = ydoc;
this.options.collaborationProvider = collaborationProvider;

// 2. Suppress DOCX comment re-import on collaborationReady.
// In local mode shouldLoadComments was set to true (see setOptions()).
// Without this, #onCollaborationReady → #initComments() would re-emit
// commentsLoaded from DOCX data, duplicating the Yjs comment hydration
// that initCollaborationComments() performs at the SuperDoc layer.
this.options.shouldLoadComments = false;

// 3. Regenerate all plugins and reconfigure PM state.
// Side effects (Y.js observers, part-sync, initSyncListener) run during
// the extensionService.plugins getter. On failure, rollback cleans them up.
let plugins: Plugin[];
try {
plugins = [...this.extensionService.plugins];
} catch (err) {
rollback();
throw err;
}

// 4. Reconfigure state with the new plugin set. ProseMirror diffs old vs new.
// Since the ydoc was seeded from this editor's state, doc content is identical
// → no content DOM mutations. Selection is preserved by reconfigure().
try {
this._state = this.state.reconfigure({ plugins });
this.view?.updateState(this._state);
} catch (err) {
rollback();
throw err;
}
}

/**
* Creates extension service.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,48 @@ export class PresentationEditor extends EventEmitter {
return this.#editor;
}

/**
* Late-attach collaboration to the presentation editor.
*
* Updates the provider reference on this instance and RemoteCursorManager,
* then delegates to the backing Editor. The existing `collaborationReady`
* listener (wired in #setupEditorListeners) triggers cursor setup
* automatically when the backing editor emits the event.
*
* @param options.ydoc The Y.Doc already seeded with this editor's state
* @param options.collaborationProvider The synced collaboration provider
*/
attachCollaboration({
ydoc,
collaborationProvider,
}: {
ydoc: Y.Doc;
collaborationProvider: NonNullable<PresentationEditorOptions['collaborationProvider']>;
}): void {
const prevProvider = this.#options.collaborationProvider;

// 1. Update PresentationEditor options so the collaborationReady handler
// check passes (it reads this.#options.collaborationProvider?.awareness).
this.#options.collaborationProvider = collaborationProvider;

// 2. Update RemoteCursorManager's provider reference so setup() reads
// the correct provider when collaborationReady fires.
this.#remoteCursorManager?.setCollaborationProvider(collaborationProvider);

// 3. Delegate to the backing Editor — triggers plugin reconfigure + Y.js observers.
// The collaborationReady event fires asynchronously (setTimeout in initSyncListener).
// The existing listener at handleCollaborationReady calls
// #setupCollaborationCursors() → remoteCursorManager.setup(). No new wiring needed.
try {
this.#editor.attachCollaboration({ ydoc, collaborationProvider });
} catch (err) {
// Editor attach failed and rolled back its own state. Restore ours too.
this.#options.collaborationProvider = prevProvider;
this.#remoteCursorManager?.setCollaborationProvider(prevProvider ?? null);
throw err;
}
}

/**
* Expose the visible host element for renderer-agnostic consumers.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ export class RemoteCursorManager {
this.#onCursorsUpdate = callback;
}

/**
* Update the collaboration provider reference. Called during late-attach
* upgrade so `setup()` reads the correct provider when `collaborationReady` fires.
*/
setCollaborationProvider(provider: CollaborationProviderLike): void {
this.#options.collaborationProvider = provider;
}

/**
* Setup awareness event subscriptions for remote cursor tracking.
* Includes scroll listener for virtualization updates.
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export function generateCollaborationData(...args: any[]): any;
export function cleanupCollaborationSideEffects(editor: any): void;
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,20 @@ export const Collaboration = Extension.create({

addPmPlugins() {
if (!this.editor.options.ydoc) return [];

// Guard against double-initialization. If extensionService.plugins is
// re-accessed after collaboration was already bootstrapped for this editor,
// return the existing sync plugin without re-creating observers or listeners.
if (collaborationCleanupByEditor.has(this.editor)) {
const fragment = this.options.fragment;
if (fragment) {
return [ySyncPlugin(fragment, { onFirstRender: () => {} })];
}
}

this.options.ydoc = this.editor.options.ydoc;

initSyncListener(this.options.ydoc, this.editor, this);
const syncListenerCleanup = initSyncListener(this.options.ydoc, this.editor, this);

const [syncPlugin, fragment] = createSyncPlugin(this.options.ydoc, this.editor);
this.options.fragment = fragment;
Expand All @@ -171,6 +182,7 @@ export const Collaboration = Extension.create({
// Store cleanup references in a non-reactive WeakMap (NOT this.options)
// to avoid Vue's deep traverse hitting circular references in Y.js Maps.
const cleanupState = {
syncListenerCleanup,
mediaMap,
mediaMapObserver,
metaMap: null,
Expand Down Expand Up @@ -225,22 +237,7 @@ export const Collaboration = Extension.create({
},

onDestroy() {
const cleanup = collaborationCleanupByEditor.get(this.editor);
if (!cleanup) return;

// Clean up Y.js media map observer
cleanup.mediaMap.unobserve(cleanup.mediaMapObserver);
cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver);

// Clean up part-sync publisher/consumer (or pending sync listener)
cleanup.partSyncHandle?.destroy();
cleanup.partSyncPendingCleanup?.();
cleanup.bodySectPrPendingCleanup?.();
if (cleanup.bodySectPrTransactionHandler && typeof this.editor.off === 'function') {
this.editor.off('transaction', cleanup.bodySectPrTransactionHandler);
}

collaborationCleanupByEditor.delete(this.editor);
cleanupCollaborationSideEffects(this.editor);
},

addCommands() {
Expand All @@ -257,6 +254,32 @@ export const Collaboration = Extension.create({
},
});

/**
* Tear down collaboration side effects registered during `addPmPlugins()`.
*
* Called by `Collaboration.onDestroy()` during normal teardown and by
* `Editor.attachCollaboration()` rollback if reconfigure fails after
* plugin generation has already created Y.js observers and listeners.
*
* @param {import('../../core/Editor').Editor} editor
*/
export const cleanupCollaborationSideEffects = (editor) => {
const cleanup = collaborationCleanupByEditor.get(editor);
if (!cleanup) return;

cleanup.syncListenerCleanup?.();
cleanup.mediaMap?.unobserve?.(cleanup.mediaMapObserver);
cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver);
cleanup.partSyncHandle?.destroy();
cleanup.partSyncPendingCleanup?.();
cleanup.bodySectPrPendingCleanup?.();
Comment thread
harbournick marked this conversation as resolved.
if (cleanup.bodySectPrTransactionHandler && typeof editor.off === 'function') {
editor.off('transaction', cleanup.bodySectPrTransactionHandler);
}

collaborationCleanupByEditor.delete(editor);
};

export const createSyncPlugin = (ydoc, editor) => {
const fragment = ydoc.getXmlFragment('supereditor');
const onFirstRender = () => {
Expand Down Expand Up @@ -297,24 +320,43 @@ export const initializeMetaMap = (ydoc, editor) => {
});
};

/**
* Schedule a `collaborationReady` emission once the provider is synced.
*
* Returns a cleanup function that cancels any pending timer or provider
* listener so a rollback in `attachCollaboration()` can prevent stale
* emissions from firing against a rolled-back editor state.
*
* @returns {() => void} cleanup
*/
const initSyncListener = (ydoc, editor, extension) => {
const provider = editor.options.collaborationProvider;
if (!provider) return;
if (!provider) return () => {};

let cancelled = false;

const emit = (synced) => {
if (cancelled) return;
if (synced === false) return;
extension.options.isReady = true;
editor.emit('collaborationReady', { editor, ydoc });
};

if (isCollaborationProviderSynced(provider)) {
setTimeout(() => {
const timerId = setTimeout(() => {
emit();
}, 250);
return;
return () => {
cancelled = true;
clearTimeout(timerId);
};
}

onCollaborationProviderSynced(provider, emit);
const removeProviderListeners = onCollaborationProviderSynced(provider, emit);
return () => {
cancelled = true;
removeProviderListeners();
};
};

export const generateCollaborationData = async (editor) => {
Expand Down
Loading
Loading