diff --git a/apps/cli/src/lib/__tests__/bootstrap.test.ts b/apps/cli/src/lib/__tests__/bootstrap.test.ts index b91adec1ba..996c5fd8cd 100644 --- a/apps/cli/src/lib/__tests__/bootstrap.test.ts +++ b/apps/cli/src/lib/__tests__/bootstrap.test.ts @@ -202,12 +202,12 @@ describe('claimBootstrap', () => { const ydoc = new YDoc(); const metaMap = ydoc.getMap('meta'); + // claimBootstrap (with jitter=0) executes synchronously until + // `await sleep(settlingMs)`, so the marker is already written + // when control returns here. Deleting it simulates another + // process removing the key during the settling window. const promise = claimBootstrap(ydoc, 20, 0); - - // Another process deletes the bootstrap key during settling - setTimeout(() => { - metaMap.delete('bootstrap'); - }, 2); + metaMap.delete('bootstrap'); const result = await promise; expect(result.granted).toBe(false); diff --git a/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts b/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts new file mode 100644 index 0000000000..a7d95f5b28 --- /dev/null +++ b/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts @@ -0,0 +1,514 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as Y from 'yjs'; + +import { buildHeadlessCommentBridge, __test__ } from '../headless-comment-bridge'; + +const { normalizeTrackedChangeToComment, addYComment, updateYComment, deleteYComment, getCommentIndex } = __test__; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createYEnv() { + const ydoc = new Y.Doc(); + const yArray = ydoc.getArray>('comments'); + return { ydoc, yArray }; +} + +function yArrayToJSON(yArray: Y.Array>): Record[] { + return yArray.toJSON() as Record[]; +} + +// --------------------------------------------------------------------------- +// Yjs write helpers +// --------------------------------------------------------------------------- + +describe('Yjs write helpers', () => { + it('addYComment pushes a YMap to the array', () => { + const { ydoc, yArray } = createYEnv(); + const comment = { commentId: 'c1', text: 'hello' }; + addYComment(yArray, ydoc, comment, { name: 'Bot' }); + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].commentId).toBe('c1'); + }); + + it('updateYComment replaces existing comment by id', () => { + const { ydoc, yArray } = createYEnv(); + addYComment(yArray, ydoc, { commentId: 'c1', text: 'v1' }); + updateYComment(yArray, ydoc, { commentId: 'c1', text: 'v2' }); + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('v2'); + }); + + it('updateYComment is a no-op for unknown id', () => { + const { ydoc, yArray } = createYEnv(); + addYComment(yArray, ydoc, { commentId: 'c1', text: 'v1' }); + updateYComment(yArray, ydoc, { commentId: 'c999', text: 'v2' }); + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('v1'); + }); + + it('deleteYComment removes comment from array', () => { + const { ydoc, yArray } = createYEnv(); + addYComment(yArray, ydoc, { commentId: 'c1', text: 'bye' }); + expect(yArrayToJSON(yArray)).toHaveLength(1); + deleteYComment(yArray, ydoc, { commentId: 'c1' }); + expect(yArrayToJSON(yArray)).toHaveLength(0); + }); + + it('deleteYComment is a no-op for unknown id', () => { + const { ydoc, yArray } = createYEnv(); + addYComment(yArray, ydoc, { commentId: 'c1' }); + deleteYComment(yArray, ydoc, { commentId: 'c999' }); + expect(yArrayToJSON(yArray)).toHaveLength(1); + }); + + it('getCommentIndex returns correct index', () => { + const { ydoc, yArray } = createYEnv(); + addYComment(yArray, ydoc, { commentId: 'a' }); + addYComment(yArray, ydoc, { commentId: 'b' }); + expect(getCommentIndex(yArray, 'a')).toBe(0); + expect(getCommentIndex(yArray, 'b')).toBe(1); + expect(getCommentIndex(yArray, 'z')).toBe(-1); + }); +}); + +// --------------------------------------------------------------------------- +// Tracked-change normalization +// --------------------------------------------------------------------------- + +describe('normalizeTrackedChangeToComment', () => { + it('maps tracked-change fields to comment shape', () => { + const result = normalizeTrackedChangeToComment({ + changeId: 'tc-1', + author: 'Alice', + authorEmail: 'alice@test.com', + authorImage: 'img.png', + date: '2025-01-01', + trackedChangeText: 'added text', + trackedChangeType: 'trackInsert', + deletedText: null, + documentId: 'doc-1', + importedAuthor: { name: 'Bob' }, + }); + + expect(result.commentId).toBe('tc-1'); + expect(result.trackedChange).toBe(true); + expect(result.creatorName).toBe('Alice'); + expect(result.creatorEmail).toBe('alice@test.com'); + expect(result.creatorImage).toBe('img.png'); + expect(result.trackedChangeText).toBe('added text'); + expect(result.trackedChangeType).toBe('trackInsert'); + expect(result.documentId).toBe('doc-1'); + expect(result.isInternal).toBe(false); + expect(result.importedAuthor).toEqual({ name: 'Bob' }); + }); + + it('defaults missing fields to null', () => { + const result = normalizeTrackedChangeToComment({ changeId: 'tc-2' }); + expect(result.creatorName).toBeNull(); + expect(result.creatorEmail).toBeNull(); + expect(result.trackedChangeText).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Event routing via buildHeadlessCommentBridge +// --------------------------------------------------------------------------- + +describe('buildHeadlessCommentBridge', () => { + let ydoc: Y.Doc; + let yArray: Y.Array>; + let bridge: ReturnType; + + beforeEach(() => { + ydoc = new Y.Doc(); + yArray = ydoc.getArray('comments'); + bridge = buildHeadlessCommentBridge(ydoc, { name: 'Agent', email: 'agent@test.com' }, 'doc-1'); + }); + + it('returns correct editorOptions shape', () => { + expect(bridge.editorOptions.isCommentsEnabled).toBe(true); + expect(bridge.editorOptions.documentMode).toBe('editing'); + expect(typeof bridge.editorOptions.onCommentsUpdate).toBe('function'); + expect(typeof bridge.editorOptions.onCommentsLoaded).toBe('function'); + }); + + // --- Tracked change events --- + + it('adds tracked-change comment to yArray on add event', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'add', + changeId: 'tc-1', + author: 'Agent', + authorEmail: 'agent@test.com', + trackedChangeText: 'inserted', + trackedChangeType: 'trackInsert', + documentId: 'doc-1', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].commentId).toBe('tc-1'); + expect(arr[0].trackedChange).toBe(true); + expect(arr[0].trackedChangeText).toBe('inserted'); + }); + + it('updates tracked-change in yArray on update event', () => { + // First add + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'add', + changeId: 'tc-1', + trackedChangeText: 'v1', + }); + + // Then update + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'update', + changeId: 'tc-1', + trackedChangeText: 'v2', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].trackedChangeText).toBe('v2'); + }); + + it('falls back to Yjs for tracked-change updates when registry misses the id', () => { + // Simulate a tracked change written by another collaborator after bridge init. + yArray.push([ + new Y.Map( + Object.entries({ + commentId: 'tc-late', + trackedChange: true, + trackedChangeText: 'v1', + trackedChangeType: 'trackInsert', + creatorName: 'Remote Author', + }), + ), + ]); + + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'update', + changeId: 'tc-late', + trackedChangeText: 'v2', + trackedChangeType: 'trackInsert', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].trackedChangeText).toBe('v2'); + // Sparse updates should not clobber existing metadata. + expect(arr[0].creatorName).toBe('Remote Author'); + }); + + it('falls back to Yjs for tracked-change resolve when registry misses the id', () => { + // Simulate a tracked change written by another collaborator after bridge init. + yArray.push([ + new Y.Map( + Object.entries({ + commentId: 'tc-late-resolve', + trackedChange: true, + trackedChangeText: 'pending', + trackedChangeType: 'trackInsert', + }), + ), + ]); + + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'resolve', + changeId: 'tc-late-resolve', + resolvedByEmail: 'resolver@test.com', + resolvedByName: 'Resolver', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(typeof arr[0].resolvedTime).toBe('string'); + expect(arr[0].resolvedByEmail).toBe('resolver@test.com'); + expect(arr[0].resolvedByName).toBe('Resolver'); + expect(arr[0].trackedChangeText).toBe('pending'); + }); + + it('deduplicates tracked-change add events', () => { + const params = { + type: 'trackedChange', + event: 'add', + changeId: 'tc-dup', + trackedChangeText: 'first', + }; + + bridge.editorOptions.onCommentsUpdate(params); + bridge.editorOptions.onCommentsUpdate({ ...params, trackedChangeText: 'second' }); + + const arr = yArrayToJSON(yArray); + // Should still be 1 entry (updated, not duplicated) + expect(arr).toHaveLength(1); + expect(arr[0].trackedChangeText).toBe('second'); + }); + + it('resolves tracked-change preserving metadata and writing resolver identity', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'add', + changeId: 'tc-resolve', + author: 'Original Author', + authorEmail: 'author@test.com', + trackedChangeText: 'some text', + trackedChangeType: 'trackInsert', + deletedText: 'old text', + date: '2025-01-01', + documentId: 'doc-1', + }); + + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'resolve', + changeId: 'tc-resolve', + resolvedByEmail: 'resolver@test.com', + resolvedByName: 'Resolver', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + const resolved = arr[0]; + + // Resolution fields written + expect(typeof resolved.resolvedTime).toBe('string'); + expect(resolved.resolvedByEmail).toBe('resolver@test.com'); + expect(resolved.resolvedByName).toBe('Resolver'); + + // Original metadata preserved (not overwritten with null) + expect(resolved.creatorName).toBe('Original Author'); + expect(resolved.creatorEmail).toBe('author@test.com'); + expect(resolved.trackedChangeText).toBe('some text'); + expect(resolved.trackedChangeType).toBe('trackInsert'); + expect(resolved.deletedText).toBe('old text'); + expect(resolved.createdTime).toBe('2025-01-01'); + expect(resolved.documentId).toBe('doc-1'); + }); + + it('resolve defaults resolver identity to bridge user when not in payload', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'add', + changeId: 'tc-resolve-default', + trackedChangeText: 'text', + }); + + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'resolve', + changeId: 'tc-resolve-default', + }); + + const arr = yArrayToJSON(yArray); + expect(arr[0].resolvedByEmail).toBe('agent@test.com'); + expect(arr[0].resolvedByName).toBe('Agent'); + }); + + it('resolve is a no-op for unknown tracked-change id', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'resolve', + changeId: 'tc-unknown', + }); + + expect(yArrayToJSON(yArray)).toHaveLength(0); + }); + + // --- Standard comment events --- + + it('adds standard comment to yArray', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'Hello' }, + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].commentId).toBe('c-1'); + }); + + it('deduplicates add events against late Yjs writes', () => { + // Simulate a standard comment written by another collaborator after bridge init. + yArray.push([new Y.Map(Object.entries({ commentId: 'c-late', text: 'from peer' }))]); + + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-late', text: 'duplicate attempt' }, + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('from peer'); + }); + + it('updates standard comment in yArray', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'v1' }, + }); + bridge.editorOptions.onCommentsUpdate({ + type: 'update', + comment: { commentId: 'c-1', text: 'v2' }, + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('v2'); + }); + + it('deletes standard comment from yArray', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'bye' }, + }); + bridge.editorOptions.onCommentsUpdate({ + type: 'deleted', + comment: { commentId: 'c-1' }, + }); + + expect(yArrayToJSON(yArray)).toHaveLength(0); + }); + + it('handles resolved event as an update', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', resolved: false }, + }); + bridge.editorOptions.onCommentsUpdate({ + type: 'resolved', + comment: { commentId: 'c-1', resolved: true }, + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(1); + expect(arr[0].resolved).toBe(true); + }); + + // --- onCommentsLoaded --- + + it('writes initial comments array to yArray in a single transaction', () => { + const transactSpy = vi.spyOn(ydoc, 'transact'); + + bridge.editorOptions.onCommentsLoaded({ + editor: {}, + comments: [ + { commentId: 'c-1', text: 'a' }, + { commentId: 'c-2', text: 'b' }, + ], + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(2); + expect(arr[0].commentId).toBe('c-1'); + expect(arr[1].commentId).toBe('c-2'); + // All written in a single transact call + expect(transactSpy).toHaveBeenCalledTimes(1); + transactSpy.mockRestore(); + }); + + it('onCommentsLoaded deduplicates against registry', () => { + // Pre-add via event + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'existing' }, + }); + + bridge.editorOptions.onCommentsLoaded({ + editor: {}, + comments: [ + { commentId: 'c-1', text: 'duplicate' }, + { commentId: 'c-2', text: 'new' }, + ], + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(2); + // c-1 should be the original, not overwritten + expect(arr[0].text).toBe('existing'); + expect(arr[1].commentId).toBe('c-2'); + }); + + // --- dispose --- + + it('dispose keeps Yjs as dedup source-of-truth', () => { + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'test' }, + }); + + bridge.dispose(); + + // After dispose, Yjs still contains the existing comment id. + bridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'c-1', text: 'after-dispose' }, + }); + + const arr = yArrayToJSON(yArray); + // Even after dispose, Yjs still holds the canonical comment and prevents duplicates. + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('test'); + }); + + // --- Dedup against existing Yjs contents --- + + it('seeds registry from pre-existing Yjs array contents to prevent duplicates', () => { + // Simulate a room that already has a comment in Yjs (e.g. from another client) + const preYdoc = new Y.Doc(); + const preArray = preYdoc.getArray('comments'); + const existing = new Y.Map(Object.entries({ commentId: 'pre-existing', text: 'from peer' })); + preArray.push([existing]); + + const lateBridge = buildHeadlessCommentBridge(preYdoc, { name: 'Late', email: 'late@test.com' }); + + // Attempting to add the same commentId should be a no-op (already known) + lateBridge.editorOptions.onCommentsUpdate({ + type: 'add', + comment: { commentId: 'pre-existing', text: 'duplicate attempt' }, + }); + + const arr = preArray.toJSON() as Record[]; + expect(arr).toHaveLength(1); + expect(arr[0].text).toBe('from peer'); + + lateBridge.dispose(); + }); + + // --- Full integration flow --- + + it('full flow: commentsLoaded then tracked-change events', () => { + // Simulate initial DOCX load + bridge.editorOptions.onCommentsLoaded({ + editor: {}, + comments: [{ commentId: 'imported-1', text: 'from docx', trackedChange: false }], + }); + + // Simulate tracked-change edit + bridge.editorOptions.onCommentsUpdate({ + type: 'trackedChange', + event: 'add', + changeId: 'tc-edit-1', + author: 'Agent', + trackedChangeText: 'new text', + trackedChangeType: 'trackInsert', + documentId: 'doc-1', + }); + + const arr = yArrayToJSON(yArray); + expect(arr).toHaveLength(2); + expect(arr[0].commentId).toBe('imported-1'); + expect(arr[1].commentId).toBe('tc-edit-1'); + expect(arr[1].trackedChange).toBe(true); + }); +}); diff --git a/apps/cli/src/lib/__tests__/repro-headless-tracked-comments.test.ts b/apps/cli/src/lib/__tests__/repro-headless-tracked-comments.test.ts new file mode 100644 index 0000000000..998b09dc5e --- /dev/null +++ b/apps/cli/src/lib/__tests__/repro-headless-tracked-comments.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { Doc as YDoc } from 'yjs'; +import { openDocument } from '../document'; + +function createIo() { + return { + stdout() {}, + stderr() {}, + async readStdinBytes() { + return new Uint8Array(); + }, + now() { + return Date.now(); + }, + }; +} + +function createProviderStub() { + const noop = () => {}; + return { + synced: true, + awareness: { + on: noop, + off: noop, + getStates: () => new Map(), + setLocalState: noop, + setLocalStateField: noop, + }, + on: noop, + off: noop, + connect: noop, + disconnect: noop, + destroy: noop, + }; +} + +describe('headless tracked changes → yjs comments', () => { + it('writes a tracked-change comment entry when creating a tracked paragraph', async () => { + const ydoc = new YDoc(); + const opened = await openDocument(undefined, createIo(), { + documentId: 'repro-doc', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Agent', email: 'agent@superdoc.dev' }, + }); + + opened.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'hello world' }, { changeMode: 'tracked' }); + + const comments = ydoc.getArray('comments').toJSON() as Array>; + opened.dispose(); + + expect(comments.length).toBeGreaterThan(0); + expect(comments[0].trackedChange).toBe(true); + }); +}); diff --git a/apps/cli/src/lib/bootstrap.ts b/apps/cli/src/lib/bootstrap.ts index 1ed1155266..cd4ec66049 100644 --- a/apps/cli/src/lib/bootstrap.ts +++ b/apps/cli/src/lib/bootstrap.ts @@ -258,8 +258,10 @@ export async function claimBootstrap( settlingMs: number, jitterMs: number = DEFAULT_BOOTSTRAP_JITTER_MS, ): Promise { + const jitterDelayMs = Math.floor(Math.random() * jitterMs); + // Random jitter reduces perfect-collision starts between concurrent clients. - await sleep(Math.floor(Math.random() * jitterMs)); + if (jitterDelayMs > 0) await sleep(jitterDelayMs); const metaMap = ydoc.getMap('meta'); metaMap.set('bootstrap', { @@ -271,7 +273,7 @@ export async function claimBootstrap( const observer = observeCompetitor(ydoc); try { - await sleep(settlingMs); + if (settlingMs > 0) await sleep(settlingMs); const competitor = observer.getCompetitor(); if (competitor) return { granted: false, competitor }; diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 55209ae4ba..f246b96e9f 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -24,6 +24,7 @@ import { } from './bootstrap'; import { CliError } from './errors'; import { pathExists } from './guards'; +import { buildHeadlessCommentBridge } from './headless-comment-bridge'; import type { ContextMetadata } from './context'; import type { CliIO, DocumentSourceMeta, ExecutionMode, UserIdentity } from './types'; import type { SessionPool } from '../host/session-pool'; @@ -46,7 +47,7 @@ interface ContentOverrideOptions { } /** Options passed through to Editor.open() alongside content overrides. */ -type EditorPassThroughOptions = Record; +type EditorPassThroughOptions = Record; interface OpenDocumentOptions { documentId?: string; @@ -144,6 +145,10 @@ export async function openDocument( // HTML content override). Always inject via options.document — never set globals. const domEnv = createCliDomEnvironment(); + // Wire headless comment/tracked-change bridge when collaboration is active. + const hasCollaboration = options.ydoc != null && options.collaborationProvider != null; + const commentBridge = hasCollaboration ? buildHeadlessCommentBridge(options.ydoc, options.user) : null; + let editor: Editor; try { const isTest = process.env.NODE_ENV === 'test'; @@ -159,9 +164,11 @@ export async function openDocument( ...(options.isNewFile != null ? { isNewFile: options.isNewFile } : {}), // Pass through HTML override directly — happy-dom provides DOM support. ...(htmlOverride != null ? { html: htmlOverride } : {}), + ...(commentBridge?.editorOptions ?? {}), ...passThroughEditorOpts, }); } catch (error) { + commentBridge?.dispose(); domEnv.dispose(); const message = error instanceof Error ? error.message : String(error); throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to open document.', { @@ -220,6 +227,7 @@ export async function openDocument( editor: editorWithDoc, meta, dispose() { + commentBridge?.dispose(); editor.destroy(); domEnv.dispose(); }, diff --git a/apps/cli/src/lib/headless-comment-bridge.ts b/apps/cli/src/lib/headless-comment-bridge.ts new file mode 100644 index 0000000000..adab125c2d --- /dev/null +++ b/apps/cli/src/lib/headless-comment-bridge.ts @@ -0,0 +1,297 @@ +/** + * Headless Comment Bridge + * + * Bridges Editor comment/tracked-change events to Yjs collaboration arrays, + * providing the headless equivalent of the browser's SuperDoc.vue + comments-store + * + collaboration/helpers.js orchestration. + * + * Yjs write helpers replicate the trivially small logic from: + * packages/superdoc/src/core/collaboration/collaboration-comments.js + * We cannot import from that Vue package into the CLI app. + */ + +import { Map as YMap } from 'yjs'; +import type { Doc as YDoc, Array as YArray } from 'yjs'; +import type { UserIdentity } from './types'; + +// --------------------------------------------------------------------------- +// Yjs write helpers (mirrors collaboration-comments.js) +// --------------------------------------------------------------------------- + +function getCommentIndex(yArray: YArray>, commentId: string): number { + const arr = yArray.toJSON() as Array>; + return arr.findIndex((c) => c.commentId === commentId); +} + +function getCommentById(yArray: YArray>, commentId: string): Record | null { + const arr = yArray.toJSON() as Array>; + return arr.find((c) => c.commentId === commentId) ?? null; +} + +function addYComment( + yArray: YArray>, + ydoc: YDoc, + comment: Record, + user?: Record, +): void { + const yComment = new YMap(Object.entries(comment)); + ydoc.transact( + () => { + yArray.push([yComment]); + }, + { user }, + ); +} + +function updateYComment( + yArray: YArray>, + ydoc: YDoc, + comment: Record, + user?: Record, +): void { + const commentId = comment.commentId as string; + const idx = getCommentIndex(yArray, commentId); + if (idx === -1) return; + + const yComment = new YMap(Object.entries(comment)); + ydoc.transact( + () => { + yArray.delete(idx, 1); + yArray.insert(idx, [yComment]); + }, + { user }, + ); +} + +function deleteYComment( + yArray: YArray>, + ydoc: YDoc, + comment: Record, + user?: Record, +): void { + const commentId = comment.commentId as string; + const idx = getCommentIndex(yArray, commentId); + if (idx === -1) return; + + ydoc.transact( + () => { + yArray.delete(idx, 1); + }, + { user }, + ); +} + +// --------------------------------------------------------------------------- +// Tracked-change normalization +// --------------------------------------------------------------------------- + +/** + * Maps a tracked-change event from the Editor's commentsUpdate emission + * into a comment-shaped object suitable for Yjs sync. + * + * Mirrors the shape produced by comments-store.js handleTrackedChangeUpdate(). + */ +function normalizeTrackedChangeToComment(params: Record): Record { + return { + commentId: params.changeId as string, + trackedChange: true, + trackedChangeText: (params.trackedChangeText as string) ?? null, + trackedChangeType: (params.trackedChangeType as string) ?? null, + deletedText: (params.deletedText as string) ?? null, + creatorName: (params.author as string) ?? null, + creatorEmail: (params.authorEmail as string) ?? null, + creatorImage: (params.authorImage as string) ?? null, + createdTime: (params.date as string) ?? null, + ...(params.importedAuthor != null ? { importedAuthor: params.importedAuthor } : {}), + documentId: (params.documentId as string) ?? null, + isInternal: false, + }; +} + +function applyTrackedChangeUpdate( + existing: Record, + params: Record, +): Record { + const updated: Record = { + ...existing, + trackedChange: true, + // Keep parity with comments-store.js: update tracked-change fields explicitly + // and clear missing values to null for partial replacements. + trackedChangeText: (params.trackedChangeText as string) ?? null, + trackedChangeType: (params.trackedChangeType as string) ?? null, + deletedText: (params.deletedText as string) ?? null, + }; + + if (params.author != null) updated.creatorName = params.author as string; + if (params.authorEmail != null) updated.creatorEmail = params.authorEmail as string; + if (params.authorImage != null) updated.creatorImage = params.authorImage as string; + if (params.date != null) updated.createdTime = params.date as string; + if (params.documentId != null) updated.documentId = params.documentId as string; + if (params.importedAuthor !== undefined) updated.importedAuthor = params.importedAuthor; + + return updated; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface HeadlessCommentBridgeResult { + /** Options to spread into Editor.open() call */ + editorOptions: { + isCommentsEnabled: true; + documentMode: 'editing'; + onCommentsUpdate: (params: Record) => void; + onCommentsLoaded: (params: { editor: unknown; comments: unknown[] }) => void; + }; + /** Cleanup — clears internal registry */ + dispose(): void; +} + +export function buildHeadlessCommentBridge(ydoc: unknown, user?: UserIdentity): HeadlessCommentBridgeResult { + const yDoc = ydoc as YDoc; + const yArray = yDoc.getArray('comments') as YArray>; + const userOrigin = user ? { name: user.name, email: user.email } : undefined; + + // Internal registry for dedup and update lookups. + // Seed from existing Yjs array so we don't duplicate comments already in the room. + const registry = new Map>(); + for (const entry of yArray.toJSON() as Array>) { + const id = entry.commentId as string | undefined; + if (id) registry.set(id, entry); + } + + function getCurrentComment(commentId: string): Record | null { + const fromYjs = getCommentById(yArray, commentId); + if (fromYjs) { + registry.set(commentId, fromYjs); + return fromYjs; + } + + // Yjs is the source of truth; remove stale registry entries when absent. + registry.delete(commentId); + return null; + } + + // ---- Event handler (mirrors syncCommentsToClients) ---- + + function handleCommentsUpdate(params: Record): void { + const type = params.type as string; + + if (type === 'trackedChange') { + const event = params.event as string; + const changeId = params.changeId as string; + if (!changeId) return; + + const comment = normalizeTrackedChangeToComment(params); + + if (event === 'add') { + const existing = getCurrentComment(changeId); + if (existing) { + // Dedup: update instead of re-adding. + const updated = applyTrackedChangeUpdate(existing, params); + registry.set(changeId, updated); + updateYComment(yArray, yDoc, updated, userOrigin); + } else { + registry.set(changeId, comment); + addYComment(yArray, yDoc, comment, userOrigin); + } + } else if (event === 'update') { + const existing = getCurrentComment(changeId); + if (existing) { + const updated = applyTrackedChangeUpdate(existing, params); + registry.set(changeId, updated); + updateYComment(yArray, yDoc, updated, userOrigin); + } + } else if (event === 'resolve') { + // Resolve payloads are sparse — only apply resolution fields, + // preserving all existing tracked-change metadata. + // Mirrors comments-store.js:380 resolveComment() behavior. + const existing = getCurrentComment(changeId); + if (existing) { + if (existing.resolvedTime) return; + const updated = { + ...existing, + resolvedTime: existing.resolvedTime ?? new Date().toISOString(), + resolvedByEmail: (params.resolvedByEmail as string) ?? userOrigin?.email ?? null, + resolvedByName: (params.resolvedByName as string) ?? userOrigin?.name ?? null, + }; + registry.set(changeId, updated); + updateYComment(yArray, yDoc, updated, userOrigin); + } + } + return; + } + + // Standard comment events + const comment = params.comment as Record | undefined; + if (!comment) return; + const commentId = comment.commentId as string; + if (!commentId) return; + + switch (type) { + case 'add': + if (!getCurrentComment(commentId)) { + registry.set(commentId, comment); + addYComment(yArray, yDoc, comment, userOrigin); + } + break; + case 'update': + case 'resolved': { + const existing = getCurrentComment(commentId); + if (!existing) break; + const updated = { ...existing, ...comment }; + registry.set(commentId, updated); + updateYComment(yArray, yDoc, updated, userOrigin); + break; + } + case 'deleted': + registry.delete(commentId); + deleteYComment(yArray, yDoc, comment, userOrigin); + break; + } + } + + // ---- onCommentsLoaded handler ---- + + function handleCommentsLoaded(params: { editor: unknown; comments: unknown[] }): void { + const { comments } = params; + if (!Array.isArray(comments) || comments.length === 0) return; + + yDoc.transact( + () => { + for (const raw of comments) { + const comment = raw as Record; + const commentId = comment.commentId as string; + if (!commentId || registry.has(commentId)) continue; + + registry.set(commentId, comment); + const yComment = new YMap(Object.entries(comment)); + yArray.push([yComment]); + } + }, + { user: userOrigin }, + ); + } + + return { + editorOptions: { + isCommentsEnabled: true, + documentMode: 'editing', + onCommentsUpdate: handleCommentsUpdate, + onCommentsLoaded: handleCommentsLoaded, + }, + dispose() { + registry.clear(); + }, + }; +} + +// Exported for testing +export const __test__ = { + normalizeTrackedChangeToComment, + addYComment, + updateYComment, + deleteYComment, + getCommentIndex, +}; diff --git a/apps/cli/tmp-repro-compare.mjs b/apps/cli/tmp-repro-compare.mjs new file mode 100644 index 0000000000..6557396871 --- /dev/null +++ b/apps/cli/tmp-repro-compare.mjs @@ -0,0 +1,55 @@ +import { Doc as YDoc } from 'yjs'; +import { createDocumentApi } from '@superdoc/document-api'; +import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; +import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; + +const source = Buffer.from(BLANK_DOCX_BASE64, 'base64'); +const noop = () => {}; +const provider = { + synced: true, + awareness: { on: noop, off: noop, getStates: () => new Map(), setLocalState: noop, setLocalStateField: noop }, + on: noop, + off: noop, + connect: noop, + disconnect: noop, + destroy: noop, +}; + +async function run(label, moduleName) { + const mod = await import(moduleName); + const Editor = mod.Editor; + const ydoc = new YDoc(); + const events = []; + const editor = await Editor.open(Buffer.from(source), { + documentId: `compare-${label}`, + user: { name: 'Agent', email: 'agent@superdoc.dev', image: null }, + ydoc, + collaborationProvider: provider, + isNewFile: true, + isCommentsEnabled: true, + isHeadless: true, + telemetry: { enabled: false }, + }); + + const pluginKeys = editor.state.plugins.map((p) => p.key || ''); + editor.on('commentsUpdate', (e) => events.push(e)); + + Object.defineProperty(editor, 'doc', { value: createDocumentApi(getDocumentApiAdapters(editor)), configurable: true }); + const receipt = editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'hello world' }, { changeMode: 'tracked' }); + + const comments = ydoc.getArray('comments').toJSON(); + const commentsPlugin = editor.state.plugins.find((plugin) => plugin.key?.startsWith?.('comments')); + console.log('\n===', label, moduleName, '==='); + console.log(JSON.stringify({ + hasCommentsPlugin: Boolean(commentsPlugin), + hasCommentsKey: pluginKeys.includes('comments$'), + receipt, + eventsCount: events.length, + commentsCount: comments.length, + }, null, 2)); + + editor.destroy(); +} + +await run('superdoc-subpath', 'superdoc/super-editor'); +await run('scoped-package', '@superdoc/super-editor'); diff --git a/packages/super-editor/src/core/Editor.lifecycle.test.ts b/packages/super-editor/src/core/Editor.lifecycle.test.ts index f8de7924c8..f319a08ea7 100644 --- a/packages/super-editor/src/core/Editor.lifecycle.test.ts +++ b/packages/super-editor/src/core/Editor.lifecycle.test.ts @@ -468,6 +468,18 @@ describe('Editor Lifecycle API', () => { await editor.open(undefined, getBlankDocOptions()); expect(editor.lifecycleState).toBe('ready'); }); + + it('should ignore late collaborationReady callbacks after close', () => { + editor.options.isCommentsEnabled = true; + editor.options.shouldLoadComments = true; + + editor.close(); + expect(editor.lifecycleState).toBe('closed'); + + expect(() => { + editor.emit('collaborationReady', { editor, ydoc: {} }); + }).not.toThrow(); + }); }); }); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 8c689ca1a3..6c10e2ae72 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2098,6 +2098,12 @@ export class Editor extends EventEmitter { */ #onCollaborationReady({ editor, ydoc }: { editor: Editor; ydoc: unknown }): void { if (this.options.collaborationIsReady) return; + + // Collaboration callbacks can arrive after close()/unload. In that state + // the converter and editor state are intentionally cleared, so there is + // nothing valid to initialize. + if (this.isDestroyed || !this.converter || !this.state) return; + console.debug('🔗 [super-editor] Collaboration ready'); this.#validateDocumentInit(); @@ -2126,6 +2132,7 @@ export class Editor extends EventEmitter { #initComments(): void { if (!this.options.isCommentsEnabled) return; if (!this.options.shouldLoadComments) return; + if (!this.converter) return; const replacedFile = this.options.replacedFile; this.emit('commentsLoaded', { editor: this, @@ -2846,7 +2853,7 @@ export class Editor extends EventEmitter { ): Promise { // Apply smart defaults const hasElement = config?.element != null || config?.selector != null; - const resolvedConfig: Partial = { + const resolvedConfig: Partial & OpenOptions = { mode: 'docx', isHeadless: !hasElement, ...config, @@ -2857,6 +2864,7 @@ export class Editor extends EventEmitter { // OpenOptions (document-level) html, markdown, + json, isCommentsEnabled, suppressDefaultDocxStyles, documentMode, @@ -2872,6 +2880,7 @@ export class Editor extends EventEmitter { mode: resolvedConfig.mode as 'docx' | 'text' | 'html', html, markdown, + json, isCommentsEnabled, suppressDefaultDocxStyles, documentMode: documentMode as 'editing' | 'viewing' | 'suggesting' | undefined, diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 00ae95ac00..eb4a38edaa 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -421,11 +421,10 @@ export const CommentsPlugin = Extension.create({ addPmPlugins() { const editor = this.editor; + const isHeadless = editor.options.isHeadless; let shouldUpdate = true; - if (editor.options.isHeadless) return []; - - const commentsPlugin = new Plugin({ + const pluginSpec = { key: CommentsPluginKey, state: { @@ -516,14 +515,17 @@ export const CommentsPlugin = Extension.create({ return pluginState; }, }, + }; - props: { + // In headless mode, skip DOM-dependent props and view — only state tracking is needed. + if (!isHeadless) { + pluginSpec.props = { decorations(state) { return this.getState(state).decorations; }, - }, + }; - view() { + pluginSpec.view = () => { let prevDoc = null; let prevActiveThreadId = null; let prevAllCommentPositions = {}; @@ -687,10 +689,10 @@ export const CommentsPlugin = Extension.create({ } }, }; - }, - }); + }; + } - return [commentsPlugin]; + return [new Plugin(pluginSpec)]; }, }); diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/extensions/comment/comments-plugin.test.js index f4d17f5698..184184f74f 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.test.js @@ -1312,3 +1312,62 @@ describe('SD-1940: no recursive dispatch from apply() on selection change', () = expect(dispatchCount).toBeLessThanOrEqual(3); }); }); + +describe('Headless mode plugin behavior', () => { + it('creates a state-only plugin in headless mode (no props or view)', () => { + const editor = { + options: { isHeadless: true, comments: {} }, + emit: vi.fn(), + }; + + const extension = Extension.create(CommentsPlugin.config); + extension.editor = editor; + const plugins = CommentsPlugin.config.addPmPlugins.call(extension); + + expect(plugins).toHaveLength(1); + expect(plugins[0].spec.props).toBeUndefined(); + expect(plugins[0].spec.view).toBeUndefined(); + // State spec should exist + expect(plugins[0].spec.state).toBeDefined(); + expect(plugins[0].spec.state.init).toBeDefined(); + expect(plugins[0].spec.state.apply).toBeDefined(); + }); + + it('creates a full plugin in browser mode (with props and view)', () => { + const editor = { + options: { isHeadless: false, comments: {} }, + emit: vi.fn(), + }; + + const extension = Extension.create(CommentsPlugin.config); + extension.editor = editor; + const plugins = CommentsPlugin.config.addPmPlugins.call(extension); + + expect(plugins).toHaveLength(1); + expect(plugins[0].spec.props).toBeDefined(); + expect(plugins[0].spec.view).toBeDefined(); + }); + + it('provides valid plugin state via CommentsPluginKey in headless mode', () => { + const schema = createCommentSchema(); + const editor = { + options: { isHeadless: true, comments: { highlightColors: { external: '#aaa', internal: '#bbb' } } }, + emit: vi.fn(), + }; + + const extension = Extension.create(CommentsPlugin.config); + extension.editor = editor; + const plugins = CommentsPlugin.config.addPmPlugins.call(extension); + + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('Hello')])]); + const state = EditorState.create({ schema, doc, plugins }); + const pluginState = CommentsPluginKey.getState(state); + + expect(pluginState).toBeDefined(); + expect(pluginState.trackedChanges).toEqual({}); + expect(pluginState.activeThreadId).toBeNull(); + expect(pluginState.allCommentPositions).toEqual({}); + expect(pluginState.externalColor).toBe('#aaa'); + expect(pluginState.internalColor).toBe('#bbb'); + }); +}); diff --git a/packages/super-editor/src/extensions/comment/comments.test.js b/packages/super-editor/src/extensions/comment/comments.test.js index e75c482a18..02ec04929d 100644 --- a/packages/super-editor/src/extensions/comment/comments.test.js +++ b/packages/super-editor/src/extensions/comment/comments.test.js @@ -844,9 +844,11 @@ describe('comments plugin commands', () => { }); describe('comments plugin pm plugin', () => { - it('skips plugin creation when editor is headless', () => { - const result = CommentsPlugin.config.addPmPlugins.call({ editor: { options: { isHeadless: true } } }); - expect(result).toEqual([]); + it('creates state-only plugin in headless mode (no props or view)', () => { + const result = CommentsPlugin.config.addPmPlugins.call({ editor: { options: { isHeadless: true, comments: {} } } }); + expect(result).toHaveLength(1); + expect(result[0].spec.props).toBeUndefined(); + expect(result[0].spec.view).toBeUndefined(); }); it('initialises state with default values', () => { diff --git a/packages/super-editor/src/extensions/tests/headless.test.js b/packages/super-editor/src/extensions/tests/headless.test.js index 6c8d4d9282..77a5b0a1d1 100644 --- a/packages/super-editor/src/extensions/tests/headless.test.js +++ b/packages/super-editor/src/extensions/tests/headless.test.js @@ -44,6 +44,42 @@ const hasInvalidParagraphRangeError = (calls) => ), ); +describe('Headless static Editor.open()', () => { + it('initializes from json option', async () => { + const jsonDoc = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'From JSON' }] }], + }; + const editor = await Editor.open(undefined, { json: jsonDoc }); + expect(editor.state.doc.textContent).toContain('From JSON'); + editor.destroy(); + }); + + it('comments plugin state is accessible via headless Editor.open()', async () => { + const { buffer } = await loadHeadlessOpenFixtureBuffer(); + + const editor = await Editor.open(buffer, { + isCommentsEnabled: true, + extensions: getStarterExtensions(), + suppressDefaultDocxStyles: true, + }); + + // Find the comments plugin by its key name + const commentsPlugin = editor.state.plugins.find((p) => p.key?.startsWith?.('comments')); + expect(commentsPlugin).toBeDefined(); + + // Verify plugin state is initialized (state spec with init/apply is active) + expect(commentsPlugin.spec.state).toBeDefined(); + expect(commentsPlugin.spec.state.init).toBeDefined(); + + // Verify DOM-dependent parts are excluded in headless mode + expect(commentsPlugin.spec.props).toBeUndefined(); + expect(commentsPlugin.spec.view).toBeUndefined(); + + editor.destroy(); + }); +}); + describe('Headless Mode Optimization', () => { it('opens real DOCX fixtures headlessly without paragraph RangeErrors', async () => { const { buffer } = await loadHeadlessOpenFixtureBuffer();