From e00f6e1e6eed15c2df1abfc2bf75c687a971614d Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Wed, 20 May 2026 13:12:01 +0300 Subject: [PATCH 001/280] fix: preserve tab underline via runProperties fallback in collab --- .../converters/inline-converters/tab.test.ts | 45 +++++++++++++++++++ .../src/converters/inline-converters/tab.ts | 18 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts index ffe2ab1ca7..3b4a9cc6bb 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts @@ -293,5 +293,50 @@ describe('tabNodeToRun', () => { expect(applyMarksToRunMock).not.toHaveBeenCalled(); }); + + it('hydrates underline from runProperties when tab marks are missing', () => { + const tabNode: PMNode = { type: 'tab' }; + const paragraphAttrs: ParagraphAttrs = {}; + const positions: PositionMap = new WeakMap(); + positions.set(tabNode, { start: 0, end: 1 }); + + const result = tabNodeToRun({ + node: tabNode, + positions, + tabOrdinal: 0, + paragraphAttrs, + runProperties: { + underline: { + 'w:val': 'single', + }, + } as any, + }) as TabRun; + + expect(result.underline).toEqual({ style: 'single' }); + }); + + it('keeps explicit tab mark precedence over runProperties fallback', () => { + const tabNode: PMNode = { + type: 'tab', + marks: [{ type: 'underline', attrs: { underlineType: 'none' } }], + }; + const paragraphAttrs: ParagraphAttrs = {}; + const positions: PositionMap = new WeakMap(); + positions.set(tabNode, { start: 0, end: 1 }); + + const result = tabNodeToRun({ + node: tabNode, + positions, + tabOrdinal: 0, + paragraphAttrs, + runProperties: { + underline: { + 'w:val': 'single', + }, + } as any, + }) as TabRun; + + expect(result.underline).toBeUndefined(); + }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts index da8b2bd4ff..24d0539718 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts @@ -1,6 +1,6 @@ import type { Run, TabRun, TabStop } from '@superdoc/contracts'; import { applyMarksToRun } from '../../marks/index.js'; -import { type InlineConverterParams } from './common.js'; +import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; /** * Converts a tab PM node to a TabRun. @@ -20,12 +20,15 @@ export function tabNodeToRun({ paragraphAttrs, inheritedMarks, sdtMetadata, + runProperties, + converterContext, + inlineRunProperties, }: InlineConverterParams): Run | null { const pos = positions.get(node); if (!pos) return null; const tabStops: TabStop[] | undefined = paragraphAttrs.tabs; const indent = paragraphAttrs.indent; - const run: TabRun = { + let run: TabRun = { kind: 'tab', text: '\t', pmStart: pos.start, @@ -40,6 +43,17 @@ export function tabNodeToRun({ run.sdt = sdtMetadata; } + // Align tab formatting with text runs: hydrate from resolved runProperties first. + // This survives Yjs element-node mark loss; explicit marks below still override. + if (runProperties) { + run = applyInlineRunProperties( + run as any, + runProperties, + converterContext, + inlineRunProperties, + ) as unknown as TabRun; + } + // Apply marks (e.g., underline) to the tab run const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; if (marks.length > 0) { From 0817882eab41e2ac4e2437595e42cab9f71cf698 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 25 May 2026 17:51:08 +0300 Subject: [PATCH 002/280] feat: add anchored metadata orphan status --- .../src/metadata/anchored-metadata.test.ts | 12 +++ .../src/metadata/anchored-metadata.ts | 6 ++ .../src/metadata/anchored-metadata.types.ts | 7 ++ .../src/editors/v1/core/Editor.ts | 8 ++ ...chored-metadata-export.integration.test.ts | 76 +++++++++++++++++ .../anchored-metadata-wrappers.test.ts | 82 ++++++++++++++++++- .../plan-engine/anchored-metadata-wrappers.ts | 33 ++++++-- .../track-changes-extension.test.js | 44 ++++++++++ 8 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts diff --git a/packages/document-api/src/metadata/anchored-metadata.test.ts b/packages/document-api/src/metadata/anchored-metadata.test.ts index 2271ca30e7..30c57bad33 100644 --- a/packages/document-api/src/metadata/anchored-metadata.test.ts +++ b/packages/document-api/src/metadata/anchored-metadata.test.ts @@ -211,6 +211,11 @@ describe('metadata.list validation', () => { ).not.toThrow(); }); + it('accepts resolvedOnly filter', () => { + const adapter = makeAdapter(); + expect(() => executeAnchoredMetadataList(adapter, { resolvedOnly: true })).not.toThrow(); + }); + it('rejects non-string namespace', () => { const adapter = makeAdapter(); expect(() => executeAnchoredMetadataList(adapter, { namespace: 42 as unknown as string })).toThrow( @@ -238,6 +243,13 @@ describe('metadata.list validation', () => { executeAnchoredMetadataList(adapter, { within: { foo: 'bar' } as unknown as SelectionTarget }), ).toThrow(DocumentApiValidationError); }); + + it('rejects non-boolean resolvedOnly', () => { + const adapter = makeAdapter(); + expect(() => executeAnchoredMetadataList(adapter, { resolvedOnly: 'yes' as unknown as boolean })).toThrow( + DocumentApiValidationError, + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/metadata/anchored-metadata.ts b/packages/document-api/src/metadata/anchored-metadata.ts index 0ef851d5e7..e1a792e586 100644 --- a/packages/document-api/src/metadata/anchored-metadata.ts +++ b/packages/document-api/src/metadata/anchored-metadata.ts @@ -182,6 +182,12 @@ export function executeAnchoredMetadataList( if (query?.within !== undefined) { validateWithin(query.within, 'metadata.list'); } + if (query?.resolvedOnly !== undefined && typeof query.resolvedOnly !== 'boolean') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `metadata.list 'resolvedOnly' must be a boolean when provided.`, + ); + } return adapter.list(query); } diff --git a/packages/document-api/src/metadata/anchored-metadata.types.ts b/packages/document-api/src/metadata/anchored-metadata.types.ts index 7f96610f95..30bdf015bc 100644 --- a/packages/document-api/src/metadata/anchored-metadata.types.ts +++ b/packages/document-api/src/metadata/anchored-metadata.types.ts @@ -126,6 +126,11 @@ export interface AnchoredMetadataListInput { * constraints as {@link MetadataTarget}: text-range only. */ within?: SelectionTarget; + /** + * When true, include only entries whose SDT anchor currently resolves + * in the document body. + */ + resolvedOnly?: boolean; limit?: number; offset?: number; } @@ -165,6 +170,8 @@ export interface AnchoredMetadataSummary { namespace: string; /** Package-relative path of the backing Storage Part. */ partName: string; + /** Whether the corresponding SDT anchor currently exists in the document. */ + anchorStatus: 'resolved' | 'orphan'; } export type AnchoredMetadataInfo = AnchoredMetadataSummary & { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 399074ab15..34c417a6de 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -92,6 +92,7 @@ import { applyEffectiveEditability, getProtectionStorage } from '../extensions/p import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewModeSelectionWithoutStructuredContent.js'; import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; +import { buildFilteredMetadataXml } from '../document-api-adapters/plan-engine/anchored-metadata-wrappers.js'; declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -3396,6 +3397,13 @@ export class Editor extends EventEmitter { } } + if (isFinalDoc) { + const filteredMetadataParts = buildFilteredMetadataXml(this, this.converter.convertedXml, { finalDoc: true }); + for (const [path, xml] of Object.entries(filteredMetadataParts)) { + updatedDocs[path] = xml; + } + } + for (const path of Object.keys(this.converter.convertedXml)) { if (!path.startsWith('customXml/')) continue; if (!path.endsWith('.xml') && !path.endsWith('.rels')) continue; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts new file mode 100644 index 0000000000..203cf3c7f8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +function seedMetadataPart( + convertedXml: Record, + partName: string, + namespace: string, + entries: Array<{ id: string; json: string }>, +): void { + convertedXml[partName] = { + elements: [ + { + type: 'element', + name: 'refs', + attributes: { xmlns: namespace }, + elements: entries.map((entry) => ({ + type: 'element', + name: 'ref', + attributes: { id: entry.id, encoding: 'json' }, + elements: [{ type: 'text', text: entry.json }], + })), + }, + ], + }; +} + +async function createEditorWithEmptyPackage() { + const docData = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + return editor; +} + +describe('anchored metadata export filtering', () => { + it('removes anchored-metadata entries from customXml when exporting final doc', async () => { + const editor = await createEditorWithEmptyPackage(); + + try { + editor.commands.insertContent('Hello'); + editor.commands.insertStructuredContentInline({ + attrs: { + id: '101', + tag: 'meta-resolved', + alias: 'Anchored metadata', + }, + text: 'Anchor', + }); + + const convertedXml = (editor as unknown as { converter: { convertedXml: Record } }).converter + .convertedXml; + seedMetadataPart(convertedXml, 'customXml/item1.xml', 'urn:test:metadata', [ + { id: 'meta-resolved', json: '{"v":1}' }, + { id: 'meta-orphan', json: '{"v":2}' }, + ]); + + const updatedDocs = (await editor.exportDocx({ isFinalDoc: true, getUpdatedDocs: true })) as Record< + string, + string | null + >; + + const metadataXml = updatedDocs['customXml/item1.xml']; + expect(typeof metadataXml).toBe('string'); + expect(metadataXml).not.toContain('meta-resolved'); + expect(metadataXml).not.toContain('meta-orphan'); + } finally { + editor.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts index 1c529528df..e469d0203b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts @@ -2,6 +2,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import { describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { + buildFilteredMetadataXml, metadataAttachWrapper, metadataGetWrapper, metadataListWrapper, @@ -195,8 +196,20 @@ describe('anchored metadata wrappers', () => { expect(attached).toMatchObject({ success: true, id: 'meta-1', namespace: 'urn:test:metadata' }); expect( - metadataListWrapper(editor).items.map(({ id, namespace, partName }) => ({ id, namespace, partName })), - ).toEqual([{ id: 'meta-1', namespace: 'urn:test:metadata', partName: 'customXml/item1.xml' }]); + metadataListWrapper(editor).items.map(({ id, namespace, partName, anchorStatus }) => ({ + id, + namespace, + partName, + anchorStatus, + })), + ).toEqual([ + { + id: 'meta-1', + namespace: 'urn:test:metadata', + partName: 'customXml/item1.xml', + anchorStatus: 'orphan', + }, + ]); expect(metadataGetWrapper(editor, { id: 'meta-1' })?.payload).toEqual({ label: 'Alpha' }); expect(metadataUpdateWrapper(editor, { id: 'meta-1', payload: { label: 'Beta' } })).toEqual({ @@ -219,6 +232,49 @@ describe('anchored metadata wrappers', () => { expect(metadataListWrapper(editor, { namespace: 'urn:b' }).items.map((item) => item.id)).toEqual(['b']); }); + it('marks entries as orphan and supports resolvedOnly filtering', () => { + const editor = makeEditor(); + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [{ id: 'meta-orphan', json: '{"v":1}' }]); + + const listed = metadataListWrapper(editor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]).toMatchObject({ id: 'meta-orphan', anchorStatus: 'orphan' }); + + const resolvedOnly = metadataListWrapper(editor, { resolvedOnly: true }); + expect(resolvedOnly.total).toBe(0); + expect(resolvedOnly.items).toEqual([]); + }); + + it('buildFilteredMetadataXml in final-doc mode removes all anchored-metadata entries', () => { + const sdt = createNode('structuredContent', [createNode('text', [], { text: 'Hello' })], { + attrs: { id: '100', tag: 'meta-resolved' }, + isInline: true, + isBlock: false, + inlineContent: true, + }); + const paragraph = createNode('paragraph', [sdt], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = makeEditor(doc); + + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [ + { id: 'meta-resolved', json: '{"v":1}' }, + { id: 'meta-orphan', json: '{"v":2}' }, + ]); + + const filtered = buildFilteredMetadataXml( + editor, + (editor as unknown as { converter: { convertedXml: Record } }).converter.convertedXml, + { finalDoc: true }, + ); + expect(filtered['customXml/item1.xml']).not.toContain('meta-resolved'); + expect(filtered['customXml/item1.xml']).not.toContain('meta-orphan'); + expect(filtered['customXml/item1.xml']).toContain(''); + }); + it('does not mutate storage during attach dry-run', () => { const editor = makeEditor(); @@ -368,6 +424,28 @@ describe('anchored metadata wrappers', () => { expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toBeNull(); }); + + it('treats block SDT with matching tag as orphan for list/resolve semantics', () => { + const blockSdt = createNode('structuredContentBlock', [createNode('text', [], { text: 'Hello' })], { + attrs: { id: '200', tag: 'meta-1' }, + isInline: false, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [blockSdt], { isBlock: false }); + const editor = makeEditor(doc); + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [{ id: 'meta-1', json: '{"label":"Alpha"}' }]); + + const listed = metadataListWrapper(editor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]).toMatchObject({ id: 'meta-1', anchorStatus: 'orphan' }); + + const resolvedOnly = metadataListWrapper(editor, { resolvedOnly: true }); + expect(resolvedOnly.total).toBe(0); + expect(resolvedOnly.items).toEqual([]); + + expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toBeNull(); + }); }); /** diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts index 25ac89125d..ae016513b1 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts @@ -61,7 +61,7 @@ type ConverterWithConvertedXml = { promoteToGuid?: () => string; }; -type MetadataEntry = AnchoredMetadataInfo; +type MetadataEntry = Omit; type MetadataPart = { namespace: string; @@ -167,6 +167,20 @@ function listMetadataParts(convertedXml: Record): MetadataPart[ .filter((part): part is MetadataPart => part !== null); } +export function buildFilteredMetadataXml( + editor: Editor, + convertedXml: Record, + options?: { finalDoc?: boolean }, +): Record { + const filtered: Record = {}; + const finalDoc = options?.finalDoc === true; + for (const part of listMetadataParts(convertedXml)) { + const resolvedEntries = finalDoc ? [] : part.entries.filter((entry) => hasAnchor(editor, entry.id)); + filtered[part.partName] = buildEnvelopeXml(part.namespace, resolvedEntries); + } + return filtered; +} + function findPartByNamespace(convertedXml: Record, namespace: string): MetadataPart | null { return listMetadataParts(convertedXml).find((part) => part.namespace === namespace) ?? null; } @@ -225,7 +239,7 @@ function findAnchorsById(editor: Editor, id: string) { } function hasAnchor(editor: Editor, id: string): boolean { - return findAllSdtNodes(editor.state.doc).some((sdt) => sdt.node.attrs?.tag === id); + return findAnchorsById(editor, id).length > 0; } function wrapRangeInAnchor(editor: Editor, target: SelectionTarget, id: string): boolean { @@ -387,11 +401,12 @@ function removeEntry(editor: Editor, id: string, dryRun: boolean): boolean { return true; } -function toSummary(entry: MetadataEntry): AnchoredMetadataSummary { +function toSummary(entry: MetadataEntry, editor: Editor): AnchoredMetadataSummary { return { id: entry.id, namespace: entry.namespace, partName: entry.partName, + anchorStatus: hasAnchor(editor, entry.id) ? 'resolved' : 'orphan', }; } @@ -403,12 +418,15 @@ function listEntries(editor: Editor, query?: AnchoredMetadataListInput): Metadat if (query?.within !== undefined) { entries = entries.filter((entry) => anchorOverlaps(editor, entry.id, query.within as SelectionTarget)); } + if (query?.resolvedOnly) { + entries = entries.filter((entry) => hasAnchor(editor, entry.id)); + } return entries; } export function metadataListWrapper(editor: Editor, query?: AnchoredMetadataListInput): AnchoredMetadataListResult { const allItems = listEntries(editor, query).map((entry) => { - const summary = toSummary(entry); + const summary = toSummary(entry, editor); return buildDiscoveryItem( summary.id, buildResolvedHandle(`metadata:${summary.id}`, 'ephemeral', 'ext:anchoredMetadata'), @@ -430,7 +448,12 @@ export function metadataListWrapper(editor: Editor, query?: AnchoredMetadataList } export function metadataGetWrapper(editor: Editor, input: AnchoredMetadataGetInput): AnchoredMetadataInfo | null { - return findEntry(getConvertedXml(editor), input.id); + const entry = findEntry(getConvertedXml(editor), input.id); + if (!entry) return null; + return { + ...entry, + anchorStatus: hasAnchor(editor, entry.id) ? 'resolved' : 'orphan', + }; } export function metadataResolveWrapper( diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 581296ba52..653e3efda5 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -1044,6 +1044,50 @@ describe('TrackChanges extension commands', () => { } }); + it('interaction(docx): rejectTrackedChangesBetween keeps inserted inline structured content wrapper', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'docx', + content: '

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.setDocumentMode('suggesting'); + interactionEditor.commands.insertStructuredContentInline({ + attrs: { + id: '101', + tag: 'meta-track-1', + alias: 'Anchored metadata', + }, + text: 'Tracked SDT', + }); + + let hasStructuredContent = false; + interactionEditor.state.doc.descendants((node) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === 'meta-track-1') { + hasStructuredContent = true; + return false; + } + }); + expect(hasStructuredContent).toBe(true); + + interactionEditor.commands.rejectTrackedChangesBetween(0, interactionEditor.state.doc.content.size); + + hasStructuredContent = false; + interactionEditor.state.doc.descendants((node) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === 'meta-track-1') { + hasStructuredContent = true; + return false; + } + }); + // Current behavior: rejecting tracked insertion clears inserted text, + // but does not remove the inline SDT wrapper node itself. + expect(hasStructuredContent).toBe(true); + } finally { + interactionEditor.destroy(); + } + }); + it('interaction: rejectTrackedChangeOnSelection reverts mixed marks + textStyle in suggesting mode', () => { const { editor: interactionEditor } = initTestEditor({ mode: 'text', From bc7592304eca06e56e4e87f8fe13fe4ea73affc7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 13:26:16 -0300 Subject: [PATCH 003/280] feat(scripts): add type-bearing JSDoc hygiene gate for .ts source (SD-673) Adds a new check:public:superdoc stage (jsdoc-hygiene-ts) that enforces TypeScript-as-single-source on the .ts public contract surface. Companion to the existing jsdoc-ratchet stage, which covers .js files; together they enforce that each language's source files use the right type system (TS syntax in .ts, JSDoc + @ts-check in .js) without one polluting the other. Scanner (check-jsdoc-hygiene-ts.cjs): - Walks .ts source under packages/superdoc/src and packages/super-editor/src (excludes test/.d.ts files). - Positive detection on tag name. ALWAYS_FLAG_TAGS: @type, @typedef, @callback, @template, @implements, @extends, @augments, @enum. FLAG_WHEN_TYPED_TAGS: @param, @returns, @return, @this (only flagged when tag.typeExpression is set, so prose-only @param name description passes). Every other tag is ignored, including @deprecated, @example, @throws, @see, @typeParam. - AST-based via ts.getJSDocTags + tag.typeExpression presence check; no regex on raw comment text. De-dupes per (file, pos, end, tag) so tags surfaced through multiple parent nodes count once. - Classifies each violation as declaration-doc-type, inline-fake-cast, or typedef-style so the future cleanup PR can group fixes by type. Baseline (jsdoc-hygiene-ts-baseline.json) records the 85 existing violations grandfathered at PR time. The ratchet fails on net-new entries or stale baseline entries (file cleaned up, snapshot still records it). Refresh with --write after intentional cleanup. Goal is to drain to zero, then flip to 'zero allowed' in a separate PR. Scanner tests (check-jsdoc-hygiene-ts.test.cjs) protect against two failure modes: silent false-positives (flagging legitimate doc tags) and silent false-negatives (scanner produces nothing and looks like it's working). 13 in-memory fixtures including a negative control (prose-only JSDoc + TS types -> zero hits) and a mixed-tag block (@deprecated + typed @param + prose @returns -> exactly one hit). Policy doc at packages/superdoc/scripts/type-hygiene.md explains the rule, the fix patterns for each violation class, and the scope. The gate's failure message links to this doc so contributors hit a clear path forward rather than a cryptic CI error. Verified: scanner tests -> 13 passed, 0 failed; baseline scan -> 85 grandfathered, no net-new; canary fixture (added typed @param) -> correctly fails with the new violation surfaced; pnpm check:public:superdoc --skip-build -> PASS (10 ran, 1 skipped, 139.4s). Sibling pnpm check:jsdoc continues to enforce the .js side unchanged. --- .../scripts/check-jsdoc-hygiene-ts.cjs | 293 ++++++++++++++++++ .../scripts/check-jsdoc-hygiene-ts.test.cjs | 225 ++++++++++++++ .../scripts/jsdoc-hygiene-ts-baseline.json | 90 ++++++ packages/superdoc/scripts/type-hygiene.md | 181 +++++++++++ scripts/check-public-contract.mjs | 13 + 5 files changed, 802 insertions(+) create mode 100644 packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs create mode 100644 packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs create mode 100644 packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json create mode 100644 packages/superdoc/scripts/type-hygiene.md diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs new file mode 100644 index 0000000000..6a4876a85d --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +/** + * SD-673 / type-hygiene-ts: gate for type-bearing JSDoc in `.ts` + * source under `packages/superdoc/src/` and `packages/super-editor/src/`. + * + * Policy: TypeScript syntax is the only source of truth for shape on + * the public contract surface. Type-bearing JSDoc in `.ts` files is + * documentation-only — TS ignores it — so duplicated type information + * drifts silently. See `type-hygiene.md` for the full rule. + * + * Detection model: + * + * - Positive detection on tag name. Two sets: + * + * ALWAYS_FLAG_TAGS — tag is a violation purely by being present + * in a `.ts` file (the tag is a JSDoc-type-system construct that + * has a native TS equivalent): + * @type, @typedef, @callback, @template, @implements, + * @extends, @augments, @enum + * + * FLAG_WHEN_TYPED_TAGS — tag is a violation only when it carries + * a type expression (the `{Type}` brace). Prose-only forms are + * fine: + * @param, @returns, @return, @this + * + * - Every other JSDoc tag is ignored (allows `@deprecated`, + * `@example`, `@throws`, `@see`, `@typeParam`, etc.). + * + * Snapshot/ratchet pattern mirrors `check-jsdoc.cjs`: + * + * - Existing violations are recorded in + * `jsdoc-hygiene-ts-baseline.json`. New violations on top of the + * baseline fail. + * - Stale baseline entries (file removed, JSDoc cleaned up) also + * fail; rerun with --write to refresh. + * + * Refreshing the baseline: + * + * node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write + * + * Scope: see `type-hygiene.md` § Scope. Excludes test files and + * declaration files. + */ + +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +const scriptDir = __dirname; +const repoRoot = path.resolve(scriptDir, '..', '..', '..'); + +const BASELINE_PATH = path.join(scriptDir, 'jsdoc-hygiene-ts-baseline.json'); +const POLICY_RELATIVE = 'packages/superdoc/scripts/type-hygiene.md'; + +const SCAN_ROOTS = ['packages/superdoc/src', 'packages/super-editor/src']; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', '__mocks__', '__fixtures__', 'dev']); +const EXCLUDED_FILE_SUFFIXES = ['.d.ts', '.test.ts', '.spec.ts', '.test.tsx', '.spec.tsx']; + +const ALWAYS_FLAG_TAGS = new Set([ + 'type', + 'typedef', + 'callback', + 'template', + 'implements', + 'extends', + 'augments', + 'enum', +]); + +const FLAG_WHEN_TYPED_TAGS = new Set(['param', 'returns', 'return', 'this']); + +const CLASS_BY_TAG = { + type: 'inline-fake-cast', + typedef: 'typedef-style', + callback: 'typedef-style', + enum: 'typedef-style', + template: 'declaration-doc-type', + implements: 'declaration-doc-type', + extends: 'declaration-doc-type', + augments: 'declaration-doc-type', + param: 'declaration-doc-type', + returns: 'declaration-doc-type', + return: 'declaration-doc-type', + this: 'declaration-doc-type', +}; + +// ─── File discovery ─────────────────────────────────────────────────── + +function listTsFiles(rootRel) { + const out = []; + const absRoot = path.join(repoRoot, rootRel); + if (!fs.existsSync(absRoot)) return out; + + const stack = [absRoot]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (EXCLUDED_DIRS.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(abs); + } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.tsx')) { + if (EXCLUDED_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue; + out.push(path.relative(repoRoot, abs).replace(/\\/g, '/')); + } else if (entry.isFile() && entry.name.endsWith('.tsx')) { + if (EXCLUDED_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue; + out.push(path.relative(repoRoot, abs).replace(/\\/g, '/')); + } + } + } + return out; +} + +// ─── AST walk ───────────────────────────────────────────────────────── + +/** + * Walk a source file's AST and emit one violation per type-bearing + * JSDoc tag. De-duped at the end by {file, pos, end}. + */ +function findViolations(filePath, sourceText) { + const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true); + const findings = []; + const seen = new Set(); + + function visit(node) { + // ts.getJSDocTags returns the JSDoc tags attached to this node. + // We don't need to traverse the JSDoc comment itself — getting the + // tags from each declaration is enough. + const tags = ts.getJSDocTags(node); + if (tags && tags.length > 0) { + for (const tag of tags) { + const name = tag.tagName && tag.tagName.escapedText ? String(tag.tagName.escapedText) : ''; + const violation = classifyTag(name, tag); + if (!violation) continue; + const key = `${tag.pos}:${tag.end}:${name}`; + if (seen.has(key)) continue; + seen.add(key); + const { line } = sf.getLineAndCharacterOfPosition(tag.pos); + findings.push({ + file: filePath, + line: line + 1, + tag: name, + class: violation, + }); + } + } + ts.forEachChild(node, visit); + } + visit(sf); + + findings.sort((a, b) => (a.line - b.line) || a.tag.localeCompare(b.tag)); + return findings; +} + +function classifyTag(name, tag) { + if (ALWAYS_FLAG_TAGS.has(name)) { + return CLASS_BY_TAG[name]; + } + if (FLAG_WHEN_TYPED_TAGS.has(name)) { + // tag.typeExpression is set when the tag carries a `{Type}` brace. + if (tag.typeExpression) return CLASS_BY_TAG[name]; + } + return null; +} + +// ─── Baseline serialization ─────────────────────────────────────────── + +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) { + return { $comment: '', knownViolations: [] }; + } + const raw = fs.readFileSync(BASELINE_PATH, 'utf8'); + try { + return JSON.parse(raw); + } catch (e) { + throw new Error(`Failed to parse baseline at ${BASELINE_PATH}: ${e.message}`); + } +} + +function violationKey(v) { + return `${v.file}:${v.line}:${v.tag}`; +} + +function writeBaseline(violations) { + const data = { + $comment: + 'Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. ' + + 'Each entry is "file:line:tag"; the gate grandfathers these and fails on net-new violations. ' + + 'Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to ' + + '"zero allowed" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.', + knownViolations: violations.map(violationKey).sort(), + }; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(data, null, 2) + '\n'); +} + +// ─── Main ───────────────────────────────────────────────────────────── + +function main() { + const writeMode = process.argv.includes('--write'); + + const files = []; + for (const root of SCAN_ROOTS) { + files.push(...listTsFiles(root)); + } + files.sort(); + + const allViolations = []; + for (const rel of files) { + const abs = path.join(repoRoot, rel); + const text = fs.readFileSync(abs, 'utf8'); + const violations = findViolations(rel, text); + allViolations.push(...violations); + } + + const currentKeys = new Set(allViolations.map(violationKey)); + const baseline = loadBaseline(); + const baselineKeys = new Set(baseline.knownViolations); + + const newViolations = allViolations.filter((v) => !baselineKeys.has(violationKey(v))); + const staleEntries = [...baselineKeys].filter((k) => !currentKeys.has(k)).sort(); + + if (writeMode) { + writeBaseline(allViolations); + console.log(`[jsdoc-hygiene-ts] wrote ${BASELINE_PATH} (${allViolations.length} entries).`); + return; + } + + // Report + const total = allViolations.length; + const grandfathered = total - newViolations.length; + console.log(`[jsdoc-hygiene-ts] type-bearing JSDoc scanner`); + console.log('========================================================================'); + console.log(`Scope: ${SCAN_ROOTS.join(', ')} (excludes test files / .d.ts)`); + console.log(`Files scanned: ${files.length}`); + console.log(`Total violations: ${total}`); + console.log(`Grandfathered: ${grandfathered}`); + console.log(`Baseline at: ${path.relative(repoRoot, BASELINE_PATH)}`); + + if (newViolations.length > 0 || staleEntries.length > 0) { + console.log(''); + if (newViolations.length > 0) { + console.log(`FAIL ${newViolations.length} new type-bearing JSDoc tag(s) in .ts source:`); + for (const v of newViolations.slice(0, 30)) { + console.log(` - ${v.file}:${v.line} @${v.tag} [${v.class}]`); + } + if (newViolations.length > 30) { + console.log(` ... and ${newViolations.length - 30} more.`); + } + console.log(''); + console.log( + 'Type-bearing JSDoc is not allowed in .ts source on the public contract surface.\n' + + 'Use TypeScript for shape (signatures, interfaces, `as Type` casts) and prose-only\n' + + 'JSDoc for documentation. See ' + + POLICY_RELATIVE + + ' for the rule and fix patterns.\n', + ); + } + if (staleEntries.length > 0) { + console.log(`FAIL ${staleEntries.length} stale baseline entry/entries (no longer violating):`); + for (const k of staleEntries.slice(0, 30)) { + console.log(` - ${k}`); + } + if (staleEntries.length > 30) { + console.log(` ... and ${staleEntries.length - 30} more.`); + } + console.log(''); + console.log( + 'These entries were cleaned up but the baseline still records them.\n' + + 'Run with --write to refresh the snapshot and lock in the win.\n', + ); + } + process.exit(1); + } + + console.log(''); + console.log(`OK ${total} violation(s) tracked as baseline; no net-new entries.`); +} + +// Export the AST + classification helpers so the test runner can +// exercise them on in-memory fixtures without touching the file +// system. Only invoke main() when executed directly as a CLI. +module.exports = { + findViolations, + classifyTag, + ALWAYS_FLAG_TAGS, + FLAG_WHEN_TYPED_TAGS, + CLASS_BY_TAG, +}; + +if (require.main === module) { + main(); +} diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs new file mode 100644 index 0000000000..7855617fb2 --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * Tests for `check-jsdoc-hygiene-ts.cjs`. + * + * Protects the scanner against two failure modes: + * + * 1. Silent false-positives — flagging legitimate documentation + * tags (@deprecated, @example, @typeParam, etc.). + * 2. Silent false-negatives — the "scanner returns nothing for + * everything" failure mode, where AST field-name drift or a + * logic bug makes the gate look like it's working when it + * isn't. + * + * Each fixture is an in-memory TypeScript source snippet plus the + * expected list of (tag, class) pairs the scanner should emit. + * + * Run with: node packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs + */ + +const { findViolations } = require('./check-jsdoc-hygiene-ts.cjs'); + +const FIXTURES = [ + // ─── Negative control ──────────────────────────────────────────── + // Clean public method with TS-only types and prose-only JSDoc. + // Asserts the scanner returns ZERO hits — catches the "silent + // false-negative" failure mode where the scanner produces nothing + // and looks like it's working. + { + name: 'negative-control: prose-only JSDoc + TS types', + src: ` +/** + * Does the thing. + * @param name The thing to do. + * @returns The result of doing it. + */ +export function doThing(name: string): boolean { + return true; +} +`, + expected: [], + }, + + // ─── Mixed-tag block ───────────────────────────────────────────── + // A single JSDoc comment that mixes legitimate documentation tags + // (@deprecated) with one type-bearing tag (@param {string}). Must + // report exactly one violation, not three, not zero. Catches the + // "detector iterates all tags and mis-keys" bug. + { + name: 'mixed-tag block: only the typed @param is flagged', + src: ` +/** + * Mounts the thing. + * @deprecated Use newThing instead. + * @param {string} foo The input. + * @returns The result. + */ +export function mount(foo: string): string { + return foo; +} +`, + expected: [{ tag: 'param', class: 'declaration-doc-type' }], + }, + + // ─── @param with type braces ───────────────────────────────────── + { + name: 'typed @param flagged', + src: ` +/** @param {Element} el */ +export function render(el: HTMLElement): void {} +`, + expected: [{ tag: 'param', class: 'declaration-doc-type' }], + }, + + // ─── @returns with type braces ─────────────────────────────────── + { + name: 'typed @returns flagged', + src: ` +/** @returns {boolean} */ +export function ready(): boolean { return true; } +`, + expected: [{ tag: 'returns', class: 'declaration-doc-type' }], + }, + + // ─── @param prose-only ─────────────────────────────────────────── + { + name: 'prose-only @param not flagged', + src: ` +/** @param el The element. */ +export function render(el: HTMLElement): void {} +`, + expected: [], + }, + + // ─── @type inline ──────────────────────────────────────────────── + { + name: 'inline @type cast flagged', + src: ` +export function pick(): unknown { + const v = {} as unknown; + /** @type {Element} */ + const e = document.body; + return e; +} +`, + expected: [{ tag: 'type', class: 'inline-fake-cast' }], + }, + + // ─── @typedef ──────────────────────────────────────────────────── + { + name: '@typedef always flagged', + src: ` +/** + * @typedef {Object} Options + * @property {string} name + */ +export const x = 1; +`, + // Each tag is reported separately. @typedef + @property both fire. + expected: [{ tag: 'typedef', class: 'typedef-style' }], + }, + + // ─── @callback ─────────────────────────────────────────────────── + { + name: '@callback always flagged', + src: ` +/** + * @callback Listener + * @param {string} event + * @returns {void} + */ +export const x = 1; +`, + expected: [{ tag: 'callback', class: 'typedef-style' }], + }, + + // ─── @template ─────────────────────────────────────────────────── + { + name: '@template always flagged in .ts', + src: ` +/** + * @template T + */ +export function identity(value: T): T { return value; } +`, + expected: [{ tag: 'template', class: 'declaration-doc-type' }], + }, + + // ─── @typeParam not flagged ────────────────────────────────────── + // TSDoc-canonical alternative to @template; pure prose form. + { + name: '@typeParam not flagged (TSDoc-canonical)', + src: ` +/** + * @typeParam T - The type of the value being returned. + */ +export function identity(value: T): T { return value; } +`, + expected: [], + }, + + // ─── @deprecated alone ─────────────────────────────────────────── + { + name: '@deprecated not flagged', + src: ` +/** @deprecated Use newThing instead. */ +export function oldThing(): void {} +`, + expected: [], + }, + + // ─── @see / @example / @throws not flagged ────────────────────── + { + name: 'doc-only tags not flagged', + src: ` +/** + * Does the thing. + * + * @see {@link OtherThing} + * @example + * doThing(); + * @throws when X is invalid. + */ +export function doThing(): void {} +`, + expected: [], + }, + + // ─── @this typed ──────────────────────────────────────────────── + // `@this` canonically takes a type expression; the TS JSDoc parser + // treats `@this ` as a typed form, so practical usage is + // always flagged. In `.ts`, the right pattern is the parameter + // `this: Foo` in the function signature. + { + name: 'typed @this flagged', + src: ` +/** @this {Foo} */ +export function a(): void {} +`, + expected: [{ tag: 'this', class: 'declaration-doc-type' }], + }, +]; + +function run() { + let passed = 0; + let failed = 0; + for (const fx of FIXTURES) { + const got = findViolations('fixture.ts', fx.src).map((v) => ({ tag: v.tag, class: v.class })); + const gotKey = JSON.stringify(got); + const expKey = JSON.stringify(fx.expected); + if (gotKey === expKey) { + passed++; + console.log(` PASS ${fx.name}`); + } else { + failed++; + console.log(` FAIL ${fx.name}`); + console.log(` expected: ${expKey}`); + console.log(` got: ${gotKey}`); + } + } + console.log(''); + console.log(`${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +run(); diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json new file mode 100644 index 0000000000..0d2cfdffa3 --- /dev/null +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -0,0 +1,90 @@ +{ + "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file:line:tag\"; the gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", + "knownViolations": [ + "packages/super-editor/src/editors/v1/core/Editor.ts:699:template", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:26:template", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:35:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:47:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:81:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:97:returns", + "packages/super-editor/src/editors/v1/core/Extension.ts:27:template", + "packages/super-editor/src/editors/v1/core/Extension.ts:28:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:10:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:11:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:42:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:43:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:44:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:9:template", + "packages/super-editor/src/editors/v1/core/Node.ts:192:template", + "packages/super-editor/src/editors/v1/core/Node.ts:193:template", + "packages/super-editor/src/editors/v1/core/Node.ts:194:template", + "packages/super-editor/src/editors/v1/core/Node.ts:72:template", + "packages/super-editor/src/editors/v1/core/Node.ts:73:template", + "packages/super-editor/src/editors/v1/core/Node.ts:74:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:24:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:25:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:26:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:6:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:7:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:8:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:36:typedef", + "packages/super-editor/src/editors/v1/core/defineMark.ts:53:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:54:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:55:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:35:typedef", + "packages/super-editor/src/editors/v1/core/defineNode.ts:52:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:53:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:54:template", + "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts:34:template", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:4940:returns", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:6918:returns", + "packages/superdoc/src/core/EventEmitter.ts:25:template", + "packages/superdoc/src/core/EventEmitter.ts:34:returns", + "packages/superdoc/src/core/EventEmitter.ts:49:returns", + "packages/superdoc/src/core/EventEmitter.ts:64:returns", + "packages/superdoc/src/core/EventEmitter.ts:80:returns", + "packages/superdoc/src/core/SuperDoc.ts:1033:returns", + "packages/superdoc/src/core/SuperDoc.ts:1114:param", + "packages/superdoc/src/core/SuperDoc.ts:1330:param", + "packages/superdoc/src/core/SuperDoc.ts:1362:returns", + "packages/superdoc/src/core/SuperDoc.ts:1386:param", + "packages/superdoc/src/core/SuperDoc.ts:1398:param", + "packages/superdoc/src/core/SuperDoc.ts:1458:param", + "packages/superdoc/src/core/SuperDoc.ts:1466:param", + "packages/superdoc/src/core/SuperDoc.ts:1488:param", + "packages/superdoc/src/core/SuperDoc.ts:1495:param", + "packages/superdoc/src/core/SuperDoc.ts:1626:param", + "packages/superdoc/src/core/SuperDoc.ts:1649:param", + "packages/superdoc/src/core/SuperDoc.ts:1650:param", + "packages/superdoc/src/core/SuperDoc.ts:1651:returns", + "packages/superdoc/src/core/SuperDoc.ts:1678:returns", + "packages/superdoc/src/core/SuperDoc.ts:1695:param", + "packages/superdoc/src/core/SuperDoc.ts:1696:returns", + "packages/superdoc/src/core/SuperDoc.ts:175:extends", + "packages/superdoc/src/core/SuperDoc.ts:176:implements", + "packages/superdoc/src/core/SuperDoc.ts:1808:param", + "packages/superdoc/src/core/SuperDoc.ts:1809:param", + "packages/superdoc/src/core/SuperDoc.ts:1827:param", + "packages/superdoc/src/core/SuperDoc.ts:185:type", + "packages/superdoc/src/core/SuperDoc.ts:1947:param", + "packages/superdoc/src/core/SuperDoc.ts:1948:returns", + "packages/superdoc/src/core/SuperDoc.ts:1961:param", + "packages/superdoc/src/core/SuperDoc.ts:1962:returns", + "packages/superdoc/src/core/SuperDoc.ts:1970:returns", + "packages/superdoc/src/core/SuperDoc.ts:1982:param", + "packages/superdoc/src/core/SuperDoc.ts:2022:returns", + "packages/superdoc/src/core/SuperDoc.ts:2039:param", + "packages/superdoc/src/core/SuperDoc.ts:2040:param", + "packages/superdoc/src/core/SuperDoc.ts:2052:param", + "packages/superdoc/src/core/SuperDoc.ts:2102:param", + "packages/superdoc/src/core/SuperDoc.ts:2192:returns", + "packages/superdoc/src/core/SuperDoc.ts:2205:type", + "packages/superdoc/src/core/SuperDoc.ts:2276:template", + "packages/superdoc/src/core/SuperDoc.ts:320:type", + "packages/superdoc/src/core/SuperDoc.ts:323:type", + "packages/superdoc/src/core/SuperDoc.ts:633:returns", + "packages/superdoc/src/core/SuperDoc.ts:700:param", + "packages/superdoc/src/core/SuperDoc.ts:860:returns", + "packages/superdoc/src/core/types/index.ts:1763:type" + ] +} diff --git a/packages/superdoc/scripts/type-hygiene.md b/packages/superdoc/scripts/type-hygiene.md new file mode 100644 index 0000000000..30e4dca7ac --- /dev/null +++ b/packages/superdoc/scripts/type-hygiene.md @@ -0,0 +1,181 @@ +# TypeScript-side JSDoc hygiene + +## Rule + +In `.ts` source under `packages/superdoc/src/` and +`packages/super-editor/src/`, do not use type-bearing JSDoc tags. +TypeScript syntax is the only source of truth for shape on the public +contract surface; JSDoc is reserved for prose documentation. + +## What this rule covers + +The scanner (`check-jsdoc-hygiene-ts.cjs`) flags two patterns in `.ts` +files: + +### 1. Type-bearing tags that always violate + +These tags exist only to declare types in JSDoc form. In `.ts` files +there is always a native TypeScript construct that is more accurate +and self-checking: + +| Tag | Use in TS instead | +|--------------|----------------------------------------------------| +| `@type` | `as Type`, or just let inference work | +| `@typedef` | `type X = ...` or `interface X { ... }` | +| `@callback` | `type X = (...) => ...` | +| `@template` | Native generic syntax: `function foo(...)`. For documentation, use `@typeParam T - description` | +| `@implements`| `class Foo implements Bar` | +| `@extends` | `class Foo extends Bar` / `interface Foo extends Bar` | +| `@augments` | Same as `@extends` | +| `@enum` | `enum X { ... }` | + +### 2. Tags that violate only when carrying a `{Type}` brace + +These tags are legitimate as prose documentation. They are flagged +only when they duplicate a TypeScript-expressible type: + +| Tag | OK | Flagged | +|--------------|-------------------------------|----------------------------------------| +| `@param` | `@param name description` | `@param {string} name description` | +| `@returns` | `@returns description` | `@returns {string} description` | +| `@return` | `@return description` | `@return {string} description` | +| `@this` | `@this description` | `@this {Foo} description` | + +## Why + +Type-bearing JSDoc in `.ts` files is documentation-only — the TS +compiler ignores it. That means: + +- The annotation can drift from the actual TS signature without any + build error. The recent `addCommentsList` fix is one example: + `@param {Element}` while the signature was `HTMLElement`. +- Inline `/** @type {Foo} */ value` looks like a cast but is a no-op. + If a real cast was intended, use `value as Foo`. If the JSDoc is + redundant commentary, delete it. +- Two sources of truth for the same shape diverge over time, and the + one TypeScript doesn't enforce becomes wrong silently. + +JSDoc is still useful in `.ts` for prose documentation: function +descriptions, `@deprecated`, `@example`, `@throws`, `@see`, +`@typeParam` (the TSDoc-canonical alternative to `@template`), etc. +The scanner does not flag these. + +## What still belongs in JSDoc + +- Prose explaining what the function does, behavioral contracts, + side-effect notes, and `@deprecated` / `@example` / `@throws` / + `@see` tags. +- Parameter and return descriptions, as long as they do not carry + `{Type}` braces: `@param name description` is fine. +- `@typeParam T - description` for documenting generic parameters + (TSDoc-canonical, prose-only). + +## How to fix violations + +### `@param {Type} name description` → `@param name description` + +```ts +// Before +/** + * @param {HTMLElement} element The DOM element to mount into. + */ +addCommentsList(element: HTMLElement) { ... } + +// After +/** + * @param element The DOM element to mount into. + */ +addCommentsList(element: HTMLElement) { ... } +``` + +The signature carries the type; the prose stays useful. + +### `@returns {Type} description` → `@returns description` + +Same pattern. Drop the `{Type}` brace, keep the prose. + +### Inline `/** @type {Foo} */ value` + +Triage each occurrence: + +- If a cast was genuinely intended (e.g. the TS inference is wider + than the runtime guarantee), replace with `value as Foo`. +- If the annotation was redundant commentary, delete it. + +In `.ts` files the inline `@type` cast is a no-op — TS ignores it. So +either fix the type system properly with `as`, or remove the misleading +comment. + +### `@typedef`, `@callback`, `@enum` + +Convert to a native TypeScript declaration: + +```ts +// Before +/** + * @typedef {Object} Options + * @property {string} name + * @property {boolean} [verbose] + */ + +// After +interface Options { + name: string; + verbose?: boolean; +} +``` + +Export the type if it is part of the public surface. + +### `@template T` + +In `.ts` files, generics belong in the signature: + +```ts +// Before +/** + * @template T + * @param {T} value + */ +function identity(value) { return value; } + +// After +/** + * @typeParam T - The type of the value being returned. + */ +function identity(value: T): T { return value; } +``` + +If you want to document the type parameter, use TSDoc's `@typeParam`, +not `@template`. + +## Scope + +The scanner runs on `.ts` files under `packages/superdoc/src/` and +`packages/super-editor/src/`. Excludes: + +- `*.d.ts` (declaration files, generated) +- `*.test.ts` / `*.spec.ts` (test files; type-bearing JSDoc is fine + for test fixtures) +- `dev/`, `__mocks__/`, `__fixtures__/` directories + +The rule **does not** apply to `.js` files. Those use JSDoc as their +type system via `// @ts-check`; that is enforced separately by +`check-jsdoc.cjs`. Both gates can coexist: TS files use TS syntax for +types, JS files use JSDoc for types, neither uses both. + +## Refreshing the baseline + +The scanner snapshot lives at +`packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json`. It records +the existing violations the gate grandfathers. New violations on top +of the baseline fail CI. + +To refresh after intentional cleanup: + +```sh +node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write +``` + +The goal is to drain the baseline to zero, then flip the gate to +"zero allowed" (separate PR). diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 5737e226ed..90da6ee1e6 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -135,6 +135,19 @@ const stages = [ 'fails when new public-reachable JSDoc files land without // @ts-check. ' + 'Cheap; runs before the slow build so JSDoc drift fails fast.', }, + { + name: 'jsdoc-hygiene-ts', + cwd: REPO_ROOT, + cmd: 'node', + args: ['packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs'], + blurb: + 'Type-bearing JSDoc gate for .ts source under packages/superdoc/src and ' + + 'packages/super-editor/src. Grandfathers an existing baseline of violations ' + + 'and fails on net-new type-bearing JSDoc tags (@param {T}, @returns {T}, ' + + '@type, @typedef, @template, etc.). See packages/superdoc/scripts/' + + 'type-hygiene.md for the rule. Cheap; complements jsdoc-ratchet (which ' + + 'covers .js files) by enforcing TS-as-single-source on the .ts side.', + }, { name: 'public-method-coverage', cwd: REPO_ROOT, From 1ed4e1db8452c26e250611238999abe0324489b1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 13:53:15 -0300 Subject: [PATCH 004/280] fix(scripts): make jsdoc-hygiene-ts baseline key line-independent + update wrapper docs Three follow-ups to the initial scanner commit, all addressing review feedback: Baseline key was file:line:tag, which churned under unrelated edits. Inserting 3 blank lines at the top of SuperDoc.ts (verified empirically) would have produced one stale + one new baseline entry per grandfathered violation in that file - ~40 false-positive ratchet failures from a no-op edit. New key shape: file::enclosingSymbol::tagName::class::occurrenceIndex Line stays in the violation record for human-readable display in failure messages, but is NOT part of the identity tuple. Verified that inserting 3 blank lines at the top of SuperDoc.ts now produces zero stale/new churn (85 still grandfathered). enclosingSymbol is the nearest named-declaration ancestor (function, method, class, interface, type alias, variable). occurrenceIndex disambiguates multiple same-name tags on the same symbol (e.g. two @param tags on the same method). Wrapper docs updated to reflect the 11-stage check:public:superdoc: - AGENTS.md (CLAUDE.md symlinks to it): stage list + count. - scripts/check-public-contract.mjs header: inserted stage 4 (jsdoc-hygiene-ts), renumbered 5-11. @typedef test fixture comment corrected: only @typedef fires (ts.getJSDocTags does not surface @property as a top-level tag); the parent @typedef is the violation and the cleanup fix takes its @property lines with it. Verified: 13/13 scanner tests pass; check:public:superdoc --skip-build PASS (10 ran with --skip-build, 1 skipped, 126.6s); line-shift canary on SuperDoc.ts produces no baseline churn. --- AGENTS.md | 2 +- .../scripts/check-jsdoc-hygiene-ts.cjs | 88 +++++++-- .../scripts/check-jsdoc-hygiene-ts.test.cjs | 7 +- .../scripts/jsdoc-hygiene-ts-baseline.json | 172 +++++++++--------- scripts/check-public-contract.mjs | 34 ++-- 5 files changed, 189 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db586c013..50ead1ba8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. - `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. -- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps ten stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. +- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. - `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs. diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs index 6a4876a85d..4d478daf8c 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -114,34 +114,82 @@ function listTsFiles(rootRel) { // ─── AST walk ───────────────────────────────────────────────────────── +/** + * Walk up from a node to find the nearest named declaration ancestor. + * Used to anchor each violation to a stable enclosing-symbol name so + * the baseline key survives line shifts caused by edits elsewhere in + * the file. + * + * Returns '' when no named ancestor is found (e.g. inline + * `/** @type *​/` cast inside an anonymous expression at module scope). + */ +function enclosingSymbolName(node) { + let n = node; + while (n) { + // Direct .name on the node (FunctionDeclaration, MethodDeclaration, + // ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, + // PropertyDeclaration / PropertySignature, GetAccessorDeclaration, + // EnumDeclaration, etc.). + if (n.name && ts.isIdentifier(n.name)) { + return String(n.name.escapedText); + } + // VariableStatement -> VariableDeclarationList -> VariableDeclaration[]. + // Use the first declared name as the anchor. + if (ts.isVariableStatement(n) && n.declarationList && n.declarationList.declarations.length > 0) { + const d = n.declarationList.declarations[0]; + if (d.name && ts.isIdentifier(d.name)) return String(d.name.escapedText); + } + if (ts.isVariableDeclaration(n) && n.name && ts.isIdentifier(n.name)) { + return String(n.name.escapedText); + } + n = n.parent; + } + return ''; +} + /** * Walk a source file's AST and emit one violation per type-bearing - * JSDoc tag. De-duped at the end by {file, pos, end}. + * JSDoc tag. + * + * Each violation's identity is the stable tuple + * `{file, symbol, tag, occurrenceIndex}`. `line` is captured for + * human-readable display only; it is NOT part of the baseline key + * because line numbers shift under unrelated edits and would produce + * spurious stale-plus-new pairs on the ratchet. */ function findViolations(filePath, sourceText) { const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true); const findings = []; - const seen = new Set(); + const seenPositions = new Set(); + const occurrenceCounter = new Map(); function visit(node) { - // ts.getJSDocTags returns the JSDoc tags attached to this node. - // We don't need to traverse the JSDoc comment itself — getting the - // tags from each declaration is enough. const tags = ts.getJSDocTags(node); if (tags && tags.length > 0) { + const symbol = enclosingSymbolName(node); for (const tag of tags) { const name = tag.tagName && tag.tagName.escapedText ? String(tag.tagName.escapedText) : ''; const violation = classifyTag(name, tag); if (!violation) continue; - const key = `${tag.pos}:${tag.end}:${name}`; - if (seen.has(key)) continue; - seen.add(key); + // Position de-dupe so the same tag surfaced via multiple parent + // walks counts once. + const positionKey = `${tag.pos}:${tag.end}:${name}`; + if (seenPositions.has(positionKey)) continue; + seenPositions.add(positionKey); + // Occurrence index within (symbol, tag) so multiple `@param`s + // on the same method are distinguished without depending on + // line numbers. + const counterKey = `${symbol}::${name}`; + const occurrenceIndex = occurrenceCounter.get(counterKey) || 0; + occurrenceCounter.set(counterKey, occurrenceIndex + 1); const { line } = sf.getLineAndCharacterOfPosition(tag.pos); findings.push({ file: filePath, line: line + 1, tag: name, class: violation, + symbol, + occurrenceIndex, }); } } @@ -149,7 +197,12 @@ function findViolations(filePath, sourceText) { } visit(sf); - findings.sort((a, b) => (a.line - b.line) || a.tag.localeCompare(b.tag)); + findings.sort( + (a, b) => + a.symbol.localeCompare(b.symbol) || + a.tag.localeCompare(b.tag) || + a.occurrenceIndex - b.occurrenceIndex, + ); return findings; } @@ -179,16 +232,23 @@ function loadBaseline() { } function violationKey(v) { - return `${v.file}:${v.line}:${v.tag}`; + // Stable across line shifts and re-orderings of unrelated code. + // `line` is intentionally excluded: editing above a JSDoc block + // would otherwise produce one stale + one new entry for an + // unchanged violation, and the ratchet would be noisy. + return `${v.file}::${v.symbol}::${v.tag}::${v.class}::${v.occurrenceIndex}`; } function writeBaseline(violations) { const data = { $comment: 'Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. ' + - 'Each entry is "file:line:tag"; the gate grandfathers these and fails on net-new violations. ' + - 'Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to ' + - '"zero allowed" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.', + 'Each entry is "file::enclosingSymbol::tagName::class::occurrenceIndex"; ' + + 'line numbers are NOT part of the key, so the baseline survives unrelated ' + + 'edits that shift line numbers. The gate grandfathers these and fails on ' + + 'net-new violations. Refresh after intentional cleanup with --write. ' + + 'Goal is to drain to zero, then flip to "zero allowed" in a separate PR. ' + + 'See packages/superdoc/scripts/type-hygiene.md.', knownViolations: violations.map(violationKey).sort(), }; fs.writeFileSync(BASELINE_PATH, JSON.stringify(data, null, 2) + '\n'); @@ -242,7 +302,7 @@ function main() { if (newViolations.length > 0) { console.log(`FAIL ${newViolations.length} new type-bearing JSDoc tag(s) in .ts source:`); for (const v of newViolations.slice(0, 30)) { - console.log(` - ${v.file}:${v.line} @${v.tag} [${v.class}]`); + console.log(` - ${v.file}:${v.line} @${v.tag} on \`${v.symbol}\` [${v.class}]`); } if (newViolations.length > 30) { console.log(` ... and ${newViolations.length - 30} more.`); diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs index 7855617fb2..d99118fdaa 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs @@ -115,7 +115,12 @@ export function pick(): unknown { */ export const x = 1; `, - // Each tag is reported separately. @typedef + @property both fire. + // Only @typedef fires; nested @property tags inside the typedef + // are not surfaced as top-level tags by ts.getJSDocTags. That's + // intentional for the detector — the parent @typedef IS the + // violation, and the cleanup fix (convert to native interface) + // takes the @property lines with it. Field-level reporting is + // reviewer ergonomics, not detector correctness. expected: [{ tag: 'typedef', class: 'typedef-style' }], }, diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json index 0d2cfdffa3..c95c481356 100644 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -1,90 +1,90 @@ { - "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file:line:tag\"; the gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", + "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file::enclosingSymbol::tagName::class::occurrenceIndex\"; line numbers are NOT part of the key, so the baseline survives unrelated edits that shift line numbers. The gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", "knownViolations": [ - "packages/super-editor/src/editors/v1/core/Editor.ts:699:template", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:26:template", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:35:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:47:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:81:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:97:returns", - "packages/super-editor/src/editors/v1/core/Extension.ts:27:template", - "packages/super-editor/src/editors/v1/core/Extension.ts:28:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:10:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:11:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:42:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:43:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:44:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:9:template", - "packages/super-editor/src/editors/v1/core/Node.ts:192:template", - "packages/super-editor/src/editors/v1/core/Node.ts:193:template", - "packages/super-editor/src/editors/v1/core/Node.ts:194:template", - "packages/super-editor/src/editors/v1/core/Node.ts:72:template", - "packages/super-editor/src/editors/v1/core/Node.ts:73:template", - "packages/super-editor/src/editors/v1/core/Node.ts:74:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:24:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:25:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:26:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:6:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:7:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:8:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:36:typedef", - "packages/super-editor/src/editors/v1/core/defineMark.ts:53:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:54:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:55:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:35:typedef", - "packages/super-editor/src/editors/v1/core/defineNode.ts:52:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:53:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:54:template", - "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts:34:template", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:4940:returns", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:6918:returns", - "packages/superdoc/src/core/EventEmitter.ts:25:template", - "packages/superdoc/src/core/EventEmitter.ts:34:returns", - "packages/superdoc/src/core/EventEmitter.ts:49:returns", - "packages/superdoc/src/core/EventEmitter.ts:64:returns", - "packages/superdoc/src/core/EventEmitter.ts:80:returns", - "packages/superdoc/src/core/SuperDoc.ts:1033:returns", - "packages/superdoc/src/core/SuperDoc.ts:1114:param", - "packages/superdoc/src/core/SuperDoc.ts:1330:param", - "packages/superdoc/src/core/SuperDoc.ts:1362:returns", - "packages/superdoc/src/core/SuperDoc.ts:1386:param", - "packages/superdoc/src/core/SuperDoc.ts:1398:param", - "packages/superdoc/src/core/SuperDoc.ts:1458:param", - "packages/superdoc/src/core/SuperDoc.ts:1466:param", - "packages/superdoc/src/core/SuperDoc.ts:1488:param", - "packages/superdoc/src/core/SuperDoc.ts:1495:param", - "packages/superdoc/src/core/SuperDoc.ts:1626:param", - "packages/superdoc/src/core/SuperDoc.ts:1649:param", - "packages/superdoc/src/core/SuperDoc.ts:1650:param", - "packages/superdoc/src/core/SuperDoc.ts:1651:returns", - "packages/superdoc/src/core/SuperDoc.ts:1678:returns", - "packages/superdoc/src/core/SuperDoc.ts:1695:param", - "packages/superdoc/src/core/SuperDoc.ts:1696:returns", - "packages/superdoc/src/core/SuperDoc.ts:175:extends", - "packages/superdoc/src/core/SuperDoc.ts:176:implements", - "packages/superdoc/src/core/SuperDoc.ts:1808:param", - "packages/superdoc/src/core/SuperDoc.ts:1809:param", - "packages/superdoc/src/core/SuperDoc.ts:1827:param", - "packages/superdoc/src/core/SuperDoc.ts:185:type", - "packages/superdoc/src/core/SuperDoc.ts:1947:param", - "packages/superdoc/src/core/SuperDoc.ts:1948:returns", - "packages/superdoc/src/core/SuperDoc.ts:1961:param", - "packages/superdoc/src/core/SuperDoc.ts:1962:returns", - "packages/superdoc/src/core/SuperDoc.ts:1970:returns", - "packages/superdoc/src/core/SuperDoc.ts:1982:param", - "packages/superdoc/src/core/SuperDoc.ts:2022:returns", - "packages/superdoc/src/core/SuperDoc.ts:2039:param", - "packages/superdoc/src/core/SuperDoc.ts:2040:param", - "packages/superdoc/src/core/SuperDoc.ts:2052:param", - "packages/superdoc/src/core/SuperDoc.ts:2102:param", - "packages/superdoc/src/core/SuperDoc.ts:2192:returns", - "packages/superdoc/src/core/SuperDoc.ts:2205:type", - "packages/superdoc/src/core/SuperDoc.ts:2276:template", - "packages/superdoc/src/core/SuperDoc.ts:320:type", - "packages/superdoc/src/core/SuperDoc.ts:323:type", - "packages/superdoc/src/core/SuperDoc.ts:633:returns", - "packages/superdoc/src/core/SuperDoc.ts:700:param", - "packages/superdoc/src/core/SuperDoc.ts:860:returns", - "packages/superdoc/src/core/types/index.ts:1763:type" + "packages/super-editor/src/editors/v1/core/Editor.ts::Editor::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::off::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::on::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::once::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/defineMark.ts::::typedef::typedef-style::0", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/defineNode.ts::::typedef::typedef-style::0", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts::getExtensionConfigField::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::1", + "packages/superdoc/src/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::off::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::on::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::once::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::extends::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::implements::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::2", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::3", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::4", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::5", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::2", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::addCommentsList::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::addSharedUser::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorBeforeCreate::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorCreate::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::event::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::export::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::exportEditorsToDOCX::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::getHTML::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::getZoom::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::navigateTo::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::openSurface::template::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::pendingCollaborationSaves::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::readyEditors::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::removeSharedUser::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::requiredNumberOfEditors::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::search::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::search::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setActiveEditor::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setTrackedChangesPreferences::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setZoom::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::upgradeToCollaboration::returns::declaration-doc-type::0", + "packages/superdoc/src/core/types/index.ts::InternalConfig::type::inline-fake-cast::0" ] } diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 90da6ee1e6..c5c4492ed9 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,7 +29,17 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. public-method-coverage - obligation-based ratchet over + * 4. jsdoc-hygiene-ts - type-bearing JSDoc gate for .ts + * source under packages/superdoc/src + * and packages/super-editor/src. + * Companion to jsdoc-ratchet on the + * .ts side: enforces TS syntax as + * the single source of truth for + * shape. Grandfathers an existing + * baseline; fails on net-new + * type-bearing JSDoc. See + * packages/superdoc/scripts/type-hygiene.md. + * 5. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + * getters. For each member the * AST computes which obligations @@ -42,7 +52,7 @@ * on their own — that's why * `search(text: string)` shipped * under v1 of this gate. - * 5. build - vite build + the postbuild + * 6. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -53,29 +63,29 @@ * Skipped when `--skip-build` is * passed (CI calls `pnpm run build` * separately in its own step). - * 6. consumer-typecheck-matrix - packs superdoc + installs the + * 7. consumer-typecheck-matrix - packs superdoc + installs the * tarball into * tests/consumer-typecheck/ * node_modules/, then runs every * consumer scenario. - * 7. deep-type-audit-supported-root - strict gate on the supported- + * 8. deep-type-audit-supported-root - strict gate on the supported- * root public surface; fails on any * `any` leak. Reuses the install - * from stage 6. - * 8. package-shape - publint + attw against the packed + * from stage 7. + * 9. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 6. - * 9. export-snapshots - super-editor / legacy / root + * from stage 7. + * 10. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 10. root-classification-closure - no supported-root or legacy-root + * 11. root-classification-closure - no supported-root or legacy-root * export references an internal- * candidate type in its public * declared shape (SD-3212 A1b). * - * Why stage 6 runs before 7-10: stage 6 packs `superdoc.tgz` and - * installs the tarball into the consumer fixture once. Stages 7, 9, - * and 10 reuse the installed fixture; stage 8 reuses the packed tarball + * Why stage 7 runs before 8-11: stage 7 packs `superdoc.tgz` and + * installs the tarball into the consumer fixture once. Stages 8, 10, + * and 11 reuse the installed fixture; stage 9 reuses the packed tarball * directly. Without this ordering each downstream stage would `--pack` * separately and multiply the work. * From 187fe18a03a08716a44f1d505c85cccccb8f41c5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 20:42:51 -0700 Subject: [PATCH 005/280] feat: overlapping tracked changes --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- .../reference/track-changes/decide.mdx | 21 +- apps/docs/document-engine/sdks.mdx | 4 +- .../src/contract/operation-definitions.ts | 6 +- packages/document-api/src/index.test.ts | 55 +- packages/document-api/src/index.ts | 2 + .../src/track-changes/track-changes.test.ts | 39 + .../src/track-changes/track-changes.ts | 109 ++- packages/document-api/src/types/receipt.ts | 2 + .../src/editors/v1/core/Editor.ts | 86 ++ .../v1/core/super-converter/SuperConverter.js | 12 + .../v2/exporter/footnotesExporter.js | 1 + .../v2/importer/docxImporter.js | 2 + .../v2/importer/importTrackingContext.js | 147 +++ .../v2/importer/markImporter.js | 13 +- .../v2/importer/markImporter.test.js | 35 + .../super-converter/v3/handlers/helpers.js | 17 +- .../v3/handlers/w/del/del-translator.js | 77 +- .../v3/handlers/w/del/del-translator.test.js | 33 + .../v3/handlers/w/ins/ins-translator.js | 77 +- .../v3/handlers/w/ins/ins-translator.test.js | 84 ++ .../v3/handlers/w/r/r-translator.js | 5 +- .../assemble-adapters.ts | 2 + ...xtract-adapter.consumer-simulation.test.ts | 75 +- .../document-api-adapters/extract-adapter.ts | 8 +- .../helpers/tracked-change-refs.ts | 9 +- .../helpers/tracked-change-resolver.test.ts | 74 +- .../helpers/tracked-change-resolver.ts | 86 +- .../track-changes-wrappers.test.ts | 187 +++- .../plan-engine/track-changes-wrappers.ts | 150 ++- .../__tests__/tracked-change-index.test.ts | 29 + .../tracked-changes/tracked-change-index.ts | 4 +- .../write-adapter.test.ts | 32 + .../v1/document-api-adapters/write-adapter.ts | 9 + .../track-changes/permission-helpers.js | 31 +- .../track-changes/permission-helpers.test.js | 64 +- .../review-model/comment-effects.js | 149 +++ .../review-model/comment-effects.test.js | 73 ++ .../review-model/decision-engine.js | 893 ++++++++++++++++++ .../review-model/decision-engine.test.js | 604 ++++++++++++ .../track-changes/review-model/edit-intent.js | 225 +++++ .../review-model/graph-invariants.js | 51 + .../track-changes/review-model/identity.js | 130 +++ .../review-model/identity.test.js | 102 ++ .../review-model/import-context.js | 185 ++++ .../review-model/import-context.test.js | 101 ++ .../import-diagnostics-integration.test.js | 58 ++ .../review-model/import-diagnostics.js | 298 ++++++ .../review-model/import-diagnostics.test.js | 174 ++++ .../track-changes/review-model/index.js | 64 ++ .../review-model/mark-metadata.js | 373 ++++++++ .../review-model/mark-metadata.test.js | 149 +++ .../review-model/overlap-compiler.js | 881 +++++++++++++++++ .../review-model/overlap-compiler.test.js | 642 +++++++++++++ .../review-model/review-graph.js | 770 +++++++++++++++ .../review-model/review-graph.perf.test.js | 60 ++ .../review-model/review-graph.test.js | 403 ++++++++ .../review-model/segment-index.js | 58 ++ .../review-model/story-locator.js | 69 ++ .../review-model/test-fixtures.js | 127 +++ .../review-model/word-id-allocator.js | 146 +++ .../review-model/word-id-allocator.test.js | 123 +++ .../track-changes-overlap-decisions.test.js | 107 +++ .../track-changes-overlap-history.test.js | 152 +++ .../track-changes-overlap.test.js | 217 +++++ .../extensions/track-changes/track-changes.js | 362 ++++--- .../extensions/track-changes/track-delete.js | 43 + .../extensions/track-changes/track-format.js | 43 + .../extensions/track-changes/track-insert.js | 50 + .../trackChangesHelpers/addMarkStep.js | 42 + .../trackChangesHelpers/getTrackChanges.js | 1 + .../trackChangesHelpers/markDeletion.js | 9 +- .../trackChangesHelpers/removeMarkStep.js | 36 + .../trackChangesHelpers/replaceAroundStep.js | 3 + .../trackChangesHelpers/replaceStep.js | 119 +++ .../trackChangesHelpers/trackedTransaction.js | 1 + .../trackChangesRoundtrip-overlap.test.js | 328 +++++++ 78 files changed, 9746 insertions(+), 266 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 4efc168b52..f14ad2c32b 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "266b6bb8fa042ebd94c3da6e11652471f49c40a061960f3df9055cf64c4bcbbe" + "sourceHash": "8c6adfd1bbb1226a5847d6a791d7e07cfe994fffe9855fe845de5e196f1f3498" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 49a58b3833..7cbeee530f 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -243,7 +243,7 @@ The tables below are grouped by namespace. | --- | --- | --- | | trackChanges.list | editor.doc.trackChanges.list(...) | List all tracked changes in the document. | | trackChanges.get | editor.doc.trackChanges.get(...) | Retrieve a single tracked change by ID. | -| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject a tracked change (by ID or scope: all). | +| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject tracked changes by ID, range, or scope: all. | #### Query diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index cfd98e37fb..98764f09be 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -1,14 +1,14 @@ --- title: trackChanges.decide sidebarTitle: trackChanges.decide -description: "Accept or reject a tracked change (by ID or scope: all)." +description: "Accept or reject tracked changes by ID, range, or scope: all." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Accept or reject a tracked change (by ID or scope: all). +Accept or reject tracked changes by ID, range, or scope: all. - Operation ID: `trackChanges.decide` - API member path: `editor.doc.trackChanges.decide(...)` @@ -20,7 +20,7 @@ Accept or reject a tracked change (by ID or scope: all). ## Expected result -Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved. +Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved and typed failures for unsupported or denied tracked-change decisions. ## Input fields @@ -60,7 +60,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"` | +| `failure.code` | enum | yes | `"NO_OP"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"COMMENT_CASCADE_PARTIAL"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -97,6 +97,9 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ## Non-applied failure codes - `NO_OP` +- `CAPABILITY_UNAVAILABLE` +- `PERMISSION_DENIED` +- `COMMENT_CASCADE_PARTIAL` ## Raw schemas @@ -169,7 +172,10 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, @@ -216,7 +222,10 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index c886ed7c82..f94c069e0e 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1006,7 +1006,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.trackChanges.list` | `track-changes list` | List all tracked changes in the document. | | `doc.trackChanges.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | +| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all. | #### History @@ -1484,7 +1484,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.track_changes.list` | `track-changes list` | List all tracked changes in the document. | | `doc.track_changes.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.track_changes.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | +| `doc.track_changes.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all. | #### History diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 7920d9b8d0..5a23fa6520 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2482,15 +2482,15 @@ export const OPERATION_DEFINITIONS = { }, 'trackChanges.decide': { memberPath: 'trackChanges.decide', - description: 'Accept or reject a tracked change (by ID or scope: all).', + description: 'Accept or reject tracked changes by ID, range, or scope: all.', expectedResult: - 'Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved.', + 'Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved and typed failures for unsupported or denied tracked-change decisions.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: false, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], + possibleFailureCodes: ['NO_OP', 'CAPABILITY_UNAVAILABLE', 'PERMISSION_DENIED', 'COMMENT_CASCADE_PARTIAL'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_INPUT', 'INVALID_TARGET'], }), referenceDocPath: 'track-changes/decide.mdx', diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 2e27a3c8df..6afcce9348 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -161,6 +161,7 @@ function makeTrackChangesAdapter(): TrackChangesAdapter { reject: mock((_input) => ({ success: true as const })), acceptAll: mock((_input) => ({ success: true as const })), rejectAll: mock((_input) => ({ success: true as const })), + decideRange: mock((_input) => ({ success: true as const })), }; } @@ -843,6 +844,14 @@ describe('createDocumentApi', () => { const acceptResult = api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } }); const rejectResult = api.trackChanges.decide({ decision: 'reject', target: { id: 'tc-1' } }); api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-2', story: footnoteStory } }); + api.trackChanges.decide({ + decision: 'accept', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + story: footnoteStory, + }, + }); const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } }); const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); @@ -853,10 +862,48 @@ describe('createDocumentApi', () => { expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }, undefined); + expect(trackAdpt.decideRange).toHaveBeenCalledWith( + { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + story: footnoteStory, + }, + undefined, + ); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); + it('returns CAPABILITY_UNAVAILABLE when trackChanges.decide range support is missing', () => { + const trackAdpt = makeTrackChangesAdapter(); + delete trackAdpt.decideRange; + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: trackAdpt, + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const result = api.trackChanges.decide({ + decision: 'reject', + target: { kind: 'range', range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] } }, + }); + + expect(result).toMatchObject({ + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + }, + }); + }); + it('delegates history.get to the history adapter', () => { const historyAdpt = makeHistoryAdapter(); const api = createDocumentApi({ @@ -965,7 +1012,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: 'tc-1' } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -974,7 +1021,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: null } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -983,7 +1030,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { foo: 'bar' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -992,7 +1039,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { id: '' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 5f09bdf468..b35e2084b2 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -1028,7 +1028,9 @@ export type { TrackChangesRejectInput, TrackChangesAcceptAllInput, TrackChangesRejectAllInput, + TrackChangesRangeInput, ReviewDecideInput, + ReviewDecisionTarget, } from './track-changes/track-changes.js'; export type { BlocksAdapter } from './blocks/blocks.js'; export type { ImagesAdapter, ImagesApi, CreateImageAdapter } from './images/images.js'; diff --git a/packages/document-api/src/track-changes/track-changes.test.ts b/packages/document-api/src/track-changes/track-changes.test.ts index b6f9f9ca54..8e1a65f9d6 100644 --- a/packages/document-api/src/track-changes/track-changes.test.ts +++ b/packages/document-api/src/track-changes/track-changes.test.ts @@ -49,4 +49,43 @@ describe('executeTrackChangesDecide validation', () => { it('rejects missing target', () => { expect(() => executeTrackChangesDecide(stubAdapter(), { decision: 'accept' } as any)).toThrow(/target must be/); }); + + it('routes canonical range targets to decideRange', () => { + const adapter = { + ...stubAdapter(), + decideRange: mock(() => ({ success: true })), + }; + + const result = executeTrackChangesDecide(adapter, { + decision: 'accept', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + }); + + expect(result.success).toBe(true); + expect(adapter.decideRange).toHaveBeenCalledWith( + { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + undefined, + ); + }); + + it('fails closed when canonical range targets are not supported by the adapter', () => { + const result = executeTrackChangesDecide(stubAdapter(), { + decision: 'reject', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + }); + + expect(result).toMatchObject({ + success: false, + failure: { code: 'CAPABILITY_UNAVAILABLE' }, + }); + }); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 4dda0c2f5a..facf82203d 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,5 +1,6 @@ import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; import type { StoryLocator } from '../types/story.types.js'; +import type { TextTarget } from '../types/address.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; @@ -27,15 +28,43 @@ export type TrackChangesAcceptAllInput = Record; export type TrackChangesRejectAllInput = Record; +/** + * Range target for partial-range decisions. + * + * `range` is a canonical {@link TextTarget}; the engine resolves the + * selected overlap with each affected logical tracked change and applies + * the {@link https://www.w3.org/TR/selection-api/ selection-style} partial + * resolution rules from the tracked-changes spec § 9. + */ +export interface TrackChangesRangeInput { + range: TextTarget; + /** Story containing the range. Omit for body (backward compatible). */ + story?: StoryLocator; +} + // --------------------------------------------------------------------------- // trackChanges.decide: consolidated accept/reject operation // --------------------------------------------------------------------------- +/** + * Canonical decide input shape per + * `plans/tracked-changes-comments/tracked-changes-spec.md` § 9. The legacy + * `{ id }` and `{ scope: 'all' }` aliases are preserved during the migration + * window so existing headless callers keep working; the executor normalizes + * them into the canonical `{ kind: ... }` form before dispatch. + */ +export type ReviewDecisionTarget = + | { kind: 'id'; id: string; story?: StoryLocator } + | { kind: 'range'; range: TextTarget; story?: StoryLocator; part?: string } + | { kind: 'all'; story?: StoryLocator | 'all' } + // Legacy aliases — kept for backwards compatibility with the previous + // call shape. Emitted as deprecation diagnostics during normalization. + | { id: string; story?: StoryLocator; kind?: undefined } + | { scope: 'all'; kind?: undefined }; + export type ReviewDecideInput = - | { decision: 'accept'; target: { id: string; story?: StoryLocator } } - | { decision: 'reject'; target: { id: string; story?: StoryLocator } } - | { decision: 'accept'; target: { scope: 'all' } } - | { decision: 'reject'; target: { scope: 'all' } }; + | { decision: 'accept'; target: ReviewDecisionTarget } + | { decision: 'reject'; target: ReviewDecisionTarget }; export interface TrackChangesAdapter { /** List tracked changes matching the given query. */ @@ -50,6 +79,17 @@ export interface TrackChangesAdapter { acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt; /** Reject all tracked changes in the document. */ rejectAll(input: TrackChangesRejectAllInput, options?: RevisionGuardOptions): Receipt; + /** + * Accept or reject a tracked-change selection range. Adapters + * that have not been updated to handle `kind: 'range'` may return a + * `CAPABILITY_UNAVAILABLE` failure receipt; the document-api executor + * surfaces that to callers without falling back to the legacy id/all + * paths because their semantics are not equivalent. + */ + decideRange?( + input: { decision: 'accept' | 'reject' } & TrackChangesRangeInput, + options?: RevisionGuardOptions, + ): Receipt; } /** Public surface for trackChanges on DocumentApi. */ @@ -122,31 +162,78 @@ export function executeTrackChangesDecide( if (typeof input.target !== 'object' || input.target == null) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must be an object with { id: string } or { scope: "all" }.', + 'trackChanges.decide target must be an object with { kind: "id" | "range" | "all" }.', { field: 'target', value: input.target }, ); } const target = input.target as Record; - const isAll = target.scope === 'all'; + const story = (target as { story?: StoryLocator }).story; + const decision = input.decision as 'accept' | 'reject'; - if (!isAll) { + // Canonical shape: `{ kind: 'id' | 'range' | 'all' }`. + if (target.kind === 'id') { if (typeof target.id !== 'string' || target.id.length === 0) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must have { id: string } or { scope: "all" }.', + 'trackChanges.decide target.kind = "id" requires a non-empty id.', { field: 'target', value: input.target }, ); } + if (decision === 'accept') return adapter.accept({ id: target.id, ...(story ? { story } : {}) }, options); + return adapter.reject({ id: target.id, ...(story ? { story } : {}) }, options); } - const story = (target as { story?: StoryLocator }).story; + if (target.kind === 'range') { + if (typeof adapter.decideRange !== 'function') { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: 'trackChanges.decide range targets are not supported by the active adapter.', + details: { target: input.target }, + }, + }; + } + const range = target.range as TextTarget; + if ( + !range || + typeof range !== 'object' || + range.kind !== 'text' || + !Array.isArray(range.segments) || + range.segments.length === 0 + ) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target.kind = "range" requires a non-empty TextTarget range.', + { field: 'target.range', value: target.range }, + ); + } + return adapter.decideRange({ decision, range, ...(story ? { story } : {}) }, options); + } + + if (target.kind === 'all') { + if (decision === 'accept') return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); + return adapter.rejectAll({} as TrackChangesRejectAllInput, options); + } + + // Legacy aliases — `{ id }` / `{ scope: 'all' }`. Preserved for backwards + // compatibility per the closed product decision in `phase0-checkpoint.md`. + const isAll = target.scope === 'all'; + if (!isAll) { + if (typeof target.id !== 'string' || target.id.length === 0) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must have { kind: "id" | "range" | "all" } or the legacy { id } / { scope: "all" } shape.', + { field: 'target', value: input.target }, + ); + } + } - if (input.decision === 'accept') { + if (decision === 'accept') { if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); } - if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); } diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index c7a4f3ff4f..5963e7cb86 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -8,7 +8,9 @@ export type ReceiptFailureCode = | 'INVALID_TARGET' | 'TARGET_NOT_FOUND' | 'CAPABILITY_UNAVAILABLE' + | 'PERMISSION_DENIED' | 'REVISION_MISMATCH' + | 'COMMENT_CASCADE_PARTIAL' | 'MATCH_NOT_FOUND' | 'AMBIGUOUS_MATCH' | 'STYLE_CONFLICT' diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 399074ab15..c7280931f2 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -34,6 +34,7 @@ import { import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; +import { createWordIdAllocator, isDecimalWordId } from '@extensions/track-changes/review-model/word-id-allocator.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; import { getNecessaryMigrations } from '@core/migrations/index.js'; @@ -3213,6 +3214,84 @@ export class Editor extends EventEmitter { return this.#prepareDocumentForExport(); } + /** + * Install a fresh Word revision id allocator on the converter. Walks the + * current document and reserves every decimal `sourceId` so newly minted ids + * never collide with imported revisions. + */ + #installWordIdAllocatorIfNeeded(): void { + if (!this.converter) return; + + const allocator = createWordIdAllocator(); + + const reserveAttrs = (attrs: { sourceId?: unknown } | undefined, path: string): void => { + const sourceId = attrs?.sourceId; + if (isDecimalWordId(sourceId)) { + allocator.reserve(path, sourceId as string | number); + } + }; + + const reserveFromDoc = (doc: PmNode | undefined | null, path: string): void => { + if (!doc) return; + doc.descendants((node) => { + if (!Array.isArray(node.marks)) return; + for (const mark of node.marks) { + const name = mark.type.name; + if (name !== 'trackInsert' && name !== 'trackDelete' && name !== 'trackFormat') continue; + reserveAttrs(mark.attrs as { sourceId?: unknown }, path); + } + }); + }; + + const reserveFromJson = (node: unknown, path: string): void => { + if (!node || typeof node !== 'object') return; + const current = node as { marks?: Array<{ type?: string; attrs?: { sourceId?: unknown } }>; content?: unknown[] }; + if (Array.isArray(current.marks)) { + for (const mark of current.marks) { + if (mark.type !== 'trackInsert' && mark.type !== 'trackDelete' && mark.type !== 'trackFormat') continue; + reserveAttrs(mark.attrs, path); + } + } + if (Array.isArray(current.content)) { + for (const child of current.content) reserveFromJson(child, path); + } + }; + + const wordPartPath = (target: unknown, fallback: string): string => { + if (typeof target !== 'string' || !target) return fallback; + const stripped = target.replace(/^\/+/, ''); + return stripped.startsWith('word/') ? stripped : `word/${stripped}`; + }; + + const relsRoot = this.converter.convertedXml?.['word/_rels/document.xml.rels']?.elements?.find( + (node: { name?: string }) => node.name === 'Relationships', + ); + const resolveRelationshipTarget = (relationshipId: string, fallback: string): string => { + const target = relsRoot?.elements?.find( + (node: { attributes?: { Id?: string; Target?: string } }) => node.attributes?.Id === relationshipId, + )?.attributes?.Target; + return wordPartPath(target, fallback); + }; + + reserveFromDoc(this.state?.doc as PmNode, 'word/document.xml'); + for (const [id, header] of Object.entries(this.converter.headers || {})) { + const partPath = resolveRelationshipTarget(id, 'word/header1.xml'); + const headerEditor = this.converter.headerEditors?.find((item: { id?: string }) => item.id === id); + reserveFromDoc(headerEditor?.editor?.state?.doc as PmNode | undefined, partPath); + reserveFromJson(header, partPath); + } + for (const [id, footer] of Object.entries(this.converter.footers || {})) { + const partPath = resolveRelationshipTarget(id, 'word/footer1.xml'); + const footerEditor = this.converter.footerEditors?.find((item: { id?: string }) => item.id === id); + reserveFromDoc(footerEditor?.editor?.state?.doc as PmNode | undefined, partPath); + reserveFromJson(footer, partPath); + } + reserveFromJson(this.converter.footnotes, 'word/footnotes.xml'); + reserveFromJson(this.converter.endnotes, 'word/endnotes.xml'); + + this.converter.wordIdAllocator = allocator; + } + /** * Export the editor document to DOCX. * @@ -3267,6 +3346,13 @@ export class Editor extends EventEmitter { // Pre-process the document state to prepare for export const json = this.#prepareDocumentForExport(preparedComments); + // Set up the Word revision id allocator on the converter so + // ins/del/rPrChange decoders can mint + // Word-compatible decimal `w:id` values without overwriting preserved + // imported `sourceId` values. The allocator is reset per export so the + // reserved set always reflects the current document state. + this.#installWordIdAllocatorIfNeeded(); + // Export the document to DOCX // GUID will be handled automatically in converter.exportToDocx if document was modified const documentXml = await this.converter.exportToDocx( diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index d37bf2637b..5fbdc441a6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -212,6 +212,14 @@ class SuperConverter { */ this.trackedChangesOptions = params?.trackedChangesOptions || null; + /** + * Word revision id allocator. Built lazily at export time; preserves + * imported decimal `w:id` values and mints fresh part-local decimal ids + * for native revisions / successor fragments. + * @type {import('@extensions/track-changes/review-model/word-id-allocator').WordIdAllocator | null} + */ + this.wordIdAllocator = null; + this.addedMedia = {}; this.comments = []; this.footnotes = []; @@ -1303,6 +1311,7 @@ class SuperConverter { preserveSdtWrappers = false, statFieldCacheMap = undefined, existingRelationships = [], + partPath = 'word/document.xml', }) { const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body'); @@ -1340,6 +1349,7 @@ class SuperConverter { preserveSdtWrappers, statFieldCacheMap: resolvedCacheMap, existingRelationships, + currentPartPath: partPath, }); return { result, params }; @@ -1500,6 +1510,7 @@ class SuperConverter { isHeaderFooter: true, isFinalDoc, existingRelationships, + partPath, }); const bodyContent = result.elements[0].elements; @@ -1566,6 +1577,7 @@ class SuperConverter { isHeaderFooter: true, isFinalDoc, existingRelationships, + partPath, }); const bodyContent = result.elements[0].elements; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js index c3b3a7a7d5..a2482e4af7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js @@ -237,6 +237,7 @@ const prepareNotesXmlForExport = ({ notes, editor, converter, convertedXml, conf converter, relationships: footnoteRelationships, media: footnoteMedia, + currentPartPath: config.notesPath, }; const footnoteElements = notes.map((fn) => createFootnoteElement(fn, exportContext, config)).filter(Boolean); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index b4f84e5680..81dc17383f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -324,6 +324,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path = [], extraParams = {}, }) => { @@ -359,6 +360,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path, extraParams, }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js new file mode 100644 index 0000000000..2ec19303fd --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js @@ -0,0 +1,147 @@ +// @ts-check +import { createImportTrackingContext, withParentFrame } from '@extensions/track-changes/review-model/import-context.js'; + +const contextsByConverter = new WeakMap(); + +/** + * @typedef {{ + * trackedChangeIdMapsByPart?: Map>, + * trackedChangeIdMap?: Map, + * trackedChangesOptions?: { replacements?: 'paired' | 'independent' }, + * }} ConverterLike + * + * @typedef {Record & { + * converter?: ConverterLike, + * currentPartPath?: string, + * filename?: string, + * importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext, + * }} ImportTrackingParams + */ + +/** + * @param {ImportTrackingParams} [params] + * @returns {string} + */ +export function resolveTrackedChangePartPath(params = {}) { + const currentPartPath = params.currentPartPath; + if (typeof currentPartPath === 'string' && currentPartPath.length > 0) return currentPartPath; + + const filename = params.filename; + if (typeof filename === 'string' && filename.length > 0) { + return filename.startsWith('word/') ? filename : `word/${filename}`; + } + + return 'word/document.xml'; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + */ +export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrackedChangePartPath(params)) { + const converter = params.converter; + if (!converter || typeof converter !== 'object') return null; + + const mapsByPart = converter.trackedChangeIdMapsByPart; + return mapsByPart?.get?.(partPath) ?? converter.trackedChangeIdMap ?? null; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} sourceId + * @returns {{ partPath: string, sourceId: string, logicalId: string }} + */ +export function resolveTrackedChangeImportIds(params = {}, sourceId = '') { + const partPath = resolveTrackedChangePartPath(params); + const id = typeof sourceId === 'string' ? sourceId : String(sourceId || ''); + const trackedChangeIdMap = getTrackedChangeIdMapForPart(params, partPath); + return { + partPath, + sourceId: id, + logicalId: id && trackedChangeIdMap?.has(id) ? (trackedChangeIdMap.get(id) ?? id) : id, + }; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + * @returns {import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext | null} + */ +export function getOrCreateImportTrackingContext(params = {}, partPath = resolveTrackedChangePartPath(params)) { + const supplied = params.importTrackingContext; + if (supplied?.forNestedPart) { + return supplied.partPath === partPath ? supplied : supplied.forNestedPart(partPath); + } + + const converter = params.converter; + const trackedChangesOptions = + converter && typeof converter === 'object' && converter.trackedChangesOptions + ? converter.trackedChangesOptions + : null; + + if (!converter || typeof converter !== 'object') { + return createImportTrackingContext({ + partPath, + replacements: trackedChangesOptions?.replacements ?? 'paired', + }); + } + + let contextsByPart = contextsByConverter.get(converter); + if (!contextsByPart) { + contextsByPart = new Map(); + contextsByConverter.set(converter, contextsByPart); + } + + let context = contextsByPart.get(partPath); + if (!context) { + context = createImportTrackingContext({ + partPath, + replacements: trackedChangesOptions?.replacements ?? 'paired', + }); + contextsByPart.set(partPath, context); + } + + return context; +} + +/** + * @param {{ + * params?: ImportTrackingParams, + * attrs: Record, + * side: 'insertion' | 'deletion' | 'formatting', + * sourceId?: string, + * partPath?: string, + * }} input + * @returns {{ context: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext | null, frame: import('@extensions/track-changes/review-model/import-context.js').ParentFrame | null }} + */ +export function stampImportTrackingAttrs(input) { + const { params = {}, attrs, side, sourceId = '', partPath = resolveTrackedChangePartPath(params) } = input; + const context = getOrCreateImportTrackingContext(params, partPath); + if (!context) { + return { context: null, frame: null }; + } + + const logicalId = typeof attrs.id === 'string' ? attrs.id : String(attrs.id || ''); + const parent = context.currentParent(); + if (parent?.logicalId) { + attrs.overlapParentId = parent.logicalId; + } + if (logicalId) { + context.recordLogicalId(logicalId, { sourceId, side }); + } + + return { + context, + frame: logicalId + ? { + logicalId, + side, + sourceId, + author: typeof attrs.author === 'string' ? attrs.author : '', + date: typeof attrs.date === 'string' ? attrs.date : '', + } + : null, + }; +} + +export { withParentFrame }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js index c49b3c20a9..39a5e3554c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js @@ -3,6 +3,7 @@ import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; import { getHexColorFromDocxSystem, isValidHexColor, twipsToInches, twipsToLines, twipsToPt } from '../../helpers.js'; import { translator as wRPrTranslator } from '../../v3/handlers/w/rpr/index.js'; import { encodeMarksFromRPr } from '@converter/styles.js'; +import { resolveTrackedChangeImportIds, stampImportTrackingAttrs } from './importTrackingContext.js'; /** * @@ -112,13 +113,21 @@ export function handleStyleChangeMarksV2(rPrChange, currentMarks, params) { } const attributes = rPrChange.attributes || {}; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, attributes['w:id']); const mappedAttributes = { - id: attributes['w:id'], - sourceId: attributes['w:id'], + id: logicalId, + sourceId, date: attributes['w:date'], author: attributes['w:author'], authorEmail: attributes['w:authorEmail'], }; + stampImportTrackingAttrs({ + params, + attrs: mappedAttributes, + side: 'formatting', + sourceId, + partPath, + }); let submarks = []; const rPr = rPrChange.elements?.find((el) => el.name === 'w:rPr'); if (rPr) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js index 4df8788ae0..09995acfdc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js @@ -13,6 +13,7 @@ import { } from './markImporter.js'; import { SuperConverter } from '../../SuperConverter.js'; import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; const themeDoc = { elements: [ @@ -188,6 +189,40 @@ describe('handleStyleChangeMarksV2', () => { expect(result[0].attrs.before).toEqual([]); expect(result[0].attrs.after).toEqual([]); }); + + it('remaps format change ids and stamps overlapParentId through import tracking context', () => { + const context = createImportTrackingContext({}); + context.pushParent({ + logicalId: 'parent-insert', + side: 'insertion', + sourceId: '4', + author: 'Parent', + date: '2026-05-21T00:00:00Z', + }); + const rPrChange = { + name: 'w:rPrChange', + attributes: { + 'w:id': '2', + 'w:date': '2024-09-04T09:29:00Z', + 'w:author': 'author@example.com', + }, + elements: [{ name: 'w:rPr', elements: [] }], + }; + + const result = handleStyleChangeMarksV2(rPrChange, [], { + docx: {}, + importTrackingContext: context, + converter: { + trackedChangeIdMap: new Map([['2', 'format-logical-id']]), + }, + }); + + expect(result[0].attrs).toMatchObject({ + id: 'format-logical-id', + sourceId: '2', + overlapParentId: 'parent-insert', + }); + }); }); describe('createImportMarks', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js index f9bc1313dd..20b26f5828 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js @@ -25,7 +25,7 @@ export const findTrackFormatMark = (marks = []) => * @param {Object|null|undefined} trackFormatMark * @returns {Object|undefined} */ -export const createRunPropertiesChangeElement = (trackFormatMark) => { +export const createRunPropertiesChangeElement = (trackFormatMark, options = {}) => { if (!trackFormatMark) return undefined; const beforeMarks = Array.isArray(trackFormatMark.attrs?.before) ? trackFormatMark.attrs.before : []; @@ -35,11 +35,20 @@ export const createRunPropertiesChangeElement = (trackFormatMark) => { elements: toRunPropertyElements(beforeMarks), }; + // Phase 005 — if an allocator was passed in, mint a Word-native decimal + // `w:id`. Legacy callers (no `options.wordIdAllocator`) keep the prior + // `sourceId || id` behavior so the exported byte stream is unchanged. + const allocator = options?.wordIdAllocator || null; + const partPath = options?.partPath || 'word/document.xml'; + const sourceId = trackFormatMark.attrs?.sourceId; + const logicalId = trackFormatMark.attrs?.id; + const wordId = allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId; + return { type: 'element', name: 'w:rPrChange', attributes: { - 'w:id': trackFormatMark.attrs?.sourceId || trackFormatMark.attrs?.id, + 'w:id': wordId, 'w:author': trackFormatMark.attrs?.author, 'w:authorEmail': trackFormatMark.attrs?.authorEmail, 'w:date': trackFormatMark.attrs?.date, @@ -55,7 +64,7 @@ export const createRunPropertiesChangeElement = (trackFormatMark) => { * @param {Array} marks * @returns {Object|null|undefined} */ -export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks = []) => { +export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks = [], options = {}) => { if (!runPropertiesNode) return runPropertiesNode; const trackFormatMark = findTrackFormatMark(marks); @@ -68,7 +77,7 @@ export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks const hasExistingChange = runPropertiesNode.elements.some((element) => element?.name === 'w:rPrChange'); if (hasExistingChange) return runPropertiesNode; - const changeElement = createRunPropertiesChangeElement(trackFormatMark); + const changeElement = createRunPropertiesChangeElement(trackFormatMark, options); if (changeElement) { runPropertiesNode.elements.push(changeElement); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index a12f9a9cbf..983e8f8204 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -2,6 +2,11 @@ import { NodeTranslator } from '@translator'; import { createAttributeHandler } from '@converter/v3/handlers/utils.js'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { + resolveTrackedChangeImportIds, + stampImportTrackingAttrs, + withParentFrame, +} from '../../../../v2/importer/importTrackingContext.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -19,33 +24,42 @@ const validXmlAttributes = [ /** * Encode the w:del element - * @param {import('@translator').SCEncoderConfig} params + * @param {import('@translator').SCEncoderConfig & { importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext }} params + * @param {Record} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter, filename } = params; + const { nodeListHandler, extraParams = {} } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. - const originalWordId = encodedAttrs.id; - const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; - const trackedChangeIdMap = - converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; - if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = trackedChangeIdMap.get(originalWordId); - } - encodedAttrs.sourceId = originalWordId || ''; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, encodedAttrs.id); + encodedAttrs.id = logicalId; + encodedAttrs.sourceId = sourceId; + const { context, frame } = stampImportTrackingAttrs({ + params, + attrs: encodedAttrs, + side: 'deletion', + sourceId, + partPath, + }); - const subs = nodeListHandler.handler({ + const childParams = { ...params, insideTrackChange: true, + importTrackingContext: context ?? params.importTrackingContext, nodes: node.elements, path: [...(params.path || []), node], - }); + }; + const subs = + context && frame + ? withParentFrame(context, frame, () => nodeListHandler.handler(childParams)) + : nodeListHandler.handler(childParams); encodedAttrs.importedAuthor = `${encodedAttrs.author} (imported)`; + const converter = /** @type {{ documentOrigin?: string } | undefined} */ (params.converter); if (converter?.documentOrigin) { encodedAttrs.origin = converter.documentOrigin; } @@ -73,17 +87,16 @@ function decode(params) { const { node } = params; if (!node || !node.type) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - const trackingMarks = ['trackInsert', 'trackDelete']; const marks = Array.isArray(node.marks) ? node.marks : []; const trackedMark = marks.find((m) => m.type === 'trackDelete'); if (!trackedMark) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => !trackingMarks.includes(m.type)); + node.marks = marks.filter((m) => m.type !== 'trackDelete'); const translatedTextNode = exportSchemaToJson({ ...params, node }); // ECMA-376 renames w:t → w:delText inside . Other inline content — @@ -96,7 +109,7 @@ function decode(params) { return { name: 'w:del', attributes: { - 'w:id': trackedMark.attrs.sourceId || trackedMark.attrs.id, + 'w:id': resolveExportWordId(params, trackedMark.attrs), 'w:author': trackedMark.attrs.author, 'w:authorEmail': trackedMark.attrs.authorEmail, 'w:date': trackedMark.attrs.date, @@ -105,6 +118,36 @@ function decode(params) { }; } +/** + * Resolve the `w:id` to write on export. Uses the Word revision id allocator + * when one is installed on the converter; otherwise falls through to + * `sourceId || id`. + * + * @param {import('@translator').SCDecoderConfig} params + * @param {Record} attrs + * @returns {string} + */ +function resolveExportWordId(params, attrs) { + const sourceId = attrs?.sourceId; + const exportSourceId = + typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(sourceId); + const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; + const exportParams = + /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( + params + ); + const allocator = exportParams?.converter?.wordIdAllocator; + const partPath = + exportParams?.currentPartPath || + (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 + ? `word/${exportParams.filename}` + : 'word/document.xml'); + if (allocator) { + return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); + } + return /** @type {string} */ (sourceId || logicalId); +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 7d0a589833..a5e614a3dc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { config, translator } from './del-translator.js'; import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; // Mock external modules vi.mock('@converter/exporter.js', () => ({ @@ -103,6 +104,38 @@ describe('w:del translator', () => { expect(attrs.id).toBe('footnote-uuid'); expect(attrs.sourceId).toBe('123'); }); + + it('flows import tracking context through nested deletion content', () => { + const context = createImportTrackingContext({}); + const childFrames = []; + const mockSubNodes = [{ content: [{ type: 'text', text: 'deleted text' }] }]; + const mockNodeListHandler = { + handler: vi.fn((childParams) => { + childFrames.push(childParams.importTrackingContext.currentParent()); + return mockSubNodes; + }), + }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + importTrackingContext: context, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + const attrs = getMarkAttrs(result); + + expect(childFrames[0]).toMatchObject({ logicalId: '123', side: 'deletion' }); + expect(context.currentParent()).toBeNull(); + expect(attrs).toEqual(expect.objectContaining({ id: '123', sourceId: '123' })); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 9ececf73e7..5528776bed 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -2,6 +2,11 @@ import { NodeTranslator } from '@translator'; import { createAttributeHandler } from '@converter/v3/handlers/utils.js'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { + resolveTrackedChangeImportIds, + stampImportTrackingAttrs, + withParentFrame, +} from '../../../../v2/importer/importTrackingContext.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:ins'; @@ -19,32 +24,41 @@ const validXmlAttributes = [ /** * Encode the w:ins element - * @param {import('@translator').SCEncoderConfig} params + * @param {import('@translator').SCEncoderConfig & { importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext }} params + * @param {Record} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter, filename } = params; + const { nodeListHandler, extraParams = {} } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. - const originalWordId = encodedAttrs.id; - const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; - const trackedChangeIdMap = - converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; - if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = trackedChangeIdMap.get(originalWordId); - } - encodedAttrs.sourceId = originalWordId || ''; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, encodedAttrs.id); + encodedAttrs.id = logicalId; + encodedAttrs.sourceId = sourceId; + const { context, frame } = stampImportTrackingAttrs({ + params, + attrs: encodedAttrs, + side: 'insertion', + sourceId, + partPath, + }); - const subs = nodeListHandler.handler({ + const childParams = { ...params, insideTrackChange: true, + importTrackingContext: context ?? params.importTrackingContext, nodes: node.elements, path: [...(params.path || []), node], - }); + }; + const subs = + context && frame + ? withParentFrame(context, frame, () => nodeListHandler.handler(childParams)) + : nodeListHandler.handler(childParams); encodedAttrs.importedAuthor = `${encodedAttrs.author} (imported)`; + const converter = /** @type {{ documentOrigin?: string } | undefined} */ (params.converter); if (converter?.documentOrigin) { encodedAttrs.origin = converter.documentOrigin; } @@ -72,24 +86,23 @@ function decode(params) { const { node } = params; if (!node || !node.type) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - const trackingMarks = ['trackInsert', 'trackDelete']; const marks = Array.isArray(node.marks) ? node.marks : []; const trackedMark = marks.find((m) => m.type === 'trackInsert'); if (!trackedMark) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => !trackingMarks.includes(m.type)); + node.marks = marks.filter((m) => m.type !== 'trackInsert'); const translatedTextNode = exportSchemaToJson({ ...params, node }); return { name: 'w:ins', attributes: { - 'w:id': trackedMark.attrs.sourceId || trackedMark.attrs.id, + 'w:id': resolveExportWordId(params, trackedMark.attrs), 'w:author': trackedMark.attrs.author, 'w:authorEmail': trackedMark.attrs.authorEmail, 'w:date': trackedMark.attrs.date, @@ -98,6 +111,36 @@ function decode(params) { }; } +/** + * Resolve the `w:id` to write on export. Uses the Word revision id allocator + * when one is installed on the converter; otherwise falls through to + * `sourceId || id`. + * + * @param {import('@translator').SCDecoderConfig} params + * @param {Record} attrs + * @returns {string} + */ +function resolveExportWordId(params, attrs) { + const sourceId = attrs?.sourceId; + const exportSourceId = + typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(sourceId); + const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; + const exportParams = + /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( + params + ); + const allocator = exportParams?.converter?.wordIdAllocator; + const partPath = + exportParams?.currentPartPath || + (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 + ? `word/${exportParams.filename}` + : 'word/document.xml'); + if (allocator) { + return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); + } + return /** @type {string} */ (sourceId || logicalId); +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index be99a7c505..445e99f8cf 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { config, translator } from './ins-translator.js'; import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; vi.mock('@converter/exporter.js', () => ({ exportSchemaToJson: vi.fn(), @@ -111,6 +112,50 @@ describe('w:ins translator', () => { expect(attrs.id).toBe('header-uuid'); expect(attrs.sourceId).toBe('123'); }); + + it('flows import tracking context through nested insertion content', () => { + const context = createImportTrackingContext({}); + context.pushParent({ + logicalId: 'parent-delete', + side: 'deletion', + sourceId: '9', + author: 'Parent', + date: '2026-05-21T00:00:00Z', + }); + const childFrames = []; + const mockSubNodes = [{ content: [{ type: 'text', text: 'added text' }] }]; + const mockNodeListHandler = { + handler: vi.fn((childParams) => { + childFrames.push(childParams.importTrackingContext.currentParent()); + return mockSubNodes; + }), + }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + importTrackingContext: context, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(childFrames[0]).toMatchObject({ logicalId: '123', side: 'insertion' }); + expect(context.currentParent()).toMatchObject({ logicalId: 'parent-delete' }); + expect(getMarkAttrs(result)).toEqual( + expect.objectContaining({ + id: '123', + sourceId: '123', + overlapParentId: 'parent-delete', + }), + ); + }); }); describe('decode', () => { @@ -170,6 +215,45 @@ describe('w:ins translator', () => { expect(result.attributes['w:id']).toBe('456'); }); + it('allocates Word ids in the current OOXML part namespace under overlap', () => { + const mockTrackedMark = { + type: 'trackInsert', + attrs: { + id: 'logical-header', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + const calls = []; + const converter = { + wordIdAllocator: { + allocate: vi.fn((input) => { + calls.push(input); + return input.partPath === 'word/header1.xml' ? '1' : '99'; + }), + }, + }; + + exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t' }] }); + + const result = config.decode({ + node: { type: 'text', marks: [mockTrackedMark] }, + converter, + currentPartPath: 'word/header1.xml', + }); + + expect(result.attributes['w:id']).toBe('1'); + expect(calls).toEqual([ + { + partPath: 'word/header1.xml', + sourceId: '', + logicalId: 'logical-header', + }, + ]); + }); + it('returns null if node is missing or invalid', () => { expect(config.decode({ node: null })).toBeNull(); expect(config.decode({ node: {} })).toBeNull(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js index 4e1638016a..9f9e82a6a3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js @@ -422,7 +422,10 @@ const decode = (params, decodedAttrs = {}) => { if (!existingRunPropertyChanges.length && runTrackFormatMark) { const runPropertiesNode = getRunPropertiesNode(runNode); - appendTrackFormatChangeToRunProperties(runPropertiesNode, [runTrackFormatMark]); + appendTrackFormatChangeToRunProperties(runPropertiesNode, [runTrackFormatMark], { + wordIdAllocator: params?.converter?.wordIdAllocator || null, + partPath: params?.currentPartPath || 'word/document.xml', + }); } }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts index cd495d0287..26a7ca4090 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts @@ -49,6 +49,7 @@ import { trackChangesRejectWrapper, trackChangesAcceptAllWrapper, trackChangesRejectAllWrapper, + trackChangesDecideRangeWrapper, } from './plan-engine/track-changes-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { blocksListWrapper, blocksDeleteWrapper, blocksDeleteRangeWrapper } from './plan-engine/blocks-wrappers.js'; @@ -443,6 +444,7 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters reject: (input, options) => trackChangesRejectWrapper(editor, input, options), acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), + decideRange: (input, options) => trackChangesDecideRangeWrapper(editor, input, options), }, blocks: { list: (input) => blocksListWrapper(editor, input), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts index c42e6aa7a1..c2f980ff2e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts @@ -148,12 +148,14 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(rendered).toContain('Here is a MS Word'); expect(rendered).toContain('sentence'); - // Pairing observation: in this fixture, w:del and w:ins authored at the - // same time map to one entityId on both halves. Confirm that here so - // any future regression in the importer's pairing surfaces immediately. + // Word-authored replacement halves remain separate source-wrapper entities, + // while each span still resolves to the correct public tracked-change entry. const delEntity = deleteSpans[0].trackedChanges!.find((c) => c.type === 'delete')!.entityId; const insEntity = insertSpans[0].trackedChanges!.find((c) => c.type === 'insert')!.entityId; - expect(insEntity).toBe(delEntity); + const entitiesById = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); + expect(insEntity).not.toBe(delEntity); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.delete).toBeTruthy(); + expect(entitiesById.get(insEntity)?.wordRevisionIds?.insert).toBeTruthy(); }); it('attaches every tracked change to the blocks it lives in via blockIds', async () => { @@ -218,11 +220,10 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { } }); - it('lets a consumer derive per-segment view of a paired change from spans', async () => { - // The aggregate trackedChanges[] entry for a paired change has a single - // `type` and (now) no `excerpt` — the spans carry the per-half text. + it('lets a consumer derive per-segment view of a Word replacement from spans', async () => { + // Word-authored replacement halves expose separate tracked-change entities. // A reviewer UI ("show me what John changed") rebuilds the segment view - // from spans + blockIds. + // from spans + blockIds rather than assuming one aggregate replacement id. const ctx = (await initTestEditor({ content: docxFixture.docx, media: docxFixture.media, @@ -243,33 +244,37 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { } } - // Find the paired replacement by its OOXML provenance: both insert and - // delete word-revision-ids are populated on a paired entity. - const pairedEntity = result.trackedChanges.find( - (tc) => !!tc.wordRevisionIds?.insert && !!tc.wordRevisionIds?.delete, - )!; - expect(pairedEntity).toBeDefined(); - - // The misleading concatenated excerpt is suppressed for paired entries. - expect(pairedEntity.excerpt).toBeUndefined(); - - // OOXML provenance is preserved so a spec-aware consumer can map back - // to the source document. - expect(pairedEntity.wordRevisionIds!.insert).toBeTruthy(); - expect(pairedEntity.wordRevisionIds!.delete).toBeTruthy(); - expect(pairedEntity.wordRevisionIds!.insert).not.toBe(pairedEntity.wordRevisionIds!.delete); - - const segments = entityToSegments.get(pairedEntity.entityId)!; - expect(segments).toBeDefined(); - - // Per-segment view: one delete of "basic " and one insert of "cool " on - // the same block. Independently addressable, independently renderable. - const deletes = segments.filter((s) => s.type === 'delete'); - const inserts = segments.filter((s) => s.type === 'insert'); - expect(deletes.map((s) => s.text)).toEqual(['basic ']); - expect(inserts.map((s) => s.text).join('')).toBe('cool '); - for (const seg of segments) { - expect(seg.blockId).toBe(pairedEntity.blockIds![0]); + const deleteEntry = Array.from(entityToSegments.entries()).find(([, segments]) => + segments.some((s) => s.type === 'delete' && s.text === 'basic '), + ); + const insertEntry = Array.from(entityToSegments.entries()).find( + ([, segments]) => + segments + .filter((s) => s.type === 'insert') + .map((s) => s.text) + .join('') === 'cool ', + ); + expect(deleteEntry).toBeDefined(); + expect(insertEntry).toBeDefined(); + const [deleteEntityId, deleteSegments] = deleteEntry!; + const [insertEntityId, insertSegments] = insertEntry!; + expect(deleteEntityId).not.toBe(insertEntityId); + + const deleteEntity = result.trackedChanges.find((tc) => tc.entityId === deleteEntityId)!; + const insertEntity = result.trackedChanges.find((tc) => tc.entityId === insertEntityId)!; + expect(deleteEntity.wordRevisionIds?.delete).toBeTruthy(); + expect(insertEntity.wordRevisionIds?.insert).toBeTruthy(); + expect(deleteEntity.blockIds).toEqual(insertEntity.blockIds); + + expect(deleteSegments.filter((s) => s.type === 'delete').map((s) => s.text)).toEqual(['basic ']); + expect( + insertSegments + .filter((s) => s.type === 'insert') + .map((s) => s.text) + .join(''), + ).toBe('cool '); + for (const seg of [...deleteSegments, ...insertSegments]) { + expect(seg.blockId).toBe(deleteEntity.blockIds![0]); } }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts index 92b63804dd..f33a9740c0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts @@ -36,7 +36,7 @@ import { getHeadingLevel, mapBlockNodeType, resolveBlockNodeId } from './helpers import { getRevision } from './plan-engine/revision-tracker.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { trackChangesListWrapper } from './plan-engine/track-changes-wrappers.js'; -import { buildTrackedChangeCanonicalIdMap } from './helpers/tracked-change-resolver.js'; +import { buildTrackedChangeCanonicalIdMap, getTrackedChangeMarkAlias } from './helpers/tracked-change-resolver.js'; import { TrackDeleteMarkName, TrackFormatMarkName, @@ -175,9 +175,9 @@ function readSpanTrackedChanges( for (const mark of node.marks) { const type = TRACK_MARK_TYPE_BY_NAME[mark.type.name]; if (!type) continue; - const rawId = (mark.attrs as Record | undefined)?.id; - if (typeof rawId !== 'string' || rawId.length === 0) continue; - const entityId = canonicalIdByAlias.get(rawId); + const alias = getTrackedChangeMarkAlias(mark); + if (!alias) continue; + const entityId = canonicalIdByAlias.get(alias); if (!entityId) continue; out.push({ entityId, type }); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts index 2d0cf1a8ea..b229fecc1d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts @@ -1,7 +1,6 @@ import type { Editor } from '../../core/Editor.js'; import { TrackInsertMarkName } from '../../extensions/track-changes/constants.js'; -import { buildTrackedChangeCanonicalIdMap } from './tracked-change-resolver.js'; -import { toNonEmptyString } from './value-utils.js'; +import { buildTrackedChangeCanonicalIdMap, getTrackedChangeMarkAlias } from './tracked-change-resolver.js'; type ReceiptInsert = { kind: 'entity'; entityType: 'trackedChange'; entityId: string }; @@ -30,9 +29,9 @@ export function collectTrackInsertRefsInRange(editor: Editor, from: number, to: const marks = node.marks ?? []; for (const mark of marks) { if (mark.type.name !== TrackInsertMarkName) continue; - const id = toNonEmptyString(mark.attrs?.id); - if (!id) continue; - ids.add(canonicalIdByAlias.get(id) ?? id); + const alias = getTrackedChangeMarkAlias(mark); + if (!alias) continue; + ids.add(canonicalIdByAlias.get(alias) ?? alias); } }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index fd170d76a4..16f8266ae4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -65,7 +65,7 @@ describe('groupTrackedChanges', () => { vi.clearAllMocks(); }); - it('groups marks by raw id', () => { + it('groups imported Word marks by source wrapper', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, @@ -74,13 +74,33 @@ describe('groupTrackedChanges', () => { const editor = makeEditor(); const grouped = groupTrackedChanges(editor); + expect(grouped).toHaveLength(2); + expect(grouped[0]?.rawId).toBe(`word:${TrackInsertMarkName}:11`); + expect(grouped[0]?.commandRawId).toBe('tc-1'); + expect(grouped[0]?.hasInsert).toBe(true); + expect(grouped[0]?.hasDelete).toBe(false); + expect(grouped[0]?.wordRevisionIds).toEqual({ insert: '11' }); + expect(grouped[1]?.rawId).toBe(`word:${TrackDeleteMarkName}:10`); + expect(grouped[1]?.commandRawId).toBe('tc-1'); + expect(grouped[1]?.hasInsert).toBe(false); + expect(grouped[1]?.hasDelete).toBe(true); + expect(grouped[1]?.wordRevisionIds).toEqual({ delete: '10' }); + }); + + it('groups native marks by raw id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1'), from: 5, to: 10 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped).toHaveLength(1); expect(grouped[0]?.rawId).toBe('tc-1'); expect(grouped[0]?.from).toBe(1); expect(grouped[0]?.to).toBe(10); expect(grouped[0]?.hasInsert).toBe(true); expect(grouped[0]?.hasDelete).toBe(true); - expect(grouped[0]?.wordRevisionIds).toEqual({ insert: '11', delete: '10' }); }); it('keeps separate entries for different raw ids', () => { @@ -148,6 +168,37 @@ describe('groupTrackedChanges', () => { expect(grouped[0]?.wordRevisionIds).toEqual({ format: '22' }); }); + it('preserves empty parent Word wrappers when the only text belongs to a child deletion', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent', { sourceId: '2', author: 'Missy Fox' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child', { + sourceId: '3', + author: 'Vivienne Salisbury', + overlapParentId: 'parent', + }); + const node = { text: 'XYZ', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...parent, node, from: 1, to: 4 }, + { ...child, node, from: 1, to: 4 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.wordRevisionIds?.insert === '2'); + const childChange = grouped.find((change) => change.wordRevisionIds?.delete === '3'); + + expect(parentChange?.excerpt).toBe(''); + expect(childChange?.excerpt).toBe('XYZ'); + }); + + it('preserves significant Word revision whitespace in explicit excerpts', () => { + const mark = makeTrackMark(TrackDeleteMarkName, 'delete-with-space', { sourceId: '4' }); + vi.mocked(getTrackChanges).mockReturnValue([ + { ...mark, node: { text: 'O ', marks: [mark.mark] }, from: 1, to: 3 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped[0]?.excerpt).toBe('O '); + }); + it('sorts results by from position', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-2'), from: 10, to: 15 }, @@ -225,6 +276,25 @@ describe('buildTrackedChangeCanonicalIdMap', () => { expect(map.get(canonicalId!)).toBe(canonicalId); }); + it('does not map shared Word command ids as unique span aliases', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, + ] as never); + + const editor = makeEditor(); + const map = buildTrackedChangeCanonicalIdMap(editor); + const grouped = groupTrackedChanges(editor); + const insertChange = grouped.find((change) => change.rawId === `word:${TrackInsertMarkName}:11`); + const deleteChange = grouped.find((change) => change.rawId === `word:${TrackDeleteMarkName}:10`); + + expect(insertChange).toBeDefined(); + expect(deleteChange).toBeDefined(); + expect(map.get(`word:${TrackInsertMarkName}:11`)).toBe(insertChange!.id); + expect(map.get(`word:${TrackDeleteMarkName}:10`)).toBe(deleteChange!.id); + expect(map.get('tc-1')).toBeUndefined(); + }); + it('returns empty map when no tracked changes exist', () => { vi.mocked(getTrackChanges).mockReturnValue([] as never); expect(buildTrackedChangeCanonicalIdMap(makeEditor()).size).toBe(0); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index f21bf2bdb2..cdcee5d412 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -24,12 +24,14 @@ type RawTrackedMark = { type: { name: string }; attrs?: Record; }; + node?: ProseMirrorNode; from: number; to: number; }; export type GroupedTrackedChange = { rawId: string; + commandRawId?: string; id: string; from: number; to: number; @@ -37,10 +39,12 @@ export type GroupedTrackedChange = { hasDelete: boolean; hasFormat: boolean; attrs: Record; + excerpt?: string; wordRevisionIds?: TrackChangeWordRevisionIds; }; type ChangeTypeInput = Pick; +type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { try { @@ -92,7 +96,10 @@ function portableHash(input: string): string { */ function deriveTrackedChangeId(editor: Editor, change: Omit): string { const type = resolveTrackedChangeType(change); - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? ''; + const excerpt = + (change.excerpt !== undefined ? change.excerpt : undefined) ?? + normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? + ''; const author = toNonEmptyString(change.attrs.author) ?? ''; const authorEmail = toNonEmptyString(change.attrs.authorEmail) ?? ''; const date = toNonEmptyString(change.attrs.date) ?? ''; @@ -134,36 +141,82 @@ function getWordRevisionIdKey(markType: string): keyof TrackChangeWordRevisionId return null; } +function getTrackedChangeGroupKey( + attrs: Readonly>, + markType: string, + fallbackId: string, +): string { + const sourceId = toNonEmptyString(attrs.sourceId); + return sourceId ? `word:${markType}:${sourceId}` : fallbackId; +} + +function isTrackedMarkName(markType: string | undefined): boolean { + return markType === TrackInsertMarkName || markType === TrackDeleteMarkName || markType === TrackFormatMarkName; +} + +export function getTrackedChangeMarkAlias(mark: { + readonly type: { readonly name: string }; + readonly attrs?: Readonly>; +}): string | null { + const markType = mark.type.name; + if (!isTrackedMarkName(markType)) return null; + const attrs = mark.attrs ?? {}; + const id = toNonEmptyString(attrs.id); + if (!id) return null; + return getTrackedChangeGroupKey(attrs, markType, id); +} + +function hasChildTrackedMarkOnNode(item: RawTrackedMark, parentId: string): boolean { + if (!parentId) return false; + const marks = Array.isArray(item.node?.marks) ? item.node.marks : []; + return marks.some((mark) => { + const markType = mark?.type?.name; + if (!isTrackedMarkName(markType)) return false; + return toNonEmptyString(mark?.attrs?.overlapParentId) === parentId; + }); +} + +function getTrackedMarkText(editor: Editor, item: RawTrackedMark): string { + const nodeText = item.node?.text; + if (typeof nodeText === 'string') return nodeText; + return editor.state.doc.textBetween(item.from, item.to, ' ', '\ufffc'); +} + export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const currentDoc = editor.state.doc; const cached = groupedCache.get(editor); if (cached && cached.doc === currentDoc) return cached.grouped; const marks = getRawTrackedMarks(editor); - const byRawId = new Map>(); + const byRawId = new Map(); for (const item of marks) { const attrs = item.mark?.attrs ?? {}; const id = toNonEmptyString(attrs.id); if (!id) continue; - const existing = byRawId.get(id); const markType = item.mark.type.name; + const groupKey = getTrackedChangeGroupKey(attrs, markType, id); + const existing = byRawId.get(groupKey); const nextHasInsert = markType === TrackInsertMarkName; const nextHasDelete = markType === TrackDeleteMarkName; const nextHasFormat = markType === TrackFormatMarkName; const wordRevisionId = toNonEmptyString(attrs.sourceId); const wordRevisionIdKey = getWordRevisionIdKey(markType); + const contributesToExcerpt = !hasChildTrackedMarkOnNode(item, id); + const excerptText = contributesToExcerpt ? getTrackedMarkText(editor, item) : ''; if (!existing) { - byRawId.set(id, { - rawId: id, + byRawId.set(groupKey, { + rawId: groupKey, + commandRawId: id, from: item.from, to: item.to, hasInsert: nextHasInsert, hasDelete: nextHasDelete, hasFormat: nextHasFormat, attrs: { ...attrs }, + excerptParts: excerptText ? [excerptText] : [], wordRevisionIds: wordRevisionIdKey ? mergeWordRevisionId(undefined, wordRevisionIdKey, wordRevisionId ?? undefined) : undefined, @@ -179,6 +232,9 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { if (Object.keys(existing.attrs).length === 0 && Object.keys(attrs).length > 0) { existing.attrs = { ...attrs }; } + if (excerptText) { + existing.excerptParts.push(excerptText); + } if (wordRevisionIdKey) { existing.wordRevisionIds = mergeWordRevisionId( existing.wordRevisionIds, @@ -189,10 +245,18 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } const grouped = Array.from(byRawId.values()) - .map((change) => ({ - ...change, - id: deriveTrackedChangeId(editor, change), - })) + .map(({ excerptParts, ...change }) => { + const hasWordSourceId = Boolean(toNonEmptyString(change.attrs.sourceId)); + const rawExcerpt = excerptParts.join(''); + const withExcerpt = { + ...change, + excerpt: excerptParts.length > 0 ? rawExcerpt : hasWordSourceId ? '' : undefined, + }; + return { + ...withExcerpt, + id: deriveTrackedChangeId(editor, withExcerpt), + }; + }) .sort((a, b) => { if (a.from !== b.from) return a.from - b.from; return a.id.localeCompare(b.id); @@ -209,7 +273,7 @@ export function resolveTrackedChange(editor: Editor, id: string): GroupedTracked export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId)?.id ?? null; + return grouped.find((item) => item.rawId === rawId || item.commandRawId === rawId)?.id ?? null; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -316,5 +380,5 @@ export function resolveTrackedChangeInStory( */ function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id || item.rawId === id) ?? null; + return grouped.find((item) => item.id === id || item.rawId === id || item.commandRawId === id) ?? null; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 7e684e500f..362fd0e3de 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -33,7 +33,11 @@ vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ resolveStoryRuntime: mocks.resolveStoryRuntime, })); -import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper } from './track-changes-wrappers.js'; +import { + trackChangesAcceptAllWrapper, + trackChangesAcceptWrapper, + trackChangesDecideRangeWrapper, +} from './track-changes-wrappers.js'; const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; @@ -44,6 +48,72 @@ function makeEditor(commands: Record = {}): Editor { } as unknown as Editor; } +function makeTextNode(text: string) { + return { + type: { name: 'text' }, + attrs: {}, + text, + nodeSize: text.length, + isText: true, + isLeaf: false, + isBlock: false, + childCount: 0, + child: () => { + throw new Error('text nodes do not have children'); + }, + }; +} + +function makeInlineWrapper(child: any) { + return { + type: { name: 'run' }, + attrs: {}, + nodeSize: child.nodeSize + 2, + isText: false, + isLeaf: false, + isBlock: false, + childCount: 1, + child: (index: number) => { + if (index !== 0) throw new Error('run child out of range'); + return child; + }, + }; +} + +function makeParagraphNode(attrs: Record, child: any = makeTextNode('abcdef')) { + return { + type: { name: 'paragraph' }, + attrs, + nodeSize: child.nodeSize + 2, + isText: false, + isLeaf: false, + isBlock: true, + childCount: 1, + child: (index: number) => { + if (index !== 0) throw new Error('paragraph child out of range'); + return child; + }, + }; +} + +function makeRangeDecisionEditor( + commands: Record, + block = makeParagraphNode({ sdBlockId: 'p1' }), + blockPos = 5, +): Editor { + return { + options: { trackedChanges: {} }, + commands, + state: { + doc: { + descendants: (fn: (node: unknown, pos: number) => void | boolean) => { + fn(block, blockPos); + }, + }, + }, + } as unknown as Editor; +} + beforeEach(() => { vi.clearAllMocks(); mocks.getRevision.mockReturnValue('0'); @@ -100,6 +170,47 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); }); + it('preserves typed overlap decision failures for by-id document-api calls', () => { + const hostEditor = makeEditor(); + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => false) }), + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'PERMISSION_DENIED', + message: 'permission denied for accept of change "canon-1".', + details: { changeId: 'canon-1' }, + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + mocks.executeDomainCommand.mockReturnValue({ steps: [{ effect: 'unchanged' }] }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'PERMISSION_DENIED', + message: 'permission denied for accept of change "canon-1".', + details: { changeId: 'canon-1' }, + }, + }); + }); + it('checks expectedRevision once on the host editor for accept-all across multiple stories', () => { const hostEditor = makeEditor(); const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); @@ -148,4 +259,78 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(bodyStory); expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); }); + + it('resolves range targets against v1 sdBlockId attributes', () => { + const acceptTrackedChangesBetween = vi.fn(() => true); + const invalidate = vi.fn(); + const hostEditor = makeRangeDecisionEditor({ acceptTrackedChangesBetween }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate, + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ success: true }); + expect(acceptTrackedChangesBetween).toHaveBeenCalledWith(8, 10); + expect(invalidate).toHaveBeenCalledWith({ kind: 'story', storyType: 'body' }); + }); + + it('resolves range targets through flattened text offsets for inline wrappers', () => { + const acceptTrackedChangesBetween = vi.fn(() => true); + const hostEditor = makeRangeDecisionEditor( + { acceptTrackedChangesBetween }, + makeParagraphNode({ sdBlockId: 'p1' }, makeInlineWrapper(makeTextNode('Hi'))), + 5, + ); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }); + + expect(receipt).toEqual({ success: true }); + expect(acceptTrackedChangesBetween).toHaveBeenCalledWith(7, 9); + }); + + it('preserves typed overlap decision failures for range document-api calls', () => { + const acceptTrackedChangesBetween = vi.fn(() => false); + const hostEditor = { + options: { trackedChanges: {} }, + commands: { acceptTrackedChangesBetween }, + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'TARGET_NOT_FOUND', + message: 'no tracked changes match the requested decision target.', + }, + }, + }, + state: makeRangeDecisionEditor({}).state, + } as unknown as Editor; + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'TARGET_NOT_FOUND', + message: 'no tracked changes match the requested decision target.', + details: { + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + story: undefined, + }, + }, + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index acd5b7d278..2769ff6e34 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -12,9 +12,11 @@ */ import type { Editor } from '../../core/Editor.js'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Receipt, RevisionGuardOptions, + TextTarget, TrackChangeInfo, TrackChangeWordRevisionIds, TrackChangesAcceptAllInput, @@ -31,6 +33,7 @@ import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@ import { DocumentApiAdapterError } from '../errors.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; +import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; import { checkRevision, getRevision } from './revision-tracker.js'; import { resolveTrackedChangeInStory, resolveTrackedChangeType } from '../helpers/tracked-change-resolver.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; @@ -86,6 +89,30 @@ function toNoOpReceipt(message: string, details?: unknown): Receipt { }; } +function decisionFailureReceipt(editor: Editor, fallbackMessage: string, fallbackDetails?: unknown): Receipt { + const storage = ( + editor as { + storage?: { + trackChanges?: { + lastDecisionFailure?: { code?: string; message?: string; details?: unknown } | null; + }; + }; + } + ).storage; + const failureInfo = storage?.trackChanges?.lastDecisionFailure ?? null; + if (!failureInfo?.code) { + return toNoOpReceipt(fallbackMessage, fallbackDetails); + } + return { + success: false, + failure: { + code: failureInfo.code as Extract['failure']['code'], + message: failureInfo.message ?? fallbackMessage, + details: failureInfo.details ?? fallbackDetails, + }, + }; +} + function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { if (!input || input.in === undefined) return 'body'; if (input.in === 'all') return 'all'; @@ -173,9 +200,9 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), authorImage: toNonEmptyString(resolved.change.attrs.authorImage), date: toNonEmptyString(resolved.change.attrs.date), - excerpt: normalizeExcerpt( - resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc'), - ), + excerpt: + (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? + normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')), }; } @@ -211,13 +238,18 @@ function decideSingle( checkRevision(hostEditor, options?.expectedRevision); - const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(resolved.change.rawId))); + const commandRawId = resolved.change.commandRawId ?? resolved.change.rawId; + const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(commandRawId))); if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, { - id, - story, - }); + return decisionFailureReceipt( + resolved.editor, + `${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, + { + id, + story, + }, + ); } if (resolved.commit) { @@ -282,7 +314,9 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu let applied = false; for (const snapshot of snapshots) { - if (perChangeCommand(snapshot.runtimeRef.rawId)) { + const resolved = resolveTrackedChangeInStory(editor, snapshot.address); + const commandRawId = resolved?.change.commandRawId ?? snapshot.runtimeRef.rawId; + if (perChangeCommand(commandRawId)) { applied = true; } } @@ -300,7 +334,10 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu } if (!anyApplied) { - return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); + return decisionFailureReceipt( + editor, + `${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`, + ); } return { success: true }; @@ -321,3 +358,96 @@ export function trackChangesRejectAllWrapper( ): Receipt { return decideAll(editor, 'reject', options); } + +// --------------------------------------------------------------------------- +// trackChanges.decide range targets +// --------------------------------------------------------------------------- + +/** + * Resolve a {@link TextTarget} into a single contiguous PM range within the + * body story. Multi-segment ranges are deferred (CAPABILITY_UNAVAILABLE) + * until fixtures land — partial decisions per phase0-004 only need a single + * contiguous selection. + */ +function resolveRangeToPmCoords(editor: Editor, range: TextTarget): { from: number; to: number } | null { + if (!range.segments?.length) return null; + if (range.segments.length > 1) return null; // multi-segment ranges deferred. + const seg = range.segments[0]; + const doc = editor.state.doc; + const block = findBlockStart(doc, seg.blockId); + if (!block) return null; + return resolveTextRangeInBlock(block.node, block.pos, seg.range); +} + +function findBlockStart( + doc: { + descendants: (fn: (node: ProseMirrorNode, pos: number) => boolean | void) => void; + }, + blockId: string, +): { node: ProseMirrorNode; pos: number } | null { + let found: { node: ProseMirrorNode; pos: number } | null = null; + doc.descendants((node, pos) => { + if (found !== null) return false; + const attrs = node.attrs as Record; + if ((attrs?.blockId ?? attrs?.sdBlockId ?? attrs?.id) === blockId) { + found = { node, pos }; + return false; + } + return true; + }); + return found; +} + +export function trackChangesDecideRangeWrapper( + editor: Editor, + input: { decision: 'accept' | 'reject'; range: TextTarget; story?: StoryLocator }, + options?: RevisionGuardOptions, +): Receipt { + // Story routing — for now partial-range decisions are implemented + // for the body story only. Non-body stories require structural plumbing + // owned by phase0-005 and fail closed here. + const story = input.story; + if (story && (story.kind !== 'story' || story.storyType !== 'body')) { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: 'trackChanges.decide range targets currently support the body story only.', + details: { story: input.story }, + }, + }; + } + const resolved = resolveRangeToPmCoords(editor, input.range); + if (!resolved) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'trackChanges.decide range could not be resolved to a contiguous PM coordinate.', + details: { range: input.range }, + }, + }; + } + checkRevision(editor, options?.expectedRevision); + + const commandName = input.decision === 'accept' ? 'acceptTrackedChangesBetween' : 'rejectTrackedChangesBetween'; + const command = (editor.commands as Record boolean) | undefined>)[commandName]; + if (typeof command !== 'function') { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: `${commandName} command is not available on the editor.`, + }, + }; + } + const applied = Boolean(command(resolved.from, resolved.to)); + if (!applied) { + return decisionFailureReceipt(editor, 'No tracked changes matched the requested decision target.', { + range: input.range, + story: input.story, + }); + } + getTrackedChangeIndex(editor).invalidate({ kind: 'story', storyType: 'body' }); + return { success: true }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts index 56e23a3d40..1e33501464 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts @@ -293,4 +293,33 @@ describe('TrackedChangeIndex — broadcast', () => { await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); + + // Phase 005 — v1-3220 collaboration requirement: remote (Yjs-origin) + // transactions still produce a `transaction` event with `docChanged: true` + // because the synced ProseMirror plugin applies steps locally. The + // tracked-change-index must broadcast `tracked-changes-changed` for those + // remote-origin transactions so bubble / sidebar / extract consumers see + // newly merged tracked marks without an extra local edit. + it('broadcasts after remote (Yjs-origin) transactions that change the doc', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + + // Simulate a remote Yjs update that mutated the document. The index does + // not inspect tr.meta(ySyncPluginKey) — it only requires docChanged so + // that remote applies trigger the same refresh path local edits use. + editor._emit('transaction', { transaction: { docChanged: true } }); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: 'body-edit', + stories: expect.arrayContaining([{ kind: 'story', storyType: 'body' }]), + }), + ); + void index; + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index 6d143f1640..0b461cfc46 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -195,7 +195,9 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { const runtimeRef: TrackedChangeRuntimeRef = { storyKey, rawId: change.rawId }; const address = buildTrackedChangeAddress(locator, storyKey, change.id); const type = resolveTrackedChangeType(change); - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); + const excerpt = + (change.excerpt !== undefined ? change.excerpt : undefined) ?? + normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); return { address, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts index c72c4b2362..75ce893298 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts @@ -862,6 +862,38 @@ describe('writeAdapter', () => { }); }); + it('preserves overlap compiler failure receipts when tracked write command does not apply', () => { + const { editor } = makeEditor('Hello'); + (editor as unknown as { options: Record; storage: Record }).options = { + user: { name: 'Test User' }, + trackedChanges: {}, + }; + (editor as unknown as { storage: Record }).storage = { + trackChanges: { + lastCompilerFailure: { + code: 'PRECONDITION_FAILED', + message: 'Tracked review graph has invariant errors before edit.', + }, + }, + }; + (editor.commands as { insertTrackedChange?: ReturnType }).insertTrackedChange = vi.fn(() => false); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + }); + it('supports direct dry-run without mutating editor state', () => { const { editor, dispatch, tr } = makeEditor('Hello'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index 264007aa17..30e2f092a8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -254,6 +254,15 @@ function applyTrackedWrite( }); if (!didApply) { + const lastCompilerFailure = (editor.storage?.trackChanges as { lastCompilerFailure?: ReceiptFailure } | undefined) + ?.lastCompilerFailure; + if (lastCompilerFailure) { + return { + success: false, + resolution: resolvedTarget.resolution, + failure: lastCompilerFailure, + }; + } return { success: false, resolution: resolvedTarget.resolution, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js index a69430070b..a9c1cd3d5a 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js @@ -1,4 +1,10 @@ import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.js'; +import { + classifyOwnership, + isSameUserHighConfidence, + getCurrentUserIdentity, + getChangeAuthorIdentity, +} from './review-model/identity.js'; const PERMISSION_MAP = { accept: { @@ -70,13 +76,15 @@ const derivePermissionKey = ({ action, isOwn }) => { return isOwn ? mapping.own : mapping.other; }; -const normalizeEmail = (value) => { - if (typeof value !== 'string') return ''; - return value.trim().toLowerCase(); -}; - const resolveChanges = (editor) => { - if (!editor) return { role: 'editor', isInternal: false, currentUser: null, resolver: null }; + if (!editor) { + return { + role: 'editor', + isInternal: false, + currentUser: null, + resolver: null, + }; + } const role = editor.options?.role ?? 'editor'; const isInternal = Boolean(editor.options?.isInternal); const currentUser = editor.options?.user ?? null; @@ -95,14 +103,17 @@ const resolveChanges = (editor) => { */ export const isTrackedChangeActionAllowed = ({ editor, action, trackedChanges }) => { if (!trackedChanges?.length) return true; - const { role, isInternal, currentUser, resolver } = resolveChanges(editor); + const { role, isInternal, resolver } = resolveChanges(editor); if (typeof resolver !== 'function') return true; - const currentEmail = normalizeEmail(currentUser?.email); + const currentIdentity = getCurrentUserIdentity(editor); return trackedChanges.every((change) => { - const authorEmail = normalizeEmail(change.attrs?.authorEmail); - const isOwn = !currentEmail || !authorEmail || currentEmail === authorEmail; + const classification = classifyOwnership({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(change.attrs ?? change), + }); + const isOwn = isSameUserHighConfidence(classification); const permission = derivePermissionKey({ action, isOwn }); if (!permission) return true; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js index 926c0b815d..5baf4447a6 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js @@ -101,8 +101,8 @@ describe('permission-helpers', () => { expect(mockResolver).toHaveBeenCalledTimes(2); }); - it('isTrackedChangeActionAllowed treats missing user email as own change', () => { - const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + it('isTrackedChangeActionAllowed treats missing user email as other change', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); editor.options.permissionResolver = mockResolver; editor.options.user = null; @@ -114,12 +114,12 @@ describe('permission-helpers', () => { expect(result).toBe(true); expect(mockResolver).toHaveBeenCalledWith( - expect.objectContaining({ permission: 'RESOLVE_OWN', trackedChange: expect.any(Object) }), + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), ); }); - it('isTrackedChangeActionAllowed treats missing author email as own change', () => { - const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + it('isTrackedChangeActionAllowed treats missing author email as other change', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); editor.options.permissionResolver = mockResolver; const result = isTrackedChangeActionAllowed({ @@ -130,7 +130,7 @@ describe('permission-helpers', () => { expect(result).toBe(true); expect(mockResolver).toHaveBeenCalledWith( - expect.objectContaining({ permission: 'RESOLVE_OWN', trackedChange: expect.any(Object) }), + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), ); }); @@ -167,4 +167,56 @@ describe('permission-helpers', () => { expect(result).toBe(false); expect(mockResolver).toHaveBeenCalledTimes(2); }); + + describe('overlap ownership routing', () => { + // Phase0-002 "Ownership Model": missing identity on either side must be + // treated as different-user under overlap. This is the documented + // customer-visible behavior. + beforeEach(() => { + editor.options.trackedChanges = { ...(editor.options.trackedChanges ?? {}) }; + }); + + it('missing change author email is no longer "own" under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); + editor.options.permissionResolver = mockResolver; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'no-author', attrs: { authorEmail: '' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OTHER' })); + }); + + it('missing current user email is no longer "own" under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); + editor.options.permissionResolver = mockResolver; + editor.options.user = null; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'x', attrs: { authorEmail: 'someone@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OTHER' })); + }); + + it('matching emails remain same-user under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + editor.options.permissionResolver = mockResolver; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'same', attrs: { authorEmail: 'owner@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OWN' })); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js new file mode 100644 index 0000000000..4f7f1ad09d --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js @@ -0,0 +1,149 @@ +// @ts-check +/** + * Comment effects plan computation for the tracked-change decision engine. + * + * Produces a {@link CommentEffectsPlan} describing how comment threads + * should change as a side effect of a tracked-change decision. Inputs are + * the planned coverage that will be removed/kept and the current PM doc; + * outputs are deterministic ids to delete or shrink plus PM-level anchor + * changes (removing `commentRangeStart` / `commentRangeEnd` / + * `commentReference` nodes that fall inside removed coverage). + * + * Boundary: this module does NOT mutate PM state. The decision engine + * applies the plan inside the atomic transaction once preflight succeeds. + */ + +/** + * @typedef {Object} CommentAnchorSpan + * @property {string} commentId + * @property {number} startPos `commentRangeStart` node position. + * @property {number} endPos `commentRangeEnd` node position. + * @property {number} referencePos `commentReference` position when present. + */ + +/** + * @typedef {Object} CommentNodeDelete + * @property {'commentRangeStart'|'commentRangeEnd'|'commentReference'} kind + * @property {number} from + * @property {number} to + * @property {string} commentId + */ + +/** + * @typedef {Object} CommentEffectsPlan + * @property {CommentNodeDelete[]} nodeDeletes PM ranges to remove. + * @property {Array<{ id: string, cause: string }>} entityDeletes Comment thread ids to remove. + * @property {Array<{ id: string, cause: string, anchor?: { from: number, to: number } }>} entityShrinks Comments whose anchor shrinks. + * @property {Array<{ code: string, message: string }>} diagnostics + */ + +const COMMENT_NODE_NAMES = new Set(['commentRangeStart', 'commentRangeEnd', 'commentReference']); + +/** + * Walk the document and produce the list of comment anchor spans. + * + * @param {import('prosemirror-model').Node} doc + * @returns {CommentAnchorSpan[]} + */ +export const enumerateCommentAnchors = (doc) => { + /** @type {Map} */ + const byId = new Map(); + if (!doc) return []; + doc.descendants((node, pos) => { + if (!COMMENT_NODE_NAMES.has(node.type.name)) return; + const id = node.attrs?.['w:id'] ?? node.attrs?.commentId ?? node.attrs?.attributes?.['w:id']; + if (id == null) return; + const key = String(id); + const existing = byId.get(key); + if (node.type.name === 'commentRangeStart') { + if (existing) { + existing.startPos = pos; + } else { + byId.set(key, { commentId: key, startPos: pos, endPos: -1, referencePos: -1 }); + } + } else if (node.type.name === 'commentRangeEnd') { + if (existing) { + existing.endPos = pos; + } else { + byId.set(key, { commentId: key, startPos: -1, endPos: pos, referencePos: -1 }); + } + } else if (node.type.name === 'commentReference') { + if (existing) { + existing.referencePos = pos; + } else { + byId.set(key, { commentId: key, startPos: -1, endPos: -1, referencePos: pos }); + } + } + }); + return Array.from(byId.values()); +}; + +/** + * Compute the comment effects plan for a set of PM ranges that will be + * removed from the document. Implements the rule subset required by + * phase0-004 Comment Effects and cross-feature-interactions.md: + * + * - comment anchor wholly inside removed coverage → delete the thread. + * - removed coverage spans the `commentRangeEnd` / `commentReference` + * boundary → delete the thread (asymmetric Word boundary rule). + * - removed coverage strictly inside an anchor (start preserved, end + * preserved) → shrink anchor; the anchor nodes themselves survive but + * the comment's coverage is reported as shrunken. + * - removed coverage at the left edge only → shrink anchor (commentRangeStart + * will be carried away by PM's range replacement; we still need to + * delete that anchor node and report a shrink with new bounds). + * + * @param {Object} input + * @param {import('prosemirror-model').Node} input.doc Document before mutation. + * @param {Array<{ from: number, to: number, cause: string }>} input.removedRanges Coverage to be removed. + * @returns {CommentEffectsPlan} + */ +export const planCommentEffects = ({ doc, removedRanges }) => { + /** @type {CommentEffectsPlan} */ + const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], diagnostics: [] }; + if (!doc || !removedRanges?.length) return plan; + const anchors = enumerateCommentAnchors(doc); + if (!anchors.length) return plan; + + const sortedRemoved = [...removedRanges].filter((r) => r.from < r.to).sort((a, b) => a.from - b.from); + + for (const anchor of anchors) { + const start = anchor.startPos; + const end = anchor.endPos; + const ref = anchor.referencePos; + if (start < 0 && end < 0 && ref < 0) continue; + const anchorStart = start >= 0 ? start : ref >= 0 ? ref : end; + const anchorEnd = end >= 0 ? end + 1 : ref >= 0 ? ref + 1 : start + 1; + const cause = sortedRemoved[0]?.cause ?? 'trackedChange'; + + // Determine whether any removed range covers the anchor end or reference. + const removesEnd = sortedRemoved.some((r) => end >= 0 && r.from <= end && r.to > end); + const removesRef = sortedRemoved.some((r) => ref >= 0 && r.from <= ref && r.to > ref); + const fullyCovered = sortedRemoved.some((r) => r.from <= anchorStart && r.to >= anchorEnd); + + if (fullyCovered || removesEnd || removesRef) { + plan.entityDeletes.push({ id: anchor.commentId, cause }); + if (start >= 0) + plan.nodeDeletes.push({ kind: 'commentRangeStart', from: start, to: start + 1, commentId: anchor.commentId }); + if (end >= 0) + plan.nodeDeletes.push({ kind: 'commentRangeEnd', from: end, to: end + 1, commentId: anchor.commentId }); + if (ref >= 0) + plan.nodeDeletes.push({ kind: 'commentReference', from: ref, to: ref + 1, commentId: anchor.commentId }); + continue; + } + + // Removed coverage overlaps the anchor but preserves end/reference: shrink. + const overlaps = sortedRemoved.some((r) => r.from < anchorEnd && r.to > anchorStart); + if (overlaps) { + plan.entityShrinks.push({ + id: anchor.commentId, + cause, + anchor: { from: anchorStart, to: anchorEnd }, + }); + // We do not remove the commentRangeStart/End nodes themselves when only + // shrinking; PM will reposition them when surrounding text is removed. + } + } + + return plan; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js new file mode 100644 index 0000000000..d2d8013710 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js @@ -0,0 +1,73 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; + +import { planCommentEffects } from './comment-effects.js'; + +const node = (name, id) => ({ + type: { name }, + attrs: { 'w:id': id }, +}); + +const fakeDoc = (entries) => ({ + descendants: (fn) => { + for (const entry of entries) { + fn(node(entry.name, entry.id), entry.pos); + } + }, +}); + +describe('planCommentEffects', () => { + it('deletes a comment thread when removed coverage wholly contains its anchor', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c1', pos: 3 }, + { name: 'commentRangeEnd', id: 'c1', pos: 7 }, + { name: 'commentReference', id: 'c1', pos: 8 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 3, to: 9, cause: 'reject-insertion:ins-1' }], + }); + + expect(result.entityDeletes).toEqual([{ id: 'c1', cause: 'reject-insertion:ins-1' }]); + expect(result.entityShrinks).toEqual([]); + expect(result.nodeDeletes.map(({ kind, from, to, commentId }) => ({ kind, from, to, commentId }))).toEqual([ + { kind: 'commentRangeStart', from: 3, to: 4, commentId: 'c1' }, + { kind: 'commentRangeEnd', from: 7, to: 8, commentId: 'c1' }, + { kind: 'commentReference', from: 8, to: 9, commentId: 'c1' }, + ]); + }); + + it('deletes a comment thread when removed coverage spans the end boundary', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c2', pos: 3 }, + { name: 'commentRangeEnd', id: 'c2', pos: 7 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 6, to: 8, cause: 'accept-deletion:del-1' }], + }); + + expect(result.entityDeletes).toEqual([{ id: 'c2', cause: 'accept-deletion:del-1' }]); + expect(result.entityShrinks).toEqual([]); + }); + + it('shrinks a comment thread when removed coverage is inside the anchor and preserves boundaries', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c3', pos: 3 }, + { name: 'commentRangeEnd', id: 'c3', pos: 8 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 5, to: 7, cause: 'partial-accept-deletion:del-2' }], + }); + + expect(result.entityDeletes).toEqual([]); + expect(result.nodeDeletes).toEqual([]); + expect(result.entityShrinks).toEqual([ + { id: 'c3', cause: 'partial-accept-deletion:del-2', anchor: { from: 3, to: 9 } }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js new file mode 100644 index 0000000000..4ec93f6271 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -0,0 +1,893 @@ +// @ts-check +/** + * Tracked-change decision engine. + * + * Single atomic entry point for accept/reject of one or more logical + * tracked changes within a story. Used by: + * + * - native commands (acceptTrackedChangeById / rejectTrackedChangeById / + * acceptTrackedChangesBetween / rejectTrackedChangesBetween / + * accept|rejectAllTrackedChanges) + * - document-api `trackChanges.decide` for id, range, and all targets + * - toolbar / context-menu wrappers via the existing resolveTrackedChangeAction + * + * Callers MUST treat `ok: false` as an abort and MUST NOT fall through to + * older mark-scan paths. + * + * The engine does NOT mutate PM state on failure. Preflight produces a + * complete mutation plan (PM ops + comment effects + bubble lifecycle + * payload + receipt entities) and the engine applies it under one + * transaction once preflight succeeds. + */ + +import { Slice } from 'prosemirror-model'; +import { AddMarkStep, RemoveMarkStep, ReplaceStep, Mapping } from 'prosemirror-transform'; + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { TrackChangesBasePluginKey } from '../plugins/index.js'; +import { findMarkInRangeBySnapshot } from '../trackChangesHelpers/markSnapshotHelpers.js'; + +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; +import { graphHasErrors } from './graph-invariants.js'; +import { + classifyOwnership, + getCurrentUserIdentity, + getChangeAuthorIdentity, + isSameUserHighConfidence, +} from './identity.js'; +import { planCommentEffects } from './comment-effects.js'; + +/** + * @typedef {'accept'|'reject'} ReviewDecision + */ + +/** + * @typedef {{ kind: 'id', id: string } + * | { kind: 'range', from: number, to: number } + * | { kind: 'all' }} NormalizedDecisionTarget + */ + +/** + * @typedef {Object} DecisionDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {Object} DecisionReceiptEntities + * @property {string[]} createdChangeIds successor fragment ids minted by partial-range decisions. + * @property {string[]} updatedChangeIds changes whose surviving coverage changed. + * @property {Array<{ id: string, cause?: string }>} removedChangeIds retired logical change ids. + * @property {Array<{ id: string, cause: string }>} deletedComments comment threads removed as side effects. + * @property {Array<{ id: string, cause: string }>} shrunkenComments comment threads that shrank. + * @property {Array<{ changeId: string }>} affectedChildren child ids that retired with their parent. + */ + +/** + * @typedef {Object} DecisionResult + * @property {true} ok + * @property {import('prosemirror-state').Transaction} tr Pending transaction the caller dispatches. + * @property {DecisionReceiptEntities} receipt + * @property {Set} touchedChangeIds Ids the bubble lifecycle should refresh. + * @property {DecisionDiagnostic[]} diagnostics + */ + +/** + * @typedef {Object} DecisionFailure + * @property {false} ok + * @property {'TARGET_NOT_FOUND'|'INVALID_TARGET'|'REVISION_MISMATCH'|'PERMISSION_DENIED'|'CAPABILITY_UNAVAILABLE'|'PRECONDITION_FAILED'|'COMMENT_CASCADE_PARTIAL'|'NO_OP'} code + * @property {string} message + * @property {DecisionDiagnostic[]} [diagnostics] + * @property {unknown} [details] + */ + +const TRACKED_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +const failure = (code, message, extra) => ({ ok: false, code, message, ...(extra || {}) }); + +/** + * Plan and apply (or fail closed) a tracked-change decision. + * + * @param {Object} input + * @param {import('prosemirror-state').EditorState} input.state PM editor state at decision time. + * @param {object} input.editor v1 editor; used for permission resolver + identity context. + * @param {ReviewDecision} input.decision + * @param {object} input.target Raw target shape (id, range, all, legacy aliases). + * @param {'paired'|'independent'} [input.replacements] replacements mode. + * @returns {DecisionResult | DecisionFailure} + */ +export const decideTrackedChanges = ({ state, editor, decision, target, replacements = 'paired' }) => { + if (decision !== 'accept' && decision !== 'reject') { + return failure('INVALID_TARGET', `decision must be "accept" or "reject" (got "${String(decision)}").`); + } + + const normalized = normalizeDecisionTarget(target); + if (!normalized.ok) return normalized.failure; + + const graph = buildReviewGraph({ + state, + replacementsMode: replacements, + }); + if (graphHasErrors(graph)) { + return failure('PRECONDITION_FAILED', 'tracked review graph has invariant errors before decision.', { + diagnostics: graph.validate(), + }); + } + + // Resolve the target into a set of selections describing which logical + // changes get resolved and how (full vs partial coverage). The selections + // are deterministic across undo/redo and collaboration replay because they + // derive from logical change ids and normalized offsets, not transient PM + // positions. + const selectionResult = resolveTargetToSelections({ graph, normalized: normalized.value }); + if (!selectionResult.ok) return selectionResult.failure; + const { selections } = selectionResult; + if (!selections.length) { + return failure('TARGET_NOT_FOUND', 'no tracked changes match the requested decision target.'); + } + + // Permission preflight — call once per logical change. One denial aborts. + const permissionResult = runPermissionPreflight({ editor, decision, selections }); + if (!permissionResult.ok) return permissionResult.failure; + + // Compute the PM mutation plan + comment effects. + const planResult = buildMutationPlan({ state, graph, selections, decision, replacements }); + if (!planResult.ok) return planResult.failure; + const { plan } = planResult; + + // Apply the plan atomically. + const applyResult = applyPlan({ state, plan }); + if (!applyResult.ok) return applyResult.failure; + + return { + ok: true, + tr: applyResult.tr, + receipt: applyResult.receipt, + touchedChangeIds: applyResult.touchedChangeIds, + diagnostics: plan.diagnostics, + }; +}; + +// --------------------------------------------------------------------------- +// Target normalization +// --------------------------------------------------------------------------- + +/** + * Normalize the raw target shape into the canonical + * `{ kind: 'id'|'range'|'all' }` form. Accepts legacy aliases: + * - `{ id: string }` → `{ kind: 'id', id }` + * - `{ scope: 'all' }` → `{ kind: 'all' }` + * - `{ from, to }` → `{ kind: 'range', from, to }` + * - canonical `{ kind: 'id'|'range'|'all' }` is passed through. + */ +const normalizeDecisionTarget = (target) => { + if (!target || typeof target !== 'object') { + return { ok: false, failure: failure('INVALID_TARGET', 'decision target must be an object.') }; + } + const t = /** @type {Record} */ (target); + if (t.kind === 'id') { + if (typeof t.id !== 'string' || !t.id) { + return { ok: false, failure: failure('INVALID_TARGET', 'target.kind = "id" requires a non-empty id.') }; + } + return { ok: true, value: { kind: 'id', id: t.id } }; + } + if (t.kind === 'range') { + const from = Number(t.from); + const to = Number(t.to); + if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || to < 0 || from > to) { + return { ok: false, failure: failure('INVALID_TARGET', 'target.kind = "range" requires from <= to.') }; + } + return { ok: true, value: { kind: 'range', from, to } }; + } + if (t.kind === 'all') { + return { ok: true, value: { kind: 'all' } }; + } + // Legacy aliases. + if (typeof t.id === 'string' && t.id) { + return { ok: true, value: { kind: 'id', id: t.id } }; + } + if (t.scope === 'all') { + return { ok: true, value: { kind: 'all' } }; + } + if (Number.isFinite(t.from) && Number.isFinite(t.to)) { + const from = Number(t.from); + const to = Number(t.to); + if (from > to) { + return { ok: false, failure: failure('INVALID_TARGET', 'range target requires from <= to.') }; + } + return { ok: true, value: { kind: 'range', from, to } }; + } + return { ok: false, failure: failure('INVALID_TARGET', 'decision target shape was not recognised.') }; +}; + +// --------------------------------------------------------------------------- +// Target → selections +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} ChangeSelection + * @property {import('./review-graph.js').LogicalTrackedChange} change + * @property {'full'|'partial'} coverage `full` resolves whole logical change. + * @property {Array<{ from: number, to: number }>} ranges Concrete PM ranges to resolve. + */ + +const resolveTargetToSelections = ({ graph, normalized }) => { + if (normalized.kind === 'all') { + /** @type {ChangeSelection[]} */ + const sel = []; + for (const change of graph.changes.values()) { + sel.push({ change, coverage: 'full', ranges: change.segments.map((s) => ({ from: s.from, to: s.to })) }); + } + // Document-order sort to make the apply pass deterministic and to keep + // reverse-order step application stable. + sel.sort((a, b) => firstFrom(a) - firstFrom(b)); + return { ok: true, selections: sel }; + } + if (normalized.kind === 'id') { + const change = graph.changes.get(normalized.id); + if (!change) + return { ok: false, failure: failure('TARGET_NOT_FOUND', `no tracked change with id "${normalized.id}".`) }; + return { + ok: true, + selections: [ + { + change, + coverage: 'full', + ranges: change.segments.map((s) => ({ from: s.from, to: s.to })), + }, + ], + }; + } + // range + const { from, to } = normalized; + /** @type {Map} */ + const byId = new Map(); + for (const segment of graph.segments) { + const overlapFrom = Math.max(segment.from, from); + const overlapTo = Math.min(segment.to, to); + if (overlapFrom >= overlapTo) { + // collapsed cursor inside a segment also counts as "select whole change" + // per phase0-004 "Range Decisions": collapsed range inside a change + // resolves the whole logical change. + if (from === to && segment.from <= from && segment.to > from) { + const change = graph.changes.get(segment.changeId); + if (!change) continue; + const existing = byId.get(change.id); + if (existing) { + existing.coverage = 'full'; + existing.ranges = change.segments.map((s) => ({ from: s.from, to: s.to })); + } else { + byId.set(change.id, { + change, + coverage: 'full', + ranges: change.segments.map((s) => ({ from: s.from, to: s.to })), + }); + } + } + continue; + } + const change = graph.changes.get(segment.changeId); + if (!change) continue; + const existing = byId.get(change.id); + if (existing) { + existing.ranges.push({ from: overlapFrom, to: overlapTo }); + // Promote to full if the union covers all segments of the change. + if (rangesCoverChange(existing.ranges, change)) { + existing.coverage = 'full'; + existing.ranges = change.segments.map((s) => ({ from: s.from, to: s.to })); + } else { + existing.coverage = 'partial'; + } + continue; + } + const isFull = + segment.from >= from && segment.to <= to && change.segments.every((s) => s.from >= from && s.to <= to); + byId.set(change.id, { + change, + coverage: isFull ? 'full' : 'partial', + ranges: [{ from: overlapFrom, to: overlapTo }], + }); + } + // Sort selections by first PM position so apply order is deterministic. + const sel = Array.from(byId.values()).sort((a, b) => firstFrom(a) - firstFrom(b)); + return { ok: true, selections: sel }; +}; + +const firstFrom = (selection) => selection.ranges[0]?.from ?? 0; + +const rangesCoverChange = (ranges, change) => { + const sorted = [...ranges].sort((a, b) => a.from - b.from); + // Merge into max envelope ranges + /** @type {Array<{from:number,to:number}>} */ + const merged = []; + for (const r of sorted) { + const last = merged[merged.length - 1]; + if (last && r.from <= last.to) { + last.to = Math.max(last.to, r.to); + } else { + merged.push({ from: r.from, to: r.to }); + } + } + return change.segments.every((seg) => merged.some((r) => r.from <= seg.from && r.to >= seg.to)); +}; + +// --------------------------------------------------------------------------- +// Permission preflight +// --------------------------------------------------------------------------- + +const runPermissionPreflight = ({ editor, decision, selections }) => { + const resolver = editor?.options?.permissionResolver; + if (typeof resolver !== 'function') return { ok: true }; + + const role = editor.options?.role ?? 'editor'; + const isInternal = Boolean(editor.options?.isInternal); + const currentIdentity = getCurrentUserIdentity(editor); + + for (const selection of selections) { + const change = selection.change; + const classification = classifyOwnership({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(change.author ? { author: change.author, authorEmail: change.authorEmail } : {}), + }); + const isOwn = isSameUserHighConfidence(classification); + const permission = + decision === 'accept' ? (isOwn ? 'RESOLVE_OWN' : 'RESOLVE_OTHER') : isOwn ? 'REJECT_OWN' : 'REJECT_OTHER'; + + const allowed = resolver({ + permission, + role, + isInternal, + trackedChange: { + id: change.id, + type: change.type, + attrs: { author: change.author, authorEmail: change.authorEmail, date: change.date }, + from: selection.ranges[0]?.from ?? 0, + to: selection.ranges[selection.ranges.length - 1]?.to ?? 0, + segments: change.segments.map((s) => ({ from: s.from, to: s.to })), + commentId: change.id, + }, + comment: null, + }); + if (allowed === false) { + return { + ok: false, + failure: failure('PERMISSION_DENIED', `permission denied for ${decision} of change "${change.id}".`, { + details: { changeId: change.id, permission }, + }), + }; + } + } + return { ok: true }; +}; + +// --------------------------------------------------------------------------- +// Mutation plan +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} MutationOp + * @property {'removeContent'|'removeMark'|'addMark'|'unwrapInsert'|'restoreFormat'|'removeFormat'} kind + * @property {number} from + * @property {number} to + * @property {string} [changeId] + * @property {string} [side] + * @property {import('prosemirror-model').Mark} [mark] + * @property {Array} [beforeMarks] + * @property {Array} [afterMarks] + */ + +/** + * @typedef {Object} MutationPlan + * @property {MutationOp[]} ops Document-order op list. + * @property {import('./comment-effects.js').CommentEffectsPlan} commentEffects + * @property {Set} touchedChangeIds Logical ids retired/updated by the decision. + * @property {Set} retiredChangeIds Logical ids retired by the decision (subset). + * @property {DecisionDiagnostic[]} diagnostics + */ + +const buildMutationPlan = ({ state, graph, selections, decision, replacements }) => { + /** @type {MutationOp[]} */ + const ops = []; + /** @type {Array<{ from: number, to: number, cause: string }>} */ + const removedRanges = []; + /** @type {Set} */ + const touched = new Set(); + /** @type {Set} */ + const retired = new Set(); + /** @type {DecisionDiagnostic[]} */ + const diagnostics = []; + + for (const selection of selections) { + const { change } = selection; + const isFull = selection.coverage === 'full'; + if (!isFull) { + if (change.type === CanonicalChangeType.Replacement) { + return { + ok: false, + failure: failure( + 'CAPABILITY_UNAVAILABLE', + 'partial-range replacement decisions are not yet fixture-backed.', + { + details: { changeId: change.id }, + }, + ), + }; + } + if (change.type === CanonicalChangeType.Formatting) { + return { + ok: false, + failure: failure('CAPABILITY_UNAVAILABLE', 'partial-range formatting decisions are not yet fixture-backed.', { + details: { changeId: change.id }, + }), + }; + } + } + touched.add(change.id); + + if (!isFull && (change.type === CanonicalChangeType.Insertion || change.type === CanonicalChangeType.Deletion)) { + const partialResult = planPartialTextDecision({ + ops, + change, + selection, + decision, + removedRanges, + retired, + diagnostics, + }); + if (!partialResult.ok) return { ok: false, failure: partialResult.failure }; + for (const id of partialResult.createdChangeIds) touched.add(id); + } else if (change.type === CanonicalChangeType.Insertion) { + planInsertionDecision({ ops, change, selection, decision, removedRanges, retired }); + } else if (change.type === CanonicalChangeType.Deletion) { + planDeletionDecision({ ops, change, selection, decision, removedRanges, retired }); + } else if (change.type === CanonicalChangeType.Replacement) { + const repResult = planReplacementDecision({ ops, change, decision, removedRanges, retired }); + if (!repResult.ok) return { ok: false, failure: repResult.failure }; + } else if (change.type === CanonicalChangeType.Formatting) { + planFormattingDecision({ ops, change, decision, retired, state }); + } else { + return { + ok: false, + failure: failure( + 'CAPABILITY_UNAVAILABLE', + `unsupported change type "${change.type}" for change "${change.id}".`, + ), + }; + } + } + + if (!ops.length) { + return { + ok: false, + failure: failure('NO_OP', 'decision target produced no operations.', { + details: { selections: selections.map((s) => s.change.id) }, + }), + }; + } + + // Identify child changes wholly inside removed ranges and mark them as + // retired side effects. Per phase0-004 "Parent/Child Decision Rules": + // accepting/rejecting a parent insertion retires children inside removed + // content; accepting a parent deletion retires children wholly inside the + // removed content; rejecting a parent deletion removes child insertions + // that were meaningful only inside it. + /** @type {Array<{ changeId: string }>} */ + const affectedChildren = []; + for (const change of graph.changes.values()) { + if (touched.has(change.id)) continue; + if (!change.parent) continue; + if (!retired.has(change.parent) && !touched.has(change.parent)) continue; + const inside = change.segments.every((seg) => removedRanges.some((r) => r.from <= seg.from && r.to >= seg.to)); + if (inside) { + retired.add(change.id); + touched.add(change.id); + affectedChildren.push({ changeId: change.id }); + } + } + + const commentEffects = planCommentEffects({ doc: state.doc, removedRanges }); + + // Convert comment node deletions into removeContent ops so apply respects + // the same reverse-order pass. Removing the anchor nodes from inside + // already-removed coverage is harmless (PM clips); explicitly listing them + // makes shrink+keep cases idempotent. + for (const del of commentEffects.nodeDeletes) { + ops.push({ kind: 'removeContent', from: del.from, to: del.to }); + } + + /** @type {MutationPlan} */ + const plan = { + ops, + commentEffects: { ...commentEffects, _affectedChildren: affectedChildren }, + touchedChangeIds: touched, + retiredChangeIds: retired, + diagnostics, + }; + return { ok: true, plan }; +}; + +const planInsertionDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const isFull = selection.coverage === 'full'; + if (decision === 'accept') { + // Accept insertion: keep content, remove the trackInsert mark. + const ranges = isFull ? change.insertedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + const segment = + change.insertedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.insertedSegments[0]; + if (!segment) continue; + ops.push({ + kind: 'removeMark', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Inserted, + mark: segment.mark, + }); + } + if (isFull) retired.add(change.id); + return; + } + // Reject insertion: remove inserted content. + const ranges = isFull ? change.insertedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + ops.push({ + kind: 'removeContent', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Inserted, + }); + removedRanges.push({ from: range.from, to: range.to, cause: `reject-insertion:${change.id}` }); + } + if (isFull) retired.add(change.id); +}; + +const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const isFull = selection.coverage === 'full'; + if (decision === 'accept') { + // Accept deletion: remove tracked-deleted content permanently. + const ranges = isFull ? change.deletedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + ops.push({ + kind: 'removeContent', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Deleted, + }); + removedRanges.push({ from: range.from, to: range.to, cause: `accept-deletion:${change.id}` }); + } + if (isFull) retired.add(change.id); + return; + } + // Reject deletion: remove the trackDelete mark; content stays as live. + const ranges = isFull ? change.deletedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + const segment = + change.deletedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.deletedSegments[0]; + if (!segment) continue; + ops.push({ + kind: 'removeMark', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Deleted, + mark: segment.mark, + }); + } + if (isFull) retired.add(change.id); +}; + +const planReplacementDecision = ({ ops, change, decision, removedRanges, retired }) => { + const inserted = change.insertedSegments; + const deleted = change.deletedSegments; + if (!inserted.length || !deleted.length) { + return { + ok: false, + failure: failure('PRECONDITION_FAILED', `replacement "${change.id}" missing inserted or deleted side.`), + }; + } + if (decision === 'accept') { + for (const seg of deleted) { + ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side: SegmentSide.Deleted }); + removedRanges.push({ from: seg.from, to: seg.to, cause: `accept-replacement-deleted:${change.id}` }); + } + for (const seg of inserted) { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Inserted, + mark: seg.mark, + }); + } + } else { + // Reject replacement: remove inserted side, restore deleted side as live. + for (const seg of inserted) { + ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side: SegmentSide.Inserted }); + removedRanges.push({ from: seg.from, to: seg.to, cause: `reject-replacement-inserted:${change.id}` }); + } + for (const seg of deleted) { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Deleted, + mark: seg.mark, + }); + } + } + retired.add(change.id); + return { ok: true }; +}; + +const planFormattingDecision = ({ ops, change, decision, retired }) => { + for (const seg of change.formattingSegments) { + if (decision === 'accept') { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: seg.mark, + }); + } else { + ops.push({ + kind: 'restoreFormat', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: seg.mark, + beforeMarks: seg.mark.attrs?.before ?? [], + afterMarks: seg.mark.attrs?.after ?? [], + }); + } + } + retired.add(change.id); +}; + +const planPartialTextDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const side = change.type === CanonicalChangeType.Insertion ? SegmentSide.Inserted : SegmentSide.Deleted; + const segments = side === SegmentSide.Inserted ? change.insertedSegments : change.deletedSegments; + if (!segments.length) { + return { ok: false, failure: failure('PRECONDITION_FAILED', `change "${change.id}" has no ${side} segments.`) }; + } + + const selectedRanges = mergeRanges(selection.ranges); + const successorRanges = []; + let logicalOffset = 0; + let successorOrdinal = 0; + + for (const segment of segments) { + ops.push({ kind: 'removeMark', from: segment.from, to: segment.to, changeId: change.id, side, mark: segment.mark }); + + const pieces = subtractRanges({ from: segment.from, to: segment.to }, selectedRanges); + for (const piece of pieces) { + const offsetStart = logicalOffset + (piece.from - segment.from); + const offsetEnd = logicalOffset + (piece.to - segment.from); + const successorId = deterministicSuccessorId({ + sourceId: change.id, + revisionGroupId: segment.attrs?.revisionGroupId || change.revisionGroupId || change.id, + side, + offsetStart, + offsetEnd, + decision, + ordinal: successorOrdinal, + }); + successorOrdinal += 1; + const successorMark = segment.mark.type.create({ + ...segment.mark.attrs, + id: successorId, + splitFromId: change.id, + revisionGroupId: segment.attrs?.revisionGroupId || change.revisionGroupId || change.id, + }); + ops.push({ kind: 'addMark', from: piece.from, to: piece.to, changeId: successorId, side, mark: successorMark }); + successorRanges.push({ id: successorId, from: piece.from, to: piece.to }); + } + logicalOffset += segment.to - segment.from; + } + + for (const range of selectedRanges) { + if (change.type === CanonicalChangeType.Insertion && decision === 'reject') { + ops.push({ kind: 'removeContent', from: range.from, to: range.to, changeId: change.id, side }); + removedRanges.push({ from: range.from, to: range.to, cause: `partial-reject-insertion:${change.id}` }); + } else if (change.type === CanonicalChangeType.Deletion && decision === 'accept') { + ops.push({ kind: 'removeContent', from: range.from, to: range.to, changeId: change.id, side }); + removedRanges.push({ from: range.from, to: range.to, cause: `partial-accept-deletion:${change.id}` }); + } + } + + retired.add(change.id); + return { ok: true, createdChangeIds: successorRanges.map((entry) => entry.id) }; +}; + +const mergeRanges = (ranges) => { + const sorted = ranges + .filter((range) => range.from < range.to) + .map((range) => ({ from: range.from, to: range.to })) + .sort((a, b) => a.from - b.from || a.to - b.to); + const merged = []; + for (const range of sorted) { + const last = merged[merged.length - 1]; + if (last && range.from <= last.to) { + last.to = Math.max(last.to, range.to); + } else { + merged.push(range); + } + } + return merged; +}; + +const subtractRanges = (range, removals) => { + let pieces = [range]; + for (const removal of removals) { + const next = []; + for (const piece of pieces) { + if (removal.to <= piece.from || removal.from >= piece.to) { + next.push(piece); + continue; + } + if (removal.from > piece.from) next.push({ from: piece.from, to: Math.min(removal.from, piece.to) }); + if (removal.to < piece.to) next.push({ from: Math.max(removal.to, piece.from), to: piece.to }); + } + pieces = next; + } + return pieces.filter((piece) => piece.from < piece.to); +}; + +const deterministicSuccessorId = ({ sourceId, revisionGroupId, side, offsetStart, offsetEnd, decision, ordinal }) => { + const input = `${sourceId}|${revisionGroupId}|${side}|${offsetStart}|${offsetEnd}|${decision}|${ordinal}`; + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `${sourceId}~${side}~${(hash >>> 0).toString(36)}`; +}; + +// --------------------------------------------------------------------------- +// Plan application +// --------------------------------------------------------------------------- + +const applyPlan = ({ state, plan }) => { + const tr = state.tr; + tr.setMeta('inputType', 'acceptReject'); + + // Mark mutations are position-stable and must run before content deletion: + // partial decisions remove the source mark, add successor marks to surviving + // text, then delete only the selected text when the decision semantics ask + // for removal. Applying all ops in one reversed pass can make a broad source + // mark removal erase freshly-added successor marks. + const sortedOps = [...plan.ops].sort((a, b) => a.from - b.from || a.to - b.to); + const markOps = sortedOps.filter((op) => op.kind !== 'removeContent'); + const contentOps = sortedOps.filter((op) => op.kind === 'removeContent').reverse(); + + try { + for (const op of markOps) { + if (op.kind === 'removeMark' && op.mark) { + tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); + continue; + } + if (op.kind === 'addMark' && op.mark) { + tr.step(new AddMarkStep(op.from, op.to, op.mark)); + continue; + } + if (op.kind === 'restoreFormat' && op.mark) { + // Remove the "after" marks first so the restored "before" marks aren't + // shadowed by overlap matching (mirrors legacy rejectTrackedChangesBetween). + for (const afterSnapshot of op.afterMarks ?? []) { + const liveMark = findMarkInRangeBySnapshot({ + doc: tr.doc, + from: op.from, + to: op.to, + snapshot: afterSnapshot, + }); + if (liveMark) { + tr.step(new RemoveMarkStep(op.from, op.to, liveMark)); + } + } + for (const beforeSnapshot of op.beforeMarks ?? []) { + const markType = state.schema.marks[beforeSnapshot.type]; + if (!markType) continue; + tr.step(new AddMarkStep(op.from, op.to, markType.create(beforeSnapshot.attrs))); + } + tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); + continue; + } + } + for (const op of contentOps) { + tr.step(new ReplaceStep(op.from, op.to, Slice.empty)); + } + } catch (error) { + return { + ok: false, + failure: failure( + 'PRECONDITION_FAILED', + /** @type {Error} */ (error).message ?? 'failed to apply mutation plan.', + { + details: { error: String(error) }, + }, + ), + }; + } + + // Tracked-change plugin meta — preserve compatibility with the comments + // plugin which listens for tracked-change resolution to update bubbles. + tr.setMeta(TrackChangesBasePluginKey, { + insertedMark: null, + deletionMark: null, + deletionNodes: [], + step: null, + emitCommentEvent: true, + decisionTouchedChangeIds: Array.from(plan.touchedChangeIds), + decisionRetiredChangeIds: Array.from(plan.retiredChangeIds), + }); + tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta('skipTrackChanges', true); + + return { + ok: true, + tr, + touchedChangeIds: plan.touchedChangeIds, + receipt: buildReceipt({ plan }), + }; +}; + +const buildReceipt = ({ plan }) => { + /** @type {DecisionReceiptEntities} */ + const receipt = { + createdChangeIds: collectCreatedChangeIds(plan), + updatedChangeIds: [], + removedChangeIds: Array.from(plan.retiredChangeIds).map((id) => ({ id, cause: 'decision' })), + deletedComments: plan.commentEffects.entityDeletes, + shrunkenComments: plan.commentEffects.entityShrinks.map(({ id, cause }) => ({ id, cause })), + affectedChildren: plan.commentEffects._affectedChildren ?? [], + }; + return receipt; +}; + +const collectCreatedChangeIds = (plan) => { + const ids = new Set(); + for (const op of plan.ops) { + if (op.kind === 'addMark' && op.changeId) ids.add(op.changeId); + } + return Array.from(ids); +}; + +// --------------------------------------------------------------------------- +// Bubble lifecycle support +// --------------------------------------------------------------------------- + +/** + * Build the bubble lifecycle payload from a successful decision result. The + * caller (acceptTrackedChangesBetween wrapper) emits this through the editor + * once the transaction dispatches so consumers update from decision data, + * not from re-scanning marks after dispatch. + * + * @param {Object} input + * @param {DecisionResult} input.result + * @param {object} input.editor + * @returns {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} + */ +export const buildDecisionBubbleEvents = ({ result, editor }) => { + const resolvedByEmail = editor?.options?.user?.email; + const resolvedByName = editor?.options?.user?.name; + const events = []; + for (const entry of result.receipt.removedChangeIds) { + events.push({ type: 'trackedChange', event: 'resolve', changeId: entry.id, resolvedByEmail, resolvedByName }); + } + for (const child of result.receipt.affectedChildren) { + events.push({ type: 'trackedChange', event: 'resolve', changeId: child.changeId, resolvedByEmail, resolvedByName }); + } + return events; +}; + +export { TRACKED_MARK_NAMES }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js new file mode 100644 index 0000000000..9f5a719d84 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js @@ -0,0 +1,604 @@ +// @ts-check +/** + * Overlap decision engine unit tests. + * + * Validates the canonical accept/reject behavior, atomicity, partial-range + * shape, parent/child rules, and comment effects produced by the + * decision engine against a real PM schema with tracked marks. + */ + +import { describe, it, expect } from 'vitest'; + +import { decideTrackedChanges } from './decision-engine.js'; +import { buildReviewGraph } from './review-graph.js'; +import { createReviewGraphTestSchema, stateFromTrackedSpans, markAttrs } from './test-fixtures.js'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +const SAME_USER = { name: 'Alice', email: 'alice@example.com' }; +const OTHER_USER = { name: 'Bob', email: 'bob@example.com' }; + +const editorFor = (user, extra) => ({ + options: { + user, + trackedChanges: {}, + ...extra, + }, + storage: { trackChanges: { lastDecisionFailure: null } }, +}); + +const insertAttrs = (id, user = SAME_USER, extra = {}) => + markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'insertion', + ...extra, + }); + +const deleteAttrs = (id, user = SAME_USER, extra = {}) => + markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'deletion', + ...extra, + }); + +const formatAttrsWithSnapshots = (id, user = SAME_USER, before = [], after = []) => ({ + ...markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'formatting', + }), + before, + after, +}); + +describe('decideTrackedChanges overlap behavior', () => { + it('accept insertion by id keeps content and removes the trackInsert mark', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'before ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-1') }] }, + { text: ' after' }, + ], + }); + const editor = editorFor(SAME_USER); + + const result = decideTrackedChanges({ + state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'ins-1' }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds).toEqual([{ id: 'ins-1', cause: 'decision' }]); + const nextState = state.apply(result.tr); + expect(nextState.doc.textContent).toBe('before NEW after'); + nextState.doc.nodesBetween(0, nextState.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackInsertMarkName); + } + }); + }); + + it('reject insertion by id removes inserted content atomically', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'keep ' }, + { text: 'BAD', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-2') }] }, + { text: ' tail' }, + ], + }); + const editor = editorFor(SAME_USER); + + const result = decideTrackedChanges({ + state, + editor, + decision: 'reject', + target: { kind: 'id', id: 'ins-2' }, + }); + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('keep tail'); + }); + + it('accept deletion removes content; reject deletion drops the mark and keeps content', () => { + const schema = createReviewGraphTestSchema(); + const accept = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'CUT', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-1') }] }, + { text: ' B' }, + ], + }); + const editor = editorFor(SAME_USER); + const acceptResult = decideTrackedChanges({ + state: accept.state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'del-1' }, + }); + expect(acceptResult.ok).toBe(true); + expect(accept.state.apply(acceptResult.tr).doc.textContent).toBe('A B'); + + const reject = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'CUT', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-2') }] }, + { text: ' B' }, + ], + }); + const rejectResult = decideTrackedChanges({ + state: reject.state, + editor, + decision: 'reject', + target: { kind: 'id', id: 'del-2' }, + }); + expect(rejectResult.ok).toBe(true); + const next = reject.state.apply(rejectResult.tr); + expect(next.doc.textContent).toBe('A CUT B'); + next.doc.nodesBetween(0, next.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackDeleteMarkName); + } + }); + }); + + it('accept all retires every open change', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'pre ' }, + { text: 'INS', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-3') }] }, + { text: ' mid ' }, + { text: 'DEL', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-3') }] }, + { text: ' post' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'all' }, + }); + expect(result.ok).toBe(true); + const ids = result.receipt.removedChangeIds.map((entry) => entry.id).sort(); + expect(ids).toEqual(['del-3', 'ins-3']); + expect(state.apply(result.tr).doc.textContent).toBe('pre INS mid post'); + }); + + it('accept paired replacement removes deleted side and keeps inserted side', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'a ' }, + { + text: 'OLD', + marks: [ + { + markType: TrackDeleteMarkName, + attrs: deleteAttrs('rep-1', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-1', + replacementSideId: 'rep-1#deleted', + }), + }, + ], + }, + { + text: 'NEW', + marks: [ + { + markType: TrackInsertMarkName, + attrs: insertAttrs('rep-1', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-1', + replacementSideId: 'rep-1#inserted', + }), + }, + ], + }, + { text: ' b' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'rep-1' }, + }); + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('a NEW b'); + }); + + it('reject paired replacement restores deleted side and removes inserted side', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'a ' }, + { + text: 'OLD', + marks: [ + { + markType: TrackDeleteMarkName, + attrs: deleteAttrs('rep-2', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-2', + replacementSideId: 'rep-2#deleted', + }), + }, + ], + }, + { + text: 'NEW', + marks: [ + { + markType: TrackInsertMarkName, + attrs: insertAttrs('rep-2', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-2', + replacementSideId: 'rep-2#inserted', + }), + }, + ], + }, + { text: ' b' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'rep-2' }, + }); + expect(result.ok).toBe(true); + expect(state.apply(result.tr).doc.textContent).toBe('a OLD b'); + }); + + it('formatting accept removes the trackFormat mark; reject restores the before snapshot', () => { + const schema = createReviewGraphTestSchema(); + const beforeSnap = [{ type: TrackInsertMarkName, attrs: insertAttrs('inner-ins') }]; + const afterSnap = [{ type: TrackInsertMarkName, attrs: insertAttrs('inner-ins-new') }]; + // To exercise mark restoration we'd need a richer mark set; here we just + // verify the engine handles formatting decisions structurally. + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'FORMATTED', + marks: [ + { + markType: TrackFormatMarkName, + attrs: formatAttrsWithSnapshots('fmt-1', SAME_USER, beforeSnap, afterSnap), + }, + ], + }, + ], + }); + const accept = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'fmt-1' }, + }); + expect(accept.ok).toBe(true); + const next = state.apply(accept.tr); + next.doc.nodesBetween(0, next.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackFormatMarkName); + } + }); + + const reject = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'fmt-1' }, + }); + expect(reject.ok).toBe(true); + }); + + it('range target resolving fully-covered insertion is treated as full coverage', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-r1') }] }, + { text: ' B' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 0, to: state.doc.content.size }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds[0]?.id).toBe('ins-r1'); + }); + + it('range target with collapsed cursor inside change resolves whole change', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-r2') }] }, + { text: ' B' }, + ], + }); + // Find a position inside the insertion (somewhere between offset 3 and 6). + const cursor = 4; + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: cursor, to: cursor }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds[0]?.id).toBe('ins-r2'); + }); + + it('partial accept of an insertion retires the source id and mints successor fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-partial-accept') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds).toEqual([{ id: 'ins-partial-accept', cause: 'decision' }]); + expect(result.receipt.createdChangeIds).toHaveLength(2); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XYZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('ins-partial-accept')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + for (const fragment of fragments) { + expect(fragment.splitFromId).toBe('ins-partial-accept'); + expect(fragment.revisionGroupId).toBe('ins-partial-accept'); + expect(result.receipt.createdChangeIds).toContain(fragment.id); + } + }); + + it('partial reject of an insertion removes selected text and keeps deterministic successor fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-partial-reject') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('ins-partial-reject')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'ins-partial-reject')).toBe(true); + }); + + it('partial accept of a deletion removes selected deleted text and preserves successor deletion fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-partial-accept') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('del-partial-accept')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'del-partial-accept')).toBe(true); + expect(fragments.every((change) => change.type === 'deletion')).toBe(true); + }); + + it('partial reject of a deletion unwraps selected text and preserves successor deletion fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-partial-reject') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XYZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('del-partial-reject')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'del-partial-reject')).toBe(true); + }); + + it('range target with no overlap returns TARGET_NOT_FOUND', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'plain text only' }], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 1, to: 4 }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('TARGET_NOT_FOUND'); + }); + + it('permission denial aborts before any mutation', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-p1', OTHER_USER) }] }, + { text: ' B' }, + ], + }); + const editor = editorFor(SAME_USER, { + permissionResolver: () => false, + }); + const result = decideTrackedChanges({ + state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'ins-p1' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects unknown id with TARGET_NOT_FOUND', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'plain' }], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'does-not-exist' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('TARGET_NOT_FOUND'); + }); + + it('rejects invalid target shapes', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'x' }] }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { not: 'real' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('child change wholly inside a rejected insertion retires as a side effect', () => { + const schema = createReviewGraphTestSchema(); + // Parent insertion "AAA" by other user, with a child same-user delete on "AA" inside. + // Rejecting parent insertion removes "AAA" content; child id should be retired. + const parentAttrs = insertAttrs('parent-1', OTHER_USER); + const childAttrs = deleteAttrs('child-1', SAME_USER, { overlapParentId: 'parent-1' }); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { + text: 'AA', + marks: [ + { markType: TrackInsertMarkName, attrs: parentAttrs }, + { markType: TrackDeleteMarkName, attrs: childAttrs }, + ], + }, + { + text: 'A', + marks: [{ markType: TrackInsertMarkName, attrs: parentAttrs }], + }, + { text: ' B' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'parent-1' }, + }); + expect(result.ok).toBe(true); + const retired = result.receipt.removedChangeIds.map((e) => e.id).sort(); + expect(retired).toContain('parent-1'); + expect(retired).toContain('child-1'); + expect(result.receipt.affectedChildren.some((c) => c.changeId === 'child-1')).toBe(true); + }); + + it('legacy aliases { id } and { scope: "all" } normalize correctly', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'X', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('legacy-id') }] }], + }); + const byId = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { id: 'legacy-id' }, + }); + expect(byId.ok).toBe(true); + + const all = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { scope: 'all' }, + }); + expect(all.ok).toBe(true); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js new file mode 100644 index 0000000000..f7e4df66d5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -0,0 +1,225 @@ +// @ts-check +/** + * TrackedEditIntent factories and helpers. + * + * Every tracked text mutation — native step rewrites, the + * `insertTrackedChange` command, and document-api tracked writes — must + * normalize its input into a TrackedEditIntent before consulting the + * overlap compiler. The intent type is the only contract the compiler + * accepts; each call site decides whether it produces a `text-insert`, + * `text-delete`, `text-replace`, or `format-apply`/`format-remove`. + * + * `source` is preserved purely for failure routing (native vs document-api + * vs plan-engine) and never changes semantic rules. `replacementGroupHint` + * exists for transitional callers that cannot avoid producing adjacent + * delete/insert intents while migrating to one fused `text-replace`. + */ + +import { Slice, Fragment } from 'prosemirror-model'; + +/** @typedef {'native'|'document-api'|'programmatic'} EditIntentSource */ + +/** + * @typedef {Object} TrackedEditIntentUser + * @property {string} name + * @property {string} email + * @property {string} [image] + */ + +/** + * @typedef {Object} TrackedEditIntentBase + * @property {EditIntentSource} source + * @property {TrackedEditIntentUser} user + * @property {string} date + * @property {string} [replacementGroupHint] + */ + +/** + * @typedef {(TrackedEditIntentBase & { + * kind: 'text-insert', + * at: number, + * content: import('prosemirror-model').Slice, + * }) | (TrackedEditIntentBase & { + * kind: 'text-delete', + * from: number, + * to: number, + * }) | (TrackedEditIntentBase & { + * kind: 'text-replace', + * from: number, + * to: number, + * content: import('prosemirror-model').Slice, + * replacements: 'paired'|'independent', + * }) | (TrackedEditIntentBase & { + * kind: 'format-apply'|'format-remove', + * from: number, + * to: number, + * mark: import('prosemirror-model').Mark, + * })} TrackedEditIntent + */ + +const isFiniteNonNeg = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0; + +/** + * Build a Slice that wraps a single text string with the given marks. + * openStart/openEnd are 0 so callers can use `replaceRange` for inline merge. + * + * @param {*} schema + * @param {string} text + * @param {Array} [marks] + * @returns {import('prosemirror-model').Slice} + */ +export const sliceFromText = (schema, text, marks) => { + if (!text) return Slice.empty; + return new Slice(Fragment.from(schema.text(text, marks ?? null)), 0, 0); +}; + +/** + * Coerce string or Slice into a Slice. + * + * @param {*} schema + * @param {*} content + * @returns {import('prosemirror-model').Slice} + */ +export const toSliceContent = (schema, content) => { + if (content instanceof Slice) return content; + if (typeof content === 'string') return sliceFromText(schema, content); + return Slice.empty; +}; + +/** + * Build a `text-insert` intent. The caller is expected to have already + * resolved `at` against the transaction it will pass to the compiler. + * + * @param {{ + * at: number, + * content: import('prosemirror-model').Slice | string, + * schema?: *, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextInsertIntent = ({ at, content, schema, user, date, source, replacementGroupHint }) => { + if (!isFiniteNonNeg(at)) { + throw new Error('makeTextInsertIntent: `at` must be a non-negative finite number'); + } + const slice = + content instanceof Slice ? content : schema ? sliceFromText(schema, /** @type {string} */ (content)) : Slice.empty; + return { + kind: 'text-insert', + at, + content: slice, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + }; +}; + +/** + * Build a `text-delete` intent. + * + * @param {{ + * from: number, + * to: number, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextDeleteIntent = ({ from, to, user, date, source, replacementGroupHint }) => { + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeTextDeleteIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeTextDeleteIntent: `from` must be <= `to`'); + return { + kind: 'text-delete', + from, + to, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + }; +}; + +/** + * Build a `text-replace` intent. + * + * @param {{ + * from: number, + * to: number, + * content: import('prosemirror-model').Slice | string, + * schema?: *, + * replacements: 'paired'|'independent', + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextReplaceIntent = ({ + from, + to, + content, + schema, + replacements, + user, + date, + source, + replacementGroupHint, +}) => { + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeTextReplaceIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeTextReplaceIntent: `from` must be <= `to`'); + const slice = + content instanceof Slice ? content : schema ? sliceFromText(schema, /** @type {string} */ (content)) : Slice.empty; + return { + kind: 'text-replace', + from, + to, + content: slice, + replacements, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + }; +}; + +/** + * Build a `format-apply`/`format-remove` intent. + * + * @param {{ + * kind: 'format-apply'|'format-remove', + * from: number, + * to: number, + * mark: import('prosemirror-model').Mark, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeFormatIntent = ({ kind, from, to, mark, user, date, source }) => { + if (kind !== 'format-apply' && kind !== 'format-remove') { + throw new Error(`makeFormatIntent: unsupported kind ${kind}`); + } + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeFormatIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeFormatIntent: `from` must be <= `to`'); + return { kind, from, to, mark, user, date, source }; +}; + +/** + * @param {TrackedEditIntent} intent + * @returns {string} + */ +export const intentKind = (intent) => intent.kind; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js new file mode 100644 index 0000000000..4083414426 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js @@ -0,0 +1,51 @@ +// @ts-check +/** + * Graph-invariant convenience layer. + * + * The invariant checks themselves live next to the builder in + * review-graph.js so they share private types. This module is a thin entry + * point that downstream code (compiler/decision engine) imports without + * pulling in the full graph builder API surface. + */ + +import { validateGraph } from './review-graph.js'; + +/** + * Run invariants and return diagnostics by severity bucket. + * + * @param {import('./review-graph.js').TrackedReviewGraph} graph + * @returns {{ + * errors: Array, + * warnings: Array, + * info: Array, + * all: Array, + * }} + */ +export const runGraphInvariants = (graph) => { + const all = validateGraph(graph); + const errors = []; + const warnings = []; + const info = []; + for (const d of all) { + if (d.severity === 'error') errors.push(d); + else if (d.severity === 'warning') warnings.push(d); + else info.push(d); + } + return { errors, warnings, info, all }; +}; + +/** + * True when the graph has any `error`-severity diagnostic. + * + * Decision and compiler paths must abort before dispatch when this returns + * true. + * + * @param {import('./review-graph.js').TrackedReviewGraph} graph + * @returns {boolean} + */ +export const graphHasErrors = (graph) => { + for (const d of validateGraph(graph)) { + if (d.severity === 'error') return true; + } + return false; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js new file mode 100644 index 0000000000..79d06930dd --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -0,0 +1,130 @@ +// @ts-check +/** + * Identity helpers for the review graph. + * + * Same-user behavior requires high-confidence identity. A trusted author email + * match on both the current editor user and the change author's stored email + * is the only signal that returns `same-user`. Every other combination — + * missing email on either side, only display-name match, imported author + * strings without a trusted email, or mismatched email — returns a + * different-user classification. + */ + +/** + * Trim and lowercase an email value. Anything not a string normalizes to ''. + * @param {unknown} value + * @returns {string} + */ +export const normalizeEmail = (value) => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +/** + * @typedef {Object} UserIdentity + * @property {string} email normalized email, '' when unknown. + * @property {string} name display name (may be empty). + * @property {boolean} hasEmail true when normalized email is non-empty. + */ + +/** + * Read the current user identity from the editor options. + * Tolerates a missing editor / options / user so callers can pass a partial + * editor (or a snapshot for tests). + * + * @param {{ options?: { user?: { name?: unknown, email?: unknown } } } | null | undefined} editor + * @returns {UserIdentity} + */ +export const getCurrentUserIdentity = (editor) => { + const user = editor?.options?.user ?? null; + const email = normalizeEmail(user?.email); + const name = typeof user?.name === 'string' ? user.name : ''; + return { email, name, hasEmail: email.length > 0 }; +}; + +/** + * Pull the author identity off a mark's attrs (or a graph-shaped change/segment). + * Accepts the raw mark, its attrs, or a graph object with `author`/`authorEmail`. + * + * @param {*} changeOrAttrs + * @returns {UserIdentity} + */ +export const getChangeAuthorIdentity = (changeOrAttrs) => { + if (!changeOrAttrs) return { email: '', name: '', hasEmail: false }; + + // Accept either { attrs: {...} }, { mark: { attrs } }, or a flat attrs map. + const attrs = changeOrAttrs.attrs ?? changeOrAttrs.mark?.attrs ?? changeOrAttrs; + + const email = normalizeEmail(attrs?.authorEmail); + const name = typeof attrs?.author === 'string' ? attrs.author : ''; + return { email, name, hasEmail: email.length > 0 }; +}; + +/** + * @typedef {( + * | 'same-user' + * | 'different-user' + * | 'unknown-current-user' + * | 'unknown-change-author' + * | 'conflicting' + * )} OwnershipClassification + */ + +/** + * Classify ownership between the current editor user and a change author. + * + * Rules (per plan): + * - normalized authorEmail match is high-confidence => `same-user`. + * - display name alone is never same-user. + * - missing current user email is `unknown-current-user`. + * - missing change author email is `unknown-change-author`. + * - both emails present but different => `different-user`. + * - conflicting signals (e.g. emails match but names differ in a way that + * indicates an impersonation) are reported as `conflicting`. Caller treats + * it as different-user; the distinct code lets diagnostics report it. + * + * Only `same-user` may trigger same-user refinement. Every other code MUST + * use different-user overlap behavior. + * + * @param {{ currentUser?: UserIdentity, change?: UserIdentity }} input + * @returns {OwnershipClassification} + */ +export const classifyOwnership = ({ currentUser, change }) => { + const cur = currentUser ?? { email: '', name: '', hasEmail: false }; + const auth = change ?? { email: '', name: '', hasEmail: false }; + + if (!cur.hasEmail) return 'unknown-current-user'; + if (!auth.hasEmail) return 'unknown-change-author'; + + if (cur.email === auth.email) { + // Same email but obviously different display name pattern is still + // 'same-user' — display name is not a security signal. Only flag + // `conflicting` if the change carries an explicit `importedAuthor` + // mismatch with a different display, which is an import-provenance + // signal that should NOT be treated as ordinary same-user refinement. + if ( + typeof change?.importedAuthor === 'string' && + change.importedAuthor.trim() && + cur.name && + change.importedAuthor.trim().toLowerCase() !== cur.name.trim().toLowerCase() && + change.name && + change.name !== cur.name + ) { + return 'conflicting'; + } + return 'same-user'; + } + + return 'different-user'; +}; + +/** + * Convenience: returns true only when the classification is high-confidence + * same-user. Use this as the gate before applying same-user refinement. + * + * @param {OwnershipClassification} classification + * @returns {boolean} + */ +export const isSameUserHighConfidence = (classification) => classification === 'same-user'; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js new file mode 100644 index 0000000000..d73e9d0908 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeEmail, + getCurrentUserIdentity, + getChangeAuthorIdentity, + classifyOwnership, + isSameUserHighConfidence, +} from './identity.js'; + +describe('review-model/identity', () => { + describe('normalizeEmail', () => { + it('lowercases and trims a string email', () => { + expect(normalizeEmail(' Alice@Example.COM ')).toBe('alice@example.com'); + }); + it('returns "" for non-string values', () => { + expect(normalizeEmail(undefined)).toBe(''); + expect(normalizeEmail(null)).toBe(''); + expect(normalizeEmail(42)).toBe(''); + expect(normalizeEmail({ email: 'x' })).toBe(''); + }); + it('returns "" for whitespace-only strings', () => { + expect(normalizeEmail(' ')).toBe(''); + }); + }); + + describe('getCurrentUserIdentity', () => { + it('extracts identity from a configured editor', () => { + const editor = { options: { user: { name: 'Alice', email: 'Alice@example.com' } } }; + expect(getCurrentUserIdentity(editor)).toEqual({ + email: 'alice@example.com', + name: 'Alice', + hasEmail: true, + }); + }); + it('returns empty identity for missing editor/user', () => { + expect(getCurrentUserIdentity(undefined)).toEqual({ email: '', name: '', hasEmail: false }); + expect(getCurrentUserIdentity({ options: {} })).toEqual({ email: '', name: '', hasEmail: false }); + }); + }); + + describe('getChangeAuthorIdentity', () => { + it('reads from a raw mark', () => { + const mark = { attrs: { author: 'Bob', authorEmail: 'BOB@example.com' } }; + expect(getChangeAuthorIdentity(mark)).toEqual({ email: 'bob@example.com', name: 'Bob', hasEmail: true }); + }); + it('reads from flat attrs', () => { + expect(getChangeAuthorIdentity({ author: 'Carol', authorEmail: 'carol@example.com' })).toEqual({ + email: 'carol@example.com', + name: 'Carol', + hasEmail: true, + }); + }); + it('returns empty for null', () => { + expect(getChangeAuthorIdentity(null)).toEqual({ email: '', name: '', hasEmail: false }); + }); + }); + + describe('classifyOwnership', () => { + const alice = { email: 'alice@example.com', name: 'Alice', hasEmail: true }; + const bob = { email: 'bob@example.com', name: 'Bob', hasEmail: true }; + + it('returns same-user for matching emails', () => { + expect(classifyOwnership({ currentUser: alice, change: { ...alice } })).toBe('same-user'); + }); + it('returns different-user for distinct emails', () => { + expect(classifyOwnership({ currentUser: alice, change: bob })).toBe('different-user'); + }); + it('returns unknown-current-user when current email is missing', () => { + expect(classifyOwnership({ currentUser: { email: '', name: '', hasEmail: false }, change: bob })).toBe( + 'unknown-current-user', + ); + }); + it('returns unknown-change-author when change email is missing', () => { + expect(classifyOwnership({ currentUser: alice, change: { email: '', name: 'B', hasEmail: false } })).toBe( + 'unknown-change-author', + ); + }); + it('display-name-only never matches', () => { + expect( + classifyOwnership({ + currentUser: { email: '', name: 'Alice', hasEmail: false }, + change: { email: '', name: 'Alice', hasEmail: false }, + }), + ).toBe('unknown-current-user'); + }); + it('returns conflicting when importedAuthor disagrees with name', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...alice, name: 'Imported Alice', importedAuthor: 'Mallory' }, + }), + ).toBe('conflicting'); + }); + it('isSameUserHighConfidence only on same-user', () => { + expect(isSameUserHighConfidence('same-user')).toBe(true); + expect(isSameUserHighConfidence('different-user')).toBe(false); + expect(isSameUserHighConfidence('unknown-current-user')).toBe(false); + expect(isSameUserHighConfidence('unknown-change-author')).toBe(false); + expect(isSameUserHighConfidence('conflicting')).toBe(false); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js new file mode 100644 index 0000000000..b61de6ba77 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js @@ -0,0 +1,185 @@ +// @ts-check +/** + * Importer tracking context for DOCX tracked-change overlap. + * + * The current `w:ins`/`w:del`/`w:rPrChange` translators receive an + * `insideTrackChange` boolean. That's enough to decide that a nested change + * exists, but it carries no information about the parent's logical id, side, + * or part path. The import pipeline needs all of that to stamp `overlapParentId` and to + * report diagnostics when the imported OOXML shape cannot reconstruct the + * full internal graph. + * + * Usage: + * const ctx = createImportTrackingContext({ + * partPath: 'word/document.xml', + * replacements: 'paired', + * }); + * + * ctx.pushParent({ logicalId, side, sourceId, author, date }); + * // ... descend into nested element ... + * ctx.popParent(); + * + * ctx.reportDiagnostic({ code: 'IMPORT_MISSING_REPLACEMENT_SIDE', ...details }); + * + * The context is intentionally lightweight: it does not own the PM document + * and does not allocate ids. The translators continue to call + * `buildTrackedChangeIdMap`; the context only adds parent-stack and + * diagnostics surfaces that the legacy translator code lacked. + * + * @typedef {'paired' | 'independent'} ReplacementsMode + * @typedef {'insertion' | 'deletion' | 'formatting'} ParentSide + * + * @typedef {{ + * logicalId: string, + * side: ParentSide, + * sourceId: string, + * author: string, + * date: string, + * }} ParentFrame + * + * @typedef {( + * | 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP' + * | 'IMPORT_MISSING_AUTHOR_IDENTITY' + * | 'IMPORT_HEURISTIC_RECONSTRUCTION' + * | 'IMPORT_REPLACEMENT_MISSING_SIDE' + * | 'IMPORT_CHILD_MISSING_PARENT' + * | 'IMPORT_DUPLICATE_LOGICAL_ID' + * | 'EXPORT_FALLBACK_LOSSY' + * )} ImportDiagnosticCode + * + * @typedef {{ + * code: ImportDiagnosticCode, + * partPath: string, + * logicalId?: string, + * parentLogicalId?: string, + * sourceId?: string, + * side?: ParentSide, + * message?: string, + * detail?: Record, + * }} ImportDiagnostic + * + * @typedef {{ + * partPath: string, + * replacements: ReplacementsMode, + * parentStack: () => ReadonlyArray, + * currentParent: () => ParentFrame | null, + * pushParent: (frame: ParentFrame) => void, + * popParent: () => ParentFrame | null, + * reportDiagnostic: (d: ImportDiagnostic) => void, + * diagnostics: () => ReadonlyArray, + * recordLogicalId: (id: string, source: { sourceId?: string, side?: ParentSide }) => void, + * hasLogicalId: (id: string) => boolean, + * forNestedPart: (partPath: string) => ImportTrackingContext, + * }} ImportTrackingContext + */ + +/** + * @param {{ + * partPath?: string, + * replacements?: ReplacementsMode, + * diagnostics?: ImportDiagnostic[], + * knownLogicalIds?: Map }>, + * }} [options] + * @returns {ImportTrackingContext} + */ +export function createImportTrackingContext(options = {}) { + const partPath = + typeof options.partPath === 'string' && options.partPath.length > 0 ? options.partPath : 'word/document.xml'; + const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; + + /** @type {ParentFrame[]} */ + const stack = []; + + /** @type {ImportDiagnostic[]} */ + const diagnostics = Array.isArray(options.diagnostics) ? options.diagnostics : []; + + /** @type {Map }>} */ + const knownLogicalIds = options.knownLogicalIds instanceof Map ? options.knownLogicalIds : new Map(); + + /** @param {ImportDiagnostic} d */ + const reportDiagnostic = (d) => { + if (!d || typeof d !== 'object' || !d.code) return; + diagnostics.push({ ...d, partPath: d.partPath || partPath }); + }; + + /** @type {ImportTrackingContext} */ + const ctx = { + partPath, + replacements, + parentStack: () => stack.slice(), + currentParent: () => (stack.length > 0 ? stack[stack.length - 1] : null), + pushParent: (frame) => { + if (!frame || typeof frame !== 'object' || !frame.logicalId) return; + stack.push({ + logicalId: String(frame.logicalId), + side: frame.side, + sourceId: typeof frame.sourceId === 'string' ? frame.sourceId : '', + author: typeof frame.author === 'string' ? frame.author : '', + date: typeof frame.date === 'string' ? frame.date : '', + }); + }, + popParent: () => (stack.length > 0 ? stack.pop() : null) ?? null, + reportDiagnostic, + diagnostics: () => diagnostics.slice(), + recordLogicalId: (id, source) => { + if (!id) return; + const prior = knownLogicalIds.get(id); + if (prior) { + const priorSides = prior.sides instanceof Set ? prior.sides : new Set(prior.side ? [prior.side] : []); + // Word paired replacements can reuse the same w:id across insertion + // and deletion sides. Reusing the same logical id on the same side is + // the ambiguous duplicate case this context should report. + if (source?.side && priorSides.has(source.side)) { + reportDiagnostic({ + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath, + logicalId: id, + side: source.side, + }); + } + if (source?.side) priorSides.add(source.side); + knownLogicalIds.set(id, { + sourceId: prior.sourceId || source?.sourceId, + side: prior.side || source?.side, + sides: priorSides, + }); + return; + } + knownLogicalIds.set(id, { + sourceId: source?.sourceId, + side: source?.side, + sides: source?.side ? new Set([source.side]) : new Set(), + }); + }, + hasLogicalId: (id) => Boolean(id) && knownLogicalIds.has(id), + forNestedPart: (nestedPartPath) => + createImportTrackingContext({ + partPath: nestedPartPath, + replacements, + diagnostics, + knownLogicalIds, + }), + }; + + return ctx; +} + +/** + * Convenience helper that wraps a recursive descent, ensuring `popParent` + * runs even when the body throws. Used by the tracked-change translator + * integration helpers. + * + * @template T + * @param {ImportTrackingContext} ctx + * @param {ParentFrame} frame + * @param {() => T} body + * @returns {T} + */ +export function withParentFrame(ctx, frame, body) { + ctx.pushParent(frame); + try { + return body(); + } finally { + ctx.popParent(); + } +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js new file mode 100644 index 0000000000..9fec177481 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js @@ -0,0 +1,101 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { createImportTrackingContext, withParentFrame } from './import-context.js'; + +describe('createImportTrackingContext', () => { + it('defaults partPath / replacements', () => { + const ctx = createImportTrackingContext(); + expect(ctx.partPath).toBe('word/document.xml'); + expect(ctx.replacements).toBe('paired'); + }); + + it('tracks a parent stack via pushParent / popParent', () => { + const ctx = createImportTrackingContext(); + expect(ctx.currentParent()).toBeNull(); + + ctx.pushParent({ logicalId: 'a', side: 'insertion', sourceId: '1', author: 'Alice', date: '2024-01-01' }); + expect(ctx.currentParent()).toMatchObject({ logicalId: 'a', side: 'insertion' }); + + ctx.pushParent({ logicalId: 'b', side: 'deletion', sourceId: '2', author: 'Bob', date: '2024-01-02' }); + expect(ctx.parentStack().map((f) => f.logicalId)).toEqual(['a', 'b']); + + expect(ctx.popParent()?.logicalId).toBe('b'); + expect(ctx.currentParent()?.logicalId).toBe('a'); + }); + + it('withParentFrame pops the frame even on throw', () => { + const ctx = createImportTrackingContext(); + expect(() => { + withParentFrame(ctx, { logicalId: 'x', side: 'insertion', sourceId: '7', author: 'A', date: 'D' }, () => { + expect(ctx.currentParent()?.logicalId).toBe('x'); + throw new Error('boom'); + }); + }).toThrow('boom'); + expect(ctx.currentParent()).toBeNull(); + }); + + it('collects diagnostics and exposes a snapshot copy', () => { + const ctx = createImportTrackingContext(); + ctx.reportDiagnostic({ code: 'IMPORT_MISSING_AUTHOR_IDENTITY', partPath: 'word/document.xml', sourceId: '1' }); + ctx.reportDiagnostic({ code: 'IMPORT_REPLACEMENT_MISSING_SIDE', partPath: 'word/document.xml' }); + + const list = ctx.diagnostics(); + expect(list).toHaveLength(2); + expect(list[0].code).toBe('IMPORT_MISSING_AUTHOR_IDENTITY'); + // snapshot must not allow callers to mutate internal storage. + list.push({ code: 'EXPORT_FALLBACK_LOSSY', partPath: 'word/document.xml' }); + expect(ctx.diagnostics()).toHaveLength(2); + }); + + it('forNestedPart shares diagnostics + knownLogicalIds across parts', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('uuid-1', { sourceId: '1', side: 'insertion' }); + + const nested = ctx.forNestedPart('word/header1.xml'); + expect(nested.partPath).toBe('word/header1.xml'); + expect(nested.hasLogicalId('uuid-1')).toBe(true); + + nested.reportDiagnostic({ code: 'IMPORT_HEURISTIC_RECONSTRUCTION', partPath: 'word/header1.xml' }); + expect(ctx.diagnostics().some((d) => d.partPath === 'word/header1.xml')).toBe(true); + }); + + it('diagnoses duplicate same-side logical ids but allows paired opposite sides', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + expect(ctx.diagnostics()).toEqual([]); + + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + expect(ctx.diagnostics()).toEqual([ + { + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath: 'word/document.xml', + logicalId: '7', + side: 'insertion', + }, + ]); + }); + + it('continues to diagnose duplicates after both replacement sides were seen', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + + expect(ctx.diagnostics()).toEqual([ + { + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath: 'word/document.xml', + logicalId: '7', + side: 'deletion', + }, + ]); + }); + + it('ignores malformed parent frames', () => { + const ctx = createImportTrackingContext(); + ctx.pushParent(/** @type {any} */ (null)); + ctx.pushParent(/** @type {any} */ ({})); + expect(ctx.parentStack()).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js new file mode 100644 index 0000000000..609f67a1db --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js @@ -0,0 +1,58 @@ +// @ts-check +/** + * Phase 005 — integration test for `scanImportDiagnostics` against real + * DOCX fixtures. + * + * Plan: v1-3220 / phase0-005 "Repo-Local Critical Tests Only". + * + * The unit tests in `import-diagnostics.test.js` already cover every + * diagnostic code with synthetic XML. This file runs the scanner against + * existing real-world DOCX fixtures shipped with the repo to prove that: + * + * 1) the scanner does NOT false-positive on clean Word-tracked-change + * documents (gdocs / msword paired revisions); + * 2) it does run end-to-end without throwing on a real OOXML body. + */ + +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '../../../tests/helpers/helpers.js'; +import { scanImportDiagnostics } from './import-diagnostics.js'; + +const realDocxAsParsedParts = async (filename) => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + try { + // SuperConverter parsed every XML file in the DOCX into convertedXml. + return editor.converter.convertedXml; + } finally { + editor.destroy(); + } +}; + +describe('scanImportDiagnostics against real DOCX fixtures', () => { + it('does not flag clean msword-tracked-changes.docx as missing replacement sides', async () => { + const parts = await realDocxAsParsedParts('msword-tracked-changes.docx'); + const ctx = scanImportDiagnostics(parts, { replacements: 'paired' }); + const diagnostics = ctx.diagnostics(); + // The msword fixture has both insertions and deletions, so the strict + // REPLACEMENT_MISSING_SIDE diagnostic (which requires a single side in + // the part) must NOT fire. Heuristic-reconstruction diagnostics may + // still fire for asymmetric author/date clusters — that signal is + // expected and informational under overlap. + const replacementMissing = diagnostics.filter((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE'); + expect(replacementMissing).toEqual([]); + }); + + it('returns diagnostics as a stable array for a large DOCX', async () => { + const parts = await realDocxAsParsedParts('features-redlines-comments-annotations-and-more.docx'); + const ctx = scanImportDiagnostics(parts, {}); + expect(Array.isArray(ctx.diagnostics())).toBe(true); + }); + + it('runs to completion without throwing on real DOCX with many tracked changes', async () => { + const parts = await realDocxAsParsedParts('features-redlines-comments-annotations-and-more.docx'); + expect(() => { + scanImportDiagnostics(parts, { replacements: 'paired' }); + }).not.toThrow(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js new file mode 100644 index 0000000000..456003d720 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js @@ -0,0 +1,298 @@ +// @ts-check +/** + * OOXML import diagnostics scanner for tracked-change overlap. + * + * Walks `w:ins`, `w:del`, and `w:rPrChange` elements across each revision- + * capable part and emits diagnostics for cases where the Word-native shape + * cannot be losslessly reconstructed into the review graph. This is a pure + * read-only scan. + * + * Diagnostics codes: + * - IMPORT_MISSING_AUTHOR_IDENTITY: tracked change has no `w:author`. + * The graph layer routes such changes through different-user behavior. + * - IMPORT_REPLACEMENT_MISSING_SIDE: a `w:ins` / `w:del` whose paired + * opposite-side was never observed under paired mode. Sometimes + * intentional (deletion-only / insertion-only revisions), but the + * internal graph cannot reconstruct a replacement pair. + * - IMPORT_CHILD_MISSING_PARENT: a nested tracked change whose parent + * element does not carry a `w:id`. Internal `overlapParentId` is + * unrecoverable; child must be linked heuristically. + * - IMPORT_DUPLICATE_LOGICAL_ID: same `w:id` appears more than once on + * incompatible sides in the same part — Word treats this as an error. + * - IMPORT_HEURISTIC_RECONSTRUCTION: emitted when adjacent ins+del were + * paired by author/date heuristics rather than carried provenance. + * - IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP: structural OOXML (e.g. + * `w:moveFrom`/`w:moveTo`) appearing as a child of `w:ins`/`w:del`. + */ + +import { createImportTrackingContext } from './import-context.js'; + +const TRACKED_NAMES = new Set(['w:ins', 'w:del']); +const FORMAT_REVISION_NAMES = new Set(['w:rPrChange', 'w:pPrChange', 'w:cellPrChange']); +const STRUCTURAL_MOVE_NAMES = new Set([ + 'w:moveFrom', + 'w:moveTo', + 'w:moveFromRangeStart', + 'w:moveFromRangeEnd', + 'w:moveToRangeStart', + 'w:moveToRangeEnd', +]); + +/** + * @param {string} name + * @returns {'insertion' | 'deletion' | 'formatting' | null} + */ +function sideFromXmlName(name) { + if (name === 'w:ins') return 'insertion'; + if (name === 'w:del') return 'deletion'; + if (FORMAT_REVISION_NAMES.has(name)) return 'formatting'; + return null; +} + +/** + * @typedef {ReturnType} ImportTrackingContext + * + * @typedef {object} ScanRecord + * @property {string} wordId + * @property {'insertion' | 'deletion' | 'formatting'} side + * @property {string} author + * @property {string} date + * @property {string | null} parentWordId + * @property {'insertion' | 'deletion' | 'formatting' | null} parentSide + */ + +/** + * Walks one parsed OOXML part and records every tracked-change element + + * its parent stack. Used by both the diagnostics emitter and tests. + * + * @param {object | undefined} part + * @returns {ScanRecord[]} + */ +export function scanTrackedElements(part) { + const records = /** @type {ScanRecord[]} */ ([]); + const root = part?.elements?.[0]; + if (!root?.elements) return records; + + /** @type {{ wordId: string, side: 'insertion' | 'deletion' | 'formatting' }[]} */ + const stack = []; + + const visit = (node) => { + if (!node || typeof node !== 'object') return; + + const side = sideFromXmlName(node.name); + if (side) { + const rawWordId = node.attributes?.['w:id']; + const wordId = rawWordId == null ? '' : String(rawWordId); + const parent = stack.length > 0 ? stack[stack.length - 1] : null; + records.push({ + wordId, + side, + author: String(node.attributes?.['w:author'] ?? ''), + date: String(node.attributes?.['w:date'] ?? ''), + parentWordId: parent ? parent.wordId : null, + parentSide: parent ? parent.side : null, + }); + + stack.push({ wordId, side }); + if (Array.isArray(node.elements)) node.elements.forEach(visit); + stack.pop(); + return; + } + + if (STRUCTURAL_MOVE_NAMES.has(node.name)) { + // Move revisions are out of scope for tracked text overlap, but we + // tag them so the emitter can report unsupported overlap when they + // nest inside `w:ins`/`w:del`. + const parent = stack.length > 0 ? stack[stack.length - 1] : null; + if (parent) { + records.push({ + wordId: '__move__', + side: parent.side, // placeholder; emitter recognizes __move__ + author: String(node.attributes?.['w:author'] ?? ''), + date: String(node.attributes?.['w:date'] ?? ''), + parentWordId: parent.wordId, + parentSide: parent.side, + }); + } + } + + if (Array.isArray(node.elements)) node.elements.forEach(visit); + }; + + root.elements.forEach(visit); + return records; +} + +/** + * Scan a DOCX (or a single part) for tracked-change shapes that cannot be + * losslessly reconstructed and emit diagnostics into the provided context. + * + * @param {Record | null | undefined} docx + * @param {{ + * replacements?: 'paired' | 'independent', + * parts?: string[], + * }} [options] + * @returns {ImportTrackingContext} + */ +export function scanImportDiagnostics(docx, options = {}) { + const ctx = createImportTrackingContext({ + partPath: 'word/document.xml', + replacements: options.replacements === 'independent' ? 'independent' : 'paired', + }); + + if (!docx || typeof docx !== 'object') { + return ctx; + } + + const parts = Array.isArray(options.parts) + ? options.parts + : Object.keys(docx).filter((p) => /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes)\.xml$/.test(p)); + + for (const partPath of parts) { + const partCtx = ctx.forNestedPart(partPath); + const records = scanTrackedElements(docx[partPath]); + if (records.length === 0) continue; + + // Tracks side observations per Word id so we can flag duplicates and + // detect replacement missing-side after the full scan. + /** @type {Map, author: string, date: string }>} */ + const sideObservations = new Map(); + + for (const record of records) { + if (record.wordId === '__move__') { + partCtx.reportDiagnostic({ + code: 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP', + partPath, + parentLogicalId: record.parentWordId ?? undefined, + side: record.parentSide ?? undefined, + message: 'w:moveFrom/w:moveTo nested inside a tracked-change wrapper is not modeled as text overlap.', + }); + continue; + } + + if (!record.author) { + partCtx.reportDiagnostic({ + code: 'IMPORT_MISSING_AUTHOR_IDENTITY', + partPath, + sourceId: record.wordId, + side: record.side, + message: 'Tracked change has no w:author; ownership classification falls through to different-user.', + }); + } + + if (record.parentWordId !== null && !record.parentWordId) { + // Nested tracked change with an empty parent w:id — provenance lost. + partCtx.reportDiagnostic({ + code: 'IMPORT_CHILD_MISSING_PARENT', + partPath, + sourceId: record.wordId, + side: record.side, + message: 'Nested tracked change has no parent w:id; overlapParentId reconstruction is heuristic.', + }); + } + + if (!record.wordId) continue; + + const existing = sideObservations.get(record.wordId); + if (existing) { + if (existing.sides.has(record.side)) { + // Same w:id, same side, appears twice in the same part — Word + // does this only when split across runs; the importer can usually + // recover but reports it so consumers can correlate. + partCtx.reportDiagnostic({ + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath, + sourceId: record.wordId, + side: record.side, + message: `w:id "${record.wordId}" appears more than once on side "${record.side}" in ${partPath}.`, + }); + } else { + existing.sides.add(record.side); + } + } else { + sideObservations.set(record.wordId, { + sides: new Set([record.side]), + author: record.author, + date: record.date, + }); + } + + partCtx.recordLogicalId(record.wordId, { sourceId: record.wordId, side: record.side }); + } + + if ((options.replacements ?? 'paired') === 'paired') { + // Heuristic replacement reconstruction: in paired mode the importer + // pairs ins+del with matching author/date. Emit a heuristic diagnostic + // when only one side of an apparent replacement candidate is present + // for a given (author, date) tuple — that's an intentional one-sided + // revision but the graph cannot reconstruct a paired replacement. + /** @type {Map} */ + const byAuthorDate = new Map(); + for (const record of records) { + if (record.wordId === '__move__') continue; + if (record.side === 'formatting') continue; + const key = `${record.author}${record.date}`; + let bucket = byAuthorDate.get(key); + if (!bucket) { + bucket = { insertions: 0, deletions: 0 }; + byAuthorDate.set(key, bucket); + } + if (record.side === 'insertion') bucket.insertions++; + else if (record.side === 'deletion') bucket.deletions++; + } + for (const [key, bucket] of byAuthorDate.entries()) { + // A pure one-sided cluster (no deletions or no insertions) is a + // straight independent revision — Word's normal shape and not a + // missing-side case. We only surface a diagnostic when the importer + // sees both sides present but unpaired by the same author/date — + // that's the heuristic-reconstruction signal the graph cannot + // resolve losslessly without explicit SuperDoc metadata. + if (bucket.insertions > 0 && bucket.deletions > 0 && bucket.insertions !== bucket.deletions) { + partCtx.reportDiagnostic({ + code: 'IMPORT_HEURISTIC_RECONSTRUCTION', + partPath, + message: `Imbalanced paired tracked-change candidates for author/date "${key}" (${bucket.insertions} insertion / ${bucket.deletions} deletion). Graph reconstruction is heuristic.`, + detail: { insertions: bucket.insertions, deletions: bucket.deletions }, + }); + } + } + + // Strict "replacement missing one side" — emitted only when a paired + // replacement candidate (matching author/date and adjacency, per the + // `trackedChangeIdMapper` rules) appears as a SINGLE w:ins or w:del + // without any opposite-side observation in the part. This catches the + // case where Word would have written both halves but only one survived. + if (sideObservations.size > 0) { + let totalInsertions = 0; + let totalDeletions = 0; + for (const record of records) { + if (record.wordId === '__move__') continue; + if (record.side === 'insertion') totalInsertions++; + if (record.side === 'deletion') totalDeletions++; + } + // Only fire when there is exactly one side in the entire part and + // the document carries explicit SuperDoc paired metadata (mirrored + // by an existing replacement marker, currently approximated by + // checking that there is a single tracked side present). Real Word + // documents typically have both sides, so this remains rare. + if (totalInsertions > 0 && totalDeletions === 0 && totalInsertions === 1) { + partCtx.reportDiagnostic({ + code: 'IMPORT_REPLACEMENT_MISSING_SIDE', + partPath, + message: `Only one tracked-change side (insertion) present in ${partPath}; paired-replacement reconstruction cannot complete.`, + detail: { insertions: totalInsertions, deletions: totalDeletions }, + }); + } else if (totalDeletions > 0 && totalInsertions === 0 && totalDeletions === 1) { + partCtx.reportDiagnostic({ + code: 'IMPORT_REPLACEMENT_MISSING_SIDE', + partPath, + message: `Only one tracked-change side (deletion) present in ${partPath}; paired-replacement reconstruction cannot complete.`, + detail: { insertions: totalInsertions, deletions: totalDeletions }, + }); + } + } + } + } + + return ctx; +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js new file mode 100644 index 0000000000..ef99fadc6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js @@ -0,0 +1,174 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { scanImportDiagnostics, scanTrackedElements } from './import-diagnostics.js'; + +const makeIns = (id, author = 'Alice', date = '2024-01-01', children = []) => ({ + name: 'w:ins', + attributes: { 'w:id': id, 'w:author': author, 'w:date': date }, + elements: children, +}); + +const makeDel = (id, author = 'Alice', date = '2024-01-01', children = []) => ({ + name: 'w:del', + attributes: { 'w:id': id, 'w:author': author, 'w:date': date }, + elements: children, +}); + +const makePara = (...children) => ({ name: 'w:p', elements: children }); + +const makeDoc = (...bodyChildren) => ({ + 'word/document.xml': { + elements: [{ name: 'w:document', elements: bodyChildren }], + }, +}); + +describe('scanTrackedElements', () => { + it('returns an empty list for a missing part', () => { + expect(scanTrackedElements(undefined)).toEqual([]); + }); + + it('records parent-child stacking for nested w:ins / w:del', () => { + const docx = makeDoc(makePara(makeIns('1', 'Alice', '2024-01-01', [makeDel('2', 'Bob', '2024-01-02')]))); + const records = scanTrackedElements(docx['word/document.xml']); + expect(records).toHaveLength(2); + + const [outer, inner] = records; + expect(outer.wordId).toBe('1'); + expect(outer.side).toBe('insertion'); + expect(outer.parentWordId).toBeNull(); + + expect(inner.wordId).toBe('2'); + expect(inner.side).toBe('deletion'); + expect(inner.parentWordId).toBe('1'); + expect(inner.parentSide).toBe('insertion'); + }); + + it('captures move elements as __move__ placeholders under tracked parents', () => { + const docx = makeDoc( + makePara( + makeIns('1', 'Alice', '2024-01-01', [ + { name: 'w:moveTo', attributes: { 'w:author': 'Alice', 'w:date': '2024-01-01' } }, + ]), + ), + ); + const records = scanTrackedElements(docx['word/document.xml']); + expect(records).toHaveLength(2); + expect(records[1].wordId).toBe('__move__'); + expect(records[1].parentWordId).toBe('1'); + }); +}); + +describe('scanImportDiagnostics', () => { + it('flags missing author identity by default', () => { + const docx = makeDoc(makePara(makeIns('', '', ''))); // missing author + id + const ctx = scanImportDiagnostics(docx); + expect(ctx.diagnostics().map((d) => d.code)).toContain('IMPORT_MISSING_AUTHOR_IDENTITY'); + }); + + it('flags tracked changes missing w:author', () => { + const docx = makeDoc(makePara(makeIns('1', '', '2024-01-01')), makePara(makeDel('2', '', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx); + const codes = ctx.diagnostics().map((d) => d.code); + expect(codes.filter((c) => c === 'IMPORT_MISSING_AUTHOR_IDENTITY')).toHaveLength(2); + }); + + it('flags imbalanced paired ins+del under paired mode as HEURISTIC_RECONSTRUCTION', () => { + const docx = makeDoc( + makePara( + makeDel('1', 'Alice', '2024-01-01'), + makeIns('2', 'Alice', '2024-01-01'), + makeIns('3', 'Alice', '2024-01-01'), // a second insertion: 2 ins vs 1 del + ), + ); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_HEURISTIC_RECONSTRUCTION'); + expect(diag).toBeDefined(); + expect(diag?.detail).toMatchObject({ insertions: 2, deletions: 1 }); + }); + + it('does not flag balanced paired insertions+deletions', () => { + const docx = makeDoc(makePara(makeDel('1', 'Alice', '2024-01-01'), makeIns('2', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + expect(ctx.diagnostics().some((d) => d.code === 'IMPORT_HEURISTIC_RECONSTRUCTION')).toBe(false); + expect(ctx.diagnostics().some((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE')).toBe(false); + }); + + it('flags a single-sided revision (only one tracked side in the part) as REPLACEMENT_MISSING_SIDE', () => { + // A document with only a single deletion and no insertions — Word can + // produce this for pure deletions, but under paired-replacement + // reconstruction it is still a missing-side scenario worth surfacing + // for downstream tooling. + const docx = makeDoc(makePara(makeDel('1', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE'); + expect(diag).toBeDefined(); + expect(diag?.detail).toMatchObject({ deletions: 1, insertions: 0 }); + }); + + it('flags nested children with empty parent w:id as CHILD_MISSING_PARENT', () => { + // Build a w:ins whose attributes are missing w:id, with a nested w:del child. + const docx = makeDoc( + makePara({ + name: 'w:ins', + attributes: { 'w:id': '', 'w:author': 'Alice', 'w:date': '2024-01-01' }, + elements: [makeDel('99', 'Bob', '2024-02-02')], + }), + ); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_CHILD_MISSING_PARENT'); + expect(diag).toBeDefined(); + expect(diag?.sourceId).toBe('99'); + }); + + it('flags w:moveTo nested inside w:ins as UNSUPPORTED_STRUCTURAL_OVERLAP', () => { + const docx = makeDoc( + makePara( + makeIns('1', 'Alice', '2024-01-01', [ + { name: 'w:moveTo', attributes: { 'w:author': 'Alice', 'w:date': '2024-01-01' } }, + ]), + ), + ); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP'); + expect(diag).toBeDefined(); + expect(diag?.parentLogicalId).toBe('1'); + }); + + it('flags duplicate same-side w:id observations as DUPLICATE_LOGICAL_ID', () => { + const docx = makeDoc(makePara(makeIns('5', 'Alice', '2024-01-01')), makePara(makeIns('5', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_DUPLICATE_LOGICAL_ID'); + expect(diag).toBeDefined(); + expect(diag?.sourceId).toBe('5'); + expect(diag?.side).toBe('insertion'); + }); + + it('scans header / footer / footnote / endnote parts when present', () => { + const docx = { + 'word/document.xml': { elements: [{ name: 'w:document', elements: [] }] }, + 'word/header1.xml': { + elements: [{ name: 'w:hdr', elements: [makePara(makeIns('h1', '', ''))] }], + }, + 'word/footer1.xml': { + elements: [{ name: 'w:ftr', elements: [makePara(makeDel('f1', '', ''))] }], + }, + 'word/footnotes.xml': { + elements: [{ name: 'w:footnotes', elements: [makePara(makeIns('fn1', '', ''))] }], + }, + 'word/endnotes.xml': { + elements: [{ name: 'w:endnotes', elements: [makePara(makeDel('en1', '', ''))] }], + }, + }; + const ctx = scanImportDiagnostics(docx); + const partPaths = new Set(ctx.diagnostics().map((d) => d.partPath)); + expect(partPaths.has('word/header1.xml')).toBe(true); + expect(partPaths.has('word/footer1.xml')).toBe(true); + expect(partPaths.has('word/footnotes.xml')).toBe(true); + expect(partPaths.has('word/endnotes.xml')).toBe(true); + }); + + it('is a no-op when the docx is empty', () => { + expect(scanImportDiagnostics(null).diagnostics()).toEqual([]); + expect(scanImportDiagnostics({}).diagnostics()).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js new file mode 100644 index 0000000000..42a6b9f282 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js @@ -0,0 +1,64 @@ +// @ts-check +/** + * Public surface of the review graph layer. + * + * Consumers (compiler, decision engine, document-api wrappers) should import + * from this module rather than reaching into individual files, so the graph + * layer's internal structure can evolve. + */ + +export { + normalizeEmail, + getCurrentUserIdentity, + getChangeAuthorIdentity, + classifyOwnership, + isSameUserHighConfidence, +} from './identity.js'; + +export { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + sideFromMarkName, + subtypeFromChangeType, + deterministicJson, + canonicalizeSourceIds, + readTrackedAttrs, + normalizedAttrsEqual, + serializeSourceIds, +} from './mark-metadata.js'; + +export { enumerateTrackedMarkSpans } from './segment-index.js'; + +export { + buildReviewGraph, + getOrBuildReviewGraph, + invalidateReviewGraphCache, + validateGraph, + signatureOf, +} from './review-graph.js'; + +export { runGraphInvariants, graphHasErrors } from './graph-invariants.js'; + +export { BODY_STORY, buildStoryKey, storyLocatorsEqual } from './story-locator.js'; + +export { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + makeFormatIntent, + sliceFromText, + toSliceContent, +} from './edit-intent.js'; + +export { compileTrackedEdit } from './overlap-compiler.js'; + +export { decideTrackedChanges, buildDecisionBubbleEvents } from './decision-engine.js'; + +export { planCommentEffects, enumerateCommentAnchors } from './comment-effects.js'; + +export { createWordIdAllocator, isDecimalWordId } from './word-id-allocator.js'; + +export { createImportTrackingContext, withParentFrame } from './import-context.js'; + +export { scanImportDiagnostics, scanTrackedElements } from './import-diagnostics.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js new file mode 100644 index 0000000000..29643495a2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js @@ -0,0 +1,373 @@ +// @ts-check +/** + * Mark metadata helpers for the review graph. + * + * The graph is the semantic source of truth. Marks are the persistence + * carrier. This module is the boundary that: + * + * 1. Reads optional persisted review attrs from a mark, falling back to + * inference when they are absent. + * 2. Canonicalizes `sourceIds` to a deterministic JSON object so adjacent + * equivalent marks don't differ only by missing-vs-empty defaults. + * 3. Provides the deterministic JSON serialization used by export. + * + * It deliberately knows nothing about transactions, ProseMirror state, or + * editor identity. It operates on mark attrs only. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +/** @typedef {'trackInsert'|'trackDelete'|'trackFormat'} TrackedMarkName */ + +/** + * Canonical semantic types stored on marks. + * + * Internal graph values use these canonical v2-style strings. The public + * document-api `TrackChangeType` union remains legacy `insert | delete | + * format` until the contract is widened (closed product decision + * 2026-05-21). + * + * @readonly + */ +export const CanonicalChangeType = Object.freeze({ + Insertion: 'insertion', + Deletion: 'deletion', + Replacement: 'replacement', + Formatting: 'formatting', +}); + +/** + * Derived `subtype` values for v1 text scope. + * + * @readonly + */ +export const ChangeSubtype = Object.freeze({ + TextInsertion: 'text-insertion', + TextDeletion: 'text-deletion', + TextReplacement: 'text-replacement', + RunFormatting: 'run-formatting', +}); + +/** + * Derived side values for v1 text-overlap scope. + * + * @readonly + */ +export const SegmentSide = Object.freeze({ + Inserted: 'inserted', + Deleted: 'deleted', + Formatting: 'formatting', +}); + +const CHANGE_TYPE_VALUES = new Set(Object.values(CanonicalChangeType)); + +/** + * Derive the canonical segment side from a tracked mark name. + * + * @param {string} markName + * @returns {'inserted'|'deleted'|'formatting'|null} + */ +export const sideFromMarkName = (markName) => { + if (markName === TrackInsertMarkName) return SegmentSide.Inserted; + if (markName === TrackDeleteMarkName) return SegmentSide.Deleted; + if (markName === TrackFormatMarkName) return SegmentSide.Formatting; + return null; +}; + +/** + * Derive the subtype string from a canonical change type for v1 text scope. + * + * @param {string} changeType + * @returns {string|null} + */ +export const subtypeFromChangeType = (changeType) => { + switch (changeType) { + case CanonicalChangeType.Insertion: + return ChangeSubtype.TextInsertion; + case CanonicalChangeType.Deletion: + return ChangeSubtype.TextDeletion; + case CanonicalChangeType.Replacement: + return ChangeSubtype.TextReplacement; + case CanonicalChangeType.Formatting: + return ChangeSubtype.RunFormatting; + default: + return null; + } +}; + +/** + * Deterministic JSON serialization with sorted keys at every object level. + * + * Mirrors the rule in phase0-002 / "Attribute Defaults And Rendering": + * + * "`sourceIds` must use one canonical serialization when stored on marks + * or exported as custom metadata: deterministic JSON with sorted keys. + * Do not alternate between object and compact string encodings." + * + * Arrays preserve their order — only object keys are sorted. Functions and + * `undefined` values are dropped, matching JSON.stringify. + * + * @param {*} value + * @returns {string} + */ +export const deterministicJson = (value) => { + const canonical = canonicalizeForSerialization(value); + // Top-level undefined/function => match JSON.stringify(undefined) === undefined. + return JSON.stringify(canonical); +}; + +const canonicalizeForSerialization = (value) => { + if (value === undefined || typeof value === 'function') return undefined; + if (value === null) return null; + if (Array.isArray(value)) { + return value.map((entry) => { + const inner = canonicalizeForSerialization(entry); + // Arrays preserve slot positions; JSON.stringify renders undefined + // slots as null to match standard JSON behavior. + return inner === undefined ? null : inner; + }); + } + if (typeof value === 'object') { + const out = {}; + const keys = Object.keys(value).sort(); + for (const key of keys) { + const inner = canonicalizeForSerialization(value[key]); + if (inner === undefined) continue; + out[key] = inner; + } + return out; + } + return value; +}; + +/** + * Normalize a `sourceIds` value to a canonical object form. + * + * Returns `{}` when input is null/undefined/non-object. String inputs are + * parsed as JSON if possible; otherwise treated as an opaque `{ raw: }`. + * + * Output is always a plain object whose entries are themselves deterministic. + * + * @param {*} value + * @returns {Record} + */ +export const canonicalizeSourceIds = (value) => { + if (value == null) return {}; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return canonicalSourceIdsFromObject(parsed); + } + } catch { + /* fall through */ + } + return { raw: trimmed }; + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return canonicalSourceIdsFromObject(value); + } + + return {}; +}; + +const canonicalSourceIdsFromObject = (obj) => { + const out = {}; + const keys = Object.keys(obj).sort(); + for (const key of keys) { + const v = obj[key]; + if (v == null) continue; + if (typeof v === 'string') { + const trimmed = v.trim(); + if (!trimmed) continue; + out[key] = trimmed; + continue; + } + if (typeof v === 'number' || typeof v === 'boolean') { + out[key] = v; + continue; + } + if (typeof v === 'object') { + out[key] = canonicalizeForSerialization(v); + continue; + } + // drop functions / undefined + } + return out; +}; + +/** + * @typedef {Object} NormalizedTrackedAttrs + * @property {string} id Active logical tracked-change id. + * @property {string} revisionGroupId Defaults to `id` for unsplit changes. + * @property {string} splitFromId Retired id this fragment descends from. + * @property {string} changeType Canonical type. + * @property {string} replacementGroupId Logical replacement group id. + * @property {string} replacementSideId Stable side id within a replacement. + * @property {string} overlapParentId Logical parent change id, or ''. + * @property {Record} sourceIds Canonical sourceIds object. + * @property {string} sourceId Legacy raw Word `w:id` value or ''. + * @property {string} importedAuthor Imported author provenance. + * @property {string} origin Optional import origin. + * @property {string} author Display name. + * @property {string} authorEmail Author email (not lowercased here). + * @property {string} authorImage Author image url/value. + * @property {string} date Created/modified ISO date. + * @property {string} markType One of trackInsert/trackDelete/trackFormat. + * @property {string} side Derived side. + * @property {string} subtype Derived subtype. + * @property {string} explicitChangeType Persisted changeType attr verbatim, or ''. + * @property {boolean} hasReviewMetadata Was any persisted review attr explicit? + */ + +const stringAttr = (value) => (typeof value === 'string' ? value : ''); + +/** + * Read review attrs off a mark/attrs blob with inference. + * + * Compatibility rules from phase0-002: + * - `id` is the logical id. + * - `revisionGroupId` defaults to `id`. + * - `splitFromId` defaults to `''`. + * - `changeType` is inferred from mark type if missing. + * - `side` is inferred from mark type. + * - explicit new metadata wins over legacy inference. + * - `sourceIds` is canonicalized; legacy `sourceId` is folded in when present. + * + * @param {{ attrs?: Record, type?: { name?: string } } | Record} markOrAttrs + * @param {string} [markName] Required when markOrAttrs is a plain attrs object. + * @returns {NormalizedTrackedAttrs} + */ +export const readTrackedAttrs = (markOrAttrs, markName) => { + const isMark = markOrAttrs && typeof markOrAttrs === 'object' && 'attrs' in markOrAttrs; + const attrs = (isMark ? /** @type {*} */ (markOrAttrs).attrs : markOrAttrs) ?? {}; + const resolvedMarkName = markName ?? (isMark ? /** @type {*} */ (markOrAttrs).type?.name : '') ?? ''; + + const id = stringAttr(attrs.id); + const explicitRevisionGroupId = stringAttr(attrs.revisionGroupId); + const explicitChangeType = stringAttr(attrs.changeType); + const explicitOrigin = stringAttr(attrs.origin); + const explicitSplitFromId = stringAttr(attrs.splitFromId); + const explicitReplacementGroupId = stringAttr(attrs.replacementGroupId); + const explicitReplacementSideId = stringAttr(attrs.replacementSideId); + const explicitOverlapParentId = stringAttr(attrs.overlapParentId); + + const sideInferred = sideFromMarkName(resolvedMarkName) ?? ''; + const changeTypeInferred = inferChangeTypeFromMarkName(resolvedMarkName); + const changeType = + explicitChangeType && CHANGE_TYPE_VALUES.has(explicitChangeType) ? explicitChangeType : changeTypeInferred; + const subtype = subtypeFromChangeType(changeType) ?? ''; + + // sourceIds canonicalization — fold legacy `sourceId` into the canonical + // shape when no explicit canonical entry exists. + const sourceIds = canonicalizeSourceIds(attrs.sourceIds); + const legacySourceId = stringAttr(attrs.sourceId); + if (legacySourceId && resolvedMarkName) { + const key = legacySourceIdKey(resolvedMarkName); + if (key && !sourceIds[key]) { + sourceIds[key] = legacySourceId; + } + } + + const hasReviewMetadata = Boolean( + explicitChangeType || + explicitRevisionGroupId || + explicitSplitFromId || + explicitReplacementGroupId || + explicitReplacementSideId || + explicitOverlapParentId || + explicitOrigin || + (attrs.sourceIds != null && Object.keys(canonicalizeSourceIds(attrs.sourceIds)).length > 0), + ); + + return { + id, + revisionGroupId: explicitRevisionGroupId || id, + splitFromId: explicitSplitFromId, + changeType, + replacementGroupId: explicitReplacementGroupId, + replacementSideId: explicitReplacementSideId, + overlapParentId: explicitOverlapParentId, + sourceIds, + sourceId: legacySourceId, + importedAuthor: stringAttr(attrs.importedAuthor), + origin: explicitOrigin, + author: stringAttr(attrs.author), + authorEmail: stringAttr(attrs.authorEmail), + authorImage: stringAttr(attrs.authorImage), + date: stringAttr(attrs.date), + markType: resolvedMarkName, + side: sideInferred, + subtype, + explicitChangeType: explicitChangeType && CHANGE_TYPE_VALUES.has(explicitChangeType) ? explicitChangeType : '', + hasReviewMetadata, + }; +}; + +const inferChangeTypeFromMarkName = (markName) => { + if (markName === TrackInsertMarkName) return CanonicalChangeType.Insertion; + if (markName === TrackDeleteMarkName) return CanonicalChangeType.Deletion; + if (markName === TrackFormatMarkName) return CanonicalChangeType.Formatting; + return ''; +}; + +const legacySourceIdKey = (markName) => { + if (markName === TrackInsertMarkName) return 'wordIdInsert'; + if (markName === TrackDeleteMarkName) return 'wordIdDelete'; + if (markName === TrackFormatMarkName) return 'wordIdFormat'; + return ''; +}; + +/** + * Two adjacent marks should be considered "the same logical segment" when + * every persisted attribute that survives normalization matches. + * + * Used by the graph builder to merge adjacent same-id, same-side mark spans + * into one TrackedSegment. The comparison is intentionally normalization-aware: + * a mark with an explicit `revisionGroupId: id` and a mark without that attr + * compare equal, because the graph view treats missing-vs-default as the same. + * + * @param {NormalizedTrackedAttrs} a + * @param {NormalizedTrackedAttrs} b + * @returns {boolean} + */ +export const normalizedAttrsEqual = (a, b) => { + if (a.markType !== b.markType) return false; + if (a.id !== b.id) return false; + if (a.revisionGroupId !== b.revisionGroupId) return false; + if (a.splitFromId !== b.splitFromId) return false; + if (a.changeType !== b.changeType) return false; + // Note: explicitChangeType is intentionally NOT compared here. Two + // adjacent marks where one persists `changeType: 'insertion'` and the + // other relies on legacy inference must still merge — the spec's + // attr-normalization rule says missing-vs-default must not split logical + // segments. + if (a.replacementGroupId !== b.replacementGroupId) return false; + if (a.replacementSideId !== b.replacementSideId) return false; + if (a.overlapParentId !== b.overlapParentId) return false; + if (a.author !== b.author) return false; + if (a.authorEmail !== b.authorEmail) return false; + if (a.authorImage !== b.authorImage) return false; + if (a.date !== b.date) return false; + if (a.importedAuthor !== b.importedAuthor) return false; + if (a.origin !== b.origin) return false; + if (deterministicJson(a.sourceIds) !== deterministicJson(b.sourceIds)) return false; + return true; +}; + +/** + * Build the deterministic JSON encoding of a sourceIds object for storage + * on a mark attr or export. Empty objects encode as `""` (empty string) + * so PM mark equality stays clean for marks without source ids. + * + * @param {Record} sourceIds + * @returns {string} + */ +export const serializeSourceIds = (sourceIds) => { + if (!sourceIds || Object.keys(sourceIds).length === 0) return ''; + return deterministicJson(sourceIds); +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js new file mode 100644 index 0000000000..b909903dd2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + canonicalizeSourceIds, + deterministicJson, + normalizedAttrsEqual, + readTrackedAttrs, + serializeSourceIds, + sideFromMarkName, + subtypeFromChangeType, +} from './mark-metadata.js'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '../constants.js'; + +describe('review-model/mark-metadata', () => { + describe('deterministicJson', () => { + it('produces stable output regardless of key order', () => { + const a = deterministicJson({ b: 1, a: { z: 3, m: 2 } }); + const b = deterministicJson({ a: { m: 2, z: 3 }, b: 1 }); + expect(a).toBe(b); + expect(a).toBe('{"a":{"m":2,"z":3},"b":1}'); + }); + it('preserves array order', () => { + expect(deterministicJson([3, 1, 2])).toBe('[3,1,2]'); + }); + it('drops undefined and functions', () => { + expect(deterministicJson({ a: undefined, b: () => 1, c: 2 })).toBe('{"c":2}'); + }); + }); + + describe('canonicalizeSourceIds', () => { + it('returns {} for nullish', () => { + expect(canonicalizeSourceIds(null)).toEqual({}); + expect(canonicalizeSourceIds(undefined)).toEqual({}); + }); + it('parses JSON strings', () => { + expect(canonicalizeSourceIds('{"wordIdInsert":"42"}')).toEqual({ wordIdInsert: '42' }); + }); + it('wraps non-JSON strings as { raw }', () => { + expect(canonicalizeSourceIds('rsid-7')).toEqual({ raw: 'rsid-7' }); + }); + it('drops empty string and null entries', () => { + expect(canonicalizeSourceIds({ wordIdInsert: '', wordIdDelete: null, rsid: 'rs1' })).toEqual({ + rsid: 'rs1', + }); + }); + }); + + describe('side and subtype derivation', () => { + it('maps mark names to sides', () => { + expect(sideFromMarkName(TrackInsertMarkName)).toBe(SegmentSide.Inserted); + expect(sideFromMarkName(TrackDeleteMarkName)).toBe(SegmentSide.Deleted); + expect(sideFromMarkName(TrackFormatMarkName)).toBe(SegmentSide.Formatting); + expect(sideFromMarkName('other')).toBeNull(); + }); + it('maps change types to subtypes', () => { + expect(subtypeFromChangeType(CanonicalChangeType.Insertion)).toBe(ChangeSubtype.TextInsertion); + expect(subtypeFromChangeType(CanonicalChangeType.Deletion)).toBe(ChangeSubtype.TextDeletion); + expect(subtypeFromChangeType(CanonicalChangeType.Replacement)).toBe(ChangeSubtype.TextReplacement); + expect(subtypeFromChangeType(CanonicalChangeType.Formatting)).toBe(ChangeSubtype.RunFormatting); + expect(subtypeFromChangeType('weird')).toBeNull(); + }); + }); + + describe('readTrackedAttrs', () => { + it('infers from legacy attrs', () => { + const result = readTrackedAttrs({ + attrs: { id: 'c1', author: 'A', authorEmail: 'a@x' }, + type: { name: TrackInsertMarkName }, + }); + expect(result.id).toBe('c1'); + expect(result.changeType).toBe(CanonicalChangeType.Insertion); + expect(result.subtype).toBe(ChangeSubtype.TextInsertion); + expect(result.side).toBe(SegmentSide.Inserted); + expect(result.revisionGroupId).toBe('c1'); + expect(result.splitFromId).toBe(''); + expect(result.hasReviewMetadata).toBe(false); + }); + + it('honors explicit overlap metadata', () => { + const result = readTrackedAttrs( + { + attrs: { + id: 'frag1', + revisionGroupId: 'root1', + splitFromId: 'root1', + changeType: CanonicalChangeType.Replacement, + replacementGroupId: 'rep1', + replacementSideId: 'rep1#deleted', + overlapParentId: 'parentA', + sourceIds: { wordIdInsert: '99' }, + origin: 'word', + }, + type: { name: TrackInsertMarkName }, + }, + TrackInsertMarkName, + ); + expect(result.revisionGroupId).toBe('root1'); + expect(result.changeType).toBe(CanonicalChangeType.Replacement); + expect(result.subtype).toBe(ChangeSubtype.TextReplacement); + expect(result.replacementGroupId).toBe('rep1'); + expect(result.replacementSideId).toBe('rep1#deleted'); + expect(result.overlapParentId).toBe('parentA'); + expect(result.sourceIds).toEqual({ wordIdInsert: '99' }); + expect(result.hasReviewMetadata).toBe(true); + }); + + it('folds legacy sourceId into sourceIds under the mark-specific key', () => { + const insert = readTrackedAttrs({ attrs: { id: 'c1', sourceId: '42' }, type: { name: TrackInsertMarkName } }); + expect(insert.sourceIds).toEqual({ wordIdInsert: '42' }); + + const del = readTrackedAttrs({ attrs: { id: 'c1', sourceId: '7' }, type: { name: TrackDeleteMarkName } }); + expect(del.sourceIds).toEqual({ wordIdDelete: '7' }); + }); + }); + + describe('normalizedAttrsEqual', () => { + it('treats missing-vs-empty defaults as equal', () => { + const legacy = readTrackedAttrs({ attrs: { id: 'c1' }, type: { name: TrackInsertMarkName } }); + const explicit = readTrackedAttrs({ + attrs: { + id: 'c1', + revisionGroupId: 'c1', + splitFromId: '', + changeType: CanonicalChangeType.Insertion, + sourceIds: null, + }, + type: { name: TrackInsertMarkName }, + }); + expect(normalizedAttrsEqual(legacy, explicit)).toBe(true); + }); + + it('distinguishes mark type / id', () => { + const a = readTrackedAttrs({ attrs: { id: 'a' }, type: { name: TrackInsertMarkName } }); + const b = readTrackedAttrs({ attrs: { id: 'b' }, type: { name: TrackInsertMarkName } }); + expect(normalizedAttrsEqual(a, b)).toBe(false); + }); + }); + + describe('serializeSourceIds', () => { + it('returns "" for empty source ids', () => { + expect(serializeSourceIds({})).toBe(''); + }); + it('produces deterministic JSON', () => { + expect(serializeSourceIds({ b: '2', a: '1' })).toBe('{"a":"1","b":"2"}'); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js new file mode 100644 index 0000000000..c54a0909f9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -0,0 +1,881 @@ +// @ts-check +/** + * Overlap-aware tracked edit compiler. + * + * Single entry point used by tracked text mutation paths: + * + * - `trackedTransaction` (native UI text steps) + * - `replaceStep` (text insert/delete/replace step rewrites) + * - `addMarkStep` / `removeMarkStep` (tracked run formatting) + * - `insertTrackedChange` (document-api tracked writes) + * - the existing backspace-to-text-delete `ReplaceAroundStep` conversion + * - document-api `write-adapter` and tracked plan-engine text rewrites + * + * Callers MUST treat `ok: false` as an abort and MUST NOT fall through to + * applying the original untracked step. + * + * Compiler scope (this plan): text-insert, text-delete, text-replace, and + * run-formatting intents. Other intents fail closed with + * `CAPABILITY_UNAVAILABLE`. + */ + +import { Slice, Fragment } from 'prosemirror-model'; +import { ReplaceStep } from 'prosemirror-transform'; +import { v4 as uuidv4 } from 'uuid'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; +import { graphHasErrors } from './graph-invariants.js'; +import { + classifyOwnership, + getCurrentUserIdentity, + getChangeAuthorIdentity, + isSameUserHighConfidence, +} from './identity.js'; + +/** + * @typedef {import('./edit-intent.js').TrackedEditIntent} TrackedEditIntent + */ + +/** + * @typedef {Object} SelectionHint + * @property {'near'|'exact-text'} kind + * @property {number} pos + * @property {-1|1} [bias] + */ + +/** + * @typedef {Object} GraphDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {{ + * ok: true, + * tr: import('prosemirror-state').Transaction, + * createdChangeIds: string[], + * updatedChangeIds: string[], + * removedChangeIds: string[], + * remappedChangeIds: Array<{ from: string, to: string }>, + * selection?: SelectionHint, + * diagnostics?: GraphDiagnostic[], + * insertedMark?: import('prosemirror-model').Mark, + * deletionMarks?: import('prosemirror-model').Mark[], + * formatMarks?: import('prosemirror-model').Mark[], + * insertedFrom?: number, + * insertedTo?: number, + * } | { + * ok: false, + * code: 'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED', + * message: string, + * details?: unknown, + * }} TrackedEditResult + */ + +const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); + +/** + * Compile a tracked edit against an accumulated transaction. + * + * The compiler mutates `tr` in place. Callers MUST inspect `result.ok` + * before dispatch; if `ok: false` the transaction has not been altered. + * + * @param {{ + * state: import('prosemirror-state').EditorState, + * tr: import('prosemirror-state').Transaction, + * intent: TrackedEditIntent, + * replacements?: 'paired'|'independent', + * }} input + * @returns {TrackedEditResult} + */ +export const compileTrackedEdit = ({ state, tr, intent, replacements = 'paired' }) => { + if (!intent || !SUPPORTED_KINDS.has(intent.kind)) { + return failure('CAPABILITY_UNAVAILABLE', `Unsupported tracked edit kind ${intent?.kind ?? 'unknown'}.`); + } + + const ctx = makeContext({ state, tr, intent, replacements }); + + // Pre-validate the graph state before allowing the compiler to write. + // graphHasErrors aborts the compile if the document is already in a state + // that would corrupt downstream decisions. + if (graphHasErrors(ctx.graph)) { + return failure('PRECONDITION_FAILED', 'Tracked review graph has invariant errors before edit.', { + diagnostics: ctx.graph.validate(), + }); + } + + try { + switch (intent.kind) { + case 'text-insert': + return compileTextInsert(ctx, intent); + case 'text-delete': + return compileTextDelete(ctx, intent); + case 'text-replace': + return compileTextReplace(ctx, intent); + case 'format-apply': + case 'format-remove': + return compileFormat(ctx, intent); + default: + return failure('CAPABILITY_UNAVAILABLE', `Unsupported tracked edit kind ${intent.kind}.`); + } + } catch (error) { + return failure('PRECONDITION_FAILED', /** @type {Error} */ (error).message ?? 'compile failed.', { error }); + } +}; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const makeContext = ({ state, tr, intent, replacements }) => { + const schema = state.schema; + const graph = buildReviewGraph({ state: { doc: tr.doc }, replacementsMode: replacements }); + const currentIdentity = getCurrentUserIdentity({ options: { user: intent.user } }); + return { + state, + tr, + schema, + graph, + intent, + replacements, + currentIdentity, + /** @type {string[]} */ createdChangeIds: [], + /** @type {string[]} */ updatedChangeIds: [], + /** @type {string[]} */ removedChangeIds: [], + /** @type {Array<{from:string,to:string}>} */ remappedChangeIds: [], + /** @type {GraphDiagnostic[]} */ diagnostics: [], + }; +}; + +const failure = (code, message, details) => ({ ok: false, code, message, ...(details ? { details } : {}) }); + +// --------------------------------------------------------------------------- +// Helpers — segments, ownership, marks +// --------------------------------------------------------------------------- + +/** + * Classify whether the segment is same-user-owned by the intent's user. + * + * @param {*} ctx + * @param {*} segment + * @returns {'same-user'|'different-user'} + */ +const classifySegment = (ctx, segment) => { + const classification = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segment?.attrs ?? {}), + }); + return isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; +}; + +const findSegmentAt = (ctx, pos) => { + // Prefer the segment that covers `pos` strictly (pos in [from, to)). When + // `pos` sits exactly at the right edge of a segment, also consider it as a + // boundary for "still inside the same logical change" so we can refine. + const hits = ctx.graph.overlapAt(pos); + if (hits.length) return hits[0]; + // Boundary fallback: a segment that ends exactly at `pos` is still + // adjacent. We do not extend into a segment that starts at `pos`, + // because the cursor is on the live side. + const left = ctx.graph.segments.find((s) => s.to === pos); + if (left) return left; + return null; +}; + +const segmentsInRange = (ctx, from, to) => ctx.graph.segmentsInRange(from, to); + +const insertSchema = (ctx) => ctx.schema.marks[TrackInsertMarkName]; +const deleteSchema = (ctx) => ctx.schema.marks[TrackDeleteMarkName]; + +const makeInsertMark = (ctx, { id, overlapParentId = '', replacementGroupId = '', replacementSideId = '' }) => { + const attrs = { + id, + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + sourceId: '', + importedAuthor: '', + revisionGroupId: id, + splitFromId: '', + // When part of a paired replacement, both halves persist the canonical + // `replacement` changeType so the graph projects one logical change. + changeType: replacementGroupId ? CanonicalChangeType.Replacement : CanonicalChangeType.Insertion, + replacementGroupId, + replacementSideId, + overlapParentId, + sourceIds: null, + origin: '', + }; + return insertSchema(ctx).create(attrs); +}; + +const makeDeleteMark = (ctx, { id, overlapParentId = '', replacementGroupId = '', replacementSideId = '' }) => { + const attrs = { + id, + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + sourceId: '', + importedAuthor: '', + revisionGroupId: id, + splitFromId: '', + changeType: replacementGroupId ? CanonicalChangeType.Replacement : CanonicalChangeType.Deletion, + replacementGroupId, + replacementSideId, + overlapParentId, + sourceIds: null, + origin: '', + }; + return deleteSchema(ctx).create(attrs); +}; + +/** + * Strip every tracked insert/delete mark from a slice's text nodes. The + * compiler always re-marks inserted content under its own logical id, so + * leftover marks from the caller would shadow that decision. + * + * @param {import('prosemirror-model').Slice} slice + * @param {*} schema + */ +const stripTrackedMarksFromSlice = (slice, schema) => { + if (!slice || slice === Slice.empty) return slice; + const trackedMarkNames = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + const stripFragment = (fragment) => { + /** @type {Array} */ + const children = []; + for (let i = 0; i < fragment.childCount; i += 1) { + const child = fragment.child(i); + if (child.isText) { + const filtered = child.marks.filter((mark) => !trackedMarkNames.has(mark.type.name)); + children.push(filtered.length === child.marks.length ? child : child.mark(filtered)); + } else if (child.content?.childCount) { + children.push(child.copy(stripFragment(child.content))); + } else { + children.push(child); + } + } + return Fragment.from(children); + }; + return new Slice(stripFragment(slice.content), slice.openStart, slice.openEnd); +}; + +// --------------------------------------------------------------------------- +// text-insert +// --------------------------------------------------------------------------- + +const compileTextInsert = (ctx, intent) => { + const { at, content } = intent; + const docSize = ctx.tr.doc.content.size; + if (at < 0 || at > docSize) { + return failure('INVALID_TARGET', `text-insert position ${at} out of range [0, ${docSize}].`); + } + + const sanitizedSlice = stripTrackedMarksFromSlice(content ?? Slice.empty, ctx.schema); + if (!sanitizedSlice.content.size) { + return failure('INVALID_TARGET', 'text-insert requires non-empty content.'); + } + + // Resolve overlap context at the insertion point. + const containing = findContainingSegment(ctx, at); + const overlapParent = containing && containing.from < at && containing.to > at ? containing : null; + const boundaryAdjacent = + !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; + + // Same-user refinement targets: own insertion that strictly contains `at`, + // OR an own-insertion edge we are adjacent to. Adjacent same-user own + // insertion still refines the same id (extend the run). + const refinementTarget = + overlapParent && overlapParent.side === SegmentSide.Inserted && classifySegment(ctx, overlapParent) === 'same-user' + ? overlapParent + : boundaryAdjacent && + boundaryAdjacent.side === SegmentSide.Inserted && + classifySegment(ctx, boundaryAdjacent) === 'same-user' + ? boundaryAdjacent + : null; + + if (refinementTarget) { + const refinedId = refinementTarget.changeId; + const insertedMark = makeInsertMark(ctx, { + id: refinedId, + overlapParentId: refinementTarget.attrs.overlapParentId || '', + replacementGroupId: refinementTarget.attrs.replacementGroupId || '', + replacementSideId: refinementTarget.attrs.replacementSideId || '', + }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, refinedId, { update: true }); + } + + // Different-user content (or any non-insertion parent): exact location, + // create a child insertion. For different-user inserted parent the rule is + // the same — refine semantically with a new id and `overlapParentId`. + if (overlapParent) { + const childId = intent.replacementGroupHint || uuidv4(); + const overlapParentId = overlapParent.changeId; + const insertedMark = makeInsertMark(ctx, { id: childId, overlapParentId }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, childId, { create: true }); + } + + // No overlap — fresh insertion at the cursor. + const newId = intent.replacementGroupHint || uuidv4(); + const insertedMark = makeInsertMark(ctx, { id: newId }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, newId, { create: true }); +}; + +const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) => { + const beforeSize = ctx.tr.doc.content.size; + try { + ctx.tr.replaceRange(at, at, slice); + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'replaceRange failed.'); + } + const afterSize = ctx.tr.doc.content.size; + if (afterSize === beforeSize) { + return failure('INVALID_TARGET', 'text-insert did not change the document.'); + } + + const insertedFrom = at; + const insertedTo = at + (afterSize - beforeSize); + + // Re-apply tracked-insert mark over the inserted range. The slice + // contained no tracked marks (we stripped them), so this is the canonical + // marking. + ctx.tr.addMark(insertedFrom, insertedTo, insertMark); + + if (create) ctx.createdChangeIds.push(changeId); + else if (update) ctx.updatedChangeIds.push(changeId); + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: insertedTo, bias: 1 }, + insertedMark: insertMark, + insertedFrom, + insertedTo, + }; +}; + +// --------------------------------------------------------------------------- +// text-delete +// --------------------------------------------------------------------------- + +/** + * Delete one inline byte range, decomposed by graph segments. + * + * Behavior per segment: + * - own-insertion (covered/partial) → collapse: remove the inserted slice + * - other-insertion → child trackDelete with overlapParentId + * - own-deletion → no-op (preserve) + * - other-deletion → preserve parent; child trackDelete with overlapParentId + * - live content → trackDelete mark + * + * @param {*} ctx + * @param {import('./edit-intent.js').TrackedEditIntent & { kind: 'text-delete' }} intent + */ +const compileTextDelete = (ctx, intent) => { + const docSize = ctx.tr.doc.content.size; + if (intent.from < 0 || intent.to > docSize) { + return failure('INVALID_TARGET', `text-delete range [${intent.from}, ${intent.to}] out of bounds.`); + } + if (intent.from === intent.to) { + return failure('INVALID_TARGET', 'text-delete requires a non-empty range.'); + } + + const result = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId: '', + replacementSideId: '', + sharedDeletionId: intent.replacementGroupHint || null, + recordSharedDeletionId: Boolean(intent.replacementGroupHint), + }); + if (!result.ok) return result; + + // Caret at original `from`: matches Word's behavior where the cursor sits + // at the left edge of a tracked deletion. + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: intent.from, bias: -1 }, + deletionMarks: result.deletionMarks, + }; +}; + +/** + * Apply tracked deletion semantics across [from, to], returning the marks + * we wrote so callers (text-replace) can reuse the id. + * + * @param {*} ctx + * @param {number} from + * @param {number} to + * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean }} options + */ +const applyTrackedDelete = ( + ctx, + from, + to, + { + replacementGroupId, + replacementSideId, + sharedDeletionId, + recordSharedDeletionId = false, + recordCollapsedIds = true, + }, +) => { + /** @type {Array} */ + const deletionMarks = []; + // Walk inline leaf nodes and act per node. We never mutate while iterating + // — collect operations first, then apply in reverse position order so + // earlier positions remain stable. + /** @type {Array<{ kind: 'collapse'|'reassign'|'mark-delete'|'noop', from: number, to: number, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string }>} */ + const ops = []; + + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (node.type.name.includes('table')) return; + const segFrom = Math.max(from, pos); + const segTo = Math.min(to, pos + node.nodeSize); + if (segFrom >= segTo) return; + + const insertMark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + const existingDelete = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + + if (insertMark) { + const segmentAtPos = ctx.graph.overlapAt(pos)[0] ?? null; + const ownership = classifySegment(ctx, segmentAtPos ?? { attrs: insertMark.attrs }); + if (ownership === 'same-user') { + // Own insertion → collapse (remove proposed content). + ops.push({ kind: 'collapse', from: segFrom, to: segTo, changeId: insertMark.attrs.id }); + return; + } + // Different-user inserted content → child trackDelete with overlapParentId. + const parentId = insertMark.attrs.id; + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo, parentId, parentSide: SegmentSide.Inserted }); + return; + } + + if (existingDelete) { + const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); + if (ownership === 'same-user') { + // Inside own deletion → no semantic change (preserve original). + ops.push({ kind: 'noop', from: segFrom, to: segTo }); + return; + } + // Inside different-user deletion → child trackDelete with overlapParentId. + ops.push({ + kind: 'mark-delete', + from: segFrom, + to: segTo, + parentId: existingDelete.attrs.id, + parentSide: SegmentSide.Deleted, + }); + return; + } + + // Live content. + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo }); + }); + + if (!ops.length) { + return failure('CAPABILITY_UNAVAILABLE', `text-delete range [${from}, ${to}] has no inline text content to track.`); + } + + // Apply in reverse so earlier positions stay stable for `collapse` ops. + const sortedOps = [...ops].sort((a, b) => b.from - a.from); + // Allocate a deletion id. For paired replacement, the caller provides one. + const deletionId = sharedDeletionId ?? uuidv4(); + let mintedThisCall = false; + const collapsedIds = new Set(); + + for (const op of sortedOps) { + if (op.kind === 'collapse') { + try { + ctx.tr.replaceRange(op.from, op.to, Slice.empty); + } catch { + // Defensive — if PM refuses the deletion, fail closed. + return failure('INVALID_TARGET', `Cannot collapse own-insertion range [${op.from}, ${op.to}].`); + } + if (op.changeId) collapsedIds.add(op.changeId); + continue; + } + if (op.kind === 'noop') continue; + if (op.kind === 'mark-delete') { + const mark = makeDeleteMark(ctx, { + id: deletionId, + overlapParentId: op.parentId || '', + replacementGroupId, + replacementSideId, + }); + try { + ctx.tr.addMark(op.from, op.to, mark); + deletionMarks.push(mark); + if (!mintedThisCall) { + if (!sharedDeletionId || recordSharedDeletionId) ctx.createdChangeIds.push(deletionId); + mintedThisCall = true; + } + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'addMark failed.'); + } + } + } + + if (recordCollapsedIds && collapsedIds.size) { + const finalGraph = buildReviewGraph({ + state: { doc: ctx.tr.doc }, + replacementsMode: ctx.replacements, + }); + for (const id of collapsedIds) { + if (finalGraph.changes.has(id)) ctx.updatedChangeIds.push(id); + else ctx.removedChangeIds.push(id); + } + } + + return { ok: true, deletionMarks, deletionId }; +}; + +// --------------------------------------------------------------------------- +// text-replace +// --------------------------------------------------------------------------- + +const compileTextReplace = (ctx, intent) => { + const docSize = ctx.tr.doc.content.size; + if (intent.from < 0 || intent.to > docSize) { + return failure('INVALID_TARGET', `text-replace range [${intent.from}, ${intent.to}] out of bounds.`); + } + if (intent.from > intent.to) { + return failure('INVALID_TARGET', 'text-replace `from` must be <= `to`.'); + } + + const sanitizedSlice = stripTrackedMarksFromSlice(intent.content ?? Slice.empty, ctx.schema); + if (!sanitizedSlice.content.size && intent.from === intent.to) { + return failure('INVALID_TARGET', 'text-replace requires a non-empty replacement or non-empty range.'); + } + + const segments = segmentsInRange(ctx, intent.from, intent.to); + + // Replacing text inside own insertion/replacement-inserted side refines + // that inserted side. Rejecting the original insertion must still remove + // all proposed content, including the replacement text. + const ownInsertedTarget = getSingleFullyCoveringOwnInsertedSegment(ctx, segments, intent.from, intent.to); + if (ownInsertedTarget) { + const deleteResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId: '', + replacementSideId: '', + sharedDeletionId: null, + recordCollapsedIds: false, + }); + if (!deleteResult.ok) return deleteResult; + + if (!sanitizedSlice.content.size) { + ctx.updatedChangeIds.push(ownInsertedTarget.changeId); + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: intent.from, bias: -1 }, + }; + } + + const insertMark = makeInsertMark(ctx, { + id: ownInsertedTarget.changeId, + overlapParentId: ownInsertedTarget.attrs.overlapParentId || '', + replacementGroupId: ownInsertedTarget.attrs.replacementGroupId || '', + replacementSideId: ownInsertedTarget.attrs.replacementSideId || '', + }); + return applyInsert( + ctx, + clampToDocSize(ctx.tr.doc.content.size, intent.from), + sanitizedSlice, + insertMark, + ownInsertedTarget.changeId, + { + update: true, + }, + ); + } + + // SD-2335: replacing text inside own deletion preserves the deletion and + // creates an insertion at the exact edit point. We detect this case by + // looking at the segments in the original range before any mutation. + const ownDeletionFullyCovers = + segments.length > 0 && + segments.every( + (s) => + s.side === SegmentSide.Deleted && + classifySegment(ctx, s) === 'same-user' && + s.from <= intent.from && + s.to >= intent.to, + ); + + if (ownDeletionFullyCovers && sanitizedSlice.content.size) { + // Insertion at exact `from` — preserve original deletion as-is. + const insertId = uuidv4(); + const insertMark = makeInsertMark(ctx, { id: insertId }); + return applyInsert(ctx, intent.from, sanitizedSlice, insertMark, insertId, { create: true }); + } + + // Paired vs independent: in paired mode share one id between insert+delete + // sides so the logical change projects as a `replacement` in the graph. + const sharedId = intent.replacements === 'paired' ? intent.replacementGroupHint || uuidv4() : null; + const replacementGroupId = sharedId ?? ''; + const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; + const replacementParentId = getReplacementParentId(ctx, segments); + + // Step 1 — tracked delete (collapses own insertions, marks live/other content). + if (intent.from !== intent.to) { + const delResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId, + replacementSideId, + sharedDeletionId: sharedId, + }); + if (!delResult.ok) return delResult; + if (sharedId && delResult.deletionMarks?.length) { + ctx.createdChangeIds.push(sharedId); + } + } + + // Step 2 — tracked insert at the original `from`. Recompute graph context + // after the deletion so own-insertion collapse adjustments don't push the + // insertion past the intended cursor. + if (sanitizedSlice.content.size) { + // We must re-resolve the insertion position because collapsed + // own-insertion content shrinks the doc. + const insertId = sharedId ?? intent.replacementGroupHint ?? uuidv4(); + const insertMark = makeInsertMark(ctx, { + id: insertId, + overlapParentId: replacementParentId, + replacementGroupId, + replacementSideId: sharedId ? `${sharedId}#inserted` : '', + }); + const insertPos = clampToDocSize(ctx.tr.doc.content.size, intent.from); + const insertResult = applyInsert(ctx, insertPos, sanitizedSlice, insertMark, insertId, { + create: sharedId ? false : true, + update: sharedId ? true : false, + }); + if (!insertResult.ok) return insertResult; + return { + ...insertResult, + selection: { kind: 'near', pos: insertResult.insertedTo, bias: 1 }, + }; + } + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: intent.from, bias: -1 }, + }; +}; + +const clampToDocSize = (size, pos) => Math.max(0, Math.min(size, pos)); + +const getSingleFullyCoveringOwnInsertedSegment = (ctx, segments, from, to) => { + if (!segments.length) return null; + const inserted = segments.filter( + (s) => s.side === SegmentSide.Inserted && classifySegment(ctx, s) === 'same-user' && s.from <= from && s.to >= to, + ); + if (inserted.length !== segments.length) return null; + const [first] = inserted; + if (!first) return null; + return inserted.every((s) => s.changeId === first.changeId) ? first : null; +}; + +const getReplacementParentId = (ctx, segments) => { + for (const segment of segments) { + if (classifySegment(ctx, segment) !== 'different-user') continue; + if (segment.attrs?.overlapParentId) return segment.attrs.overlapParentId; + return segment.changeId || ''; + } + return ''; +}; + +// --------------------------------------------------------------------------- +// format-apply / format-remove (SD-486 folding) +// --------------------------------------------------------------------------- + +const compileFormat = (ctx, intent) => { + if (!TrackedFormatMarkNames.includes(intent.mark.type.name)) { + return failure('CAPABILITY_UNAVAILABLE', `Mark ${intent.mark.type.name} is not a tracked formatting mark.`); + } + + // Walk segments in range. For each contiguous subrange: + // - if covered by same-user own insertion (or replacement inserted side), + // directly apply/remove the mark (SD-486 fold). + // - if covered by other-user inserted content, defer to trackFormat + // creation. To minimize compiler/legacy duplication we leave this to + // the existing addMarkStep/removeMarkStep helper by returning a hint; + // however since the compiler must drive consistent semantics, we + // directly create the formatting change here over the entire other- + // content range using the same canonical attrs. + // - if mixed structural ranges (paragraph boundaries we can't safely + // model under the tracked text scope), fail closed. + const subranges = computeFormatSubranges(ctx, intent.from, intent.to); + if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses unsupported structural boundary.'); + + const trackFormatType = ctx.schema.marks[TrackFormatMarkName]; + if (!trackFormatType) return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + + /** @type {Array} */ + const formatMarks = []; + + for (const range of subranges) { + if (intent.kind === 'format-apply') { + if (range.fold) { + ctx.tr.addMark(range.from, range.to, intent.mark); + } else { + ctx.tr.addMark(range.from, range.to, intent.mark); + const formatMark = trackFormatType.create({ + id: uuidv4(), + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before: [], + after: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: range.parentId || '', + sourceIds: null, + origin: '', + }); + ctx.tr.addMark(range.from, range.to, formatMark); + formatMarks.push(formatMark); + ctx.createdChangeIds.push(formatMark.attrs.id); + } + } else { + if (range.fold) { + ctx.tr.removeMark(range.from, range.to, intent.mark); + } else { + ctx.tr.removeMark(range.from, range.to, intent.mark); + const formatMark = trackFormatType.create({ + id: uuidv4(), + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], + after: [], + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: range.parentId || '', + sourceIds: null, + origin: '', + }); + ctx.tr.addMark(range.from, range.to, formatMark); + formatMarks.push(formatMark); + ctx.createdChangeIds.push(formatMark.attrs.id); + } + } + } + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + formatMarks, + }; +}; + +/** + * Build the list of contiguous subranges inside [from, to] that share the + * same "format folding" decision. Returns null when the range crosses a + * boundary the compiler refuses to handle (e.g. a non-textblock structural + * node). + * + * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string }> | null} + */ +const computeFormatSubranges = (ctx, from, to) => { + /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string }>} */ + const out = []; + let boundaryCrossed = false; + let lastTextBlock = null; + + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || node.type.name === 'run') return; + if (boundaryCrossed) return false; + // Identify the textblock parent of this inline leaf. + const $pos = ctx.tr.doc.resolve(pos); + const parent = $pos.parent?.type?.name ?? ''; + if (!parent) return; + if (lastTextBlock === null) lastTextBlock = parent; + // We do not consider crossing textblocks unsafe here; PM clips ranges + // to inline content. The compiler refuses only structural marks (handled + // by the SUPPORTED_KINDS check above). + + const segFrom = Math.max(from, pos); + const segTo = Math.min(to, pos + node.nodeSize); + if (segFrom >= segTo) return; + + const insertMark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + if (insertMark) { + const ownership = classifySegment(ctx, { attrs: insertMark.attrs }); + if (ownership === 'same-user') { + appendSubrange(out, { from: segFrom, to: segTo, fold: true }); + } else { + appendSubrange(out, { from: segFrom, to: segTo, fold: false, parentId: insertMark.attrs.id }); + } + return; + } + const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + if (deleteMark) { + // Tracked-deleted content: do not modify formatting; legacy addMarkStep + // also skips this. Fail closed to avoid silent drift. + boundaryCrossed = true; + return false; + } + appendSubrange(out, { from: segFrom, to: segTo, fold: false }); + }); + + if (boundaryCrossed) return null; + return out; +}; + +const appendSubrange = (out, range) => { + const last = out[out.length - 1]; + if (last && last.fold === range.fold && last.parentId === range.parentId && last.to === range.from) { + last.to = range.to; + return; + } + out.push(range); +}; + +/** + * Find the segment that strictly contains `pos` if any. When no segment + * strictly contains the position, returns the segment whose right edge is at + * `pos` (for adjacent same-user refinement detection). + */ +const findContainingSegment = (ctx, pos) => findSegmentAt(ctx, pos); + +// --------------------------------------------------------------------------- +// Diagnostics surfaced for telemetry/tests. +// --------------------------------------------------------------------------- + +export const compilerInternalsForTest = { stripTrackedMarksFromSlice, classifySegment, computeFormatSubranges }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js new file mode 100644 index 0000000000..5838881ac3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -0,0 +1,642 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { Slice, Fragment } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { compileTrackedEdit } from './overlap-compiler.js'; +import { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + makeFormatIntent, + sliceFromText, +} from './edit-intent.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; + +// The review-graph test schema does not carry bold/italic/etc. mark types. +// For format tests we extend the standard fixture schema with a `bold` mark. +import { Schema } from 'prosemirror-model'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; +const NO_EMAIL = { name: 'Anon', email: '' }; + +const FIXED_DATE = '2026-05-21T00:00:00.000Z'; + +const schema = createReviewGraphTestSchema(); + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); +const deleteMark = (attrs) => ({ markType: TrackDeleteMarkName, attrs: markAttrs(attrs) }); + +const runCompile = ({ state, intent, replacements = 'paired' }) => + compileTrackedEdit({ + state, + tr: state.tr, + intent, + replacements, + }); + +const textOf = (tr) => tr.doc.textContent; + +describe('overlap-compiler: text-insert fresh content', () => { + it('marks live-content insertion with a new logical id', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('heXllo'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.authorEmail).toBe(ALICE.email); + expect(result.createdChangeIds).toHaveLength(1); + }); + + it('honors a provided logical id hint for document-api inserts', () => { + const providedId = 'api-insert-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.get(providedId)).toBeDefined(); + expect(result.createdChangeIds).toEqual([providedId]); + }); +}); + +describe('overlap-compiler: same-user own-insertion refinement (SD-486-adjacent / refinement matrix row)', () => { + it('extends the same logical id when inserting inside own insertion', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { + text: 'world', + marks: [ + insertMark({ + id, + author: ALICE.name, + authorEmail: ALICE.email, + date: FIXED_DATE, + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + // Insert "great " at position 5 — inside "world" (after "wo"). + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'great '), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi wogreat rld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Still one logical change with one id — id of the existing insertion. + expect(graph.changes.size).toBe(1); + expect(graph.changes.get(id)).toBeDefined(); + expect(result.updatedChangeIds).toContain(id); + }); + + it('refines own insertion when caret sits at the right edge', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + // Right edge of "world" is position 9. Insert "!" → refine same id. + const intent = makeTextInsertIntent({ + at: 9, + content: sliceFromText(schema, '!'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world!'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + expect(graph.changes.get(id)).toBeDefined(); + }); + + it('replaces inside own insertion while preserving the existing insertion id', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 5, + to: 7, + content: sliceFromText(schema, 'AR'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi wARld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get(id); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(result.updatedChangeIds).toContain(id); + }); +}); + +describe('overlap-compiler: different-user child insertion inside other-user insertion', () => { + it('mints a child id with overlapParentId set to the parent insertion id', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + // Alice inserts "X" at position 6 inside Bob's insertion. + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi woXrld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Two logical changes now: parent and child. + expect(graph.changes.size).toBe(2); + const childChange = Array.from(graph.changes.values()).find((c) => c.id !== parentId); + expect(childChange).toBeDefined(); + expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + expect(childChange.authorEmail).toBe(ALICE.email); + }); +}); + +describe('overlap-compiler: text-insert at exact location inside other-user deletion (SD-3210)', () => { + it('places child insertion at the cursor offset, not at end of deletion span', () => { + const parentId = 'del-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + { text: ' rest' }, + ], + }); + // Alice's cursor lands at position 5 (between "g" and "o"). + const intent = makeTextInsertIntent({ + at: 5, + content: sliceFromText(schema, 'INS'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // Inserted exactly between the original "g" and "o" of the deleted run. + expect(textOf(result.tr)).toBe('Hi gINSone rest'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const insertion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Insertion); + expect(insertion).toBeDefined(); + expect(insertion.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); +}); + +describe('overlap-compiler: text-replace inside own deletion (SD-2335)', () => { + it('preserves the original deletion and creates insertion at the edit point', () => { + const delId = 'del-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'pre ' }, + { text: 'old', marks: [deleteMark({ id: delId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' post' }, + ], + }); + // Alice "types" "new" while her selection covers her own deletion of "old". + const intent = makeTextReplaceIntent({ + from: 5, + to: 8, + content: sliceFromText(schema, 'new'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // Insertion appears at the edit point; original deletion preserved. + expect(textOf(result.tr)).toBe('pre newold post'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // One deletion (preserved) plus one new insertion = two logical changes. + expect(graph.changes.size).toBe(2); + const insertion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Insertion); + const deletion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Deletion); + expect(insertion).toBeDefined(); + expect(deletion).toBeDefined(); + expect(deletion.id).toBe(delId); + }); +}); + +describe('overlap-compiler: text-delete', () => { + it('marks live text as tracked deletion', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextDeleteIntent({ from: 7, to: 12, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('hello world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Deletion); + }); + + it('honors a provided logical id hint for document-api deletions', () => { + const providedId = 'api-delete-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextDeleteIntent({ + from: 7, + to: 12, + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.get(providedId)).toBeDefined(); + expect(result.createdChangeIds).toEqual([providedId]); + }); + + it('collapses own insertion when deleting it', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'new', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' world' }, + ], + }); + // Alice deletes her own insertion "new" at [4, 7]. + const intent = makeTextDeleteIntent({ from: 4, to: 7, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(0); + expect(result.removedChangeIds).toEqual([id]); + }); + + it('creates child deletion inside other-user insertion', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + // Alice deletes "or" inside Bob's insertion. + const intent = makeTextDeleteIntent({ from: 5, to: 7, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const aliceChange = Array.from(graph.changes.values()).find((c) => c.authorEmail === ALICE.email); + expect(aliceChange).toBeDefined(); + expect(aliceChange.type).toBe(CanonicalChangeType.Deletion); + expect(aliceChange.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('no-ops when deleting inside own deletion', () => { + const delId = 'del-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: delId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextDeleteIntent({ from: 4, to: 6, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Only the original deletion remains. + expect(graph.changes.size).toBe(1); + expect(Array.from(graph.changes.values())[0].id).toBe(delId); + }); +}); + +describe('overlap-compiler: weak-identity routes through different-user path', () => { + it('missing author email on parent insertion forces different-user behavior', () => { + const parentId = 'ins-anon'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: '', date: FIXED_DATE })] }, + ], + }); + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Two changes: weak-identity parent stays, Alice's child insertion is new. + expect(graph.changes.size).toBe(2); + const childChange = Array.from(graph.changes.values()).find((c) => c.id !== parentId); + expect(childChange).toBeDefined(); + expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('missing current-user email forces different-user behavior', () => { + const parentId = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + // Current user has no email — should not refine ALICE's insertion. + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: NO_EMAIL, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Parent + child = 2 changes. + expect(graph.changes.size).toBe(2); + }); +}); + +describe('overlap-compiler: text-replace produces paired replacement metadata', () => { + it('paired mode marks shared logical id across delete + insert', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 6, + content: sliceFromText(schema, 'HELLO'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + // One logical change since paired. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.replacement?.inserted.length).toBeGreaterThan(0); + expect(change.replacement?.deleted.length).toBeGreaterThan(0); + }); + + it('links both sides of a child replacement to an other-user insertion parent', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 5, + to: 7, + content: sliceFromText(schema, 'AR'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Replacement); + expect(child.replacement.inserted[0].attrs.overlapParentId).toBe(parentId); + expect(child.replacement.deleted[0].attrs.overlapParentId).toBe(parentId); + }); + + it('honors a provided logical id hint for paired document-api replacements', () => { + const providedId = 'api-replace-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 6, + content: sliceFromText(schema, 'HELLO'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + const change = graph.changes.get(providedId); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(result.createdChangeIds).toEqual([providedId]); + }); +}); + +describe('overlap-compiler: format folding (SD-486)', () => { + // Build a richer schema that includes a bold mark for these tests. + const schemaWithBold = (() => { + const baseSchema = createReviewGraphTestSchema(); + return new Schema({ + nodes: baseSchema.spec.nodes, + marks: { + ...baseSchema.spec.marks.toObject(), + bold: { parseDOM: [{ tag: 'strong' }], toDOM: () => ['strong'] }, + }, + }); + })(); + + const makeBoldedDoc = (spans) => stateFromTrackedSpans({ schema: schemaWithBold, spans }); + + it('folds bold into same-user own insertion without creating trackFormat', () => { + const id = 'ins-alice'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 9, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // No trackFormat created. + expect(result.createdChangeIds).toHaveLength(0); + // The inserted run should now carry bold. + let hasBold = false; + result.tr.doc.descendants((node) => { + if (!node.isText) return; + if (node.marks.some((m) => m.type.name === 'bold')) hasBold = true; + }); + expect(hasBold).toBe(true); + }); + + it('creates a trackFormat over different-user inserted content', () => { + const parentId = 'ins-bob'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 9, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.formatMarks).toHaveLength(1); + expect(result.formatMarks[0].attrs.overlapParentId).toBe(parentId); + }); +}); + +describe('overlap-compiler: typed failures', () => { + it('returns INVALID_TARGET for out-of-range insert', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 99, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns INVALID_TARGET for empty text-insert content', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = { + kind: 'text-insert', + at: 1, + content: Slice.empty, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }; + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns INVALID_TARGET for collapsed text-delete', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextDeleteIntent({ from: 2, to: 2, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns CAPABILITY_UNAVAILABLE for non-tracked format marks', () => { + const baseSchema = createReviewGraphTestSchema(); + const augmented = new Schema({ + nodes: baseSchema.spec.nodes, + marks: { + ...baseSchema.spec.marks.toObject(), + someStructural: { parseDOM: [], toDOM: () => ['x'] }, + }, + }); + const { state } = stateFromTrackedSpans({ schema: augmented, spans: [{ text: 'hello' }] }); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 1, + to: 4, + mark: augmented.marks.someStructural.create(), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = compileTrackedEdit({ state, tr: state.tr, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('CAPABILITY_UNAVAILABLE'); + }); +}); + +describe('overlap-compiler: insertTrackedChange / document-api parity', () => { + it('produces the same graph from document-api intent as from native', () => { + const buildIntent = (source) => + makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source, + }); + const { state: nativeState } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const { state: apiState } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const nativeResult = runCompile({ state: nativeState, intent: buildIntent('native') }); + const apiResult = runCompile({ state: apiState, intent: buildIntent('document-api') }); + expect(nativeResult.ok).toBe(true); + expect(apiResult.ok).toBe(true); + expect(textOf(nativeResult.tr)).toBe(textOf(apiResult.tr)); + const nativeGraph = buildReviewGraph({ state: { doc: nativeResult.tr.doc } }); + const apiGraph = buildReviewGraph({ state: { doc: apiResult.tr.doc } }); + expect(nativeGraph.changes.size).toBe(apiGraph.changes.size); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js new file mode 100644 index 0000000000..b92f155872 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js @@ -0,0 +1,770 @@ +// @ts-check +/** + * TrackedReviewGraph builder. + * + * Plan: v1-3220 / phase0-002 ("Graph Types"). + * + * The graph is rebuilt from the current PM document whenever a tracked-change + * operation needs semantic answers. It MUST be fully reconstructible from + * marks; the cache below is an optional memoization layer keyed by editor + + * doc identity that callers may discard at any time. + * + * Boundaries (phase0-001 "Non-Negotiable Boundaries"): + * - the graph layer must not import document-api adapters, converter + * translators, comments UI, or PresentationEditor. + * - consumers may call the graph; the graph remains below those adapters. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { enumerateTrackedMarkSpans } from './segment-index.js'; +import { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + readTrackedAttrs, + normalizedAttrsEqual, + subtypeFromChangeType, + deterministicJson, +} from './mark-metadata.js'; +import { BODY_STORY, buildStoryKey } from './story-locator.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} TrackedSegment + * @property {string} segmentId + * @property {string} changeId + * @property {string} markType + * @property {string} side 'inserted' | 'deleted' | 'formatting'. + * @property {number} from + * @property {number} to + * @property {string} text + * @property {import('prosemirror-model').Mark} mark + * @property {import('./mark-metadata.js').NormalizedTrackedAttrs} attrs + * @property {string} parentId + * @property {string} parentSide + * @property {'parent'|'child'|'standalone'} overlapRole + * @property {Array} [nodePath] optional diagnostics nodePath. + */ + +/** + * @typedef {Object} LogicalReplacementProjection + * @property {string} groupId + * @property {Array} inserted + * @property {Array} deleted + * @property {string} insertedSideId + * @property {string} deletedSideId + */ + +/** + * @typedef {Object} LogicalTrackedChange + * @property {string} id + * @property {string} type + * @property {string} subtype + * @property {'open'} state + * @property {Array} segments + * @property {Array} coverageSegments + * @property {Array} insertedSegments + * @property {Array} deletedSegments + * @property {Array} formattingSegments + * @property {LogicalReplacementProjection | null} replacement + * @property {string} author + * @property {string} authorEmail + * @property {string} authorImage + * @property {string} date + * @property {Record} sourceIds + * @property {string} revisionGroupId + * @property {string} splitFromId + * @property {string} sourcePlatform + * @property {import('./story-locator.js').StoryLocator} story + * @property {string|null} parent + * @property {Array} children + * @property {Array} before + * @property {Array} after + * @property {string} excerpt + */ + +/** + * @typedef {Object} GraphDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {Object} TrackedReviewGraph + * @property {Map} changes + * @property {Array} segments + * @property {Map} bySegmentId + * @property {Map>} byRevisionGroupId + * @property {Map>} byReplacementGroupId + * @property {Map>} byParentId + * @property {import('./story-locator.js').StoryLocator} story + * @property {(pos: number) => Array} overlapAt + * @property {(from: number, to: number) => Array} segmentsInRange + * @property {(from: number, to: number) => Array} changesInRange + * @property {() => Array} validate + */ + +/** + * Build (or fetch a cached) graph for one story editor. + * + * @param {{ + * state: import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node }, + * story?: import('./story-locator.js').StoryLocator, + * replacementsMode?: 'paired'|'independent', + * }} input + * @returns {TrackedReviewGraph} + */ +export const buildReviewGraph = ({ state, story = BODY_STORY, replacementsMode = 'paired' }) => { + const spans = enumerateTrackedMarkSpans(state); + return buildGraphFromSpans({ spans, doc: state?.doc ?? null, story, replacementsMode }); +}; + +// --------------------------------------------------------------------------- +// Memoization +// --------------------------------------------------------------------------- + +const graphCache = new WeakMap(); +const NULL_DOC_KEY = '__nullDoc__'; + +/** + * Memoized graph build keyed on editor identity + doc identity + story key. + * The cache is discardable; callers can call `buildReviewGraph` directly. + * + * @param {{ + * editor: object, + * state?: import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node }, + * story?: import('./story-locator.js').StoryLocator, + * replacementsMode?: 'paired'|'independent', + * }} input + * @returns {TrackedReviewGraph} + */ +export const getOrBuildReviewGraph = ({ editor, state, story = BODY_STORY, replacementsMode = 'paired' }) => { + const effectiveState = state ?? /** @type {*} */ (editor)?.state ?? null; + const storyKey = buildStoryKey(story); + const doc = effectiveState?.doc ?? null; + const docKey = doc ?? NULL_DOC_KEY; + const cacheKey = `${storyKey}|${replacementsMode}`; + + let perEditor = graphCache.get(editor); + if (!perEditor) { + perEditor = new Map(); + graphCache.set(editor, perEditor); + } + + const cached = perEditor.get(cacheKey); + if (cached && cached.docKey === docKey) { + return cached.graph; + } + + const graph = buildReviewGraph({ state: effectiveState ?? { doc: null }, story, replacementsMode }); + perEditor.set(cacheKey, { docKey, graph }); + return graph; +}; + +/** + * Discard any cached graphs for an editor. Tests and consumers can call this + * to prove the graph is always reconstructible from marks. + * + * @param {object} editor + */ +export const invalidateReviewGraphCache = (editor) => { + graphCache.delete(editor); +}; + +// --------------------------------------------------------------------------- +// Internal builder +// --------------------------------------------------------------------------- + +const buildGraphFromSpans = ({ spans, doc, story, replacementsMode }) => { + /** @type {Array<{ attrs: import('./mark-metadata.js').NormalizedTrackedAttrs, span: import('./segment-index.js').TrackedMarkSpan }>} */ + const normalized = spans.map((span) => ({ + attrs: readTrackedAttrs(span.mark, span.mark.type.name), + span, + })); + + // 1. Merge adjacent equivalent mark spans into TrackedSegments. + const mergedSegments = mergeAdjacentSpans(normalized); + hydrateSegmentText({ segments: mergedSegments, doc }); + + // 2. Compute side ownership per logical id, then derive replacement + // groupings (paired) when the same id has both inserted and deleted + // segments, or when explicit replacementGroupId metadata says so. + const segmentsByChangeId = groupBy(mergedSegments, (s) => s.changeId); + + // 3. Build the LogicalTrackedChange map. This pass also normalizes + // `changeType` so a paired id with both inserted and deleted segments + // projects as `replacement` even if the legacy marks omit changeType. + const changes = new Map(); + const byRevisionGroupId = new Map(); + const byReplacementGroupId = new Map(); + const byParentId = new Map(); + + for (const [changeId, segs] of segmentsByChangeId) { + const logical = buildLogicalChange({ changeId, segments: segs, doc, story, replacementsMode }); + changes.set(changeId, logical); + + appendToMap(byRevisionGroupId, logical.revisionGroupId, changeId); + + if (logical.replacement) { + appendToMap(byReplacementGroupId, logical.replacement.groupId, changeId); + } + // Also index by any explicit replacementGroupId from segment attrs. + for (const seg of segs) { + if (seg.attrs.replacementGroupId) { + appendToMap(byReplacementGroupId, seg.attrs.replacementGroupId, changeId); + } + } + } + + // 4. Resolve parent/child relationships and stamp `overlapRole`/`parentSide`. + for (const [id, logical] of changes) { + let resolvedParentId = ''; + for (const seg of logical.segments) { + const parentId = seg.attrs.overlapParentId; + if (!parentId || parentId === id) continue; + const parentLogical = changes.get(parentId); + seg.parentId = parentId; + seg.parentSide = parentLogical + ? parentLogical.type === CanonicalChangeType.Replacement + ? '' + : (parentLogical.segments[0]?.side ?? '') + : ''; + seg.overlapRole = 'child'; + if (!resolvedParentId) resolvedParentId = parentId; + appendToMap(byParentId, parentId, id); + } + if (resolvedParentId) logical.parent = resolvedParentId; + } + + // 5. Fill children arrays. + for (const [parentId, childIds] of byParentId) { + const parent = changes.get(parentId); + if (parent) { + parent.children = unique(childIds); + const childCoverageSegments = parent.children + .flatMap((childId) => changes.get(childId)?.segments ?? []) + .filter((seg) => shouldContributeToParentCoverage(seg)); + parent.coverageSegments = uniqueSegments([...parent.coverageSegments, ...childCoverageSegments]); + } + } + + // 6. Build segment-id index. + const bySegmentId = new Map(); + for (const segs of segmentsByChangeId.values()) { + for (const seg of segs) bySegmentId.set(seg.segmentId, seg); + } + + // 7. Flat ordered segment list. + const segments = mergedSegments; + + /** @type {TrackedReviewGraph} */ + const graph = { + changes, + segments, + bySegmentId, + byRevisionGroupId, + byReplacementGroupId, + byParentId, + story, + overlapAt: (pos) => segments.filter((seg) => pos >= seg.from && pos < seg.to), + segmentsInRange: (from, to) => { + if (from > to) [from, to] = [to, from]; + return segments.filter((seg) => seg.to > from && seg.from < to); + }, + changesInRange: (from, to) => { + const ids = new Set(); + for (const seg of segments) { + if (seg.to <= from || seg.from >= to) continue; + ids.add(seg.changeId); + } + return Array.from(ids) + .map((id) => changes.get(id)) + .filter(Boolean); + }, + validate: () => validateGraph(graph), + }; + + Object.defineProperty(graph, 'replacementsMode', { + value: replacementsMode, + enumerable: false, + }); + + return graph; +}; + +// --------------------------------------------------------------------------- +// Mark span merge +// --------------------------------------------------------------------------- + +const mergeAdjacentSpans = (normalized) => { + // Spans come from inline node traversal; sort by `from` then `to` to be + // safe in case of zero-width quirks. Stability is preserved by the + // secondary key. + const sorted = [...normalized].sort((a, b) => { + if (a.span.from !== b.span.from) return a.span.from - b.span.from; + return a.span.to - b.span.to; + }); + + /** @type {Array} */ + const merged = []; + // Track per-change stable ordinal for deterministic segment ids. + const ordinalByChange = new Map(); + + for (const { attrs, span } of sorted) { + const last = merged.length ? merged[merged.length - 1] : null; + const canMerge = + last && + last.changeId === attrs.id && + last.markType === attrs.markType && + last.to === span.from && + normalizedAttrsEqual(last.attrs, attrs); + + if (canMerge) { + last.to = span.to; + continue; + } + + const ordinal = ordinalByChange.get(attrs.id) ?? 0; + ordinalByChange.set(attrs.id, ordinal + 1); + + const side = attrs.side || sideFromMarkNameSafe(attrs.markType); + const segmentId = makeSegmentId(attrs.id, side, span.from, span.to, ordinal); + + /** @type {TrackedSegment} */ + const seg = { + segmentId, + changeId: attrs.id, + markType: attrs.markType, + side, + from: span.from, + to: span.to, + text: '', + mark: span.mark, + attrs, + parentId: attrs.overlapParentId || '', + parentSide: '', + overlapRole: attrs.overlapParentId ? 'child' : 'standalone', + }; + merged.push(seg); + } + + return merged; +}; + +const hydrateSegmentText = ({ segments, doc }) => { + if (!doc) return; + for (const seg of segments) { + try { + seg.text = doc.textBetween(seg.from, seg.to, ' ', ''); + } catch { + seg.text = ''; + } + } +}; + +const shouldContributeToParentCoverage = (childSegment) => { + // A child insertion inside a parent revision is newly proposed content and + // should not extend the parent's original coverage. Child deletion and + // formatting segments, however, may carry text that still belongs to the + // parent's logical saved structure after same-type overlap decomposition. + return childSegment.side !== SegmentSide.Inserted; +}; + +const sideFromMarkNameSafe = (markName) => { + if (markName === TrackInsertMarkName) return SegmentSide.Inserted; + if (markName === TrackDeleteMarkName) return SegmentSide.Deleted; + if (markName === TrackFormatMarkName) return SegmentSide.Formatting; + return ''; +}; + +const makeSegmentId = (changeId, side, from, to, ordinal) => `${changeId}:${side}:${from}:${to}:${ordinal}`; + +// --------------------------------------------------------------------------- +// Logical change projection +// --------------------------------------------------------------------------- + +const buildLogicalChange = ({ changeId, segments, doc, story, replacementsMode }) => { + const inserted = segments.filter((s) => s.side === SegmentSide.Inserted); + const deleted = segments.filter((s) => s.side === SegmentSide.Deleted); + const formatting = segments.filter((s) => s.side === SegmentSide.Formatting); + + // Determine canonical change type. + // - an *explicitly persisted* changeType attr on any segment wins. The + // inferred changeType from readTrackedAttrs is NOT used here, because + // inferred values reflect mark type only — they would shadow the paired + // ins+del replacement detection below. + // - paired (default): both inserted and deleted under one id => replacement. + // - independent: keep separate sides as separate logical changes — but the + // builder still groups by id, so independent-mode replacements must + // already have been minted with distinct ids by the compiler. If we see + // both sides under one id in independent mode, we still project as + // replacement (defensive — better than silently dropping a side). + const explicitType = segments.find((s) => s.attrs.explicitChangeType)?.attrs.explicitChangeType ?? ''; + + let type; + if (explicitType) { + type = explicitType; + } else if (inserted.length && deleted.length) { + type = CanonicalChangeType.Replacement; + } else if (inserted.length) { + type = CanonicalChangeType.Insertion; + } else if (deleted.length) { + type = CanonicalChangeType.Deletion; + } else if (formatting.length) { + type = CanonicalChangeType.Formatting; + } else { + type = ''; + } + + const subtype = subtypeFromChangeType(type) ?? ''; + const primary = segments[0]?.attrs ?? null; + + /** @type {LogicalReplacementProjection | null} */ + let replacement = null; + if (type === CanonicalChangeType.Replacement) { + const insertedSideId = inserted[0]?.attrs.replacementSideId || `${changeId}#inserted`; + const deletedSideId = deleted[0]?.attrs.replacementSideId || `${changeId}#deleted`; + const explicitGroupId = segments.find((s) => s.attrs.replacementGroupId)?.attrs.replacementGroupId ?? ''; + replacement = { + groupId: explicitGroupId || changeId, + inserted, + deleted, + insertedSideId, + deletedSideId, + }; + } + + // Aggregate sourceIds across segments (e.g. paired replacement carries + // wordIdInsert + wordIdDelete). Deterministic merge order: sort by side + // then segment ordinal so output is stable across rebuilds. + const aggregatedSourceIds = aggregateSourceIds(segments); + + const sourcePlatform = derivePlatform(segments, primary); + + // before/after carriers from trackFormat segments — kept as raw arrays so + // downstream decision/export code can interpret them. + const before = formatting.length ? /** @type {*} */ (formatting[0]?.mark?.attrs?.before ?? []) : []; + const after = formatting.length ? /** @type {*} */ (formatting[0]?.mark?.attrs?.after ?? []) : []; + + // Coverage segments: by default the segments themselves cover the logical + // change. Child segments inside a parent deletion are tracked here so + // accept/reject can use coverage rather than only parent-owned persisted + // segments. The compiler in plan 003 will write explicit coverage; the + // graph layer just exposes the structure. + const coverageSegments = [...segments]; + + const excerpt = doc ? extractExcerpt(doc, segments) : ''; + + /** @type {LogicalTrackedChange} */ + const logical = { + id: changeId, + type, + subtype, + state: 'open', + segments, + coverageSegments, + insertedSegments: inserted, + deletedSegments: deleted, + formattingSegments: formatting, + replacement, + author: primary?.author ?? '', + authorEmail: primary?.authorEmail ?? '', + authorImage: primary?.authorImage ?? '', + date: primary?.date ?? '', + sourceIds: aggregatedSourceIds, + revisionGroupId: primary?.revisionGroupId || changeId, + splitFromId: primary?.splitFromId ?? '', + sourcePlatform, + story, + parent: null, + children: [], + before, + after, + excerpt, + }; + + // replacementsMode is informational here; the compiler (plan 003) uses it + // when minting new replacement ids. The graph stores it on the change for + // observability/tests. + Object.defineProperty(logical, 'replacementsMode', { + value: replacementsMode, + enumerable: false, + }); + + return logical; +}; + +const aggregateSourceIds = (segments) => { + /** @type {Record} */ + const out = {}; + // Sort by side then by ordinal-in-id to be deterministic. + const sorted = [...segments].sort((a, b) => { + if (a.side !== b.side) return a.side < b.side ? -1 : 1; + return a.from - b.from; + }); + for (const seg of sorted) { + for (const [k, v] of Object.entries(seg.attrs.sourceIds || {})) { + if (v == null || v === '') continue; + if (out[k] == null) out[k] = v; + } + } + return out; +}; + +const derivePlatform = (segments, primary) => { + if (primary?.origin) return primary.origin; + // Heuristic: a sourceIds object with wordId* hints at a Word import. + const sourceIds = primary?.sourceIds || {}; + if (sourceIds.wordIdInsert || sourceIds.wordIdDelete || sourceIds.wordIdFormat) return 'word'; + if (primary?.sourceId) return 'word'; + return ''; +}; + +const extractExcerpt = (doc, segments) => { + if (!segments.length || !doc) return ''; + // Use first inserted segment's range as the canonical excerpt source; + // fall back to first segment if there is no inserted side. + const target = segments.find((s) => s.side === SegmentSide.Inserted) ?? segments[0]; + try { + const text = doc.textBetween(target.from, target.to, ' ', ''); + return text.length > 200 ? `${text.slice(0, 200)}…` : text; + } catch { + return ''; + } +}; + +// --------------------------------------------------------------------------- +// Validation / invariants +// --------------------------------------------------------------------------- + +/** + * Run all graph invariants from phase0-002 "Graph Invariants". + * + * Severity: + * - `error`: hard invariant violation. Decision/compiler paths must abort. + * - `warning`: structural anomaly that the graph still represents (e.g. + * replacement with empty deleted side mid-transaction). + * - `info`: telemetry only. + * + * @param {TrackedReviewGraph} graph + * @returns {Array} + */ +export const validateGraph = (graph) => { + /** @type {GraphDiagnostic[]} */ + const diagnostics = []; + + // 1. Every tracked mark has an id. + for (const seg of graph.segments) { + if (!seg.changeId) { + diagnostics.push({ + code: 'INV_MARK_MISSING_ID', + severity: 'error', + message: 'tracked mark span lacks an id', + details: { from: seg.from, to: seg.to, markType: seg.markType }, + }); + } + } + + for (const [id, change] of graph.changes) { + // 2. Every open logical change has at least one segment. + if (!change.segments.length) { + diagnostics.push({ + code: 'INV_OPEN_CHANGE_NO_SEGMENTS', + severity: 'error', + message: 'logical change has no segments', + changeIds: [id], + }); + continue; + } + + // 3. Every segment range is non-empty. + for (const seg of change.segments) { + if (seg.from >= seg.to) { + diagnostics.push({ + code: 'INV_EMPTY_SEGMENT_RANGE', + severity: 'error', + message: 'tracked segment has an empty range', + changeIds: [id], + details: { segmentId: seg.segmentId, from: seg.from, to: seg.to }, + }); + } + } + + // 4. Replacement has at least one inserted and one deleted side. + if (change.type === CanonicalChangeType.Replacement) { + if (!change.insertedSegments.length || !change.deletedSegments.length) { + diagnostics.push({ + code: 'INV_REPLACEMENT_MISSING_SIDE', + severity: 'warning', + message: 'replacement is missing one side', + changeIds: [id], + details: { + inserted: change.insertedSegments.length, + deleted: change.deletedSegments.length, + }, + }); + } + // 5. Replacement side metadata matches the logical change. + if ( + change.replacement && + change.replacement.groupId !== id && + !graph.byReplacementGroupId.has(change.replacement.groupId) + ) { + diagnostics.push({ + code: 'INV_REPLACEMENT_GROUP_MISMATCH', + severity: 'warning', + message: 'replacementGroupId not indexed', + changeIds: [id], + }); + } + } + + // 6. splitFromId never equals id. + if (change.splitFromId && change.splitFromId === id) { + diagnostics.push({ + code: 'INV_SPLIT_FROM_SELF', + severity: 'error', + message: 'splitFromId equals own id', + changeIds: [id], + }); + } + + // 7. overlapParentId points to an existing parent. + for (const seg of change.segments) { + if (seg.attrs.overlapParentId && !graph.changes.has(seg.attrs.overlapParentId)) { + diagnostics.push({ + code: 'INV_CHILD_MISSING_PARENT', + severity: 'warning', + message: 'segment references a missing overlap parent', + changeIds: [id], + details: { segmentId: seg.segmentId, parentId: seg.attrs.overlapParentId }, + }); + } + } + + // 9. revisionGroupId is stable across fragments. + const revisionGroups = unique(change.segments.map((s) => s.attrs.revisionGroupId || id)); + if (revisionGroups.length > 1) { + diagnostics.push({ + code: 'INV_REVISION_GROUP_INCONSISTENT', + severity: 'warning', + message: 'segments of one logical change reference multiple revisionGroupIds', + changeIds: [id], + details: { revisionGroups }, + }); + } + + // 11. Derived fields match persisted mark type. + for (const seg of change.segments) { + const expectedSide = sideFromMarkNameSafe(seg.markType); + if (expectedSide && seg.side !== expectedSide) { + diagnostics.push({ + code: 'INV_SIDE_DERIVATION_MISMATCH', + severity: 'error', + message: 'segment side does not match mark type', + changeIds: [id], + details: { segmentId: seg.segmentId, side: seg.side, markType: seg.markType }, + }); + } + } + } + + // 8. Same-type overlap on one character — checked by scanning for two + // segments of the same side+markType covering an identical [from,to]. + // The merge pass already collapses adjacent same-id, same-attrs segments, + // so any remaining same-type stacked segment is an integrity error. + const segmentsByPosition = new Map(); + for (const seg of graph.segments) { + const key = `${seg.markType}:${seg.from}:${seg.to}`; + if (segmentsByPosition.has(key)) { + diagnostics.push({ + code: 'INV_SAME_TYPE_OVERLAP', + severity: 'error', + message: 'same-type tracked marks occupy identical range', + changeIds: [seg.changeId, segmentsByPosition.get(key).changeId], + details: { range: { from: seg.from, to: seg.to }, markType: seg.markType }, + }); + } else { + segmentsByPosition.set(key, seg); + } + } + + return diagnostics; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const groupBy = (arr, fn) => { + /** @type {Map>} */ + const out = new Map(); + for (const item of arr) { + const key = fn(item); + const list = out.get(key); + if (list) list.push(item); + else out.set(key, [item]); + } + return out; +}; + +const appendToMap = (map, key, value) => { + const list = map.get(key); + if (list) { + if (!list.includes(value)) list.push(value); + } else { + map.set(key, [value]); + } +}; + +const unique = (arr) => Array.from(new Set(arr)); + +const uniqueSegments = (segments) => { + const seen = new Set(); + const out = []; + for (const seg of segments) { + if (seen.has(seg.segmentId)) continue; + seen.add(seg.segmentId); + out.push(seg); + } + out.sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + if (a.to !== b.to) return a.to - b.to; + return a.segmentId.localeCompare(b.segmentId); + }); + return out; +}; + +// --------------------------------------------------------------------------- +// Public helpers used by future consumers (plan 003/004) +// --------------------------------------------------------------------------- + +/** + * Deterministic signature for a logical change that downstream code can use + * to detect graph-equivalent rebuilds (e.g. collaboration replay). Stable + * across PM position drift because it includes the revisionGroupId and side + * counts but not absolute positions. + * + * @param {LogicalTrackedChange} change + * @returns {string} + */ +export const signatureOf = (change) => { + return deterministicJson({ + id: change.id, + type: change.type, + revisionGroupId: change.revisionGroupId, + inserted: change.insertedSegments.length, + deleted: change.deletedSegments.length, + formatting: change.formattingSegments.length, + children: change.children, + sourceIds: change.sourceIds, + }); +}; + +export { CanonicalChangeType, ChangeSubtype, SegmentSide }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js new file mode 100644 index 0000000000..51214c70c1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { TrackInsertMarkName } from '../constants.js'; +import { buildReviewGraph } from './review-graph.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; + +const schema = createReviewGraphTestSchema(); + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); + +describe('review-graph: performance', () => { + // Phase0-002 "Graph Invariants": "Graph rebuild must be O(N) in tracked-mark + // spans. Add a performance test or benchmark around a document with at + // least 5,000 tracked spans and a key-repeat edit inside an existing + // tracked insertion." + // + // The threshold here is intentionally generous so CI variance does not + // flake the suite. The assertion exists to catch a regression that turns + // the build accidentally O(N^2): on a healthy machine the build for 5k + // distinct-id spans finishes well under 100ms; we allow 2.5s. + it('builds a graph with at least 5000 distinct tracked-mark spans within budget', () => { + const N = 5000; + const spans = []; + for (let i = 0; i < N; i++) { + // Each span gets a distinct id so the merge pass cannot collapse them. + // Interleave with one untracked space so the spans stay as discrete + // mark spans rather than being merged into one inline node. + spans.push({ text: `x`, marks: [insertMark({ id: `id-${i}` })] }); + spans.push({ text: ' ', marks: [] }); + } + + const { state } = stateFromTrackedSpans({ schema, spans }); + + const start = Date.now(); + const graph = buildReviewGraph({ state }); + const elapsed = Date.now() - start; + + expect(graph.changes.size).toBe(N); + expect(elapsed).toBeLessThan(2500); + }); + + // Same shape with one shared id: this is what a "long, key-repeat-style + // refinement inside a single tracked insertion" looks like once the + // compiler folds adjacent same-id segments. The merge pass should collapse + // them, so this test asserts on segment count rather than time. + it('merges 5000 adjacent same-id insertion segments into one segment', () => { + const N = 5000; + const spans = []; + for (let i = 0; i < N; i++) { + spans.push({ text: 'x', marks: [insertMark({ id: 'one' })] }); + } + const { state } = stateFromTrackedSpans({ schema, spans }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.size).toBe(1); + // Even though each character is a distinct PM text node, the merge pass + // should produce one segment because positions are adjacent and attrs + // are identical. + expect(graph.changes.get('one').segments).toHaveLength(1); + expect(graph.changes.get('one').segments[0].to - graph.changes.get('one').segments[0].from).toBe(N); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js new file mode 100644 index 0000000000..9273d63822 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '../constants.js'; +import { + buildReviewGraph, + getOrBuildReviewGraph, + invalidateReviewGraphCache, + CanonicalChangeType, + SegmentSide, + signatureOf, +} from './review-graph.js'; +import { runGraphInvariants, graphHasErrors } from './graph-invariants.js'; +import { BODY_STORY } from './story-locator.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; + +const schema = createReviewGraphTestSchema(); + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); +const deleteMark = (attrs) => ({ markType: TrackDeleteMarkName, attrs: markAttrs(attrs) }); +const formatMark = (attrs) => ({ markType: TrackFormatMarkName, attrs: markAttrs(attrs) }); + +describe('review-graph: legacy mark inference', () => { + it('builds an insertion logical change from a legacy trackInsert mark', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hello, ', marks: [] }, + { + text: 'world', + marks: [insertMark({ id: 'c1', author: ALICE.name, authorEmail: ALICE.email, date: '2026-01-01' })], + }, + ], + }); + const graph = buildReviewGraph({ state, story: BODY_STORY }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get('c1'); + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.subtype).toBe('text-insertion'); + expect(change.segments).toHaveLength(1); + expect(change.insertedSegments[0].text).toBe('world'); + expect(change.excerpt).toBe('world'); + expect(change.replacement).toBeNull(); + expect(change.author).toBe('Alice'); + expect(change.authorEmail).toBe(ALICE.email); + expect(change.revisionGroupId).toBe('c1'); + expect(graph.validate()).toEqual([]); + }); + + it('builds a deletion logical change from a legacy trackDelete mark', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hello, ', marks: [] }, + { text: 'world', marks: [deleteMark({ id: 'c2', author: ALICE.name, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('c2'); + expect(change.type).toBe(CanonicalChangeType.Deletion); + expect(change.subtype).toBe('text-deletion'); + expect(change.deletedSegments).toHaveLength(1); + expect(change.insertedSegments).toHaveLength(0); + }); + + it('projects paired ins+del sharing one id as one replacement', () => { + const sharedId = 'rep1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: sharedId, authorEmail: ALICE.email })] }, + { text: 'new', marks: [insertMark({ id: sharedId, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state, replacementsMode: 'paired' }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get(sharedId); + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.subtype).toBe('text-replacement'); + expect(change.replacement).not.toBeNull(); + expect(change.replacement.inserted).toHaveLength(1); + expect(change.replacement.deleted).toHaveLength(1); + expect(change.replacement.groupId).toBe(sharedId); + expect(change.replacement.insertedSideId).toBe(`${sharedId}#inserted`); + expect(change.replacement.deletedSideId).toBe(`${sharedId}#deleted`); + }); + + it('keeps ins+del under distinct ids as two logical changes (independent mode shape)', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: 'd1' })] }, + { text: 'new', marks: [insertMark({ id: 'i1' })] }, + ], + }); + const graph = buildReviewGraph({ state, replacementsMode: 'independent' }); + expect(graph.changes.size).toBe(2); + expect(graph.changes.get('d1').type).toBe(CanonicalChangeType.Deletion); + expect(graph.changes.get('i1').type).toBe(CanonicalChangeType.Insertion); + }); +}); + +describe('review-graph: multi-segment changes', () => { + it('merges adjacent equivalent insertion spans into one segment', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'aa', marks: [insertMark({ id: 'i1' })] }, + { text: 'bb', marks: [insertMark({ id: 'i1' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('i1'); + expect(change.segments).toHaveLength(1); + expect(change.segments[0].to - change.segments[0].from).toBe(4); + }); + + it('keeps two same-id parent deletion segments when split by a child', () => { + const parent = 'p1'; + const child = 'c1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'ab', marks: [deleteMark({ id: parent, authorEmail: ALICE.email })] }, + { + text: 'cd', + marks: [ + deleteMark({ + id: child, + authorEmail: BOB.email, + overlapParentId: parent, + }), + ], + }, + { text: 'ef', marks: [deleteMark({ id: parent, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const parentChange = graph.changes.get(parent); + expect(parentChange.segments).toHaveLength(2); + expect(parentChange.coverageSegments.map((seg) => seg.text)).toEqual(['ab', 'cd', 'ef']); + expect(parentChange.type).toBe(CanonicalChangeType.Deletion); + expect(parentChange.children).toEqual([child]); + + const childChange = graph.changes.get(child); + expect(childChange.parent).toBe(parent); + expect(childChange.segments[0].overlapRole).toBe('child'); + }); +}); + +describe('review-graph: child overlap relationships', () => { + it('tracks child insertion inside a parent deletion via overlapParentId', () => { + const parent = 'pd1'; + const child = 'ci1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'abc', marks: [deleteMark({ id: parent })] }, + { text: 'new', marks: [insertMark({ id: child, overlapParentId: parent })] }, + { text: 'def', marks: [deleteMark({ id: parent })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.byParentId.get(parent)).toEqual([child]); + expect(graph.changes.get(child).parent).toBe(parent); + expect(graph.changes.get(parent).children).toEqual([child]); + }); + + it('tracks child deletion inside a parent insertion', () => { + const parent = 'pi1'; + const child = 'cd1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'abc', marks: [insertMark({ id: parent, authorEmail: ALICE.email })] }, + { text: 'mid', marks: [deleteMark({ id: child, authorEmail: BOB.email, overlapParentId: parent })] }, + { text: 'def', marks: [insertMark({ id: parent, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get(child).parent).toBe(parent); + expect(graph.changes.get(parent).children).toEqual([child]); + }); +}); + +describe('review-graph: source-id projection', () => { + it('folds legacy sourceId into sourceIds with the mark-side-specific key', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'ins', marks: [insertMark({ id: 's1', sourceId: 'wid-42' })] }], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get('s1').sourceIds).toEqual({ wordIdInsert: 'wid-42' }); + }); + + it('aggregates inserted and deleted sourceIds onto one replacement', () => { + const sharedId = 'r1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: sharedId, sourceId: 'del-7' })] }, + { text: 'new', marks: [insertMark({ id: sharedId, sourceId: 'ins-9' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get(sharedId).sourceIds).toEqual({ wordIdDelete: 'del-7', wordIdInsert: 'ins-9' }); + }); +}); + +describe('review-graph: invariants', () => { + it('reports no diagnostics on a clean document', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'hi', marks: [insertMark({ id: 'ok1' })] }], + }); + const graph = buildReviewGraph({ state }); + const result = runGraphInvariants(graph); + expect(result.errors).toEqual([]); + expect(graphHasErrors(graph)).toBe(false); + }); + + it('detects splitFromId === id as an error', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'bad', splitFromId: 'bad' })] }], + }); + const graph = buildReviewGraph({ state }); + const errors = runGraphInvariants(graph).errors; + expect(errors.some((d) => d.code === 'INV_SPLIT_FROM_SELF')).toBe(true); + expect(graphHasErrors(graph)).toBe(true); + }); + + it('reports tracked marks without ids instead of dropping them before validation', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [{ markType: TrackInsertMarkName, attrs: { authorEmail: ALICE.email } }] }], + }); + const graph = buildReviewGraph({ state }); + const errors = runGraphInvariants(graph).errors; + expect(errors.some((d) => d.code === 'INV_MARK_MISSING_ID')).toBe(true); + expect(graph.segments).toHaveLength(1); + }); + + it('warns when child references a missing parent', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'hi', marks: [insertMark({ id: 'orphan', overlapParentId: 'ghost' })] }], + }); + const graph = buildReviewGraph({ state }); + const warnings = runGraphInvariants(graph).warnings; + expect(warnings.some((d) => d.code === 'INV_CHILD_MISSING_PARENT')).toBe(true); + }); + + it('warns when a replacement is missing one side', () => { + // Force a "replacement" type with only an inserted side by setting + // explicit changeType on the mark. + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'incomplete', marks: [insertMark({ id: 'r2', changeType: CanonicalChangeType.Replacement })] }], + }); + const graph = buildReviewGraph({ state }); + const warnings = runGraphInvariants(graph).warnings; + expect(warnings.some((d) => d.code === 'INV_REPLACEMENT_MISSING_SIDE')).toBe(true); + }); +}); + +describe('review-graph: mixed legacy + overlap metadata', () => { + it('builds correctly when one segment carries explicit metadata and another does not', () => { + const sharedId = 'mixed'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + // Legacy insertion, no overlap attrs. + { text: 'aa', marks: [insertMark({ id: sharedId })] }, + // Same-id segment with explicit overlap metadata. + { + text: 'bb', + marks: [ + insertMark({ + id: sharedId, + revisionGroupId: sharedId, + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get(sharedId); + // Mixed metadata should still produce one logical change. + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.segments.length).toBeGreaterThanOrEqual(1); + // No revisionGroupId inconsistency: both segments resolve to sharedId. + const warns = runGraphInvariants(graph).warnings.filter((d) => d.code === 'INV_REVISION_GROUP_INCONSISTENT'); + expect(warns).toEqual([]); + }); +}); + +describe('review-graph: attr normalization for adjacent merge', () => { + it('merges adjacent identical attrs even when one omits default fields', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'aa', marks: [insertMark({ id: 'n1', authorEmail: ALICE.email })] }, + { + text: 'bb', + marks: [ + insertMark({ + id: 'n1', + authorEmail: ALICE.email, + revisionGroupId: 'n1', + splitFromId: '', + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('n1'); + expect(change.segments).toHaveLength(1); + }); +}); + +describe('review-graph: story scope', () => { + it('carries the story locator on every logical change', () => { + const story = { kind: 'story', storyType: 'footnote', noteId: 'fn1' }; + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'note', marks: [insertMark({ id: 's-fn1' })] }], + }); + const graph = buildReviewGraph({ state, story }); + expect(graph.story).toBe(story); + expect(graph.changes.get('s-fn1').story).toBe(story); + }); +}); + +describe('review-graph: caching', () => { + it('returns the same graph instance for the same doc identity', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'c1' })] }], + }); + const editor = { state }; + const a = getOrBuildReviewGraph({ editor, state }); + const b = getOrBuildReviewGraph({ editor, state }); + expect(a).toBe(b); + }); + + it('rebuilds after invalidate', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'c1' })] }], + }); + const editor = { state }; + const a = getOrBuildReviewGraph({ editor, state }); + invalidateReviewGraphCache(editor); + const b = getOrBuildReviewGraph({ editor, state }); + expect(a).not.toBe(b); + }); +}); + +describe('review-graph: signature', () => { + it('produces deterministic JSON across rebuilds', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'sig1' })] }], + }); + const g1 = buildReviewGraph({ state }); + const g2 = buildReviewGraph({ state }); + expect(signatureOf(g1.changes.get('sig1'))).toBe(signatureOf(g2.changes.get('sig1'))); + }); +}); + +describe('review-graph: range and overlap queries', () => { + it('overlapAt finds segments covering a position', () => { + const { state, paragraphStart } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'hello ', marks: [] }, + { text: 'world', marks: [insertMark({ id: 'q1' })] }, + ], + }); + // 'world' starts at paragraphStart + 'hello '.length = 1 + 6 = 7. + const graph = buildReviewGraph({ state }); + const at = graph.overlapAt(paragraphStart + 7); + expect(at).toHaveLength(1); + expect(at[0].changeId).toBe('q1'); + }); + it('segmentsInRange returns intersecting segments', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'hello ', marks: [] }, + { text: 'world', marks: [insertMark({ id: 'q2' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.segmentsInRange(0, 100).length).toBe(1); + expect(graph.segmentsInRange(0, 1).length).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js new file mode 100644 index 0000000000..52e233cf4b --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js @@ -0,0 +1,58 @@ +// @ts-check +/** + * Low-level tracked-mark span enumerator. + * + * Plan: v1-3220 / phase0-002 ("Consumers To Migrate"). + * + * The legacy raw mark scan can remain as a primitive inside this module. + * `getTrackChanges` is preserved as a low-level mark-span enumerator for + * tests and compatibility, but it MUST NOT define logical behavior for + * list/get/decide/bubbles/permissions. That is the job of the graph + * (review-graph.js), which is built on top of this primitive. + * + * This file deliberately re-exports the existing helper rather than + * duplicating its body, so any future bugfix in `getTrackChanges` keeps the + * graph and the legacy raw-scan consumers in sync. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { findInlineNodes } from '../trackChangesHelpers/documentHelpers.js'; + +const TRACKED_MARK_SET = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +/** + * @typedef {Object} TrackedMarkSpan + * @property {import('prosemirror-model').Mark} mark + * @property {number} from + * @property {number} to + */ + +/** + * Enumerate every tracked-mark span in document order. One inline leaf node + * can carry more than one tracked mark (e.g. trackInsert + trackFormat + * stacked) — every mark is yielded as its own span. + * + * Tolerates a missing or partially-initialized state and returns [] instead + * of throwing, matching `getTrackChanges`. Comment-import bootstrap can call + * this through a setTimeout(0) before the editor's PM state is attached. + * + * @param {import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node } | null | undefined} state + * @returns {TrackedMarkSpan[]} + */ +export const enumerateTrackedMarkSpans = (state) => { + const out = []; + if (!state?.doc) return out; + const inlineNodes = findInlineNodes(state.doc); + if (!inlineNodes.length) return out; + + for (const { node, pos } of inlineNodes) { + const marks = node?.marks ?? []; + if (!marks.length) continue; + for (const mark of marks) { + if (!TRACKED_MARK_SET.has(mark.type.name)) continue; + out.push({ mark, from: pos, to: pos + node.nodeSize }); + } + } + + return out; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js new file mode 100644 index 0000000000..ffa8322a25 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js @@ -0,0 +1,69 @@ +// @ts-check +/** + * Story locator helpers for the review graph. + * + * A review graph is built per story editor. The body, headers, footers, + * footnotes, and endnotes each keep their own PM coordinate space and their + * own raw Word-id namespaces; the graph never crosses those boundaries. + * + * `TrackedChangeIndexImpl` remains the host-level aggregator that composes + * per-story graph projections (`packages/super-editor/.../document-api- + * adapters/tracked-changes/tracked-change-index.ts`). This module owns the + * locator type used by the graph itself so the graph stays decoupled from + * the document-api adapter layer. + */ + +/** + * Public-style story locator that mirrors the `StoryLocator` exported from + * `@superdoc/document-api`. Duplicated as a local typedef so this module + * does not import from document-api adapters (the graph must stay below the + * adapter layer per phase0-001 boundaries). + * + * @typedef {Object} StoryLocator + * @property {'story'} kind + * @property {'body'|'header'|'footer'|'headerFooterPart'|'footnote'|'endnote'} storyType + * @property {string} [refId] + * @property {string} [noteId] + */ + +/** @type {StoryLocator} */ +export const BODY_STORY = Object.freeze({ kind: 'story', storyType: 'body' }); + +/** + * Deterministic stable key for a story locator. Used as the cache key for + * graph snapshots. Mirrors the same shape as the document-api adapter's + * `buildStoryKey` so cache lookups stay consistent across layers without a + * cross-layer import. + * + * @param {StoryLocator | null | undefined} locator + * @returns {string} + */ +export const buildStoryKey = (locator) => { + if (!locator) return 'body'; + const { storyType, refId, noteId } = locator; + switch (storyType) { + case 'body': + return 'body'; + case 'header': + return `header:${refId ?? ''}`; + case 'footer': + return `footer:${refId ?? ''}`; + case 'headerFooterPart': + return `hf:${refId ?? ''}`; + case 'footnote': + return `fn:${noteId ?? ''}`; + case 'endnote': + return `en:${noteId ?? ''}`; + default: + return `unknown:${storyType ?? ''}`; + } +}; + +/** + * Compare two locators for equality. + * + * @param {StoryLocator | null | undefined} a + * @param {StoryLocator | null | undefined} b + * @returns {boolean} + */ +export const storyLocatorsEqual = (a, b) => buildStoryKey(a) === buildStoryKey(b); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js new file mode 100644 index 0000000000..372a21d542 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -0,0 +1,127 @@ +// @ts-check +/** + * Test fixture helpers for the review graph. + * + * Plan: v1-3220 / phase0-002 ("Tests"). These helpers exist so unit tests + * can build tracked mark configurations against a real PM + * schema without each test re-inventing the boilerplate. + * + * Not exported from the public package surface — they are internal test + * affordances. The eventual cross-feature fixture corpus owned by + * overlap fixture coverage lives under `extensions/track-changes/fixtures/`. + */ + +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +const NODES = { + doc: { content: 'block+' }, + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, +}; + +const MARK_DEFS_WITH_GRAPH_ATTRS = { + id: { default: '' }, + author: { default: '' }, + authorEmail: { default: '' }, + authorImage: { default: '' }, + date: { default: '' }, + sourceId: { default: '' }, + importedAuthor: { default: '' }, + revisionGroupId: { default: '' }, + splitFromId: { default: '' }, + changeType: { default: '' }, + replacementGroupId: { default: '' }, + replacementSideId: { default: '' }, + overlapParentId: { default: '' }, + sourceIds: { default: null }, + origin: { default: '' }, +}; + +const MARKS = { + [TrackInsertMarkName]: { + inclusive: false, + attrs: MARK_DEFS_WITH_GRAPH_ATTRS, + }, + [TrackDeleteMarkName]: { + inclusive: false, + attrs: MARK_DEFS_WITH_GRAPH_ATTRS, + }, + [TrackFormatMarkName]: { + inclusive: false, + attrs: { + ...MARK_DEFS_WITH_GRAPH_ATTRS, + before: { default: [] }, + after: { default: [] }, + }, + }, +}; + +/** + * A minimal PM schema sufficient for review-graph unit tests. Mirrors the + * tracked-change mark shape used in production; consumers needing a richer + * schema can use `initTestEditor` from the package tests helpers instead. + */ +export const createReviewGraphTestSchema = () => new Schema({ nodes: NODES, marks: MARKS }); + +/** + * @typedef {Object} TextSpanSpec + * @property {string} text + * @property {Array<{ markType: 'trackInsert'|'trackDelete'|'trackFormat', attrs: Record }>} [marks] + */ + +/** + * Build an EditorState containing one paragraph composed of the given + * tracked text spans. Positions inside the resulting doc are stable and + * documented: + * + * pos 0 = before doc + * pos 1 = inside paragraph, before first inline content + * pos 1 + offset = inside paragraph at character offset + * + * @param {{ schema: Schema, spans: TextSpanSpec[] }} input + * @returns {{ state: EditorState, schema: Schema, paragraphStart: number }} + */ +export const stateFromTrackedSpans = ({ schema, spans }) => { + const inlineNodes = spans.map(({ text, marks = [] }) => { + const pmMarks = marks.map(({ markType, attrs }) => schema.marks[markType].create(attrs)); + return schema.text(text, pmMarks); + }); + + const paragraph = schema.nodes.paragraph.create({}, inlineNodes); + const doc = schema.nodes.doc.create({}, [paragraph]); + const state = EditorState.create({ schema, doc }); + return { state, schema, paragraphStart: 1 }; +}; + +/** + * Build a tracked-mark attrs blob with sensible defaults so test + * declarations stay short. + * + * @param {Partial> & { id: string }} attrs + * @returns {Record} + */ +export const markAttrs = (attrs) => ({ + id: '', + author: '', + authorEmail: '', + authorImage: '', + date: '', + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: '', + replacementGroupId: '', + replacementSideId: '', + overlapParentId: '', + sourceIds: null, + origin: '', + ...attrs, +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js new file mode 100644 index 0000000000..19f9a9b3fa --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js @@ -0,0 +1,146 @@ +// @ts-check +/** + * Word revision id allocator for DOCX export. + * + * Word stores tracked-change revision ids (`w:id` on ``, ``, + * ``) as decimal strings whose namespace is per-part: document, + * each header, each footer, footnotes, and endnotes are independent. The + * allocator preserves imported raw Word ids (carried on the SuperDoc mark as + * `sourceId`) and mints fresh part-local decimal ids for native revisions and + * successor fragments whose `sourceId` is empty or non-decimal. + * + * The allocator only assigns `w:id` values. It does NOT replace the internal + * SuperDoc logical id (`id`, a UUID) — internal graph metadata stays on PM + * marks; only the Word-native `w:id` attribute is decimal. + * + * Two phases: + * 1) `reserveAll(allMarks)` walks every tracked mark and reserves every + * decimal `sourceId` value found. + * 2) `allocate({ partPath, sourceId, logicalId })` returns the `w:id` to + * write into OOXML for a single mark. Same `logicalId` returns the same + * `w:id` within the same part, so paired replacement halves stay linked. + * + * @typedef {{ + * reserve: (partPath: string, sourceId: string | number | null | undefined) => void, + * reserveAll: (entries: Iterable<{ partPath: string, sourceId: string | number | null | undefined }>) => void, + * allocate: (input: { partPath: string, sourceId?: string | number | null, logicalId?: string | null }) => string, + * isDecimal: (value: unknown) => boolean, + * __snapshot: () => Record, + * }} WordIdAllocator + * + * @typedef {{ + * reservedDecimal: Set, + * nextDecimal: number, + * assignedByLogicalId: Map, + * }} PartWordIdState + */ + +const DECIMAL = /^\d+$/; + +/** + * Returns true when the given value, after coercion to a trimmed string, is + * a base-10 integer Word would accept as `w:id`. + * + * @param {unknown} value + * @returns {boolean} + */ +export function isDecimalWordId(value) { + if (value == null) return false; + const str = String(value).trim(); + if (!str) return false; + return DECIMAL.test(str); +} + +/** + * @returns {WordIdAllocator} + */ +export function createWordIdAllocator() { + /** @type {Map} */ + const stateByPart = new Map(); + + /** + * @param {string} partPath + * @returns {PartWordIdState} + */ + const ensureState = (partPath) => { + const key = typeof partPath === 'string' && partPath.length > 0 ? partPath : 'word/document.xml'; + let state = stateByPart.get(key); + if (!state) { + state = { + reservedDecimal: new Set(), + nextDecimal: 1, + assignedByLogicalId: new Map(), + }; + stateByPart.set(key, state); + } + return state; + }; + + /** @type {WordIdAllocator['reserve']} */ + const reserve = (partPath, sourceId) => { + if (!isDecimalWordId(sourceId)) return; + const state = ensureState(partPath); + const n = Number(String(sourceId).trim()); + if (!Number.isFinite(n) || n < 0) return; + state.reservedDecimal.add(n); + }; + + /** @type {WordIdAllocator['reserveAll']} */ + const reserveAll = (entries) => { + if (!entries) return; + for (const entry of entries) { + reserve(entry?.partPath, entry?.sourceId); + } + }; + + /** @type {WordIdAllocator['allocate']} */ + const allocate = ({ partPath, sourceId, logicalId }) => { + const state = ensureState(partPath); + + // Preserve imported decimal w:id values verbatim. Word reuses tracked- + // change ids across the document, so we trust the imported value over + // any allocator state. + if (isDecimalWordId(sourceId)) { + const asString = String(sourceId).trim(); + const n = Number(asString); + state.reservedDecimal.add(n); + if (logicalId) state.assignedByLogicalId.set(logicalId, n); + return asString; + } + + // Repeat hits for the same logical id within the same part share the + // newly-minted id. Paired replacement halves carry the same logical id + // so both sides emit the same `w:id` on export, matching Word's pairing + // convention. + if (logicalId && state.assignedByLogicalId.has(logicalId)) { + return String(state.assignedByLogicalId.get(logicalId)); + } + + let n = state.nextDecimal; + while (state.reservedDecimal.has(n)) n++; + state.reservedDecimal.add(n); + state.nextDecimal = n + 1; + if (logicalId) state.assignedByLogicalId.set(logicalId, n); + return String(n); + }; + + const __snapshot = () => { + /** @type {Record} */ + const out = {}; + for (const [part, state] of stateByPart.entries()) { + out[part] = { + reservedDecimal: [...state.reservedDecimal].sort((a, b) => a - b), + nextDecimal: state.nextDecimal, + }; + } + return out; + }; + + return { + reserve, + reserveAll, + allocate, + isDecimal: isDecimalWordId, + __snapshot, + }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js new file mode 100644 index 0000000000..c1e8cf6b40 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js @@ -0,0 +1,123 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { createWordIdAllocator, isDecimalWordId } from './word-id-allocator.js'; + +describe('isDecimalWordId', () => { + it.each([ + ['0', true], + ['1', true], + ['12345', true], + ['-3', false], + ['', false], + ['abc', false], + ['1a', false], + ['1.0', false], + [null, false], + [undefined, false], + [42, true], + ])('classifies %p as %p', (value, expected) => { + expect(isDecimalWordId(value)).toBe(expected); + }); +}); + +describe('createWordIdAllocator', () => { + it('mints sequential decimal ids per part', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'b' })).toBe('2'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'c' })).toBe('3'); + }); + + it('keeps allocations isolated per part', () => { + const alloc = createWordIdAllocator(); + alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' }); + alloc.allocate({ partPath: 'word/header1.xml', logicalId: 'a' }); + alloc.allocate({ partPath: 'word/footer1.xml', logicalId: 'b' }); + + const snap = alloc.__snapshot(); + expect(snap['word/document.xml'].nextDecimal).toBe(2); + expect(snap['word/header1.xml'].nextDecimal).toBe(2); + expect(snap['word/footer1.xml'].nextDecimal).toBe(2); + }); + + it('preserves a decimal sourceId verbatim and reserves it', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '42', logicalId: 'imported' })).toBe('42'); + // The mint counter must skip the reserved value. + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'next' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'next2' })).toBe('2'); + }); + + it('skips minted ids that collide with reserved sourceIds', () => { + const alloc = createWordIdAllocator(); + alloc.reserveAll([ + { partPath: 'word/document.xml', sourceId: '1' }, + { partPath: 'word/document.xml', sourceId: '2' }, + { partPath: 'word/document.xml', sourceId: '4' }, + ]); + + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('3'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'b' })).toBe('5'); + }); + + it('returns the same id for the same logical id within a part', () => { + const alloc = createWordIdAllocator(); + const first = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + const second = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + expect(first).toBe(second); + }); + + it('returns different ids for the same logical id across parts', () => { + const alloc = createWordIdAllocator(); + const body = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + const header = alloc.allocate({ partPath: 'word/header1.xml', logicalId: 'shared' }); + expect(body).toBe('1'); + expect(header).toBe('1'); + }); + + it('ignores non-decimal sourceIds when allocating', () => { + const alloc = createWordIdAllocator(); + // UUID-like sourceIds (some upstream tools embed non-decimal strings) are + // treated as missing — the allocator mints a fresh decimal id instead. + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'aaaa-bbbb', logicalId: 'x' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '-3', logicalId: 'y' })).toBe('2'); + }); + + it('handles missing partPath by routing to document.xml', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: '', logicalId: 'x' })).toBe('1'); + const snap = alloc.__snapshot(); + expect(Object.keys(snap)).toEqual(['word/document.xml']); + }); + + it('reserve() is a no-op for non-decimal values', () => { + const alloc = createWordIdAllocator(); + alloc.reserve('word/document.xml', 'not-a-number'); + alloc.reserve('word/document.xml', ''); + alloc.reserve('word/document.xml', null); + alloc.reserve('word/document.xml', undefined); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('1'); + }); + + it('successor fragments get fresh part-local ids after preserved imports', () => { + const alloc = createWordIdAllocator(); + // Imagine document.xml originally had w:id 1, 3, 7. + alloc.reserveAll([ + { partPath: 'word/document.xml', sourceId: '1' }, + { partPath: 'word/document.xml', sourceId: '3' }, + { partPath: 'word/document.xml', sourceId: '7' }, + ]); + + // Preserve original ids on re-export. + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '1', logicalId: 'imp-1' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '3', logicalId: 'imp-3' })).toBe('3'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '7', logicalId: 'imp-7' })).toBe('7'); + + // Successor fragments mint fresh ids that avoid the reserved set. + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-a' })).toBe('2'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-b' })).toBe('4'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-c' })).toBe('5'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-d' })).toBe('6'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-e' })).toBe('8'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js new file mode 100644 index 0000000000..7f2df0ebda --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js @@ -0,0 +1,107 @@ +// @ts-check +/** + * Integration tests for overlap-aware decision engine paths. + * + * Verifies that v1 commands route through the decision engine and leave the + * document in the expected logical state. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { buildReviewGraph } from './review-model/review-graph.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; + +const setup = () => { + const { editor } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + }); + editor.commands.enableTrackChanges(); + return editor; +}; + +const graphFor = (editor) => buildReviewGraph({ state: editor.state }); + +describe('overlap wired decision engine (phase0-004)', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('acceptAllTrackedChanges via overlap retires tracked insertions', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'BIG ', user: ALICE }); + const graphBefore = graphFor(editor); + expect(graphBefore.changes.size).toBe(1); + + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptAllTrackedChanges(); + expect(applied).toBe(true); + const graphAfter = graphFor(editor); + expect(graphAfter.changes.size).toBe(0); + // Accepted insertion keeps the inserted content; document text is unchanged. + expect(editor.state.doc.textContent).toBe(textBefore); + }); + + it('rejectAllTrackedChanges via overlap removes inserted content', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'BAD ', user: ALICE }); + const applied = editor.commands.rejectAllTrackedChanges(); + expect(applied).toBe(true); + expect(editor.state.doc.textContent).toBe('Hello world'); + expect(graphFor(editor).changes.size).toBe(0); + }); + + it('acceptTrackedChangeById routes through the decision engine', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'ins-by-id' }); + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptTrackedChangeById('ins-by-id'); + expect(applied).toBe(true); + // Accepting an insertion preserves text. + expect(editor.state.doc.textContent).toBe(textBefore); + expect(graphFor(editor).changes.has('ins-by-id')).toBe(false); + }); + + it('rejectTrackedChangeById routes through the decision engine', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'ins-by-id-2' }); + const applied = editor.commands.rejectTrackedChangeById('ins-by-id-2'); + expect(applied).toBe(true); + expect(editor.state.doc.textContent).toBe('Hello world'); + expect(graphFor(editor).changes.has('ins-by-id-2')).toBe(false); + }); + + it('acceptTrackedChangesBetween routes through the decision engine for range targets', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'NEW ', user: ALICE }); + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptTrackedChangesBetween(0, editor.state.doc.content.size); + expect(applied).toBe(true); + // Accepted insertion keeps inserted text; ensure graph is now empty. + expect(editor.state.doc.textContent).toBe(textBefore); + expect(graphFor(editor).changes.size).toBe(0); + }); + + it('permission denial under overlap aborts before mutation', () => { + const { editor: ed } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + permissionResolver: () => false, + }); + ed.commands.enableTrackChanges(); + ed.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'deny-ins' }); + const before = ed.state.doc.textContent; + const applied = ed.commands.acceptTrackedChangeById('deny-ins'); + expect(applied).toBe(false); + expect(ed.state.doc.textContent).toBe(before); + const failure = ed.storage.trackChanges.lastDecisionFailure; + expect(failure?.code).toBe('PERMISSION_DENIED'); + ed.destroy(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js new file mode 100644 index 0000000000..b38313fa2e --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js @@ -0,0 +1,152 @@ +// @ts-check +/** + * Phase 005 — undo/redo restores successor fragment ids verbatim. + * + * Plan: v1-3220 / phase0-005 "Undo/Redo Requirements". + * + * "Do not recompute new ids on redo. The decision engine must write the + * full successor-fragment mark state into the dispatched transaction so + * ProseMirror history restores it verbatim. Redo must not rerun the + * decision engine to mint ids again." + * + * These integration tests prove that a partial-range accept on a tracked + * insertion: + * + * 1) writes deterministic successor fragment ids in the dispatched + * transaction (already covered by the decision-engine unit tests); + * 2) survives undo with the source logical id restored; + * 3) survives redo with the SAME successor ids (not re-minted by re-running + * the engine on redo). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { TrackInsertMarkName } from './constants.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; + +const setup = () => { + const { editor } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + }); + editor.commands.enableTrackChanges(); + return editor; +}; + +const collectInsertMarks = (state) => { + const marks = []; + state.doc.descendants((node, pos) => { + if (!node.marks) return; + for (const mark of node.marks) { + if (mark.type.name !== TrackInsertMarkName) continue; + marks.push({ + id: mark.attrs.id, + splitFromId: mark.attrs.splitFromId, + revisionGroupId: mark.attrs.revisionGroupId, + text: node.text ?? '', + pos, + }); + } + }); + return marks; +}; + +describe('overlap — partial accept + undo/redo preserves successor ids', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('preserves successor fragment ids verbatim across undo / redo', () => { + editor = setup(); + + // Insert a tracked insertion spanning a known range. + editor.commands.insertTrackedChange({ + from: 6, + to: 6, + text: 'INSERTED', + user: ALICE, + id: 'logical-ins', + }); + + const beforeMarks = collectInsertMarks(editor.state); + expect(beforeMarks).toHaveLength(1); + expect(beforeMarks[0].id).toBe('logical-ins'); + + // Find the inserted-mark range in the document; the inserted text is + // 'INSERTED' (8 chars). Accept only the middle 4 chars to force a + // partial decision that mints two successor fragments. + const insertedPos = beforeMarks[0].pos; + const partialFrom = insertedPos + 2; // skip 'IN' + const partialTo = insertedPos + 6; // through 'SERT' + + const applied = editor.commands.acceptTrackedChangesBetween(partialFrom, partialTo); + expect(applied).toBe(true); + + const afterMarks = collectInsertMarks(editor.state); + // Source id retired; two successor fragments remain with splitFromId. + expect(afterMarks).toHaveLength(2); + for (const m of afterMarks) { + expect(m.splitFromId).toBe('logical-ins'); + expect(m.id).not.toBe('logical-ins'); + } + // Capture the minted successor ids so we can compare across history. + const successorIds = afterMarks.map((m) => m.id).sort(); + expect(successorIds[0]).not.toBe(successorIds[1]); + + // Undo — original logical id restored, successor ids gone. + const undid = editor.commands.undo(); + expect(undid).toBe(true); + const undoneMarks = collectInsertMarks(editor.state); + expect(undoneMarks).toHaveLength(1); + expect(undoneMarks[0].id).toBe('logical-ins'); + expect(undoneMarks[0].splitFromId).toBe(''); + + // Redo — the SAME successor ids must come back. Re-running the engine + // would mint new ids; PM history restores the dispatched transaction + // verbatim, including the typed successor marks. + const redid = editor.commands.redo(); + expect(redid).toBe(true); + const redoneMarks = collectInsertMarks(editor.state); + const redoneSuccessorIds = redoneMarks.map((m) => m.id).sort(); + expect(redoneSuccessorIds).toEqual(successorIds); + for (const m of redoneMarks) { + expect(m.splitFromId).toBe('logical-ins'); + } + }); + + it('preserves successor revisionGroupId across undo / redo', () => { + editor = setup(); + editor.commands.insertTrackedChange({ + from: 6, + to: 6, + text: 'INSERTED', + user: ALICE, + id: 'group-ins', + }); + + const before = collectInsertMarks(editor.state); + const baseGroup = before[0].revisionGroupId || before[0].id; + const insertedPos = before[0].pos; + + editor.commands.acceptTrackedChangesBetween(insertedPos + 2, insertedPos + 6); + const after = collectInsertMarks(editor.state); + for (const m of after) { + // Successor fragments inherit the source's revisionGroupId so the + // graph can correlate them as one logical group. + expect(m.revisionGroupId).toBe(baseGroup); + } + + editor.commands.undo(); + editor.commands.redo(); + + const redone = collectInsertMarks(editor.state); + for (const m of redone) { + expect(m.revisionGroupId).toBe(baseGroup); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js new file mode 100644 index 0000000000..a680c5dccf --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js @@ -0,0 +1,217 @@ +// @ts-check +/** + * Integration tests for overlap-aware tracked editing. + * + * Phase 0 / plan 003 ("Tests"): the matrix tests above (see + * `review-model/overlap-compiler.test.js`) verify the compiler in isolation. + * This file exercises the wired path: a real editor with suggesting mode + * enabled and ordinary command dispatches. + * + * The tests assert: + * - text content after the edit + * - tracked-mark structure (insert/delete/format) + * - logical change projection via the review graph + * + * Decision-engine accept/reject lifecycle is owned by plan 004 and not + * exercised here. + */ +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName } from './constants.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { buildReviewGraph, CanonicalChangeType } from './review-model/review-graph.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; + +const setup = (user = ALICE, content = '

Hi there

') => { + const { editor } = initTestEditor({ + mode: 'text', + content, + user, + trackedChanges: {}, + }); + // Enable suggesting (track changes) mode. + editor.commands.enableTrackChanges(); + return editor; +}; + +const graphFor = (editor) => buildReviewGraph({ state: editor.state }); + +describe('overlap wired: native trackedTransaction routes through compiler', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('marks a fresh insertion with a tracked-insert mark', () => { + editor = setup(); + // Set caret inside the paragraph (after "Hi "). + const insertPos = 4; + editor.commands.command(({ tr, dispatch }) => { + tr.setSelection(TextSelection.create(tr.doc, insertPos)); + tr.insertText('X', insertPos); + if (dispatch) dispatch(tr); + return true; + }); + const text = editor.state.doc.textContent; + expect(text).toContain('X'); + const graph = graphFor(editor); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.authorEmail).toBe(ALICE.email); + }); + + it('refines own insertion when the same user types again inside it', () => { + editor = setup(); + const at = 4; + editor.commands.command(({ tr, dispatch }) => { + tr.insertText('XY', at); + if (dispatch) dispatch(tr); + return true; + }); + // Now insert in the middle of the just-typed insertion. + const middle = at + 1; // between X and Y + editor.commands.command(({ tr, dispatch }) => { + tr.insertText('M', middle); + if (dispatch) dispatch(tr); + return true; + }); + const graph = graphFor(editor); + // Still one logical change — refinement preserved a single id. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.insertedSegments.length).toBeGreaterThanOrEqual(1); + // Doc text contains all the inserted characters in order. + expect(editor.state.doc.textContent).toContain('XMY'); + }); +}); + +describe('overlap wired: insertTrackedChange delegates to compiler', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('document-api tracked replace produces a paired replacement in the graph', () => { + editor = setup(ALICE, '

hello world

'); + // Replace "hello" with "HELLO" — paired (default). + const ok = editor.commands.insertTrackedChange({ + from: 1, + to: 6, + text: 'HELLO', + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + // Paired mode → one logical replacement change. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.replacement?.inserted.length).toBeGreaterThan(0); + expect(change.replacement?.deleted.length).toBeGreaterThan(0); + }); + + it('document-api tracked insert in middle of text creates one insertion change', () => { + editor = setup(ALICE, '

hello

'); + // Find the inline text position for "hello" and pick the offset after + // the first three characters ("hel"). + let textNode = null; + let textNodePos = -1; + editor.state.doc.descendants((node, pos) => { + if (textNode || !node.isText) return; + textNode = node; + textNodePos = pos; + }); + expect(textNode).toBeTruthy(); + const insertAt = textNodePos + 3; // between "hel" and "lo" + const ok = editor.commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'X', + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(editor.state.doc.textContent).toBe('helXlo'); + }); + + it('document-api tracked insert preserves active inline formatting marks', () => { + editor = setup(ALICE, '

hello

'); + let textNodePos = -1; + editor.state.doc.descendants((node, pos) => { + if (!node.isText || textNodePos !== -1) return; + textNodePos = pos; + }); + expect(textNodePos).toBeGreaterThanOrEqual(0); + + const insertAt = textNodePos + 3; + const ok = editor.commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'X', + user: ALICE, + }); + + expect(ok).toBe(true); + let insertedMarks = []; + editor.state.doc.descendants((node) => { + if (node.isText && node.text === 'X') { + insertedMarks = node.marks.map((mark) => mark.type.name); + return false; + } + }); + expect(insertedMarks).toContain('bold'); + expect(insertedMarks).toContain(TrackInsertMarkName); + }); + + it('document-api tracked insert uses the provided id as the logical change id', () => { + editor = setup(ALICE, '

hello

'); + const providedId = 'api-provided-id'; + const ok = editor.commands.insertTrackedChange({ + from: 4, + to: 4, + text: 'X', + id: providedId, + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + expect(graph.changes.get(providedId)).toBeDefined(); + }); + + it('document-api tracked replacement uses the provided id as the paired logical change id', () => { + editor = setup(ALICE, '

hello

'); + const providedId = 'api-replace-id'; + const ok = editor.commands.insertTrackedChange({ + from: 1, + to: 6, + text: 'HELLO', + id: providedId, + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + const change = graph.changes.get(providedId); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Replacement); + }); + + it('returns false for invalid range', () => { + editor = setup(ALICE, '

short

'); + const ok = editor.commands.insertTrackedChange({ + from: 0, + to: 999, + text: 'X', + user: ALICE, + }); + expect(ok).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index f33f7040bd..0555eea2d1 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -1,16 +1,21 @@ import { Extension } from '@core/Extension.js'; import { Slice } from 'prosemirror-model'; import { Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; -import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.js'; -import { markDeletion } from './trackChangesHelpers/markDeletion.js'; -import { markInsertion } from './trackChangesHelpers/markInsertion.js'; import { collectTrackedChanges, isTrackedChangeActionAllowed } from './permission-helpers.js'; import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../comment/comments-plugin.js'; import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; +import { compileTrackedEdit } from './review-model/overlap-compiler.js'; +import { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + sliceFromText, +} from './review-model/edit-intent.js'; +import { decideTrackedChanges, buildDecisionBubbleEvents } from './review-model/decision-engine.js'; /** * Reads the `replacements` mode from editor.options.trackedChanges. @@ -20,14 +25,68 @@ import { hasExpandedSelection } from '@utils/selectionUtils.js'; const readReplacementsMode = (editor) => editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +/** + * Runs the atomic decision engine, dispatches the resulting transaction, and + * emits bubble lifecycle events from the decision receipt. + */ +const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) => { + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastDecisionFailure = null; + } + const result = decideTrackedChanges({ + state, + editor, + decision, + target, + replacements: readReplacementsMode(editor), + }); + if (!result.ok) { + // Fail closed (do NOT mutate) for hard errors. NO_OP and + // CAPABILITY_UNAVAILABLE return `false` so toolbar wrappers can decide + // how to surface the result. + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastDecisionFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return { applied: false, failure: result }; + } + if (dispatch) { + dispatch(result.tr); + const events = buildDecisionBubbleEvents({ result, editor }); + if (editor?.emit && events.length) { + for (const event of events) editor.emit('commentsUpdate', event); + } + } + return { applied: true, result }; +}; + export const TrackChanges = Extension.create({ name: 'trackChanges', + addStorage() { + return { + lastCompilerFailure: null, + lastDecisionFailure: null, + }; + }, + addCommands() { return { acceptTrackedChangesBetween: (from, to) => ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'range', from, to }, + }); + if (reviewDecision) return reviewDecision.applied; + const trackedChanges = collectTrackedChanges({ state, from, to }); if (!isTrackedChangeActionAllowed({ editor, action: 'accept', trackedChanges })) return false; @@ -74,6 +133,15 @@ export const TrackChanges = Extension.create({ rejectTrackedChangesBetween: (from, to) => ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'range', from, to }, + }); + if (reviewDecision) return reviewDecision.applied; + const trackedChanges = collectTrackedChanges({ state, from, to }); if (!isTrackedChangeActionAllowed({ editor, action: 'reject', trackedChanges })) return false; @@ -188,7 +256,16 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, commands }) => { + ({ state, tr, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'id', id }, + }); + if (reviewDecision) return reviewDecision.applied; + const toResolve = getChangesByIdToResolve(state, id) || []; return toResolve @@ -202,7 +279,16 @@ export const TrackChanges = Extension.create({ acceptAllTrackedChanges: () => - ({ state, commands }) => { + ({ state, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'all' }, + }); + if (reviewDecision) return reviewDecision.applied; + const from = 0, to = state.doc.content.size; return commands.acceptTrackedChangesBetween(from, to); @@ -210,7 +296,16 @@ export const TrackChanges = Extension.create({ rejectTrackedChangeById: (id) => - ({ state, tr, commands }) => { + ({ state, tr, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'id', id }, + }); + if (reviewDecision) return reviewDecision.applied; + const toReject = getChangesByIdToResolve(state, id) || []; return toReject @@ -272,7 +367,16 @@ export const TrackChanges = Extension.create({ rejectAllTrackedChanges: () => - ({ state, commands }) => { + ({ state, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'all' }, + }); + if (reviewDecision) return reviewDecision.applied; + const from = 0, to = state.doc.content.size; return commands.rejectTrackedChangesBetween(from, to); @@ -316,108 +420,21 @@ export const TrackChanges = Extension.create({ console.warn('insertTrackedChange: no user name/email provided, track change will have undefined author'); } const date = new Date().toISOString(); - const tr = state.tr; - - // Get marks from original position BEFORE any changes for format preservation - const marks = state.doc.resolve(from).marks(); - - // id-minting strategy for a tracked insert/delete/replace: - // - One `primaryId` anchors the operation. When the caller supplies - // `id` (e.g. the Document API write adapter), that becomes the - // primary; otherwise we mint a fresh UUID. - // - The primary id is used for the insertion (pure insert) or the - // lone deletion (pure delete), and always as the `changeId` we - // report back — comment threads key off this id too. - // - For a replacement: in `'paired'` mode both halves share the - // primary id (Google-Docs-like one-click resolve). In - // `'independent'` mode (modules.trackChanges.replacements: - // 'independent'), the insertion keeps the primary id and the - // deletion mints its own fresh id via markDeletion, so each - // revision is independently addressable per ECMA-376 §17.13.5. - const replacementsMode = readReplacementsMode(editor); - const pairedReplacements = replacementsMode === 'paired'; - const isReplacement = from !== to && text; - const primaryId = id ?? uuidv4(); - const insertionId = primaryId; - const deletionId = pairedReplacements || !isReplacement ? primaryId : null; - - const changeId = primaryId; - let insertPos = to; // Default insert position is after the selection - let deletionMark = null; - let deletionNodes = []; - - // Step 1: Mark the original text as deleted (if there's text to delete) - if (from !== to) { - const result = markDeletion({ - tr, - from, - to, - user: resolvedUser, - date, - id: deletionId, - }); - deletionMark = result.deletionMark; - deletionNodes = result.nodes || []; - // Map the insert position through the deletion mapping - insertPos = result.deletionMap.map(to); - } - - // Step 2: Insert the new text after the deleted content - let insertedMark = null; - let insertedNode = null; - if (text) { - insertedNode = state.schema.text(text, marks); - tr.insert(insertPos, insertedNode); - - // Step 3: Mark the insertion - const insertedFrom = insertPos; - const insertedTo = insertPos + insertedNode.nodeSize; - insertedMark = markInsertion({ - tr, - from: insertedFrom, - to: insertedTo, - user: resolvedUser, - date, - id: insertionId, - }); - } - // Store metadata for external consumers (pass full mark objects for comments plugin) - // Create a mock step with slice for the comments plugin to extract nodes - const mockStep = insertedNode - ? { - slice: { content: { content: [insertedNode] } }, - } - : null; - - tr.setMeta(TrackChangesBasePluginKey, { - insertedMark: insertedMark || null, - deletionMark: deletionMark || null, - deletionNodes, - step: mockStep, + return dispatchCompiledInsertTrackedChange({ + editor, + state, + dispatch, + from, + to, + text, + resolvedUser, + date, + providedId: id, + comment, + addToHistory, emitCommentEvent, }); - tr.setMeta(CommentsPluginKey, { type: 'force' }); - tr.setMeta('skipTrackChanges', true); - - if (!addToHistory) { - tr.setMeta('addToHistory', false); - } - - dispatch(tr); - - // Handle comment if provided (guard for editors without comments extension) - if (comment?.trim() && changeId && editor.commands.addCommentReply) { - editor.commands.addCommentReply({ - parentId: changeId, - content: comment, - author: resolvedUser.name, - authorEmail: resolvedUser.email, - authorImage: resolvedUser.image, - }); - } - - return true; }, toggleTrackChanges: @@ -725,3 +742,136 @@ const getChangesByIdToResolve = (state, id) => { return [matchingChange, ...linkedAfter, ...linkedBefore]; }; + +/** + * Routes the document-api tracked text mutation through the shared overlap + * compiler so native and document-api semantics agree. + * + * @param {{ + * editor: import('../../core/Editor.ts').Editor, + * state: import('prosemirror-state').EditorState, + * dispatch: (tr: import('prosemirror-state').Transaction) => void, + * from: number, + * to: number, + * text: string, + * resolvedUser: object, + * date: string, + * providedId?: string, + * comment?: string, + * addToHistory: boolean, + * emitCommentEvent: boolean, + * }} options + */ +const dispatchCompiledInsertTrackedChange = ({ + editor, + state, + dispatch, + from, + to, + text, + resolvedUser, + date, + providedId, + comment, + addToHistory, + emitCommentEvent, +}) => { + const replacements = readReplacementsMode(editor); + const tr = state.tr; + const schema = state.schema; + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastCompilerFailure = null; + } + const activeMarks = state.storedMarks ?? state.doc.resolve(from).marks(); + let intent; + try { + if (from === to && text) { + intent = makeTextInsertIntent({ + at: from, + content: sliceFromText(schema, text, activeMarks), + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else if (from !== to && !text) { + intent = makeTextDeleteIntent({ + from, + to, + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else if (from !== to && text) { + intent = makeTextReplaceIntent({ + from, + to, + content: sliceFromText(schema, text, activeMarks), + replacements, + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else { + return false; + } + } catch (error) { + console.warn('insertTrackedChange: could not build intent', error); + return false; + } + + const result = compileTrackedEdit({ + state, + tr, + intent, + replacements, + }); + + if (!result.ok) { + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastCompilerFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return false; + } + if (!dispatch) { + return true; + } + + const meta = { + insertedMark: result.insertedMark || null, + deletionMark: result.deletionMarks?.[0] || null, + deletionNodes: [], + step: result.insertedMark ? { slice: { content: { content: [] } } } : null, + emitCommentEvent, + }; + tr.setMeta(TrackChangesBasePluginKey, meta); + tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta('skipTrackChanges', true); + if (!addToHistory) { + tr.setMeta('addToHistory', false); + } + + dispatch(tr); + + // Compute a public-facing change id for the comment thread: prefer the + // explicit id the caller provided; otherwise the first created/updated + // change id from the compiler receipt. + const changeId = providedId || result.createdChangeIds?.[0] || result.updatedChangeIds?.[0] || null; + if (comment?.trim() && changeId && editor.commands?.addCommentReply) { + editor.commands.addCommentReply({ + parentId: changeId, + content: comment, + author: resolvedUser.name, + authorEmail: resolvedUser.email, + authorImage: resolvedUser.image, + }); + } + + return true; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js index 89542f676c..1a7e67e205 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js @@ -85,6 +85,49 @@ export const TrackDelete = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. See track-insert.js for the rationale — + // never DOM-rendered, optional, inferred for older marks. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js index 36da848685..1706f2e464 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js @@ -120,6 +120,49 @@ export const TrackFormat = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. See track-insert.js for the rationale — + // never DOM-rendered, optional, inferred for older marks. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js index f66f369cef..f8af8c29ec 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js @@ -85,6 +85,56 @@ export const TrackInsert = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. + // These optional persisted attrs carry logical review graph + // state. They are never rendered as DOM attributes — graph metadata is + // not visual state. Compatibility: older marks omit them and the graph + // builder infers values from mark type and sibling adjacency. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + // Deterministic JSON object carrying raw Word ids / rsids when present. + // Empty object is the canonical default at the graph level; on the mark + // we store `null` so adjacent marks without source ids do not differ by + // missing-vs-empty defaults during PM mark.eq comparison. + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js index c169b4a08b..eb95797f13 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -12,6 +12,8 @@ import { createMarkSnapshot, } from './markSnapshotHelpers.js'; import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeFormatIntent } from '../review-model/edit-intent.js'; /** * Add mark step. @@ -24,6 +26,46 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { + // Route tracked run-format intents through the compiler so formatting + // inside same-user own insertion/replacement folds into the inserted side + // instead of producing a separate trackFormat. + if (TrackedFormatMarkNames.includes(step.mark.type.name)) { + const intentUser = { + name: user?.name || '', + email: user?.email || '', + image: user?.image || '', + }; + const intent = makeFormatIntent({ + kind: 'format-apply', + from: step.from, + to: step.to, + mark: step.mark, + user: intentUser, + date, + source: 'native', + }); + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + }); + if (result.ok) { + if (result.formatMarks?.length) { + newTr.setMeta(TrackChangesBasePluginKey, { + formatMark: result.formatMarks[0], + step, + }); + } + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + return; + } + if (result.code !== 'CAPABILITY_UNAVAILABLE') { + // Fail closed for typed errors; do not silently apply untracked. + return; + } + // Otherwise fall through to legacy path. + } + /** @type {{ formatMark?: import('prosemirror-model').Mark, step?: import('prosemirror-transform').AddMarkStep }} */ const meta = {}; /** @type {string | null} */ diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js index bc01183934..ee8ca8756b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js @@ -37,6 +37,7 @@ export const getTrackChanges = (state, id = null) => { if (trackedMarks.includes(mark.type.name)) { trackedChanges.push({ mark, + node, from: pos, to: pos + node.nodeSize, }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js index 3a42230e43..aff29e1244 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js @@ -4,6 +4,7 @@ import { Slice } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; +import { normalizeEmail } from '../review-model/identity.js'; /** * Mark deletion. @@ -17,18 +18,14 @@ import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; * @returns {{ deletionMark: import('prosemirror-model').Mark, deletionMap: Mapping, nodes: import('prosemirror-model').Node[] }} Deletion map and deletion mark. */ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { - /** - * @param {unknown} value - */ - const normalizeEmail = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''); const userEmail = normalizeEmail(user?.email); /** * @param {import('prosemirror-model').Mark | null | undefined} mark */ const isOwnInsertion = (mark) => { const authorEmail = normalizeEmail(mark?.attrs?.authorEmail); - // Word imports often omit authorEmail, treat missing as "own" to allow deletion. - if (!authorEmail || !userEmail) return true; + // Missing identity is not same-user. Only a trusted authorEmail match counts. + if (!authorEmail || !userEmail) return false; return authorEmail === userEmail; }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js index 686788cbe0..ec80c2b384 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -9,6 +9,8 @@ import { upsertMarkSnapshotByType, } from './markSnapshotHelpers.js'; import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeFormatIntent } from '../review-model/edit-intent.js'; /** * Remove mark step. @@ -21,6 +23,40 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { + if (TrackedFormatMarkNames.includes(step.mark.type.name)) { + const intentUser = { + name: user?.name || '', + email: user?.email || '', + image: user?.image || '', + }; + const intent = makeFormatIntent({ + kind: 'format-remove', + from: step.from, + to: step.to, + mark: step.mark, + user: intentUser, + date, + source: 'native', + }); + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + }); + if (result.ok) { + if (result.formatMarks?.length) { + newTr.setMeta(TrackChangesBasePluginKey, { + formatMark: result.formatMarks[0], + step, + }); + } + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + return; + } + if (result.code !== 'CAPABILITY_UNAVAILABLE') return; + // Fall through to legacy on capability gaps. + } + const meta = {}; let sharedWid = null; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js index 80ba605bd9..b89c6edeec 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js @@ -117,6 +117,7 @@ const findPreviousLiveCharPos = (doc, cursorPos, trackDeleteMarkType) => { * @param {string} options.date * @param {import('prosemirror-transform').Step} options.originalStep * @param {number} options.originalStepIndex + * @param {'paired' | 'independent'} [options.replacements] */ export const replaceAroundStep = ({ state, @@ -129,6 +130,7 @@ export const replaceAroundStep = ({ date, originalStep, originalStepIndex, + replacements = 'paired', }) => { // Diff replay uses forceTrackChanges for consistency, but structural metadata updates // (e.g. table style setNodeMarkup) are encoded as ReplaceAroundStep and cannot be @@ -202,6 +204,7 @@ export const replaceAroundStep = ({ date, originalStep: charStep, originalStepIndex, + replacements, }); // Position the cursor at the deletion edge. The original transaction's diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index 3791b3fa16..ae74d04181 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -7,6 +7,8 @@ import { TrackDeleteMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; import { findMarkPosition } from './documentHelpers.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeTextInsertIntent, makeTextDeleteIntent, makeTextReplaceIntent } from '../review-model/edit-intent.js'; /** * Given a range (from..to) and a count of characters ("the Nth character in that range"), @@ -173,6 +175,26 @@ export const replaceStep = ({ step.to !== originalRange.to || step.slice.content.size !== originalRange.sliceSize; + const compiled = tryCompileStep({ + state, + tr, + newTr, + step, + stepWasNormalized, + originalStep, + map, + user, + date, + replacements, + }); + if (compiled.handled) { + return; + } + if (compiled.failed) { + // Do not fall through to applying the original step untracked. + return; + } + // Handle structural deletions with no inline content (e.g., empty paragraph removal, // paragraph joins). When there's no content being inserted and no inline content in // the deletion range, markDeletion has nothing to mark — apply the step directly. @@ -380,6 +402,103 @@ export const replaceStep = ({ * Selection taken from another transaction/document context. * @returns {void} */ +/** + * Try to route a text-shaped ReplaceStep through the overlap-aware compiler. + * + * Returns one of: + * - `{ handled: true }` — compiler applied the edit; caller must return. + * - `{ failed: true }` — compiler aborted (typed failure); caller must + * NOT fall back to the original untracked step. + * - `{ handled: false }` — compiler declined (e.g. structural step + * without inline content). Caller falls through + * to the legacy path. + * + * @param {{ state: import('prosemirror-state').EditorState, tr: import('prosemirror-state').Transaction, newTr: import('prosemirror-state').Transaction, step: import('prosemirror-transform').ReplaceStep, stepWasNormalized: boolean, originalStep: import('prosemirror-transform').ReplaceStep, map: import('prosemirror-transform').Mapping, user: object, date: string, replacements: 'paired'|'independent' }} options + */ +const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalStep, map, user, date, replacements }) => { + // Empty structural deletion handled by the existing legacy branch above. + if (step.from !== step.to && step.slice.content.size === 0) { + let hasInlineContent = false; + newTr.doc.nodesBetween(step.from, step.to, (node) => { + if (node.isInline) { + hasInlineContent = true; + return false; + } + }); + if (!hasInlineContent) return { handled: false }; + } + + // Build the intent. Pure inserts and pure deletes use the matching intent + // type; mixed (text-replace) carries the original slice. + let intent; + try { + if (step.from === step.to && step.slice.content.size > 0) { + intent = makeTextInsertIntent({ at: step.from, content: step.slice, user, date, source: 'native' }); + } else if (step.from !== step.to && step.slice.content.size === 0) { + intent = makeTextDeleteIntent({ from: step.from, to: step.to, user, date, source: 'native' }); + } else if (step.from !== step.to && step.slice.content.size > 0) { + intent = makeTextReplaceIntent({ + from: step.from, + to: step.to, + content: step.slice, + replacements, + user, + date, + source: 'native', + }); + } else { + // Zero-op step; nothing to compile. + return { handled: false }; + } + } catch (error) { + return { failed: true, error }; + } + + const beforeSize = newTr.doc.content.size; + const beforeSteps = newTr.steps.length; + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + replacements, + }); + + if (!result.ok) { + // Structural fallback: when the compiler reports CAPABILITY_UNAVAILABLE + // for a content shape it cannot model (e.g. mixed structural slice), let + // the legacy path handle it. Otherwise — INVALID_TARGET or + // PRECONDITION_FAILED — fail closed. + if (result.code === 'CAPABILITY_UNAVAILABLE') return { handled: false }; + return { failed: true, error: new Error(result.message) }; + } + + // Track that we mutated newTr. We still need to update the outer mapping + // (`map`) so subsequent steps in the same transaction can map through. + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap()); + } + + // Mirror the position-mapping behavior expected by trackedTransaction + // selection logic: when there is an inserted side, record `insertedTo`. + const meta = {}; + if (typeof result.insertedTo === 'number') { + meta.insertedTo = result.insertedTo; + } + if (result.insertedMark) { + meta.insertedMark = result.insertedMark; + } + if (result.deletionMarks?.length) { + meta.deletionMark = result.deletionMarks[0]; + } + if (result.selection?.kind === 'near' && stepWasNormalized && !result.insertedMark) { + meta.selectionPos = result.selection.pos; + } + newTr.setMeta(TrackChangesBasePluginKey, meta); + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + + return { handled: true, sizeDelta: newTr.doc.content.size - beforeSize }; +}; + const syncSelectionFromTransaction = ({ targetTr, sourceSelection }) => { const boundedFrom = Math.max(0, Math.min(sourceSelection.from, targetTr.doc.content.size)); const boundedTo = Math.max(0, Math.min(sourceSelection.to, targetTr.doc.content.size)); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 5f1aadb789..b255c09eb3 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -415,6 +415,7 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) date, originalStep, originalStepIndex, + replacements, }); } else { // Non-structural steps (AttrStep, SetNodeMarkupStep) are typically diff --git a/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js new file mode 100644 index 0000000000..42e0c0d947 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js @@ -0,0 +1,328 @@ +// @ts-check +/** + * Phase 005 — repo-local critical tests for overlap DOCX export cleanliness. + * + * Plan: v1-3220 / phase0-005 "DOCX Metadata Storage Contract" + "Word + * Revision Id Allocation" + "Repo-Local Critical Tests Only". + * + * The plan forbids exporting SuperDoc-private metadata into DOCX. These + * tests prove that overlap-aware export: + * + * 1. exported `word/document.xml` contains only Word-native tracked-change + * wrappers (`w:ins`, `w:del`, `w:rPrChange`). + * 2. all `w:id` attributes on tracked-change wrappers are Word-compatible + * decimal strings. + * 3. imported decimal `w:id` values are preserved verbatim. + * 4. no SuperDoc-specific namespaces / custom XML parts appear anywhere in + * the exported package. + * + */ + +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '../helpers/helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; + +const TRACK_NAMES = new Set(['w:ins', 'w:del']); +const FORMAT_REVISION_NAMES = new Set(['w:rPrChange', 'w:pPrChange']); +const FORBIDDEN_NAMESPACE_PREFIXES = ['sd:', 'sdrev:', 'superdoc:']; + +const visitNodes = (node, visit) => { + if (!node || typeof node !== 'object') return; + visit(node); + if (Array.isArray(node.elements)) node.elements.forEach((child) => visitNodes(child, visit)); +}; + +const collectTrackedWrappers = (body) => { + const tracked = []; + const formats = []; + visitNodes(body, (node) => { + if (TRACK_NAMES.has(node.name)) tracked.push(node); + if (FORMAT_REVISION_NAMES.has(node.name)) formats.push(node); + }); + return { tracked, formats }; +}; + +const allAttributeKeysFor = (body) => { + const keys = new Set(); + visitNodes(body, (node) => { + if (!node.attributes || typeof node.attributes !== 'object') return; + for (const key of Object.keys(node.attributes)) keys.add(key); + }); + return keys; +}; + +const allElementNames = (body) => { + const names = new Set(); + visitNodes(body, (node) => { + if (typeof node.name === 'string') names.add(node.name); + }); + return names; +}; + +const loadExportedPackage = async (exportedBuffer) => { + const zipper = new DocxZipper(); + const files = await zipper.getDocxData(exportedBuffer, true); + return files; +}; + +describe('overlap export — Word-native shape only', () => { + it('preserves imported decimal w:id values and emits no SuperDoc-private metadata', async () => { + const fileName = 'msword-tracked-changes.docx'; + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(fileName); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + + try { + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + expect(exportedBuffer?.byteLength ?? exportedBuffer?.length).toBeGreaterThan(0); + + const exportedFiles = await loadExportedPackage(exportedBuffer); + + // Phase 005 — hard prohibitions: no SuperDoc-private custom XML parts + // and no review-graph sidecars must be present in the package. + const sdSidecar = exportedFiles.find((entry) => /customXml\/.*sd-tracked-review/i.test(entry.name)); + expect(sdSidecar, 'No customXml/sd-tracked-review.xml sidecar permitted').toBeUndefined(); + + const customXmlEntries = exportedFiles.filter((entry) => entry.name.startsWith('customXml/')); + for (const entry of customXmlEntries) { + // Any custom XML parts that survive round-trip must NOT carry the + // overlap review graph; surface that as a hard test failure by + // scanning for our internal attribute names. + expect(entry.content).not.toMatch(/sdrev:/); + expect(entry.content).not.toMatch(/sd-tracked-review/); + } + + const documentXmlEntry = exportedFiles.find((entry) => entry.name === 'word/document.xml'); + expect(documentXmlEntry).toBeDefined(); + + const documentJson = parseXmlToJson(documentXmlEntry.content); + const documentNode = documentJson.elements?.find((el) => el.name === 'w:document'); + const body = documentNode?.elements?.find((el) => el.name === 'w:body'); + expect(body).toBeDefined(); + + const { tracked, formats } = collectTrackedWrappers(body); + expect(tracked.length).toBeGreaterThan(0); + + // Phase 005 — every emitted `w:id` must be a Word-compatible decimal + // string. UUIDs / non-decimal sourceIds are not valid Word revision + // ids; the allocator mints fresh decimals for SuperDoc-native + // revisions while preserving imported decimals verbatim. + for (const node of tracked) { + const id = node.attributes?.['w:id']; + expect(id, `Tracked-change node missing w:id (${node.name})`).toBeDefined(); + expect(String(id)).toMatch(/^\d+$/); + } + + // Format revisions, when present, follow the same allocator rule. + for (const node of formats) { + const id = node.attributes?.['w:id']; + expect(id, `Format revision node missing w:id (${node.name})`).toBeDefined(); + expect(String(id)).toMatch(/^\d+$/); + } + + // Phase 005 — no SuperDoc-private attributes on tracked-change + // wrappers. The Word-native attribute set is `w:id`, `w:author`, + // `w:authorEmail`, `w:date`, plus optional `w:authorImage` (treated + // as Word-supported per converter convention). + // Word-standard tracked-change attributes. `w:rsid*` are + // revision-save ids that ship in untouched Word documents. + const allowedAttrs = new Set([ + 'w:id', + 'w:author', + 'w:authorEmail', + 'w:date', + 'w:authorImage', + 'w:rsidDel', + 'w:rsidR', + 'w:rsidRDefault', + 'w:rsidRPr', + 'w:rsidP', + 'w:rsidTr', + 'w:rsidSect', + ]); + for (const node of [...tracked, ...formats]) { + if (!node.attributes) continue; + for (const key of Object.keys(node.attributes)) { + // overlap internal attrs (`changeType`, `revisionGroupId`, + // `splitFromId`, etc.) live on PM marks only — never on OOXML. + expect(allowedAttrs.has(key), `Tracked-change wrapper carries non-Word attribute "${key}"`).toBe(true); + } + } + + // Phase 005 — no SuperDoc-private namespaces appear anywhere in the + // body. `sd:*`, `sdrev:*`, and `superdoc:*` are forbidden even on + // unrelated wrappers. + const allKeys = allAttributeKeysFor(body); + for (const key of allKeys) { + for (const banned of FORBIDDEN_NAMESPACE_PREFIXES) { + expect(key.startsWith(banned), `Body attribute "${key}" uses forbidden namespace ${banned}`).toBe(false); + } + } + + // Phase 005 — no SuperDoc-specific element names. + const allNames = allElementNames(body); + for (const name of allNames) { + for (const banned of FORBIDDEN_NAMESPACE_PREFIXES) { + expect(name.startsWith(banned), `Body element "${name}" uses forbidden namespace ${banned}`).toBe(false); + } + } + } finally { + editor.destroy(); + } + }); + + it('installs the Word id allocator by default', async () => { + const fileName = 'msword-tracked-changes.docx'; + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(fileName); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + }); + + try { + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedFiles = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = exportedFiles.find((entry) => entry.name === 'word/document.xml'); + expect(documentXmlEntry).toBeDefined(); + expect(editor.converter.wordIdAllocator).not.toBeNull(); + } finally { + editor.destroy(); + } + }); +}); + +describe('overlap export — allocator collision behavior', () => { + it('successor fragments mint ids that do not collide with preserved sourceIds', async () => { + // Build a synthetic document whose tracked marks carry preserved decimal + // sourceIds (1, 2) plus a native revision with no sourceId. The + // allocator must mint a fresh decimal id for the native revision + // that does not collide with the preserved set. + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + + try { + const schema = editor.schema; + const baseDocJson = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'imported', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-a', + sourceId: '1', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + { + type: 'run', + content: [ + { + type: 'text', + text: 'paired', + marks: [ + { + type: 'trackDelete', + attrs: { + id: 'logical-b', + sourceId: '2', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + { + type: 'run', + content: [ + { + type: 'text', + text: 'native', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-c', + sourceId: '', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-02-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + const replacementDoc = schema.nodeFromJSON(baseDocJson); + const tx = editor.state.tr.replaceWith(0, editor.state.doc.content.size, replacementDoc.content); + editor.dispatch(tx); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const files = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = files.find((f) => f.name === 'word/document.xml'); + const documentJson = parseXmlToJson(documentXmlEntry.content); + const documentNode = documentJson.elements?.find((el) => el.name === 'w:document'); + const body = documentNode?.elements?.find((el) => el.name === 'w:body'); + + const { tracked } = collectTrackedWrappers(body); + const ids = tracked.map((node) => String(node.attributes?.['w:id'])); + + // Imported sourceIds preserved. + expect(ids).toContain('1'); + expect(ids).toContain('2'); + + // The native revision must NOT collide with the preserved set. + const nativeId = ids.find((id) => id !== '1' && id !== '2'); + expect(nativeId).toBeDefined(); + expect(nativeId).toMatch(/^\d+$/); + expect(nativeId).not.toBe('1'); + expect(nativeId).not.toBe('2'); + + // Every id is decimal. + for (const id of ids) { + expect(id).toMatch(/^\d+$/); + } + } finally { + editor.destroy(); + } + }); +}); From 612e6e9ce647a0f8ccb36511582c18d6b0effef6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:18:06 -0700 Subject: [PATCH 006/280] chore: tests for review issues --- .../Editor.track-changes-dispatch.test.js | 37 +++++++++++- .../src/stores/comments-store.test.js | 59 +++++++++++++++++++ .../floating-comments-virtualization.spec.ts | 16 +++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 33e391570d..db364da655 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { TrackInsertMarkName } from '@extensions/track-changes/constants.js'; @@ -104,4 +104,39 @@ describe('Editor dispatch tracked-change meta', () => { const tracked = getTrackChanges(editor.state); expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); }); + + it('emits an add commentsUpdate when a native suggesting insert creates a tracked insertion', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + user: { name: 'Test', email: 'test@example.com' }, + useImmediateSetTimeout: false, + })); + + editor.setDocumentMode('suggesting'); + + const emitSpy = vi.spyOn(editor, 'emit'); + const tr = editor.state.tr.insertText('Tracked ', 1, 1).setMeta('inputType', 'insertText'); + + editor.dispatch(tr); + + const tracked = getTrackChanges(editor.state); + expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); + + const addPayload = emitSpy.mock.calls.find( + ([eventName, payload]) => + eventName === 'commentsUpdate' && + payload?.type === 'trackedChange' && + payload?.event === 'add' && + payload?.trackedChangeType === TrackInsertMarkName, + )?.[1]; + + expect(addPayload).toEqual( + expect.objectContaining({ + trackedChangeText: expect.stringContaining('Tracked'), + author: 'Test', + authorEmail: 'test@example.com', + }), + ); + }); }); diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 065aaa401e..2440913c54 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1523,6 +1523,65 @@ describe('comments-store', () => { expect(superdoc.emit).not.toHaveBeenCalled(); }); + it('creates the first live tracked-change comment from an add event', () => { + const superdoc = { + ...__mockSuperdoc, + emit: vi.fn(), + activeEditor: { commands: { removeComment: vi.fn(), setActiveComment: vi.fn() } }, + }; + + store.commentsList = []; + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'add', + changeId: 'tc-live-insert', + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + documentId: 'doc-1', + }, + broadcastChanges: false, + }); + + expect(store.commentsList).toHaveLength(1); + expect(store.commentsList[0]).toEqual( + expect.objectContaining({ + commentId: 'tc-live-insert', + trackedChange: true, + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + fileId: 'doc-1', + }), + ); + }); + + it('does not create a missing tracked-change comment from an update-only refresh event', () => { + const superdoc = { emit: vi.fn() }; + + store.commentsList = []; + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'update', + changeId: 'tc-live-insert', + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + documentId: 'doc-1', + }, + }); + + expect(store.commentsList).toEqual([]); + expect(superdoc.emit).not.toHaveBeenCalled(); + }); + it('keeps imported resolved tracked-change comments resolved during initial tracked-change rebuild', async () => { const editorDispatch = vi.fn(); const tr = { setMeta: vi.fn() }; diff --git a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts index 30b15798f2..9bea93f453 100644 --- a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts +++ b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts @@ -22,6 +22,22 @@ test('@behavior SD-1997: floating comment bubbles render after tracked changes', .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) .toBeGreaterThanOrEqual(5); + // The live review/comment model should also contain one tracked-change + // comment per inserted suggestion before any visual sidebar assertion runs. + await expect + .poll(async () => + superdoc.page.evaluate(() => { + const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; + return comments.filter( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeType === 'trackInsert' && + String(comment?.trackedChangeText ?? '').includes('tracked change'), + ).length; + }), + ) + .toBeGreaterThanOrEqual(5); + // Verify floating comment placeholders appear in the sidebar const placeholders = superdoc.page.locator('.comment-placeholder'); await expect(placeholders.first()).toBeAttached({ timeout: 10_000 }); From 8e9397b0f7893117d0463a817843697eb5f7905c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:39:31 -0700 Subject: [PATCH 007/280] fix: floating comments fixes --- .../plan-engine/comments-wrappers.test.ts | 75 +++++++++++++ .../plan-engine/comments-wrappers.ts | 104 +++++++++++++++++- .../v1/extensions/comment/comments-plugin.js | 20 +++- .../floating-comments-virtualization.spec.ts | 33 +++--- 4 files changed, 211 insertions(+), 21 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index 3a44dce750..a3e1d81804 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -18,6 +18,10 @@ vi.mock('./revision-tracker.js', () => ({ getRevision: vi.fn(() => 'rev-1'), })); +vi.mock('../tracked-changes/tracked-change-index.js', () => ({ + getTrackedChangeIndex: vi.fn(() => ({ getAll: () => [] })), +})); + vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn(), })); @@ -33,6 +37,7 @@ vi.mock('../helpers/adapter-utils.js', async () => { import { listCommentAnchors } from '../helpers/comment-target-resolver.js'; import { resolveTextTarget } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; function makeAnchor( overrides: Partial & { commentId: string; pos: number; end: number }, @@ -69,6 +74,11 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => values[i++] ?? ''); } +beforeEach(() => { + vi.mocked(listCommentAnchors).mockReturnValue([]); + vi.mocked(getTrackedChangeIndex).mockReturnValue({ getAll: () => [] } as never); +}); + describe('comments-wrappers: anchoredText', () => { beforeEach(() => { vi.clearAllMocks(); @@ -175,6 +185,71 @@ describe('comments-wrappers: anchoredText', () => { const result = wrapper.list({ includeResolved: true }); expect(result.items[0]!.anchoredText).toBe('resolved text'); }); + + it('projects live tracked changes as tracked-change comments', () => { + const editor = makeEditor([]); + vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getAll: () => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-live' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-live' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2026-05-22T04:00:00.000Z', + excerpt: 'live-review-comment', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-live', + range: { from: 1, to: 20 }, + }, + ], + } as never); + + const wrapper = createCommentsWrapper(editor); + const result = wrapper.list(); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: 'tc-live', + trackedChange: true, + trackedChangeType: 'insert', + trackedChangeText: 'live-review-comment', + trackedChangeAnchorKey: 'tc::body::tc-live', + creatorName: 'Alice', + creatorEmail: 'alice@example.com', + }), + ); + }); + + it('does not add synthetic comments for imported Word tracked changes', () => { + const editor = makeEditor([]); + vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getAll: () => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-imported' }, + runtimeRef: { storyKey: 'body', rawId: 'word:trackInsert:1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + author: 'Alice', + date: '2026-05-22T04:00:00.000Z', + excerpt: 'imported', + wordRevisionIds: { insert: '1' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::word:trackInsert:1', + range: { from: 1, to: 9 }, + }, + ], + } as never); + + const wrapper = createCommentsWrapper(editor); + const result = wrapper.list(); + + expect(result.items).toHaveLength(0); + }); }); describe('comments-wrappers: multi-segment TextTarget', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index b5f4ec71e0..69d8b867c6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -24,10 +24,12 @@ import type { ReplyToCommentInput, ResolveCommentInput, RevisionGuardOptions, + StoryLocator, SetCommentActiveInput, SetCommentInternalInput, TextSegment, TextTarget, + TrackChangeType, } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { TextSelection } from 'prosemirror-state'; @@ -50,6 +52,8 @@ import { } from '../helpers/comment-entity-store.js'; import { listCommentAnchors, resolveCommentAnchorsById } from '../helpers/comment-target-resolver.js'; import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; +import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; // --------------------------------------------------------------------------- // Internal helpers @@ -61,6 +65,15 @@ type EditorUserIdentity = { image?: string; }; +type TrackedChangeCommentInfo = CommentInfo & { + story?: StoryLocator; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeAnchorKey?: string; + trackedChangeText?: string; + deletedText?: string | null; +}; + function toCommentAddress(commentId: string): { kind: 'entity'; entityType: 'comment'; entityId: string } { return { kind: 'entity', @@ -393,9 +406,83 @@ function mergeAnchorData( } } -function buildCommentInfos(editor: Editor): CommentInfo[] { +function parseCreatedTime(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function trackedChangeTextFields( + snapshot: TrackedChangeSnapshot, +): Pick { + const excerpt = snapshot.excerpt ?? ''; + if (snapshot.type === 'delete') { + return { trackedChangeText: '', deletedText: excerpt }; + } + return { trackedChangeText: excerpt, deletedText: null }; +} + +function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedChangeCommentInfo | null { + const commentId = toNonEmptyString(snapshot.address.entityId); + if (!commentId) return null; + + const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + + return { + address: toCommentAddress(commentId), + commentId, + text: trackedChangeText || deletedText || undefined, + status: 'open', + creatorName: snapshot.author, + creatorEmail: snapshot.authorEmail, + createdTime: parseCreatedTime(snapshot.date), + anchoredText: snapshot.excerpt, + story: snapshot.story, + trackedChange: true, + trackedChangeType: snapshot.type, + trackedChangeAnchorKey: snapshot.anchorKey, + trackedChangeText, + deletedText, + }; +} + +function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map): void { + let trackedChanges: ReadonlyArray; + try { + trackedChanges = getTrackedChangeIndex(editor).getAll(); + } catch { + return; + } + + for (const snapshot of trackedChanges) { + const trackedChangeComment = toTrackedChangeCommentInfo(snapshot); + if (!trackedChangeComment) continue; + + const existing = infosById.get(trackedChangeComment.commentId); + if (!existing) { + if (snapshot.wordRevisionIds) continue; + infosById.set(trackedChangeComment.commentId, trackedChangeComment); + continue; + } + + Object.assign(existing, { + story: trackedChangeComment.story, + trackedChange: true, + trackedChangeType: trackedChangeComment.trackedChangeType, + trackedChangeAnchorKey: trackedChangeComment.trackedChangeAnchorKey, + trackedChangeText: trackedChangeComment.trackedChangeText, + deletedText: trackedChangeComment.deletedText, + anchoredText: existing.anchoredText ?? trackedChangeComment.anchoredText, + creatorName: existing.creatorName ?? trackedChangeComment.creatorName, + creatorEmail: existing.creatorEmail ?? trackedChangeComment.creatorEmail, + createdTime: existing.createdTime ?? trackedChangeComment.createdTime, + }); + } +} + +function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { const store = getCommentEntityStore(editor); - const infosById = new Map(); + const infosById = new Map(); for (const entry of store) { const commentId = toNonEmptyString(entry.commentId) ?? toNonEmptyString(entry.importedId) ?? null; @@ -404,6 +491,7 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { } mergeAnchorData(editor, infosById, listCommentAnchorsSafe(editor)); + mergeTrackedChangeCommentInfos(editor, infosById); // Inherit target + anchoredText from nearest anchored ancestor for replies. // Walks up the parent chain so deep threads resolve regardless of iteration order. @@ -1105,6 +1193,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment creatorName, creatorEmail, address, + story, + trackedChange, + trackedChangeType, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, } = comment; return buildDiscoveryItem(comment.commentId, handle, { address, @@ -1118,6 +1212,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment createdTime, creatorName, creatorEmail, + story, + trackedChange, + trackedChangeType, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index 52a7dabd1a..c30700ec92 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -918,6 +918,15 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd return true; }; + const trackedChangesForIdCache = new Map(); + const getTrackedChangesForId = (changeId) => { + if (!changeId) return []; + if (!trackedChangesForIdCache.has(changeId)) { + trackedChangesForIdCache.set(changeId, getTrackChanges(newEditorState, changeId)); + } + return trackedChangesForIdCache.get(changeId); + }; + const buildTrackedChangePayload = ({ event, marks, nodes, deletionNodes = [] }) => { if (!marks.insertedMark && !marks.deletionMark && !marks.formatMark) { return null; @@ -936,7 +945,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd deletionNodes, nodes, newEditorState, - trackedChangesForId: getTrackChanges(newEditorState, trackedMarkId), + trackedChangesForId: getTrackedChangesForId(trackedMarkId), }); }; @@ -954,15 +963,18 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd }); } - const hasCandidateNodes = nodes.length > 0 || Boolean(deletionNodes?.length); + const hasLiveTrackedChange = (changeId) => getTrackedChangesForId(changeId).length > 0; + const hasCandidateNodes = nodes.length > 0 || Boolean(deletionNodes?.length) || hasLiveTrackedChange(primaryId); const hasIndependentReplacementIds = Boolean(insertedMark && deletionMark) && Boolean(insertedId) && Boolean(deletionId) && insertedId !== deletionId; if (hasIndependentReplacementIds) { const isNewInsertion = registerTrackedChangeId(insertedId, { insertion: insertedId }); const isNewDeletion = registerTrackedChangeId(deletionId, { deletion: deletionId }); + const hasInsertionCandidateNodes = nodes.length > 0 || hasLiveTrackedChange(insertedId); + const hasDeletionCandidateNodes = Boolean(deletionNodes?.length) || hasLiveTrackedChange(deletionId); - const insertionPayload = hasCandidateNodes + const insertionPayload = hasInsertionCandidateNodes ? buildTrackedChangePayload({ event: isNewInsertion ? 'add' : 'update', marks: { @@ -976,7 +988,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd : null; const deletionPayload = - deletionMark && (hasCandidateNodes || getTrackChanges(newEditorState, deletionId).length > 0) + deletionMark && hasDeletionCandidateNodes ? buildTrackedChangePayload({ event: isNewDeletion ? 'add' : 'update', marks: { diff --git a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts index 9bea93f453..4a0aed1367 100644 --- a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts +++ b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { assertDocumentApiReady, listTrackChanges, listComments } from '../../helpers/document-api.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); @@ -18,25 +19,27 @@ test('@behavior SD-1997: floating comment bubbles render after tracked changes', } // Verify tracked changes were created + let trackedInsertTotal = 0; await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => { + trackedInsertTotal = (await listTrackChanges(superdoc.page, { type: 'insert' })).total; + return trackedInsertTotal; + }) .toBeGreaterThanOrEqual(5); // The live review/comment model should also contain one tracked-change - // comment per inserted suggestion before any visual sidebar assertion runs. + // comment per live tracked insertion before any visual sidebar assertion runs. await expect - .poll(async () => - superdoc.page.evaluate(() => { - const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; - return comments.filter( - (comment: any) => - comment?.trackedChange === true && - comment?.trackedChangeType === 'trackInsert' && - String(comment?.trackedChangeText ?? '').includes('tracked change'), - ).length; - }), - ) - .toBeGreaterThanOrEqual(5); + .poll(async () => { + const comments = await getCommentsSnapshot(superdoc.page); + return comments.filter( + (comment) => + comment?.trackedChange === true && + comment?.trackedChangeType === 'trackInsert' && + String(comment?.trackedChangeText ?? '').length > 0, + ).length; + }) + .toBeGreaterThanOrEqual(trackedInsertTotal); // Verify floating comment placeholders appear in the sidebar const placeholders = superdoc.page.locator('.comment-placeholder'); From 2c2d49d793108f0742255a9125b1a266c25bc854 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:56:01 -0700 Subject: [PATCH 008/280] chore: add tests for metadata issue --- packages/document-api/README.md | 6 ++ .../src/contract/contract.test.ts | 72 +++++++++++++++++++ .../track-changes-wrappers.test.ts | 59 ++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/document-api/README.md b/packages/document-api/README.md index 8b4cee33a1..f2f2863612 100644 --- a/packages/document-api/README.md +++ b/packages/document-api/README.md @@ -60,6 +60,12 @@ operation-definitions.ts types.ts (re-exports + CommandCatalog, guards) - `operation-registry.ts` is the single source of truth for type signatures (input/options/output per operation). - `TypedDispatchTable` (in `invoke.ts`) validates at compile time that dispatch wiring conforms to the registry. +## Receipt Failure Metadata + +For operations that return receipt-shaped results, `possibleFailureCodes` must be the complete set of codes that can appear in a returned `{ success: false, failure }` receipt. `throws.preApply` is only for validation or adapter errors thrown before a receipt is produced; listing a code there does not make it valid for receipt output. + +Generated SDK/CLI schemas derive their `failure.code` enums from `possibleFailureCodes`. When an adapter bridges runtime engine failures into receipt failures, add a local parity test proving every bridged code is present in `possibleFailureCodes` before regenerating downstream artifacts. + ## OperationRegistry and invoke `operation-registry.ts` is the canonical type-level mapping from `OperationId` to `{ input, options, output }`. Bidirectional `Assert` checks guarantee every `OperationId` has a registry entry and vice versa. diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index e9f2fe449b..0a20e4741e 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -8,6 +8,26 @@ import { PUBLIC_MUTATION_STEP_OP_IDS, STEP_OP_CATALOG } from './step-op-catalog. import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js'; import { Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from '../images/z-order.js'; +const TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES = [ + 'NO_OP', + 'INVALID_TARGET', + 'TARGET_NOT_FOUND', + 'CAPABILITY_UNAVAILABLE', + 'PERMISSION_DENIED', + 'PRECONDITION_FAILED', + 'COMMENT_CASCADE_PARTIAL', +] as const; + +function expectArrayToIncludeValues( + actual: readonly string[] | undefined, + expected: readonly string[], + label: string, +): void { + expect(Array.isArray(actual), `${label} should be an array`).toBe(true); + const missing = expected.filter((code) => !actual!.includes(code)); + expect(missing, `${label} missing expected codes`).toEqual([]); +} + describe('document-api contract catalog', () => { it('keeps operation ids explicit and format-valid', () => { expect([...new Set(OPERATION_IDS)]).toHaveLength(OPERATION_IDS.length); @@ -212,6 +232,58 @@ describe('document-api contract catalog', () => { expect(insertFailureSchema.properties?.failure?.properties?.code?.enum).toContain('UNSUPPORTED_ENVIRONMENT'); }); + it('declares every trackChanges.decide receipt failure code in command metadata', () => { + expectArrayToIncludeValues( + COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide possibleFailureCodes', + ); + }); + + it('includes every trackChanges.decide receipt failure code in the generated failure schema', () => { + const schemas = buildInternalContractSchemas(); + const decideFailureSchema = schemas.operations['trackChanges.decide'].failure as { + properties?: { + failure?: { + properties?: { + code?: { + enum?: string[]; + }; + }; + }; + }; + }; + + expectArrayToIncludeValues( + decideFailureSchema.properties?.failure?.properties?.code?.enum, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide failure schema code enum', + ); + }); + + it('includes every trackChanges.decide receipt failure code in the generated output schema', () => { + const schemas = buildInternalContractSchemas(); + const decideOutputSchema = schemas.operations['trackChanges.decide'].output as { + oneOf?: Array<{ + properties?: { + failure?: { + properties?: { + code?: { + enum?: string[]; + }; + }; + }; + }; + }>; + }; + + expectArrayToIncludeValues( + decideOutputSchema.oneOf?.[1]?.properties?.failure?.properties?.code?.enum, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide output schema failure code enum', + ); + }); + it('includes global.history in capabilities.get output schema', () => { const schemas = buildInternalContractSchemas(); const capabilitiesOutput = schemas.operations['capabilities.get'].output as { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 362fd0e3de..11c8a5e1cd 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; -import type { StoryLocator } from '@superdoc/document-api'; +import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; const mocks = vi.hoisted(() => ({ checkRevision: vi.fn(), @@ -41,6 +41,10 @@ import { const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; +function expectTrackChangesDecideReceiptCodeDeclared(code: string): void { + expect(COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes).toContain(code); +} + function makeEditor(commands: Record = {}): Editor { return { commands, @@ -332,5 +336,58 @@ describe('track-changes-wrappers revision guard', () => { }, }, }); + expectTrackChangesDecideReceiptCodeDeclared('TARGET_NOT_FOUND'); + }); + + it('keeps precondition range decision receipt failures declared in document-api metadata', () => { + const acceptTrackedChangesBetween = vi.fn(() => false); + const hostEditor = { + options: { trackedChanges: {} }, + commands: { acceptTrackedChangesBetween }, + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'PRECONDITION_FAILED', + message: 'tracked review graph has invariant errors before decision.', + details: { diagnostics: [{ code: 'INV_REPLACEMENT_MISSING_SIDE' }] }, + }, + }, + }, + state: makeRangeDecisionEditor({}).state, + } as unknown as Editor; + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'PRECONDITION_FAILED', + message: 'tracked review graph has invariant errors before decision.', + details: { diagnostics: [{ code: 'INV_REPLACEMENT_MISSING_SIDE' }] }, + }, + }); + expectTrackChangesDecideReceiptCodeDeclared('PRECONDITION_FAILED'); + }); + + it('keeps unresolved range target receipt failures declared in document-api metadata', () => { + const hostEditor = makeRangeDecisionEditor({ acceptTrackedChangesBetween: vi.fn(() => true) }); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'missing', range: { start: 0, end: 1 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'trackChanges.decide range could not be resolved to a contiguous PM coordinate.', + details: { range: { kind: 'text', segments: [{ blockId: 'missing', range: { start: 0, end: 1 } }] } }, + }, + }); + expectTrackChangesDecideReceiptCodeDeclared('INVALID_TARGET'); }); }); From ff2d2380ee34ece9392e7cbeeb53950a777f76b8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 22:07:01 -0700 Subject: [PATCH 009/280] chore: review fix, type fix --- .../reference/_generated-manifest.json | 2 +- .../reference/track-changes/decide.mdx | 11 ++- .../src/contract/operation-definitions.ts | 10 +- .../v3/handlers/w/del/del-translator.js | 13 ++- .../v3/handlers/w/ins/ins-translator.js | 13 ++- .../v1/core/types/EditorPublicSurfaces.ts | 8 +- .../review-model/decision-engine.js | 6 +- .../track-changes/review-model/identity.js | 4 +- .../review-model/mark-metadata.js | 7 ++ .../review-model/overlap-compiler.js | 93 ++++++++++++++----- .../review-model/test-fixtures.js | 1 + .../trackChangesHelpers/addMarkStep.js | 4 +- 12 files changed, 132 insertions(+), 40 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index f14ad2c32b..690a439709 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "8c6adfd1bbb1226a5847d6a791d7e07cfe994fffe9855fe845de5e196f1f3498" + "sourceHash": "a25eb8affb38731851a794b94a7bdbe8da1b5e5f3d4c21cc214ba454ad1ae898" } diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index 98764f09be..4cc7f7de74 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -60,7 +60,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"COMMENT_CASCADE_PARTIAL"` | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"PRECONDITION_FAILED"`, `"COMMENT_CASCADE_PARTIAL"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -97,8 +97,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ## Non-applied failure codes - `NO_OP` +- `INVALID_TARGET` +- `TARGET_NOT_FOUND` - `CAPABILITY_UNAVAILABLE` - `PERMISSION_DENIED` +- `PRECONDITION_FAILED` - `COMMENT_CASCADE_PARTIAL` ## Raw schemas @@ -173,8 +176,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "code": { "enum": [ "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", "PERMISSION_DENIED", + "PRECONDITION_FAILED", "COMMENT_CASCADE_PARTIAL" ] }, @@ -223,8 +229,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "code": { "enum": [ "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", "PERMISSION_DENIED", + "PRECONDITION_FAILED", "COMMENT_CASCADE_PARTIAL" ] }, diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 5a23fa6520..8790b6de19 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2490,7 +2490,15 @@ export const OPERATION_DEFINITIONS = { idempotency: 'conditional', supportsDryRun: false, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'CAPABILITY_UNAVAILABLE', 'PERMISSION_DENIED', 'COMMENT_CASCADE_PARTIAL'], + possibleFailureCodes: [ + 'NO_OP', + 'INVALID_TARGET', + 'TARGET_NOT_FOUND', + 'CAPABILITY_UNAVAILABLE', + 'PERMISSION_DENIED', + 'PRECONDITION_FAILED', + 'COMMENT_CASCADE_PARTIAL', + ], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_INPUT', 'INVALID_TARGET'], }), referenceDocPath: 'track-changes/decide.mdx', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index 983e8f8204..adc2baae43 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -129,8 +129,17 @@ function decode(params) { */ function resolveExportWordId(params, attrs) { const sourceId = attrs?.sourceId; - const exportSourceId = - typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(sourceId); + /** @type {string | number | null | undefined} */ + let exportSourceId; + if (typeof sourceId === 'string' || typeof sourceId === 'number') { + exportSourceId = sourceId; + } else if (sourceId === null) { + exportSourceId = null; + } else if (sourceId === undefined) { + exportSourceId = undefined; + } else { + exportSourceId = String(sourceId); + } const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; const exportParams = /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 5528776bed..a5cc935776 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -122,8 +122,17 @@ function decode(params) { */ function resolveExportWordId(params, attrs) { const sourceId = attrs?.sourceId; - const exportSourceId = - typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(sourceId); + /** @type {string | number | null | undefined} */ + let exportSourceId; + if (typeof sourceId === 'string' || typeof sourceId === 'number') { + exportSourceId = sourceId; + } else if (sourceId === null) { + exportSourceId = null; + } else if (sourceId === undefined) { + exportSourceId = undefined; + } else { + exportSourceId = String(sourceId); + } const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; const exportParams = /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( diff --git a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts index 75b326981f..547780f92b 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts @@ -16,7 +16,7 @@ * cast at the call site (no `any`). */ import type { Plugin } from 'prosemirror-state'; -import type { Schema } from 'prosemirror-model'; +import type { Node as PmNode, Schema } from 'prosemirror-model'; import type { NodeViewConstructor } from 'prosemirror-view'; import type { EditorHelpers } from './EditorTypes.js'; import type { Comment } from './EditorEvents.js'; @@ -24,6 +24,7 @@ import type { EditorExtension } from './EditorConfig.js'; import type { CommandProps } from './ChainedCommands.js'; import type { ExtensionAttribute } from '../Attribute.js'; import type { NumberingModel } from '../parts/adapters/numbering-transforms.js'; +import type { WordIdAllocator } from '../../extensions/track-changes/review-model/word-id-allocator.js'; /** * Loosely-typed OOXML part as held in `convertedXml`. Element trees @@ -47,7 +48,7 @@ export type HeaderFooterIdMap = Record void } & Record; + editor?: { destroy?: () => void; state?: { doc?: PmNode } } & Record; [key: string]: unknown; } @@ -67,6 +68,8 @@ export interface EditorConverterSurface { footerEditors: HeaderFooterEditorEntry[]; footerIds: HeaderFooterIdMap; footers: Record; + footnotes: unknown; + endnotes: unknown; footnoteProperties: unknown; headerEditors: HeaderFooterEditorEntry[]; headerFooterModified: boolean; @@ -114,6 +117,7 @@ export interface EditorConverterSurface { * helpers iterate both maps. */ translatedNumbering: { abstracts?: Record; definitions?: Record }; + wordIdAllocator?: WordIdAllocator | null; // --- Methods --- /** diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index 4ec93f6271..77c2852972 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -383,7 +383,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { /** * @typedef {Object} MutationPlan * @property {MutationOp[]} ops Document-order op list. - * @property {import('./comment-effects.js').CommentEffectsPlan} commentEffects + * @property {import('./comment-effects.js').CommentEffectsPlan & { _affectedChildren?: Array<{ changeId: string }> }} commentEffects * @property {Set} touchedChangeIds Logical ids retired/updated by the decision. * @property {Set} retiredChangeIds Logical ids retired by the decision (subset). * @property {DecisionDiagnostic[]} diagnostics @@ -436,7 +436,6 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) decision, removedRanges, retired, - diagnostics, }); if (!partialResult.ok) return { ok: false, failure: partialResult.failure }; for (const id of partialResult.createdChangeIds) touched.add(id); @@ -448,7 +447,7 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) const repResult = planReplacementDecision({ ops, change, decision, removedRanges, retired }); if (!repResult.ok) return { ok: false, failure: repResult.failure }; } else if (change.type === CanonicalChangeType.Formatting) { - planFormattingDecision({ ops, change, decision, retired, state }); + planFormattingDecision({ ops, change, decision, retired }); } else { return { ok: false, @@ -880,6 +879,7 @@ const collectCreatedChangeIds = (plan) => { export const buildDecisionBubbleEvents = ({ result, editor }) => { const resolvedByEmail = editor?.options?.user?.email; const resolvedByName = editor?.options?.user?.name; + /** @type {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} */ const events = []; for (const entry of result.receipt.removedChangeIds) { events.push({ type: 'trackedChange', event: 'resolve', changeId: entry.id, resolvedByEmail, resolvedByName }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js index 79d06930dd..5fa7f82689 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -27,6 +27,7 @@ export const normalizeEmail = (value) => { * @property {string} email normalized email, '' when unknown. * @property {string} name display name (may be empty). * @property {boolean} hasEmail true when normalized email is non-empty. + * @property {string} [importedAuthor] imported author provenance, when present. */ /** @@ -59,7 +60,8 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { const email = normalizeEmail(attrs?.authorEmail); const name = typeof attrs?.author === 'string' ? attrs.author : ''; - return { email, name, hasEmail: email.length > 0 }; + const importedAuthor = typeof attrs?.importedAuthor === 'string' ? attrs.importedAuthor : ''; + return { email, name, hasEmail: email.length > 0, importedAuthor }; }; /** diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js index 29643495a2..7f2d59d876 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js @@ -59,6 +59,7 @@ export const SegmentSide = Object.freeze({ Formatting: 'formatting', }); +/** @type {Set} */ const CHANGE_TYPE_VALUES = new Set(Object.values(CanonicalChangeType)); /** @@ -128,6 +129,7 @@ const canonicalizeForSerialization = (value) => { }); } if (typeof value === 'object') { + /** @type {Record} */ const out = {}; const keys = Object.keys(value).sort(); for (const key of keys) { @@ -175,7 +177,12 @@ export const canonicalizeSourceIds = (value) => { return {}; }; +/** + * @param {Record} obj + * @returns {Record} + */ const canonicalSourceIdsFromObject = (obj) => { + /** @type {Record} */ const out = {}; const keys = Object.keys(obj).sort(); for (const key of keys) { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index c54a0909f9..a139791bd5 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -53,26 +53,32 @@ import { */ /** - * @typedef {{ - * ok: true, - * tr: import('prosemirror-state').Transaction, - * createdChangeIds: string[], - * updatedChangeIds: string[], - * removedChangeIds: string[], - * remappedChangeIds: Array<{ from: string, to: string }>, - * selection?: SelectionHint, - * diagnostics?: GraphDiagnostic[], - * insertedMark?: import('prosemirror-model').Mark, - * deletionMarks?: import('prosemirror-model').Mark[], - * formatMarks?: import('prosemirror-model').Mark[], - * insertedFrom?: number, - * insertedTo?: number, - * } | { - * ok: false, - * code: 'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED', - * message: string, - * details?: unknown, - * }} TrackedEditResult + * @typedef {Object} TrackedEditSuccess + * @property {true} ok + * @property {import('prosemirror-state').Transaction} tr + * @property {string[]} createdChangeIds + * @property {string[]} updatedChangeIds + * @property {string[]} removedChangeIds + * @property {Array<{ from: string, to: string }>} remappedChangeIds + * @property {SelectionHint} [selection] + * @property {GraphDiagnostic[]} [diagnostics] + * @property {import('prosemirror-model').Mark} [insertedMark] + * @property {import('prosemirror-model').Mark[]} [deletionMarks] + * @property {import('prosemirror-model').Mark[]} [formatMarks] + * @property {number} [insertedFrom] + * @property {number} [insertedTo] + */ + +/** + * @typedef {Object} TrackedEditFailure + * @property {false} ok + * @property {'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED'} code + * @property {string} message + * @property {unknown} [details] + */ + +/** + * @typedef {TrackedEditSuccess | TrackedEditFailure} TrackedEditResult */ const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); @@ -119,7 +125,7 @@ export const compileTrackedEdit = ({ state, tr, intent, replacements = 'paired' case 'format-remove': return compileFormat(ctx, intent); default: - return failure('CAPABILITY_UNAVAILABLE', `Unsupported tracked edit kind ${intent.kind}.`); + return failure('CAPABILITY_UNAVAILABLE', 'Unsupported tracked edit kind.'); } } catch (error) { return failure('PRECONDITION_FAILED', /** @type {Error} */ (error).message ?? 'compile failed.', { error }); @@ -150,7 +156,18 @@ const makeContext = ({ state, tr, intent, replacements }) => { }; }; -const failure = (code, message, details) => ({ ok: false, code, message, ...(details ? { details } : {}) }); +/** + * @param {'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED'} code + * @param {string} message + * @param {unknown} [details] + * @returns {TrackedEditFailure} + */ +const failure = (code, message, details) => ({ + ok: false, + code, + message, + ...(details !== undefined ? { details } : {}), +}); // --------------------------------------------------------------------------- // Helpers — segments, ownership, marks @@ -268,6 +285,11 @@ const stripTrackedMarksFromSlice = (slice, schema) => { // text-insert // --------------------------------------------------------------------------- +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-insert' }} intent + * @returns {TrackedEditResult} + */ const compileTextInsert = (ctx, intent) => { const { at, content } = intent; const docSize = ctx.tr.doc.content.size; @@ -325,6 +347,15 @@ const compileTextInsert = (ctx, intent) => { return applyInsert(ctx, at, sanitizedSlice, insertedMark, newId, { create: true }); }; +/** + * @param {*} ctx + * @param {number} at + * @param {import('prosemirror-model').Slice} slice + * @param {import('prosemirror-model').Mark} insertMark + * @param {string} changeId + * @param {{ update?: boolean, create?: boolean }} flags + * @returns {TrackedEditResult} + */ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) => { const beforeSize = ctx.tr.doc.content.size; try { @@ -378,6 +409,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = * * @param {*} ctx * @param {import('./edit-intent.js').TrackedEditIntent & { kind: 'text-delete' }} intent + * @returns {TrackedEditResult} */ const compileTextDelete = (ctx, intent) => { const docSize = ctx.tr.doc.content.size; @@ -394,7 +426,7 @@ const compileTextDelete = (ctx, intent) => { sharedDeletionId: intent.replacementGroupHint || null, recordSharedDeletionId: Boolean(intent.replacementGroupHint), }); - if (!result.ok) return result; + if (result.ok === false) return result; // Caret at original `from`: matches Word's behavior where the cursor sits // at the left edge of a tracked deletion. @@ -418,6 +450,7 @@ const compileTextDelete = (ctx, intent) => { * @param {number} from * @param {number} to * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean }} options + * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionId: string } | TrackedEditFailure} */ const applyTrackedDelete = ( ctx, @@ -546,6 +579,11 @@ const applyTrackedDelete = ( // text-replace // --------------------------------------------------------------------------- +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-replace' }} intent + * @returns {TrackedEditResult} + */ const compileTextReplace = (ctx, intent) => { const docSize = ctx.tr.doc.content.size; if (intent.from < 0 || intent.to > docSize) { @@ -573,7 +611,7 @@ const compileTextReplace = (ctx, intent) => { sharedDeletionId: null, recordCollapsedIds: false, }); - if (!deleteResult.ok) return deleteResult; + if (deleteResult.ok === false) return deleteResult; if (!sanitizedSlice.content.size) { ctx.updatedChangeIds.push(ownInsertedTarget.changeId); @@ -640,7 +678,7 @@ const compileTextReplace = (ctx, intent) => { replacementSideId, sharedDeletionId: sharedId, }); - if (!delResult.ok) return delResult; + if (delResult.ok === false) return delResult; if (sharedId && delResult.deletionMarks?.length) { ctx.createdChangeIds.push(sharedId); } @@ -708,6 +746,11 @@ const getReplacementParentId = (ctx, segments) => { // format-apply / format-remove (SD-486 folding) // --------------------------------------------------------------------------- +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'format-apply'|'format-remove' }} intent + * @returns {TrackedEditResult} + */ const compileFormat = (ctx, intent) => { if (!TrackedFormatMarkNames.includes(intent.mark.type.name)) { return failure('CAPABILITY_UNAVAILABLE', `Mark ${intent.mark.type.name} is not a tracked formatting mark.`); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js index 372a21d542..9ebe68d833 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -15,6 +15,7 @@ import { Schema } from 'prosemirror-model'; import { EditorState } from 'prosemirror-state'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +/** @type {Record} */ const NODES = { doc: { content: 'block+' }, paragraph: { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js index eb95797f13..7b14d8d772 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -49,7 +49,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { tr: newTr, intent, }); - if (result.ok) { + if (result.ok === true) { if (result.formatMarks?.length) { newTr.setMeta(TrackChangesBasePluginKey, { formatMark: result.formatMarks[0], @@ -59,7 +59,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.code !== 'CAPABILITY_UNAVAILABLE') { + if (result.ok === false && result.code !== 'CAPABILITY_UNAVAILABLE') { // Fail closed for typed errors; do not silently apply untracked. return; } From 0d475d9362673491e836030aa200b6065fd5a9fc Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 22:51:40 -0700 Subject: [PATCH 010/280] chore: add dispatch test for collab bug --- .../Editor.track-changes-dispatch.test.js | 198 +++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index db364da655..8d76e05547 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -1,9 +1,84 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; -import { TrackInsertMarkName } from '@extensions/track-changes/constants.js'; +import { TrackDeleteMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/trackChangesBasePlugin.js'; +const ALICE = { name: 'Alice Reviewer', email: 'alice@example.com' }; +const BOB = { name: 'Bob Reviewer', email: 'bob@example.com' }; +const FIXED_DATE = '2026-05-21T00:00:00.000Z'; +const FOREIGN_INSERT_ID = 'foreign-insert'; +const INSERTED_TEXT = 'here is my new text, do you like it?'; +const INSERTED_TAIL = 'do you like it?'; + +const findTextRange = (editor, text) => { + let found = null; + editor.state.doc.descendants((node, pos) => { + if (found || !node.isText || !node.text) return; + const index = node.text.indexOf(text); + if (index === -1) return; + found = { from: pos + index, to: pos + index + text.length }; + return false; + }); + if (!found) throw new Error(`Text not found: ${text}`); + return found; +}; + +const markEntries = (editor, markName) => { + const entries = []; + editor.state.doc.descendants((node) => { + if (!node.isText || !node.text) return; + for (const mark of node.marks ?? []) { + if (mark.type?.name === markName) entries.push({ text: node.text, mark }); + } + }); + return entries; +}; + +const textForMarkId = (editor, markName, id) => + markEntries(editor, markName) + .filter(({ mark }) => mark.attrs?.id === id) + .map(({ text }) => text) + .join(''); + +const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_INSERT_ID } = {}) => { + const { schema } = editor; + const insertMark = schema.marks[TrackInsertMarkName].create({ + id, + author: author.name, + authorEmail: author.email, + date: FIXED_DATE, + }); + const doc = schema.nodes.doc.create( + {}, + schema.nodes.paragraph.create( + {}, + schema.nodes.run.create({}, [ + schema.text('hello there '), + schema.text(INSERTED_TEXT, [insertMark]), + schema.text(' after'), + ]), + ), + ); + + editor.dispatch( + editor.state.tr + .replaceWith(0, editor.state.doc.content.size, doc.content) + .setMeta('skipTrackChanges', true) + .setMeta('inputType', 'test-setup'), + ); +}; + +const deleteText = (editor, text) => { + const { from, to } = findTextRange(editor, text); + editor.dispatch(editor.state.tr.delete(from, to).setMeta('inputType', 'deleteContentBackward')); +}; + +const replaceText = (editor, text, replacement) => { + const { from, to } = findTextRange(editor, text); + editor.dispatch(editor.state.tr.insertText(replacement, from, to).setMeta('inputType', 'insertText')); +}; + describe('Editor dispatch tracked-change meta', () => { let editor; @@ -139,4 +214,125 @@ describe('Editor dispatch tracked-change meta', () => { }), ); }); + + it('protects another user tracked insertion from direct delete while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + + deleteText(editor, INSERTED_TAIL); + + expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects another user tracked insertion from direct replace while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + replaceText(editor, INSERTED_TAIL, 'yes'); + + expect(editor.state.doc.textContent).toContain(INSERTED_TAIL); + expect(editor.state.doc.textContent).toContain('yes'); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + + const childInsertion = markEntries(editor, TrackInsertMarkName).find( + ({ text, mark }) => text === 'yes' && mark.attrs?.id !== FOREIGN_INSERT_ID, + ); + expect(childInsertion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('emits review comment state for a protected child deletion instead of only truncating the parent', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const emitSpy = vi.spyOn(editor, 'emit'); + deleteText(editor, INSERTED_TAIL); + + const childDeletionEvent = emitSpy.mock.calls.find( + ([eventName, payload]) => + eventName === 'commentsUpdate' && + payload?.type === 'trackedChange' && + payload?.event === 'add' && + payload?.trackedChangeType === TrackDeleteMarkName, + )?.[1]; + + expect(childDeletionEvent).toEqual( + expect.objectContaining({ + deletedText: expect.stringContaining(INSERTED_TAIL), + authorEmail: BOB.email, + }), + ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + }); + + it('still allows direct deletion of untracked plain text while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

hello plain text

', + user: BOB, + useImmediateSetTimeout: false, + })); + + deleteText(editor, 'plain '); + + expect(editor.state.doc.textContent).toBe('hello text'); + expect(markEntries(editor, TrackInsertMarkName)).toHaveLength(0); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('collapses the current user own insertion on direct delete without creating a child review item', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: BOB, id: 'own-insert' }); + + deleteText(editor, INSERTED_TEXT); + + expect(editor.state.doc.textContent).toBe('hello there after'); + expect(markEntries(editor, TrackInsertMarkName).filter(({ mark }) => mark.attrs?.id === 'own-insert')).toHaveLength( + 0, + ); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); }); From 026e5d6c1777a16b850f461acc8538d838289585 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 06:20:45 -0700 Subject: [PATCH 011/280] fix: collab mode bug --- .../reference/_generated-manifest.json | 2 +- .../reference/track-changes/get.mdx | 8 ++ .../reference/track-changes/list.mdx | 6 + packages/document-api/src/contract/schemas.ts | 4 + .../src/types/track-changes.types.ts | 8 ++ .../Editor.track-changes-dispatch.test.js | 74 +++++++++++ .../src/editors/v1/core/Editor.ts | 122 +++++++++++++++++- .../helpers/text-offset-resolver.test.ts | 20 +++ .../helpers/text-offset-resolver.ts | 3 +- .../helpers/tracked-change-resolver.test.ts | 20 +++ .../helpers/tracked-change-resolver.ts | 2 +- .../plan-engine/executor.ts | 43 ++++++ .../plan-engine/track-changes-wrappers.ts | 32 ++++- .../review-model/overlap-compiler.js | 10 +- .../review-model/overlap-compiler.test.js | 16 ++- 15 files changed, 351 insertions(+), 19 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 690a439709..ecbc575eb2 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "a25eb8affb38731851a794b94a7bdbe8da1b5e5f3d4c21cc214ba454ad1ae898" + "sourceHash": "a9aff0330980d98962b9026299b98b684ce60e47e88b163e2d12040d40bf5b0c" } diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index f3d9ab8a54..3b26d367da 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -54,8 +54,10 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `authorEmail` | string | no | | | `authorImage` | string | no | | | `date` | string | no | | +| `deletedText` | string | no | | | `excerpt` | string | no | | | `id` | string | yes | | +| `insertedText` | string | no | | | `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | @@ -135,12 +137,18 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, "type": { "enum": [ "insert", diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 6411a19b16..59ab4aa6d3 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -166,6 +166,9 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, @@ -175,6 +178,9 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, "type": { "enum": [ "insert", diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index c0a919e361..010c7ecef6 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1512,6 +1512,8 @@ const trackChangeInfoSchema = objectSchema( authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'id', 'type'], ); @@ -1526,6 +1528,8 @@ const trackChangeDomainItemSchema = discoveryItemSchema( authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'type'], ); diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index ce4c380840..2d785b4cd5 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -39,6 +39,10 @@ export interface TrackChangeInfo { authorImage?: string; date?: string; excerpt?: string; + /** Inserted content for insertion-style changes when available. */ + insertedText?: string; + /** Deleted content for deletion-style changes when available. */ + deletedText?: string; } export interface TrackChangesListQuery { @@ -67,6 +71,10 @@ export interface TrackChangeDomain { authorImage?: string; date?: string; excerpt?: string; + /** Inserted content for insertion-style changes when available. */ + insertedText?: string; + /** Deleted content for deletion-style changes when available. */ + deletedText?: string; } /** diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 8d76e05547..5cd08abf50 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -69,6 +69,31 @@ const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_ ); }; +const setDocumentWithSeparateRunTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_INSERT_ID } = {}) => { + const { schema } = editor; + const insertMark = schema.marks[TrackInsertMarkName].create({ + id, + author: author.name, + authorEmail: author.email, + date: FIXED_DATE, + }); + const doc = schema.nodes.doc.create( + {}, + schema.nodes.paragraph.create({}, [ + schema.nodes.run.create({}, schema.text('The quick brown fox jumps over the ')), + schema.nodes.run.create({}, schema.text('lazy ', [insertMark])), + schema.nodes.run.create({}, schema.text('dog.')), + ]), + ); + + editor.dispatch( + editor.state.tr + .replaceWith(0, editor.state.doc.content.size, doc.content) + .setMeta('skipTrackChanges', true) + .setMeta('inputType', 'test-setup'), + ); +}; + const deleteText = (editor, text) => { const { from, to } = findTextRange(editor, text); editor.dispatch(editor.state.tr.delete(from, to).setMeta('inputType', 'deleteContentBackward')); @@ -79,6 +104,16 @@ const replaceText = (editor, text, replacement) => { editor.dispatch(editor.state.tr.insertText(replacement, from, to).setMeta('inputType', 'insertText')); }; +const getFirstMatchRef = (editor, pattern) => { + const match = editor.doc.query.match({ + select: { type: 'text', pattern }, + require: 'first', + }); + const ref = match?.items?.[0]?.handle?.ref; + if (!ref) throw new Error(`Could not resolve ref for pattern "${pattern}"`); + return ref; +}; + describe('Editor dispatch tracked-change meta', () => { let editor; @@ -274,6 +309,45 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + it('protects an imported-style separate-run tracked insertion from direct doc.replace', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithSeparateRunTrackedInsertion(editor); + + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef(editor, 'lazy'), + text: 'quickly', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe('lazy '); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'lazy'); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + + const childInsertion = markEntries(editor, TrackInsertMarkName).find( + ({ text, mark }) => text === 'quickly' && mark.attrs?.id !== FOREIGN_INSERT_ID, + ); + expect(childInsertion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + it('emits review comment state for a protected child deletion instead of only truncating the parent', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index c7280931f2..401941c88c 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1,5 +1,5 @@ import type { EditorState, Transaction, Plugin } from 'prosemirror-state'; -import { Transform } from 'prosemirror-transform'; +import { AddMarkStep, RemoveMarkStep, ReplaceAroundStep, ReplaceStep, Transform } from 'prosemirror-transform'; import type { EditorView as PmEditorView } from 'prosemirror-view'; import type { Node as PmNode, Schema } from 'prosemirror-model'; import type { Doc as YDoc, XmlFragment as YXmlFragment } from 'yjs'; @@ -35,6 +35,7 @@ import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; import { createWordIdAllocator, isDecimalWordId } from '@extensions/track-changes/review-model/word-id-allocator.js'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; import { getNecessaryMigrations } from '@core/migrations/index.js'; @@ -106,6 +107,121 @@ const CURRENT_APP_VERSION = const PIXELS_PER_INCH = 96; const MAX_HEIGHT_BUFFER_PX = 50; const MAX_WIDTH_BUFFER_PX = 20; +const TRACKED_REVIEW_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +const isTrackedReviewMark = (mark: { type?: { name?: string } } | null | undefined): boolean => + Boolean(mark?.type?.name && TRACKED_REVIEW_MARK_NAMES.has(mark.type.name)); + +const trackedReviewMarkKey = (mark: { type?: { name?: string }; attrs?: Record }): string | null => { + if (!isTrackedReviewMark(mark)) return null; + const id = typeof mark.attrs?.id === 'string' ? mark.attrs.id : ''; + return `${mark.type?.name ?? ''}:${id}`; +}; + +const trackedReviewMarkKeysForNode = (node: PmNode | null | undefined): Set => { + const keys = new Set(); + if (!node) return keys; + for (const mark of node.marks ?? []) { + const key = trackedReviewMarkKey(mark); + if (key) keys.add(key); + } + return keys; +}; + +const inlineNodeHasTrackedReviewMark = (node: PmNode): boolean => + Boolean(node.isInline && node.marks?.some(isTrackedReviewMark)); + +const rangeHasTrackedReviewMark = (doc: PmNode, from: number, to: number): boolean => { + if (from >= to) return false; + + const docStart = 0; + const docEnd = doc.content.size; + const clampedFrom = Math.max(docStart, Math.min(docEnd, from)); + const clampedTo = Math.max(docStart, Math.min(docEnd, to)); + if (clampedFrom >= clampedTo) return false; + + let found = false; + doc.nodesBetween(clampedFrom, clampedTo, (node) => { + if (inlineNodeHasTrackedReviewMark(node)) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + +const collapsedPositionIsInsideTrackedReviewMark = (doc: PmNode, pos: number): boolean => { + const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); + const $pos = doc.resolve(boundedPos); + const beforeKeys = trackedReviewMarkKeysForNode($pos.nodeBefore); + if (!beforeKeys.size) return false; + + const afterKeys = trackedReviewMarkKeysForNode($pos.nodeAfter); + for (const key of beforeKeys) { + if (afterKeys.has(key)) return true; + } + return false; +}; + +const fragmentHasTrackedReviewMark = (fragment: { + descendants?: (fn: (node: PmNode) => false | void) => void; +}): boolean => { + let found = false; + fragment.descendants?.((node) => { + if (inlineNodeHasTrackedReviewMark(node)) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + +const collapsedInsertionExtendsTrackedReviewMark = ( + doc: PmNode, + pos: number, + slice: { content?: { descendants?: (fn: (node: PmNode) => false | void) => void } }, +): boolean => { + if (!slice.content || !fragmentHasTrackedReviewMark(slice.content)) return false; + const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); + const $pos = doc.resolve(boundedPos); + return ( + trackedReviewMarkKeysForNode($pos.nodeBefore).size > 0 || trackedReviewMarkKeysForNode($pos.nodeAfter).size > 0 + ); +}; + +const stepTouchesTrackedReviewState = (step: unknown, doc: PmNode): boolean => { + if (step instanceof ReplaceStep) { + if (rangeHasTrackedReviewMark(doc, step.from, step.to)) return true; + if (step.from === step.to && step.slice.content.size > 0) { + return ( + collapsedPositionIsInsideTrackedReviewMark(doc, step.from) || + collapsedInsertionExtendsTrackedReviewMark(doc, step.from, step.slice) + ); + } + return false; + } + + if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + return rangeHasTrackedReviewMark(doc, step.from, step.to); + } + + if (step instanceof ReplaceAroundStep) { + return ( + rangeHasTrackedReviewMark(doc, step.from, step.to) || rangeHasTrackedReviewMark(doc, step.gapFrom, step.gapTo) + ); + } + + return false; +}; + +const transactionTouchesTrackedReviewState = (state: EditorState, tr: Transaction): boolean => { + if (!tr.docChanged || !tr.steps.length) return false; + + const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; + return tr.steps.some((step, index) => stepTouchesTrackedReviewState(step, docs[index] ?? state.doc)); +}; type ExtensionInstanceLike = { type?: string; @@ -2784,8 +2900,10 @@ export class Editor extends EventEmitter { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; + const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); - const shouldTrack = (isTrackChangesActive || forceTrackChanges) && !skipTrackChanges; + const shouldTrack = + ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; if (shouldTrack && forceTrackChanges && !this.options.user) { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts index 1a22fcdfba..bf372bea9a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -68,6 +68,26 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 2, to: 4 }); }); + it('resolves a non-collapsed range starting at an inline-wrapper boundary to the next text node', () => { + const prefix = createNode('run', [createNode('text', [], { text: 'The quick brown fox jumps over the ' })], { + isInline: true, + isLeaf: false, + }); + const insertedRun = createNode('run', [createNode('text', [], { text: 'lazy ' })], { + isInline: true, + isLeaf: false, + }); + const suffix = createNode('run', [createNode('text', [], { text: 'dog.' })], { + isInline: true, + isLeaf: false, + }); + const paragraph = createNode('paragraph', [prefix, insertedRun, suffix], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 35, end: 39 }); + + expect(result).toEqual({ from: 39, to: 43 }); + }); + it('returns null for out-of-range offsets', () => { const textNode = createNode('text', [], { text: 'Hi' }); const paragraph = createNode('paragraph', [textNode], { isBlock: true, inlineContent: true }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts index 0272a9c28d..6cbfbe942a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts @@ -160,7 +160,8 @@ export function resolveTextRangeInBlock( const segmentStart = offset; const segmentEnd = offset + segmentLength; - if (fromPos == null && range.start <= segmentEnd) { + const collapsed = range.start === range.end; + if (fromPos == null && (range.start < segmentEnd || (collapsed && range.start <= segmentEnd))) { fromPos = resolveSegmentPosition(range.start, segmentStart, segmentLength, docFrom, docTo); } if (toPos == null && range.end <= segmentEnd) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index 16f8266ae4..a17873b2df 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -189,6 +189,26 @@ describe('groupTrackedChanges', () => { expect(childChange?.excerpt).toBe('XYZ'); }); + it('keeps live parent insertion text when a child deletion overlaps it', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent', { author: 'Live Author' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child', { + author: 'Second Author', + overlapParentId: 'parent', + }); + const node = { text: 'review', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...parent, node, from: 1, to: 7 }, + { ...child, node, from: 1, to: 7 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.rawId === 'parent'); + const childChange = grouped.find((change) => change.rawId === 'child'); + + expect(parentChange?.excerpt).toBe('review'); + expect(childChange?.excerpt).toBe('review'); + }); + it('preserves significant Word revision whitespace in explicit excerpts', () => { const mark = makeTrackMark(TrackDeleteMarkName, 'delete-with-space', { sourceId: '4' }); vi.mocked(getTrackChanges).mockReturnValue([ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index cdcee5d412..a9abfb41df 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -203,7 +203,7 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const nextHasFormat = markType === TrackFormatMarkName; const wordRevisionId = toNonEmptyString(attrs.sourceId); const wordRevisionIdKey = getWordRevisionIdKey(markType); - const contributesToExcerpt = !hasChildTrackedMarkOnNode(item, id); + const contributesToExcerpt = !wordRevisionId || !hasChildTrackedMarkOnNode(item, id); const excerptText = contributesToExcerpt ? getTrackedMarkText(editor, item) : ''; if (!existing) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 31118612e3..4eaee5b0d0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -60,6 +60,11 @@ import type { Transaction } from 'prosemirror-state'; import type { Mapping } from 'prosemirror-transform'; import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from '../helpers/text-with-tabs.js'; import { getFormattingStateAtPos } from '../../core/helpers/getMarksFromSelection.js'; +import { + TrackDeleteMarkName, + TrackFormatMarkName, + TrackInsertMarkName, +} from '../../extensions/track-changes/constants.js'; // --------------------------------------------------------------------------- // Character-offset → document-position mapping @@ -108,6 +113,7 @@ export function charOffsetToDocPos( const textStart = Math.max(pos, rangeFrom); const textEnd = Math.min(pos + node.nodeSize, rangeTo); const textLen = textEnd - textStart; + if (textLen <= 0) return false; if (count + textLen >= charOffset) { foundPos = textStart + (charOffset - count); } @@ -178,6 +184,33 @@ function asProseMirrorMarks(marks: readonly unknown[]): readonly ProseMirrorMark return marks as readonly ProseMirrorMark[]; } +const TRACKED_REVIEW_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +function hasTrackedReviewMark(marks: readonly ProseMirrorMark[] | undefined): boolean { + return Boolean(marks?.some((mark) => TRACKED_REVIEW_MARK_NAMES.has(mark.type.name))); +} + +function rangeTouchesTrackedReviewState(doc: ProseMirrorNode, from: number, to: number): boolean { + let found = false; + + doc.nodesBetween(from, to, (node, pos) => { + if (found) return false; + + if (node.isText) { + const textStart = Math.max(from, pos); + const textEnd = Math.min(to, pos + node.nodeSize); + if (textStart >= textEnd) return false; + } + + if ((node.isText || node.isInline) && hasTrackedReviewMark(node.marks as readonly ProseMirrorMark[] | undefined)) { + found = true; + return false; + } + }); + + return found; +} + function resolveMarksForRange(editor: Editor, target: CompiledRangeTarget, step: MutationStep): readonly unknown[] { if (step.op !== 'text.rewrite') return []; const rewriteStep = step as TextRewriteStep; @@ -897,6 +930,16 @@ export function executeTextRewrite( const origLen = originalText.length; const replLen = replacementText.length; + if (rangeTouchesTrackedReviewState(tr.doc, absFrom, absTo)) { + if (replacementText.length === 0) { + tr.delete(absFrom, absTo); + return { changed: target.text.length > 0 }; + } + const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks)); + tr.replaceWith(absFrom, absTo, content); + return { changed: replacementText !== target.text }; + } + let prefix = 0; while (prefix < origLen && prefix < replLen && originalText[prefix] === replacementText[prefix]) { prefix++; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 2769ff6e34..28c2fef919 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -57,6 +57,9 @@ function normalizeWordRevisionIds( } function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { + const changedText = snapshot.excerpt + ? { [snapshot.type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } + : {}; return { address: snapshot.address, id: snapshot.address.entityId, @@ -67,6 +70,7 @@ function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { authorImage: snapshot.authorImage, date: snapshot.date, excerpt: snapshot.excerpt, + ...(snapshot.type === 'format' ? {} : changedText), }; } @@ -144,7 +148,18 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList const items = paged.items.map((snapshot) => { const info = snapshotToInfo(snapshot); const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); - const { address, type, wordRevisionIds, author, authorEmail, authorImage, date, excerpt } = info; + const { + address, + type, + wordRevisionIds, + author, + authorEmail, + authorImage, + date, + excerpt, + insertedText, + deletedText, + } = info; return buildDiscoveryItem(info.id, handle, { address, type, @@ -154,6 +169,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList authorImage, date, excerpt, + insertedText, + deletedText, }); }); @@ -186,6 +203,12 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp if (snapshot) return snapshotToInfo(snapshot); + const type = resolveTrackedChangeType(resolved.change); + const excerpt = + (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? + normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')); + const changedText = excerpt ? { [type === 'delete' ? 'deletedText' : 'insertedText']: excerpt } : {}; + return { address: { kind: 'entity', @@ -194,15 +217,14 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp ...(storyKey === BODY_STORY_KEY ? {} : { story: resolved.story }), }, id: resolved.change.id, - type: resolveTrackedChangeType(resolved.change), + type, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), author: toNonEmptyString(resolved.change.attrs.author), authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), authorImage: toNonEmptyString(resolved.change.attrs.authorImage), date: toNonEmptyString(resolved.change.attrs.date), - excerpt: - (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? - normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')), + excerpt, + ...(type === 'format' ? {} : changedText), }; } diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index a139791bd5..d6a7d0b15d 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -664,12 +664,16 @@ const compileTextReplace = (ctx, intent) => { return applyInsert(ctx, intent.from, sanitizedSlice, insertMark, insertId, { create: true }); } + const replacementParentId = getReplacementParentId(ctx, segments); // Paired vs independent: in paired mode share one id between insert+delete - // sides so the logical change projects as a `replacement` in the graph. - const sharedId = intent.replacements === 'paired' ? intent.replacementGroupHint || uuidv4() : null; + // sides so a top-level replacement projects as one logical graph change. + // A replacement nested inside another author's open review item must keep + // each side separately reviewable, so those child sides intentionally use + // distinct ids even when the caller's default replacement mode is paired. + const shouldPairReplacement = intent.replacements === 'paired' && !replacementParentId; + const sharedId = shouldPairReplacement ? intent.replacementGroupHint || uuidv4() : null; const replacementGroupId = sharedId ?? ''; const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; - const replacementParentId = getReplacementParentId(ctx, segments); // Step 1 — tracked delete (collapses own insertions, marks live/other content). if (intent.from !== intent.to) { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index 5838881ac3..0cd991837c 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -436,7 +436,7 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', expect(change.replacement?.deleted.length).toBeGreaterThan(0); }); - it('links both sides of a child replacement to an other-user insertion parent', () => { + it('keeps both sides of a child replacement separately reviewable under an other-user insertion parent', () => { const parentId = 'ins-bob'; const { state } = stateFromTrackedSpans({ schema, @@ -457,11 +457,15 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', const result = runCompile({ state, intent }); expect(result.ok).toBe(true); const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); - const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); - expect(child).toBeDefined(); - expect(child.type).toBe(CanonicalChangeType.Replacement); - expect(child.replacement.inserted[0].attrs.overlapParentId).toBe(parentId); - expect(child.replacement.deleted[0].attrs.overlapParentId).toBe(parentId); + const children = Array.from(graph.changes.values()).filter((change) => change.parent === parentId); + expect(children).toHaveLength(2); + expect(children.map((change) => change.type).sort()).toEqual([ + CanonicalChangeType.Deletion, + CanonicalChangeType.Insertion, + ]); + expect( + children.every((change) => change.segments.every((segment) => segment.attrs.overlapParentId === parentId)), + ).toBe(true); }); it('honors a provided logical id hint for paired document-api replacements', () => { From 95ad0ed373704768f083c8aa9333f55859c1a516 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 06:51:10 -0700 Subject: [PATCH 012/280] chore: type fixes --- packages/super-editor/src/editors/v1/core/Editor.ts | 6 +++++- .../src/editors/v1/core/types/EditorPublicSurfaces.ts | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 401941c88c..7d5ca0a96b 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -95,6 +95,10 @@ import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewM import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; +type ConverterWithInternalWordIdAllocator = EditorConverterSurface & { + wordIdAllocator?: unknown; +}; + declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -3407,7 +3411,7 @@ export class Editor extends EventEmitter { reserveFromJson(this.converter.footnotes, 'word/footnotes.xml'); reserveFromJson(this.converter.endnotes, 'word/endnotes.xml'); - this.converter.wordIdAllocator = allocator; + (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator = allocator; } /** diff --git a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts index 547780f92b..5e0883b0dc 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts @@ -24,7 +24,6 @@ import type { EditorExtension } from './EditorConfig.js'; import type { CommandProps } from './ChainedCommands.js'; import type { ExtensionAttribute } from '../Attribute.js'; import type { NumberingModel } from '../parts/adapters/numbering-transforms.js'; -import type { WordIdAllocator } from '../../extensions/track-changes/review-model/word-id-allocator.js'; /** * Loosely-typed OOXML part as held in `convertedXml`. Element trees @@ -117,7 +116,6 @@ export interface EditorConverterSurface { * helpers iterate both maps. */ translatedNumbering: { abstracts?: Record; definitions?: Record }; - wordIdAllocator?: WordIdAllocator | null; // --- Methods --- /** From 422623d1238264c4cafaba830371676c1e05d478 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:01:14 -0700 Subject: [PATCH 013/280] fix: tc fixes --- .../__tests__/lib/special-handlers.test.ts | 89 +++ apps/cli/src/lib/special-handlers.ts | 61 +- .../src/types/track-changes.types.ts | 17 + packages/layout-engine/contracts/src/index.ts | 5 + .../painters/dom/src/renderer.ts | 63 +- .../pm-adapter/src/marks/application.ts | 52 +- .../pm-adapter/src/marks/index.ts | 1 + .../pm-adapter/src/tracked-changes.ts | 27 +- .../context-menu/tests/utils.test.js | 14 + .../v1/components/context-menu/utils.js | 51 +- .../src/editors/v1/core/Editor.ts | 45 +- .../presentation-editor/PresentationEditor.ts | 87 ++- .../tests/PresentationEditor.test.ts | 45 ++ .../core/super-converter/SuperConverter.d.ts | 6 + .../v2/importer/docxImporter.js | 47 ++ .../v2/importer/importTrackingContext.js | 18 +- .../v2/importer/importTrackingContext.test.js | 31 + .../src/editors/v1/core/types/EditorConfig.ts | 2 + .../src/editors/v1/core/types/EditorEvents.ts | 4 + .../helpers/tracked-change-resolver.ts | 86 +++ .../plan-engine/track-changes-wrappers.ts | 4 + .../__tests__/tracked-change-index.test.ts | 39 + .../tracked-changes/tracked-change-index.ts | 76 +- .../tracked-change-snapshot.ts | 3 + .../v1/extensions/comment/comments-plugin.js | 20 +- .../normalize-comment-event-payload.js | 4 + .../permission-ranges.test.js | 24 + .../v1/extensions/protection/editability.js | 21 +- .../track-changes/permission-helpers.test.js | 17 + .../review-model/decision-engine.js | 143 ++-- .../review-model/decision-engine.test.js | 41 + .../track-changes/review-model/edit-intent.js | 4 + .../track-changes/review-model/identity.js | 140 +++- .../review-model/identity.test.js | 110 ++- .../review-model/mark-metadata.js | 3 + .../review-model/overlap-compiler.js | 716 ++++++++++++++---- .../review-model/overlap-compiler.test.js | 195 +++++ .../review-model/review-graph.js | 12 + .../review-model/test-fixtures.js | 2 + .../review-model/word-id-allocator.js | 35 +- .../review-model/word-id-allocator.test.js | 14 + .../track-changes-extension.test.js | 377 +++++---- .../extensions/track-changes/track-changes.js | 371 ++++----- .../extensions/track-changes/track-delete.js | 5 + .../extensions/track-changes/track-format.js | 5 + .../extensions/track-changes/track-insert.js | 5 + .../trackChangesHelpers/addMarkStep.js | 114 +-- .../findTrackedMarkBetween.js | 6 +- .../trackChangesHelpers/markDeletion.js | 29 +- .../trackChangesHelpers/markInsertion.js | 13 +- .../trackChangesHelpers/removeMarkStep.js | 98 +-- .../trackChangesHelpers/replaceAroundStep.js | 9 +- .../trackChangesHelpers/replaceStep.js | 302 ++------ .../trackChangesHelpers.test.js | 40 +- .../trackChangesHelpers/trackedTransaction.js | 14 +- .../v1/extensions/types/comment-commands.ts | 6 + .../v1/extensions/types/mark-attributes.ts | 6 + .../trackChangesRoundtrip-overlap.test.js | 86 +++ packages/superdoc/src/SuperDoc.vue | 13 +- .../CommentsLayer/CommentDialog.vue | 2 + .../CommentsLayer/CommentHeader.test.js | 126 +++ .../CommentsLayer/CommentHeader.vue | 19 +- .../CommentsLayer/CommentsLayer.vue | 1 + .../CommentsLayer/comment-schemas.js | 2 + .../CommentsLayer/comment-schemas.test.js | 3 +- .../use-comment-extended.test.js | 9 +- .../components/CommentsLayer/use-comment.js | 28 +- packages/superdoc/src/core/SuperDoc.ts | 26 +- .../collaboration/awareness-identity.test.js | 37 + .../core/collaboration/collaboration.test.js | 8 +- .../src/core/collaboration/helpers.js | 3 +- packages/superdoc/src/core/types/index.ts | 4 +- .../src/dev/components/SuperdocDev.vue | 15 +- .../superdoc/src/stores/comments-store.js | 22 +- .../src/stores/comments-store.test.js | 9 +- shared/common/collaboration/awareness.ts | 24 +- shared/common/comments-types.ts | 2 + shared/common/identity.ts | 96 +++ shared/common/index.ts | 3 + 79 files changed, 3052 insertions(+), 1260 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/special-handlers.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js create mode 100644 packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js create mode 100644 packages/superdoc/src/core/collaboration/awareness-identity.test.js create mode 100644 shared/common/identity.ts diff --git a/apps/cli/src/__tests__/lib/special-handlers.test.ts b/apps/cli/src/__tests__/lib/special-handlers.test.ts new file mode 100644 index 0000000000..fbff2a2e9e --- /dev/null +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import { POST_INVOKE_HOOKS } from '../../lib/special-handlers'; + +const rawTrackChangesList = { + evaluatedRevision: '0', + total: 2, + items: [ + { + id: 'raw-parent', + handle: { ref: 'tc::body::raw-parent', refStability: 'stable', targetKind: 'trackedChange' }, + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'raw-parent' }, + type: 'insert', + author: 'Missy Fox', + date: '2026-05-20T14:08:00Z', + excerpt: 'ABCXYZ', + overlap: { + visualLayers: [ + { id: 'raw-parent', type: 'insert', relationship: 'parent' }, + { id: 'raw-child', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'raw-child', + preferredContextTarget: { id: 'raw-child', type: 'delete', relationship: 'child' }, + }, + }, + { + id: 'raw-child', + handle: { ref: 'tc::body::raw-child', refStability: 'stable', targetKind: 'trackedChange' }, + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'raw-child' }, + type: 'delete', + author: 'Vivienne Salisbury', + date: '2026-05-20T14:08:00Z', + excerpt: 'HELLO', + }, + ], +}; + +type Overlap = { + visualLayers: Array<{ id: string }>; + preferredContextTargetId: string; + preferredContextTarget: { id: string }; +}; + +type TrackChangeItem = { + id: string; + overlap?: Overlap; +}; + +describe('special track-changes handlers', () => { + test('normalizes overlap IDs to the same stable IDs as trackChanges.list items', () => { + const hook = POST_INVOKE_HOOKS['trackChanges.list']; + if (!hook) throw new Error('trackChanges.list post hook must be registered'); + + const result = hook(rawTrackChangesList, { editor: {} as never }) as { items: TrackChangeItem[] }; + const parent = result.items[0] as TrackChangeItem & { overlap: Overlap }; + const child = result.items[1] as TrackChangeItem; + + expect(parent.id).not.toBe('raw-parent'); + expect(child.id).not.toBe('raw-child'); + expect(parent.overlap.visualLayers[0].id).toBe(parent.id); + expect(parent.overlap.visualLayers[1].id).toBe(child.id); + expect(parent.overlap.preferredContextTargetId).toBe(child.id); + expect(parent.overlap.preferredContextTarget.id).toBe(child.id); + }); + + test('normalizes overlap IDs on trackChanges.get output', () => { + const hook = POST_INVOKE_HOOKS['trackChanges.get']; + const listHook = POST_INVOKE_HOOKS['trackChanges.list']; + if (!hook) throw new Error('trackChanges.get post hook must be registered'); + if (!listHook) throw new Error('trackChanges.list post hook must be registered'); + + const context = { + editor: { + doc: { + invoke: () => rawTrackChangesList, + }, + }, + }; + const result = hook(rawTrackChangesList.items[0], context as never) as TrackChangeItem & { overlap: Overlap }; + const normalizedList = listHook(rawTrackChangesList, { editor: {} as never }) as { items: TrackChangeItem[] }; + const parentId = normalizedList.items[0]?.id; + const childId = normalizedList.items[1]?.id; + if (!parentId || !childId) throw new Error('expected normalized list to contain parent and child ids'); + + expect(result.id).toBe(parentId); + expect(result.overlap.visualLayers[1].id).toBe(childId); + expect(result.overlap.preferredContextTargetId).toBe(childId); + expect(result.overlap.preferredContextTarget.id).toBe(childId); + }); +}); diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index 24029e20f6..e7b3dadb07 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -67,6 +67,39 @@ function stableTrackChangeSignature(change: TrackChangeLike): string { return `${type}|${author}|${authorEmail}|${date}|${excerpt}`; } +function normalizeStableTrackChangeId(value: unknown, rawToStableId: ReadonlyMap): unknown { + if (typeof value !== 'string' || value.length === 0) return value; + return rawToStableId.get(value) ?? value; +} + +function normalizeOverlapLayer(value: unknown, rawToStableId: ReadonlyMap): unknown { + const record = asRecord(value); + if (!record) return value; + return { + ...record, + id: normalizeStableTrackChangeId(record.id, rawToStableId), + }; +} + +function normalizeTrackChangeOverlap(value: unknown, rawToStableId: ReadonlyMap): unknown { + const record = asRecord(value); + if (!record) return value; + + const visualLayers = Array.isArray(record.visualLayers) + ? record.visualLayers.map((layer) => normalizeOverlapLayer(layer, rawToStableId)) + : record.visualLayers; + const preferredContextTarget = record.preferredContextTarget + ? normalizeOverlapLayer(record.preferredContextTarget, rawToStableId) + : record.preferredContextTarget; + + return { + ...record, + ...(Array.isArray(record.visualLayers) ? { visualLayers } : {}), + preferredContextTargetId: normalizeStableTrackChangeId(record.preferredContextTargetId, rawToStableId), + ...(record.preferredContextTarget ? { preferredContextTarget } : {}), + }; +} + /** * Builds stable-ID ↔ raw-ID mappings from a track-changes list result. * The CLI uses SHA-1-based stable IDs instead of adapter raw IDs. @@ -85,14 +118,14 @@ function buildStableIdMappings(rawListResult: unknown): { const rawToStableId = new Map(); const signatureCounts = new Map(); - const normalizedItems = asArray(record.items) + const entries = asArray(record.items) .map((entry) => asRecord(entry)) .filter((entry): entry is Record => Boolean(entry)) .map((entry) => { const rawId = (typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : undefined) ?? asTrackChangeAddress(entry.address)?.entityId; - if (!rawId) return entry; + if (!rawId) return { entry }; const signature = stableTrackChangeSignature(entry); const hash = createHash('sha1').update(signature).digest('hex').slice(0, 24); @@ -103,16 +136,23 @@ function buildStableIdMappings(rawListResult: unknown): { stableToRawId.set(stableId, rawId); rawToStableId.set(rawId, stableId); - const normalizedAddress = asTrackChangeAddress(entry.address); - const handleRecord = asRecord(entry.handle); - return { - ...entry, - id: stableId, - address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : entry.address, - handle: handleRecord ? { ...handleRecord, ref: `tc:${stableId}` } : entry.handle, - }; + return { entry, rawId, stableId }; }); + const normalizedItems = entries.map(({ entry, rawId, stableId }) => { + if (!rawId || !stableId) return entry; + + const normalizedAddress = asTrackChangeAddress(entry.address); + const handleRecord = asRecord(entry.handle); + return { + ...entry, + id: stableId, + address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : entry.address, + handle: handleRecord ? { ...handleRecord, ref: `tc:${stableId}` } : entry.handle, + ...(entry.overlap !== undefined ? { overlap: normalizeTrackChangeOverlap(entry.overlap, rawToStableId) } : {}), + }; + }); + return { normalizedResult: { ...record, @@ -207,6 +247,7 @@ const normalizeTrackChangeGetId: PostInvokeHook = (result, context) => { ...record, id: stableId, address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : record.address, + ...(record.overlap !== undefined ? { overlap: normalizeTrackChangeOverlap(record.overlap, rawToStableId) } : {}), }; }; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 2d785b4cd5..b33cf52d71 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -3,6 +3,19 @@ import type { DiscoveryOutput } from './discovery.js'; import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; +export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; + +export interface TrackChangeOverlapLayer { + id: string; + type: TrackChangeType; + relationship: TrackChangeOverlapRelationship; +} + +export interface TrackChangeOverlapInfo { + visualLayers?: TrackChangeOverlapLayer[]; + preferredContextTargetId?: string; + preferredContextTarget?: TrackChangeOverlapLayer; +} /** * Scope marker used by {@link TrackChangesListQuery.in} to request changes @@ -34,6 +47,8 @@ export interface TrackChangeInfo { type: TrackChangeType; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; author?: string; authorEmail?: string; authorImage?: string; @@ -66,6 +81,8 @@ export interface TrackChangeDomain { type: TrackChangeType; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; author?: string; authorEmail?: string; authorImage?: string; diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 027f02fab2..72da33493a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -257,6 +257,8 @@ export type RunMark = { export type TrackedChangeMeta = { kind: TrackedChangeKind; id: string; + overlapParentId?: string; + relationship?: 'parent' | 'child' | 'standalone'; /** * Internal story key identifying which content story owns this tracked * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). @@ -356,6 +358,8 @@ export type TextRun = RunMarks & { }; /** Tracked-change metadata from ProseMirror marks. */ trackedChange?: TrackedChangeMeta; + /** All tracked-change layers on this run, preserving overlap order. */ + trackedChanges?: TrackedChangeMeta[]; /** * Run-level bidi signals preserved from the source DOCX (run rtl flag, * embedding/override directions). Direction-only - script formatting lives @@ -497,6 +501,7 @@ export type BreakRun = { pmEnd?: number; sdt?: SdtMetadata; trackedChange?: TrackedChangeMeta; + trackedChanges?: TrackedChangeMeta[]; }; /** diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 23ab6fa786..36bd23e8e6 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -43,6 +43,7 @@ import type { MathRun, TextRun, TrackedChangeKind, + TrackedChangeMeta, TrackedChangesMode, VectorShapeDrawing, VectorShapeStyle, @@ -1028,6 +1029,13 @@ type TrackedChangesRenderConfig = { enabled: boolean; }; +const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + /** * Sanitize a URL to prevent XSS attacks. * Only allows http, https, mailto, tel, and internal anchors. @@ -7076,23 +7084,28 @@ export class DomPainter { } const textRun = run as TextRun; - const meta = textRun.trackedChange; - if (!meta) { + const layers = getTrackedChangeLayers(textRun); + if (layers.length === 0) { return; } + const meta = textRun.trackedChange ?? layers[0]; - const baseClass = TRACK_CHANGE_BASE_CLASS[meta.kind]; - if (baseClass) { - elem.classList.add(baseClass); - } + layers.forEach((layer) => { + const baseClass = TRACK_CHANGE_BASE_CLASS[layer.kind]; + if (baseClass) { + elem.classList.add(baseClass); + } - const modifier = TRACK_CHANGE_MODIFIER_CLASS[meta.kind]?.[config.mode]; - if (modifier) { - elem.classList.add(modifier); - } + const modifier = TRACK_CHANGE_MODIFIER_CLASS[layer.kind]?.[config.mode]; + if (modifier) { + elem.classList.add(modifier); + } + }); elem.dataset.trackChangeId = meta.id; elem.dataset.trackChangeKind = meta.kind; + elem.dataset.trackChangeIds = layers.map((layer) => layer.id).join(','); + elem.dataset.trackChangeKinds = layers.map((layer) => layer.kind).join(','); elem.dataset.storyKey = meta.storyKey ?? 'body'; if (meta.author) { elem.dataset.trackChangeAuthor = meta.author; @@ -7896,19 +7909,23 @@ const deriveBlockVersion = (block: FlowBlock): string => { // Handle TextRun (kind is 'text' or undefined) const textRun = run as TextRun; - const trackedChangeVersion = textRun.trackedChange - ? [ - textRun.trackedChange.kind ?? '', - textRun.trackedChange.id ?? '', - textRun.trackedChange.storyKey ?? '', - textRun.trackedChange.author ?? '', - textRun.trackedChange.authorEmail ?? '', - textRun.trackedChange.authorImage ?? '', - textRun.trackedChange.date ?? '', - textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', - textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', - ].join(':') - : ''; + const trackedChangeVersion = getTrackedChangeLayers(textRun) + .map((trackedChange) => + [ + trackedChange.kind ?? '', + trackedChange.id ?? '', + trackedChange.storyKey ?? '', + trackedChange.overlapParentId ?? '', + trackedChange.relationship ?? '', + trackedChange.author ?? '', + trackedChange.authorEmail ?? '', + trackedChange.authorImage ?? '', + trackedChange.date ?? '', + trackedChange.before ? JSON.stringify(trackedChange.before) : '', + trackedChange.after ? JSON.stringify(trackedChange.after) : '', + ].join(':'), + ) + .join('|'); return [ textRun.text ?? '', textRun.fontFamily, diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 493b43232f..58f50df191 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -459,6 +459,10 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): kind, id: deriveTrackedChangeId(kind, attrs), }; + if (typeof attrs.overlapParentId === 'string' && attrs.overlapParentId) { + meta.overlapParentId = attrs.overlapParentId; + meta.relationship = 'child'; + } if (typeof attrs.author === 'string' && attrs.author) { meta.author = attrs.author; } @@ -502,6 +506,25 @@ export const selectTrackedChangeMeta = ( return existing; }; +const trackedChangeLayerKey = (meta: TrackedChangeMeta): string => `${meta.kind}:${meta.id}`; + +const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + +const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void => { + const layers = normalizeTrackedChangeLayers(run); + const key = trackedChangeLayerKey(meta); + if (!layers.some((layer) => trackedChangeLayerKey(layer) === key)) { + layers.push(meta); + } + run.trackedChanges = layers; + run.trackedChange = selectTrackedChangeMeta(run.trackedChange, meta); +}; + /** * Checks if two text runs have compatible tracked change metadata for merging. * Runs are compatible if they have the same kind and ID, or both have no metadata. @@ -511,11 +534,13 @@ export const selectTrackedChangeMeta = ( * @returns true if runs can be merged, false otherwise */ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { - const aMeta = a.trackedChange; - const bMeta = b.trackedChange; - if (!aMeta && !bMeta) return true; - if (!aMeta || !bMeta) return false; - return aMeta.kind === bMeta.kind && aMeta.id === bMeta.id; + const aLayers = normalizeTrackedChangeLayers(a); + const bLayers = normalizeTrackedChangeLayers(b); + if (aLayers.length !== bLayers.length) return false; + return aLayers.every((aMeta, index) => { + const bMeta = bLayers[index]; + return Boolean(bMeta && aMeta.kind === bMeta.kind && aMeta.id === bMeta.id); + }); }; /** @@ -534,6 +559,21 @@ export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: strin }, undefined); }; +export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta[] => { + if (!marks || !marks.length) return []; + const seen = new Set(); + const trackedChanges: TrackedChangeMeta[] = []; + marks.forEach((mark) => { + const meta = buildTrackedChangeMetaFromMark(mark, storyKey); + if (!meta) return; + const key = trackedChangeLayerKey(meta); + if (seen.has(key)) return; + seen.add(key); + trackedChanges.push(meta); + }); + return trackedChanges; +}; + /** * Normalizes underline style value from PM mark attributes. * Returns a valid UnderlineStyle, or undefined for explicit off values. @@ -862,7 +902,7 @@ export const applyMarksToRun = ( if (!isTabRun) { const tracked = buildTrackedChangeMetaFromMark(mark, storyKey); if (tracked) { - run.trackedChange = selectTrackedChangeMeta(run.trackedChange, tracked); + appendTrackedChangeLayer(run, tracked); } } break; diff --git a/packages/layout-engine/pm-adapter/src/marks/index.ts b/packages/layout-engine/pm-adapter/src/marks/index.ts index b95bd14e69..3c5063091b 100644 --- a/packages/layout-engine/pm-adapter/src/marks/index.ts +++ b/packages/layout-engine/pm-adapter/src/marks/index.ts @@ -27,6 +27,7 @@ export { selectTrackedChangeMeta, trackedChangesCompatible, collectTrackedChangeFromMarks, + collectTrackedChangesFromMarks, normalizeUnderlineStyle, applyTextStyleMark, applyMarksToRun, diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index 808f2f6df7..731573df54 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -71,6 +71,9 @@ export const stripTrackedChangeFromRun = (run: Run): void => { if ('trackedChange' in run && run.trackedChange) { delete run.trackedChange; } + if ('trackedChanges' in run && run.trackedChanges) { + delete run.trackedChanges; + } }; /** @@ -222,6 +225,10 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): kind, id: deriveTrackedChangeId(kind, attrs), }; + if (typeof attrs.overlapParentId === 'string' && attrs.overlapParentId) { + meta.overlapParentId = attrs.overlapParentId; + meta.relationship = 'child'; + } if (typeof attrs.author === 'string' && attrs.author) { meta.author = attrs.author; } @@ -265,6 +272,13 @@ export const selectTrackedChangeMeta = ( return existing; }; +const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + /** * Checks if two text runs have compatible tracked change metadata for merging. * Runs are compatible if they have the same kind and ID, or both have no metadata. @@ -274,11 +288,13 @@ export const selectTrackedChangeMeta = ( * @returns true if runs can be merged, false otherwise */ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { - const aMeta = a.trackedChange; - const bMeta = b.trackedChange; - if (!aMeta && !bMeta) return true; - if (!aMeta || !bMeta) return false; - return aMeta.kind === bMeta.kind && aMeta.id === bMeta.id; + const aLayers = normalizeTrackedChangeLayers(a); + const bLayers = normalizeTrackedChangeLayers(b); + if (aLayers.length !== bLayers.length) return false; + return aLayers.every((aMeta, index) => { + const bMeta = bLayers[index]; + return Boolean(bMeta && aMeta.kind === bMeta.kind && aMeta.id === bMeta.id); + }); }; /** @@ -517,6 +533,7 @@ export const applyTrackedChangesModeToRuns = ( (run.trackedChange.kind === 'insert' || run.trackedChange.kind === 'delete') ) { delete run.trackedChange; + delete run.trackedChanges; } }); } diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js index 01a8c91ae4..373880052e 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js @@ -65,6 +65,7 @@ import { __isCollaborationEnabledForTest, __getCellSelectionInfoForTest, __resolveProofingContextForTest, + __choosePreferredTrackedChangeIdForTest, } from '../utils.js'; import { readFromClipboard } from '../../../core/utilities/clipboardUtils.js'; import { selectionHasNodeOrMark } from '../../cursor-helpers.js'; @@ -114,6 +115,19 @@ describe('utils.js', () => { ); describe('getEditorContext', () => { + it('prefers a child deletion as the context target for overlapping tracked marks', () => { + const parentInsert = { + type: { name: 'trackInsert' }, + attrs: { id: 'parent-insert' }, + }; + const childDelete = { + type: { name: 'trackDelete' }, + attrs: { id: 'child-delete', overlapParentId: 'parent-insert' }, + }; + + expect(__choosePreferredTrackedChangeIdForTest([parentInsert, childDelete])).toBe('child-delete'); + }); + it('should return comprehensive editor context', async () => { // Note: getEditorContext() no longer reads clipboard proactively. // Clipboard reading is deferred to paste action to avoid permission prompts. diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index dc7d47edf6..07982afc43 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -17,6 +17,47 @@ import { selectedRect } from 'prosemirror-tables'; export const resolveContextMenuCommandEditor = (editor) => { return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; }; + +const TRACKED_MARK_NAMES = new Set(['trackInsert', 'trackDelete', 'trackFormat']); +const TRACKED_MARK_PRIORITY = { + trackDelete: 3, + trackFormat: 2, + trackInsert: 1, +}; + +function isTrackedChangeMark(mark) { + return Boolean(mark?.type?.name && TRACKED_MARK_NAMES.has(mark.type.name)); +} + +function choosePreferredTrackedChangeId(marks) { + const candidates = []; + const seen = new Set(); + + marks.forEach((mark, index) => { + if (!isTrackedChangeMark(mark)) return; + const id = typeof mark.attrs?.id === 'string' ? mark.attrs.id : ''; + if (!id) return; + const key = `${mark.type.name}:${id}`; + if (seen.has(key)) return; + seen.add(key); + candidates.push({ + id, + markType: mark.type.name, + isChildOverlap: Boolean(mark.attrs?.overlapParentId), + index, + }); + }); + + candidates.sort((a, b) => { + if (a.isChildOverlap !== b.isChildOverlap) return a.isChildOverlap ? -1 : 1; + const priorityDelta = (TRACKED_MARK_PRIORITY[b.markType] ?? 0) - (TRACKED_MARK_PRIORITY[a.markType] ?? 0); + if (priorityDelta !== 0) return priorityDelta; + return a.index - b.index; + }); + + return candidates[0]?.id ?? null; +} + /** * Get props by item id * @@ -141,6 +182,7 @@ export async function getEditorContext(editor, event) { const activeMarks = []; let trackedChangeId = null; + const trackedMarkCandidates = []; if (event && pos !== null) { const $pos = state.doc.resolve(pos); @@ -149,11 +191,8 @@ export async function getEditorContext(editor, event) { if (!activeMarks.includes(mark.type.name)) { activeMarks.push(mark.type.name); } - if ( - !trackedChangeId && - (mark.type.name === 'trackInsert' || mark.type.name === 'trackDelete' || mark.type.name === 'trackFormat') - ) { - trackedChangeId = mark.attrs.id; + if (isTrackedChangeMark(mark)) { + trackedMarkCandidates.push(mark); } }; @@ -171,6 +210,7 @@ export async function getEditorContext(editor, event) { } state.storedMarks?.forEach(processMark); + trackedChangeId = choosePreferredTrackedChangeId(trackedMarkCandidates); } else { state.storedMarks?.forEach((mark) => activeMarks.push(mark.type.name)); state.selection.$head.marks().forEach((mark) => activeMarks.push(mark.type.name)); @@ -458,4 +498,5 @@ export { isCollaborationEnabled as __isCollaborationEnabledForTest, getCellSelectionInfo as __getCellSelectionInfoForTest, resolveProofingContext as __resolveProofingContextForTest, + choosePreferredTrackedChangeId as __choosePreferredTrackedChangeIdForTest, }; diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 7d5ca0a96b..2f25eb0b29 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -34,7 +34,11 @@ import { import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; -import { createWordIdAllocator, isDecimalWordId } from '@extensions/track-changes/review-model/word-id-allocator.js'; +import { + createWordIdAllocator, + isDecimalWordId, + TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY, +} from '@extensions/track-changes/review-model/word-id-allocator.js'; import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; @@ -96,7 +100,9 @@ import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-sta import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; type ConverterWithInternalWordIdAllocator = EditorConverterSurface & { - wordIdAllocator?: unknown; + wordIdAllocator?: { + getSourceIdMap?: () => Record>; + } | null; }; declare const __APP_VERSION__: string | undefined; @@ -3414,6 +3420,39 @@ export class Editor extends EventEmitter { (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator = allocator; } + #persistTrackedChangeSourceIdMap(): void { + if (!this.converter) return; + + const allocator = (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator; + const sourceIdMap = allocator?.getSourceIdMap?.() ?? {}; + if (Object.keys(sourceIdMap).length === 0 && !this.#hasTrackedChangeSourceIdMapProperty()) return; + + const payload = JSON.stringify({ version: 1, parts: sourceIdMap }); + SuperConverter.setStoredCustomProperty( + this.converter.convertedXml, + TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY, + payload, + false, + ); + } + + #hasTrackedChangeSourceIdMapProperty(): boolean { + const customXml = this.converter?.convertedXml?.['docProps/custom.xml']; + const properties = customXml?.elements?.find((node: { name?: string }) => { + const name = node?.name; + return name === 'Properties' || name?.endsWith(':Properties'); + }); + return Boolean( + properties?.elements?.some((node: { name?: string; attributes?: { name?: string } }) => { + const name = node?.name; + return ( + (name === 'property' || name?.endsWith(':property')) && + node.attributes?.name === TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY + ); + }), + ); + } + /** * Export the editor document to DOCX. * @@ -3493,6 +3532,8 @@ export class Editor extends EventEmitter { if (exportXmlOnly || exportJsonOnly) return documentXml; + this.#persistTrackedChangeSourceIdMap(); + const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]); const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml'].elements[0]); const hasCustomSettings = !!this.converter.convertedXml['word/settings.xml']?.elements?.length; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d72de0811f..803e8d459e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -266,7 +266,10 @@ import { findAllBookmarksInDocument, resolveBookmarkTarget, } from '../../document-api-adapters/helpers/bookmark-resolver.js'; -import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js'; +import { + resolveTrackedChange, + resolveTrackedChangeInStory, +} from '../../document-api-adapters/helpers/tracked-change-resolver.js'; import { makeTrackedChangeAnchorKey } from '../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; import { getTrackedChangeIndex } from '../../document-api-adapters/tracked-changes/tracked-change-index.js'; import { normalizeVariant } from './header-footer/header-footer-variant.js'; @@ -8563,33 +8566,54 @@ export class PresentationEditor extends EventEmitter { const behavior = options.behavior ?? 'auto'; const block = options.block ?? 'center'; + const navigationIds = this.#resolveTrackedChangeNavigationIds(entityId, storyKey); if (storyKey && storyKey !== BODY_STORY_KEY) { - if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { - return true; + for (const id of navigationIds) { + if (this.#navigateToActiveStoryTrackedChange(id, storyKey)) { + return true; + } } - if (await this.#activateTrackedChangeStorySurface(entityId, storyKey, preferredPageIndex)) { - if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { - return true; + for (const id of navigationIds) { + if (await this.#activateTrackedChangeStorySurface(id, storyKey, preferredPageIndex)) { + for (const activeId of navigationIds) { + if (this.#navigateToActiveStoryTrackedChange(activeId, storyKey)) { + return true; + } + } } } - return this.#scrollToRenderedTrackedChange(entityId, storyKey, preferredPageIndex, { behavior, block }); + for (const id of navigationIds) { + if (await this.#scrollToRenderedTrackedChange(id, storyKey, preferredPageIndex, { behavior, block })) { + return true; + } + } + return false; } const setCursorById = editor.commands?.setCursorById; // Try direct cursor placement, then scroll to the new selection. - if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) { - await this.scrollToPositionAsync(editor.state.selection.from, { behavior, block }); - return true; + if (typeof setCursorById === 'function') { + for (const id of navigationIds) { + if (setCursorById(id, { preferredActiveThreadId: id })) { + await this.scrollToPositionAsync(editor.state.selection.from, { behavior, block }); + return true; + } + } } // Fall back to resolving the tracked change position and scrolling. - const resolved = resolveTrackedChange(editor, entityId); + const resolved = navigationIds.map((id) => resolveTrackedChange(editor, id)).find(Boolean); if (!resolved) { - return this.#scrollToRenderedTrackedChange(entityId, undefined, preferredPageIndex, { behavior, block }); + for (const id of navigationIds) { + if (await this.#scrollToRenderedTrackedChange(id, undefined, preferredPageIndex, { behavior, block })) { + return true; + } + } + return false; } // Try with the raw ID (tracked changes may use a different internal ID). @@ -8612,6 +8636,45 @@ export class PresentationEditor extends EventEmitter { return true; } + #resolveTrackedChangeNavigationIds(entityId: string, storyKey?: string): string[] { + const ids: string[] = []; + const seen = new Set(); + const add = (value: unknown) => { + if (value === undefined || value === null) return; + const id = String(value).trim(); + if (!id || seen.has(id)) return; + seen.add(id); + ids.push(id); + }; + + add(entityId); + + let story: StoryLocator | undefined; + if (storyKey && storyKey !== BODY_STORY_KEY) { + try { + story = parseStoryKey(storyKey); + } catch { + story = undefined; + } + } + + try { + const resolved = resolveTrackedChangeInStory(this.#editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId, + ...(story ? { story } : {}), + }); + add(resolved?.change?.commandRawId); + add(resolved?.change?.rawId); + add(resolved?.change?.id); + } catch { + // Navigation still has direct-id and rendered-DOM fallbacks. + } + + return ids; + } + async #activateTrackedChangeStorySurface( entityId: string, storyKey: string, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index dd75db1090..350da2f310 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -3323,6 +3323,51 @@ describe('PresentationEditor', () => { expect(sessionEditor?.view.focus).toHaveBeenCalled(); }); + it('resolves public Word tracked-change ids to runtime mark ids during story navigation', async () => { + const { sessionEditor } = await activateFootnoteSession(); + const setCursorById = vi.fn((id: string) => id === 'runtime-note-1'); + if (sessionEditor?.commands) { + sessionEditor.commands.setCursorById = setCursorById; + } + if (sessionEditor?.state?.doc) { + sessionEditor.state.doc.descendants = vi.fn((callback: (node: unknown, pos: number) => void) => { + callback( + { + isInline: true, + nodeSize: 13, + text: 'FN_TC_CHARLIE', + marks: [ + { + type: { name: 'trackInsert' }, + attrs: { + id: 'runtime-note-1', + sourceId: '101', + author: 'Story Harness', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + 2, + ); + }); + } + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'word:trackInsert:101', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + expect(setCursorById).toHaveBeenCalledWith('word:trackInsert:101', { + preferredActiveThreadId: 'word:trackInsert:101', + }); + expect(setCursorById).toHaveBeenCalledWith('runtime-note-1', { preferredActiveThreadId: 'runtime-note-1' }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + }); + it('falls back to rendered tracked-change stamps for inactive non-body stories', async () => { const { viewport } = await prepareFootnoteEditor(); const page = document.createElement('div'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts index 1fa76a804f..ef0c4d4c85 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts @@ -40,6 +40,12 @@ export class SuperConverter { }); static getStoredSuperdocVersion(docx: readonly { readonly name: string; readonly content: string }[]): string | null; + static setStoredCustomProperty( + docx: unknown, + propertyName: string, + value: string | (() => string), + preserveExisting?: boolean, + ): string | null; // The setter accepts either shape (array of file entries or mutable map // keyed by package path); the underlying `setStoredCustomProperty` does // `docx[customLocation] = ...`, which works on both at runtime. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 81dc17383f..b091226ba6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -47,6 +47,7 @@ import { translator as wNumberingTranslator } from '@converter/v3/handlers/w/num import { baseNumbering } from '@converter/v2/exporter/helpers/base-list.definitions.js'; import { patchNumberingDefinitions } from './patchNumberingDefinitions.js'; import { startCollection, drainDiagnostics } from '@converter/v3/handlers/import-diagnostics.js'; +import { TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY } from '@extensions/track-changes/review-model/word-id-allocator.js'; /** * @typedef {import()} XmlNode @@ -95,6 +96,51 @@ const detectDocumentOrigin = (docx) => { return 'unknown'; }; +const matchesElementName = (name, localName) => { + if (typeof name !== 'string') return false; + return name === localName || name.endsWith(`:${localName}`); +}; + +const readCustomProperty = (docx, propertyName) => { + try { + const customXml = docx?.['docProps/custom.xml']; + const properties = customXml?.elements?.find((el) => matchesElementName(el?.name, 'Properties')); + const property = properties?.elements?.find( + (el) => matchesElementName(el?.name, 'property') && el?.attributes?.name === propertyName, + ); + const value = property?.elements?.[0]?.elements?.[0]?.text; + return typeof value === 'string' && value.length > 0 ? value : null; + } catch { + return null; + } +}; + +const parseTrackedChangeSourceIdMap = (raw) => { + if (typeof raw !== 'string' || raw.length === 0) return new Map(); + + try { + const parsed = JSON.parse(raw); + const parts = parsed && typeof parsed === 'object' ? parsed.parts : null; + if (!parts || typeof parts !== 'object') return new Map(); + + const byPart = new Map(); + for (const [partPath, entries] of Object.entries(parts)) { + if (!partPath || !entries || typeof entries !== 'object') continue; + const byWordId = new Map(); + for (const [wordId, sourceId] of Object.entries(entries)) { + if (typeof sourceId === 'string' && sourceId.length > 0) byWordId.set(wordId, sourceId); + } + if (byWordId.size > 0) byPart.set(partPath, byWordId); + } + return byPart; + } catch { + return new Map(); + } +}; + +const readTrackedChangeSourceIdMap = (docx) => + parseTrackedChangeSourceIdMap(readCustomProperty(docx, TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY)); + /** * Detect the document-level threading profile for comments based on file structure. * @param {ParsedDocx} docx The parsed docx object @@ -125,6 +171,7 @@ export const createDocumentJson = (docx, converter, editor) => { importViewSettingFromSettings(docx, converter); converter.documentOrigin = detectDocumentOrigin(docx); converter.commentThreadingProfile = detectCommentThreadingProfile(docx); + converter.trackedChangeSourceIdMapByPart = readTrackedChangeSourceIdMap(docx); } const nodeListHandler = defaultNodeListHandler(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js index 2ec19303fd..1441ad1024 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js @@ -7,6 +7,7 @@ const contextsByConverter = new WeakMap(); * @typedef {{ * trackedChangeIdMapsByPart?: Map>, * trackedChangeIdMap?: Map, + * trackedChangeSourceIdMapByPart?: Map>, * trackedChangesOptions?: { replacements?: 'paired' | 'independent' }, * }} ConverterLike * @@ -46,6 +47,20 @@ export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrac return mapsByPart?.get?.(partPath) ?? converter.trackedChangeIdMap ?? null; } +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + * @param {string} [wordId] + */ +function getTrackedChangeSourceIdForPart(params = {}, partPath = resolveTrackedChangePartPath(params), wordId = '') { + const converter = params.converter; + if (!converter || typeof converter !== 'object') return null; + + const sourceIdsByPart = converter.trackedChangeSourceIdMapByPart; + const restored = sourceIdsByPart?.get?.(partPath)?.get?.(wordId); + return typeof restored === 'string' && restored.length > 0 ? restored : null; +} + /** * @param {ImportTrackingParams} [params] * @param {string} sourceId @@ -54,10 +69,11 @@ export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrac export function resolveTrackedChangeImportIds(params = {}, sourceId = '') { const partPath = resolveTrackedChangePartPath(params); const id = typeof sourceId === 'string' ? sourceId : String(sourceId || ''); + const restoredSourceId = getTrackedChangeSourceIdForPart(params, partPath, id) ?? id; const trackedChangeIdMap = getTrackedChangeIdMapForPart(params, partPath); return { partPath, - sourceId: id, + sourceId: restoredSourceId, logicalId: id && trackedChangeIdMap?.has(id) ? (trackedChangeIdMap.get(id) ?? id) : id, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js new file mode 100644 index 0000000000..17331586b8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js @@ -0,0 +1,31 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { resolveTrackedChangeImportIds } from './importTrackingContext.js'; + +describe('resolveTrackedChangeImportIds', () => { + it('restores exported non-decimal source ids while keeping logical ids keyed by the Word id', () => { + const converter = { + trackedChangeIdMap: new Map([['1', 'logical-from-import-map']]), + trackedChangeSourceIdMapByPart: new Map([['word/document.xml', new Map([['1', 'uuid-source-id']])]]), + }; + + expect(resolveTrackedChangeImportIds({ converter }, '1')).toEqual({ + partPath: 'word/document.xml', + sourceId: 'uuid-source-id', + logicalId: 'logical-from-import-map', + }); + }); + + it('preserves raw decimal Word ids when no source-id restore entry exists', () => { + const converter = { + trackedChangeIdMap: new Map([['2', 'logical-two']]), + trackedChangeSourceIdMapByPart: new Map([['word/document.xml', new Map([['1', 'uuid-source-id']])]]), + }; + + expect(resolveTrackedChangeImportIds({ converter }, '2')).toEqual({ + partPath: 'word/document.xml', + sourceId: '2', + logicalId: 'logical-two', + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 0e73cb976f..9e039bf953 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -122,6 +122,8 @@ export interface FontConfig { * User information for collaboration */ export interface User { + /** Stable actor id for authorship and ownership checks. */ + id?: string | null; /** The user's name */ name?: string; diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index 147fab0fab..41161bf6ec 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -46,10 +46,14 @@ export interface Comment { commentId: string; /** Timestamp when the comment was created (ms since epoch) */ createdTime: number | null; + /** Stable actor id of the comment author */ + creatorId?: string | null; /** Display name of the comment author */ creatorName: string | null; /** Email address of the comment author */ creatorEmail: string | null; + /** Stable actor id of the resolver */ + resolvedById?: string | null; /** Avatar URL of the comment author */ creatorImage?: string | null; /** Structured body content of the comment */ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index a9abfb41df..33c2b6fc8c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -2,6 +2,8 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; import type { StoryLocator, + TrackChangeOverlapInfo, + TrackChangeOverlapLayer, TrackChangeType, TrackChangeWordRevisionIds, TrackedChangeAddress, @@ -41,10 +43,15 @@ export type GroupedTrackedChange = { attrs: Record; excerpt?: string; wordRevisionIds?: TrackChangeWordRevisionIds; + overlap?: TrackChangeOverlapInfo; }; type ChangeTypeInput = Pick; type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; +type InternalTrackChangeOverlapLayer = TrackChangeOverlapLayer & { + rawId?: string; + commandRawId?: string; +}; function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { try { @@ -154,6 +161,84 @@ function isTrackedMarkName(markType: string | undefined): boolean { return markType === TrackInsertMarkName || markType === TrackDeleteMarkName || markType === TrackFormatMarkName; } +function getTrackedChangeAliasCandidates(change: GroupedTrackedChange): string[] { + const candidates = [ + change.rawId, + change.commandRawId, + change.id, + toNonEmptyString(change.attrs.id), + toNonEmptyString(change.attrs.sourceId), + ]; + return Array.from(new Set(candidates.filter((value): value is string => Boolean(value)))); +} + +function layerFromChange( + change: GroupedTrackedChange, + relationship: TrackChangeOverlapLayer['relationship'], +): InternalTrackChangeOverlapLayer { + return { + id: change.id, + rawId: change.rawId, + commandRawId: change.commandRawId, + type: resolveTrackedChangeType(change), + relationship, + }; +} + +function compareOverlapChildren(a: GroupedTrackedChange, b: GroupedTrackedChange): number { + const aType = resolveTrackedChangeType(a); + const bType = resolveTrackedChangeType(b); + if (aType !== bType) { + if (aType === 'delete') return -1; + if (bType === 'delete') return 1; + } + if (a.from !== b.from) return a.from - b.from; + return a.id.localeCompare(b.id); +} + +function attachOverlapMetadata(grouped: GroupedTrackedChange[]): void { + if (grouped.length < 2) return; + + const byAlias = new Map(); + for (const change of grouped) { + for (const alias of getTrackedChangeAliasCandidates(change)) { + if (!byAlias.has(alias)) byAlias.set(alias, change); + } + } + + const childrenByParent = new Map(); + for (const child of grouped) { + const parentRef = toNonEmptyString(child.attrs.overlapParentId); + if (!parentRef) continue; + const parent = byAlias.get(parentRef); + if (!parent || parent === child) continue; + const children = childrenByParent.get(parent) ?? []; + children.push(child); + childrenByParent.set(parent, children); + } + + for (const [parent, children] of childrenByParent.entries()) { + const orderedChildren = children.slice().sort(compareOverlapChildren); + const visualLayers = [ + layerFromChange(parent, 'parent'), + ...orderedChildren.map((child) => layerFromChange(child, 'child')), + ]; + const preferredContextTarget = + visualLayers.find((layer) => layer.relationship === 'child' && layer.type === 'delete') ?? + visualLayers.find((layer) => layer.relationship === 'child'); + + parent.overlap = { + visualLayers, + ...(preferredContextTarget + ? { + preferredContextTargetId: preferredContextTarget.id, + preferredContextTarget, + } + : {}), + }; + } +} + export function getTrackedChangeMarkAlias(mark: { readonly type: { readonly name: string }; readonly attrs?: Readonly>; @@ -261,6 +346,7 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { if (a.from !== b.from) return a.from - b.from; return a.id.localeCompare(b.id); }); + attachOverlapMetadata(grouped); groupedCache.set(editor, { doc: currentDoc, grouped }); return grouped; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 28c2fef919..c412d4d0b2 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -65,6 +65,7 @@ function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { id: snapshot.address.entityId, type: snapshot.type, wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + overlap: snapshot.overlap, author: snapshot.author, authorEmail: snapshot.authorEmail, authorImage: snapshot.authorImage, @@ -152,6 +153,7 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList address, type, wordRevisionIds, + overlap, author, authorEmail, authorImage, @@ -164,6 +166,7 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList address, type, wordRevisionIds, + overlap, author, authorEmail, authorImage, @@ -219,6 +222,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp id: resolved.change.id, type, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), + overlap: resolved.change.overlap, author: toNonEmptyString(resolved.change.attrs.author), authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), authorImage: toNonEmptyString(resolved.change.attrs.authorImage), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts index 1e33501464..1d599398f3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts @@ -132,6 +132,45 @@ describe('TrackedChangeIndex — per-story cache', () => { }); }); + it('preserves overlap metadata on snapshots', () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValueOnce([ + { + ...makeGroupedChange('parent-insert', 0, 5), + overlap: { + visualLayers: [ + { id: 'stale-parent-id', rawId: 'parent-insert', type: 'insert', relationship: 'parent' }, + { id: 'stale-child-id', rawId: 'child-delete', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'stale-child-id', + preferredContextTarget: { + id: 'stale-child-id', + rawId: 'child-delete', + type: 'delete', + relationship: 'child', + }, + }, + }, + { + ...makeGroupedChange('child-delete', 1, 4), + hasInsert: false, + hasDelete: true, + }, + ]); + + const index = getTrackedChangeIndex(editor); + const snapshots = index.get({ kind: 'story', storyType: 'body' }); + + expect(snapshots[0]?.overlap).toEqual({ + visualLayers: [ + { id: 'canon-parent-insert', type: 'insert', relationship: 'parent' }, + { id: 'canon-child-delete', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'canon-child-delete', + preferredContextTarget: { id: 'canon-child-delete', type: 'delete', relationship: 'child' }, + }); + }); + it('returns story-scoped anchor keys for footnote stories', () => { const editor = makeEditor(); mocks.enumerateRevisionCapableStories.mockReturnValue([ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index 0b461cfc46..b9099ce24a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -12,7 +12,7 @@ * comments-store, navigation, and review surfaces can resync. */ -import type { StoryLocator } from '@superdoc/document-api'; +import type { StoryLocator, TrackChangeOverlapInfo, TrackChangeOverlapLayer } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import type { PartChangedEvent } from '../../core/parts/types.js'; import { buildStoryKey, BODY_STORY_KEY, parseStoryKeyType } from '../story-runtime/story-key.js'; @@ -65,6 +65,73 @@ function buildTrackedChangeAddress( }; } +type OverlapLayerWithAliases = TrackChangeOverlapLayer & { + rawId?: string; + commandRawId?: string; +}; + +function addCanonicalAlias(map: Map, alias: unknown, canonicalId: string): void { + const normalized = toNonEmptyString(alias); + if (!normalized || map.has(normalized)) return; + map.set(normalized, canonicalId); +} + +function buildCanonicalIdByAlias(grouped: readonly GroupedTrackedChange[]): Map { + const map = new Map(); + for (const change of grouped) { + addCanonicalAlias(map, change.id, change.id); + addCanonicalAlias(map, change.rawId, change.id); + addCanonicalAlias(map, change.commandRawId, change.id); + addCanonicalAlias(map, change.attrs.id, change.id); + addCanonicalAlias(map, change.attrs.sourceId, change.id); + } + return map; +} + +function resolveCanonicalId(alias: unknown, canonicalIdByAlias: ReadonlyMap): string | undefined { + const normalized = toNonEmptyString(alias); + if (!normalized) return undefined; + return canonicalIdByAlias.get(normalized) ?? normalized; +} + +function copyOverlapLayer( + layer: TrackChangeOverlapLayer, + canonicalIdByAlias: ReadonlyMap, +): TrackChangeOverlapLayer { + const layerWithAliases = layer as OverlapLayerWithAliases; + const id = + resolveCanonicalId(layerWithAliases.rawId, canonicalIdByAlias) ?? + resolveCanonicalId(layerWithAliases.commandRawId, canonicalIdByAlias) ?? + resolveCanonicalId(layerWithAliases.id, canonicalIdByAlias) ?? + layer.id; + + return { + id, + type: layer.type, + relationship: layer.relationship, + }; +} + +function copyOverlapInfo( + overlap: TrackChangeOverlapInfo | undefined, + canonicalIdByAlias: ReadonlyMap, +): TrackChangeOverlapInfo | undefined { + if (!overlap) return undefined; + + const visualLayers = overlap.visualLayers?.map((layer) => copyOverlapLayer(layer, canonicalIdByAlias)); + const preferredContextTarget = overlap.preferredContextTarget + ? copyOverlapLayer(overlap.preferredContextTarget, canonicalIdByAlias) + : undefined; + const preferredContextTargetId = + preferredContextTarget?.id ?? resolveCanonicalId(overlap.preferredContextTargetId, canonicalIdByAlias); + + return { + ...(visualLayers && visualLayers.length > 0 ? { visualLayers } : {}), + ...(preferredContextTargetId ? { preferredContextTargetId } : {}), + ...(preferredContextTarget ? { preferredContextTarget } : {}), + }; +} + class TrackedChangeIndexImpl implements TrackedChangeIndex { readonly #hostEditor: Editor; readonly #snapshots = new Map(); @@ -180,8 +247,11 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { const storyKind = classifyStoryKind(locator); const storyLabel = describeStoryLocation(locator); + const canonicalIdByAlias = buildCanonicalIdByAlias(grouped); - return grouped.map((change) => this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel)); + return grouped.map((change) => + this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel, canonicalIdByAlias), + ); } #buildSnapshot( @@ -191,6 +261,7 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { locator: StoryLocator, storyKind: TrackedChangeSnapshot['storyKind'], storyLabel: string, + canonicalIdByAlias: ReadonlyMap, ): TrackedChangeSnapshot { const runtimeRef: TrackedChangeRuntimeRef = { storyKey, rawId: change.rawId }; const address = buildTrackedChangeAddress(locator, storyKey, change.id); @@ -210,6 +281,7 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { date: toNonEmptyString(change.attrs.date), excerpt, wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + overlap: copyOverlapInfo(change.overlap, canonicalIdByAlias), storyLabel, storyKind, anchorKey: makeTrackedChangeAnchorKey(runtimeRef), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts index b9ed36a2e2..0e7cbcf672 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -8,6 +8,7 @@ import type { StoryLocator, TrackedChangeAddress, TrackChangeType, + TrackChangeOverlapInfo, TrackChangeWordRevisionIds, } from '@superdoc/document-api'; import type { TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; @@ -33,6 +34,8 @@ export interface TrackedChangeSnapshot { excerpt?: string; /** Raw imported Word revision IDs, if present. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; /** Human-readable label for sidebar cards ("Footer · Section 3", "Footnote 12"). */ storyLabel: string; /** Coarse classifier for UI decisions (icon, label). */ diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index c30700ec92..a5dec3659c 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -35,6 +35,7 @@ export const CommentsPlugin = Extension.create({ * @param {string} [contentOrOptions.content] - The comment content (text or HTML) * @param {string} [contentOrOptions.commentId] - Explicit comment ID (defaults to a new UUID) * @param {string} [contentOrOptions.author] - Author name (defaults to user from editor config) + * @param {string} [contentOrOptions.authorId] - Stable actor id (defaults to user from editor config) * @param {string} [contentOrOptions.authorEmail] - Author email (defaults to user from editor config) * @param {string} [contentOrOptions.authorImage] - Author image URL (defaults to user from editor config) * @param {boolean} [contentOrOptions.isInternal=false] - Whether the comment is internal/private @@ -70,7 +71,7 @@ export const CommentsPlugin = Extension.create({ } // Handle string or options object - let content, explicitCommentId, author, authorEmail, authorImage, isInternal; + let content, explicitCommentId, author, authorId, authorEmail, authorImage, isInternal; if (typeof contentOrOptions === 'string') { content = contentOrOptions; @@ -78,6 +79,7 @@ export const CommentsPlugin = Extension.create({ content = contentOrOptions.content; explicitCommentId = contentOrOptions.commentId; author = contentOrOptions.author; + authorId = contentOrOptions.authorId; authorEmail = contentOrOptions.authorEmail; authorImage = contentOrOptions.authorImage; isInternal = contentOrOptions.isInternal; @@ -110,6 +112,7 @@ export const CommentsPlugin = Extension.create({ isInternal: resolvedInternal, commentText: content, creatorName: author ?? configUser.name, + creatorId: authorId ?? configUser.id, creatorEmail: authorEmail ?? configUser.email, creatorImage: authorImage ?? configUser.image, createdTime: Date.now(), @@ -135,6 +138,7 @@ export const CommentsPlugin = Extension.create({ * @param {string} options.parentId - The ID of the parent comment or tracked change * @param {string} [options.content] - The reply content (text or HTML) * @param {string} [options.author] - Author name (defaults to user from editor config) + * @param {string} [options.authorId] - Stable actor id (defaults to user from editor config) * @param {string} [options.authorEmail] - Author email (defaults to user from editor config) * @param {string} [options.authorImage] - Author image URL (defaults to user from editor config) * @returns {boolean} True if the reply was added successfully, false otherwise @@ -147,7 +151,15 @@ export const CommentsPlugin = Extension.create({ addCommentReply: (options = {}) => ({ editor }) => { - const { parentId, content, author, authorEmail, authorImage, commentId: explicitCommentId } = options; + const { + parentId, + content, + author, + authorId, + authorEmail, + authorImage, + commentId: explicitCommentId, + } = options; if (!parentId) { console.warn('addCommentReply requires a parentId'); @@ -163,6 +175,7 @@ export const CommentsPlugin = Extension.create({ parentCommentId: parentId, commentText: content, creatorName: author ?? configUser.name, + creatorId: authorId ?? configUser.id, creatorEmail: authorEmail ?? configUser.email, creatorImage: authorImage ?? configUser.image, createdTime: Date.now(), @@ -1163,7 +1176,7 @@ const createOrUpdateTrackedChangeComment = ({ const { type, attrs } = trackedMark; const { name: trackedChangeType } = type; - const { author, authorEmail, authorImage, date, importedAuthor } = attrs; + const { author, authorId, authorEmail, authorImage, date, importedAuthor } = attrs; const id = attrs.id; const insertedMarkId = marks.insertedMark?.attrs?.id ?? null; @@ -1245,6 +1258,7 @@ const createOrUpdateTrackedChangeComment = ({ trackedChangeDisplayType, deletedText: isReplacement || marks.deletionMark ? deletionText : null, author, + ...(authorId && { authorId }), authorEmail, ...(authorImage && { authorImage }), date, diff --git a/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js b/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js index 169c04af73..7fd2f3da90 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js @@ -22,6 +22,10 @@ export const normalizeCommentEventPayload = ({ conversation, editorOptions, fall normalized.creatorName = user.name; } + if (!normalized.creatorId && user?.id) { + normalized.creatorId = user.id; + } + if (!normalized.creatorEmail && user?.email) { normalized.creatorEmail = user.email; } diff --git a/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js b/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js index cba42bd057..58da795981 100644 --- a/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js +++ b/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js @@ -66,6 +66,21 @@ const docWithUserSpecificPermission = { ], }; +const docWithActorIdPermission = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'permStart', attrs: { id: 'actor-1', ed: 'alice-id' } }, + { type: 'text', text: 'Actor specific section. ' }, + { type: 'permEnd', attrs: { id: 'actor-1', ed: 'alice-id' } }, + { type: 'text', text: 'Locked section.' }, + ], + }, + ], +}; + const findTextPos = (doc, searchText) => { let found = null; doc.descendants((node, pos) => { @@ -780,4 +795,13 @@ describe('PermissionRanges extension', () => { expect(instance.isEditable).toBe(true); expect(instance.storage.permissionRanges?.ranges?.length).toBeGreaterThan(0); }); + + it('matches permission ranges by actor id when no explicit permissionPrincipals are set', () => { + const instance = createProtectedEditor(docWithActorIdPermission, { + user: { id: 'alice-id', name: 'Alice', email: 'shared@example.com' }, + }); + + expect(instance.isEditable).toBe(true); + expect(instance.storage.permissionRanges?.ranges?.length).toBeGreaterThan(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/protection/editability.js b/packages/super-editor/src/editors/v1/extensions/protection/editability.js index 10201088e4..d281458606 100644 --- a/packages/super-editor/src/editors/v1/extensions/protection/editability.js +++ b/packages/super-editor/src/editors/v1/extensions/protection/editability.js @@ -171,12 +171,23 @@ export function buildAllowedIdentifierSetFromEditor(editor) { return new Set(principals.map((p) => (typeof p === 'string' ? p.trim().toLowerCase() : '')).filter(Boolean)); } - // Fallback: derive from email only when permissionPrincipals is not set + // Fallback: derive from stable actor id when available, plus the legacy + // email-derived principal for documents that still use Word-style `ed` + // values. This lets embedders move to first-class actor ids without + // breaking older documents. + const identifiers = new Set(); + const actorId = typeof user.id === 'string' ? user.id.trim().toLowerCase() : ''; + if (actorId) identifiers.add(actorId); + const email = typeof user.email === 'string' ? user.email.trim().toLowerCase() : ''; - if (!email) return new Set(); - const [localPart, domain] = email.split('@'); - if (!localPart || !domain) return new Set(); - return new Set([`${domain}\\${localPart}`]); + if (email) { + const [localPart, domain] = email.split('@'); + if (localPart && domain) { + identifiers.add(`${domain}\\${localPart}`); + } + } + + return identifiers; } /** diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js index 5baf4447a6..6fa56790bc 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js @@ -151,6 +151,23 @@ describe('permission-helpers', () => { ); }); + it('treats same-email different actor ids as other-user changes', () => { + const mockResolver = vi.fn(() => true); + editor.options.permissionResolver = mockResolver; + editor.options.user = { id: 'owner-id', email: 'shared@example.com' }; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'case-match', attrs: { authorId: 'other-id', authorEmail: 'shared@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith( + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), + ); + }); + it('isTrackedChangeActionAllowed short-circuits when any resolver call denies access', () => { const mockResolver = vi.fn(({ permission }) => permission !== 'REJECT_OTHER'); editor.options.permissionResolver = mockResolver; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index 77c2852972..6cd64f2836 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -331,7 +331,12 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { const change = selection.change; const classification = classifyOwnership({ currentUser: currentIdentity, - change: getChangeAuthorIdentity(change.author ? { author: change.author, authorEmail: change.authorEmail } : {}), + change: getChangeAuthorIdentity({ + author: change.author, + authorId: change.authorId, + authorEmail: change.authorEmail, + importedAuthor: change.importedAuthor, + }), }); const isOwn = isSameUserHighConfidence(classification); const permission = @@ -344,7 +349,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { trackedChange: { id: change.id, type: change.type, - attrs: { author: change.author, authorEmail: change.authorEmail, date: change.date }, + attrs: { author: change.author, authorId: change.authorId, authorEmail: change.authorEmail, date: change.date }, from: selection.ranges[0]?.from ?? 0, to: selection.ranges[selection.ranges.length - 1]?.to ?? 0, segments: change.segments.map((s) => ({ from: s.from, to: s.to })), @@ -515,16 +520,12 @@ const planInsertionDecision = ({ ops, change, selection, decision, removedRanges // Accept insertion: keep content, remove the trackInsert mark. const ranges = isFull ? change.insertedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; for (const range of ranges) { - const segment = - change.insertedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.insertedSegments[0]; - if (!segment) continue; - ops.push({ - kind: 'removeMark', - from: range.from, - to: range.to, + pushRemoveMarkOpsForRange({ + ops, + segments: change.insertedSegments, + range, changeId: change.id, side: SegmentSide.Inserted, - mark: segment.mark, }); } if (isFull) retired.add(change.id); @@ -566,16 +567,12 @@ const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, // Reject deletion: remove the trackDelete mark; content stays as live. const ranges = isFull ? change.deletedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; for (const range of ranges) { - const segment = - change.deletedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.deletedSegments[0]; - if (!segment) continue; - ops.push({ - kind: 'removeMark', - from: range.from, - to: range.to, + pushRemoveMarkOpsForRange({ + ops, + segments: change.deletedSegments, + range, changeId: change.id, side: SegmentSide.Deleted, - mark: segment.mark, }); } if (isFull) retired.add(change.id); @@ -596,13 +593,11 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired removedRanges.push({ from: seg.from, to: seg.to, cause: `accept-replacement-deleted:${change.id}` }); } for (const seg of inserted) { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Inserted, - mark: seg.mark, }); } } else { @@ -612,13 +607,11 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired removedRanges.push({ from: seg.from, to: seg.to, cause: `reject-replacement-inserted:${change.id}` }); } for (const seg of deleted) { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Deleted, - mark: seg.mark, }); } } @@ -629,25 +622,25 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired const planFormattingDecision = ({ ops, change, decision, retired }) => { for (const seg of change.formattingSegments) { if (decision === 'accept') { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Formatting, - mark: seg.mark, }); } else { - ops.push({ - kind: 'restoreFormat', - from: seg.from, - to: seg.to, - changeId: change.id, - side: SegmentSide.Formatting, - mark: seg.mark, - beforeMarks: seg.mark.attrs?.before ?? [], - afterMarks: seg.mark.attrs?.after ?? [], - }); + for (const run of getSegmentMarkRuns(seg)) { + ops.push({ + kind: 'restoreFormat', + from: run.from, + to: run.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: run.mark, + beforeMarks: run.mark.attrs?.before ?? [], + afterMarks: run.mark.attrs?.after ?? [], + }); + } } } retired.add(change.id); @@ -666,7 +659,12 @@ const planPartialTextDecision = ({ ops, change, selection, decision, removedRang let successorOrdinal = 0; for (const segment of segments) { - ops.push({ kind: 'removeMark', from: segment.from, to: segment.to, changeId: change.id, side, mark: segment.mark }); + pushRemoveMarkOpsForSegment({ + ops, + segment, + changeId: change.id, + side, + }); const pieces = subtractRanges({ from: segment.from, to: segment.to }, selectedRanges); for (const piece of pieces) { @@ -708,6 +706,40 @@ const planPartialTextDecision = ({ ops, change, selection, decision, removedRang return { ok: true, createdChangeIds: successorRanges.map((entry) => entry.id) }; }; +const pushRemoveMarkOpsForRange = ({ ops, segments, range, changeId, side }) => { + for (const segment of segments) { + if (segment.to <= range.from || segment.from >= range.to) continue; + pushRemoveMarkOpsForSegment({ + ops, + segment, + changeId, + side, + from: Math.max(segment.from, range.from), + to: Math.min(segment.to, range.to), + }); + } +}; + +const pushRemoveMarkOpsForSegment = ({ ops, segment, changeId, side, from = segment.from, to = segment.to }) => { + for (const run of getSegmentMarkRuns(segment)) { + const clippedFrom = Math.max(from, run.from); + const clippedTo = Math.min(to, run.to); + if (clippedFrom >= clippedTo) continue; + ops.push({ + kind: 'removeMark', + from: clippedFrom, + to: clippedTo, + changeId, + side, + mark: run.mark, + }); + } +}; + +const getSegmentMarkRuns = (segment) => { + return segment.markRuns?.length ? segment.markRuns : [{ from: segment.from, to: segment.to, mark: segment.mark }]; +}; + const mergeRanges = (ranges) => { const sorted = ranges .filter((range) => range.from < range.to) @@ -874,18 +906,33 @@ const collectCreatedChangeIds = (plan) => { * @param {Object} input * @param {DecisionResult} input.result * @param {object} input.editor - * @returns {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} + * @returns {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedById?: string, resolvedByEmail?: string, resolvedByName?: string }>} */ export const buildDecisionBubbleEvents = ({ result, editor }) => { + const resolvedById = editor?.options?.user?.id; const resolvedByEmail = editor?.options?.user?.email; const resolvedByName = editor?.options?.user?.name; - /** @type {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} */ + /** @type {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedById?: string, resolvedByEmail?: string, resolvedByName?: string }>} */ const events = []; for (const entry of result.receipt.removedChangeIds) { - events.push({ type: 'trackedChange', event: 'resolve', changeId: entry.id, resolvedByEmail, resolvedByName }); + events.push({ + type: 'trackedChange', + event: 'resolve', + changeId: entry.id, + resolvedById, + resolvedByEmail, + resolvedByName, + }); } for (const child of result.receipt.affectedChildren) { - events.push({ type: 'trackedChange', event: 'resolve', changeId: child.changeId, resolvedByEmail, resolvedByName }); + events.push({ + type: 'trackedChange', + event: 'resolve', + changeId: child.changeId, + resolvedById, + resolvedByEmail, + resolvedByName, + }); } return events; }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js index 9f5a719d84..cf14ff45cf 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js @@ -88,6 +88,47 @@ describe('decideTrackedChanges overlap behavior', () => { }); }); + it('accept insertion by id removes every same-id segment even when attrs differ across split text nodes', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'before ' }, + { + text: 'N', + marks: [ + { + markType: TrackInsertMarkName, + attrs: markAttrs({ + id: 'ins-split', + author: SAME_USER.name, + authorEmail: SAME_USER.email, + }), + }, + ], + }, + { text: 'EW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-split') }] }, + { text: ' after' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'ins-split' }, + }); + + expect(result.ok).toBe(true); + const nextState = state.apply(result.tr); + expect(nextState.doc.textContent).toBe('before NEW after'); + nextState.doc.nodesBetween(0, nextState.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackInsertMarkName); + } + }); + }); + it('reject insertion by id removes inserted content atomically', () => { const schema = createReviewGraphTestSchema(); const { state } = stateFromTrackedSpans({ diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js index f7e4df66d5..57e2580587 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -32,6 +32,10 @@ import { Slice, Fragment } from 'prosemirror-model'; * @property {TrackedEditIntentUser} user * @property {string} date * @property {string} [replacementGroupHint] + * @property {boolean} [probeForDeletionSpan] When true, the compiler may + * probe for an adjacent tracked-delete span and move the insertion to + * after it. Single-step user replace turns this on; multi-step transactions + * leave it off so each granular op lands at its own position. */ /** diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js index 5fa7f82689..9bd91d2d91 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -1,13 +1,12 @@ // @ts-check +import { normalizeActorEmail, normalizeActorId, normalizeActorName } from '@superdoc/common'; + /** * Identity helpers for the review graph. * - * Same-user behavior requires high-confidence identity. A trusted author email - * match on both the current editor user and the change author's stored email - * is the only signal that returns `same-user`. Every other combination — - * missing email on either side, only display-name match, imported author - * strings without a trusted email, or mismatched email — returns a - * different-user classification. + * Same-user behavior requires high-confidence identity. Actor ids are the + * canonical principal when both sides provide them. Legacy/imported content + * still falls back to trusted author-email matching when ids are absent. */ /** @@ -16,16 +15,29 @@ * @returns {string} */ export const normalizeEmail = (value) => { - if (typeof value !== 'string') return ''; - const trimmed = value.trim(); - if (!trimmed) return ''; - return trimmed.toLowerCase(); + return normalizeActorEmail(value); +}; + +/** + * Trim and lowercase a display-name value. Anything not a string normalizes to ''. + * @param {unknown} value + * @returns {string} + */ +export const normalizeName = (value) => { + return normalizeActorName(value); +}; + +const normalizeImportedAuthorName = (value) => { + const normalized = normalizeName(value).replace(/\s+\(imported\)$/, ''); + return normalized === 'undefined' || normalized === 'null' ? '' : normalized; }; /** * @typedef {Object} UserIdentity + * @property {string} id normalized actor id, '' when unknown. * @property {string} email normalized email, '' when unknown. * @property {string} name display name (may be empty). + * @property {boolean} hasId true when normalized id is non-empty. * @property {boolean} hasEmail true when normalized email is non-empty. * @property {string} [importedAuthor] imported author provenance, when present. */ @@ -35,14 +47,15 @@ export const normalizeEmail = (value) => { * Tolerates a missing editor / options / user so callers can pass a partial * editor (or a snapshot for tests). * - * @param {{ options?: { user?: { name?: unknown, email?: unknown } } } | null | undefined} editor + * @param {{ options?: { user?: { id?: unknown, name?: unknown, email?: unknown } } } | null | undefined} editor * @returns {UserIdentity} */ export const getCurrentUserIdentity = (editor) => { const user = editor?.options?.user ?? null; + const id = normalizeActorId(user?.id); const email = normalizeEmail(user?.email); const name = typeof user?.name === 'string' ? user.name : ''; - return { email, name, hasEmail: email.length > 0 }; + return { id, email, name, hasId: id.length > 0, hasEmail: email.length > 0 }; }; /** @@ -53,15 +66,30 @@ export const getCurrentUserIdentity = (editor) => { * @returns {UserIdentity} */ export const getChangeAuthorIdentity = (changeOrAttrs) => { - if (!changeOrAttrs) return { email: '', name: '', hasEmail: false }; + if (!changeOrAttrs) return { id: '', email: '', name: '', hasId: false, hasEmail: false }; // Accept either { attrs: {...} }, { mark: { attrs } }, or a flat attrs map. const attrs = changeOrAttrs.attrs ?? changeOrAttrs.mark?.attrs ?? changeOrAttrs; + const id = normalizeActorId(attrs?.authorId); const email = normalizeEmail(attrs?.authorEmail); const name = typeof attrs?.author === 'string' ? attrs.author : ''; const importedAuthor = typeof attrs?.importedAuthor === 'string' ? attrs.importedAuthor : ''; - return { email, name, hasEmail: email.length > 0, importedAuthor }; + /** @type {UserIdentity} */ + const identity = { id, email, name, hasId: id.length > 0, hasEmail: email.length > 0 }; + if (importedAuthor) identity.importedAuthor = importedAuthor; + return identity; +}; + +const hasImportedAuthorConflict = ({ currentUser, change }) => { + const importedAuthorName = normalizeImportedAuthorName(change?.importedAuthor); + const currentName = normalizeName(currentUser?.name); + const changeName = normalizeName(change?.name); + + if (!importedAuthorName || !currentName || !changeName) return false; + if (importedAuthorName === currentName) return false; + if (changeName === currentName) return false; + return true; }; /** @@ -77,8 +105,9 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { /** * Classify ownership between the current editor user and a change author. * - * Rules (per plan): - * - normalized authorEmail match is high-confidence => `same-user`. + * Rules: + * - when both sides provide actor ids, id match is authoritative. + * - otherwise, normalized authorEmail match is high-confidence => `same-user`. * - display name alone is never same-user. * - missing current user email is `unknown-current-user`. * - missing change author email is `unknown-change-author`. @@ -94,28 +123,21 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { * @returns {OwnershipClassification} */ export const classifyOwnership = ({ currentUser, change }) => { - const cur = currentUser ?? { email: '', name: '', hasEmail: false }; - const auth = change ?? { email: '', name: '', hasEmail: false }; + const cur = currentUser ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const auth = change ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + + if (hasImportedAuthorConflict({ currentUser: cur, change: auth })) { + return 'conflicting'; + } + + if (cur.hasId && auth.hasId) { + return cur.id === auth.id ? 'same-user' : 'different-user'; + } if (!cur.hasEmail) return 'unknown-current-user'; if (!auth.hasEmail) return 'unknown-change-author'; if (cur.email === auth.email) { - // Same email but obviously different display name pattern is still - // 'same-user' — display name is not a security signal. Only flag - // `conflicting` if the change carries an explicit `importedAuthor` - // mismatch with a different display, which is an import-provenance - // signal that should NOT be treated as ordinary same-user refinement. - if ( - typeof change?.importedAuthor === 'string' && - change.importedAuthor.trim() && - cur.name && - change.importedAuthor.trim().toLowerCase() !== cur.name.trim().toLowerCase() && - change.name && - change.name !== cur.name - ) { - return 'conflicting'; - } return 'same-user'; } @@ -130,3 +152,55 @@ export const classifyOwnership = ({ currentUser, change }) => { * @returns {boolean} */ export const isSameUserHighConfidence = (classification) => classification === 'same-user'; + +/** + * Refinement is slightly more permissive than review ownership. When neither + * side carries actor ids or emails, legacy anonymous typing still coalesces + * into the same logical change only when the stored no-email author is + * actually unattributed, or when display names match. + * + * @param {{ currentUser?: UserIdentity, change?: UserIdentity }} input + * @returns {boolean} + */ +export const matchesSameUserRefinement = ({ currentUser, change }) => { + const cur = currentUser ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const auth = change ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const classification = classifyOwnership({ currentUser: cur, change: auth }); + if (isSameUserHighConfidence(classification)) return true; + + if (!cur.hasId && !auth.hasId && !cur.hasEmail && !auth.hasEmail) { + const changeName = normalizeName(auth.name) || normalizeImportedAuthorName(auth.importedAuthor); + if (!changeName) return true; + const currentName = normalizeName(cur.name); + return Boolean(currentName && currentName === changeName); + } + + return false; +}; + +/** + * Imported/no-email insertions predate reliable authorEmail metadata. They may + * collapse on delete only when the mark is truly unattributed, or when its + * no-email display name matches the current user. A named different author + * with no email is still different-user review state and must be protected. + * + * @param {{ currentUser?: { id?: unknown, name?: unknown, email?: unknown }, insertionAttrs?: Record | null | undefined }} input + * @returns {boolean} + */ +export const shouldCollapseNoEmailInsertion = ({ currentUser, insertionAttrs }) => { + const authorId = normalizeActorId(insertionAttrs?.authorId); + const currentId = normalizeActorId(currentUser?.id); + if (authorId || currentId) { + return Boolean(authorId && currentId && authorId === currentId); + } + + const authorEmail = normalizeEmail(insertionAttrs?.authorEmail); + if (authorEmail) return false; + + const authorName = + normalizeName(insertionAttrs?.author) || normalizeImportedAuthorName(insertionAttrs?.importedAuthor); + if (!authorName) return true; + + const currentName = normalizeName(currentUser?.name); + return Boolean(currentName && currentName === authorName); +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js index d73e9d0908..0c7cc59660 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js @@ -5,6 +5,7 @@ import { getChangeAuthorIdentity, classifyOwnership, isSameUserHighConfidence, + matchesSameUserRefinement, } from './identity.js'; describe('review-model/identity', () => { @@ -25,61 +26,111 @@ describe('review-model/identity', () => { describe('getCurrentUserIdentity', () => { it('extracts identity from a configured editor', () => { - const editor = { options: { user: { name: 'Alice', email: 'Alice@example.com' } } }; + const editor = { options: { user: { id: 'alice-id', name: 'Alice', email: 'Alice@example.com' } } }; expect(getCurrentUserIdentity(editor)).toEqual({ + id: 'alice-id', email: 'alice@example.com', name: 'Alice', + hasId: true, hasEmail: true, }); }); it('returns empty identity for missing editor/user', () => { - expect(getCurrentUserIdentity(undefined)).toEqual({ email: '', name: '', hasEmail: false }); - expect(getCurrentUserIdentity({ options: {} })).toEqual({ email: '', name: '', hasEmail: false }); + expect(getCurrentUserIdentity(undefined)).toEqual({ id: '', email: '', name: '', hasId: false, hasEmail: false }); + expect(getCurrentUserIdentity({ options: {} })).toEqual({ + id: '', + email: '', + name: '', + hasId: false, + hasEmail: false, + }); }); }); describe('getChangeAuthorIdentity', () => { it('reads from a raw mark', () => { - const mark = { attrs: { author: 'Bob', authorEmail: 'BOB@example.com' } }; - expect(getChangeAuthorIdentity(mark)).toEqual({ email: 'bob@example.com', name: 'Bob', hasEmail: true }); + const mark = { attrs: { author: 'Bob', authorId: 'bob-id', authorEmail: 'BOB@example.com' } }; + expect(getChangeAuthorIdentity(mark)).toEqual({ + id: 'bob-id', + email: 'bob@example.com', + name: 'Bob', + hasId: true, + hasEmail: true, + }); }); it('reads from flat attrs', () => { - expect(getChangeAuthorIdentity({ author: 'Carol', authorEmail: 'carol@example.com' })).toEqual({ + expect( + getChangeAuthorIdentity({ author: 'Carol', authorId: 'carol-id', authorEmail: 'carol@example.com' }), + ).toEqual({ + id: 'carol-id', email: 'carol@example.com', name: 'Carol', + hasId: true, hasEmail: true, }); }); it('returns empty for null', () => { - expect(getChangeAuthorIdentity(null)).toEqual({ email: '', name: '', hasEmail: false }); + expect(getChangeAuthorIdentity(null)).toEqual({ id: '', email: '', name: '', hasId: false, hasEmail: false }); }); }); describe('classifyOwnership', () => { - const alice = { email: 'alice@example.com', name: 'Alice', hasEmail: true }; - const bob = { email: 'bob@example.com', name: 'Bob', hasEmail: true }; + const alice = { id: 'alice-id', email: 'alice@example.com', name: 'Alice', hasId: true, hasEmail: true }; + const bob = { id: 'bob-id', email: 'bob@example.com', name: 'Bob', hasId: true, hasEmail: true }; + it('prefers actor ids over matching emails', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...bob, email: alice.email }, + }), + ).toBe('different-user'); + }); + it('treats matching actor ids as same-user even when emails differ', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...alice, email: 'alias@example.com' }, + }), + ).toBe('same-user'); + }); it('returns same-user for matching emails', () => { - expect(classifyOwnership({ currentUser: alice, change: { ...alice } })).toBe('same-user'); + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + }), + ).toBe('same-user'); }); it('returns different-user for distinct emails', () => { - expect(classifyOwnership({ currentUser: alice, change: bob })).toBe('different-user'); + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: bob.email, name: 'Bob', hasId: false, hasEmail: true }, + }), + ).toBe('different-user'); }); it('returns unknown-current-user when current email is missing', () => { - expect(classifyOwnership({ currentUser: { email: '', name: '', hasEmail: false }, change: bob })).toBe( - 'unknown-current-user', - ); + expect( + classifyOwnership({ + currentUser: { id: '', email: '', name: '', hasId: false, hasEmail: false }, + change: { id: '', email: bob.email, name: 'Bob', hasId: false, hasEmail: true }, + }), + ).toBe('unknown-current-user'); }); it('returns unknown-change-author when change email is missing', () => { - expect(classifyOwnership({ currentUser: alice, change: { email: '', name: 'B', hasEmail: false } })).toBe( - 'unknown-change-author', - ); + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: '', name: 'B', hasId: false, hasEmail: false }, + }), + ).toBe('unknown-change-author'); }); it('display-name-only never matches', () => { expect( classifyOwnership({ - currentUser: { email: '', name: 'Alice', hasEmail: false }, - change: { email: '', name: 'Alice', hasEmail: false }, + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, }), ).toBe('unknown-current-user'); }); @@ -98,5 +149,26 @@ describe('review-model/identity', () => { expect(isSameUserHighConfidence('unknown-change-author')).toBe(false); expect(isSameUserHighConfidence('conflicting')).toBe(false); }); + it('allows legacy anonymous refinement only when names match or are absent', () => { + expect( + matchesSameUserRefinement({ + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + }), + ).toBe(true); + expect( + matchesSameUserRefinement({ + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { + id: '', + email: '', + name: '', + hasId: false, + hasEmail: false, + importedAuthor: 'Mallory (imported)', + }, + }), + ).toBe(false); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js index 7f2d59d876..2a8c3cb05c 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js @@ -221,6 +221,7 @@ const canonicalSourceIdsFromObject = (obj) => { * @property {string} importedAuthor Imported author provenance. * @property {string} origin Optional import origin. * @property {string} author Display name. + * @property {string} authorId Stable actor id. * @property {string} authorEmail Author email (not lowercased here). * @property {string} authorImage Author image url/value. * @property {string} date Created/modified ISO date. @@ -304,6 +305,7 @@ export const readTrackedAttrs = (markOrAttrs, markName) => { importedAuthor: stringAttr(attrs.importedAuthor), origin: explicitOrigin, author: stringAttr(attrs.author), + authorId: stringAttr(attrs.authorId), authorEmail: stringAttr(attrs.authorEmail), authorImage: stringAttr(attrs.authorImage), date: stringAttr(attrs.date), @@ -357,6 +359,7 @@ export const normalizedAttrsEqual = (a, b) => { if (a.replacementSideId !== b.replacementSideId) return false; if (a.overlapParentId !== b.overlapParentId) return false; if (a.author !== b.author) return false; + if (a.authorId !== b.authorId) return false; if (a.authorEmail !== b.authorEmail) return false; if (a.authorImage !== b.authorImage) return false; if (a.date !== b.date) return false; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index d6a7d0b15d..0f46f4ea47 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -20,7 +20,7 @@ */ import { Slice, Fragment } from 'prosemirror-model'; -import { ReplaceStep } from 'prosemirror-transform'; +import { ReplaceStep, Mapping } from 'prosemirror-transform'; import { v4 as uuidv4 } from 'uuid'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; @@ -30,7 +30,20 @@ import { getCurrentUserIdentity, getChangeAuthorIdentity, isSameUserHighConfidence, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, } from './identity.js'; +import { findMarkPosition } from '../trackChangesHelpers/documentHelpers.js'; +import { markInsertion } from '../trackChangesHelpers/markInsertion.js'; +import { + createMarkSnapshot, + getTypeName, + hasMatchingMark, + isTrackFormatNoOp, + markSnapshotMatchesStepMark, + upsertMarkSnapshotByType, +} from '../trackChangesHelpers/markSnapshotHelpers.js'; +import { getLiveInlineMarksInRange } from '../trackChangesHelpers/getLiveInlineMarksInRange.js'; /** * @typedef {import('./edit-intent.js').TrackedEditIntent} TrackedEditIntent @@ -62,11 +75,17 @@ import { * @property {Array<{ from: string, to: string }>} remappedChangeIds * @property {SelectionHint} [selection] * @property {GraphDiagnostic[]} [diagnostics] - * @property {import('prosemirror-model').Mark} [insertedMark] + * @property {import('prosemirror-model').Mark | null} [insertedMark] + * @property {import('prosemirror-model').Mark | null} [deletionMark] * @property {import('prosemirror-model').Mark[]} [deletionMarks] * @property {import('prosemirror-model').Mark[]} [formatMarks] * @property {number} [insertedFrom] * @property {number} [insertedTo] + * @property {number} [deletedFrom] + * @property {number} [deletedTo] + * @property {import('prosemirror-model').Node[]} [insertedNodes] + * @property {import('prosemirror-model').Node[]} [deletionNodes] + * @property {import('prosemirror-transform').ReplaceStep | null} [insertedStep] */ /** @@ -188,6 +207,28 @@ const classifySegment = (ctx, segment) => { return isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; }; +/** + * Permissive same-user check for refinement (extending the current user's + * own contiguous edit). Differs from the high-confidence `classifySegment` + * gate used for overlap parent decisions: refinement is allowed when the + * stored authorEmail matches the current user's normalized email — including + * when both sides have no email at all (default unidentified user typing). + * + * Permission ownership and overlap parent decisions still require the + * high-confidence `classifySegment` path; this helper is only for "is this + * the same logical author for the purpose of coalescing contiguous edits". + * + * @param {*} ctx + * @param {*} segment + * @returns {boolean} + */ +const isSameUserForRefinement = (ctx, segment) => { + return matchesSameUserRefinement({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segment?.attrs ?? {}), + }); +}; + const findSegmentAt = (ctx, pos) => { // Prefer the segment that covers `pos` strictly (pos in [from, to)). When // `pos` sits exactly at the right edge of a segment, also consider it as a @@ -211,6 +252,7 @@ const makeInsertMark = (ctx, { id, overlapParentId = '', replacementGroupId = '' const attrs = { id, author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', authorEmail: ctx.intent.user.email || '', authorImage: ctx.intent.user.image || '', date: ctx.intent.date, @@ -234,6 +276,7 @@ const makeDeleteMark = (ctx, { id, overlapParentId = '', replacementGroupId = '' const attrs = { id, author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', authorEmail: ctx.intent.user.email || '', authorImage: ctx.intent.user.image || '', date: ctx.intent.date, @@ -309,14 +352,18 @@ const compileTextInsert = (ctx, intent) => { !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; // Same-user refinement targets: own insertion that strictly contains `at`, - // OR an own-insertion edge we are adjacent to. Adjacent same-user own - // insertion still refines the same id (extend the run). + // OR an own-insertion edge we are adjacent to. Refinement uses the + // permissive `isSameUserForRefinement` check so contiguous typing by the + // default unidentified user (no email) still coalesces into one id — + // matching the legacy `findTrackedMarkBetween({ authorEmail: '' })` + // behavior. Permission and overlap-parent decisions still go through the + // high-confidence `classifySegment` gate. const refinementTarget = - overlapParent && overlapParent.side === SegmentSide.Inserted && classifySegment(ctx, overlapParent) === 'same-user' + overlapParent && overlapParent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, overlapParent) ? overlapParent : boundaryAdjacent && boundaryAdjacent.side === SegmentSide.Inserted && - classifySegment(ctx, boundaryAdjacent) === 'same-user' + isSameUserForRefinement(ctx, boundaryAdjacent) ? boundaryAdjacent : null; @@ -379,6 +426,12 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = if (create) ctx.createdChangeIds.push(changeId); else if (update) ctx.updatedChangeIds.push(changeId); + /** @type {Array} */ + const insertedNodes = []; + ctx.tr.doc.nodesBetween(insertedFrom, insertedTo, (node) => { + if (node.isInline) insertedNodes.push(node); + }); + return { ok: true, tr: ctx.tr, @@ -390,6 +443,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = insertedMark: insertMark, insertedFrom, insertedTo, + insertedNodes, }; }; @@ -438,7 +492,9 @@ const compileTextDelete = (ctx, intent) => { removedChangeIds: ctx.removedChangeIds, remappedChangeIds: ctx.remappedChangeIds, selection: { kind: 'near', pos: intent.from, bias: -1 }, + deletionMark: result.deletionMarks[0] || null, deletionMarks: result.deletionMarks, + deletionNodes: result.deletionNodes, }; }; @@ -449,8 +505,8 @@ const compileTextDelete = (ctx, intent) => { * @param {*} ctx * @param {number} from * @param {number} to - * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean }} options - * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionId: string } | TrackedEditFailure} + * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean, reassignExistingDeletions?: boolean }} options + * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionNodes: import('prosemirror-model').Node[], deletionId: string, mintedThisCall: boolean } | TrackedEditFailure} */ const applyTrackedDelete = ( ctx, @@ -462,16 +518,31 @@ const applyTrackedDelete = ( sharedDeletionId, recordSharedDeletionId = false, recordCollapsedIds = true, + reassignExistingDeletions = false, }, ) => { /** @type {Array} */ const deletionMarks = []; + /** @type {Array} */ + const deletionNodes = []; // Walk inline leaf nodes and act per node. We never mutate while iterating // — collect operations first, then apply in reverse position order so // earlier positions remain stable. - /** @type {Array<{ kind: 'collapse'|'reassign'|'mark-delete'|'noop', from: number, to: number, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string }>} */ + /** @type {Array<{ kind: 'collapse'|'mark-delete'|'reassign'|'noop', from: number, to: number, node?: import('prosemirror-model').Node, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string, existingDeleteMarks?: Array }>} */ const ops = []; + // Imported-insertion collapse rule (plan §4): no-email imported insertions + // collapse only when they are truly unattributed, or when their no-email + // display name matches the current user. Named different authors with no + // email remain protected review state. + const isImportedOwnInsertion = (mark) => { + if (!mark) return false; + return shouldCollapseNoEmailInsertion({ + currentUser: ctx.intent.user, + insertionAttrs: mark.attrs, + }); + }; + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { if (!node.isInline || !node.isLeaf) return; if (node.type.name.includes('table')) return; @@ -484,20 +555,42 @@ const applyTrackedDelete = ( if (insertMark) { const segmentAtPos = ctx.graph.overlapAt(pos)[0] ?? null; - const ownership = classifySegment(ctx, segmentAtPos ?? { attrs: insertMark.attrs }); - if (ownership === 'same-user') { + const classification = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segmentAtPos?.attrs ?? insertMark.attrs), + }); + const ownership = isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; + if (ownership === 'same-user' || isImportedOwnInsertion(insertMark)) { // Own insertion → collapse (remove proposed content). ops.push({ kind: 'collapse', from: segFrom, to: segTo, changeId: insertMark.attrs.id }); return; } // Different-user inserted content → child trackDelete with overlapParentId. const parentId = insertMark.attrs.id; - ops.push({ kind: 'mark-delete', from: segFrom, to: segTo, parentId, parentSide: SegmentSide.Inserted }); + ops.push({ + kind: 'mark-delete', + from: segFrom, + to: segTo, + node, + parentId, + parentSide: SegmentSide.Inserted, + }); return; } if (existingDelete) { const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); + const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); + if (reassignExistingDeletions) { + ops.push({ + kind: 'reassign', + from: segFrom, + to: segTo, + node, + existingDeleteMarks: allExistingDeletes, + }); + return; + } if (ownership === 'same-user') { // Inside own deletion → no semantic change (preserve original). ops.push({ kind: 'noop', from: segFrom, to: segTo }); @@ -508,6 +601,7 @@ const applyTrackedDelete = ( kind: 'mark-delete', from: segFrom, to: segTo, + node, parentId: existingDelete.attrs.id, parentSide: SegmentSide.Deleted, }); @@ -515,7 +609,7 @@ const applyTrackedDelete = ( } // Live content. - ops.push({ kind: 'mark-delete', from: segFrom, to: segTo }); + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo, node }); }); if (!ops.length) { @@ -541,6 +635,29 @@ const applyTrackedDelete = ( continue; } if (op.kind === 'noop') continue; + if (op.kind === 'reassign') { + // Replacement over existing deletion: reassign deletion id to the new + // deletion mark so the new replacement encloses the prior delete. + const mark = makeDeleteMark(ctx, { + id: deletionId, + overlapParentId: '', + replacementGroupId, + replacementSideId, + }); + try { + for (const m of op.existingDeleteMarks ?? []) ctx.tr.removeMark(op.from, op.to, m); + ctx.tr.addMark(op.from, op.to, mark); + deletionMarks.push(mark); + if (op.node) deletionNodes.push(op.node); + if (!mintedThisCall) { + if (!sharedDeletionId || recordSharedDeletionId) ctx.createdChangeIds.push(deletionId); + mintedThisCall = true; + } + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'addMark failed.'); + } + continue; + } if (op.kind === 'mark-delete') { const mark = makeDeleteMark(ctx, { id: deletionId, @@ -551,6 +668,7 @@ const applyTrackedDelete = ( try { ctx.tr.addMark(op.from, op.to, mark); deletionMarks.push(mark); + if (op.node) deletionNodes.push(op.node); if (!mintedThisCall) { if (!sharedDeletionId || recordSharedDeletionId) ctx.createdChangeIds.push(deletionId); mintedThisCall = true; @@ -572,7 +690,7 @@ const applyTrackedDelete = ( } } - return { ok: true, deletionMarks, deletionId }; + return { ok: true, deletionMarks, deletionNodes, deletionId, mintedThisCall }; }; // --------------------------------------------------------------------------- @@ -664,54 +782,202 @@ const compileTextReplace = (ctx, intent) => { return applyInsert(ctx, intent.from, sanitizedSlice, insertMark, insertId, { create: true }); } + // Different-user nested case: the replacement happens inside another author's + // open review item. Each side must remain independently reviewable, so use + // distinct ids and the exact edit location for the insertion. const replacementParentId = getReplacementParentId(ctx, segments); - // Paired vs independent: in paired mode share one id between insert+delete - // sides so a top-level replacement projects as one logical graph change. - // A replacement nested inside another author's open review item must keep - // each side separately reviewable, so those child sides intentionally use - // distinct ids even when the caller's default replacement mode is paired. + + // Ordinary live replacement: use the proven "insert after deleted range" + // algorithm so existing product behavior is preserved (paragraph order on + // multi-paragraph replacements, SD-3044 shared-anchor rewrites, + // accept-side text identity). The compiler is the single semantic center; + // markInsertion / markDeletion are used as low-level primitives. + return compileOrdinaryTextReplace(ctx, intent, sanitizedSlice, replacementParentId); +}; + +/** + * Ordinary text replacement (no own-inserted refinement, no own-deletion + * preservation). Mirrors the legacy `replaceStep` algorithm verbatim and is + * the only ordinary-replacement implementation: there is no dual semantic + * path. The compiler owns the decision tree; the legacy markInsertion / + * markDeletion helpers are imported as low-level primitives. + * + * Order of operations: + * 1. Optionally probe for an adjacent tracked-delete span (single-step + * user replace only; multi-step transactions don't probe). + * 2. In a throwaway temp transaction, insert the original slice at the + * chosen position. Fall back to Slice.maxOpen on failure to make + * paste-into-textblock cases merge inline. + * 3. Mark the inserted range with the insertion mark (refining same-user + * adjacent ids if present) in the temp tr. + * 4. Extract the marked slice and apply it as a single condensed + * ReplaceStep on ctx.tr (so the tracked-transaction stays single-step). + * 5. Run applyTrackedDelete on the original range to mark deletion (this + * may collapse own insertions, reassign existing deletions, etc.). + * 6. Map insertedTo through the delete-induced mapping so the selection / + * meta still points after the inserted content. + * + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-replace' }} intent + * @param {import('prosemirror-model').Slice} sanitizedSlice + * @param {string} replacementParentId + * @returns {TrackedEditResult} + */ +const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementParentId) => { + // In paired mode share one id between insert/delete sides so a top-level + // replacement projects as one logical graph change. A replacement nested + // inside another author's open review item must keep each side separately + // reviewable, so those child sides intentionally use distinct ids even when + // the caller's default replacement mode is paired. const shouldPairReplacement = intent.replacements === 'paired' && !replacementParentId; const sharedId = shouldPairReplacement ? intent.replacementGroupHint || uuidv4() : null; const replacementGroupId = sharedId ?? ''; - const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; - // Step 1 — tracked delete (collapses own insertions, marks live/other content). + // 1. Probe for adjacent tracked-delete span at intent.to - 1 (legacy + // behavior). Only applies for single-step user actions — plan-engine + // multi-step rewrites must not probe. + let positionTo = intent.to; + if (intent.from !== intent.to && intent.probeForDeletionSpan) { + const probePos = Math.max(intent.from, intent.to - 1); + const deletionSpan = findMarkPosition(ctx.tr.doc, probePos, TrackDeleteMarkName); + if (deletionSpan && deletionSpan.to > positionTo) positionTo = deletionSpan.to; + } + + // 2. Build a temp insertion in a throwaway transaction so we can read the + // inserted positions and the marked slice. We then condense the result + // into a single ReplaceStep on ctx.tr. + const baseParentIsTextblock = ctx.tr.doc.resolve(positionTo).parent?.isTextblock; + const shouldPreferInlineInsertion = intent.from === intent.to && baseParentIsTextblock; + + const tryTempInsert = (slice) => { + const tempTr = ctx.state.apply(ctx.tr).tr; + const isEmptySlice = !slice || slice.content.size === 0; + try { + tempTr.replaceRange(positionTo, positionTo, slice ?? Slice.empty); + } catch { + return null; + } + if (!tempTr.docChanged && !isEmptySlice) return null; + const insertedFrom = tempTr.mapping.map(positionTo, -1); + const insertedTo = tempTr.mapping.map(positionTo, 1); + if (insertedFrom === insertedTo) return { tempTr, insertedFrom, insertedTo }; + if (shouldPreferInlineInsertion && !tempTr.doc.resolve(insertedFrom).parent?.isTextblock) return null; + return { tempTr, insertedFrom, insertedTo }; + }; + + let insertion = null; + if (sanitizedSlice.content.size) { + const openSlice = Slice.maxOpen(sanitizedSlice.content, true); + insertion = tryTempInsert(sanitizedSlice) || tryTempInsert(openSlice); + if (!insertion) { + return failure('CAPABILITY_UNAVAILABLE', 'replacement slice could not be inserted into the document.'); + } + } + + /** @type {import('prosemirror-model').Mark | null} */ + let insertedMark = null; + /** @type {import('prosemirror-model').Slice} */ + let trackedInsertedSlice = Slice.empty; + /** @type {Array} */ + const insertedNodes = []; + + if (insertion && insertion.insertedFrom !== insertion.insertedTo) { + const { tempTr, insertedFrom, insertedTo } = insertion; + // Use the legacy markInsertion primitive so id reuse / refinement matches + // existing behavior exactly. Compiler-specific overlap fields + // (overlapParentId, replacementGroupId, replacementSideId) are layered on + // afterward. + const forcedInsertId = sharedId || (replacementParentId ? uuidv4() : undefined); + insertedMark = markInsertion({ + tr: tempTr, + from: insertedFrom, + to: insertedTo, + user: ctx.intent.user, + date: ctx.intent.date, + id: forcedInsertId, + }); + if (!insertedMark) { + return failure('PRECONDITION_FAILED', 'Failed to create tracked insertion mark for replacement.'); + } + if (replacementParentId || replacementGroupId) { + const overlayMark = makeInsertMark(ctx, { + id: insertedMark.attrs.id, + overlapParentId: replacementParentId, + replacementGroupId, + replacementSideId: sharedId ? `${sharedId}#inserted` : '', + }); + tempTr.removeMark(insertedFrom, insertedTo, insertedMark); + tempTr.addMark(insertedFrom, insertedTo, overlayMark); + insertedMark = overlayMark; + } + const insertId = /** @type {import('prosemirror-model').Mark} */ (insertedMark).attrs.id; + if (!ctx.createdChangeIds.includes(insertId) && !ctx.updatedChangeIds.includes(insertId)) { + ctx.createdChangeIds.push(insertId); + } + trackedInsertedSlice = tempTr.doc.slice(insertedFrom, insertedTo); + tempTr.doc.nodesBetween(insertedFrom, insertedTo, (node) => { + if (node.isInline) insertedNodes.push(node); + }); + } + + // 3. Apply the condensed insertion step to ctx.tr. + let insertedFromAbs = positionTo; + let insertedToAbs = positionTo; + let insertedLength = 0; + /** @type {import('prosemirror-transform').ReplaceStep | null} */ + let condensedStep = null; + if (trackedInsertedSlice && trackedInsertedSlice.content.size) { + const stepIndexBeforeCondensed = ctx.tr.steps.length; + condensedStep = new ReplaceStep(positionTo, positionTo, trackedInsertedSlice, false); + if (ctx.tr.maybeStep(condensedStep).failed) { + return failure('INVALID_TARGET', 'condensed insertion step failed to apply.'); + } + // Record the actual inserted range using just the condensed step's map. + const condensedMap = ctx.tr.steps[stepIndexBeforeCondensed].getMap(); + insertedFromAbs = condensedMap.map(positionTo, -1); + insertedToAbs = condensedMap.map(positionTo, 1); + insertedLength = insertedToAbs - insertedFromAbs; + } + + // 4. Apply tracked delete on the original range. The range positions are + // unaffected by the insertion (insertion happened at positionTo which is + // >= intent.to). The delete may collapse own insertions inside the + // range, shifting the doc — we map the inserted position through the + // delete-induced map after. + /** @type {Array} */ + let deletionMarks = []; + /** @type {Array} */ + let deletionNodes = []; + /** @type {import('prosemirror-model').Mark | null} */ + let deletionMark = null; + if (intent.from !== intent.to) { + const stepsBefore = ctx.tr.steps.length; const delResult = applyTrackedDelete(ctx, intent.from, intent.to, { replacementGroupId, - replacementSideId, + replacementSideId: sharedId ? `${sharedId}#deleted` : '', sharedDeletionId: sharedId, + reassignExistingDeletions: Boolean(sharedId), }); if (delResult.ok === false) return delResult; - if (sharedId && delResult.deletionMarks?.length) { - ctx.createdChangeIds.push(sharedId); + deletionMarks = delResult.deletionMarks; + deletionMark = delResult.deletionMarks[0] || null; + deletionNodes = delResult.deletionNodes; + // Map inserted positions through delete steps so collapses don't strand + // them past stale offsets. + if (insertedLength > 0) { + const delMapping = new Mapping(); + for (let i = stepsBefore; i < ctx.tr.steps.length; i += 1) { + delMapping.appendMap(ctx.tr.steps[i].getMap()); + } + insertedFromAbs = delMapping.map(insertedFromAbs, 1); + insertedToAbs = delMapping.map(insertedToAbs, 1); } } - // Step 2 — tracked insert at the original `from`. Recompute graph context - // after the deletion so own-insertion collapse adjustments don't push the - // insertion past the intended cursor. - if (sanitizedSlice.content.size) { - // We must re-resolve the insertion position because collapsed - // own-insertion content shrinks the doc. - const insertId = sharedId ?? intent.replacementGroupHint ?? uuidv4(); - const insertMark = makeInsertMark(ctx, { - id: insertId, - overlapParentId: replacementParentId, - replacementGroupId, - replacementSideId: sharedId ? `${sharedId}#inserted` : '', - }); - const insertPos = clampToDocSize(ctx.tr.doc.content.size, intent.from); - const insertResult = applyInsert(ctx, insertPos, sanitizedSlice, insertMark, insertId, { - create: sharedId ? false : true, - update: sharedId ? true : false, - }); - if (!insertResult.ok) return insertResult; - return { - ...insertResult, - selection: { kind: 'near', pos: insertResult.insertedTo, bias: 1 }, - }; - } + /** @type {SelectionHint} */ + const selection = + insertedLength > 0 ? { kind: 'near', pos: insertedToAbs, bias: 1 } : { kind: 'near', pos: intent.from, bias: -1 }; return { ok: true, @@ -720,7 +986,15 @@ const compileTextReplace = (ctx, intent) => { updatedChangeIds: ctx.updatedChangeIds, removedChangeIds: ctx.removedChangeIds, remappedChangeIds: ctx.remappedChangeIds, - selection: { kind: 'near', pos: intent.from, bias: -1 }, + selection, + insertedMark, + insertedFrom: insertedFromAbs, + insertedTo: insertedToAbs, + insertedNodes, + insertedStep: condensedStep, + deletionMark, + deletionMarks, + deletionNodes, }; }; @@ -747,7 +1021,7 @@ const getReplacementParentId = (ctx, segments) => { }; // --------------------------------------------------------------------------- -// format-apply / format-remove (SD-486 folding) +// format-apply / format-remove // --------------------------------------------------------------------------- /** @@ -760,82 +1034,39 @@ const compileFormat = (ctx, intent) => { return failure('CAPABILITY_UNAVAILABLE', `Mark ${intent.mark.type.name} is not a tracked formatting mark.`); } - // Walk segments in range. For each contiguous subrange: - // - if covered by same-user own insertion (or replacement inserted side), - // directly apply/remove the mark (SD-486 fold). - // - if covered by other-user inserted content, defer to trackFormat - // creation. To minimize compiler/legacy duplication we leave this to - // the existing addMarkStep/removeMarkStep helper by returning a hint; - // however since the compiler must drive consistent semantics, we - // directly create the formatting change here over the entire other- - // content range using the same canonical attrs. - // - if mixed structural ranges (paragraph boundaries we can't safely - // model under the tracked text scope), fail closed. - const subranges = computeFormatSubranges(ctx, intent.from, intent.to); - if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses unsupported structural boundary.'); + const subranges = computeFormatLeafRanges(ctx, intent.from, intent.to); + if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses tracked-deleted content.'); const trackFormatType = ctx.schema.marks[TrackFormatMarkName]; - if (!trackFormatType) return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + const needsTrackFormat = subranges.some((range) => !range.fold); + if (!trackFormatType && needsTrackFormat) { + return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + } /** @type {Array} */ const formatMarks = []; + /** @type {string | null} */ + let sharedWid = null; for (const range of subranges) { - if (intent.kind === 'format-apply') { - if (range.fold) { - ctx.tr.addMark(range.from, range.to, intent.mark); - } else { - ctx.tr.addMark(range.from, range.to, intent.mark); - const formatMark = trackFormatType.create({ - id: uuidv4(), - author: ctx.intent.user.name || '', - authorEmail: ctx.intent.user.email || '', - authorImage: ctx.intent.user.image || '', - date: ctx.intent.date, - before: [], - after: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], - sourceId: '', - importedAuthor: '', - revisionGroupId: '', - splitFromId: '', - changeType: CanonicalChangeType.Formatting, - replacementGroupId: '', - replacementSideId: '', - overlapParentId: range.parentId || '', - sourceIds: null, - origin: '', - }); - ctx.tr.addMark(range.from, range.to, formatMark); - formatMarks.push(formatMark); - ctx.createdChangeIds.push(formatMark.attrs.id); - } - } else { - if (range.fold) { - ctx.tr.removeMark(range.from, range.to, intent.mark); - } else { - ctx.tr.removeMark(range.from, range.to, intent.mark); - const formatMark = trackFormatType.create({ - id: uuidv4(), - author: ctx.intent.user.name || '', - authorEmail: ctx.intent.user.email || '', - authorImage: ctx.intent.user.image || '', - date: ctx.intent.date, - before: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], - after: [], - sourceId: '', - importedAuthor: '', - revisionGroupId: '', - splitFromId: '', - changeType: CanonicalChangeType.Formatting, - replacementGroupId: '', - replacementSideId: '', - overlapParentId: range.parentId || '', - sourceIds: null, - origin: '', - }); - ctx.tr.addMark(range.from, range.to, formatMark); - formatMarks.push(formatMark); - ctx.createdChangeIds.push(formatMark.attrs.id); + if (range.fold) { + if (intent.kind === 'format-apply') ctx.tr.addMark(range.from, range.to, intent.mark); + else ctx.tr.removeMark(range.from, range.to, intent.mark); + continue; + } + + const result = applyTrackedFormatRange({ + ctx, + intent, + range, + trackFormatType, + sharedWid, + }); + sharedWid = result.sharedWid; + if (result.formatMark) { + formatMarks.push(result.formatMark); + if (!ctx.createdChangeIds.includes(result.formatMark.attrs.id)) { + ctx.createdChangeIds.push(result.formatMark.attrs.id); } } } @@ -852,68 +1083,235 @@ const compileFormat = (ctx, intent) => { }; /** - * Build the list of contiguous subranges inside [from, to] that share the - * same "format folding" decision. Returns null when the range crosses a - * boundary the compiler refuses to handle (e.g. a non-textblock structural - * node). + * Collect leaf inline formatting ranges. Same-user inserted ranges fold the + * live mark directly into the insertion; all other live/other-user inserted + * ranges use the normal trackFormat snapshot model. Tracked-deleted content + * fails closed because applying visual formatting there is not safely + * representable as an independent review action. * - * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string }> | null} + * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string, node: import('prosemirror-model').Node }> | null} */ -const computeFormatSubranges = (ctx, from, to) => { - /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string }>} */ - const out = []; - let boundaryCrossed = false; - let lastTextBlock = null; +const computeFormatLeafRanges = (ctx, from, to) => { + /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string, node: import('prosemirror-model').Node }>} */ + const ranges = []; + let touchesDeletion = false; ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (touchesDeletion) return false; if (!node.isInline || node.type.name === 'run') return; - if (boundaryCrossed) return false; - // Identify the textblock parent of this inline leaf. - const $pos = ctx.tr.doc.resolve(pos); - const parent = $pos.parent?.type?.name ?? ''; - if (!parent) return; - if (lastTextBlock === null) lastTextBlock = parent; - // We do not consider crossing textblocks unsafe here; PM clips ranges - // to inline content. The compiler refuses only structural marks (handled - // by the SUPPORTED_KINDS check above). const segFrom = Math.max(from, pos); const segTo = Math.min(to, pos + node.nodeSize); if (segFrom >= segTo) return; + const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + if (deleteMark) { + touchesDeletion = true; + return false; + } + const insertMark = node.marks.find((m) => m.type.name === TrackInsertMarkName); if (insertMark) { const ownership = classifySegment(ctx, { attrs: insertMark.attrs }); - if (ownership === 'same-user') { - appendSubrange(out, { from: segFrom, to: segTo, fold: true }); + ranges.push({ + from: segFrom, + to: segTo, + fold: ownership === 'same-user', + ...(ownership === 'same-user' ? {} : { parentId: insertMark.attrs.id }), + node, + }); + return; + } + + ranges.push({ from: segFrom, to: segTo, fold: false, node }); + }); + + if (touchesDeletion) return null; + return ranges; +}; + +/** + * Apply ordinary tracked formatting semantics for one leaf range, preserving + * the previous snapshot behavior while allowing overlap-parent metadata when + * formatting another user's inserted text. + * + * @param {{ + * ctx: *, + * intent: TrackedEditIntent & { kind: 'format-apply'|'format-remove' }, + * range: { from: number, to: number, parentId?: string, node: import('prosemirror-model').Node }, + * trackFormatType: import('prosemirror-model').MarkType, + * sharedWid: string | null, + * }} input + * @returns {{ sharedWid: string | null, formatMark: import('prosemirror-model').Mark | null }} + */ +const applyTrackedFormatRange = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + if (intent.kind === 'format-apply') { + return applyTrackedFormatAdd({ ctx, intent, range, trackFormatType, sharedWid }); + } + return applyTrackedFormatRemove({ ctx, intent, range, trackFormatType, sharedWid }); +}; + +const applyTrackedFormatAdd = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + const liveMarks = getLiveInlineMarksInRange({ + doc: ctx.tr.doc, + from: range.from, + to: range.to, + }); + const existingChangeMark = liveMarks.find((mark) => + [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), + ); + const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? uuidv4()); + + ctx.tr.addMark(range.from, range.to, intent.mark); + + if (!hasMatchingMark(liveMarks, intent.mark)) { + const formatChangeMark = liveMarks.find((mark) => mark.type.name === TrackFormatMarkName); + let after = []; + let before = []; + + if (formatChangeMark) { + const beforeSnapshots = Array.isArray(formatChangeMark.attrs.before) ? formatChangeMark.attrs.before : []; + const afterSnapshots = Array.isArray(formatChangeMark.attrs.after) ? formatChangeMark.attrs.after : []; + const foundBefore = beforeSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, intent.mark, true)); + + if (foundBefore) { + before = beforeSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, intent.mark, true)); + after = afterSnapshots.filter((mark) => getTypeName(mark) !== intent.mark.type.name); } else { - appendSubrange(out, { from: segFrom, to: segTo, fold: false, parentId: insertMark.attrs.id }); + before = [...beforeSnapshots]; + after = upsertMarkSnapshotByType(afterSnapshots, { + type: intent.mark.type.name, + attrs: intent.mark.attrs, + }); } - return; + } else { + const existingMarkOfSameType = liveMarks.find( + (mark) => + mark.type.name === intent.mark.type.name && + ![TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), + ); + before = existingMarkOfSameType + ? [createMarkSnapshot(existingMarkOfSameType.type.name, existingMarkOfSameType.attrs)] + : []; + after = [createMarkSnapshot(intent.mark.type.name, intent.mark.attrs)]; } - const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); - if (deleteMark) { - // Tracked-deleted content: do not modify formatting; legacy addMarkStep - // also skips this. Fail closed to avoid silent drift. - boundaryCrossed = true; - return false; + + if (isTrackFormatNoOp(before, after)) { + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: wid, formatMark: null }; + } + + if (after.length || before.length) { + const formatMark = createTrackFormatMark({ + ctx, + trackFormatType, + id: wid, + before, + after, + parentId: range.parentId || '', + existingFormatMark: formatChangeMark, + }); + ctx.tr.addMark(range.from, range.to, formatMark); + return { sharedWid: wid, formatMark }; } - appendSubrange(out, { from: segFrom, to: segTo, fold: false }); - }); - if (boundaryCrossed) return null; - return out; + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + } + + return { sharedWid: wid, formatMark: null }; }; -const appendSubrange = (out, range) => { - const last = out[out.length - 1]; - if (last && last.fold === range.fold && last.parentId === range.parentId && last.to === range.from) { - last.to = range.to; - return; +const applyTrackedFormatRemove = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + const liveMarksBeforeRemove = getLiveInlineMarksInRange({ + doc: ctx.tr.doc, + from: range.from, + to: range.to, + }); + ctx.tr.removeMark(range.from, range.to, intent.mark); + + if (!hasMatchingMark(liveMarksBeforeRemove, intent.mark)) { + return { sharedWid, formatMark: null }; + } + + const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); + let after = []; + let before = []; + + if (formatChangeMark) { + const afterSnapshots = Array.isArray(formatChangeMark.attrs.after) ? formatChangeMark.attrs.after : []; + const beforeSnapshots = Array.isArray(formatChangeMark.attrs.before) ? formatChangeMark.attrs.before : []; + const foundAfter = afterSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, intent.mark, true)); + + if (foundAfter) { + after = afterSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, intent.mark, true)); + if (after.length === 0) { + const remainingFormatMarks = liveMarksBeforeRemove.filter( + (m) => + ![TrackDeleteMarkName, TrackFormatMarkName].includes(m.type.name) && m.type.name !== intent.mark.type.name, + ); + const isNoop = beforeSnapshots.every((snapshot) => + remainingFormatMarks.some((m) => markSnapshotMatchesStepMark(snapshot, m, true)), + ); + if (isNoop) { + ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: formatChangeMark.attrs.id || sharedWid, formatMark: null }; + } + } + before = [...beforeSnapshots]; + } else { + after = [...afterSnapshots]; + before = upsertMarkSnapshotByType(beforeSnapshots, { + type: intent.mark.type.name, + attrs: intent.mark.attrs, + }); + } + } else { + after = []; + const existingMark = range.node.marks.find((mark) => mark.type === intent.mark.type); + before = existingMark ? [createMarkSnapshot(intent.mark.type.name, existingMark.attrs)] : []; + } + + if (after.length || before.length) { + const wid = formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? uuidv4()); + const formatMark = createTrackFormatMark({ + ctx, + trackFormatType, + id: wid, + before, + after, + parentId: range.parentId || '', + existingFormatMark: formatChangeMark, + }); + ctx.tr.addMark(range.from, range.to, formatMark); + return { sharedWid: wid, formatMark }; } - out.push(range); + + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: formatChangeMark?.attrs?.id || sharedWid, formatMark: null }; }; +const createTrackFormatMark = ({ ctx, trackFormatType, id, before, after, parentId, existingFormatMark }) => + trackFormatType.create({ + id, + sourceId: existingFormatMark?.attrs?.sourceId || '', + author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before, + after, + importedAuthor: existingFormatMark?.attrs?.importedAuthor || '', + revisionGroupId: existingFormatMark?.attrs?.revisionGroupId || id, + splitFromId: existingFormatMark?.attrs?.splitFromId || '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: parentId || existingFormatMark?.attrs?.overlapParentId || '', + sourceIds: existingFormatMark?.attrs?.sourceIds ?? null, + origin: existingFormatMark?.attrs?.origin || '', + }); + /** * Find the segment that strictly contains `pos` if any. When no segment * strictly contains the position, returns the segment whose right edge is at @@ -925,4 +1323,4 @@ const findContainingSegment = (ctx, pos) => findSegmentAt(ctx, pos); // Diagnostics surfaced for telemetry/tests. // --------------------------------------------------------------------------- -export const compilerInternalsForTest = { stripTrackedMarksFromSlice, classifySegment, computeFormatSubranges }; +export const compilerInternalsForTest = { stripTrackedMarksFromSlice, classifySegment }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index 0cd991837c..01aa8220e7 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -21,6 +21,8 @@ import { Schema } from 'prosemirror-model'; const ALICE = { name: 'Alice', email: 'alice@example.com' }; const BOB = { name: 'Bob', email: 'bob@example.com' }; const NO_EMAIL = { name: 'Anon', email: '' }; +const SAME_EMAIL_ALICE = { id: 'alice-id', name: 'Alice', email: 'shared@example.com' }; +const SAME_EMAIL_BOB = { id: 'bob-id', name: 'Bob', email: 'shared@example.com' }; const FIXED_DATE = '2026-05-21T00:00:00.000Z'; @@ -342,6 +344,102 @@ describe('overlap-compiler: text-delete', () => { expect(aliceChange.deletedSegments[0].attrs.overlapParentId).toBe(parentId); }); + it('treats same-email different-id collaborators as different users', () => { + const parentId = 'ins-shared'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { + text: 'world', + marks: [ + insertMark({ + id: parentId, + author: SAME_EMAIL_ALICE.name, + authorId: SAME_EMAIL_ALICE.id, + authorEmail: SAME_EMAIL_ALICE.email, + date: FIXED_DATE, + }), + ], + }, + ], + }); + + const intent = makeTextDeleteIntent({ + from: 5, + to: 7, + user: SAME_EMAIL_BOB, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const bobChange = Array.from(graph.changes.values()).find((c) => c.authorId === SAME_EMAIL_BOB.id); + expect(bobChange).toBeDefined(); + expect(bobChange.type).toBe(CanonicalChangeType.Deletion); + expect(bobChange.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('protects named no-email insertion as different-user state when deleting inside it', () => { + const parentId = 'ins-alice-no-email'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'lazy ', + marks: [ + insertMark({ + id: parentId, + author: '', + importedAuthor: 'Alice Reviewer (imported)', + authorEmail: '', + date: FIXED_DATE, + }), + ], + }, + ], + }); + const intent = makeTextDeleteIntent({ + from: 1, + to: 5, + user: { name: 'CLI', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('lazy '); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Deletion); + expect(child.deletedSegments[0].text).toBe('lazy'); + expect(child.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('collapses truly unattributed no-email insertion when deleting it', () => { + const parentId = 'ins-unattributed'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'draft', marks: [insertMark({ id: parentId, author: '', authorEmail: '', date: FIXED_DATE })] }], + }); + const intent = makeTextDeleteIntent({ from: 1, to: 6, user: BOB, date: FIXED_DATE, source: 'document-api' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe(''); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(0); + expect(result.removedChangeIds).toEqual([parentId]); + }); + it('no-ops when deleting inside own deletion', () => { const delId = 'del-alice'; const { state } = stateFromTrackedSpans({ @@ -361,6 +459,59 @@ describe('overlap-compiler: text-delete', () => { }); }); +describe('overlap-compiler: text-replace inside named no-email insertion', () => { + it('preserves parent insertion and creates child replacement sides', () => { + const parentId = 'ins-alice-no-email'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'lazy ', + marks: [ + insertMark({ + id: parentId, + author: '', + importedAuthor: 'Alice Reviewer (imported)', + authorEmail: '', + date: FIXED_DATE, + }), + ], + }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 5, + content: sliceFromText(schema, 'quickly'), + replacements: 'paired', + user: { name: 'CLI', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('lazyquickly '); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(3); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + const childDelete = Array.from(graph.changes.values()).find( + (change) => change.type === CanonicalChangeType.Deletion, + ); + const childInsert = Array.from(graph.changes.values()).find( + (change) => change.type === CanonicalChangeType.Insertion && change.id !== parentId, + ); + expect(childDelete).toBeDefined(); + expect(childDelete.deletedSegments[0].text).toBe('lazy'); + expect(childDelete.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + expect(childInsert).toBeDefined(); + expect(childInsert.insertedSegments.map((segment) => segment.text).join('')).toBe('quickly'); + expect(childInsert.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); +}); + describe('overlap-compiler: weak-identity routes through different-user path', () => { it('missing author email on parent insertion forces different-user behavior', () => { const parentId = 'ins-anon'; @@ -535,6 +686,50 @@ describe('overlap-compiler: format folding (SD-486)', () => { expect(hasBold).toBe(true); }); + it('folds same-user insertion formatting while tracking adjacent live text in the same operation', () => { + const id = 'ins-alice'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' tail' }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 14, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.formatMarks).toHaveLength(1); + + let insertionHasBold = false; + let insertionHasTrackFormat = false; + let liveHasBold = false; + let liveHasTrackFormat = false; + result.tr.doc.descendants((node) => { + if (!node.isText) return; + if (node.text.includes('world')) { + insertionHasBold = node.marks.some((m) => m.type.name === 'bold'); + insertionHasTrackFormat = node.marks.some((m) => m.type.name === TrackFormatMarkName); + } + if (node.text.includes(' tail')) { + liveHasBold = node.marks.some((m) => m.type.name === 'bold'); + liveHasTrackFormat = node.marks.some((m) => m.type.name === TrackFormatMarkName); + } + }); + + expect(insertionHasBold).toBe(true); + expect(insertionHasTrackFormat).toBe(false); + expect(liveHasBold).toBe(true); + expect(liveHasTrackFormat).toBe(true); + }); + it('creates a trackFormat over different-user inserted content', () => { const parentId = 'ins-bob'; const { state } = makeBoldedDoc([ diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js index b92f155872..4ce6600194 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js @@ -32,6 +32,13 @@ import { BODY_STORY, buildStoryKey } from './story-locator.js'; // Types // --------------------------------------------------------------------------- +/** + * @typedef {Object} TrackedMarkRun + * @property {number} from + * @property {number} to + * @property {import('prosemirror-model').Mark} mark + */ + /** * @typedef {Object} TrackedSegment * @property {string} segmentId @@ -42,6 +49,7 @@ import { BODY_STORY, buildStoryKey } from './story-locator.js'; * @property {number} to * @property {string} text * @property {import('prosemirror-model').Mark} mark + * @property {Array} markRuns * @property {import('./mark-metadata.js').NormalizedTrackedAttrs} attrs * @property {string} parentId * @property {string} parentSide @@ -71,6 +79,7 @@ import { BODY_STORY, buildStoryKey } from './story-locator.js'; * @property {Array} formattingSegments * @property {LogicalReplacementProjection | null} replacement * @property {string} author + * @property {string} authorId * @property {string} authorEmail * @property {string} authorImage * @property {string} date @@ -327,6 +336,7 @@ const mergeAdjacentSpans = (normalized) => { if (canMerge) { last.to = span.to; + last.markRuns.push({ from: span.from, to: span.to, mark: span.mark }); continue; } @@ -346,6 +356,7 @@ const mergeAdjacentSpans = (normalized) => { to: span.to, text: '', mark: span.mark, + markRuns: [{ from: span.from, to: span.to, mark: span.mark }], attrs, parentId: attrs.overlapParentId || '', parentSide: '', @@ -474,6 +485,7 @@ const buildLogicalChange = ({ changeId, segments, doc, story, replacementsMode } formattingSegments: formatting, replacement, author: primary?.author ?? '', + authorId: primary?.authorId ?? '', authorEmail: primary?.authorEmail ?? '', authorImage: primary?.authorImage ?? '', date: primary?.date ?? '', diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js index 9ebe68d833..0eee3a748e 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -30,6 +30,7 @@ const NODES = { const MARK_DEFS_WITH_GRAPH_ATTRS = { id: { default: '' }, author: { default: '' }, + authorId: { default: '' }, authorEmail: { default: '' }, authorImage: { default: '' }, date: { default: '' }, @@ -111,6 +112,7 @@ export const stateFromTrackedSpans = ({ schema, spans }) => { export const markAttrs = (attrs) => ({ id: '', author: '', + authorId: '', authorEmail: '', authorImage: '', date: '', diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js index 19f9a9b3fa..d4e623c7b3 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js @@ -25,6 +25,7 @@ * reserveAll: (entries: Iterable<{ partPath: string, sourceId: string | number | null | undefined }>) => void, * allocate: (input: { partPath: string, sourceId?: string | number | null, logicalId?: string | null }) => string, * isDecimal: (value: unknown) => boolean, + * getSourceIdMap: () => Record>, * __snapshot: () => Record, * }} WordIdAllocator * @@ -32,11 +33,14 @@ * reservedDecimal: Set, * nextDecimal: number, * assignedByLogicalId: Map, + * sourceIdByWordId: Map, * }} PartWordIdState */ const DECIMAL = /^\d+$/; +export const TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY = 'SuperdocTrackedChangeSourceIds'; + /** * Returns true when the given value, after coercion to a trimmed string, is * a base-10 integer Word would accept as `w:id`. @@ -70,12 +74,25 @@ export function createWordIdAllocator() { reservedDecimal: new Set(), nextDecimal: 1, assignedByLogicalId: new Map(), + sourceIdByWordId: new Map(), }; stateByPart.set(key, state); } return state; }; + /** + * @param {PartWordIdState} state + * @param {string | number | null | undefined} sourceId + * @param {number} wordId + */ + const recordSourceIdRewrite = (state, sourceId, wordId) => { + if (sourceId == null) return; + const source = String(sourceId).trim(); + if (!source || isDecimalWordId(source)) return; + state.sourceIdByWordId.set(String(wordId), source); + }; + /** @type {WordIdAllocator['reserve']} */ const reserve = (partPath, sourceId) => { if (!isDecimalWordId(sourceId)) return; @@ -113,7 +130,11 @@ export function createWordIdAllocator() { // so both sides emit the same `w:id` on export, matching Word's pairing // convention. if (logicalId && state.assignedByLogicalId.has(logicalId)) { - return String(state.assignedByLogicalId.get(logicalId)); + const assigned = state.assignedByLogicalId.get(logicalId); + if (typeof assigned === 'number') { + recordSourceIdRewrite(state, sourceId, assigned); + return String(assigned); + } } let n = state.nextDecimal; @@ -121,9 +142,20 @@ export function createWordIdAllocator() { state.reservedDecimal.add(n); state.nextDecimal = n + 1; if (logicalId) state.assignedByLogicalId.set(logicalId, n); + recordSourceIdRewrite(state, sourceId, n); return String(n); }; + const getSourceIdMap = () => { + /** @type {Record>} */ + const out = {}; + for (const [part, state] of stateByPart.entries()) { + if (state.sourceIdByWordId.size === 0) continue; + out[part] = Object.fromEntries([...state.sourceIdByWordId.entries()].sort(([a], [b]) => a.localeCompare(b))); + } + return out; + }; + const __snapshot = () => { /** @type {Record} */ const out = {}; @@ -141,6 +173,7 @@ export function createWordIdAllocator() { reserveAll, allocate, isDecimal: isDecimalWordId, + getSourceIdMap, __snapshot, }; } diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js index c1e8cf6b40..185ddfe19d 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js @@ -83,6 +83,20 @@ describe('createWordIdAllocator', () => { expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '-3', logicalId: 'y' })).toBe('2'); }); + it('records non-decimal sourceId rewrites for reopen import', () => { + const alloc = createWordIdAllocator(); + + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'uuid-a', logicalId: 'a' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '9', logicalId: 'word-import' })).toBe('9'); + expect(alloc.allocate({ partPath: 'word/header1.xml', sourceId: 'uuid-h', logicalId: 'h' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'uuid-a', logicalId: 'a' })).toBe('1'); + + expect(alloc.getSourceIdMap()).toEqual({ + 'word/document.xml': { 1: 'uuid-a' }, + 'word/header1.xml': { 1: 'uuid-h' }, + }); + }); + it('handles missing partPath by routing to document.xml', () => { const alloc = createWordIdAllocator(); expect(alloc.allocate({ partPath: '', logicalId: 'x' })).toBe('1'); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 581296ba52..7310c06407 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -799,29 +799,29 @@ describe('TrackChanges extension commands', () => { const doc = schema.nodes.doc.create(null, paragraph); const state = createState(doc); - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; + let nextState = state; + const editor = { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }; + const dispatch = (tr) => { + nextState = nextState.apply(tr); + }; const result = commands.acceptTrackedChangeById('ins-id')({ state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, + tr: state.tr, + dispatch, + editor, + commands: {}, }); - expect(result).toBe(true); - // Call one time not multiple - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(2, 3); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById('ins-id')({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, + // The target's "B" is accepted (no longer tracked-inserted), but the + // unrelated "prev" insertion ("A") remains tracked. + const insertIds = new Set(); + nextState.doc.descendants((node) => { + if (!node.isText) return; + const mark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + if (mark?.attrs?.id) insertIds.add(mark.attrs.id); }); - expect(rejectResult).toBe(true); - // Call one time not multiple - expect(rejectSpy).toHaveBeenCalledTimes(1); - expect(rejectSpy).toHaveBeenCalledWith(2, 3); + expect(insertIds.has('ins-id')).toBe(false); + expect(insertIds.has('prev')).toBe(true); }); it('interaction: color suggestion reject removes inline color styling from DOM', () => { @@ -1233,231 +1233,178 @@ describe('TrackChanges extension commands', () => { } }); - it('acceptTrackedChangeById links contiguous insertion segments sharing an id across formatting', () => { + // The by-id tests below assert product-visible outcomes (final doc text + + // remaining tracked marks) instead of internal delegation to the range + // command. The decision engine is the single accept/reject path; how it + // groups same-id segments internally is an implementation detail and not a + // contract callers depend on. + + const runByIdDecision = ({ decision, id, doc }) => { + const state = createState(doc); + let nextState = state; + const editor = { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }; + const command = decision === 'accept' ? commands.acceptTrackedChangeById(id) : commands.rejectTrackedChangeById(id); + const result = command({ + state, + tr: state.tr, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor, + commands: { + // Provide range-command fallbacks so the underlying command stays + // functional if it ever needs to compose them. These are not the path + // under test — the decision engine handles by-id natively. + acceptTrackedChangesBetween: (from, to) => { + const tr = nextState.tr; + nextState.doc.nodesBetween(from, to, (node, pos) => { + const mark = node.marks.find((m) => TRACKED_MARK_NAMES.has(m.type.name)); + if (!mark) return; + const mFrom = Math.max(pos, from); + const mTo = Math.min(pos + node.nodeSize, to); + if (mark.type.name === TrackDeleteMarkName) tr.replace(mFrom, mTo); + else tr.removeMark(mFrom, mTo, mark); + }); + nextState = nextState.apply(tr); + return true; + }, + rejectTrackedChangesBetween: (from, to) => { + const tr = nextState.tr; + nextState.doc.nodesBetween(from, to, (node, pos) => { + const mark = node.marks.find((m) => TRACKED_MARK_NAMES.has(m.type.name)); + if (!mark) return; + const mFrom = Math.max(pos, from); + const mTo = Math.min(pos + node.nodeSize, to); + if (mark.type.name === TrackInsertMarkName) tr.replace(mFrom, mTo); + else tr.removeMark(mFrom, mTo, mark); + }); + nextState = nextState.apply(tr); + return true; + }, + }, + }); + return { result, nextState }; + }; + + const TRACKED_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName]); + + it('acceptTrackedChangeById resolves contiguous insertion segments sharing an id (across inline formatting)', () => { const italicMark = schema.marks.italic.create(); const insertionId = 'ins-multi'; - const firstSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const secondSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const thirdSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('A', [firstSegmentMark]), - schema.text('B', [italicMark, secondSegmentMark]), - schema.text('C', [thirdSegmentMark]), + schema.text('A', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('B', [italicMark, schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('C', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(insertionId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: insertionId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(3); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 2); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 2, 3); - expect(acceptSpy).toHaveBeenNthCalledWith(3, 3, 4); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(insertionId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(3); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 1, 2); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 2, 3); - expect(rejectSpy).toHaveBeenNthCalledWith(3, 3, 4); + expect(nextState.doc.textContent).toBe('ABC'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); }); - it('acceptTrackedChangeById resolves contiguous same-id insertions without pulling in adjacent different-id deletions', () => { + it('rejectTrackedChangeById removes inserted content across formatting splits', () => { const italicMark = schema.marks.italic.create(); - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionId = 'shared-id'; - const firstSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const secondSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); + const insertionId = 'ins-multi-reject'; const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('A', [firstSegmentMark]), - schema.text('B', [italicMark, secondSegmentMark]), + schema.text('A', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('B', [italicMark, schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('C', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(insertionId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'reject', id: insertionId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 4, 5); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 5, 6); + expect(nextState.doc.textContent).toBe(''); + }); - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(insertionId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); + it('acceptTrackedChangeById does not pull in adjacent different-id deletions', () => { + const insertionId = 'shared-id'; + const paragraph = schema.nodes.paragraph.create(null, [ + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text('AB', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + ]); + const doc = schema.nodes.doc.create(null, paragraph); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(2); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 4, 5); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 5, 6); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: insertionId, doc }); + expect(result).toBe(true); + expect(nextState.doc.textContent).toBe('oldAB'); + // The unrelated deletion is still tracked-deleted. + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); + // Our insertion is accepted (no longer tracked-inserted). + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); }); - it('acceptTrackedChangeById and rejectTrackedChangeById should NOT link adjacent deletion-insertion pairs with different ids', () => { - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); + it('by-id decisions on adjacent del+ins pairs with different ids resolve only the target id', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('new', [insertionMark]), + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text('new', [schema.marks[TrackInsertMarkName].create({ id: 'ins-id' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('ins-id')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'ins-id', doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(4, 7); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById('ins-id')({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(1); - expect(rejectSpy).toHaveBeenCalledWith(4, 7); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); - it('acceptTrackedChangeById and rejectTrackedChangeById should still link adjacent deletion-insertion pairs with the same id', () => { + it('by-id decisions on adjacent del+ins pairs with the same id resolve the paired replacement together', () => { const sharedId = 'replace-id'; - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: sharedId }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: sharedId }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('new', [insertionMark]), + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: sharedId })]), + schema.text('new', [schema.marks[TrackInsertMarkName].create({ id: sharedId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(sharedId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: sharedId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 4); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 4, 7); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(sharedId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(2); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 1, 4); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 4, 7); + expect(nextState.doc.textContent).toBe('new'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(false); }); - it('should NOT link changes separated by untracked content', () => { - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); + it('by-id decisions do not resolve unrelated tracked changes separated by untracked content', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('deleted', [deletionMark]), - schema.text(' '), // Untracked space between - schema.text('inserted', [insertionMark]), + schema.text('deleted', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text(' '), + schema.text('inserted', [schema.marks[TrackInsertMarkName].create({ id: 'ins-id' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('ins-id')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'ins-id', doc }); expect(result).toBe(true); - // Should only resolve the insertion, not the deletion - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(9, 17); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); - it('acceptTrackedChangesById should link changes sharing the same id even if they are not directly connected', () => { + it('by-id decisions resolve same-id changes even when they are not directly adjacent', () => { const id = 'shared-id'; - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('deleted', [deletionMark]), - schema.text(' '), // Untracked space between - schema.text('inserted', [insertionMark]), + schema.text('deleted', [schema.marks[TrackDeleteMarkName].create({ id })]), + schema.text(' '), + schema.text('inserted', [schema.marks[TrackInsertMarkName].create({ id })]), ]); - const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(id)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 8); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 9, 17); + expect(nextState.doc.textContent).toBe(' inserted'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(false); }); - it('should NOT link two deletions', () => { - const deletionMark1 = schema.marks[TrackDeleteMarkName].create({ id: 'del-1' }); - const deletionMark2 = schema.marks[TrackDeleteMarkName].create({ id: 'del-2' }); + it('by-id decisions resolve only the target deletion, leaving sibling deletions intact', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('first', [deletionMark1]), - schema.text('second', [deletionMark2]), + schema.text('first', [schema.marks[TrackDeleteMarkName].create({ id: 'del-1' })]), + schema.text('second', [schema.marks[TrackDeleteMarkName].create({ id: 'del-2' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('del-2')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'del-2', doc }); expect(result).toBe(true); - // Should only resolve the target deletion, not the previous deletion - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(6, 12); + expect(nextState.doc.textContent).toBe('first'); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); it('toggle and enable commands set plugin metadata', () => { @@ -1533,6 +1480,10 @@ describe('TrackChanges extension commands', () => { }); it('wrapper commands delegate to range-based handlers', () => { + // Single-target wrappers still delegate to the range commands because the + // wrappers compose them. The All wrappers go through the unified decision + // engine instead (see "acceptAllTrackedChanges resolves every tracked + // change in the document" below). const rangeCommand = vi.fn().mockReturnValue(true); const trackedChange = { start: 5, end: 9 }; @@ -1570,27 +1521,41 @@ describe('TrackChanges extension commands', () => { }), ).toBe(true); expect(rejectSelection).toHaveBeenCalledWith(1, 4); + }); - const doc = createDoc('All the things'); + it('acceptAllTrackedChanges resolves every tracked change in the document', () => { + const doc = createDoc('All the things', [schema.marks[TrackInsertMarkName].create({ id: 'all-accept' })]); const state = createState(doc); - const acceptAll = vi.fn().mockReturnValue(true); - const rejectAll = vi.fn().mockReturnValue(true); - expect( - commands.acceptAllTrackedChanges()({ - state, - commands: { acceptTrackedChangesBetween: acceptAll }, - }), - ).toBe(true); - expect(acceptAll).toHaveBeenCalledWith(0, doc.content.size); + let nextState; + commands.acceptAllTrackedChanges()({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }, + }); - expect( - commands.rejectAllTrackedChanges()({ - state, - commands: { rejectTrackedChangesBetween: rejectAll }, - }), - ).toBe(true); - expect(rejectAll).toHaveBeenCalledWith(0, doc.content.size); + expect(nextState).toBeDefined(); + expect(nextState.doc.textContent).toBe('All the things'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + }); + + it('rejectAllTrackedChanges removes every tracked insertion from the document', () => { + const doc = createDoc('Hello world', [schema.marks[TrackInsertMarkName].create({ id: 'all-reject' })]); + const state = createState(doc); + + let nextState; + commands.rejectAllTrackedChanges()({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }, + }); + + expect(nextState).toBeDefined(); + expect(nextState.doc.textContent).toBe(''); }); describe('insertTrackedChange', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 0555eea2d1..17743e0ed1 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -1,12 +1,9 @@ import { Extension } from '@core/Extension.js'; -import { Slice } from 'prosemirror-model'; -import { Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.js'; -import { collectTrackedChanges, isTrackedChangeActionAllowed } from './permission-helpers.js'; -import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../comment/comments-plugin.js'; -import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; +import { collectTrackedChanges } from './permission-helpers.js'; +import { CommentsPluginKey } from '../comment/comments-plugin.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; import { compileTrackedEdit } from './review-model/overlap-compiler.js'; import { @@ -54,15 +51,130 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = return { applied: false, failure: result }; } if (dispatch) { + // Compute the post-dispatch state locally so we can derive update events + // for partial decisions (where a change has remaining tracked text on the + // doc). Then dispatch the real transaction. + const nextState = state.apply(result.tr); dispatch(result.tr); - const events = buildDecisionBubbleEvents({ result, editor }); - if (editor?.emit && events.length) { - for (const event of events) editor.emit('commentsUpdate', event); + + if (editor?.emit) { + // Partial decisions retire the original id and mint successor fragments + // (splitFromId === originalId). For each retired id, decide whether to + // emit `resolve` (no successors remain) or `update` (successors keep + // the logical change alive with refreshed text). + const resolveEvents = buildDecisionBubbleEvents({ result, editor }); + for (const event of resolveEvents) { + const successorsPresent = collectRemainingForLogicalId({ + state: nextState, + originalId: event.changeId, + }).some(({ mark }) => mark.attrs?.splitFromId === event.changeId); + if (successorsPresent) continue; + editor.emit('commentsUpdate', event); + } + + const touched = + result.touchedChangeIds instanceof Set ? result.touchedChangeIds : new Set(result.touchedChangeIds || []); + const emittedFor = new Set(); + for (const changeId of touched) { + if (emittedFor.has(changeId)) continue; + // Skip ids that are successor fragments of another touched id (we + // emit one update for the logical original id). + if ( + Array.from(touched).some( + (other) => other !== changeId && isSuccessorOf({ state: nextState, id: changeId, originalId: other }), + ) + ) { + continue; + } + const remaining = collectRemainingForLogicalId({ state: nextState, originalId: changeId }); + if (!remaining.length) continue; + const payload = buildPartialUpdatePayload({ + state: nextState, + documentId: editor.options?.documentId, + originalId: changeId, + remaining, + }); + if (payload) { + editor.emit('commentsUpdate', payload); + emittedFor.add(changeId); + } + } } } return { applied: true, result }; }; +/** + * Collect tracked-change marks that represent the logical original id, either + * directly (mark.attrs.id === originalId) or via successor fragments + * (mark.attrs.splitFromId === originalId). + * + * @param {{ state: import('prosemirror-state').EditorState, originalId: string }} options + * @returns {Array<{ from: number, to: number, mark: import('prosemirror-model').Mark, node: import('prosemirror-model').Node }>} + */ +const collectRemainingForLogicalId = ({ state, originalId }) => { + const all = getTrackChanges(state); + return all.filter(({ mark }) => mark.attrs?.id === originalId || mark.attrs?.splitFromId === originalId); +}; + +const isSuccessorOf = ({ state, id, originalId }) => { + const all = getTrackChanges(state); + return all.some(({ mark }) => mark.attrs?.id === id && mark.attrs?.splitFromId === originalId); +}; + +/** + * Build a comments-plugin-shaped `update` payload from the remaining + * tracked-change marks for a logical original id. Aggregates inserted / + * deleted text across all surviving successor fragments and uses the original + * id as the changeId so existing bubble threads remain addressable. + */ +const buildPartialUpdatePayload = ({ state, documentId, originalId, remaining }) => { + let insertedMark = null; + let deletionMark = null; + let formatMark = null; + for (const entry of remaining) { + if (!insertedMark && entry.mark.type.name === TrackInsertMarkName) insertedMark = entry.mark; + if (!deletionMark && entry.mark.type.name === TrackDeleteMarkName) deletionMark = entry.mark; + if (!formatMark && entry.mark.type.name === TrackFormatMarkName) formatMark = entry.mark; + } + const anchorMark = insertedMark || deletionMark || formatMark; + if (!anchorMark) return null; + + const insertedText = remaining + .filter(({ mark }) => mark.type.name === TrackInsertMarkName) + .map(({ node }) => node?.text || node?.textContent || '') + .join(''); + const deletedText = remaining + .filter(({ mark }) => mark.type.name === TrackDeleteMarkName) + .map(({ node }) => node?.text || node?.textContent || '') + .join(''); + + const trackedChangeType = insertedMark + ? TrackInsertMarkName + : deletionMark + ? TrackDeleteMarkName + : TrackFormatMarkName; + const isReplacement = Boolean(insertedMark && deletionMark); + const { author, authorId, authorEmail, authorImage, date, importedAuthor } = anchorMark.attrs; + + return { + event: 'update', + type: 'trackedChange', + documentId, + changeId: originalId, + trackedChangeType: isReplacement ? 'both' : trackedChangeType, + trackedChangeText: trackedChangeType === TrackDeleteMarkName ? deletedText : insertedText, + trackedChangeDisplayType: null, + deletedText: isReplacement || deletionMark ? deletedText : null, + author, + ...(authorId && { authorId }), + authorEmail, + ...(authorImage && { authorImage }), + date, + ...(importedAuthor && { importedAuthor: { name: importedAuthor } }), + }; +}; + export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -85,49 +197,7 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'range', from, to }, }); - if (reviewDecision) return reviewDecision.applied; - - const trackedChanges = collectTrackedChanges({ state, from, to }); - if (!isTrackedChangeActionAllowed({ editor, action: 'accept', trackedChanges })) return false; - - let { tr, doc } = state; - - // if (from === to) { - // to += 1; - // } - - // tr.setMeta('acceptReject', true); - tr.setMeta('inputType', 'acceptReject'); - const touchedChangeIds = new Set(); - const map = new Mapping(); - - doc.nodesBetween(from, to, (node, pos) => { - const trackedMark = getTrackedMark(node); - if (!trackedMark) return; - - const mappedFrom = map.map(Math.max(pos, from)); - const mappedTo = map.map(Math.min(pos + node.nodeSize, to)); - if (mappedFrom >= mappedTo) return; - - if (trackedMark.attrs?.id) touchedChangeIds.add(trackedMark.attrs.id); - - if (trackedMark.type.name === TrackDeleteMarkName) { - const deletionStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty); - tr.step(deletionStep); - map.appendMap(deletionStep.getMap()); - return; - } - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - }); - - return dispatchTrackedChangeResolution({ - state, - tr, - dispatch, - editor, - touchedChangeIds, - }); + return reviewDecision.applied; }, rejectTrackedChangesBetween: @@ -140,70 +210,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'range', from, to }, }); - if (reviewDecision) return reviewDecision.applied; - - const trackedChanges = collectTrackedChanges({ state, from, to }); - if (!isTrackedChangeActionAllowed({ editor, action: 'reject', trackedChanges })) return false; - - const { tr, doc } = state; - const touchedChangeIds = new Set(); - tr.setMeta('inputType', 'acceptReject'); - - const map = new Mapping(); - - doc.nodesBetween(from, to, (node, pos) => { - const trackedMark = getTrackedMark(node); - if (!trackedMark) return; - - const mappedFrom = map.map(Math.max(pos, from)); - const mappedTo = map.map(Math.min(pos + node.nodeSize, to)); - if (mappedFrom >= mappedTo) return; - - if (trackedMark.attrs?.id) touchedChangeIds.add(trackedMark.attrs.id); - - if (trackedMark.type.name === TrackDeleteMarkName) { - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - return; - } - - if (trackedMark.type.name === TrackInsertMarkName) { - const deletionStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty); - tr.step(deletionStep); - map.appendMap(deletionStep.getMap()); - return; - } - - trackedMark.attrs.after.forEach((newMark) => { - const liveMark = findMarkInRangeBySnapshot({ - doc: tr.doc, - from: mappedFrom, - to: mappedTo, - snapshot: newMark, - }); - - if (!liveMark) { - return; - } - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark)); - }); - - // Remove suggested "after" marks first, then restore "before" marks. - // This avoids overlap matching removing a just-restored attribute-only mark (e.g. textStyle). - trackedMark.attrs.before.forEach((oldMark) => { - tr.step(new AddMarkStep(mappedFrom, mappedTo, state.schema.marks[oldMark.type].create(oldMark.attrs))); - }); - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - }); - - return dispatchTrackedChangeResolution({ - state, - tr, - dispatch, - editor, - touchedChangeIds, - }); + return reviewDecision.applied; }, acceptTrackedChange: @@ -256,7 +263,7 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -264,22 +271,12 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'id', id }, }); - if (reviewDecision) return reviewDecision.applied; - - const toResolve = getChangesByIdToResolve(state, id) || []; - - return toResolve - .map(({ from, to }) => { - let mappedFrom = tr.mapping.map(from); - let mappedTo = tr.mapping.map(to); - return commands.acceptTrackedChangesBetween(mappedFrom, mappedTo); - }) - .every((result) => result); + return reviewDecision.applied; }, acceptAllTrackedChanges: () => - ({ state, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -287,16 +284,12 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'all' }, }); - if (reviewDecision) return reviewDecision.applied; - - const from = 0, - to = state.doc.content.size; - return commands.acceptTrackedChangesBetween(from, to); + return reviewDecision.applied; }, rejectTrackedChangeById: (id) => - ({ state, tr, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -304,17 +297,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'id', id }, }); - if (reviewDecision) return reviewDecision.applied; - - const toReject = getChangesByIdToResolve(state, id) || []; - - return toReject - .map(({ from, to }) => { - let mappedFrom = tr.mapping.map(from); - let mappedTo = tr.mapping.map(to); - return commands.rejectTrackedChangesBetween(mappedFrom, mappedTo); - }) - .every((result) => result); + return reviewDecision.applied; }, rejectTrackedChange: @@ -367,7 +350,7 @@ export const TrackChanges = Extension.create({ rejectAllTrackedChanges: () => - ({ state, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -375,11 +358,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'all' }, }); - if (reviewDecision) return reviewDecision.applied; - - const from = 0, - to = state.doc.content.size; - return commands.rejectTrackedChangesBetween(from, to); + return reviewDecision.applied; }, insertTrackedChange: @@ -530,10 +509,6 @@ export const TrackChanges = Extension.create({ }, }); -const TRACKED_CHANGE_MARKS = [TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName]; - -const getTrackedMark = (node) => node?.marks?.find((mark) => TRACKED_CHANGE_MARKS.includes(mark.type.name)) ?? null; - const getTrackedChangeActionSelection = ({ state, editor }) => { const currentSelection = state?.selection; if (hasExpandedSelection(currentSelection)) { @@ -624,76 +599,6 @@ const resolveTrackedChangeAction = ({ : selectionCommand(); }; -const collectRemainingMarksByType = (trackedChanges = []) => ({ - insertedMark: trackedChanges.find(({ mark }) => mark.type.name === TrackInsertMarkName)?.mark ?? null, - deletionMark: trackedChanges.find(({ mark }) => mark.type.name === TrackDeleteMarkName)?.mark ?? null, - formatMark: trackedChanges.find(({ mark }) => mark.type.name === TrackFormatMarkName)?.mark ?? null, -}); - -const emitTrackedChangeCommentLifecycle = ({ editor, nextState, touchedChangeIds }) => { - if (!editor?.emit || !touchedChangeIds?.size) { - return; - } - - const resolvedByEmail = editor.options?.user?.email; - const resolvedByName = editor.options?.user?.name; - - touchedChangeIds.forEach((changeId) => { - const remainingTrackedChanges = getTrackChanges(nextState, changeId); - - // Partial resolution keeps the tracked-change thread alive with updated text; - // full resolution emits the normal resolve event so the bubble can disappear. - if (!remainingTrackedChanges.length) { - editor.emit('commentsUpdate', { - type: 'trackedChange', - event: 'resolve', - changeId, - resolvedByEmail, - resolvedByName, - }); - return; - } - - const marks = collectRemainingMarksByType(remainingTrackedChanges); - const updatePayload = createOrUpdateTrackedChangeComment({ - event: 'update', - marks, - deletionNodes: [], - nodes: [], - newEditorState: nextState, - documentId: editor.options?.documentId, - trackedChangesForId: remainingTrackedChanges, - }); - - if (updatePayload) { - editor.emit('commentsUpdate', updatePayload); - } - }); -}; - -const dispatchTrackedChangeResolution = ({ state, tr, dispatch, editor, touchedChangeIds }) => { - if (!tr.steps.length) { - return true; - } - - // Apply tr locally to get nextState for comment lifecycle; dispatch(tr) updates the editor afterward. - const nextState = state.apply(tr); - - if (dispatch) { - dispatch(tr); - } - - if (dispatch && touchedChangeIds?.size) { - emitTrackedChangeCommentLifecycle({ - editor, - nextState, - touchedChangeIds, - }); - } - - return true; -}; - const getChangesByIdToResolve = (state, id) => { const trackedChanges = getTrackChanges(state); const changeIndex = trackedChanges.findIndex(({ mark }) => mark.attrs.id === id); @@ -843,11 +748,20 @@ const dispatchCompiledInsertTrackedChange = ({ return true; } + // Build real metadata for the comments plugin from the compiler result so + // the bubble pipeline can derive the inserted/deleted text immediately + // without re-scanning the doc. + const insertedNodes = result.insertedNodes ?? []; + const deletionNodes = result.deletionNodes ?? []; const meta = { insertedMark: result.insertedMark || null, - deletionMark: result.deletionMarks?.[0] || null, - deletionNodes: [], - step: result.insertedMark ? { slice: { content: { content: [] } } } : null, + deletionMark: result.deletionMark || result.deletionMarks?.[0] || null, + deletionNodes, + step: result.insertedStep + ? result.insertedStep + : result.insertedMark + ? { slice: { content: { content: insertedNodes } } } + : null, emitCommentEvent, }; tr.setMeta(TrackChangesBasePluginKey, meta); @@ -868,6 +782,7 @@ const dispatchCompiledInsertTrackedChange = ({ parentId: changeId, content: comment, author: resolvedUser.name, + authorId: resolvedUser.id, authorEmail: resolvedUser.email, authorImage: resolvedUser.image, }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js index 1a7e67e205..efc7f2db2e 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js @@ -43,6 +43,11 @@ export const TrackDelete = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js index 1706f2e464..fd37c7bc2d 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js @@ -44,6 +44,11 @@ export const TrackFormat = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js index f8af8c29ec..9bb5178d83 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js @@ -43,6 +43,11 @@ export const TrackInsert = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js index 7b14d8d772..67317bc55b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -1,17 +1,7 @@ // @ts-check -import { TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; -import { v4 as uuidv4 } from 'uuid'; +import { TrackDeleteMarkName, TrackedFormatMarkNames } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { - hasMatchingMark, - markSnapshotMatchesStepMark, - upsertMarkSnapshotByType, - isTrackFormatNoOp, - getTypeName, - createMarkSnapshot, -} from './markSnapshotHelpers.js'; -import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; import { makeFormatIntent } from '../review-model/edit-intent.js'; @@ -26,9 +16,6 @@ import { makeFormatIntent } from '../review-model/edit-intent.js'; * @param {string} options.date Date. */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { - // Route tracked run-format intents through the compiler so formatting - // inside same-user own insertion/replacement folds into the inserted side - // instead of producing a separate trackFormat. if (TrackedFormatMarkNames.includes(step.mark.type.name)) { const intentUser = { name: user?.name || '', @@ -59,18 +46,10 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.ok === false && result.code !== 'CAPABILITY_UNAVAILABLE') { - // Fail closed for typed errors; do not silently apply untracked. - return; - } - // Otherwise fall through to legacy path. + // Fail closed for tracked formatting; do not silently apply untracked. + return; } - /** @type {{ formatMark?: import('prosemirror-model').Mark, step?: import('prosemirror-transform').AddMarkStep }} */ - const meta = {}; - /** @type {string | null} */ - let sharedWid = null; - doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { return; @@ -80,93 +59,6 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } - const rangeFrom = Math.max(step.from, pos); - const rangeTo = Math.min(step.to, pos + node.nodeSize); - - /** @type {import('prosemirror-model').Mark[]} */ - const liveMarks = getLiveInlineMarksInRange({ - doc: newTr.doc, - from: rangeFrom, - to: rangeTo, - }); - const existingChangeMark = liveMarks.find((mark) => - [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), - ); - const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())); newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - - if (TrackedFormatMarkNames.includes(step.mark.type.name) && !hasMatchingMark(liveMarks, step.mark)) { - const formatChangeMark = liveMarks.find((mark) => mark.type.name === TrackFormatMarkName); - - /** @type {{ type?: string, attrs?: Record }[]} */ - let after = []; - /** @type {{ type?: string, attrs?: Record }[]} */ - let before = []; - - if (formatChangeMark) { - const beforeSnapshots = /** @type {{ type?: string, attrs?: Record }[]} */ ( - formatChangeMark.attrs.before || [] - ); - const afterSnapshots = /** @type {{ type?: string, attrs?: Record }[]} */ ( - formatChangeMark.attrs.after || [] - ); - let foundBefore = beforeSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, step.mark, true)); - - if (foundBefore) { - before = [...beforeSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true))]; - // The step restores the original mark for this type — remove the - // corresponding "after" entry since the change has been reverted. - after = afterSnapshots.filter((mark) => getTypeName(mark) !== step.mark.type.name); - } else { - before = [...beforeSnapshots]; - after = upsertMarkSnapshotByType(afterSnapshots, { - type: step.mark.type.name, - attrs: step.mark.attrs, - }); - } - } else { - const existingMarkOfSameType = liveMarks.find( - (mark) => - mark.type.name === step.mark.type.name && - ![TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), - ); - before = existingMarkOfSameType - ? [createMarkSnapshot(existingMarkOfSameType.type.name, existingMarkOfSameType.attrs)] - : []; - - after = [createMarkSnapshot(step.mark.type.name, step.mark.attrs)]; - } - - // Check if the format change is effectively a no-op (e.g., reverting - // vertAlign to 'baseline' when the original had no vertAlign). - if (isTrackFormatNoOp(before, after)) { - if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - return; - } - - if (after.length || before.length) { - const newFormatMark = state.schema.marks[TrackFormatMarkName].create({ - id: wid, - sourceId: formatChangeMark?.attrs?.sourceId || '', - author: user.name || '', - authorEmail: user.email || '', - authorImage: user.image || '', - date, - before, - after, - }); - newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark); - - meta.formatMark = newFormatMark; - meta.step = step; - - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); - } else if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - } }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index 449ff7d4bb..89c98873ec 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -27,6 +27,7 @@ export const findTrackedMarkBetween = ({ to, markName, attrs = {}, + predicate = null, offset = 1, // To get non-inclusive marks. }) => { const { doc } = tr; @@ -43,7 +44,10 @@ export const findTrackedMarkBetween = ({ } const mark = node.marks?.find( - (mark) => mark.type.name === markName && Object.keys(attrs).every((attr) => mark.attrs[attr] === attrs[attr]), + (mark) => + mark.type.name === markName && + Object.keys(attrs).every((attr) => mark.attrs[attr] === attrs[attr]) && + (typeof predicate !== 'function' || predicate(mark)), ); if (mark && !markFound) { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js index aff29e1244..6b2f2e14fb 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js @@ -4,7 +4,12 @@ import { Slice } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; -import { normalizeEmail } from '../review-model/identity.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, +} from '../review-model/identity.js'; /** * Mark deletion. @@ -18,15 +23,20 @@ import { normalizeEmail } from '../review-model/identity.js'; * @returns {{ deletionMark: import('prosemirror-model').Mark, deletionMap: Mapping, nodes: import('prosemirror-model').Node[] }} Deletion map and deletion mark. */ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { - const userEmail = normalizeEmail(user?.email); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); /** * @param {import('prosemirror-model').Mark | null | undefined} mark */ const isOwnInsertion = (mark) => { - const authorEmail = normalizeEmail(mark?.attrs?.authorEmail); - // Missing identity is not same-user. Only a trusted authorEmail match counts. - if (!authorEmail || !userEmail) return false; - return authorEmail === userEmail; + const changeIdentity = getChangeAuthorIdentity(mark); + if (matchesSameUserRefinement({ currentUser: currentIdentity, change: changeIdentity })) return true; + // No-email imported insertions collapse only when truly unattributed, or + // when their no-email display name matches the current user. A named + // different author with no email remains protected review state. + if (!changeIdentity.hasId && !changeIdentity.hasEmail) { + return shouldCollapseNoEmailInsertion({ currentUser: user, insertionAttrs: mark?.attrs }); + } + return false; }; const trackedMark = @@ -36,7 +46,11 @@ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { from, to, markName: TrackDeleteMarkName, - attrs: { authorEmail: user.email || '' }, + predicate: (mark) => + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), }) ); @@ -53,6 +67,7 @@ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({ id, author: user.name || '', + authorId: user.id || '', authorEmail: user.email || '', authorImage: user.image || '', date, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js index 52bd72f0fa..3528db19cd 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js @@ -2,6 +2,11 @@ import { v4 as uuidv4 } from 'uuid'; import { TrackInsertMarkName, TrackDeleteMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, +} from '../review-model/identity.js'; /** * Mark insertion. @@ -17,6 +22,7 @@ import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { tr.removeMark(from, to, tr.doc.type.schema.marks[TrackDeleteMarkName]); tr.removeMark(from, to, tr.doc.type.schema.marks[TrackInsertMarkName]); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); const trackedMark = /** @type {{ from: number, to: number, mark: import('prosemirror-model').Mark } | null | undefined} */ ( @@ -25,7 +31,11 @@ export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { from, to, markName: TrackInsertMarkName, - attrs: { authorEmail: user.email || '' }, + predicate: (mark) => + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), }) ); @@ -42,6 +52,7 @@ export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({ id, author: user.name || '', + authorId: user.id || '', authorEmail: user.email || '', authorImage: user.image || '', date, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js index ec80c2b384..f9eb9d5e6f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -1,14 +1,6 @@ -import { v4 as uuidv4 } from 'uuid'; -import { TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; +import { TrackDeleteMarkName, TrackedFormatMarkNames } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { - createMarkSnapshot, - hasMatchingMark, - markSnapshotMatchesStepMark, - upsertMarkSnapshotByType, -} from './markSnapshotHelpers.js'; -import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; import { makeFormatIntent } from '../review-model/edit-intent.js'; @@ -53,13 +45,10 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.code !== 'CAPABILITY_UNAVAILABLE') return; - // Fall through to legacy on capability gaps. + // Fail closed for tracked formatting; do not silently apply untracked. + return; } - const meta = {}; - let sharedWid = null; - doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { return true; @@ -69,87 +58,6 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } - const rangeFrom = Math.max(step.from, pos); - const rangeTo = Math.min(step.to, pos + node.nodeSize); - const liveMarksBeforeRemove = getLiveInlineMarksInRange({ - doc: newTr.doc, - from: rangeFrom, - to: rangeTo, - }); newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - - if (TrackedFormatMarkNames.includes(step.mark.type.name) && hasMatchingMark(liveMarksBeforeRemove, step.mark)) { - const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); - - let after = []; - let before = []; - - if (formatChangeMark) { - let foundAfter = formatChangeMark.attrs.after.find((mark) => - markSnapshotMatchesStepMark(mark, step.mark, true), - ); - - if (foundAfter) { - after = [ - ...formatChangeMark.attrs.after.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)), - ]; - if (after.length === 0) { - // All additions were canceled. Check if any marks in `before` were - // actually removed from the node. If they all still exist, the - // tracked change is a no-op — clean it up. - const remainingFormatMarks = liveMarksBeforeRemove.filter( - (m) => - ![TrackDeleteMarkName, TrackFormatMarkName].includes(m.type.name) && - m.type.name !== step.mark.type.name, - ); - const isNoop = formatChangeMark.attrs.before.every((snapshot) => - remainingFormatMarks.some((m) => markSnapshotMatchesStepMark(snapshot, m, true)), - ); - if (isNoop) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - return; - } - } - before = [...formatChangeMark.attrs.before]; - } else { - after = [...formatChangeMark.attrs.after]; - before = upsertMarkSnapshotByType(formatChangeMark.attrs.before, { - type: step.mark.type.name, - attrs: step.mark.attrs, - }); - } - } else { - after = []; - let existingMark = node.marks.find((mark) => mark.type === step.mark.type); - if (existingMark) { - before = [createMarkSnapshot(step.mark.type.name, existingMark.attrs)]; - } else { - before = []; - } - } - - if (after.length || before.length) { - const newFormatMark = state.schema.marks[TrackFormatMarkName].create({ - id: formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())), - sourceId: formatChangeMark?.attrs?.sourceId || '', - author: user.name || '', - authorEmail: user.email || '', - authorImage: user.image || '', - date, - before, - after, - }); - - newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark); - - meta.formatMark = newFormatMark; - meta.step = step; - - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); - } else if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - } }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js index b89c6edeec..4981e8be24 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js @@ -3,6 +3,7 @@ import { Slice } from 'prosemirror-model'; import { replaceStep } from './replaceStep.js'; import { TrackDeleteMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; +import { getChangeAuthorIdentity, matchesSameUserRefinement } from '../review-model/identity.js'; /** * Check whether the enclosing structural scope (listItem, or paragraph @@ -223,8 +224,8 @@ export const replaceAroundStep = ({ // earlier deletion with the current ID so they merge into a single tracked change. if (trackMeta.deletionMark) { const ourId = trackMeta.deletionMark.attrs.id; - const ourEmail = trackMeta.deletionMark.attrs.authorEmail; const ourDate = trackMeta.deletionMark.attrs.date; + const ourIdentity = getChangeAuthorIdentity(trackMeta.deletionMark); const searchTo = Math.min(newTr.doc.content.size, deleteFrom + 20); let contiguous = true; @@ -236,7 +237,11 @@ export const replaceAroundStep = ({ contiguous = false; // Live text — stop, deletions are no longer contiguous. return; } - if (delMark.attrs.id !== ourId && delMark.attrs.authorEmail === ourEmail && delMark.attrs.date === ourDate) { + const isSameActor = matchesSameUserRefinement({ + currentUser: ourIdentity, + change: getChangeAuthorIdentity(delMark), + }); + if (delMark.attrs.id !== ourId && isSameActor && delMark.attrs.date === ourDate) { const markType = state.schema.marks[TrackDeleteMarkName]; const merged = markType.create({ ...delMark.attrs, id: ourId }); newTr.removeMark(pos, pos + node.nodeSize, delMark); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index ae74d04181..b81075f60c 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -1,12 +1,7 @@ import { ReplaceStep } from 'prosemirror-transform'; import { Slice } from 'prosemirror-model'; -import { Selection, TextSelection } from 'prosemirror-state'; -import { markInsertion } from './markInsertion.js'; -import { markDeletion } from './markDeletion.js'; -import { TrackDeleteMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { findMarkPosition } from './documentHelpers.js'; import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; import { makeTextInsertIntent, makeTextDeleteIntent, makeTextReplaceIntent } from '../review-model/edit-intent.js'; @@ -182,6 +177,7 @@ export const replaceStep = ({ step, stepWasNormalized, originalStep, + originalStepIndex, map, user, date, @@ -197,13 +193,13 @@ export const replaceStep = ({ // Handle structural deletions with no inline content (e.g., empty paragraph removal, // paragraph joins). When there's no content being inserted and no inline content in - // the deletion range, markDeletion has nothing to mark — apply the step directly. + // the deletion range, there is no tracked inline content for the compiler to mark, so + // apply the structural step directly. // // Edge case: if a paragraph contains only TrackDelete-marked text, hasInlineContent - // returns true and the normal tracking flow runs. markDeletion skips already-deleted - // nodes, but the join still applies through the replace machinery — the delete is - // not swallowed. This is correct: the structural join merges the blocks while - // preserving the existing deletion marks on the text content. + // returns true and the compiler path runs. If the compiler cannot represent that + // mixed text/structure operation, the edit fails closed rather than applying + // untracked content. if (step.from !== step.to && step.slice.content.size === 0) { let hasInlineContent = false; newTr.doc.nodesBetween(step.from, step.to, (node) => { @@ -220,188 +216,11 @@ export const replaceStep = ({ return; } } - - const trTemp = state.apply(newTr).tr; - - // Default: insert replacement after the selected range (Word-like replace behavior). - // If the selection ends inside an existing deletion, move insertion to after that deletion span. - // NOTE: Only adjust position for single-step transactions. Multi-step transactions (like input rules) - // have subsequent steps that depend on original positions, and adjusting breaks their mapping. - let positionTo = step.to; - const isSingleStep = tr.steps.length === 1; - - if (isSingleStep) { - const probePos = Math.max(step.from, step.to - 1); - const deletionSpan = findMarkPosition(trTemp.doc, probePos, TrackDeleteMarkName); - if (deletionSpan && deletionSpan.to > positionTo) { - positionTo = deletionSpan.to; - } - } - - // When pasting into a textblock, try the open slice first so content merges inline - // instead of creating new paragraphs (prevents inserting block nodes into non-textblocks). - const baseParentIsTextblock = trTemp.doc.resolve(positionTo).parent?.isTextblock; - const shouldPreferInlineInsertion = step.from === step.to && baseParentIsTextblock; - - const tryInsert = (slice) => { - const tempTr = state.apply(newTr).tr; - // Empty slices represent pure deletions (no content to insert). - // Detecting them ensures deletion tracking runs even if `tempTr` doesn't change. - const isEmptySlice = slice?.content?.size === 0; - try { - tempTr.replaceRange(positionTo, positionTo, slice); - } catch { - return null; - } - - if (!tempTr.docChanged && !isEmptySlice) return null; - - const insertedFrom = tempTr.mapping.map(positionTo, -1); - const insertedTo = tempTr.mapping.map(positionTo, 1); - if (insertedFrom === insertedTo) return { tempTr, insertedFrom, insertedTo }; - if (shouldPreferInlineInsertion && !tempTr.doc.resolve(insertedFrom).parent?.isTextblock) return null; - return { tempTr, insertedFrom, insertedTo }; - }; - - const openSlice = Slice.maxOpen(step.slice.content, true); - const insertion = tryInsert(step.slice) || tryInsert(openSlice); - - // If we can't insert the replacement content into the temp transaction, fall back to applying the original step. - // This keeps user intent (content change) even if we can't represent it as tracked insert+delete. - if (!insertion) { - if (!newTr.maybeStep(step).failed) { - map.appendMap(step.getMap()); - } - return; - } - - const meta = {}; - const { insertedFrom, insertedTo, tempTr } = insertion; - let insertedMark = null; - let trackedInsertedSlice = Slice.empty; - - if (insertedFrom !== insertedTo) { - insertedMark = markInsertion({ - tr: tempTr, - from: insertedFrom, - to: insertedTo, - user, - date, - }); - trackedInsertedSlice = tempTr.doc.slice(insertedFrom, insertedTo); - } - - // Condense insertion down to a single replace step (so this tracked transaction remains a single-step insertion). - const docBeforeCondensedStep = newTr.doc; - const condensedStep = new ReplaceStep(positionTo, positionTo, trackedInsertedSlice, false); - if (newTr.maybeStep(condensedStep).failed) { - // If the condensed step can't be applied, fall back to the original step and skip deletion tracking. - if (!newTr.maybeStep(step).failed) { - map.appendMap(step.getMap()); - } - return; - } - - // We didn't apply the original step in its original place. We adjust the map accordingly. - // When stepWasNormalized is true, `step` is already in the mapped position space - // (originalStep.map(map) was applied before entering replaceStep). Calling .map(map) - // again would double-map positions and corrupt subsequent step/selection mapping - // in multi-step transactions. - const invertSourceStep = stepWasNormalized ? step : originalStep; - const invertSourceDoc = stepWasNormalized ? docBeforeCondensedStep : tr.docs[originalStepIndex]; - const invertStep = stepWasNormalized - ? invertSourceStep.invert(invertSourceDoc) - : invertSourceStep.invert(invertSourceDoc).map(map); - map.appendMap(invertStep.getMap()); - const mirrorIndex = map.maps.length - 1; - map.appendMap(condensedStep.getMap(), mirrorIndex); - - if (insertedFrom !== insertedTo) { - meta.insertedMark = insertedMark; - meta.step = condensedStep; - // Store insertion end position when (1) we adjusted the insertion position (e.g. past a - // deletion span), or (2) single-step replace of a range — selection mapping is wrong then - // so we need an explicit caret position. Skip for multi-step (e.g. input rules) so their - // intended selection is preserved. - const needInsertedTo = positionTo !== step.to || (isSingleStep && step.from !== step.to); - if (needInsertedTo) { - const insertionLength = insertedTo - insertedFrom; - meta.insertedTo = positionTo + insertionLength; - } - } - - if (!newTr.selection.eq(tempTr.selection)) { - syncSelectionFromTransaction({ targetTr: newTr, sourceSelection: tempTr.selection }); - } - - if (step.from !== step.to) { - const { - deletionMark, - deletionMap, - nodes: deletionNodes, - } = markDeletion({ - tr: newTr, - from: step.from, - to: step.to, - user, - date, - // SD-2607: in 'paired' mode (default), share the insertion's id so the - // two halves of a user-driven replacement resolve together. In - // 'independent' mode, pass undefined so markDeletion mints its own id - // — making the deletion an independent revision per ECMA-376 §17.13.5. - id: replacements === 'paired' ? meta.insertedMark?.attrs?.id : undefined, - }); - - meta.deletionNodes = deletionNodes; - meta.deletionMark = deletionMark; - - // Map insertedTo through deletionMap to account for position shifts from removing - // the user's own prior insertions (which markDeletion deletes instead of marking). - if (meta.insertedTo !== undefined) { - meta.insertedTo = deletionMap.map(meta.insertedTo, 1); - } - - // Normalized broad -> single-char deletions should keep the caret at the - // normalized deletion edge, not the original broad transaction selection. - // This avoids follow-up Backspace events targeting structural boundaries. - if (stepWasNormalized && !meta.insertedMark) { - meta.selectionPos = deletionMap.map(step.from, -1); - } - - map.appendMapping(deletionMap); - } - - // Add meta to the new transaction. - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); + // Every text-shaped tracked edit must be represented by compileTrackedEdit. + // If the compiler declined and the structural branch above did not apply, + // fail closed instead of keeping a second tracked-write implementation here. }; -/** - * Copies a selection from one transaction into another transaction that has a different - * document instance, while guaranteeing the resulting selection is valid for the target doc. - * - * ProseMirror selections are bound to a specific document object. Reusing a `Selection` - * created from another transaction can throw: - * `Selection passed to setSelection must point at the current document`. - * - * This helper performs a safe transfer strategy: - * 1. Clamp source selection positions to the target document bounds. - * 2. Recreate `TextSelection` directly on the target doc when possible. - * 3. If recreation fails (for example, target endpoints are no longer valid text positions), - * fall back to `Selection.near(...)` so caret placement still succeeds. - * 4. For non-text selections, use the same `Selection.near(...)` fallback. - * - * The intent is to preserve cursor location as closely as possible without ever throwing - * during tracked replay. - * - * @param {{ targetTr: import('prosemirror-state').Transaction, sourceSelection: import('prosemirror-state').Selection }} options - * @param {import('prosemirror-state').Transaction} options.targetTr - * Transaction that should receive the selection. The resulting selection is always created - * against `targetTr.doc`. - * @param {import('prosemirror-state').Selection} options.sourceSelection - * Selection taken from another transaction/document context. - * @returns {void} - */ /** * Try to route a text-shaped ReplaceStep through the overlap-aware compiler. * @@ -410,13 +229,25 @@ export const replaceStep = ({ * - `{ failed: true }` — compiler aborted (typed failure); caller must * NOT fall back to the original untracked step. * - `{ handled: false }` — compiler declined (e.g. structural step - * without inline content). Caller falls through - * to the legacy path. + * without inline content). Caller may run the + * narrow structural fallback below. * * @param {{ state: import('prosemirror-state').EditorState, tr: import('prosemirror-state').Transaction, newTr: import('prosemirror-state').Transaction, step: import('prosemirror-transform').ReplaceStep, stepWasNormalized: boolean, originalStep: import('prosemirror-transform').ReplaceStep, map: import('prosemirror-transform').Mapping, user: object, date: string, replacements: 'paired'|'independent' }} options */ -const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalStep, map, user, date, replacements }) => { - // Empty structural deletion handled by the existing legacy branch above. +const tryCompileStep = ({ + state, + tr, + newTr, + step, + stepWasNormalized, + originalStep, + originalStepIndex, + map, + user, + date, + replacements, +}) => { + // Empty structural deletion handled by the structural branch above. if (step.from !== step.to && step.slice.content.size === 0) { let hasInlineContent = false; newTr.doc.nodesBetween(step.from, step.to, (node) => { @@ -446,6 +277,11 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte date, source: 'native', }); + // Single-step user actions (text replace from one ReplaceStep) probe + // for adjacent tracked-delete spans so insertion lands past the + // strike-through content. Multi-step transactions (input rules, + // plan-engine multi-op rewrites) must not probe. + if (tr.steps.length === 1) /** @type {any} */ (intent).probeForDeletionSpan = true; } else { // Zero-op step; nothing to compile. return { handled: false }; @@ -456,6 +292,7 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte const beforeSize = newTr.doc.content.size; const beforeSteps = newTr.steps.length; + const newTrDocBeforeCompile = newTr.doc; const result = compileTrackedEdit({ state, tr: newTr, @@ -464,22 +301,44 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte }); if (!result.ok) { - // Structural fallback: when the compiler reports CAPABILITY_UNAVAILABLE - // for a content shape it cannot model (e.g. mixed structural slice), let - // the legacy path handle it. Otherwise — INVALID_TARGET or - // PRECONDITION_FAILED — fail closed. - if (result.code === 'CAPABILITY_UNAVAILABLE') return { handled: false }; return { failed: true, error: new Error(result.message) }; } - // Track that we mutated newTr. We still need to update the outer mapping - // (`map`) so subsequent steps in the same transaction can map through. - for (let i = beforeSteps; i < newTr.steps.length; i += 1) { - map.appendMap(newTr.steps[i].getMap()); + // Update the outer mapping (`map`) so subsequent original steps in the + // same transaction remap correctly into newTr.doc space. We didn't apply + // the original step in its original place (we applied a condensed insert + // at positionTo plus delete marks). For trackedTransaction's + // `originalStep.map(map)` to land subsequent steps where the user expected, + // the outer map must encode the original step's user-view position effect. + // Mirror the legacy invert+condensed dance: append the inverse of the + // source step (cancels the original step's expected map) then mirror-append + // the compiled steps (what we actually did to newTr). + const invertSourceStep = stepWasNormalized ? step : originalStep; + const invertSourceDoc = stepWasNormalized ? newTrDocBeforeCompile : tr.docs[originalStepIndex]; + let invertStep; + try { + invertStep = stepWasNormalized + ? invertSourceStep.invert(invertSourceDoc) + : invertSourceStep.invert(invertSourceDoc).map(map); + } catch { + invertStep = null; + } + + if (invertStep) { + map.appendMap(invertStep.getMap()); + const mirrorIndex = map.maps.length - 1; + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap(), mirrorIndex); + } + } else { + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap()); + } } - // Mirror the position-mapping behavior expected by trackedTransaction - // selection logic: when there is an inserted side, record `insertedTo`. + // Build comments-plugin-shaped metadata directly from the compiler result + // so the bubble pipeline can derive inserted/deleted text immediately + // (without fake step.slice payloads). const meta = {}; if (typeof result.insertedTo === 'number') { meta.insertedTo = result.insertedTo; @@ -487,9 +346,23 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte if (result.insertedMark) { meta.insertedMark = result.insertedMark; } - if (result.deletionMarks?.length) { + if (result.deletionMark) { + meta.deletionMark = result.deletionMark; + } else if (result.deletionMarks?.length) { meta.deletionMark = result.deletionMarks[0]; } + if (result.deletionNodes?.length) { + meta.deletionNodes = result.deletionNodes; + } + if (result.insertedMark && result.insertedStep) { + // Pass the real condensed ReplaceStep so the comments plugin can read + // step.slice.content (Fragment) just like the legacy code did. + meta.step = result.insertedStep; + } else if (result.insertedMark && result.insertedNodes?.length) { + // Compiler paths that don't produce a single condensed ReplaceStep — + // fall back to a shaped step the comments plugin already understands. + meta.step = { slice: { content: { content: result.insertedNodes } } }; + } if (result.selection?.kind === 'near' && stepWasNormalized && !result.insertedMark) { meta.selectionPos = result.selection.pos; } @@ -498,20 +371,3 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte return { handled: true, sizeDelta: newTr.doc.content.size - beforeSize }; }; - -const syncSelectionFromTransaction = ({ targetTr, sourceSelection }) => { - const boundedFrom = Math.max(0, Math.min(sourceSelection.from, targetTr.doc.content.size)); - const boundedTo = Math.max(0, Math.min(sourceSelection.to, targetTr.doc.content.size)); - - if (sourceSelection instanceof TextSelection) { - try { - targetTr.setSelection(TextSelection.create(targetTr.doc, boundedFrom, boundedTo)); - return; - } catch { - targetTr.setSelection(Selection.near(targetTr.doc.resolve(boundedFrom), -1)); - return; - } - } - - targetTr.setSelection(Selection.near(targetTr.doc.resolve(boundedFrom), -1)); -}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js index dcf77f01c9..b8be6965d3 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js @@ -305,8 +305,8 @@ describe('trackChangesHelpers', () => { expect(plainHasOldDeleteId).toBe(false); }); - it('removes Word-imported insertions without authorEmail when deleted', () => { - const insertXml = ` + it('removes unattributed Word-imported insertions without authorEmail when deleted', () => { + const insertXml = ` Inserted @@ -335,6 +335,42 @@ describe('trackChangesHelpers', () => { expect(finalState.doc.textContent).toBe(''); }); + it('preserves named Word-imported insertions without authorEmail when deleted by another user', () => { + const insertXml = ` + + Inserted + + `; + const nodes = parseXmlToJson(insertXml).elements; + const result = handleTrackChangeNode({ docx: {}, nodes, nodeListHandler: defaultNodeListHandler() }); + expect(result.nodes.length).toBe(1); + + const insertedMark = result.nodes?.[0]?.content?.[0]?.marks?.find((mark) => mark.type === TrackInsertMarkName); + expect(insertedMark).toBeDefined(); + expect(insertedMark.attrs?.author).toBe('Word Author'); + expect(insertedMark.attrs?.authorEmail).toBeUndefined(); + + const runNodes = result.nodes.map((node) => ProseMirrorNode.fromJSON(schema, node)); + const paragraph = schema.nodes.paragraph.create({}, runNodes); + const doc = schema.nodes.doc.create({}, paragraph); + const state = createState(doc); + + const textEntry = documentHelpers.findInlineNodes(state.doc).find(({ node }) => node.isText); + expect(textEntry).toBeDefined(); + + const deleteTr = state.tr.delete(textEntry.pos, textEntry.pos + textEntry.node.nodeSize); + deleteTr.setMeta('inputType', 'deleteContentBackward'); + const trackedDelete = trackedTransaction({ tr: deleteTr, state, user }); + const finalState = state.apply(trackedDelete); + + expect(finalState.doc.textContent).toBe('Inserted'); + const finalTextEntry = documentHelpers.findInlineNodes(finalState.doc).find(({ node }) => node.isText); + expect(finalTextEntry).toBeDefined(); + const markNames = finalTextEntry.node.marks.map((mark) => mark.type.name); + expect(markNames).toContain(TrackInsertMarkName); + expect(markNames).toContain(TrackDeleteMarkName); + }); + it('addMarkStep adds format mark metadata for styling changes', () => { const state = createState(createDocWithText('Format me')); const boldMark = schema.marks.bold.create(); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index b255c09eb3..a97118a116 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -10,6 +10,11 @@ import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; import { findMark } from '@core/helpers/index.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, +} from '../review-model/identity.js'; const COMPOSITION_INPUT_TYPES = new Set(['insertCompositionText', 'deleteCompositionText']); const COMBINING_MARK_REGEX = /^\p{Mark}$/u; @@ -85,8 +90,14 @@ const getOwnedDeadKeyPlaceholderInfoAt = ({ doc, pos, user }) => { } const textNodeAtPos = getTextNodeAtPos({ doc, pos }); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); const hasOwnTrackedInsert = textNodeAtPos?.node?.marks?.some( - (mark) => mark.type.name === TrackInsertMarkName && mark.attrs?.authorEmail === user.email, + (mark) => + mark.type.name === TrackInsertMarkName && + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), ); return hasOwnTrackedInsert ? { placeholderChar, combiningMark, textNodeAtPos } : null; @@ -303,6 +314,7 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { return { pos, placeholderChar: insertedText, + authorId: user.id, authorEmail: user.email, }; }; diff --git a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts index b642b5fc66..c41a37177d 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts @@ -12,6 +12,8 @@ export type AddCommentOptions = { commentId?: string; /** Author name (defaults to user from editor config) */ author?: string; + /** Stable actor id (defaults to user from editor config) */ + authorId?: string; /** Author email (defaults to user from editor config) */ authorEmail?: string; /** Author image URL (defaults to user from editor config) */ @@ -36,6 +38,8 @@ export type InsertCommentOptions = { commentText?: string; /** Comment creator name */ creatorName?: string; + /** Comment creator actor id */ + creatorId?: string; /** Comment creator email */ creatorEmail?: string; /** Comment creator image URL */ @@ -135,6 +139,8 @@ export type AddCommentReplyOptions = { content?: string; /** Author name (defaults to user from editor config) */ author?: string; + /** Stable actor id (defaults to user from editor config) */ + authorId?: string; /** Author email (defaults to user from editor config) */ authorEmail?: string; /** Author image URL (defaults to user from editor config) */ diff --git a/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts index ce4b81c25a..d9ec2761b6 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts @@ -180,6 +180,8 @@ export interface TrackInsertAttrs { id?: string; /** Author of the insertion */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ @@ -196,6 +198,8 @@ export interface TrackDeleteAttrs { id?: string; /** Author of the deletion */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ @@ -218,6 +222,8 @@ export interface TrackFormatAttrs { id?: string; /** Author of the format change */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ diff --git a/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js index 42e0c0d947..9929d75dcf 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js @@ -20,8 +20,10 @@ import { describe, it, expect } from 'vitest'; import { loadTestDataForEditorTests, initTestEditor } from '../helpers/helpers.js'; +import { Editor } from '@core/Editor.js'; import DocxZipper from '@core/DocxZipper.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY } from '@extensions/track-changes/review-model/word-id-allocator.js'; const TRACK_NAMES = new Set(['w:ins', 'w:del']); const FORMAT_REVISION_NAMES = new Set(['w:rPrChange', 'w:pPrChange']); @@ -325,4 +327,88 @@ describe('overlap export — allocator collision behavior', () => { editor.destroy(); } }); + + it('restores non-decimal sourceIds after Word-compatible export rewrites w:id', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + let reopened; + + try { + const schema = editor.schema; + const originalSourceId = '77eb0a88-caef-402e-9329-ea504555afa3'; + const replacementDoc = schema.nodeFromJSON({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'superdoc-origin', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-superdoc-origin', + sourceId: originalSourceId, + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + ], + }, + ], + }); + editor.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, replacementDoc.content)); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const files = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = files.find((f) => f.name === 'word/document.xml'); + expect(documentXmlEntry.content).toContain('w:id="1"'); + expect(documentXmlEntry.content).not.toContain(originalSourceId); + + const customXmlEntry = files.find((f) => f.name === 'docProps/custom.xml'); + expect(customXmlEntry.content).toContain(TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY); + expect(customXmlEntry.content).toContain(originalSourceId); + + const [roundtripDocx, roundtripMedia, roundtripMediaFiles, roundtripFonts] = await Editor.loadXmlData( + exportedBuffer, + true, + ); + ({ editor: reopened } = await initTestEditor({ + content: roundtripDocx, + media: roundtripMedia, + mediaFiles: roundtripMediaFiles, + fonts: roundtripFonts, + isHeadless: true, + trackedChanges: {}, + })); + + const sourceIds = []; + reopened.state.doc.descendants((node) => { + for (const mark of node.marks ?? []) { + if (mark.type.name === 'trackInsert') sourceIds.push(mark.attrs.sourceId); + } + }); + expect(sourceIds).toContain(originalSourceId); + } finally { + editor.destroy(); + reopened?.destroy(); + } + }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index b9df414235..77f3cb526d 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -866,6 +866,7 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ 'trackedChangeDisplayType', 'deletedText', 'resolvedTime', + 'resolvedById', 'resolvedByEmail', 'resolvedByName', 'importedAuthor', @@ -874,18 +875,27 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ const applyReplayIsDoneResolutionFallback = (target, payload = {}) => { if (!target || payload.isDone === undefined) return; - if (payload.resolvedTime != null || payload.resolvedByEmail != null || payload.resolvedByName != null) return; + if ( + payload.resolvedTime != null || + payload.resolvedById != null || + payload.resolvedByEmail != null || + payload.resolvedByName != null + ) { + return; + } // Imported replay payloads often use `isDone` while resolved fields remain null. // When resolved fields are not explicitly populated, derive sidebar/export state from `isDone`. if (payload.isDone) { target.resolvedTime = target.resolvedTime || Date.now(); + target.resolvedById = target.resolvedById || payload.creatorId || null; target.resolvedByEmail = target.resolvedByEmail || payload.creatorEmail || null; target.resolvedByName = target.resolvedByName || payload.creatorName || null; return; } target.resolvedTime = null; + target.resolvedById = null; target.resolvedByEmail = null; target.resolvedByName = null; }; @@ -1005,6 +1015,7 @@ const onEditorCommentsUpdate = (params = {}) => { const currentUser = proxy.$superdoc?.user; if (currentUser) { + if (!commentPayload.creatorId) commentPayload.creatorId = currentUser.id; if (!commentPayload.creatorName) commentPayload.creatorName = currentUser.name; if (!commentPayload.creatorEmail) commentPayload.creatorEmail = currentUser.email; } diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 557a541eb8..6aac8e72c8 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -609,6 +609,7 @@ const handleReject = () => { // disappears from getFloatingComments — even when a custom handler is used (SD-2049). if (props.comment.trackedChange) { props.comment.resolveComment({ + id: superdocStore.user.id, email: superdocStore.user.email, name: superdocStore.user.name, superdoc: proxy.$superdoc, @@ -642,6 +643,7 @@ const handleResolve = () => { // Always resolve so resolvedTime is set and the bubble disappears // from getFloatingComments — even when a custom handler is used (SD-2049). props.comment.resolveComment({ + id: superdocStore.user.id, email: superdocStore.user.email, name: superdocStore.user.name, superdoc: proxy.$superdoc, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js new file mode 100644 index 0000000000..1e57fb3a33 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +let commentsStoreStub; +let isAllowedMock; + +const { PERMISSIONS } = vi.hoisted(() => ({ + PERMISSIONS: { + RESOLVE_OWN: 'RESOLVE_OWN', + RESOLVE_OTHER: 'RESOLVE_OTHER', + REJECT_OWN: 'REJECT_OWN', + REJECT_OTHER: 'REJECT_OTHER', + COMMENTS_DELETE_OWN: 'COMMENTS_DELETE_OWN', + COMMENTS_DELETE_OTHER: 'COMMENTS_DELETE_OTHER', + }, +})); + +vi.mock('@superdoc/stores/comments-store', () => ({ + useCommentsStore: () => commentsStoreStub, +})); + +vi.mock('@superdoc/core/collaboration/permissions.js', () => ({ + PERMISSIONS, + isAllowed: (...args) => isAllowedMock(...args), +})); + +vi.mock('@superdoc/composables/useUiFontFamily.js', () => ({ + useUiFontFamily: () => ({ uiFontFamily: 'Test Sans' }), +})); + +vi.mock('@superdoc/components/general/Avatar.vue', () => ({ + default: defineComponent({ + name: 'AvatarStub', + props: ['user'], + setup(props) { + return () => h('div', { class: 'avatar-stub' }, props.user?.name ?? ''); + }, + }), +})); + +vi.mock('./CommentsDropdown.vue', () => ({ + default: defineComponent({ + name: 'CommentsDropdownStub', + props: ['options'], + setup(props, { slots }) { + return () => + h('div', { class: 'comments-dropdown-stub' }, [ + h('span', { class: 'options-labels' }, (props.options ?? []).map((option) => option.label).join(',')), + slots.default?.(), + ]); + }, + }), +})); + +import CommentHeader from './CommentHeader.vue'; + +const makeComment = (overrides = {}) => ({ + creatorId: 'alice-id', + creatorEmail: 'shared@example.com', + creatorName: 'Alice', + createdTime: Date.now(), + resolvedTime: null, + trackedChange: false, + parentCommentId: null, + trackedChangeParentId: null, + origin: null, + importedAuthor: null, + getCommentUser: () => ({ id: 'alice-id', name: 'Alice', email: 'shared@example.com' }), + ...overrides, +}); + +const mountHeader = ({ currentUser, comment, config = { readOnly: false } }) => + mount(CommentHeader, { + props: { + config, + comment, + isActive: true, + }, + global: { + config: { + globalProperties: { + $superdoc: { + config: { + role: 'editor', + isInternal: false, + user: currentUser, + }, + }, + }, + }, + }, + }); + +describe('CommentHeader.vue', () => { + beforeEach(() => { + commentsStoreStub = { + pendingComment: null, + }; + isAllowedMock = vi.fn((permission) => permission === PERMISSIONS.COMMENTS_DELETE_OWN); + }); + + it('does not treat same-email different-id comments as own comments', () => { + const wrapper = mountHeader({ + currentUser: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' }, + comment: makeComment(), + }); + + expect(wrapper.find('.comments-dropdown-stub').exists()).toBe(false); + expect(isAllowedMock).toHaveBeenCalledWith( + PERMISSIONS.COMMENTS_DELETE_OTHER, + 'editor', + false, + expect.objectContaining({ comment: expect.any(Object) }), + ); + }); + + it('keeps the imported tag for a different actor even when emails match', () => { + const wrapper = mountHeader({ + currentUser: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' }, + comment: makeComment({ origin: 'word' }), + }); + + expect(wrapper.find('.imported-tag').exists()).toBe(true); + }); +}); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index 40aab0b779..8769074984 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -2,6 +2,7 @@ import { formatDate } from './helpers'; import { superdocIcons } from '@superdoc/icons.js'; import { computed, getCurrentInstance } from 'vue'; +import { actorIdentitiesMatch } from '@superdoc/common'; import { isAllowed, PERMISSIONS } from '@superdoc/core/collaboration/permissions.js'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import Avatar from '@superdoc/components/general/Avatar.vue'; @@ -37,7 +38,12 @@ const props = defineProps({ const { proxy } = getCurrentInstance(); const role = proxy.$superdoc.config.role; const isInternal = proxy.$superdoc.config.isInternal; -const isOwnComment = props.comment.creatorEmail === proxy.$superdoc.config.user.email; +const isCommentOwnedByCurrentUser = (comment) => + actorIdentitiesMatch({ + current: proxy.$superdoc.config.user, + other: { id: comment?.creatorId, email: comment?.creatorEmail }, + }); +const isOwnComment = computed(() => isCommentOwnedByCurrentUser(props.comment)); const { uiFontFamily } = useUiFontFamily(); @@ -70,7 +76,7 @@ const allowResolve = computed(() => { superdoc: proxy.$superdoc, }; - if (isOwnComment || props.comment.trackedChange) { + if (isOwnComment.value || props.comment.trackedChange) { return isAllowed(PERMISSIONS.RESOLVE_OWN, role, isInternal, context); } else { return isAllowed(PERMISSIONS.RESOLVE_OTHER, role, isInternal, context); @@ -87,7 +93,7 @@ const allowReject = computed(() => { superdoc: proxy.$superdoc, }; - if (isOwnComment || props.comment.trackedChange) { + if (isOwnComment.value || props.comment.trackedChange) { return isAllowed(PERMISSIONS.REJECT_OWN, role, isInternal, context); } else { return isAllowed(PERMISSIONS.REJECT_OTHER, role, isInternal, context); @@ -110,11 +116,11 @@ const getOverflowOptions = computed(() => { const options = new Set(); // Only the comment creator can edit, and only when comments aren't read-only - if (!props.config.readOnly && props.comment.creatorEmail === proxy.$superdoc.config.user.email) { + if (!props.config.readOnly && isCommentOwnedByCurrentUser(props.comment)) { options.add('edit'); } - const isOwnComment = props.comment.creatorEmail === proxy.$superdoc.config.user.email; + const isOwnComment = isCommentOwnedByCurrentUser(props.comment); const context = { comment: props.comment, @@ -144,8 +150,7 @@ const handleSelect = (value) => emit('overflow-select', value); const isImported = computed(() => { const hasImportOrigin = props.comment.origin != null || !!props.comment.importedAuthor?.name; if (!hasImportOrigin) return false; - const currentUserEmail = proxy.$superdoc.config.user?.email; - if (currentUserEmail && props.comment.creatorEmail === currentUserEmail) return false; + if (isCommentOwnedByCurrentUser(props.comment)) return false; return true; }); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue b/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue index 1794f9bafe..90576eb649 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue @@ -27,6 +27,7 @@ const props = defineProps({ const addCommentEntry = (selection) => { const params = { + creatorId: props.user.id, creatorEmail: props.user.email, creatorName: props.user.name, documentId: selection.documentId, diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js b/packages/superdoc/src/components/CommentsLayer/comment-schemas.js index 27cf7670cd..e5947b254e 100644 --- a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js +++ b/packages/superdoc/src/components/CommentsLayer/comment-schemas.js @@ -1,6 +1,7 @@ export const conversation = { conversationId: null, documentId: null, + creatorId: null, creatorEmail: null, creatorName: null, comments: [], @@ -10,6 +11,7 @@ export const conversation = { export const comment = { comment: null, user: { + id: null, name: null, email: null, }, diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js b/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js index 3800899407..5f4001ebcc 100644 --- a/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js +++ b/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js @@ -6,6 +6,7 @@ describe('comment-schemas', () => { expect(conversation).toEqual({ conversationId: null, documentId: null, + creatorId: null, creatorEmail: null, creatorName: null, comments: [], @@ -16,7 +17,7 @@ describe('comment-schemas', () => { it('exposes a comment template with user/timestamp placeholders', () => { expect(comment).toEqual({ comment: null, - user: { name: null, email: null }, + user: { id: null, name: null, email: null }, timestamp: null, }); }); diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js b/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js index 5eb320fe21..1b1a23cf83 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js @@ -27,7 +27,8 @@ describe('use-comment: extended coverage', () => { it('sets resolved fields and emits a resolved event', () => { const c = useComment({ commentId: 'c-1', fileId: 'doc-1' }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); + expect(c.resolvedById).toBe('alice-id'); expect(c.resolvedByEmail).toBe('a@b.com'); expect(c.resolvedByName).toBe('Alice'); expect(typeof c.resolvedTime).toBe('number'); @@ -41,7 +42,8 @@ describe('use-comment: extended coverage', () => { it('is a no-op when already resolved', () => { const c = useComment({ commentId: 'c-1', resolvedTime: 1234 }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); + expect(c.resolvedById).toBeNull(); expect(c.resolvedByEmail).toBeNull(); expect(superdoc.emit).not.toHaveBeenCalled(); }); @@ -49,7 +51,7 @@ describe('use-comment: extended coverage', () => { it('emits when tracked change is present (suggestion resolve path)', () => { const c = useComment({ commentId: 'c-1', trackedChange: { insert: {} } }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); expect(superdoc.emit).toHaveBeenCalled(); expect(superdoc.activeEditor.commands.resolveComment).toHaveBeenCalled(); }); @@ -179,6 +181,7 @@ describe('use-comment: extended coverage', () => { creatorImage: '/a.png', }); expect(c.getCommentUser()).toEqual({ + id: null, name: 'Alice', email: 'a@b.com', image: '/a.png', diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.js b/packages/superdoc/src/components/CommentsLayer/use-comment.js index e918d28c0d..27130daeb4 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.js @@ -27,10 +27,11 @@ export default function useComment(params) { const commentElement = ref(null); const isFocused = ref(params.isFocused || false); - const creatorEmail = params.creatorEmail; - const creatorName = params.creatorName; - const creatorImage = params.creatorImage; - const createdTime = params.createdTime || Date.now(); + const creatorId = ref(params.creatorId ?? null); + const creatorEmail = ref(params.creatorEmail ?? null); + const creatorName = ref(params.creatorName ?? null); + const creatorImage = ref(params.creatorImage ?? null); + const createdTime = ref(params.createdTime || Date.now()); const importedAuthor = ref(params.importedAuthor || null); const docxCommentJSON = ref(params.docxCommentJSON || null); const origin = params.origin; @@ -65,19 +66,22 @@ export default function useComment(params) { const deletedText = ref(params.deletedText || null); const resolvedTime = ref(params.resolvedTime || null); + const resolvedById = ref(params.resolvedById || null); const resolvedByEmail = ref(params.resolvedByEmail || null); const resolvedByName = ref(params.resolvedByName || null); /** * Mark this conversation as resolved with UTC date * + * @param {String} id The actor id of the user marking this conversation as done * @param {String} email The email of the user marking this conversation as done * @param {String} name The name of the user marking this conversation as done * @returns {void} */ - const resolveComment = ({ email, name, superdoc }) => { + const resolveComment = ({ id, email, name, superdoc }) => { if (resolvedTime.value) return; resolvedTime.value = Date.now(); + resolvedById.value = id ?? null; resolvedByEmail.value = email; resolvedByName.value = name; @@ -209,7 +213,7 @@ export default function useComment(params) { const getCommentUser = () => { const user = importedAuthor.value ? { name: importedAuthor.value.name || '(Imported)', email: importedAuthor.value.email } - : { name: creatorName, email: creatorEmail, image: creatorImage }; + : { id: creatorId.value, name: creatorName.value, email: creatorEmail.value, image: creatorImage.value }; return user; }; @@ -244,10 +248,11 @@ export default function useComment(params) { return { ...u, name: u.name ? u.name : u.email }; }), createdAtVersionNumber, - creatorEmail, - creatorName, - creatorImage, - createdTime, + creatorId: creatorId.value, + creatorEmail: creatorEmail.value, + creatorName: creatorName.value, + creatorImage: creatorImage.value, + createdTime: createdTime.value, importedAuthor: importedAuthor.value, docxCommentJSON: docxCommentJSON.value, isInternal: isInternal.value, @@ -263,6 +268,7 @@ export default function useComment(params) { trackedChangeAnchorKey: trackedChangeAnchorKey.value, deletedText: deletedText.value, resolvedTime: resolvedTime.value, + resolvedById: resolvedById.value, resolvedByEmail: resolvedByEmail.value, resolvedByName: resolvedByName.value, origin, @@ -284,6 +290,7 @@ export default function useComment(params) { mentions, commentElement, isFocused, + creatorId, creatorEmail, creatorName, creatorImage, @@ -302,6 +309,7 @@ export default function useComment(params) { trackedChangeStoryLabel, trackedChangeAnchorKey, resolvedTime, + resolvedById, resolvedByEmail, resolvedByName, importedAuthor, diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 1fc61f2ee3..77834f1817 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { markRaw, toRaw } from 'vue'; import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; -import { DOCX, PDF, HTML } from '@superdoc/common'; +import { DOCX, PDF, HTML, getActorIdentityKey, normalizeActorEmail } from '@superdoc/common'; import { SuperToolbar, createZip, seedEditorStateToYDoc, onCollaborationProviderSynced } from '@superdoc/super-editor'; import { SuperComments } from '../components/CommentsLayer/commentsList/super-comments-list.js'; import { createSuperdocVueApp } from './create-app.js'; @@ -23,6 +23,7 @@ import { createDeprecatedEditorProxy } from '../helpers/deprecation.js'; import { normalizeTrackChangesConfig } from './helpers/normalize-track-changes-config.js'; const DEFAULT_USER = Object.freeze({ + id: null, name: 'Default SuperDoc user', email: null, }); @@ -1004,7 +1005,7 @@ export class SuperDoc extends EventEmitter { if (!user || user.color) return; const palette = this.colors.length > 0 ? this.colors : DEFAULT_AWARENESS_PALETTE; - const userKey = user.email || user.name || ''; + const userKey = user.id || user.email || user.name || ''; let hash = 5381; for (let i = 0; i < userKey.length; i++) { hash = ((hash << 5) + hash) ^ userKey.charCodeAt(i); @@ -1387,19 +1388,30 @@ export class SuperDoc extends EventEmitter { */ addSharedUser(user: User) { this.#requireReady('addSharedUser'); - if (this.users.some((u) => u.email === user.email)) return; + const userKey = getActorIdentityKey({ actor: user }); + if (userKey && this.users.some((u) => getActorIdentityKey({ actor: u }) === userKey)) return; this.users.push(user); } /** * Remove a user from the shared users list. Requires the instance - * to be ready for the same reason as `addSharedUser`. + * to be ready for the same reason as `addSharedUser`. Accepts + * either a user-like object or a legacy email string. * - * @param {String} email The email of the user to remove + * @param {User | string} userOrEmail The user or email of the user to remove */ - removeSharedUser(email: string) { + removeSharedUser(userOrEmail: User | string) { this.#requireReady('removeSharedUser'); - this.users = this.users.filter((u) => u.email !== email); + const legacyEmail = typeof userOrEmail === 'string' ? normalizeActorEmail(userOrEmail) : ''; + const targetKey = + typeof userOrEmail === 'string' ? `email:${legacyEmail}` : getActorIdentityKey({ actor: userOrEmail }); + + this.users = this.users.filter((u) => { + const existingKey = getActorIdentityKey({ actor: u }); + if (targetKey) return existingKey !== targetKey; + if (legacyEmail) return normalizeActorEmail(u.email) !== legacyEmail; + return true; + }); } /** diff --git a/packages/superdoc/src/core/collaboration/awareness-identity.test.js b/packages/superdoc/src/core/collaboration/awareness-identity.test.js new file mode 100644 index 0000000000..cac16c061b --- /dev/null +++ b/packages/superdoc/src/core/collaboration/awareness-identity.test.js @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { awarenessStatesToArray } from '@superdoc/common/collaboration/awareness'; + +const makeContext = () => ({ + userColorMap: new Map(), + colorIndex: 0, + config: { + colors: ['#111111', '#222222', '#333333'], + }, +}); + +describe('awareness identity dedupe', () => { + it('keeps same-email different-id actors as separate presence entries', () => { + const states = new Map([ + [1, { user: { id: 'alice-id', email: 'shared@example.com', name: 'Alice' } }], + [2, { user: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' } }], + ]); + + const result = awarenessStatesToArray(makeContext(), states); + + expect(result).toHaveLength(2); + expect(result.map((user) => user.id)).toEqual(['alice-id', 'bob-id']); + }); + + it('dedupes multiple sessions for the same actor id even when emails differ', () => { + const states = new Map([ + [1, { user: { id: 'alice-id', email: 'alice@example.com', name: 'Alice' } }], + [2, { user: { id: 'alice-id', email: 'alias@example.com', name: 'Alice (Laptop)' } }], + ]); + + const result = awarenessStatesToArray(makeContext(), states); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('alice-id'); + }); +}); diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index c21b36e039..5c8cc51aa0 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -233,7 +233,7 @@ describe('collaboration helpers', () => { superdoc = { config: { superdocId: 'doc-123', - user: { name: 'Owner', email: 'owner@example.com' }, + user: { id: 'owner-id', name: 'Owner', email: 'owner@example.com' }, role: 'editor', isInternal: false, socket: { id: 'socket' }, @@ -291,6 +291,12 @@ describe('collaboration helpers', () => { // Event from same user should be ignored commentsArray.emit({ transaction: { origin: { user: superdoc.config.user } } }); expect(useCommentMock).toHaveBeenCalledTimes(2); + + // Same email but different actor id should not be ignored. + commentsArray.emit({ + transaction: { origin: { user: { id: 'other-id', name: 'Other', email: superdoc.config.user.email } } }, + }); + expect(useCommentMock).toHaveBeenCalledTimes(4); }); it('initCollaborationComments loads existing comments from ydoc on init', () => { diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js index 9ba0a54e48..bdf416f8bc 100644 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ b/packages/superdoc/src/core/collaboration/helpers.js @@ -1,5 +1,6 @@ import { createProvider } from '../collaboration/collaboration'; import useComment from '../../components/CommentsLayer/use-comment'; +import { actorIdentitiesMatch } from '@superdoc/common'; import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; @@ -97,7 +98,7 @@ export const initCollaborationComments = (superdoc) => { const origin = event?.transaction?.origin; const { user = {} } = origin || {}; - if (currentUser.name === user.name && currentUser.email === user.email) return; + if (actorIdentitiesMatch({ current: currentUser, other: user })) return; // Update conversations updateCommentsStore(); diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 41f1fe5029..6674145a2b 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -93,8 +93,8 @@ export interface AwarenessUser extends User { * The runtime helper `awarenessStatesToArray` spreads each remote user * onto the top of the entry (`{ clientId, ...value.user, color }`), so * `User` fields like `name`, `email`, `image` appear at the top level - * (not nested under a `user` property). Consumers should read - * `state.name` / `state.email`, not `state.user.name`. + * (not nested under a `user` property). Consumers should read `state.id`, + * `state.name`, and `state.email`, not `state.user.name`. * * Application-specific fields attached to the awareness state by the * provider surface through the `[key: string]: unknown` index diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index d15842eae5..234a9b6f87 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -39,7 +39,17 @@ const wordBaselineServiceUrl = 'http://127.0.0.1:9185'; const clampOpacity = (v) => Math.min(1, Math.max(0, v)); const overlayOpacityFromUrl = Number.parseFloat(urlParams.get('wordOverlayOpacity') ?? '0.45'); const isInternal = urlParams.has('internal'); -const testUserEmail = urlParams.get('email') || 'user@superdoc.com'; +const ensureStableDevTabId = () => { + const storageKey = 'superdoc-dev-tab-id'; + const existingId = window.sessionStorage.getItem(storageKey); + if (existingId) return existingId; + const nextId = `dev-${crypto.randomUUID()}`; + window.sessionStorage.setItem(storageKey, nextId); + return nextId; +}; +const stableDevTabId = ensureStableDevTabId(); +const testUserId = urlParams.get('userId') || urlParams.get('id') || stableDevTabId; +const testUserEmail = urlParams.get('email') || `${stableDevTabId}@dev.superdoc`; const testUserName = urlParams.get('name') || `SuperDoc ${Math.floor(1000 + Math.random() * 9000)}`; const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); @@ -111,6 +121,7 @@ const handleLoadFromUrl = async () => { }; const user = { + id: testUserId, name: testUserName, email: testUserEmail, }; @@ -1155,7 +1166,7 @@ onMounted(async () => { const ydoc = new Y.Doc(); const provider = new WebsocketProvider(collabUrl, collabRoom, ydoc, { params: { - userId: user.email || user.name, + userId: user.id || user.email || user.name, }, }); diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 65a8e625ea..de56c792e3 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -216,17 +216,20 @@ export const useCommentsStore = defineStore('comments', () => { if (!comment) return; if ( comment.resolvedTime !== undefined || + comment.resolvedById !== undefined || comment.resolvedByEmail !== undefined || comment.resolvedByName !== undefined ) { trackedChangeResolutionSnapshots.set(comment, { resolvedTime: comment.resolvedTime ?? null, + resolvedById: comment.resolvedById ?? null, resolvedByEmail: comment.resolvedByEmail ?? null, resolvedByName: comment.resolvedByName ?? null, }); } // Sets the resolved state to null so it can be restored in the comments sidebar comment.resolvedTime = null; + comment.resolvedById = null; comment.resolvedByEmail = null; comment.resolvedByName = null; }; @@ -518,6 +521,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType, trackedChangeDisplayType, deletedText, + authorId, authorEmail, authorImage, date, @@ -557,6 +561,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeDisplayType, deletedText, createdTime: date, + creatorId: authorId ?? null, creatorName: authorName, creatorEmail: authorEmail, creatorImage: authorImage, @@ -606,6 +611,7 @@ export const useCommentsStore = defineStore('comments', () => { }; const setIfChanged = (target, key, value) => { + if (target?.[key] == null && value == null) return false; if (!target || shallowEqual(target[key], value)) return false; target[key] = value; return true; @@ -635,6 +641,11 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType: trackedChangeType ?? null, trackedChangeDisplayType: trackedChangeDisplayType ?? null, deletedText: deletedText ?? null, + creatorId: authorId ?? null, + creatorName: authorName ?? null, + creatorEmail: authorEmail ?? null, + creatorImage: authorImage ?? null, + createdTime: date ?? null, }; let didChange = false; @@ -646,7 +657,10 @@ export const useCommentsStore = defineStore('comments', () => { const updateExistingTrackedChange = (trackedComment) => { const wasResolved = Boolean( - trackedComment.resolvedTime || trackedComment.resolvedByEmail || trackedComment.resolvedByName, + trackedComment.resolvedTime || + trackedComment.resolvedById || + trackedComment.resolvedByEmail || + trackedComment.resolvedByName, ); if (wasResolved) clearResolvedMetadata(trackedComment); // AIDEV-NOTE: Targeted tracked-change refresh runs during body typing. @@ -684,6 +698,7 @@ export const useCommentsStore = defineStore('comments', () => { } else if (event === 'resolve') { const existingTrackedChange = findTrackedChangeById(); const resolveArgs = { + id: params.resolvedById ?? superdoc?.user?.id ?? null, email: params.resolvedByEmail ?? superdoc?.user?.email ?? null, name: params.resolvedByName ?? superdoc?.user?.name ?? null, superdoc, @@ -988,6 +1003,7 @@ export const useCommentsStore = defineStore('comments', () => { fileId: activeDocument.id, fileType: activeDocument.type, parentCommentId, + creatorId: superdocStore.user.id, creatorEmail: superdocStore.user.email, creatorName: superdocStore.user.name, creatorImage: superdocStore.user.image, @@ -1186,6 +1202,7 @@ export const useCommentsStore = defineStore('comments', () => { isInternal: false, parentCommentId: comment.parentCommentId, trackedChangeParentId: comment.trackedChangeParentId, + creatorId: null, creatorName, createdTime: comment.createdTime, creatorEmail: comment.creatorEmail, @@ -1195,6 +1212,7 @@ export const useCommentsStore = defineStore('comments', () => { }, commentText: htmlContent, resolvedTime: comment.isDone ? Date.now() : null, + resolvedById: null, resolvedByEmail: comment.isDone ? comment.creatorEmail : null, resolvedByName: comment.isDone ? importedName || '(Imported)' : null, trackedChange: comment.trackedChange || false, @@ -1404,6 +1422,7 @@ export const useCommentsStore = defineStore('comments', () => { const resolutionSnapshot = trackedChangeResolutionSnapshots.get(comment); if (resolutionSnapshot) { comment.resolvedTime = resolutionSnapshot.resolvedTime ?? Date.now(); + comment.resolvedById = resolutionSnapshot.resolvedById ?? null; comment.resolvedByEmail = resolutionSnapshot.resolvedByEmail ?? null; comment.resolvedByName = resolutionSnapshot.resolvedByName ?? null; restoredComments.push(comment); @@ -1593,6 +1612,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType: snapshot.type, trackedChangeDisplayType: snapshot.type, deletedText: snapshot.type === 'delete' ? (snapshot.excerpt ?? '') : null, + authorId: snapshot.authorId, authorEmail: snapshot.authorEmail, authorImage: snapshot.authorImage, date: snapshot.date, diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 2440913c54..ba1d9a01d9 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -604,7 +604,7 @@ describe('comments-store', () => { it('resolves tracked change comments on resolve events', () => { const superdoc = { emit: vi.fn(), - user: { email: 'reviewer@example.com', name: 'Reviewer' }, + user: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, }; const existingComment = { @@ -614,8 +614,9 @@ describe('comments-store', () => { resolvedByEmail: null, resolvedByName: null, getValues: vi.fn(() => ({ commentId: 'change-resolve-1', resolvedTime: Date.now() })), - resolveComment: vi.fn(function ({ email, name }) { + resolveComment: vi.fn(function ({ id, email, name }) { this.resolvedTime = Date.now(); + this.resolvedById = id; this.resolvedByEmail = email; this.resolvedByName = name; const emitData = { type: comments_module_events.RESOLVED, comment: this.getValues() }; @@ -634,6 +635,7 @@ describe('comments-store', () => { }); expect(existingComment.resolveComment).toHaveBeenCalledWith({ + id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer', superdoc, @@ -654,7 +656,7 @@ describe('comments-store', () => { it('cascades resolve to user comments anchored to the same tracked change (SD-2528)', async () => { const superdoc = { emit: vi.fn(), - user: { email: 'reviewer@example.com', name: 'Reviewer' }, + user: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, }; const trackedChangeComment = { @@ -699,6 +701,7 @@ describe('comments-store', () => { await Promise.resolve(); expect(linkedUserComment.resolveComment).toHaveBeenCalledTimes(1); expect(linkedUserComment.resolveComment).toHaveBeenCalledWith({ + id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer', superdoc, diff --git a/shared/common/collaboration/awareness.ts b/shared/common/collaboration/awareness.ts index 564ec4c377..fa593f30a8 100644 --- a/shared/common/collaboration/awareness.ts +++ b/shared/common/collaboration/awareness.ts @@ -1,3 +1,5 @@ +import { getActorIdentityKey } from '../identity'; + type ReadonlyLooseRecord = Readonly>; /** @@ -6,8 +8,10 @@ type ReadonlyLooseRecord = Readonly>; export type HexColor = `#${string}`; export interface User extends ReadonlyLooseRecord { - readonly email: string; - readonly name?: string; + readonly id?: string | null; + readonly email?: string | null; + readonly name?: string | null; + readonly color?: HexColor | string; } export interface AwarenessState extends ReadonlyLooseRecord { @@ -49,17 +53,17 @@ export const awarenessStatesToArray = ( return Array.from(states.entries()) .filter(hasUser) - .filter(([, value]) => { - const userEmail = value.user.email; - if (seenUsers.has(userEmail)) return false; - seenUsers.add(userEmail); + .filter(([clientId, value]) => { + const identityKey = getActorIdentityKey({ actor: value.user, fallbackKey: clientId }); + if (!identityKey) return false; + if (seenUsers.has(identityKey)) return false; + seenUsers.add(identityKey); return true; }) .map(([key, value]) => { - // Type narrowing guarantees user exists here - const email = value.user.email; + const identityKey = getActorIdentityKey({ actor: value.user, fallbackKey: key }); - let color = context.userColorMap.get(email); + let color = context.userColorMap.get(identityKey); if (!color) { // Prefer the color already set on the user's awareness state (e.g. hash-assigned by SuperDoc). // Fall back to the configured palette if available. @@ -69,7 +73,7 @@ export const awarenessStatesToArray = ( (context.config.colors.length > 0 ? context.config.colors[context.colorIndex % context.config.colors.length] : (undefined as unknown as HexColor)); - context.userColorMap.set(email, color); + context.userColorMap.set(identityKey, color); context.colorIndex++; } diff --git a/shared/common/comments-types.ts b/shared/common/comments-types.ts index 1a6b037ad9..61959cb578 100644 --- a/shared/common/comments-types.ts +++ b/shared/common/comments-types.ts @@ -4,6 +4,7 @@ export type Comment = { fileId: string; fileType: string; mentions: unknown[]; + creatorId?: string | null; creatorName: string; creatorEmail?: string; createdTime: number; @@ -25,6 +26,7 @@ export type Comment = { trackedChangeDisplayType?: 'hyperlinkAdded' | 'hyperlinkModified' | null; deletedText: string | null; resolvedTime: number | null; + resolvedById: string | null; resolvedByEmail: string | null; resolvedByName: string | null; commentJSON: CommentJSON; diff --git a/shared/common/identity.ts b/shared/common/identity.ts new file mode 100644 index 0000000000..acd0b29f25 --- /dev/null +++ b/shared/common/identity.ts @@ -0,0 +1,96 @@ +type IdentityLike = Readonly> | null | undefined; + +export interface NormalizedActorIdentity { + readonly id: string; + readonly email: string; + readonly name: string; + readonly hasId: boolean; + readonly hasEmail: boolean; +} + +/** + * Trim a principal id. Actor ids are treated as opaque, case-sensitive values. + */ +export const normalizeActorId = (value: unknown): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +/** + * Trim and lowercase an email value. + */ +export const normalizeActorEmail = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +/** + * Trim and lowercase a display-name value. + */ +export const normalizeActorName = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +export const getActorIdentity = (value: IdentityLike): NormalizedActorIdentity => { + const record = (value ?? {}) as Record; + const id = normalizeActorId(record.id); + const email = normalizeActorEmail(record.email); + const name = typeof record.name === 'string' ? record.name : ''; + return { + id, + email, + name, + hasId: id.length > 0, + hasEmail: email.length > 0, + }; +}; + +/** + * Principal-first actor comparison. + * + * - If both sides have ids, ids decide. + * - Otherwise, when both sides have emails, emails decide. + * - Missing comparable identifiers means "not provably same actor". + */ +export const actorIdentitiesMatch = ({ current, other }: { current?: IdentityLike; other?: IdentityLike }): boolean => { + const currentIdentity = getActorIdentity(current); + const otherIdentity = getActorIdentity(other); + + if (currentIdentity.hasId && otherIdentity.hasId) { + return currentIdentity.id === otherIdentity.id; + } + + if (currentIdentity.hasEmail && otherIdentity.hasEmail) { + return currentIdentity.email === otherIdentity.email; + } + + return false; +}; + +/** + * Stable identity key for dedupe/color assignment. + * + * Id wins over email. Callers may supply a per-session fallback key when no + * durable principal data exists. + */ +export const getActorIdentityKey = ({ + actor, + fallbackKey = '', +}: { + actor?: IdentityLike; + fallbackKey?: string | number | null | undefined; +}): string => { + const identity = getActorIdentity(actor); + if (identity.hasId) return `id:${identity.id}`; + if (identity.hasEmail) return `email:${identity.email}`; + + const fallback = + typeof fallbackKey === 'number' ? String(fallbackKey) : typeof fallbackKey === 'string' ? fallbackKey.trim() : ''; + if (fallback) return `fallback:${fallback}`; + return ''; +}; diff --git a/shared/common/index.ts b/shared/common/index.ts index 21158ff68a..bacdc8cfa4 100644 --- a/shared/common/index.ts +++ b/shared/common/index.ts @@ -16,6 +16,9 @@ export type { CommentThreadingStyle, } from './comments-types'; +// Identity helpers +export * from './identity'; + // List numbering helpers export * from './list-numbering'; export * from './list-rendering'; From 0b24363ed228b17ebfa59c150ec4d716315b4edc Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:39:25 -0700 Subject: [PATCH 014/280] fix: more cases --- .../Editor.track-changes-dispatch.test.js | 104 ++++++++++++++++++ .../src/editors/v1/core/Editor.ts | 6 +- .../track-changes/review-model/edit-intent.js | 32 +++++- .../track-changes/review-model/identity.js | 24 ++++ .../review-model/identity.test.js | 17 +++ .../review-model/overlap-compiler.js | 6 +- .../review-model/overlap-compiler.test.js | 43 +++++++- .../trackChangesHelpers/replaceStep.js | 20 +++- .../trackChangesHelpers/trackedTransaction.js | 7 +- 9 files changed, 250 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 5cd08abf50..8aceeb7d04 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -276,6 +276,110 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + it('protects anonymous live tracked insertion from direct delete without a configured editor user', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: { name: '', email: '' }, id: FOREIGN_INSERT_ID }); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + + deleteText(editor, INSERTED_TAIL); + + expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: '', + authorEmail: '', + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects anonymous live tracked insertion from document-api direct delete', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: { name: '', email: '' }, id: FOREIGN_INSERT_ID }); + + const receipt = editor.doc.delete({ ref: getFirstMatchRef(editor, INSERTED_TAIL) }, { changeMode: 'direct' }); + + expect(receipt.success).toBe(true); + expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: '', + authorEmail: '', + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects tracked insertion created by document-api insert from document-api direct delete', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: { id: 'cli', name: 'CLI' }, + useImmediateSetTimeout: false, + })); + + expect(editor.doc.trackChanges.list().total).toBe(0); + expect(editor.doc.comments.list().total).toBe(0); + + const insertReceipt = editor.doc.insert({ value: 'live-review-comment' }, { changeMode: 'tracked' }); + expect(insertReceipt.success).toBe(true); + + const insertMark = markEntries(editor, TrackInsertMarkName).find(({ text }) => text === 'live-review-comment'); + expect(insertMark?.mark.attrs).toEqual( + expect.objectContaining({ + author: 'CLI', + authorId: 'cli', + authorEmail: '', + }), + ); + + const deleteReceipt = editor.doc.delete({ ref: getFirstMatchRef(editor, 'review') }, { changeMode: 'direct' }); + + expect(deleteReceipt.success).toBe(true); + expect(editor.state.doc.textContent).toContain('live-review-comment'); + + expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live-review-comment'); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'review'); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: 'CLI', + authorId: 'cli', + authorEmail: '', + overlapParentId: insertMark?.mark.attrs.id, + }), + ); + + const trackedChanges = editor.doc.trackChanges.list(); + expect(trackedChanges.total).toBe(2); + expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type).sort()).toEqual(['delete', 'insert']); + + const comments = editor.doc.comments.list(); + expect(comments.total).toBe(2); + expect(comments.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live-review-comment' }), + expect.objectContaining({ trackedChangeType: 'delete', deletedText: 'review' }), + ]), + ); + }); + it('protects another user tracked insertion from direct replace while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 2f25eb0b29..6311e091da 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2911,6 +2911,9 @@ export class Editor extends EventEmitter { const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); + if (protectsExistingTrackedReviewState && skipTrackChanges) { + transactionToApply.setMeta('protectTrackedReviewState', true); + } const shouldTrack = ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; @@ -2918,11 +2921,12 @@ export class Editor extends EventEmitter { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); } + const trackedUser = this.options.user ?? {}; transactionToApply = shouldTrack ? trackedTransaction({ tr: transactionToApply, state: prevState, - user: this.options.user!, + user: trackedUser, replacements: this.options.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired', }) : transactionToApply; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js index 57e2580587..c7b1acff4d 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -36,6 +36,10 @@ import { Slice, Fragment } from 'prosemirror-model'; * probe for an adjacent tracked-delete span and move the insertion to * after it. Single-step user replace turns this on; multi-step transactions * leave it off so each granular op lands at its own position. + * @property {boolean} [preserveExistingReviewState] When true, the compiler + * must not collapse/refine existing tracked review marks even if the + * current user owns them. Used by explicit direct mutations that are + * re-routed through tracking only to protect existing review state. */ /** @@ -102,10 +106,20 @@ export const toSliceContent = (schema, content) => { * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ -export const makeTextInsertIntent = ({ at, content, schema, user, date, source, replacementGroupHint }) => { +export const makeTextInsertIntent = ({ + at, + content, + schema, + user, + date, + source, + replacementGroupHint, + preserveExistingReviewState, +}) => { if (!isFiniteNonNeg(at)) { throw new Error('makeTextInsertIntent: `at` must be a non-negative finite number'); } @@ -119,6 +133,7 @@ export const makeTextInsertIntent = ({ at, content, schema, user, date, source, date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; @@ -132,10 +147,19 @@ export const makeTextInsertIntent = ({ at, content, schema, user, date, source, * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ -export const makeTextDeleteIntent = ({ from, to, user, date, source, replacementGroupHint }) => { +export const makeTextDeleteIntent = ({ + from, + to, + user, + date, + source, + replacementGroupHint, + preserveExistingReviewState, +}) => { if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { throw new Error('makeTextDeleteIntent: `from`/`to` must be non-negative finite numbers'); } @@ -148,6 +172,7 @@ export const makeTextDeleteIntent = ({ from, to, user, date, source, replacement date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; @@ -164,6 +189,7 @@ export const makeTextDeleteIntent = ({ from, to, user, date, source, replacement * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ @@ -177,6 +203,7 @@ export const makeTextReplaceIntent = ({ date, source, replacementGroupHint, + preserveExistingReviewState, }) => { if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { throw new Error('makeTextReplaceIntent: `from`/`to` must be non-negative finite numbers'); @@ -194,6 +221,7 @@ export const makeTextReplaceIntent = ({ date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js index 9bd91d2d91..0871012543 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -92,6 +92,28 @@ const hasImportedAuthorConflict = ({ currentUser, change }) => { return true; }; +const hasImportedInsertionProvenance = (attrs) => { + const sourceId = attrs?.sourceId; + if (sourceId !== undefined && sourceId !== null && String(sourceId).trim()) { + return true; + } + + if (normalizeImportedAuthorName(attrs?.importedAuthor)) { + return true; + } + + const sourceIds = attrs?.sourceIds; + if (typeof sourceIds === 'string') { + const trimmed = sourceIds.trim(); + return Boolean(trimmed && trimmed !== '{}' && trimmed !== 'null'); + } + if (sourceIds && typeof sourceIds === 'object' && !Array.isArray(sourceIds)) { + return Object.keys(sourceIds).length > 0; + } + + return false; +}; + /** * @typedef {( * | 'same-user' @@ -197,6 +219,8 @@ export const shouldCollapseNoEmailInsertion = ({ currentUser, insertionAttrs }) const authorEmail = normalizeEmail(insertionAttrs?.authorEmail); if (authorEmail) return false; + if (!hasImportedInsertionProvenance(insertionAttrs)) return false; + const authorName = normalizeName(insertionAttrs?.author) || normalizeImportedAuthorName(insertionAttrs?.importedAuthor); if (!authorName) return true; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js index 0c7cc59660..76fcea2d77 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js @@ -6,6 +6,7 @@ import { classifyOwnership, isSameUserHighConfidence, matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, } from './identity.js'; describe('review-model/identity', () => { @@ -170,5 +171,21 @@ describe('review-model/identity', () => { }), ).toBe(false); }); + + it('only collapses no-email insertions through imported provenance', () => { + expect( + shouldCollapseNoEmailInsertion({ + currentUser: { name: '', email: '' }, + insertionAttrs: { author: '', authorEmail: '', sourceId: '1' }, + }), + ).toBe(true); + + expect( + shouldCollapseNoEmailInsertion({ + currentUser: { name: '', email: '' }, + insertionAttrs: { author: '', authorEmail: '', sourceId: '' }, + }), + ).toBe(false); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index 0f46f4ea47..bb07ac4a7e 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -560,7 +560,9 @@ const applyTrackedDelete = ( change: getChangeAuthorIdentity(segmentAtPos?.attrs ?? insertMark.attrs), }); const ownership = isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; - if (ownership === 'same-user' || isImportedOwnInsertion(insertMark)) { + const shouldCollapseOwnInsertion = + !ctx.intent.preserveExistingReviewState && (ownership === 'same-user' || isImportedOwnInsertion(insertMark)); + if (shouldCollapseOwnInsertion) { // Own insertion → collapse (remove proposed content). ops.push({ kind: 'collapse', from: segFrom, to: segTo, changeId: insertMark.attrs.id }); return; @@ -722,7 +724,7 @@ const compileTextReplace = (ctx, intent) => { // that inserted side. Rejecting the original insertion must still remove // all proposed content, including the replacement text. const ownInsertedTarget = getSingleFullyCoveringOwnInsertedSegment(ctx, segments, intent.from, intent.to); - if (ownInsertedTarget) { + if (ownInsertedTarget && !intent.preserveExistingReviewState) { const deleteResult = applyTrackedDelete(ctx, intent.from, intent.to, { replacementGroupId: '', replacementSideId: '', diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index 01aa8220e7..166ece1fe4 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -429,7 +429,12 @@ describe('overlap-compiler: text-delete', () => { const parentId = 'ins-unattributed'; const { state } = stateFromTrackedSpans({ schema, - spans: [{ text: 'draft', marks: [insertMark({ id: parentId, author: '', authorEmail: '', date: FIXED_DATE })] }], + spans: [ + { + text: 'draft', + marks: [insertMark({ id: parentId, author: '', authorEmail: '', sourceId: '1', date: FIXED_DATE })], + }, + ], }); const intent = makeTextDeleteIntent({ from: 1, to: 6, user: BOB, date: FIXED_DATE, source: 'document-api' }); const result = runCompile({ state, intent }); @@ -440,6 +445,42 @@ describe('overlap-compiler: text-delete', () => { expect(result.removedChangeIds).toEqual([parentId]); }); + it('creates a child deletion inside a live anonymous no-email insertion', () => { + const parentId = 'ins-live-anonymous'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'live-review-comment', + marks: [insertMark({ id: parentId, author: '', authorEmail: '', sourceId: '', date: FIXED_DATE })], + }, + ], + }); + const intent = makeTextDeleteIntent({ + from: 6, + to: 12, + user: { name: '', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('live-review-comment'); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + expect(parent.insertedSegments.map((segment) => segment.text).join('')).toBe('live-review-comment'); + + const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Deletion); + expect(child.deletedSegments[0].text).toBe('review'); + expect(child.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + it('no-ops when deleting inside own deletion', () => { const delId = 'del-alice'; const { state } = stateFromTrackedSpans({ diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index b81075f60c..e3134c7a8f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -263,10 +263,25 @@ const tryCompileStep = ({ // type; mixed (text-replace) carries the original slice. let intent; try { + const preserveExistingReviewState = tr.getMeta('protectTrackedReviewState') === true; if (step.from === step.to && step.slice.content.size > 0) { - intent = makeTextInsertIntent({ at: step.from, content: step.slice, user, date, source: 'native' }); + intent = makeTextInsertIntent({ + at: step.from, + content: step.slice, + user, + date, + source: 'native', + preserveExistingReviewState, + }); } else if (step.from !== step.to && step.slice.content.size === 0) { - intent = makeTextDeleteIntent({ from: step.from, to: step.to, user, date, source: 'native' }); + intent = makeTextDeleteIntent({ + from: step.from, + to: step.to, + user, + date, + source: 'native', + preserveExistingReviewState, + }); } else if (step.from !== step.to && step.slice.content.size > 0) { intent = makeTextReplaceIntent({ from: step.from, @@ -276,6 +291,7 @@ const tryCompileStep = ({ user, date, source: 'native', + preserveExistingReviewState, }); // Single-step user actions (text replace from one ReplaceStep) probe // for adjacent tracked-delete spans so insertion lands past the diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index a97118a116..dcdbc8f3ac 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -348,7 +348,12 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) const isProgrammaticInput = tr.getMeta('inputType') === 'programmatic'; const ySyncMeta = tr.getMeta(ySyncPluginKey); const pendingDeadKeyPlaceholder = TrackChangesBasePluginKey.getState(state)?.pendingDeadKeyPlaceholder ?? null; - const allowedMeta = new Set([...onlyInputTypeMeta, ySyncPluginKey.key, 'forceTrackChanges']); + const allowedMeta = new Set([ + ...onlyInputTypeMeta, + ySyncPluginKey.key, + 'forceTrackChanges', + 'protectTrackedReviewState', + ]); const hasDisallowedMeta = tr.meta && Object.keys(tr.meta).some((meta) => !allowedMeta.has(meta)); if ( From 8f893f7a9dafa4ad7eac9489ce88288b779069b1 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:51:23 -0700 Subject: [PATCH 015/280] fix: expose tracked mark predicate option --- .../track-changes/trackChangesHelpers/findTrackedMarkBetween.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index 89c98873ec..7c89f7175b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -16,6 +16,8 @@ * @param {import('./types.js').Attrs} [args.attrs] - Partial attrs * to match; every key listed must equal the candidate's attr value. * Defaults to `{}` (no attr constraint). + * @param {((mark: import('./types.js').PmMark) => boolean) | null} [args.predicate] - + * Additional predicate a candidate mark must satisfy. * @param {number} [args.offset] - Expand the range by this many * positions on each side. Defaults to `1` to catch non-inclusive marks. * @returns {import('./types.js').TrackedMarkRange | null} The first From bb884e107444379113f08fd13793d26553976fa8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:27:16 -0700 Subject: [PATCH 016/280] fix: restore tracked change comment interactions --- .../review-model/overlap-compiler.js | 22 ++---- .../review-model/overlap-compiler.test.js | 27 +++++++ .../CommentsLayer/CommentHeader.test.js | 14 ++++ .../CommentsLayer/CommentHeader.vue | 25 ++++-- .../superdoc/src/stores/comments-store.js | 78 ++++++++++++++++++- .../src/stores/comments-store.test.js | 29 +++++++ 6 files changed, 169 insertions(+), 26 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index bb07ac4a7e..42f87dcfad 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -457,8 +457,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = * Behavior per segment: * - own-insertion (covered/partial) → collapse: remove the inserted slice * - other-insertion → child trackDelete with overlapParentId - * - own-deletion → no-op (preserve) - * - other-deletion → preserve parent; child trackDelete with overlapParentId + * - existing deletion → no-op (plain delete preserves existing review ids) * - live content → trackDelete mark * * @param {*} ctx @@ -581,7 +580,6 @@ const applyTrackedDelete = ( } if (existingDelete) { - const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); if (reassignExistingDeletions) { ops.push({ @@ -593,20 +591,10 @@ const applyTrackedDelete = ( }); return; } - if (ownership === 'same-user') { - // Inside own deletion → no semantic change (preserve original). - ops.push({ kind: 'noop', from: segFrom, to: segTo }); - return; - } - // Inside different-user deletion → child trackDelete with overlapParentId. - ops.push({ - kind: 'mark-delete', - from: segFrom, - to: segTo, - node, - parentId: existingDelete.attrs.id, - parentSide: SegmentSide.Deleted, - }); + // Plain delete inside any existing deletion is already represented by + // review state. Preserve the original mark ids and do not add a nested + // delete unless the replacement path explicitly asked to reassign them. + ops.push({ kind: 'noop', from: segFrom, to: segTo }); return; } diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index 166ece1fe4..cbc95fb9b9 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -498,6 +498,33 @@ describe('overlap-compiler: text-delete', () => { expect(graph.changes.size).toBe(1); expect(Array.from(graph.changes.values())[0].id).toBe(delId); }); + + it('preserves another user deletion when plain-deleting through it', () => { + const delId = 'del-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: delId, authorEmail: BOB.email, date: FIXED_DATE })] }, + { text: ' live' }, + ], + }); + const intent = makeTextDeleteIntent({ from: 4, to: 10, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const deletedChanges = Array.from(graph.changes.values()).filter( + (change) => change.type === CanonicalChangeType.Deletion, + ); + expect(deletedChanges.map((change) => change.id).sort()).toEqual([delId, result.deletionMarks[0].attrs.id].sort()); + expect( + graph.changes + .get(delId) + ?.deletedSegments.map((segment) => segment.text) + .join(''), + ).toBe('gone'); + }); }); describe('overlap-compiler: text-replace inside named no-email insertion', () => { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js index 1e57fb3a33..5875ca3fb4 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js @@ -115,6 +115,20 @@ describe('CommentHeader.vue', () => { ); }); + it('allows the anonymous default user to edit comments created in the same session', () => { + const wrapper = mountHeader({ + currentUser: { id: null, email: null, name: 'Default SuperDoc user' }, + comment: makeComment({ + creatorId: null, + creatorEmail: null, + creatorName: 'Default SuperDoc user', + getCommentUser: () => ({ id: null, name: 'Default SuperDoc user', email: null }), + }), + }); + + expect(wrapper.find('.options-labels').text()).toContain('Edit'); + }); + it('keeps the imported tag for a different actor even when emails match', () => { const wrapper = mountHeader({ currentUser: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' }, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index 8769074984..00c56453ef 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -2,7 +2,7 @@ import { formatDate } from './helpers'; import { superdocIcons } from '@superdoc/icons.js'; import { computed, getCurrentInstance } from 'vue'; -import { actorIdentitiesMatch } from '@superdoc/common'; +import { actorIdentitiesMatch, getActorIdentity, normalizeActorName } from '@superdoc/common'; import { isAllowed, PERMISSIONS } from '@superdoc/core/collaboration/permissions.js'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import Avatar from '@superdoc/components/general/Avatar.vue'; @@ -38,11 +38,24 @@ const props = defineProps({ const { proxy } = getCurrentInstance(); const role = proxy.$superdoc.config.role; const isInternal = proxy.$superdoc.config.isInternal; -const isCommentOwnedByCurrentUser = (comment) => - actorIdentitiesMatch({ - current: proxy.$superdoc.config.user, - other: { id: comment?.creatorId, email: comment?.creatorEmail }, - }); +const isCommentOwnedByCurrentUser = (comment) => { + const currentUser = proxy.$superdoc.config.user; + const otherUser = { id: comment?.creatorId, email: comment?.creatorEmail }; + if (actorIdentitiesMatch({ current: currentUser, other: otherUser })) return true; + + const currentIdentity = getActorIdentity(currentUser); + const otherIdentity = getActorIdentity(otherUser); + if (currentIdentity.hasId || currentIdentity.hasEmail || otherIdentity.hasId || otherIdentity.hasEmail) { + return false; + } + + const hasImportOrigin = comment?.origin != null || Boolean(comment?.importedAuthor?.name); + if (hasImportOrigin) return false; + + const currentName = normalizeActorName(currentUser?.name); + const commentName = normalizeActorName(comment?.creatorName); + return Boolean(currentName && commentName && currentName === commentName); +}; const isOwnComment = computed(() => isCommentOwnedByCurrentUser(props.comment)); const { uiFontFamily } = useUiFontFamily(); diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index de56c792e3..29e600b968 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -101,6 +101,77 @@ export const useCommentsStore = defineStore('comments', () => { } }; + const normalizeCommentId = (id) => (id === undefined || id === null ? null : String(id)); + + const getPositionEntryByAlias = (id) => { + const normalizedId = normalizeCommentId(id); + if (!normalizedId) return { key: null, entry: null }; + + const positions = editorCommentPositions.value || {}; + if (positions[normalizedId] !== undefined) { + return { key: normalizedId, entry: positions[normalizedId] }; + } + + for (const [key, entry] of Object.entries(positions)) { + const entryKey = normalizeCommentId(entry?.key); + const threadId = normalizeCommentId(entry?.threadId); + if (entryKey === normalizedId || threadId === normalizedId) { + return { key, entry }; + } + } + + return { key: null, entry: null }; + }; + + const boundsOverlap = (a, b) => { + if (!a || !b) return false; + const left = Math.max(Number(a.left), Number(b.left)); + const right = Math.min(Number(a.right), Number(b.right)); + const top = Math.max(Number(a.top), Number(b.top)); + const bottom = Math.min(Number(a.bottom), Number(b.bottom)); + return [left, right, top, bottom].every(Number.isFinite) && right > left && bottom > top; + }; + + const isEquivalentTrackedChangePosition = (candidate, existing) => { + if (!candidate || !existing) return false; + if (candidate.kind !== 'trackedChange' || existing.kind !== 'trackedChange') return false; + if (candidate.storyKey && existing.storyKey && candidate.storyKey !== existing.storyKey) return false; + const candidatePage = Number(candidate.pageIndex); + const existingPage = Number(existing.pageIndex); + if (Number.isFinite(candidatePage) && Number.isFinite(existingPage) && candidatePage !== existingPage) { + return false; + } + + if (boundsOverlap(candidate.bounds, existing.bounds)) return true; + + const candidateStart = Number(candidate.start); + const candidateEnd = Number(candidate.end); + const existingStart = Number(existing.start); + const existingEnd = Number(existing.end); + return ( + [candidateStart, candidateEnd, existingStart, existingEnd].every(Number.isFinite) && + candidateStart === existingStart && + candidateEnd === existingEnd + ); + }; + + const getTrackedChangeCommentByPositionAlias = (id) => { + const { entry: targetEntry } = getPositionEntryByAlias(id); + if (!targetEntry) return null; + + const matches = commentsList.value.filter((comment) => { + if (!comment?.trackedChange) return false; + + const aliases = [comment.trackedChangeAnchorKey, comment.commentId, comment.importedId]; + return aliases.some((alias) => { + const { entry } = getPositionEntryByAlias(alias); + return isEquivalentTrackedChangePosition(targetEntry, entry); + }); + }); + + return matches.length === 1 ? matches[0] : null; + }; + /** * Get a comment by either ID or imported ID * @@ -109,7 +180,10 @@ export const useCommentsStore = defineStore('comments', () => { */ const getComment = (id) => { if (id === undefined || id === null) return null; - return commentsList.value.find((c) => c.commentId == id || c.importedId == id); + const directMatch = commentsList.value.find( + (c) => c.commentId == id || c.importedId == id || c.trackedChangeAnchorKey == id, + ); + return directMatch || getTrackedChangeCommentByPositionAlias(id); }; const getThreadParent = (comment) => { @@ -166,8 +240,6 @@ export const useCommentsStore = defineStore('comments', () => { return trackedChangeAnchorKey ?? commentId ?? importedId ?? null; }; - const normalizeCommentId = (id) => (id === undefined || id === null ? null : String(id)); - // Comments can be referenced by the imported DOCX id, the internal commentId, or a raw id // coming from UI/editor events. Normalize everything to strings and keep all aliases so every // lookup path resolves against the same set of ids. diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index ba1d9a01d9..6f7fa9a538 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -208,6 +208,35 @@ describe('comments-store', () => { expect(store.getComment(undefined)).toBeNull(); }); + it('resolves rendered tracked-change aliases by equivalent position', () => { + const canonicalAnchorKey = 'tc::hf:part:rId8::word:trackInsert:101'; + const renderedAnchorKey = 'tc::hf:part:rId8::rendered-uuid'; + const comment = { + commentId: 'word:trackInsert:101', + trackedChange: true, + trackedChangeAnchorKey: canonicalAnchorKey, + }; + store.commentsList = [comment]; + store.editorCommentPositions = { + [canonicalAnchorKey]: { + key: canonicalAnchorKey, + threadId: 'word:trackInsert:101', + kind: 'trackedChange', + storyKey: 'hf:part:rId8', + bounds: { top: 49, left: 199, bottom: 69, right: 267, width: 68, height: 20 }, + }, + [renderedAnchorKey]: { + key: renderedAnchorKey, + threadId: 'rendered-uuid', + kind: 'trackedChange', + storyKey: 'hf:part:rId8', + bounds: { top: 47, left: 188, bottom: 69, right: 300, width: 112, height: 22 }, + }, + }; + + expect(store.getComment('rendered-uuid')).toEqual(comment); + }); + it('prefers tracked-change anchor keys for position lookup and alias resolution', () => { const comment = { commentId: 'tc-1', From 79a0f8e4868fe6915bc3aaeef21bec2768ed639c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:37:40 -0700 Subject: [PATCH 017/280] chore: generated files update From 7796869613eba443e0258a871097475030c5b651 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:51:41 -0700 Subject: [PATCH 018/280] fix: coalesce tracked inserts across run gaps --- .../review-model/overlap-compiler.js | 54 ++++++++++- .../review-model/overlap-compiler.test.js | 96 +++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index 42f87dcfad..ced0855fed 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -101,6 +101,7 @@ import { getLiveInlineMarksInRange } from '../trackChangesHelpers/getLiveInlineM */ const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); +const EMPTY_STRUCTURAL_GAP_REFINEMENT_MAX_DISTANCE = 4; /** * Compile a tracked edit against an accumulated transaction. @@ -243,6 +244,49 @@ const findSegmentAt = (ctx, pos) => { return null; }; +const findSegmentAcrossEmptyStructuralGap = (ctx, pos) => { + let nearest = null; + for (const segment of ctx.graph.segments) { + if (segment.to >= pos) continue; + const distance = pos - segment.to; + if (distance > EMPTY_STRUCTURAL_GAP_REFINEMENT_MAX_DISTANCE) continue; + if (!isEmptyStructuralGap(ctx, segment.to, pos)) continue; + if (!nearest || segment.to > nearest.to) nearest = segment; + } + return nearest; +}; + +const isEmptyStructuralGap = (ctx, from, to) => { + if (to <= from) return false; + if (!sharesTextblock(ctx.tr.doc, from, to)) return false; + if (ctx.graph.segmentsInRange(from, to).length) return false; + if (ctx.tr.doc.textBetween(from, to, '', '')) return false; + + let hasInlineLeaf = false; + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (pos < from || pos >= to) return; + if (node.isInline && node.isLeaf) { + hasInlineLeaf = true; + return false; + } + }); + return !hasInlineLeaf; +}; + +const sharesTextblock = (doc, from, to) => { + const left = textblockStart(doc, from); + const right = textblockStart(doc, to); + return left !== null && left === right; +}; + +const textblockStart = (doc, pos) => { + const resolved = doc.resolve(Math.max(0, Math.min(doc.content.size, pos))); + for (let depth = resolved.depth; depth > 0; depth -= 1) { + if (resolved.node(depth).isTextblock) return resolved.start(depth); + } + return null; +}; + const segmentsInRange = (ctx, from, to) => ctx.graph.segmentsInRange(from, to); const insertSchema = (ctx) => ctx.schema.marks[TrackInsertMarkName]; @@ -350,9 +394,11 @@ const compileTextInsert = (ctx, intent) => { const overlapParent = containing && containing.from < at && containing.to > at ? containing : null; const boundaryAdjacent = !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; + const emptyGapAdjacent = !overlapParent && !boundaryAdjacent ? findSegmentAcrossEmptyStructuralGap(ctx, at) : null; // Same-user refinement targets: own insertion that strictly contains `at`, - // OR an own-insertion edge we are adjacent to. Refinement uses the + // an own-insertion edge we are adjacent to, OR the same edge separated only + // by run-wrapper position gaps. Refinement uses the // permissive `isSameUserForRefinement` check so contiguous typing by the // default unidentified user (no email) still coalesces into one id — // matching the legacy `findTrackedMarkBetween({ authorEmail: '' })` @@ -365,7 +411,11 @@ const compileTextInsert = (ctx, intent) => { boundaryAdjacent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, boundaryAdjacent) ? boundaryAdjacent - : null; + : emptyGapAdjacent && + emptyGapAdjacent.side === SegmentSide.Inserted && + isSameUserForRefinement(ctx, emptyGapAdjacent) + ? emptyGapAdjacent + : null; if (refinementTarget) { const refinedId = refinementTarget.changeId; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index cbc95fb9b9..b74633eb24 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -27,6 +27,30 @@ const SAME_EMAIL_BOB = { id: 'bob-id', name: 'Bob', email: 'shared@example.com' const FIXED_DATE = '2026-05-21T00:00:00.000Z'; const schema = createReviewGraphTestSchema(); +const createReviewGraphRunTestSchema = () => { + const baseSchema = createReviewGraphTestSchema(); + return new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + selectable: false, + parseDOM: [{ tag: 'span[data-run]' }], + toDOM: () => ['span', { 'data-run': '1' }, 0], + }, + text: { group: 'inline' }, + }, + marks: baseSchema.spec.marks.toObject(), + }); +}; const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); const deleteMark = (attrs) => ({ markType: TrackDeleteMarkName, attrs: markAttrs(attrs) }); @@ -145,6 +169,78 @@ describe('overlap-compiler: same-user own-insertion refinement (SD-486-adjacent expect(graph.changes.get(id)).toBeDefined(); }); + it('refines own insertion across an empty run-wrapper position gap', () => { + const runSchema = createReviewGraphRunTestSchema(); + const id = 'ins-alice'; + const mark = runSchema.marks[TrackInsertMarkName].create( + markAttrs({ id, authorEmail: ALICE.email, date: FIXED_DATE }), + ); + const run = runSchema.nodes.run.create({}, [runSchema.text('a', [mark])]); + const doc = runSchema.nodes.doc.create({}, [runSchema.nodes.paragraph.create({}, [run])]); + const state = EditorState.create({ schema: runSchema, doc }); + + let trackedTextEnd = null; + let runEnd = null; + doc.descendants((node, pos) => { + if (node.isText && node.text === 'a') trackedTextEnd = pos + node.nodeSize; + if (node.type.name === 'run') runEnd = pos + node.nodeSize; + }); + expect(runEnd).toBeGreaterThan(trackedTextEnd); + + const intent = makeTextInsertIntent({ + at: runEnd, + content: sliceFromText(runSchema, 'b'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(0); + expect(result.updatedChangeIds).toContain(id); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + expect( + graph.changes + .get(id) + ?.insertedSegments.map((segment) => segment.text) + .join(''), + ).toBe('ab'); + }); + + it('does not refine own insertion across live text', () => { + const runSchema = createReviewGraphRunTestSchema(); + const id = 'ins-alice'; + const mark = runSchema.marks[TrackInsertMarkName].create( + markAttrs({ id, authorEmail: ALICE.email, date: FIXED_DATE }), + ); + const run = runSchema.nodes.run.create({}, [runSchema.text('a', [mark])]); + const liveText = runSchema.text('x'); + const doc = runSchema.nodes.doc.create({}, [runSchema.nodes.paragraph.create({}, [run, liveText])]); + const state = EditorState.create({ schema: runSchema, doc }); + + let liveTextEnd = null; + doc.descendants((node, pos) => { + if (node.isText && node.text === 'x') liveTextEnd = pos + node.nodeSize; + }); + + const intent = makeTextInsertIntent({ + at: liveTextEnd, + content: sliceFromText(runSchema, 'b'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.updatedChangeIds).not.toContain(id); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + }); + it('replaces inside own insertion while preserving the existing insertion id', () => { const id = 'ins-alice'; const { state } = stateFromTrackedSpans({ From 767702996bcc31bdffb4bf79532b295b0caba223 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 22:33:40 -0700 Subject: [PATCH 019/280] fix: remaining collab bugs --- .../src/__tests__/lib/error-mapping.test.ts | 11 + .../__tests__/lib/validate-type-spec.test.ts | 33 + apps/cli/src/lib/error-mapping.ts | 7 + .../reference/_generated-manifest.json | 2 +- .../reference/comments/create.mdx | 18 +- .../document-api/reference/comments/get.mdx | 62 ++ .../document-api/reference/comments/list.mdx | 54 ++ .../document-api/reference/comments/patch.mdx | 22 +- .../reference/track-changes/get.mdx | 25 +- .../reference/track-changes/list.mdx | 23 +- apps/mcp/src/generated/catalog.ts | 25 +- .../src/comments/comments.test.ts | 20 +- .../document-api/src/comments/comments.ts | 75 +- .../src/comments/comments.types.ts | 44 +- .../src/contract/contract.test.ts | 47 ++ .../src/contract/operation-definitions.ts | 2 +- .../src/contract/operation-registry.ts | 3 +- packages/document-api/src/contract/schemas.ts | 68 +- packages/document-api/src/format/format.ts | 17 + packages/document-api/src/index.test.ts | 182 ++++- packages/document-api/src/index.ts | 46 +- .../document-api/src/invoke/invoke.test.ts | 12 +- .../src/overview-examples.test.ts | 12 +- .../src/types/track-changes.types.ts | 5 + .../src/__tests__/handle-and-tools.test.ts | 40 ++ packages/sdk/langs/node/src/index.ts | 18 + .../src/editors/v1/core/Editor.ts | 7 +- .../helpers/comment-entity-store.test.ts | 27 +- .../helpers/comment-entity-store.ts | 136 ++-- .../helpers/tracked-change-resolver.ts | 29 +- .../plan-engine/comments-wrappers.test.ts | 188 +++-- .../plan-engine/comments-wrappers.ts | 657 ++++++++++++++++-- .../track-changes-wrappers.test.ts | 44 +- .../plan-engine/track-changes-wrappers.ts | 197 +++++- .../tracked-changes/tracked-change-index.ts | 6 + .../tracked-change-snapshot.ts | 9 + .../review-model/comment-effects.js | 26 +- .../review-model/decision-engine.js | 15 +- .../extensions/track-changes/track-changes.js | 43 +- .../exportDocx.commentsFallback.test.js | 37 + .../src/editors/v1/utils/comment-content.ts | 35 + packages/superdoc/src/SuperDoc.vue | 4 + .../superdoc/src/stores/comments-store.js | 40 +- .../src/stores/comments-store.test.js | 27 +- 44 files changed, 2072 insertions(+), 328 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/utils/comment-content.ts diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index cbb0f165da..52f5384b25 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -14,6 +14,17 @@ describe('mapInvokeError', () => { expect(mapped.message).toBe('blocks.delete requires a target.'); expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } }); }); + + test('preserves TARGET_NOT_FOUND for trackChanges.decide stale ids', () => { + const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), { + code: 'TARGET_NOT_FOUND', + details: { id: 'tc-1' }, + }); + + const mapped = mapInvokeError('trackChanges.decide' as any, error); + expect(mapped.code).toBe('TARGET_NOT_FOUND'); + expect(mapped.details).toEqual({ operationId: 'trackChanges.decide', details: { id: 'tc-1' } }); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts index 415ecc8266..621b16cb7c 100644 --- a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts +++ b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts @@ -179,6 +179,39 @@ describe('doc.find select schema — accepts canonical and shorthand forms', () }); }); +describe('comments target schema — accepts selection and tracked-change targets', () => { + const createMetadata = CLI_OPERATION_METADATA['doc.comments.create']; + const patchMetadata = CLI_OPERATION_METADATA['doc.comments.patch']; + const createTargetSchema = createMetadata.params.find((p) => p.name === 'target')?.schema; + const patchTargetSchema = patchMetadata.params.find((p) => p.name === 'target')?.schema; + + if (!createTargetSchema || !patchTargetSchema) { + throw new Error('comments metadata missing target schema'); + } + + const selectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: '36D666B6', offset: 10 }, + end: { kind: 'text', blockId: '36D666B6', offset: 18 }, + }; + + test('accepts SelectionTarget for comments.create', () => { + expect(() => validateValueAgainstTypeSpec(selectionTarget, createTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts SelectionTarget for comments.patch', () => { + expect(() => validateValueAgainstTypeSpec(selectionTarget, patchTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts tracked-change target without explicit kind for comments.create', () => { + expect(() => validateValueAgainstTypeSpec({ trackedChangeId: 'tc-1' }, createTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts tracked-change target without explicit kind for comments.patch', () => { + expect(() => validateValueAgainstTypeSpec({ trackedChangeId: 'tc-1' }, patchTargetSchema, 'target')).not.toThrow(); + }); +}); + describe('validateValueAgainstTypeSpec – object without explicit properties', () => { // type: 'object' schemas that use additionalProperties (or nothing at all) // must not crash the validator when `properties` is absent. diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 4a24bc3a75..d45a9fe287 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -41,6 +41,10 @@ function mapTrackChangesError(operationId: CliExposedOperationId, error: unknown const message = extractErrorMessage(error); const details = extractErrorDetails(error); + if (operationId === 'trackChanges.decide' && code === 'TARGET_NOT_FOUND') { + return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); + } + if (code === 'TARGET_NOT_FOUND' || (typeof message === 'string' && message.includes('was not found'))) { return new CliError('TRACK_CHANGE_NOT_FOUND', message, { operationId, details }); } @@ -439,6 +443,9 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk // Track-changes family if (family === 'trackChanges') { + if (operationId === 'trackChanges.decide' && failureCode === 'TARGET_NOT_FOUND') { + return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure }); + } if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', failureMessage, { operationId, failure }); } diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index ecbc575eb2..76fd594f32 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "a9aff0330980d98962b9026299b98b684ce60e47e88b163e2d12040d40bf5b0c" + "sourceHash": "b2a628b73f6cb983a78e8068179f0c18cc99024112b143c1a832b95b4c2ad914" } diff --git a/apps/docs/document-api/reference/comments/create.mdx b/apps/docs/document-api/reference/comments/create.mdx index b805fbb0b1..2b0df61bad 100644 --- a/apps/docs/document-api/reference/comments/create.mdx +++ b/apps/docs/document-api/reference/comments/create.mdx @@ -20,14 +20,14 @@ Create a new comment thread (or reply when parentCommentId is given). ## Expected result -Returns a Receipt confirming the comment was created; reports NO_OP if the anchor target is invalid. +Returns a Receipt confirming the comment was created, including the new comment id; reports NO_OP if the anchor target is invalid. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `parentCommentId` | string | no | | -| `target` | TextAddress \\| TextTarget | no | One of: TextAddress, TextTarget | +| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | yes | | ### Example request @@ -53,6 +53,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho | Field | Type | Required | Description | | --- | --- | --- | --- | +| `id` | string | yes | | | `inserted` | EntityAddress[] | no | | | `removed` | EntityAddress[] | no | | | `success` | `true` | yes | Constant: `true` | @@ -72,6 +73,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho ```json { + "id": "id-001", "inserted": [ { "entityId": "entity-789", @@ -113,13 +115,19 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho "type": "string" }, "target": { - "description": "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", "oneOf": [ { "$ref": "#/$defs/TextAddress" }, { "$ref": "#/$defs/TextTarget" + }, + { + "$ref": "#/$defs/SelectionTarget" + }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" } ] }, @@ -141,7 +149,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho { "oneOf": [ { - "$ref": "#/$defs/ReceiptSuccess" + "$ref": "#/$defs/CommentsCreateSuccess" }, { "additionalProperties": false, @@ -184,7 +192,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho ```json { - "$ref": "#/$defs/ReceiptSuccess" + "$ref": "#/$defs/CommentsCreateSuccess" } ``` diff --git a/apps/docs/document-api/reference/comments/get.mdx b/apps/docs/document-api/reference/comments/get.mdx index cdf60532ee..f2e4741461 100644 --- a/apps/docs/document-api/reference/comments/get.mdx +++ b/apps/docs/document-api/reference/comments/get.mdx @@ -49,6 +49,7 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `createdTime` | number | no | | | `creatorEmail` | string | no | | | `creatorName` | string | no | | +| `deletedText` | any | no | | | `importedId` | string | no | | | `isInternal` | boolean | no | | | `parentCommentId` | string | no | | @@ -58,6 +59,13 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `target.segments` | TextSegment[] | no | | | `target.story` | StoryLocator | no | StoryLocator | | `text` | string | no | | +| `trackedChange` | boolean | no | | +| `trackedChangeAnchorKey` | any | no | | +| `trackedChangeDisplayType` | any | no | | +| `trackedChangeLink` | CommentTrackedChangeLink \\| null | no | One of: CommentTrackedChangeLink, null | +| `trackedChangeStory` | StoryLocator \\| null | no | One of: StoryLocator, null | +| `trackedChangeText` | any | no | | +| `trackedChangeType` | enum | no | `"insert"`, `"delete"`, `"format"` | ### Example response @@ -125,6 +133,12 @@ Returns a CommentInfo object with the comment text, author, date, and thread met "creatorName": { "type": "string" }, + "deletedText": { + "type": [ + "string", + "null" + ] + }, "importedId": { "type": "string" }, @@ -145,6 +159,54 @@ Returns a CommentInfo object with the comment text, author, date, and thread met }, "text": { "type": "string" + }, + "trackedChange": { + "type": "boolean" + }, + "trackedChangeAnchorKey": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeDisplayType": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeLink": { + "oneOf": [ + { + "$ref": "#/$defs/CommentTrackedChangeLink" + }, + { + "type": "null" + } + ] + }, + "trackedChangeStory": { + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "type": "null" + } + ] + }, + "trackedChangeText": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeType": { + "enum": [ + "insert", + "delete", + "format" + ] } }, "required": [ diff --git a/apps/docs/document-api/reference/comments/list.mdx b/apps/docs/document-api/reference/comments/list.mdx index 96e21e03ed..d622a5bbd5 100644 --- a/apps/docs/document-api/reference/comments/list.mdx +++ b/apps/docs/document-api/reference/comments/list.mdx @@ -143,6 +143,12 @@ Returns a CommentsListResult with an array of comment threads and total count. "creatorName": { "type": "string" }, + "deletedText": { + "type": [ + "string", + "null" + ] + }, "handle": { "$ref": "#/$defs/ResolvedHandle" }, @@ -169,6 +175,54 @@ Returns a CommentsListResult with an array of comment threads and total count. }, "text": { "type": "string" + }, + "trackedChange": { + "type": "boolean" + }, + "trackedChangeAnchorKey": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeDisplayType": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeLink": { + "oneOf": [ + { + "$ref": "#/$defs/CommentTrackedChangeLink" + }, + { + "type": "null" + } + ] + }, + "trackedChangeStory": { + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "type": "null" + } + ] + }, + "trackedChangeText": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeType": { + "enum": [ + "insert", + "delete", + "format" + ] } }, "required": [ diff --git a/apps/docs/document-api/reference/comments/patch.mdx b/apps/docs/document-api/reference/comments/patch.mdx index 60204130ca..e689e20c1b 100644 --- a/apps/docs/document-api/reference/comments/patch.mdx +++ b/apps/docs/document-api/reference/comments/patch.mdx @@ -29,12 +29,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields | `commentId` | string | yes | | | `isInternal` | boolean | no | | | `status` | enum | no | `"resolved"`, `"active"` | -| `target` | TextAddress | no | TextAddress | -| `target.blockId` | string | no | | -| `target.kind` | `"text"` | no | Constant: `"text"` | -| `target.range` | Range | no | Range | -| `target.range.end` | integer | no | | -| `target.range.start` | integer | no | | +| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | no | | ### Example request @@ -131,7 +126,20 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields ] }, "target": { - "$ref": "#/$defs/TextAddress" + "oneOf": [ + { + "$ref": "#/$defs/TextAddress" + }, + { + "$ref": "#/$defs/TextTarget" + }, + { + "$ref": "#/$defs/SelectionTarget" + }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] }, "text": { "description": "Updated comment text.", diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index 3b26d367da..2b63b81477 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -56,8 +56,10 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `date` | string | no | | | `deletedText` | string | no | | | `excerpt` | string | no | | +| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"aggregate"`, `"unknown"` | | `id` | string | yes | | | `insertedText` | string | no | | +| `pairedWithChangeId` | any | no | | | `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | @@ -77,13 +79,10 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "storyType": "body" } }, - "author": "Jane Doe", + "grouping": "standalone", "id": "id-001", - "type": "insert", - "wordRevisionIds": { - "delete": "example", - "insert": "example" - } + "pairedWithChangeId": {}, + "type": "insert" } ``` @@ -143,12 +142,26 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "aggregate", + "unknown" + ] + }, "id": { "type": "string" }, "insertedText": { "type": "string" }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 59ab4aa6d3..51216270d0 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -68,18 +68,15 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "storyType": "body" } }, - "author": "Jane Doe", + "grouping": "standalone", "handle": { "ref": "handle:abc123", "refStability": "stable", "targetKind": "text" }, "id": "id-001", - "type": "insert", - "wordRevisionIds": { - "delete": "example", - "insert": "example" - } + "pairedWithChangeId": {}, + "type": "insert" } ], "page": { @@ -172,6 +169,14 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "aggregate", + "unknown" + ] + }, "handle": { "$ref": "#/$defs/ResolvedHandle" }, @@ -181,6 +186,12 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "insertedText": { "type": "string" }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 4cd0a69ba6..e95c818b37 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2327,16 +2327,35 @@ export const MCP_TOOL_CATALOG = { { $ref: '#/$defs/TextTarget', }, + { + $ref: '#/$defs/SelectionTarget', + }, + { + $ref: '#/$defs/CommentTrackedChangeTarget', + }, ], description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", }, { - $ref: '#/$defs/TextAddress', + oneOf: [ + { + $ref: '#/$defs/TextAddress', + }, + { + $ref: '#/$defs/TextTarget', + }, + { + $ref: '#/$defs/SelectionTarget', + }, + { + $ref: '#/$defs/CommentTrackedChangeTarget', + }, + ], }, ], description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks. Only for actions 'create', 'update'. Omit for other actions.", + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions.", }, parentId: { type: 'string', diff --git a/packages/document-api/src/comments/comments.test.ts b/packages/document-api/src/comments/comments.test.ts index ca6d3d7793..6ca739bc17 100644 --- a/packages/document-api/src/comments/comments.test.ts +++ b/packages/document-api/src/comments/comments.test.ts @@ -9,9 +9,17 @@ import { const stubAdapter = () => ({ - add: mock(() => ({ success: true })), + add: mock(() => ({ + success: true, + id: 'c1', + inserted: [{ kind: 'entity', entityType: 'comment', entityId: 'c1' }], + })), edit: mock(() => ({ success: true })), - reply: mock(() => ({ success: true })), + reply: mock(() => ({ + success: true, + id: 'c2', + inserted: [{ kind: 'entity', entityType: 'comment', entityId: 'c2' }], + })), move: mock(() => ({ success: true })), resolve: mock(() => ({ success: true })), reopen: mock(() => ({ success: true })), @@ -40,6 +48,14 @@ describe('executeCommentsCreate validation', () => { expect(e.code).toBe('INVALID_INPUT'); } }); + + it('returns the created comment id on success', () => { + const adapter = stubAdapter(); + const target = { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } }; + const receipt = executeCommentsCreate(adapter, { text: 'hello', target }); + expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c1'); + }); }); describe('executeCommentsPatch validation', () => { diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index a908a26d80..fb37b5e10d 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -1,8 +1,16 @@ -import type { Receipt, TextAddress, TextTarget } from '../types/index.js'; -import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments.types.js'; +import type { Receipt, ReceiptFailureResult, ReceiptSuccess } from '../types/index.js'; +import type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + TrackedChangeCommentTarget, +} from './comments.types.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isTextAddress, isTextTarget, assertNoUnknownFields } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; +import { validateStoryLocator } from '../validation/story-validator.js'; /** * Input for adding a comment to a text range. @@ -20,7 +28,7 @@ export interface AddCommentInput { * `textRanges[0]`) or a {@link TextTarget} with multi-segment for * selections that span multiple blocks. */ - target?: TextAddress | TextTarget; + target?: CommentTarget; /** The comment body text. */ text: string; } @@ -37,7 +45,7 @@ export interface ReplyToCommentInput { export interface MoveCommentInput { commentId: string; - target: TextAddress; + target: CommentTarget; } export interface ResolveCommentInput { @@ -93,7 +101,7 @@ export interface CommentsCreateInput { * {@link TextTarget}. Prefer passing `editor.doc.selection.current().target` * directly for selections that may span multiple blocks. */ - target?: TextAddress | TextTarget; + target?: CommentTarget; /** Parent comment ID: when provided, creates a reply instead of a root comment. */ parentCommentId?: string; } @@ -111,7 +119,7 @@ export interface CommentsPatchInput { /** New body text (routes to edit). */ text?: string; /** New anchor range (routes to move). */ - target?: TextAddress; + target?: CommentTarget; /** * Lifecycle transition. `'resolved'` routes to resolve, `'active'` * routes to reopen: symmetric inverse that removes the resolve @@ -130,16 +138,23 @@ export interface CommentsDeleteInput { commentId: string; } +export type CommentsCreateReceiptSuccess = ReceiptSuccess & { + /** Convenience alias for the created comment id. */ + id: string; +}; + +export type CommentsCreateReceipt = CommentsCreateReceiptSuccess | ReceiptFailureResult; + /** * Engine-specific adapter that the comments API delegates to. */ export interface CommentsAdapter { /** Add a comment at the specified text range. */ - add(input: AddCommentInput, options?: RevisionGuardOptions): Receipt; + add(input: AddCommentInput, options?: RevisionGuardOptions): CommentsCreateReceipt; /** Edit the body text of an existing comment. */ edit(input: EditCommentInput, options?: RevisionGuardOptions): Receipt; /** Reply to an existing comment thread. */ - reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt; + reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): CommentsCreateReceipt; /** Move a comment to a different text range. */ move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Resolve an open comment. */ @@ -176,7 +191,7 @@ export interface CommentsAdapter { * of the document-api contract. */ export interface CommentsApi { - create(input: CommentsCreateInput, options?: RevisionGuardOptions): Receipt; + create(input: CommentsCreateInput, options?: RevisionGuardOptions): CommentsCreateReceipt; patch(input: CommentsPatchInput, options?: RevisionGuardOptions): Receipt; delete(input: CommentsDeleteInput, options?: RevisionGuardOptions): Receipt; get(input: GetCommentInput): CommentInfo; @@ -185,6 +200,32 @@ export interface CommentsApi { const CREATE_COMMENT_ALLOWED_KEYS = new Set(['target', 'text', 'parentCommentId']); +function isTrackedChangeCommentTarget(value: unknown): value is TrackedChangeCommentTarget { + if (!isRecord(value)) return false; + if (value.kind !== undefined && value.kind !== 'trackedChange') return false; + return typeof value.trackedChangeId === 'string' && value.trackedChangeId.length > 0; +} + +function validateCommentTarget(target: unknown, operationName: string): void { + if (isTextAddress(target) || isTextTarget(target) || isSelectionTarget(target)) { + return; + } + + if (isTrackedChangeCommentTarget(target)) { + validateStoryLocator(target.story, 'target.story'); + return; + } + + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} target must be a TextAddress, TextTarget, SelectionTarget, or tracked-change target.`, + { + field: 'target', + value: target, + }, + ); +} + /** * Validates CommentsCreateInput for root comments (non-reply) and throws DocumentApiValidationError on violations. */ @@ -230,12 +271,7 @@ function validateCreateCommentInput(input: unknown): asserts input is CommentsCr }); } - if (!isTextAddress(target) && !isTextTarget(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a TextAddress or TextTarget object.', { - field: 'target', - value: target, - }); - } + validateCommentTarget(target, 'comments.create'); } const PATCH_COMMENT_ALLOWED_KEYS = new Set(['commentId', 'target', 'text', 'status', 'isInternal']); @@ -306,11 +342,8 @@ function validatePatchCommentInput(input: unknown): asserts input is CommentsPat }); } - if (hasTarget && !isTextAddress(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { - field: 'target', - value: target, - }); + if (hasTarget) { + validateCommentTarget(target, 'comments.patch'); } } @@ -331,7 +364,7 @@ export function executeCommentsCreate( adapter: CommentsAdapter, input: CommentsCreateInput, options?: RevisionGuardOptions, -): Receipt { +): CommentsCreateReceipt { // Validate the raw input first (catches null, unknown fields, etc.) validateCreateCommentInput(input); diff --git a/packages/document-api/src/comments/comments.types.ts b/packages/document-api/src/comments/comments.types.ts index 95cede1e23..1a4b8d3057 100644 --- a/packages/document-api/src/comments/comments.types.ts +++ b/packages/document-api/src/comments/comments.types.ts @@ -1,8 +1,34 @@ -import type { CommentAddress, CommentStatus, TextTarget } from '../types/index.js'; +import type { + CommentAddress, + CommentStatus, + SelectionTarget, + StoryLocator, + TextAddress, + TextTarget, +} from '../types/index.js'; import type { DiscoveryOutput } from '../types/discovery.js'; +import type { TrackChangeType } from '../types/track-changes.types.js'; export type { CommentStatus } from '../types/index.js'; +export interface TrackedChangeCommentTarget { + kind?: 'trackedChange'; + trackedChangeId: string; + story?: StoryLocator; +} + +export type CommentTarget = TextAddress | TextTarget | SelectionTarget | TrackedChangeCommentTarget; + +export interface CommentTrackedChangeLink { + trackedChange: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; +} + export interface CommentInfo { address: CommentAddress; commentId: string; @@ -16,6 +42,14 @@ export interface CommentInfo { createdTime?: number; creatorName?: string; creatorEmail?: string; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; } export interface CommentsListQuery { @@ -42,6 +76,14 @@ export interface CommentDomain { createdTime?: number; creatorName?: string; creatorEmail?: string; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; } /** diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 0a20e4741e..a061509e3b 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -214,6 +214,53 @@ describe('document-api contract catalog', () => { } }); + it('allows null trackedChangeLink on comment read models', () => { + const schemas = buildInternalContractSchemas(); + const commentInfoSchema = schemas.operations['comments.get'].output as { + properties?: { + trackedChangeLink?: { oneOf?: Array> }; + }; + }; + const commentsListSchema = schemas.operations['comments.list'].output as { + properties?: { + items?: { + items?: { + properties?: { + trackedChangeLink?: { oneOf?: Array> }; + }; + }; + }; + }; + }; + + const getVariants = commentInfoSchema.properties?.trackedChangeLink?.oneOf ?? []; + const listVariants = commentsListSchema.properties?.items?.items?.properties?.trackedChangeLink?.oneOf ?? []; + + expect(getVariants.some((variant) => variant.type === 'null')).toBe(true); + expect(listVariants.some((variant) => variant.type === 'null')).toBe(true); + }); + + it('requires id on comments.create success receipts', () => { + const schemas = buildInternalContractSchemas(); + const createOutputSchema = schemas.operations['comments.create'].output as { + oneOf?: Array<{ + $ref?: string; + }>; + }; + const defs = schemas.$defs as Record< + string, + { + properties?: Record; + required?: string[]; + } + >; + + const successSchema = createOutputSchema.oneOf?.[0]; + expect(successSchema?.$ref).toBe('#/$defs/CommentsCreateSuccess'); + expect(defs.CommentsCreateSuccess?.properties).toHaveProperty('id'); + expect(defs.CommentsCreateSuccess?.required).toEqual(expect.arrayContaining(['success', 'id'])); + }); + it('declares UNSUPPORTED_ENVIRONMENT for insert metadata and generated failure schema', () => { const schemas = buildInternalContractSchemas(); const insertFailureSchema = schemas.operations.insert.failure as { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 8790b6de19..37cec1a83b 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2374,7 +2374,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'comments.create', description: 'Create a new comment thread (or reply when parentCommentId is given).', expectedResult: - 'Returns a Receipt confirming the comment was created; reports NO_OP if the anchor target is invalid.', + 'Returns a Receipt confirming the comment was created, including the new comment id; reports NO_OP if the anchor target is invalid.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'non-idempotent', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 2bd05fab8f..64b10bda58 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -46,6 +46,7 @@ import type { FormatInlineAliasInput, StyleApplyInput } from '../format/format.j import type { InlineRunPatchKey } from '../format/inline-run-patch.js'; import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/index.js'; import type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -867,7 +868,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { }; // --- comments.* --- - 'comments.create': { input: CommentsCreateInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.create': { input: CommentsCreateInput; options: RevisionGuardOptions; output: CommentsCreateReceipt }; 'comments.patch': { input: CommentsPatchInput; options: RevisionGuardOptions; output: Receipt }; 'comments.delete': { input: CommentsDeleteInput; options: RevisionGuardOptions; output: Receipt }; 'comments.get': { input: GetCommentInput; options: never; output: CommentInfo }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 010c7ecef6..bb16029e7b 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -273,6 +273,23 @@ const SHARED_DEFS: Record = { }, ['kind', 'start', 'end'], ), + CommentTrackedChangeTarget: objectSchema( + { + kind: { const: 'trackedChange' }, + trackedChangeId: { type: 'string' }, + story: ref('StoryLocator'), + }, + ['trackedChangeId'], + ), + CommentTrackedChangeLink: objectSchema({ + trackedChange: { const: true }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [ref('StoryLocator'), { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + }), TargetLocator: { oneOf: [ objectSchema({ target: ref('SelectionTarget') }, ['target']), @@ -428,6 +445,16 @@ const SHARED_DEFS: Record = { }, ['success'], ), + CommentsCreateSuccess: objectSchema( + { + success: { const: true }, + id: { type: 'string' }, + inserted: arraySchema(ref('EntityAddress')), + updated: arraySchema(ref('EntityAddress')), + removed: arraySchema(ref('EntityAddress')), + }, + ['success', 'id'], + ), ReceiptFailure: objectSchema( { code: { type: 'string' }, @@ -594,6 +621,7 @@ const inlineAnchorSchema = ref('InlineAnchor'); const targetKindSchema = ref('TargetKind'); const textAddressSchema = ref('TextAddress'); const textTargetSchema = ref('TextTarget'); +const commentTrackedChangeTargetSchema = ref('CommentTrackedChangeTarget'); const blockNodeAddressSchema = ref('BlockNodeAddress'); const deletableBlockNodeAddressSchema = ref('DeletableBlockNodeAddress'); const tableAddressSchema = ref('TableAddress'); @@ -613,11 +641,13 @@ const commentAddressSchema = ref('CommentAddress'); const trackedChangeAddressSchema = ref('TrackedChangeAddress'); const entityAddressSchema = ref('EntityAddress'); const selectionTargetSchema = ref('SelectionTarget'); +const commentTrackedChangeLinkSchema = ref('CommentTrackedChangeLink'); const targetLocatorSchema = ref('TargetLocator'); const deleteBehaviorSchema = ref('DeleteBehavior'); const resolvedHandleSchema = ref('ResolvedHandle'); const pageInfoSchema = ref('PageInfo'); const receiptSuccessSchema = ref('ReceiptSuccess'); +const commentsCreateSuccessSchema = ref('CommentsCreateSuccess'); const textMutationRangeSchema = ref('TextMutationRange'); const textMutationResolutionSchema = ref('TextMutationResolution'); const textMutationSuccessSchema = ref('TextMutationSuccess'); @@ -746,6 +776,12 @@ function receiptResultSchemaFor(operationId: OperationId): JsonSchema { }; } +function commentsCreateResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [commentsCreateSuccessSchema, receiptFailureResultSchemaFor(operationId)], + }; +} + function textMutationFailureSchemaFor(operationId: OperationId): JsonSchema { return objectSchema( { @@ -1456,6 +1492,14 @@ const commentInfoSchema = objectSchema( createdTime: { type: 'number' }, creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, + trackedChange: { type: 'boolean' }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + trackedChangeLink: { oneOf: [commentTrackedChangeLinkSchema, { type: 'null' }] }, }, ['address', 'commentId', 'status'], ); @@ -1473,6 +1517,14 @@ const commentDomainItemSchema = discoveryItemSchema( createdTime: { type: 'number' }, creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, + trackedChange: { type: 'boolean' }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + trackedChangeLink: { oneOf: [commentTrackedChangeLinkSchema, { type: 'null' }] }, }, ['address', 'status'], ); @@ -1506,6 +1558,8 @@ const trackChangeInfoSchema = objectSchema( address: trackedChangeAddressSchema, id: { type: 'string' }, type: { enum: ['insert', 'delete', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, @@ -1522,6 +1576,8 @@ const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, type: { enum: ['insert', 'delete', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, @@ -4876,9 +4932,9 @@ const operationSchemas: Record = { { text: { type: 'string', description: 'Comment text content.' }, target: { - oneOf: [textAddressSchema, textTargetSchema], + oneOf: [textAddressSchema, textTargetSchema, selectionTargetSchema, commentTrackedChangeTargetSchema], description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", }, parentCommentId: { type: 'string', @@ -4887,8 +4943,8 @@ const operationSchemas: Record = { }, ['text'], ), - output: receiptResultSchemaFor('comments.create'), - success: receiptSuccessSchema, + output: commentsCreateResultSchemaFor('comments.create'), + success: commentsCreateSuccessSchema, failure: receiptFailureResultSchemaFor('comments.create'), }, 'comments.patch': { @@ -4896,7 +4952,9 @@ const operationSchemas: Record = { { commentId: { type: 'string' }, text: { type: 'string', description: 'Updated comment text.' }, - target: textAddressSchema, + target: { + oneOf: [textAddressSchema, textTargetSchema, selectionTargetSchema, commentTrackedChangeTargetSchema], + }, status: { enum: ['resolved', 'active'], description: diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index a69d08a1b2..f9107bcc30 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -67,6 +67,23 @@ export type StyleApplyInput = TargetLocator & { in?: StoryLocator; }; +/** + * Legacy root-level alias input for `doc.formatRange(...)`. + * + * Kept for SDK compatibility while routing through the canonical + * `format.apply` implementation. + */ +export type FormatRangeInput = TargetLocator & { + target?: SelectionTarget; + ref?: string; + properties: InlineRunPatch; + /** Target a specific document story (body, header, footer, footnote, endnote). */ + in?: StoryLocator; + changeMode?: MutationOptions['changeMode']; + dryRun?: boolean; + expectedRevision?: string; +}; + /** * Named alias for MutationOptions on format.apply. * diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 6afcce9348..3644439682 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -87,9 +87,17 @@ function makeInfoAdapter(result?: Partial) { function makeCommentsAdapter(): CommentsAdapter { return { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), @@ -544,6 +552,7 @@ describe('createDocumentApi', () => { const receipt = api.comments.create(input); expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c1'); expect(commentsAdpt.add).toHaveBeenCalledWith(input, undefined); }); @@ -567,6 +576,7 @@ describe('createDocumentApi', () => { const receipt = api.comments.create(input); expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c2'); expect(commentsAdpt.reply).toHaveBeenCalledWith({ parentCommentId: 'c1', text: 'reply text' }, undefined); }); @@ -794,6 +804,34 @@ describe('createDocumentApi', () => { ); }); + it('delegates root formatRange to selectionMutation.execute with inline properties', () => { + const selectionAdpt = makeSelectionMutationAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + selectionMutation: selectionAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; + api.formatRange({ target, properties: { bold: true }, changeMode: 'tracked', dryRun: true }); + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { bold: true } }, + { changeMode: 'tracked', dryRun: true }, + ); + }); + it('delegates trackChanges read operations', () => { const trackAdpt = makeTrackChangesAdapter(); const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; @@ -2079,6 +2117,24 @@ describe('createDocumentApi', () => { expect(result.success).toBe(true); }); + it('accepts SelectionTarget', () => { + const api = makeApi(); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + const result = api.comments.create({ target, text: 'comment' }); + expect(result.success).toBe(true); + }); + + it('accepts tracked-change target', () => { + const api = makeApi(); + const target = { kind: 'trackedChange' as const, trackedChangeId: 'tc-1' }; + const result = api.comments.create({ target, text: 'comment' }); + expect(result.success).toBe(true); + }); + it('accepts reply without target (parentCommentId only)', () => { const api = makeApi(); const result = api.comments.create({ parentCommentId: 'c1', text: 'reply' }); @@ -2135,7 +2191,7 @@ describe('createDocumentApi', () => { const api = makeApi(); expectValidationError( () => api.comments.create({ target: { kind: 'text', blockId: 'p1' }, text: 'comment' } as any), - 'target must be a TextAddress or TextTarget object', + 'SelectionTarget, or tracked-change target', ); }); @@ -2190,6 +2246,58 @@ describe('createDocumentApi', () => { api.comments.create({ target, text: 'comment' }); expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); }); + + it('forwards SelectionTarget unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 1 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 4 }, + }; + api.comments.create({ target, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); + }); + + it('forwards tracked-change target unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'trackedChange' as const, + trackedChangeId: 'tc-1', + story: { kind: 'story' as const, storyType: 'body' as const }, + }; + api.comments.create({ target, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); + }); }); describe('selection adapter', () => { @@ -2267,6 +2375,24 @@ describe('createDocumentApi', () => { expect(result.success).toBe(true); }); + it('accepts SelectionTarget', () => { + const api = makeApi(); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + const result = api.comments.patch({ commentId: 'c1', target }); + expect(result.success).toBe(true); + }); + + it('accepts tracked-change target', () => { + const api = makeApi(); + const target = { trackedChangeId: 'tc-1' }; + const result = api.comments.patch({ commentId: 'c1', target }); + expect(result.success).toBe(true); + }); + it('accepts text-only patch (no target needed)', () => { const api = makeApi(); const result = api.comments.patch({ commentId: 'c1', text: 'updated' }); @@ -2318,7 +2444,7 @@ describe('createDocumentApi', () => { const api = makeApi(); expectValidationError( () => api.comments.patch({ commentId: 'c1', target: { kind: 'text', blockId: 'p1' } } as any), - 'target must be a text address object', + 'SelectionTarget, or tracked-change target', ); }); @@ -2388,6 +2514,54 @@ describe('createDocumentApi', () => { undefined, ); }); + + it('forwards SelectionTarget to adapter.move unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 1 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 4 }, + }; + api.comments.patch({ commentId: 'c1', target }); + expect(commentsAdpt.move).toHaveBeenCalledWith({ commentId: 'c1', target }, undefined); + }); + + it('forwards tracked-change target to adapter.move unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'trackedChange' as const, trackedChangeId: 'tc-1' }; + api.comments.patch({ commentId: 'c1', target }); + expect(commentsAdpt.move).toHaveBeenCalledWith({ commentId: 'c1', target }, undefined); + }); }); describe('create.* location validation', () => { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index b35e2084b2..8607695008 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -69,9 +69,17 @@ import type { TrackChangesListResult, ExtractResult, } from './types/index.js'; -import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +import type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + CommentTrackedChangeLink, + TrackedChangeCommentTarget, +} from './comments/comments.types.js'; import type { CommentsAdapter, + CommentsCreateReceipt, CommentsApi, CommentsCreateInput, CommentsPatchInput, @@ -90,6 +98,7 @@ import { executeFind, type FindAdapter } from './find/find.js'; import type { SDFindInput, SDFindResult, SDGetInput, SDNodeResult } from './types/sd-envelope.js'; import type { FormatApi, + FormatRangeInput, FormatInlineAliasApi, FormatInlineAliasInput, FormatStrikethroughInput, @@ -957,6 +966,7 @@ export type { FormatInlineAliasInput, FormatBoldInput, FormatItalicInput, + FormatRangeInput, FormatUnderlineInput, FormatStrikethroughInput, StyleApplyInput, @@ -1445,6 +1455,7 @@ export type { SectionsSetVerticalAlignInput, } from './sections/sections.types.js'; export type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -1462,7 +1473,14 @@ export type { GoToCommentInput, SetCommentActiveInput, } from './comments/comments.js'; -export type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +export type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + CommentTrackedChangeLink, + TrackedChangeCommentTarget, +} from './comments/comments.types.js'; export { DocumentApiValidationError } from './errors.js'; export { textReceiptToSDReceipt, buildStructuralReceipt } from './receipt-bridge.js'; export type { StructuralReceiptParams } from './receipt-bridge.js'; @@ -1643,6 +1661,12 @@ export interface DocumentApi { * Formatting operations (inline and paragraph direct formatting). */ format: FormatApi & { paragraph: ParagraphFormatApi }; + /** + * Legacy root-level alias for inline range formatting. + * + * Routes to {@link DocumentApi.format.apply}. + */ + formatRange(input: FormatRangeInput, options?: MutationOptions): TextMutationReceipt; /** * Stylesheet operations (docDefaults, style definitions, paragraph style references). */ @@ -2020,7 +2044,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeClearContent(adapters.clearContent, input, options); }, comments: { - create(input: CommentsCreateInput, options?: RevisionGuardOptions): Receipt { + create(input: CommentsCreateInput, options?: RevisionGuardOptions): CommentsCreateReceipt { return executeCommentsCreate(adapters.comments, input, options); }, patch(input: CommentsPatchInput, options?: RevisionGuardOptions): Receipt { @@ -2045,6 +2069,22 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { delete(input: DeleteInput, options?: MutationOptions): TextMutationReceipt { return executeDelete(adapters.selectionMutation, input, options); }, + formatRange(input: FormatRangeInput, options?: MutationOptions): TextMutationReceipt { + const raw = input as unknown; + if (!raw || typeof raw !== 'object') { + return executeStyleApply(adapters.selectionMutation, raw as StyleApplyInput, options); + } + const { properties, changeMode, dryRun, expectedRevision, ...rest } = raw as FormatRangeInput; + const styleInput: StyleApplyInput = + typeof rest.ref === 'string' + ? { ref: rest.ref, inline: properties, ...(rest.in ? { in: rest.in } : {}) } + : { target: rest.target, inline: properties, ...(rest.in ? { in: rest.in } : {}) }; + return executeStyleApply(adapters.selectionMutation, styleInput, { + expectedRevision: expectedRevision ?? options?.expectedRevision, + changeMode: changeMode ?? options?.changeMode, + dryRun: dryRun ?? options?.dryRun, + }); + }, format: { ...inlineAliasApi, strikethrough(input: FormatStrikethroughInput, options?: MutationOptions): TextMutationReceipt { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 5fb7718f46..4488b03b15 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -62,9 +62,17 @@ function makeAdapters() { ), }; const commentsAdapter: CommentsAdapter = { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index faa870bff2..7dffeca087 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -169,9 +169,17 @@ function makeParagraphsAdapter() { function makeCommentsAdapter() { return { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index b33cf52d71..87d9b3a685 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -4,6 +4,7 @@ import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; +export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'aggregate' | 'unknown'; export interface TrackChangeOverlapLayer { id: string; @@ -45,6 +46,8 @@ export interface TrackChangeInfo { /** Convenience alias for `address.entityId`. */ id: string; type: TrackChangeType; + grouping?: TrackChangeGrouping; + pairedWithChangeId?: string | null; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; /** Overlap metadata for nested tracked changes that share the same text range. */ @@ -79,6 +82,8 @@ export interface TrackChangesListQuery { export interface TrackChangeDomain { address: TrackedChangeAddress; type: TrackChangeType; + grouping?: TrackChangeGrouping; + pairedWithChangeId?: string | null; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; /** Overlap metadata for nested tracked changes that share the same text range. */ diff --git a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts index 3464ba0a43..f0413b1d87 100644 --- a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts +++ b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts @@ -16,8 +16,48 @@ describe('SuperDocDocument', () => { expect(typeof doc.getMarkdown).toBe('function'); expect(typeof doc.query.match).toBe('function'); + expect(typeof doc.formatRange).toBe('function'); expect('api' in (doc as unknown as Record)).toBe(false); }); + + test('formatRange delegates to doc.format.apply with inline properties', async () => { + const calls: Array<{ operationId: string; params: unknown }> = []; + const boundRuntime = { + invoke: async (operation: { operationId: string }, params: unknown) => { + calls.push({ operationId: operation.operationId, params }); + return { ok: true }; + }, + markClosed: () => {}, + }; + const client = { removeHandle: () => {} }; + + const doc = new SuperDocDocument(boundRuntime as any, 'session-1', { contextId: 'session-1' }, client as any); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + + const result = await doc.formatRange({ + target, + properties: { bold: true, italic: false }, + changeMode: 'tracked', + dryRun: true, + }); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([ + { + operationId: 'doc.format.apply', + params: { + target, + inline: { bold: true, italic: false }, + changeMode: 'tracked', + dryRun: true, + }, + }, + ]); + }); }); describe('SuperDocClient handle lifecycle', () => { diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index 632e84fcbd..b3e1e8b25a 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -4,6 +4,8 @@ import { type BoundDocApi, type DocCloseBoundParams, type DocCloseResult, + type DocFormatApplyBoundParams, + type DocFormatApplyResult, type DocOpenParams as GeneratedDocOpenParams, type DocOpenResult, type DocSaveBoundParams, @@ -58,6 +60,10 @@ class BoundRuntime implements RuntimeInvoker { } } +export interface DocFormatRangeBoundParams extends Omit { + properties: NonNullable; +} + // --------------------------------------------------------------------------- // Document handle // --------------------------------------------------------------------------- @@ -114,6 +120,18 @@ class SuperDocDocumentCore { markClosed(): void { this.boundRuntime.markClosed(); } + + async formatRange(params: DocFormatRangeBoundParams, options: InvokeOptions = {}): Promise { + const { properties, ...rest } = params; + return this.boundRuntime.invoke( + CONTRACT.operations['doc.format.apply'], + { + ...rest, + inline: properties, + }, + options, + ); + } } type SuperDocDocumentInstance = SuperDocDocumentCore & BoundDocApi; diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 6311e091da..0e6ee04913 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -72,6 +72,7 @@ import { createDocFromMarkdown, createDocFromHTML } from '@core/helpers/index.js import { COMMENT_FILE_BASENAMES } from '@core/super-converter/constants.js'; import { isHeadless } from '../utils/headless-helpers.js'; import { canUseDOM } from '../utils/canUseDOM.js'; +import { buildCommentJsonFromText } from '../utils/comment-content.js'; import { buildSchemaSummary } from './schema-summary.js'; import type { PresentationEditor } from './presentation-editor/index.js'; import type { EditorRenderer } from './renderers/EditorRenderer.js'; @@ -3502,9 +3503,13 @@ export class Editor extends EventEmitter { // Normalize commentJSON property (imported comments provide `elements`) const preparedComments = effectiveComments.map((comment: Comment) => { const elements = Array.isArray(comment.elements) && comment.elements.length ? comment.elements : undefined; + const commentJson = + comment.commentJSON ?? + elements ?? + (typeof comment.commentText === 'string' ? buildCommentJsonFromText(comment.commentText) : undefined); return { ...comment, - commentJSON: comment.commentJSON ?? elements, + commentJSON: commentJson, }; }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index 144c24ec97..61712ea60c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -287,7 +287,7 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { expect(store[0].commentText).toBe('short form'); }); - it('skips entries flagged trackedChange:true (those belong to a separate domain)', () => { + it('skips synthetic tracked-change projection entries without comment payload', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [ { commentId: 'tc-1', trackedChange: true, trackedChangeText: 'inserted', creatorName: 'A' }, @@ -298,6 +298,31 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { expect(store[0].commentId).toBe('c-1'); }); + it('syncs linked tracked-content comments when they include comment payload', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'tc-user-1', + trackedChange: true, + trackedChangeParentId: 'tc-root-1', + trackedChangeText: 'inserted text', + commentText: 'user-authored tracked comment', + creatorName: 'A', + }, + ]); + + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0]).toMatchObject({ + commentId: 'tc-user-1', + trackedChange: true, + trackedChangeParentId: 'tc-root-1', + trackedChangeText: 'inserted text', + commentText: 'user-authored tracked comment', + creatorName: 'A', + }); + }); + it('skips entries without a commentId', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [{ creatorName: 'orphan' }, { commentId: 'c-ok', creatorName: 'X' }]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index cd6d1fdfdf..ef380d3e57 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -1,5 +1,14 @@ import type { Editor } from '../../core/Editor.js'; -import type { CommentInfo, CommentStatus, TextTarget } from '@superdoc/document-api'; +import type { + CommentInfo, + CommentStatus, + CommentTrackedChangeLink, + StoryLocator, + TextTarget, + TrackChangeType, +} from '@superdoc/document-api'; +export { buildCommentJsonFromText } from '../../utils/comment-content.js'; +import { buildCommentJsonFromText } from '../../utils/comment-content.js'; const FALLBACK_STORE_KEY = '__documentApiComments'; @@ -19,6 +28,16 @@ export interface CommentEntityRecord { creatorEmail?: string; creatorImage?: string; createdTime?: number; + trackedChange?: boolean; + trackedChangeParentId?: string | null; + trackedChangeType?: TrackChangeType | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; [key: string]: unknown; } @@ -109,21 +128,6 @@ export function removeCommentEntityTree(store: CommentEntityRecord[], commentId: return removed; } -/** - * Strips HTML tags from a comment text string using simple regex replacement. - * - * This is only intended for normalizing comment content that was already authored - * within the editor. It is NOT a security sanitizer and must not be used to - * neutralize untrusted or user-supplied HTML. - */ -function stripHtmlToText(value: string): string { - return value - .replace(/<[^>]+>/g, ' ') - .replace(/ /gi, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - function collectTextFragments(value: unknown, sink: string[]): void { if (!value) return; @@ -146,6 +150,29 @@ function collectTextFragments(value: unknown, sink: string[]): void { if (record.nodes) collectTextFragments(record.nodes, sink); } +function hasOwnProperty(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function hasCommentBodyPayload(raw: Record): boolean { + return ( + hasOwnProperty(raw, 'commentText') || + hasOwnProperty(raw, 'text') || + hasOwnProperty(raw, 'commentJSON') || + hasOwnProperty(raw, 'elements') + ); +} + +function isSyntheticTrackedChangeProjection(raw: Record): boolean { + if (raw.trackedChange !== true) return false; + + // Tracked-change projection rows share the comments Y.Array, but they are + // not user-authored comment entities. Linked user comments on tracked + // content also carry `trackedChange: true`, so only skip rows that lack any + // actual comment body payload or thread metadata. + return !hasCommentBodyPayload(raw) && !hasOwnProperty(raw, 'parentCommentId'); +} + export function extractCommentText(entry: CommentEntityRecord): string | undefined { if (typeof entry.commentText === 'string') return entry.commentText; @@ -157,27 +184,6 @@ export function extractCommentText(entry: CommentEntityRecord): string | undefin return fragments.join('').trim(); } -export function buildCommentJsonFromText(text: string): unknown[] { - const normalized = stripHtmlToText(text); - - return [ - { - type: 'paragraph', - content: [ - { - type: 'run', - content: [ - { - type: 'text', - text: normalized, - }, - ], - }, - ], - }, - ]; -} - export function isCommentResolved(entry: CommentEntityRecord): boolean { return Boolean(entry.isDone || entry.resolvedTime); } @@ -196,8 +202,9 @@ export function isCommentResolved(entry: CommentEntityRecord): boolean { * - Each entry with a `commentId` is upserted into the store. Existing * entries are merged (collaborator-authored fields override locally * captured ones; missing fields are left alone). - * - Entries flagged `trackedChange: true` are skipped — those belong to - * the tracked-changes domain, not the comments store. + * - Synthetic tracked-change projection rows are skipped — those belong to + * the tracked-changes domain, not the comments store. Linked user + * comments on tracked content are still synced. * - When `options.previouslySynced` is provided, any id present in the * prior set but absent from the current entries is treated as a remote * deletion and pruned via `removeCommentEntityTree`. Locally-authored @@ -228,7 +235,7 @@ export function syncCommentEntitiesFromCollaboration( const validEntries: Array> = []; for (const raw of entries) { if (!raw || typeof raw !== 'object') continue; - if (raw.trackedChange === true) continue; + if (isSyntheticTrackedChangeProjection(raw)) continue; const cid = toNonEmptyString(raw.commentId); const iid = toNonEmptyString(raw.importedId); if (cid) upstreamIds.add(cid); @@ -270,6 +277,8 @@ export function syncCommentEntitiesFromCollaboration( // Identity fields if (typeof raw.importedId === 'string') patch.importedId = raw.importedId; if (typeof raw.parentCommentId === 'string') patch.parentCommentId = raw.parentCommentId; + if (raw.trackedChangeParentId === null) patch.trackedChangeParentId = null; + if (typeof raw.trackedChangeParentId === 'string') patch.trackedChangeParentId = raw.trackedChangeParentId; // Body const commentText = typeof raw.commentText === 'string' ? raw.commentText : typeof raw.text === 'string' ? raw.text : undefined; @@ -281,6 +290,26 @@ export function syncCommentEntitiesFromCollaboration( if (typeof raw.creatorEmail === 'string') patch.creatorEmail = raw.creatorEmail; if (typeof raw.creatorImage === 'string') patch.creatorImage = raw.creatorImage; if (typeof raw.createdTime === 'number') patch.createdTime = raw.createdTime; + // Tracked-change linkage + if (typeof raw.trackedChange === 'boolean') patch.trackedChange = raw.trackedChange; + if (typeof raw.trackedChangeType === 'string') patch.trackedChangeType = raw.trackedChangeType as TrackChangeType; + if (raw.trackedChangeType === null) patch.trackedChangeType = null; + if (typeof raw.trackedChangeDisplayType === 'string') patch.trackedChangeDisplayType = raw.trackedChangeDisplayType; + if (raw.trackedChangeDisplayType === null) patch.trackedChangeDisplayType = null; + if (raw.trackedChangeStory === null) patch.trackedChangeStory = null; + if (raw.trackedChangeStory && typeof raw.trackedChangeStory === 'object') { + patch.trackedChangeStory = raw.trackedChangeStory as StoryLocator; + } + if (typeof raw.trackedChangeStoryKind === 'string') patch.trackedChangeStoryKind = raw.trackedChangeStoryKind; + if (raw.trackedChangeStoryKind === null) patch.trackedChangeStoryKind = null; + if (typeof raw.trackedChangeStoryLabel === 'string') patch.trackedChangeStoryLabel = raw.trackedChangeStoryLabel; + if (raw.trackedChangeStoryLabel === null) patch.trackedChangeStoryLabel = null; + if (typeof raw.trackedChangeAnchorKey === 'string') patch.trackedChangeAnchorKey = raw.trackedChangeAnchorKey; + if (raw.trackedChangeAnchorKey === null) patch.trackedChangeAnchorKey = null; + if (typeof raw.trackedChangeText === 'string') patch.trackedChangeText = raw.trackedChangeText; + if (raw.trackedChangeText === null) patch.trackedChangeText = null; + if (typeof raw.deletedText === 'string') patch.deletedText = raw.deletedText; + if (raw.deletedText === null) patch.deletedText = null; // Status if (typeof raw.isInternal === 'boolean') patch.isInternal = raw.isInternal; if (typeof raw.isDone === 'boolean') patch.isDone = raw.isDone; @@ -319,10 +348,29 @@ export function toCommentInfo( target?: TextTarget; status?: CommentStatus; anchoredText?: string; + trackedChangeLink?: CommentTrackedChangeLink | null; } = {}, ): CommentInfo { const resolvedId = typeof entry.commentId === 'string' ? entry.commentId : String(entry.importedId ?? ''); const status = options.status ?? (isCommentResolved(entry) ? 'resolved' : 'open'); + const trackedChangeLink = + options.trackedChangeLink !== undefined + ? options.trackedChangeLink + : entry.trackedChange === true || + entry.trackedChangeType != null || + entry.trackedChangeAnchorKey != null || + entry.trackedChangeText != null || + entry.deletedText != null + ? { + trackedChange: true, + trackedChangeType: entry.trackedChangeType ?? undefined, + trackedChangeDisplayType: entry.trackedChangeDisplayType ?? null, + trackedChangeStory: entry.trackedChangeStory ?? null, + trackedChangeAnchorKey: entry.trackedChangeAnchorKey ?? null, + trackedChangeText: entry.trackedChangeText ?? null, + deletedText: entry.deletedText ?? null, + } + : null; return { address: { @@ -341,5 +389,13 @@ export function toCommentInfo( createdTime: typeof entry.createdTime === 'number' ? entry.createdTime : undefined, creatorName: typeof entry.creatorName === 'string' ? entry.creatorName : undefined, creatorEmail: typeof entry.creatorEmail === 'string' ? entry.creatorEmail : undefined, + trackedChange: trackedChangeLink?.trackedChange === true ? true : undefined, + trackedChangeType: trackedChangeLink?.trackedChangeType, + trackedChangeDisplayType: trackedChangeLink?.trackedChangeDisplayType ?? undefined, + trackedChangeStory: trackedChangeLink?.trackedChangeStory ?? undefined, + trackedChangeAnchorKey: trackedChangeLink?.trackedChangeAnchorKey ?? undefined, + trackedChangeText: trackedChangeLink?.trackedChangeText ?? undefined, + deletedText: trackedChangeLink?.deletedText ?? undefined, + trackedChangeLink, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index 33c2b6fc8c..e163db05ae 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -46,6 +46,8 @@ export type GroupedTrackedChange = { overlap?: TrackChangeOverlapInfo; }; +export type TrackedChangeProjectedSide = 'inserted' | 'deleted'; + type ChangeTypeInput = Pick; type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; type InternalTrackChangeOverlapLayer = TrackChangeOverlapLayer & { @@ -353,13 +355,18 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } export function resolveTrackedChange(editor: Editor, id: string): GroupedTrackedChange | null { + const { baseId } = splitProjectedTrackedChangeId(id); const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id) ?? null; + return grouped.find((item) => item.id === baseId) ?? null; } export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { + const { baseId, side } = splitProjectedTrackedChangeId(rawId); const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId || item.commandRawId === rawId)?.id ?? null; + const canonical = + grouped.find((item) => item.rawId === baseId || item.commandRawId === baseId || item.id === baseId)?.id ?? null; + if (!canonical) return null; + return side ? `${canonical}#${side}` : canonical; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -368,10 +375,25 @@ export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map item.id === id || item.rawId === id || item.commandRawId === id) ?? null; + return grouped.find((item) => item.id === baseId || item.rawId === baseId || item.commandRawId === baseId) ?? null; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index a3e1d81804..3cb7828b68 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -26,19 +26,26 @@ vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn(), })); -vi.mock('../helpers/adapter-utils.js', async () => { - const actual = await vi.importActual('../helpers/adapter-utils.js'); - return { - ...actual, - resolveTextTarget: vi.fn(), - }; -}); +vi.mock('../helpers/adapter-utils.js', () => ({ + resolveTextTarget: vi.fn(), + validatePaginationInput: vi.fn(), + paginate: vi.fn((items: T[], offset = 0, limit?: number) => { + const total = items.length; + const effectiveLimit = limit ?? total; + return { total, items: items.slice(offset, offset + effectiveLimit) }; + }), +})); import { listCommentAnchors } from '../helpers/comment-target-resolver.js'; import { resolveTextTarget } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; +const listCommentAnchorsMock = listCommentAnchors as unknown as ReturnType; +const resolveTextTargetMock = resolveTextTarget as unknown as ReturnType; +const executeDomainCommandMock = executeDomainCommand as unknown as ReturnType; +const getTrackedChangeIndexMock = getTrackedChangeIndex as unknown as ReturnType; + function makeAnchor( overrides: Partial & { commentId: string; pos: number; end: number }, ): CommentAnchor { @@ -75,8 +82,8 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { } beforeEach(() => { - vi.mocked(listCommentAnchors).mockReturnValue([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ getAll: () => [] } as never); + listCommentAnchorsMock.mockReturnValue([]); + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [] } as never); }); describe('comments-wrappers: anchoredText', () => { @@ -86,7 +93,7 @@ describe('comments-wrappers: anchoredText', () => { it('populates anchoredText for a root comment with an anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], 'selected text'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 10, end: 23 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 10, end: 23 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -96,7 +103,7 @@ describe('comments-wrappers: anchoredText', () => { it('returns anchoredText as undefined when comment has no anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }]); - vi.mocked(listCommentAnchors).mockReturnValue([]); + listCommentAnchorsMock.mockReturnValue([]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -112,7 +119,7 @@ describe('comments-wrappers: anchoredText', () => { ], 'anchored excerpt', ); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 5, end: 20 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 5, end: 20 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -125,7 +132,7 @@ describe('comments-wrappers: anchoredText', () => { it('returns anchoredText on comments.get as well', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], 'get excerpt'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 11 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 11 })]); const wrapper = createCommentsWrapper(editor); const info = wrapper.get({ commentId: 'c1' }); @@ -137,7 +144,7 @@ describe('comments-wrappers: anchoredText', () => { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => { throw new Error('out of range'); }); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 999, end: 1000 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 999, end: 1000 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -153,7 +160,7 @@ describe('comments-wrappers: anchoredText', () => { ], 'deep excerpt', ); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 12 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 12 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -168,7 +175,7 @@ describe('comments-wrappers: anchoredText', () => { it('strips object-replacement characters from range-node atoms', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], '\ufffchello world\ufffc'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 15 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 15 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -177,9 +184,7 @@ describe('comments-wrappers: anchoredText', () => { it('populates anchoredText for resolved comments', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Resolved note', isDone: true }], 'resolved text'); - vi.mocked(listCommentAnchors).mockReturnValue([ - makeAnchor({ commentId: 'c1', pos: 0, end: 13, status: 'resolved' }), - ]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 13, status: 'resolved' })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list({ includeResolved: true }); @@ -188,7 +193,7 @@ describe('comments-wrappers: anchoredText', () => { it('projects live tracked changes as tracked-change comments', () => { const editor = makeEditor([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [ { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-live' }, @@ -226,7 +231,7 @@ describe('comments-wrappers: anchoredText', () => { it('does not add synthetic comments for imported Word tracked changes', () => { const editor = makeEditor([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [ { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-imported' }, @@ -259,7 +264,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { it('returns a single-segment target for a comment with one anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abc'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -280,7 +285,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abc', 'def'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -314,7 +319,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { mockTextBetweenSequence(editor, 'first', 'second'); // Anchors provided out of document order — sorted internally by pos - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 50, @@ -340,7 +345,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { it('returns target as undefined when comment has no anchors', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); - vi.mocked(listCommentAnchors).mockReturnValue([]); + listCommentAnchorsMock.mockReturnValue([]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -351,7 +356,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'hello', 'world'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -379,7 +384,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { ]); mockTextBetweenSequence(editor, 'abc', 'def'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -417,7 +422,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { return 'second segment'; }); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 999, @@ -444,7 +449,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, '\ufffcabc\ufffc', '\ufffcdef\ufffc'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -473,7 +478,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { it('merges adjacent same-block anchors into one segment', () => { // Two adjacent ranges in block p1: [0,5] and [5,10] → merged [0,10] const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abcdefghij'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -502,7 +507,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { it('merges overlapping same-block anchors into one segment', () => { // Two overlapping ranges in block p1: [0,5] and [3,8] → merged [0,8] const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abcdefgh'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -532,7 +537,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abc', 'ghij'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -566,7 +571,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abcdefghij', 'xyz'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -628,7 +633,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // segments[0] resolves to a later PM range than segments[1] — the // caller built an out-of-order TextTarget (e.g. stitched two // selections together backwards). - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 50, to: 60 }; if (target.blockId === 'pB') return { from: 10, to: 20 }; return null; @@ -658,7 +663,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // segments[0] and segments[1] are in order, but there is text // between them (pm positions 10..20 are selected, 30..40 selected, // positions 20..30 have real text the caller did not select). - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'p1') return { from: 10, to: 20 }; if (target.blockId === 'p3') return { from: 30, to: 40 }; return null; @@ -688,7 +693,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // Two adjacent textblocks — the flattened PM gap between them is // just block-boundary tokens, which textBetween(prev.to, curr.from, '') // renders as an empty string. - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 10, to: 20 }; if (target.blockId === 'pB') return { from: 22, to: 30 }; return null; @@ -696,7 +701,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => ''); // Simulate a successful plan execution so the handler reaches the // success branch after validation + applyTextSelection. - vi.mocked(executeDomainCommand).mockReturnValue({ + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -718,14 +723,103 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(receipt.success).toBe(true); }); + it('accepts trackedChangeId targets from the tracked-change index when live resolution misses a body deletion', () => { + const editor = makeWriteEditor(); + const bodyDeletionSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-del-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-del-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'deleted text', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-del-1', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 19, to: 27 }, + }; + getTrackedChangeIndexMock.mockReturnValue({ + get: () => [bodyDeletionSnapshot], + getAll: () => [], + } as never); + executeDomainCommandMock.mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const receipt = wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1', + } as Parameters[0]['target'], + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 19, to: 27 }); + }); + + it('creates a tracked-change-linked comment without a text selection when a deletion target cannot be resolved live', () => { + const editor = { + ...makeWriteEditor(), + emit: vi.fn(), + options: { + documentId: 'doc-1', + user: { + name: 'Actor B', + email: 'actor-b@labs.requirements', + }, + }, + } as unknown as Editor; + + (editor.commands!.addComment as ReturnType).mockImplementation(() => { + throw new Error('addComment should not run for detached tracked deletions'); + }); + + const wrapper = createCommentsWrapper(editor); + const receipt = wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1#deleted', + } as Parameters[0]['target'], + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection as ReturnType).not.toHaveBeenCalled(); + expect(editor.commands!.addComment as ReturnType).not.toHaveBeenCalled(); + expect(editor.converter!.comments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + commentId: receipt.id, + commentText: 'comment on deletion', + trackedChange: true, + trackedChangeParentId: 'tc-del-1', + trackedChangeType: 'delete', + }), + ]), + ); + expect((editor as { emit: ReturnType }).emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + type: 'add', + activeCommentId: receipt.id, + comment: expect.objectContaining({ + commentId: receipt.id, + trackedChangeParentId: 'tc-del-1', + trackedChangeType: 'delete', + }), + }), + ); + }); + it('treats a TextAddress with an undefined `segments` field as TextAddress, not TextTarget', () => { // Regression: a plain structural `'segments' in target` check misclassifies // a TextAddress carrying an extra undefined `segments` field (e.g. from // object spread) as a TextTarget, then crashes on `segments[0]`. The // runtime guard must reject a non-array `segments` before the spread. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 5, to: 12 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 5, to: 12 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -762,8 +856,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // requires the absence of TextAddress fields, so a hybrid falls // through to the explicit-block branch. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -797,8 +891,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // a TextAddress, so the adapter must not fall through to the // single-block path and dereference target.range. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -823,8 +917,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // but not as a TextAddress. The adapter should resolve the real // segment instead of manufacturing a segment from the stray range. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -852,7 +946,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // firstResolved.from < lastResolved.to across the block boundary), // silently anchoring a comment over content the caller never selected. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 10, to: 10 }; if (target.blockId === 'pB') return { from: 20, to: 20 }; return null; @@ -882,7 +976,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // because PM omits leaves from textBetween by default. The contiguity // check must use a leafText callback so atom-only gaps still reject. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'p1') return { from: 5, to: 10 }; if (target.blockId === 'p1-after-image') return { from: 12, to: 17 }; return null; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index 69d8b867c6..d96ec2f329 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -10,8 +10,11 @@ import type { Editor } from '../../core/Editor.js'; import type { AddCommentInput, + CommentTarget, CommentInfo, + CommentTrackedChangeLink, CommentsAdapter, + CommentsCreateReceipt, CommentsListQuery, CommentsListResult, EditCommentInput, @@ -24,6 +27,7 @@ import type { ReplyToCommentInput, ResolveCommentInput, RevisionGuardOptions, + SelectionTarget, StoryLocator, SetCommentActiveInput, SetCommentInternalInput, @@ -37,12 +41,14 @@ import { v4 as uuidv4 } from 'uuid'; import { DocumentApiAdapterError } from '../errors.js'; import { requireEditorCommand } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; -import { getRevision } from './revision-tracker.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; import { resolveTextTarget, paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { getCachedProjectedTrackedChangeSnapshot, projectSnapshots } from './track-changes-wrappers.js'; import { buildCommentJsonFromText, extractCommentText, + type CommentEntityRecord, findCommentEntity, getCommentEntityStore, isCommentResolved, @@ -54,6 +60,9 @@ import { listCommentAnchors, resolveCommentAnchorsById } from '../helpers/commen import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; +import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; +import { resolveTrackedChangeInStory, splitProjectedTrackedChangeId } from '../helpers/tracked-change-resolver.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- // Internal helpers @@ -69,9 +78,12 @@ type TrackedChangeCommentInfo = CommentInfo & { story?: StoryLocator; trackedChange?: boolean; trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; trackedChangeAnchorKey?: string; trackedChangeText?: string; deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; }; function toCommentAddress(commentId: string): { kind: 'entity'; entityType: 'comment'; entityId: string } { @@ -88,13 +100,6 @@ function toNotFoundError(input: unknown): DocumentApiAdapterError { }); } -function isSameTarget( - left: { blockId: string; range: { start: number; end: number } }, - right: { blockId: string; range: { start: number; end: number } }, -): boolean { - return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; -} - /** * Check whether a payload carries a complete TextAddress. The * document-api input validator accepts a payload if it satisfies either @@ -152,6 +157,132 @@ function targetToSegments( return null; } +function isTrackedChangeCommentTargetShape( + target: unknown, +): target is { trackedChangeId: string; story?: StoryLocator } { + if (!target || typeof target !== 'object') return false; + const value = target as { kind?: unknown; trackedChangeId?: unknown }; + if (value.kind !== undefined && value.kind !== 'trackedChange') return false; + return typeof value.trackedChangeId === 'string' && value.trackedChangeId.length > 0; +} + +function buildTrackedChangeLink(snapshot: TrackedChangeSnapshot): CommentTrackedChangeLink { + const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + return { + trackedChange: true, + trackedChangeType: snapshot.type, + trackedChangeDisplayType: null, + trackedChangeStory: snapshot.story, + trackedChangeAnchorKey: snapshot.anchorKey, + trackedChangeText, + deletedText, + }; +} + +function getTrackedChangeThreadingId(snapshot: TrackedChangeSnapshot): string | null { + return snapshot.commandRawId ?? snapshot.runtimeRef.rawId ?? snapshot.address.entityId ?? null; +} + +function trackedChangeSnapshotAliases(snapshot: TrackedChangeSnapshot): string[] { + return Array.from( + new Set( + [ + snapshot.address.entityId, + snapshot.runtimeRef.rawId, + snapshot.commandRawId, + snapshot.replacementGroupId, + snapshot.replacementSideId, + snapshot.anchorKey, + ].filter((value): value is string => typeof value === 'string' && value.length > 0), + ), + ); +} + +function overlapsTrackedChangeRange(snapshot: TrackedChangeSnapshot, from: number, to: number): boolean { + if (from === to) { + return snapshot.range.from <= from && snapshot.range.to >= to; + } + return snapshot.range.from < to && snapshot.range.to > from; +} + +function trackedChangeTypePriority(type: TrackChangeType): number { + if (type === 'delete') return 0; + if (type === 'format') return 1; + return 2; +} + +function choosePreferredTrackedChangeSnapshot( + snapshots: ReadonlyArray, + preferredId?: string, +): TrackedChangeSnapshot | null { + if (snapshots.length === 0) return null; + + const preferred = preferredId ? String(preferredId) : null; + const ordered = [...snapshots].sort((left, right) => { + const leftMatchesPreferred = preferred + ? trackedChangeSnapshotAliases(left).some((alias) => alias === preferred) + : false; + const rightMatchesPreferred = preferred + ? trackedChangeSnapshotAliases(right).some((alias) => alias === preferred) + : false; + if (leftMatchesPreferred !== rightMatchesPreferred) { + return leftMatchesPreferred ? -1 : 1; + } + + const leftLength = Math.max(0, left.range.to - left.range.from); + const rightLength = Math.max(0, right.range.to - right.range.from); + if (leftLength !== rightLength) return leftLength - rightLength; + + const typeDelta = trackedChangeTypePriority(left.type) - trackedChangeTypePriority(right.type); + if (typeDelta !== 0) return typeDelta; + + if (left.range.from !== right.range.from) return left.range.from - right.range.from; + return left.address.entityId.localeCompare(right.address.entityId); + }); + + return ordered[0] ?? null; +} + +function inferTrackedChangeSnapshotForRange( + editor: Editor, + from: number, + to: number, + preferredId?: string, +): TrackedChangeSnapshot | null { + let snapshots: ReadonlyArray; + try { + snapshots = getTrackedChangeIndex(editor).getAll(); + } catch { + return null; + } + + const overlapping = snapshots.filter((snapshot) => overlapsTrackedChangeRange(snapshot, from, to)); + return choosePreferredTrackedChangeSnapshot(overlapping, preferredId); +} + +function assignTrackedChangeLink(info: TrackedChangeCommentInfo, link: CommentTrackedChangeLink | null): void { + if (!link) { + delete info.trackedChange; + delete info.trackedChangeType; + delete info.trackedChangeDisplayType; + delete info.trackedChangeStory; + delete info.trackedChangeAnchorKey; + delete info.trackedChangeText; + delete info.deletedText; + info.trackedChangeLink = null; + return; + } + + info.trackedChange = true; + info.trackedChangeType = link.trackedChangeType; + info.trackedChangeDisplayType = link.trackedChangeDisplayType ?? undefined; + info.trackedChangeStory = link.trackedChangeStory ?? undefined; + info.trackedChangeAnchorKey = link.trackedChangeAnchorKey ?? undefined; + info.trackedChangeText = link.trackedChangeText ?? undefined; + info.deletedText = link.deletedText ?? undefined; + info.trackedChangeLink = link; +} + function listCommentAnchorsSafe(editor: Editor): ReturnType { try { return listCommentAnchors(editor); @@ -183,6 +314,16 @@ function emitCommentLifecycleUpdate( emitter.call(editor, 'commentsUpdate', { type, comment }); } +function emitCommentAdd(editor: Editor, comment: Record, activeCommentId?: string): void { + const emitter = (editor as unknown as { emit?: (event: string, payload: unknown) => void }).emit; + if (typeof emitter !== 'function') return; + emitter.call(editor, 'commentsUpdate', { + type: 'add', + comment, + ...(activeCommentId ? { activeCommentId } : {}), + }); +} + function applyTextSelection(editor: Editor, from: number, to: number): boolean { const setTextSelection = editor.commands?.setTextSelection; if (typeof setTextSelection === 'function') { @@ -204,6 +345,58 @@ function applyTextSelection(editor: Editor, from: number, to: number): boolean { return false; } +function addDetachedTrackedChangeComment( + editor: Editor, + input: AddCommentInput, + target: Extract, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { + if (options?.expectedRevision) { + checkRevision(editor, options.expectedRevision); + } + + const commentId = uuidv4(); + const now = Date.now(); + const store = getCommentEntityStore(editor); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + const { baseId, side } = splitProjectedTrackedChangeId(target.trackedChangeId); + const trackedChangeType = side === 'deleted' ? 'delete' : side === 'inserted' ? 'insert' : null; + const trackedChangeStory = target.story ?? ({ kind: 'story', storyType: 'body' } as StoryLocator); + + upsertCommentEntity(store, commentId, { + commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + parentCommentId: undefined, + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + trackedChange: true, + trackedChangeParentId: baseId, + trackedChangeType, + trackedChangeDisplayType: null, + trackedChangeStory, + trackedChangeStoryKind: trackedChangeStory.kind === 'story' ? trackedChangeStory.storyType : null, + trackedChangeStoryLabel: + trackedChangeStory.kind === 'story' && trackedChangeStory.storyType === 'body' ? 'Body' : null, + trackedChangeAnchorKey: null, + trackedChangeText: trackedChangeType === 'delete' ? '' : null, + deletedText: trackedChangeType === 'delete' ? '' : null, + }); + + const stored = findCommentEntity(store, commentId); + if (stored) { + emitCommentAdd(editor, buildCommentLifecyclePayload(stored), commentId); + } + + return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; +} + function resolveCommentIdentity( editor: Editor, commentId: string, @@ -264,6 +457,8 @@ type CanonicalAnchor = { end: number; }; +type CanonicalAnchorMap = Map; + /** * Merges same-block adjacent/overlapping anchors into canonical segments. * @@ -361,8 +556,9 @@ function mergeAnchorData( editor: Editor, infosById: Map, anchors: ReturnType, -): void { +): CanonicalAnchorMap { const grouped = new Map(); + const canonicalByCommentId: CanonicalAnchorMap = new Map(); for (const anchor of anchors) { const group = grouped.get(anchor.commentId) ?? []; group.push(anchor); @@ -374,6 +570,7 @@ function mergeAnchorData( const firstAnchor = sorted[0]; const status = sorted.every((anchor) => anchor.status === 'resolved') ? 'resolved' : 'open'; const canonical = canonicalizeAnchors(sorted); + canonicalByCommentId.set(commentId, canonical); const target = buildTextTarget(canonical); const anchoredText = buildAnchoredText(editor, canonical); const existing = infosById.get(commentId); @@ -404,6 +601,8 @@ function mergeAnchorData( ), ); } + + return canonicalByCommentId; } function parseCreatedTime(value: string | undefined): number | undefined { @@ -427,6 +626,7 @@ function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedCha if (!commentId) return null; const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + const trackedChangeLink = buildTrackedChangeLink(snapshot); return { address: toCommentAddress(commentId), @@ -440,9 +640,11 @@ function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedCha story: snapshot.story, trackedChange: true, trackedChangeType: snapshot.type, + trackedChangeStory: snapshot.story, trackedChangeAnchorKey: snapshot.anchorKey, trackedChangeText, deletedText, + trackedChangeLink, }; } @@ -469,6 +671,7 @@ function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map(); let cursor: CommentInfo | undefined = info; while (cursor?.parentCommentId && !visited.has(cursor.parentCommentId)) { @@ -505,6 +721,9 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { if (ancestor?.target != null) { if (info.target == null) info.target = ancestor.target; if (info.anchoredText == null && ancestor.anchoredText != null) info.anchoredText = ancestor.anchoredText; + if (info.trackedChangeLink == null && ancestor.trackedChangeLink != null) { + assignTrackedChangeLink(info, ancestor.trackedChangeLink); + } break; } cursor = ancestor; @@ -527,50 +746,254 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { return infos; } -// --------------------------------------------------------------------------- -// Mutation handlers -// --------------------------------------------------------------------------- +type ResolvedCommentTarget = { + from: number; + to: number; + trackedChangeSnapshot: TrackedChangeSnapshot | null; +}; -function addCommentHandler(editor: Editor, input: AddCommentInput, options?: RevisionGuardOptions): Receipt { - requireEditorCommand(editor.commands?.addComment, 'comments.create (addComment)'); +type CommentTargetResolution = + | { ok: true; value: ResolvedCommentTarget } + | { ok: false; failure: Extract['failure'] }; - // The target can be either a single-block TextAddress or a multi-segment - // TextTarget. For a TextTarget, resolve each segment and require they - // cover a contiguous PM range in document order — out-of-order or - // disjoint segments would otherwise silently anchor the comment over - // intervening text the caller never selected. - const target = input.target; - if (!target) { +function buildTrackedChangeEntityFields(snapshot: TrackedChangeSnapshot | null): Record { + if (!snapshot) { return { - success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment target is required.', + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeDisplayType: null, + trackedChangeStory: null, + trackedChangeStoryKind: null, + trackedChangeStoryLabel: null, + trackedChangeAnchorKey: null, + trackedChangeText: null, + deletedText: null, + }; + } + + const link = buildTrackedChangeLink(snapshot); + return { + trackedChange: true, + trackedChangeParentId: getTrackedChangeThreadingId(snapshot), + trackedChangeType: link.trackedChangeType ?? null, + trackedChangeDisplayType: link.trackedChangeDisplayType ?? null, + trackedChangeStory: link.trackedChangeStory ?? null, + trackedChangeStoryKind: snapshot.storyKind, + trackedChangeStoryLabel: snapshot.storyLabel, + trackedChangeAnchorKey: link.trackedChangeAnchorKey ?? null, + trackedChangeText: link.trackedChangeText ?? null, + deletedText: link.deletedText ?? null, + }; +} + +function hasTrackedChangeEntityFields(record: CommentEntityRecord | undefined): boolean { + return Boolean( + record && + (record.trackedChange === true || + record.trackedChangeParentId != null || + record.trackedChangeType != null || + record.trackedChangeAnchorKey != null || + record.trackedChangeText != null || + record.deletedText != null), + ); +} + +function buildTrackedChangeEntityFieldsFromRecord( + record: CommentEntityRecord | undefined, +): Record | null { + if (!hasTrackedChangeEntityFields(record)) return null; + return { + trackedChange: record?.trackedChange === true, + trackedChangeParentId: record?.trackedChangeParentId ?? null, + trackedChangeType: record?.trackedChangeType ?? null, + trackedChangeDisplayType: record?.trackedChangeDisplayType ?? null, + trackedChangeStory: record?.trackedChangeStory ?? null, + trackedChangeStoryKind: record?.trackedChangeStoryKind ?? null, + trackedChangeStoryLabel: record?.trackedChangeStoryLabel ?? null, + trackedChangeAnchorKey: record?.trackedChangeAnchorKey ?? null, + trackedChangeText: record?.trackedChangeText ?? null, + deletedText: record?.deletedText ?? null, + }; +} + +function buildCommentLifecyclePayload(record: CommentEntityRecord): Record { + const payload: Record = { + commentId: record.commentId, + }; + + if (record.importedId !== undefined) payload.importedId = record.importedId; + if (record.parentCommentId !== undefined) payload.parentCommentId = record.parentCommentId; + if (record.commentText !== undefined) { + payload.commentText = record.commentText; + payload.text = record.commentText; + } + if (record.commentJSON !== undefined) payload.commentJSON = record.commentJSON; + if (record.creatorName !== undefined) payload.creatorName = record.creatorName; + if (record.creatorEmail !== undefined) payload.creatorEmail = record.creatorEmail; + if (record.creatorImage !== undefined) payload.creatorImage = record.creatorImage; + if (record.createdTime !== undefined) payload.createdTime = record.createdTime; + if (record.isInternal !== undefined) payload.isInternal = record.isInternal; + if (record.isDone !== undefined) payload.isDone = record.isDone; + if (record.resolvedTime !== undefined) payload.resolvedTime = record.resolvedTime; + if (record.fileId !== undefined) payload.fileId = record.fileId; + if (record.documentId !== undefined) payload.documentId = record.documentId; + if (record.trackedChange !== undefined) payload.trackedChange = record.trackedChange; + if (record.trackedChangeParentId !== undefined) payload.trackedChangeParentId = record.trackedChangeParentId; + if (record.trackedChangeType !== undefined) payload.trackedChangeType = record.trackedChangeType; + if (record.trackedChangeDisplayType !== undefined) payload.trackedChangeDisplayType = record.trackedChangeDisplayType; + if (record.trackedChangeStory !== undefined) payload.trackedChangeStory = record.trackedChangeStory; + if (record.trackedChangeStoryKind !== undefined) payload.trackedChangeStoryKind = record.trackedChangeStoryKind; + if (record.trackedChangeStoryLabel !== undefined) payload.trackedChangeStoryLabel = record.trackedChangeStoryLabel; + if (record.trackedChangeAnchorKey !== undefined) payload.trackedChangeAnchorKey = record.trackedChangeAnchorKey; + if (record.trackedChangeText !== undefined) payload.trackedChangeText = record.trackedChangeText; + if (record.deletedText !== undefined) payload.deletedText = record.deletedText; + + return payload; +} + +function findTrackedChangeSnapshotByAlias(editor: Editor, alias: string): TrackedChangeSnapshot | null { + try { + const index = getTrackedChangeIndex(editor); + const bodyStory: StoryLocator = { kind: 'story', storyType: 'body' }; + const bodySnapshots = + typeof index.get === 'function' ? Array.from(index.get(bodyStory)) : ([] as TrackedChangeSnapshot[]); + const candidateSets = + bodySnapshots.length > 0 ? [bodySnapshots, Array.from(index.getAll())] : [Array.from(index.getAll())]; + + for (const snapshots of candidateSets) { + const matching = snapshots.filter((snapshot) => + trackedChangeSnapshotAliases(snapshot).some((value) => value === alias), + ); + const directMatch = choosePreferredTrackedChangeSnapshot(matching, alias); + if (directMatch) return directMatch; + + const projectedMatch = projectSnapshots(snapshots).find((row) => row.info.id === alias); + if (projectedMatch) return projectedMatch.snapshot; + } + + return getCachedProjectedTrackedChangeSnapshot(editor, alias); + } catch { + return null; + } +} + +function resolveCommentTrackedChangeSnapshot(editor: Editor, commentId: string): TrackedChangeSnapshot | null { + const store = getCommentEntityStore(editor); + const record = findCommentEntity(store, commentId); + const preferredId = toNonEmptyString(record?.trackedChangeParentId); + if (preferredId) { + const direct = findTrackedChangeSnapshotByAlias(editor, preferredId); + if (direct) return direct; + } + + const info = buildCommentInfos(editor).find( + (candidate) => candidate.commentId === commentId || candidate.importedId === commentId, + ); + if (!info?.target) return null; + + const resolved = resolveCommentTarget(editor, info.target); + if (!resolved.ok) return null; + return resolved.value.trackedChangeSnapshot; +} + +function resolveCommentTarget(editor: Editor, target: CommentTarget): CommentTargetResolution { + if (isTrackedChangeCommentTargetShape(target)) { + const resolved = resolveTrackedChangeInStory(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: target.trackedChangeId, + ...(target.story ? { story: target.story } : {}), + }); + const indexedSnapshot = resolved ? null : findTrackedChangeSnapshotByAlias(editor, target.trackedChangeId); + if (!resolved && !indexedSnapshot) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Comment target could not be resolved.', { + target, + }); + } + if (resolved && resolved.editor !== editor) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment tracked-change targets outside the active story are not supported on this editor handle.', + details: { target }, + }, + }; + } + if (indexedSnapshot && buildStoryKey(indexedSnapshot.story) !== BODY_STORY_KEY) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment tracked-change targets outside the active story are not supported on this editor handle.', + details: { target }, + }, + }; + } + + const from = resolved?.change.from ?? indexedSnapshot!.range.from; + const to = resolved?.change.to ?? indexedSnapshot!.range.to; + if (from === to) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + details: { target }, + }, + }; + } + const trackedChangeSnapshot = + indexedSnapshot ?? inferTrackedChangeSnapshotForRange(editor, from, to, target.trackedChangeId); + return { + ok: true, + value: { + from, + to, + trackedChangeSnapshot, + }, + }; + } + + if ((target as SelectionTarget | undefined)?.kind === 'selection') { + const resolved = resolveSelectionTarget(editor, target as SelectionTarget); + if (resolved.absFrom === resolved.absTo) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + details: { target }, + }, + }; + } + return { + ok: true, + value: { + from: resolved.absFrom, + to: resolved.absTo, + trackedChangeSnapshot: inferTrackedChangeSnapshotForRange(editor, resolved.absFrom, resolved.absTo), }, }; } + const segments = targetToSegments(target); if (!segments) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', - message: 'Comment target must be a TextAddress or TextTarget.', + message: 'Comment target must be a TextAddress, TextTarget, SelectionTarget, or tracked-change target.', details: { target }, }, }; } - // Per-segment collapse check. Without this, two collapsed segments in - // different blocks (e.g. caret at end of p1 and caret at start of p2) - // pass the order + contiguity checks AND the spanning-range collapse - // check (because firstResolved.from < lastResolved.to across the block - // boundary), then silently anchor a comment over intervening content. - // Each individual segment must represent a non-empty range. for (const seg of segments) { if (seg.range.start === seg.range.end) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.', @@ -595,7 +1018,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev const curr = resolvedSegments[i]!; if (prev.to > curr.from) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: 'Comment target segments must be in document order.', @@ -603,19 +1026,10 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev }, }; } - // Detect content the caller didn't select sitting between segments. - // `textBetween(prev.to, curr.from, '')` returns: - // - '' for true adjacency (same block) or pure block boundaries - // (a legitimate multi-block selection between adjacent blocks); - // - '' if any text node sits in the gap. - // The `leafText` 4th argument lets us also surface inline atoms - // (images, math, etc) that PM otherwise omits from `textBetween`. - // We pass a sentinel for atoms only — keeping `blockSeparator: ''` - // so legitimate cross-block adjacency still produces an empty gap. const gap = docForGap ? docForGap.textBetween(prev.to, curr.from, '', () => '\u0001') : ''; if (gap.length > 0) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: @@ -628,18 +1042,67 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev const firstResolved = resolvedSegments[0]!; const lastResolved = resolvedSegments[resolvedSegments.length - 1]!; - const resolved = { from: firstResolved.from, to: lastResolved.to }; - if (resolved.from === resolved.to) { + if (firstResolved.from === lastResolved.to) { + return { + ok: false, + failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.', details: { target } }, + }; + } + + return { + ok: true, + value: { + from: firstResolved.from, + to: lastResolved.to, + trackedChangeSnapshot: inferTrackedChangeSnapshotForRange(editor, firstResolved.from, lastResolved.to), + }, + }; +} + +// --------------------------------------------------------------------------- +// Mutation handlers +// --------------------------------------------------------------------------- + +function addCommentHandler( + editor: Editor, + input: AddCommentInput, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { + // The target can be either a single-block TextAddress or a multi-segment + // TextTarget. For a TextTarget, resolve each segment and require they + // cover a contiguous PM range in document order — out-of-order or + // disjoint segments would otherwise silently anchor the comment over + // intervening text the caller never selected. + const target = input.target; + if (!target) { return { success: false, failure: { code: 'INVALID_TARGET', - message: 'Comment target range must be non-collapsed.', + message: 'Comment target is required.', }, }; } + let resolvedTarget: CommentTargetResolution; + try { + resolvedTarget = resolveCommentTarget(editor, target); + } catch (error) { + if ( + isTrackedChangeCommentTargetShape(target) && + error instanceof DocumentApiAdapterError && + error.code === 'TARGET_NOT_FOUND' + ) { + return addDetachedTrackedChangeComment(editor, input, target, options); + } + throw error; + } + if (!resolvedTarget.ok) { + return { success: false, failure: resolvedTarget.failure }; + } - if (!applyTextSelection(editor, resolved.from, resolved.to)) { + requireEditorCommand(editor.commands?.addComment, 'comments.create (addComment)'); + + if (!applyTextSelection(editor, resolvedTarget.value.from, resolvedTarget.value.to)) { return { success: false, failure: { @@ -651,6 +1114,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev } const commentId = uuidv4(); + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, @@ -675,7 +1139,12 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev isInternal: false, fileId: editor.options?.documentId, documentId: editor.options?.documentId, + ...buildTrackedChangeEntityFields(resolvedTarget.value.trackedChangeSnapshot), }); + const stored = findCommentEntity(store, commentId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } } return didInsert; }, @@ -689,7 +1158,11 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev }; } - return { success: true, inserted: [toCommentAddress(commentId)] }; + if (trackedPayload && resolvedTarget.value.trackedChangeSnapshot) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + + return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; } function editCommentHandler(editor: Editor, input: EditCommentInput, options?: RevisionGuardOptions): Receipt { @@ -736,7 +1209,11 @@ function editCommentHandler(editor: Editor, input: EditCommentInput, options?: R return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt { +function replyToCommentHandler( + editor: Editor, + input: ReplyToCommentInput, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { const addCommentReply = requireEditorCommand(editor.commands?.addCommentReply, 'comments.create (addCommentReply)'); if (!input.parentCommentId) { @@ -750,7 +1227,13 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio } const parentIdentity = resolveCommentIdentity(editor, input.parentCommentId); + const store = getCommentEntityStore(editor); + const parentRecord = findCommentEntity(store, parentIdentity.commentId); + const inheritedTrackedFields = + buildTrackedChangeEntityFieldsFromRecord(parentRecord) ?? + buildTrackedChangeEntityFields(resolveCommentTrackedChangeSnapshot(editor, parentIdentity.commentId)); const replyId = uuidv4(); + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, @@ -763,7 +1246,6 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio if (didReply) { const now = Date.now(); const user = (editor.options?.user ?? {}) as EditorUserIdentity; - const store = getCommentEntityStore(editor); upsertCommentEntity(store, replyId, { commentId: replyId, parentCommentId: parentIdentity.commentId, @@ -777,7 +1259,12 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio isInternal: false, fileId: editor.options?.documentId, documentId: editor.options?.documentId, + ...(inheritedTrackedFields ?? {}), }); + const stored = findCommentEntity(store, replyId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } } return Boolean(didReply); }, @@ -791,30 +1278,22 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio }; } - return { success: true, inserted: [toCommentAddress(replyId)] }; + if (trackedPayload && inheritedTrackedFields) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + + return { success: true, id: replyId, inserted: [toCommentAddress(replyId)] }; } function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: RevisionGuardOptions): Receipt { const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.patch (moveComment)'); - if (input.target.range.start === input.target.range.end) { - return { - success: false, - failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, - }; - } - - const resolved = resolveTextTarget(editor, input.target); - if (!resolved) { - throw toNotFoundError(input.target); - } - if (resolved.from === resolved.to) { - return { - success: false, - failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, - }; + const resolvedTarget = resolveCommentTarget(editor, input.target); + if (!resolvedTarget.ok) { + return { success: false, failure: resolvedTarget.failure }; } + const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); if (!identity.anchors.length) { return { @@ -833,17 +1312,37 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R }; } - const currentTarget = identity.anchors[0]?.target; - if (currentTarget && isSameTarget(currentTarget, input.target)) { + const currentAnchor = identity.anchors[0]; + if ( + currentAnchor && + currentAnchor.pos === resolvedTarget.value.from && + currentAnchor.end === resolvedTarget.value.to + ) { return { success: false, failure: { code: 'NO_OP', message: 'Comment move produced no change.' }, }; } + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, - () => Boolean(moveComment({ commentId: identity.commentId, from: resolved.from, to: resolved.to })), + () => { + const didMove = Boolean( + moveComment({ commentId: identity.commentId, from: resolvedTarget.value.from, to: resolvedTarget.value.to }), + ); + if (didMove) { + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + ...buildTrackedChangeEntityFields(resolvedTarget.value.trackedChangeSnapshot), + }); + const stored = findCommentEntity(store, identity.commentId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } + } + return didMove; + }, { expectedRevision: options?.expectedRevision }, ); @@ -854,6 +1353,10 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R }; } + if (trackedPayload) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } @@ -1196,9 +1699,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment story, trackedChange, trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, trackedChangeAnchorKey, trackedChangeText, deletedText, + trackedChangeLink, } = comment; return buildDiscoveryItem(comment.commentId, handle, { address, @@ -1215,9 +1721,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment story, trackedChange, trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, trackedChangeAnchorKey, trackedChangeText, deletedText, + trackedChangeLink, }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 11c8a5e1cd..8ee30f44b9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; -const mocks = vi.hoisted(() => ({ +const mocks = { checkRevision: vi.fn(), getRevision: vi.fn(() => '0'), executeDomainCommand: vi.fn(), resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), -})); +}; vi.mock('./revision-tracker.js', () => ({ checkRevision: mocks.checkRevision, @@ -37,6 +37,8 @@ import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, + trackChangesListWrapper, + getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; @@ -391,3 +393,41 @@ describe('track-changes-wrappers revision guard', () => { expectTrackChangesDecideReceiptCodeDeclared('INVALID_TARGET'); }); }); + +describe('track-changes-wrappers projected id cache', () => { + it('caches list ids for reuse on the same editor revision', () => { + const editor = makeEditor(); + const snapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-del-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-del-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'deleted text', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-del-1', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 19, to: 27 }, + }; + + mocks.getRevision.mockReturnValue('4'); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [snapshot]), + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.items[0]?.id).toBe('tc-del-1'); + expect(getCachedProjectedTrackedChangeSnapshot(editor, 'tc-del-1')).toBe(snapshot); + + mocks.getRevision.mockReturnValue('5'); + expect(getCachedProjectedTrackedChangeSnapshot(editor, 'tc-del-1')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index c412d4d0b2..385ed46fd4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -35,7 +35,11 @@ import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; import { checkRevision, getRevision } from './revision-tracker.js'; -import { resolveTrackedChangeInStory, resolveTrackedChangeType } from '../helpers/tracked-change-resolver.js'; +import { + resolveTrackedChangeInStory, + resolveTrackedChangeType, + splitProjectedTrackedChangeId, +} from '../helpers/tracked-change-resolver.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; @@ -56,31 +60,162 @@ function normalizeWordRevisionIds( return Object.keys(normalized).length > 0 ? normalized : undefined; } -function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { +type ProjectedTrackChange = { + info: TrackChangeInfo; + handleKey: string; + snapshot: TrackedChangeSnapshot; +}; + +type ProjectedTrackedChangeCacheEntry = { + revision: string; + byProjectedId: Map; +}; + +const projectedTrackedChangeCache = new WeakMap(); + +function buildProjectedInfo( + snapshot: TrackedChangeSnapshot, + options: { + id?: string; + type?: TrackChangeType; + grouping?: TrackChangeInfo['grouping']; + pairedWithChangeId?: string | null; + handleSuffix?: string; + } = {}, +): ProjectedTrackChange { + const id = options.id ?? snapshot.address.entityId; + const type = options.type ?? snapshot.type; const changedText = snapshot.excerpt - ? { [snapshot.type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } + ? { [type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } : {}; return { - address: snapshot.address, - id: snapshot.address.entityId, - type: snapshot.type, - wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), - overlap: snapshot.overlap, - author: snapshot.author, - authorEmail: snapshot.authorEmail, - authorImage: snapshot.authorImage, - date: snapshot.date, - excerpt: snapshot.excerpt, - ...(snapshot.type === 'format' ? {} : changedText), + info: { + address: { + ...snapshot.address, + entityId: id, + }, + id, + type, + grouping: options.grouping, + pairedWithChangeId: options.pairedWithChangeId ?? undefined, + wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + overlap: snapshot.overlap, + author: snapshot.author, + authorEmail: snapshot.authorEmail, + authorImage: snapshot.authorImage, + date: snapshot.date, + excerpt: snapshot.excerpt, + ...(type === 'format' ? {} : changedText), + }, + handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, + snapshot, }; } -function filterByType( - snapshots: ReadonlyArray, +function isCombinedReplacementSnapshot(snapshot: TrackedChangeSnapshot): boolean { + return snapshot.hasInsert && snapshot.hasDelete && !snapshot.hasFormat; +} + +function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { + if (snapshot.type !== 'insert' && snapshot.type !== 'delete') return null; + if (snapshot.replacementGroupId) { + return `group:${snapshot.runtimeRef.storyKey}:${snapshot.replacementGroupId}`; + } + if (snapshot.commandRawId) { + return `command:${snapshot.runtimeRef.storyKey}:${snapshot.commandRawId}`; + } + return null; +} + +function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { + return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; +} + +export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { + const byPairKey = new Map(); + for (const snapshot of snapshots) { + if (isCombinedReplacementSnapshot(snapshot)) continue; + const key = replacementPairKey(snapshot); + if (!key) continue; + const group = byPairKey.get(key) ?? []; + group.push(snapshot); + byPairKey.set(key, group); + } + + const pairedById = new Map(); + for (const group of byPairKey.values()) { + const inserts = group.filter((snapshot) => snapshot.type === 'insert'); + const deletes = group.filter((snapshot) => snapshot.type === 'delete'); + if (inserts.length !== 1 || deletes.length !== 1) continue; + pairedById.set(inserts[0].address.entityId, deletes[0].address.entityId); + pairedById.set(deletes[0].address.entityId, inserts[0].address.entityId); + } + + const projected: ProjectedTrackChange[] = []; + for (const snapshot of snapshots) { + if (isCombinedReplacementSnapshot(snapshot)) { + const insertedId = `${snapshot.address.entityId}#inserted`; + const deletedId = `${snapshot.address.entityId}#deleted`; + projected.push( + buildProjectedInfo(snapshot, { + id: insertedId, + type: 'insert', + grouping: 'replacement-pair', + pairedWithChangeId: deletedId, + handleSuffix: '#inserted', + }), + ); + projected.push( + buildProjectedInfo(snapshot, { + id: deletedId, + type: 'delete', + grouping: 'replacement-pair', + pairedWithChangeId: insertedId, + handleSuffix: '#deleted', + }), + ); + continue; + } + + const pairedWithChangeId = pairedById.get(snapshot.address.entityId) ?? null; + projected.push( + buildProjectedInfo(snapshot, { + grouping: pairedWithChangeId ? 'replacement-pair' : 'standalone', + pairedWithChangeId, + }), + ); + } + + return projected; +} + +function cacheProjectedTrackedChanges( + editor: Editor, + projected: ReadonlyArray, + revision = getRevision(editor), +): void { + projectedTrackedChangeCache.set(editor, { + revision, + byProjectedId: new Map(projected.map((row) => [row.info.id, row.snapshot])), + }); +} + +export function getCachedProjectedTrackedChangeSnapshot( + editor: Editor, + projectedId: string, +): TrackedChangeSnapshot | null { + const cache = projectedTrackedChangeCache.get(editor); + if (!cache) return null; + if (cache.revision !== getRevision(editor)) return null; + return cache.byProjectedId.get(projectedId) ?? null; +} + +function filterProjectedByType( + rows: ReadonlyArray, requestedType?: TrackChangeType, -): TrackedChangeSnapshot[] { - if (!requestedType) return [...snapshots]; - return snapshots.filter((snapshot) => snapshot.type === requestedType); +): ProjectedTrackChange[] { + if (!requestedType) return [...rows]; + return rows.filter((row) => row.info.type === requestedType); } function toNoOpReceipt(message: string, details?: unknown): Receipt { @@ -139,19 +274,23 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList rawSnapshots = index.get(scope.story); } - const filtered = filterByType(rawSnapshots, input?.type); + const projected = projectSnapshots(rawSnapshots); + const evaluatedRevision = getRevision(editor); + cacheProjectedTrackedChanges(editor, projected, evaluatedRevision); + const filtered = filterProjectedByType(projected, input?.type); const paged = paginate(filtered, input?.offset, input?.limit); // Track-changes discovery uses a document-level revision token across every // scope. Part commits also advance the host revision, so one shared token // correctly guards body, story-scoped, and aggregate review flows. - const evaluatedRevision = getRevision(editor); - const items = paged.items.map((snapshot) => { - const info = snapshotToInfo(snapshot); - const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); + const items = paged.items.map((row) => { + const info = row.info; + const handle = buildResolvedHandle(row.handleKey, 'stable', 'trackedChange'); const { address, type, + grouping, + pairedWithChangeId, wordRevisionIds, overlap, author, @@ -165,6 +304,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList return buildDiscoveryItem(info.id, handle, { address, type, + grouping, + pairedWithChangeId, wordRevisionIds, overlap, author, @@ -202,7 +343,13 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp const anchorKey = makeTrackedChangeAnchorKey(resolved.runtimeRef); const snapshots = storyKey === BODY_STORY_KEY ? index.get({ kind: 'story', storyType: 'body' }) : index.get(resolved.story); - const snapshot = snapshots.find((item) => item.anchorKey === anchorKey); + const projected = projectSnapshots(snapshots); + const projectedMatch = projected.find((row) => row.info.id === id); + + if (projectedMatch) return projectedMatch.info; + + const { baseId } = splitProjectedTrackedChangeId(id); + const snapshot = snapshots.find((item) => item.anchorKey === anchorKey || item.address.entityId === baseId); if (snapshot) return snapshotToInfo(snapshot); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index b9099ce24a..5bd2bebb72 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -285,6 +285,12 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { storyLabel, storyKind, anchorKey: makeTrackedChangeAnchorKey(runtimeRef), + commandRawId: change.commandRawId, + replacementGroupId: toNonEmptyString(change.attrs.replacementGroupId), + replacementSideId: toNonEmptyString(change.attrs.replacementSideId), + hasInsert: change.hasInsert, + hasDelete: change.hasDelete, + hasFormat: change.hasFormat, range: { from: change.from, to: change.to }, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts index 0e7cbcf672..fb307f6d8a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -42,6 +42,15 @@ export interface TrackedChangeSnapshot { storyKind: 'body' | 'headerFooter' | 'footnote' | 'endnote'; /** Canonical shared-map anchor key (`tc::::`). */ anchorKey: string; + /** Internal raw command id when distinct from the story-level raw id. */ + commandRawId?: string; + /** Replacement metadata used by public projection helpers. */ + replacementGroupId?: string; + replacementSideId?: string; + /** Raw grouped-change shape retained for projection logic. */ + hasInsert: boolean; + hasDelete: boolean; + hasFormat: boolean; /** Absolute PM position range within the story editor. */ range: { from: number; to: number }; } diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js index 4f7f1ad09d..0d5eb6d178 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js @@ -34,6 +34,7 @@ * @property {CommentNodeDelete[]} nodeDeletes PM ranges to remove. * @property {Array<{ id: string, cause: string }>} entityDeletes Comment thread ids to remove. * @property {Array<{ id: string, cause: string, anchor?: { from: number, to: number } }>} entityShrinks Comments whose anchor shrinks. + * @property {Array<{ id: string, cause: string }>} entityDetaches Comments whose anchor survives but should detach from tracked-change threading. * @property {Array<{ code: string, message: string }>} diagnostics */ @@ -96,16 +97,19 @@ export const enumerateCommentAnchors = (doc) => { * @param {Object} input * @param {import('prosemirror-model').Node} input.doc Document before mutation. * @param {Array<{ from: number, to: number, cause: string }>} input.removedRanges Coverage to be removed. + * @param {Array<{ from: number, to: number, cause: string }>} [input.resolvedRanges] Full tracked-change coverage being retired while content may survive. * @returns {CommentEffectsPlan} */ -export const planCommentEffects = ({ doc, removedRanges }) => { +export const planCommentEffects = ({ doc, removedRanges, resolvedRanges = [] }) => { /** @type {CommentEffectsPlan} */ - const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], diagnostics: [] }; - if (!doc || !removedRanges?.length) return plan; + const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], entityDetaches: [], diagnostics: [] }; + if (!doc) return plan; const anchors = enumerateCommentAnchors(doc); if (!anchors.length) return plan; const sortedRemoved = [...removedRanges].filter((r) => r.from < r.to).sort((a, b) => a.from - b.from); + const sortedResolved = [...resolvedRanges].filter((r) => r.from < r.to).sort((a, b) => a.from - b.from); + if (!sortedRemoved.length && !sortedResolved.length) return plan; for (const anchor of anchors) { const start = anchor.startPos; @@ -114,7 +118,9 @@ export const planCommentEffects = ({ doc, removedRanges }) => { if (start < 0 && end < 0 && ref < 0) continue; const anchorStart = start >= 0 ? start : ref >= 0 ? ref : end; const anchorEnd = end >= 0 ? end + 1 : ref >= 0 ? ref + 1 : start + 1; - const cause = sortedRemoved[0]?.cause ?? 'trackedChange'; + const removedCause = sortedRemoved[0]?.cause ?? 'trackedChange'; + const resolvedMatch = sortedResolved.find((r) => r.from < anchorEnd && r.to > anchorStart); + const resolvedCause = resolvedMatch?.cause ?? removedCause; // Determine whether any removed range covers the anchor end or reference. const removesEnd = sortedRemoved.some((r) => end >= 0 && r.from <= end && r.to > end); @@ -122,7 +128,7 @@ export const planCommentEffects = ({ doc, removedRanges }) => { const fullyCovered = sortedRemoved.some((r) => r.from <= anchorStart && r.to >= anchorEnd); if (fullyCovered || removesEnd || removesRef) { - plan.entityDeletes.push({ id: anchor.commentId, cause }); + plan.entityDeletes.push({ id: anchor.commentId, cause: removedCause }); if (start >= 0) plan.nodeDeletes.push({ kind: 'commentRangeStart', from: start, to: start + 1, commentId: anchor.commentId }); if (end >= 0) @@ -137,11 +143,19 @@ export const planCommentEffects = ({ doc, removedRanges }) => { if (overlaps) { plan.entityShrinks.push({ id: anchor.commentId, - cause, + cause: removedCause, anchor: { from: anchorStart, to: anchorEnd }, }); // We do not remove the commentRangeStart/End nodes themselves when only // shrinking; PM will reposition them when surrounding text is removed. + if (resolvedMatch) { + plan.entityDetaches.push({ id: anchor.commentId, cause: resolvedCause }); + } + continue; + } + + if (resolvedMatch) { + plan.entityDetaches.push({ id: anchor.commentId, cause: resolvedCause }); } } diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index 6cd64f2836..19d81f0346 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -63,6 +63,7 @@ import { planCommentEffects } from './comment-effects.js'; * @property {string[]} updatedChangeIds changes whose surviving coverage changed. * @property {Array<{ id: string, cause?: string }>} removedChangeIds retired logical change ids. * @property {Array<{ id: string, cause: string }>} deletedComments comment threads removed as side effects. + * @property {Array<{ id: string, cause: string }>} detachedComments comment threads whose anchors survive but should detach from tracked-change threading. * @property {Array<{ id: string, cause: string }>} shrunkenComments comment threads that shrank. * @property {Array<{ changeId: string }>} affectedChildren child ids that retired with their parent. */ @@ -399,6 +400,8 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) const ops = []; /** @type {Array<{ from: number, to: number, cause: string }>} */ const removedRanges = []; + /** @type {Array<{ from: number, to: number, cause: string }>} */ + const resolvedRanges = []; /** @type {Set} */ const touched = new Set(); /** @type {Set} */ @@ -432,6 +435,15 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) } } touched.add(change.id); + if (isFull) { + for (const segment of change.segments) { + resolvedRanges.push({ + from: segment.from, + to: segment.to, + cause: `${decision}:${change.id}`, + }); + } + } if (!isFull && (change.type === CanonicalChangeType.Insertion || change.type === CanonicalChangeType.Deletion)) { const partialResult = planPartialTextDecision({ @@ -493,7 +505,7 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) } } - const commentEffects = planCommentEffects({ doc: state.doc, removedRanges }); + const commentEffects = planCommentEffects({ doc: state.doc, removedRanges, resolvedRanges }); // Convert comment node deletions into removeContent ops so apply respects // the same reverse-order pass. Removing the anchor nodes from inside @@ -879,6 +891,7 @@ const buildReceipt = ({ plan }) => { updatedChangeIds: [], removedChangeIds: Array.from(plan.retiredChangeIds).map((id) => ({ id, cause: 'decision' })), deletedComments: plan.commentEffects.entityDeletes, + detachedComments: plan.commentEffects.entityDetaches, shrunkenComments: plan.commentEffects.entityShrinks.map(({ id, cause }) => ({ id, cause })), affectedChildren: plan.commentEffects._affectedChildren ?? [], }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 17743e0ed1..34ec5ee8c5 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -58,6 +58,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = dispatch(result.tr); if (editor?.emit) { + const documentId = editor.options?.documentId; // Partial decisions retire the original id and mint successor fragments // (splitFromId === originalId). For each retired id, decide whether to // emit `resolve` (no successors remain) or `update` (successors keep @@ -90,7 +91,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = if (!remaining.length) continue; const payload = buildPartialUpdatePayload({ state: nextState, - documentId: editor.options?.documentId, + documentId, originalId: changeId, remaining, }); @@ -99,6 +100,18 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = emittedFor.add(changeId); } } + + const deletedCommentIds = new Set((result.receipt?.deletedComments ?? []).map(({ id }) => id).filter(Boolean)); + const detachedCommentIds = new Set( + (result.receipt?.detachedComments ?? []).map(({ id }) => id).filter((id) => id && !deletedCommentIds.has(id)), + ); + + for (const commentId of deletedCommentIds) { + editor.emit('commentsUpdate', buildDeletedCommentPayload({ commentId, documentId })); + } + for (const commentId of detachedCommentIds) { + editor.emit('commentsUpdate', buildDetachedCommentUpdatePayload({ commentId, documentId })); + } } } return { applied: true, result }; @@ -175,6 +188,34 @@ const buildPartialUpdatePayload = ({ state, documentId, originalId, remaining }) }; }; +const buildDetachedCommentUpdatePayload = ({ commentId, documentId }) => ({ + type: 'update', + comment: { + commentId, + documentId, + fileId: documentId ?? null, + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeDisplayType: null, + trackedChangeText: null, + trackedChangeStory: null, + trackedChangeStoryKind: null, + trackedChangeStoryLabel: null, + trackedChangeAnchorKey: null, + deletedText: null, + }, +}); + +const buildDeletedCommentPayload = ({ commentId, documentId }) => ({ + type: 'deleted', + comment: { + commentId, + documentId, + fileId: documentId ?? null, + }, +}); + export const TrackChanges = Extension.create({ name: 'trackChanges', diff --git a/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js b/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js index b3c09b0ffe..b69b1b7893 100644 --- a/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js +++ b/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js @@ -30,6 +30,15 @@ const FAKE_FROM_CALLER = [ }, ]; +const FAKE_FROM_TEXT_ONLY = [ + { + commentId: 'c-text-1', + creatorEmail: 'text@example.com', + creatorName: 'Text Only', + commentText: 'Hi from text only', + }, +]; + /** * Pins the engine-level contract that `SuperDoc.exportEditorsToDOCX` * relies on: @@ -110,6 +119,34 @@ describe('Editor.exportDocx() comments fallback contract', () => { } }); + it('caller passes commentText without commentJSON/elements → engine synthesizes exportable comment JSON', async () => { + const editor = await buildEditorWithImports(); + try { + const spy = vi.spyOn(editor.converter, 'exportToDocx').mockResolvedValue(Buffer.from('')); + await editor.exportDocx({ exportXmlOnly: true, comments: FAKE_FROM_TEXT_ONLY }); + expect(spy).toHaveBeenCalledTimes(1); + const passedComments = spy.mock.calls[0][5]; + expect(Array.isArray(passedComments)).toBe(true); + expect(passedComments[0]).toMatchObject({ + commentId: 'c-text-1', + commentText: 'Hi from text only', + commentJSON: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hi from text only' }], + }, + ], + }, + ], + }); + } finally { + editor.destroy(); + } + }); + it('null/undefined `converter.comments` resolves to [] (not a throw)', async () => { const editor = await Editor.open(undefined, { json: SAMPLE_JSON }); try { diff --git a/packages/super-editor/src/editors/v1/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts new file mode 100644 index 0000000000..73ed8b71e3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -0,0 +1,35 @@ +/** + * Strips HTML tags from a comment text string using simple regex replacement. + * + * This is only intended for normalizing comment content that was already authored + * within the editor. It is NOT a security sanitizer and must not be used to + * neutralize untrusted or user-supplied HTML. + */ +export function stripHtmlToText(value: string): string { + return value + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function buildCommentJsonFromText(text: string): unknown[] { + const normalized = stripHtmlToText(text); + + return [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: normalized, + }, + ], + }, + ], + }, + ]; +} diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 77f3cb526d..67e6ef93fe 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -864,6 +864,10 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ 'trackedChangeType', 'trackedChangeText', 'trackedChangeDisplayType', + 'trackedChangeStory', + 'trackedChangeStoryKind', + 'trackedChangeStoryLabel', + 'trackedChangeAnchorKey', 'deletedText', 'resolvedTime', 'resolvedById', diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 29e600b968..8b46743a7f 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -782,42 +782,10 @@ export const useCommentsStore = defineStore('comments', () => { existingTrackedChange.resolveComment(resolveArgs); } - // AIDEV-NOTE: SD-2528. User-attached comments on a tracked change carry - // trackedChangeParentId === . When the TC is accepted - // or rejected, those comment bubbles must also resolve — otherwise the - // comment lingers after the redline it referred to is gone. Defer to a - // microtask so the cascading resolveComment doesn't dispatch into a - // still-running acceptTrackedChangeById/rejectTrackedChangeById loop and - // collide with its mutable `tr`. - // - // AIDEV-NOTE: SD-2528 P2 #1. Mirror `findTrackedChangeById`'s - // documentId scope (see line 591-596). In multi-document sessions - // tracked-change ids can collide across documents (each imported file - // has its own w:id space); without this filter, accepting a change in - // document A would cascade-resolve comments anchored on document B - // that happen to share the same id. Single-document callers (no - // documentId on the event) keep the legacy global behaviour. - if (normalizedChangeId) { - const linkedToResolve = commentsList.value.filter((linkedComment) => { - if (!linkedComment || linkedComment === existingTrackedChange) return false; - if (linkedComment.resolvedTime) return false; - const linkedParentId = - linkedComment.trackedChangeParentId != null ? String(linkedComment.trackedChangeParentId) : null; - if (linkedParentId !== normalizedChangeId) return false; - if (normalizedDocumentId) { - return belongsToTrackedChangeSyncDocument(linkedComment, normalizedDocumentId); - } - return true; - }); - if (linkedToResolve.length) { - Promise.resolve().then(() => { - linkedToResolve.forEach((linkedComment) => { - if (linkedComment.resolvedTime) return; - linkedComment.resolveComment(resolveArgs); - }); - }); - } - } + // User comments linked to tracked content are no longer blanket-cascaded + // here. The decision engine emits explicit standard comment update/delete + // events for each affected thread so accepted insertions can keep their + // comments while rejected/removed coverage still deletes the right ones. } }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 6f7fa9a538..de7bc80cee 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -682,7 +682,7 @@ describe('comments-store', () => { ); }); - it('cascades resolve to user comments anchored to the same tracked change (SD-2528)', async () => { + it('does not blanket-cascade linked user comments on tracked-change resolve events', async () => { const superdoc = { emit: vi.fn(), user: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, @@ -726,25 +726,12 @@ describe('comments-store', () => { }); expect(trackedChangeComment.resolveComment).toHaveBeenCalledTimes(1); - // Cascading runs in a microtask so we wait one turn before asserting. await Promise.resolve(); - expect(linkedUserComment.resolveComment).toHaveBeenCalledTimes(1); - expect(linkedUserComment.resolveComment).toHaveBeenCalledWith({ - id: 'reviewer-id', - email: 'reviewer@example.com', - name: 'Reviewer', - superdoc, - }); + expect(linkedUserComment.resolveComment).not.toHaveBeenCalled(); expect(unrelatedUserComment.resolveComment).not.toHaveBeenCalled(); }); - // SD-2528 P2 #1 — when the resolve event carries an explicit `documentId`, - // the cascade must filter linked comments by that document. `findTrackedChangeById` - // does this for the primary comment; the cascade scan one level down was - // missing the same guard. In multi-document sessions where imported TC ids - // happen to collide, accepting/rejecting a change in one document must not - // resolve comments anchored on a different document. - it('scopes cascade resolve to the active document when documentId is provided', async () => { + it('does not cascade linked comments even when tracked-change resolve carries a documentId', async () => { const superdoc = { emit: vi.fn(), user: { email: 'reviewer@example.com', name: 'Reviewer' } }; __mockSuperdoc.documents.value = [ { id: 'doc-A', type: 'docx' }, @@ -789,13 +776,11 @@ describe('comments-store', () => { }); await Promise.resolve(); - expect(linkedOnDocA.resolveComment).toHaveBeenCalledTimes(1); + expect(linkedOnDocA.resolveComment).not.toHaveBeenCalled(); expect(linkedOnDocB.resolveComment).not.toHaveBeenCalled(); }); - // Regression: when no documentId is passed, single-document behaviour is - // unchanged. Mirrors the legacy `cascades resolve` test contract. - it('cascades to every doc-anchored linked comment when no documentId is provided (single-doc)', async () => { + it('does not cascade linked comments in single-document resolve flows either', async () => { const superdoc = { emit: vi.fn(), user: { email: 'r@e', name: 'R' } }; const trackedChangeComment = { @@ -824,7 +809,7 @@ describe('comments-store', () => { }); await Promise.resolve(); - expect(linkedNoFileId.resolveComment).toHaveBeenCalledTimes(1); + expect(linkedNoFileId.resolveComment).not.toHaveBeenCalled(); }); it('does not re-resolve already-resolved linked user comments', async () => { From 7aba49f199a1033b3447ea1a47bb12ec89107aba Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 22:48:23 -0700 Subject: [PATCH 020/280] chore: ci fixes --- .../document-api/available-operations.mdx | 3 +- .../reference/_generated-manifest.json | 4 +- .../reference/capabilities/get.mdx | 46 +++++++++++++++++++ .../document-api/reference/format/index.mdx | 1 + apps/docs/document-api/reference/index.mdx | 3 +- .../src/contract/operation-definitions.ts | 17 +++++++ .../src/contract/operation-registry.ts | 3 +- packages/document-api/src/contract/schemas.ts | 28 +++++++++++ packages/document-api/src/invoke/invoke.ts | 1 + .../plan-engine/comments-wrappers.ts | 10 ++-- 10 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 61e9ce9491..fc2370f907 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -28,7 +28,7 @@ Use the tables below to see what operations are available and where each one is | Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) | | Fields | 5 | 0 | 5 | [Reference](/document-api/reference/fields/index) | | Footnotes | 6 | 0 | 6 | [Reference](/document-api/reference/footnotes/index) | -| Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | +| Format | 45 | 1 | 46 | [Reference](/document-api/reference/format/index) | | Headers & Footers | 9 | 0 | 9 | [Reference](/document-api/reference/header-footers/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | @@ -192,6 +192,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.footnotes.update(...) | [`footnotes.update`](/document-api/reference/footnotes/update) | | editor.doc.footnotes.remove(...) | [`footnotes.remove`](/document-api/reference/footnotes/remove) | | editor.doc.footnotes.configure(...) | [`footnotes.configure`](/document-api/reference/footnotes/configure) | +| editor.doc.formatRange(...) | [`formatRange`](/document-api/reference/format/apply) | | editor.doc.format.apply(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.bold(...) | [`format.bold`](/document-api/reference/format/bold) | | editor.doc.format.italic(...) | [`format.italic`](/document-api/reference/format/italic) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 76fd594f32..ec0b18a6bd 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -152,6 +152,7 @@ "apps/docs/document-api/reference/footnotes/remove.mdx", "apps/docs/document-api/reference/footnotes/update.mdx", "apps/docs/document-api/reference/format/apply.mdx", + "apps/docs/document-api/reference/format/apply.mdx", "apps/docs/document-api/reference/format/b-cs.mdx", "apps/docs/document-api/reference/format/bold.mdx", "apps/docs/document-api/reference/format/border.mdx", @@ -524,6 +525,7 @@ "aliasMemberPaths": ["format.strikethrough"], "key": "format", "operationIds": [ + "formatRange", "format.apply", "format.bold", "format.italic", @@ -1077,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "b2a628b73f6cb983a78e8068179f0c18cc99024112b143c1a832b95b4c2ad914" + "sourceHash": "cecf48b9ef043255c73faeed6b35f236bca0cd969d19779a21952f9c15037705" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 9746e87c3a..28b5000970 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1260,6 +1260,11 @@ _No fields._ | `operations.format.webHidden.dryRun` | boolean | yes | | | `operations.format.webHidden.reasons` | enum[] | no | | | `operations.format.webHidden.tracked` | boolean | yes | | +| `operations.formatRange` | object | yes | | +| `operations.formatRange.available` | boolean | yes | | +| `operations.formatRange.dryRun` | boolean | yes | | +| `operations.formatRange.reasons` | enum[] | no | | +| `operations.formatRange.tracked` | boolean | yes | | | `operations.get` | object | yes | | | `operations.get.available` | boolean | yes | | | `operations.get.dryRun` | boolean | yes | | @@ -3566,6 +3571,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "formatRange": { + "available": true, + "dryRun": true, + "tracked": true + }, "get": { "available": true, "dryRun": false, @@ -13194,6 +13204,41 @@ _No fields._ ], "type": "object" }, + "formatRange": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "get": { "additionalProperties": false, "properties": { @@ -20385,6 +20430,7 @@ _No fields._ "insert", "replace", "delete", + "formatRange", "blocks.list", "blocks.delete", "blocks.deleteRange", diff --git a/apps/docs/document-api/reference/format/index.mdx b/apps/docs/document-api/reference/format/index.mdx index df4abd5fde..feefdc85a8 100644 --- a/apps/docs/document-api/reference/format/index.mdx +++ b/apps/docs/document-api/reference/format/index.mdx @@ -12,6 +12,7 @@ Canonical formatting mutation with directive semantics ('on', 'off', 'clear'). | Operation | Member path | Mutates | Idempotency | Tracked | Dry run | | --- | --- | --- | --- | --- | --- | +| formatRange | `formatRange` | Yes | `conditional` | Yes | Yes | | format.apply | `format.apply` | Yes | `conditional` | Yes | Yes | | format.bold | `format.bold` | Yes | `conditional` | Yes | Yes | | format.italic | `format.italic` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 7cbeee530f..f3a0afcbee 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -24,7 +24,7 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | -| Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | +| Format | 45 | 1 | 46 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | | Lists | 39 | 0 | 39 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | @@ -131,6 +131,7 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | +| formatRange | editor.doc.formatRange(...) | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | format.apply | editor.doc.format.apply(...) | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | format.bold | editor.doc.format.bold(...) | Set or clear the `bold` inline run property on the target text range. | | format.italic | editor.doc.format.italic(...) | Set or clear the `italic` inline run property on the target text range. | diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 37cec1a83b..ce232460cb 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -954,6 +954,23 @@ export const OPERATION_DEFINITIONS = { intentGroup: 'edit', intentAction: 'delete', }, + formatRange: { + memberPath: 'formatRange', + description: + 'Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers.', + expectedResult: 'Returns a TextMutationReceipt confirming inline styles were applied to the target range.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT', ...T_STORY], + }), + referenceDocPath: 'format/apply.mdx', + referenceGroup: 'format', + skipAsATool: true, + }, 'blocks.list': { memberPath: 'blocks.list', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 64b10bda58..0f78543249 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -42,7 +42,7 @@ import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; import type { DeleteInput } from '../delete/delete.js'; import type { MutationOptions, RevisionGuardOptions } from '../write/write.js'; -import type { FormatInlineAliasInput, StyleApplyInput } from '../format/format.js'; +import type { FormatInlineAliasInput, FormatRangeInput, StyleApplyInput } from '../format/format.js'; import type { InlineRunPatchKey } from '../format/inline-run-patch.js'; import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/index.js'; import type { @@ -572,6 +572,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { insert: { input: InsertInput; options: MutationOptions; output: SDMutationReceipt }; replace: { input: ReplaceInput; options: MutationOptions; output: SDMutationReceipt }; delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt }; + formatRange: { input: FormatRangeInput; options: MutationOptions; output: TextMutationReceipt }; // --- blocks.* --- 'blocks.list': { input: BlocksListInput | undefined; options: never; output: BlocksListResult }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index bb16029e7b..25b78fbc2f 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3272,6 +3272,34 @@ const operationSchemas: Record = { success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('delete'), }, + formatRange: { + input: { + ...targetLocatorWithPayload( + { + in: storyLocatorSchema, + properties: { + ...buildInlineRunPatchSchema(), + description: + 'Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold.', + }, + changeMode: { + enum: ['direct', 'tracked'], + description: "Edit mode: 'direct' applies changes immediately, 'tracked' records tracked formatting.", + }, + dryRun: { type: 'boolean', description: 'Preview the result without mutating the document.' }, + expectedRevision: { + type: 'string', + description: + 'Document revision for optimistic concurrency. Mutation fails if document was modified since this revision.', + }, + }, + ['properties'], + ), + }, + output: textMutationResultSchemaFor('formatRange'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('formatRange'), + }, 'format.apply': { input: { ...targetLocatorWithPayload( diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index c3390ebb9c..403ed5703e 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -74,6 +74,7 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { insert: (input, options) => api.insert(input, options), replace: (input, options) => api.replace(input, options), delete: (input, options) => api.delete(input, options), + formatRange: (input, options) => api.formatRange(input, options), // --- blocks.* --- 'blocks.list': (input) => api.blocks.list(input), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index d96ec2f329..08539ad15d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -146,12 +146,10 @@ function isTextTargetShape(target: unknown): target is TextTarget { } /** - * Normalize a TextAddress | TextTarget comment target into an array of + * Normalize the text-addressable comment target shapes into an array of * segments. For TextAddress, the result is a single-entry array. */ -function targetToSegments( - target: { kind: 'text'; blockId: string; range: { start: number; end: number } } | TextTarget, -): TextSegment[] | null { +function targetToSegments(target: CommentTarget): TextSegment[] | null { if (isTextAddressShape(target)) return [{ blockId: target.blockId, range: target.range }]; if (isTextTargetShape(target)) return [...target.segments]; return null; @@ -1096,7 +1094,7 @@ function addCommentHandler( } throw error; } - if (!resolvedTarget.ok) { + if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } @@ -1289,7 +1287,7 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.patch (moveComment)'); const resolvedTarget = resolveCommentTarget(editor, input.target); - if (!resolvedTarget.ok) { + if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } From 828100689eddd70c5a13f38112b449b17df38d07 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 08:51:40 -0700 Subject: [PATCH 021/280] chore: more fixes --- apps/cli/src/cli/operation-hints.ts | 5 ++ apps/docs/document-engine/sdks.mdx | 2 + .../w/p/helpers/translate-paragraph-node.js | 55 +++++++++++++++++-- .../helpers/translate-paragraph-node.test.js | 45 ++++++++++++++- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 59c07fbb73..fff7c99b31 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -88,6 +88,7 @@ export const SUCCESS_VERB: Record = { 'blocks.list': 'listed blocks', 'blocks.delete': 'deleted block', 'blocks.deleteRange': 'deleted block range', + formatRange: 'applied style', 'format.apply': 'applied style', ...buildFormatInlineAliasRecord('applied style'), ...buildParagraphRecord('updated paragraph formatting'), @@ -473,6 +474,7 @@ export const OUTPUT_FORMAT: Record = { 'blocks.list': 'plain', 'blocks.delete': 'plain', 'blocks.deleteRange': 'plain', + formatRange: 'mutationReceipt', 'format.apply': 'mutationReceipt', ...buildFormatInlineAliasRecord('mutationReceipt'), ...buildParagraphRecord('plain'), @@ -840,6 +842,7 @@ export const RESPONSE_ENVELOPE_KEY: Record 'blocks.list': 'result', 'blocks.delete': 'result', 'blocks.deleteRange': 'result', + formatRange: null, 'format.apply': null, ...buildFormatInlineAliasRecord(null), ...buildParagraphRecord('result'), @@ -1194,6 +1197,7 @@ export const RESPONSE_VALIDATION_KEY: Partial = 'blocks.list': 'blocks', 'blocks.delete': 'blocks', 'blocks.deleteRange': 'blocks', + formatRange: 'textMutation', 'format.apply': 'textMutation', ...buildFormatInlineAliasRecord('textMutation'), ...buildParagraphRecord('textMutation'), diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index f94c069e0e..1d2513fd33 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -746,6 +746,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.formatRange` | `format-range` | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | `doc.format.apply` | `format apply` | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | `doc.format.bold` | `format bold` | Set or clear the `bold` inline run property on the target text range. | | `doc.format.italic` | `format italic` | Set or clear the `italic` inline run property on the target text range. | @@ -1224,6 +1225,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.format_range` | `format-range` | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | `doc.format.apply` | `format apply` | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | `doc.format.bold` | `format bold` | Set or clear the `bold` inline run property on the target text range. | | `doc.format.italic` | `format italic` | Set or clear the `italic` inline run property on the target text range. | diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index ba6a766082..ce5faec085 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -3,11 +3,45 @@ import { generateParagraphProperties } from './generate-paragraph-properties.js' const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del'; +const getCommentReferenceNode = (el) => { + if (el?.name !== 'w:r' || !Array.isArray(el.elements)) return null; + return el.elements.find((child) => child?.name === 'w:commentReference') ?? null; +}; + const isCommentMarker = (el) => { if (!el) return false; if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true; - if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true; - return false; + return getCommentReferenceNode(el) != null; +}; + +const getCommentMarkerId = (el) => { + if (!el) return null; + if ((el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') && el.attributes?.['w:id'] != null) { + return String(el.attributes['w:id']); + } + const referenceNode = getCommentReferenceNode(el); + if (referenceNode?.attributes?.['w:id'] != null) { + return String(referenceNode.attributes['w:id']); + } + return null; +}; + +const collectLeadingCommentStartIds = (elements = []) => { + const startedIds = new Set(); + for (const element of elements) { + if (element?.name !== 'w:commentRangeStart') break; + const id = getCommentMarkerId(element); + if (id) startedIds.add(id); + } + return startedIds; +}; + +const shouldAbsorbTrailingMarkersIntoWrapper = (pendingComments = [], startedInsideWrapper = new Set()) => { + if (!pendingComments.length || !startedInsideWrapper.size) return false; + return pendingComments.every((marker) => { + const markerId = getCommentMarkerId(marker); + return markerId != null && startedInsideWrapper.has(markerId); + }); }; // AIDEV-NOTE: SD-2528. The importer associates a comment with a tracked change @@ -45,8 +79,8 @@ function foldLeadingCommentStartsIntoTrackedChanges(elements) { * Merge consecutive tracked change elements (w:ins/w:del) with the same ID, * and fold any commentRangeStart that immediately precedes a tracked-change * wrapper INTO the wrapper as its first child(ren). Trailing commentRangeEnd - * and w:r→w:commentReference stay as siblings and are only absorbed when a - * same-id successor wrapper triggers an SD-1519 merge. + * / commentReference markers are absorbed too when they close comments that + * started inside that wrapper, preserving Word's replacement adjacency. * * @param {Array} elements The translated paragraph elements * @returns {Array} Elements with consecutive tracked changes merged @@ -91,11 +125,22 @@ function mergeConsecutiveTrackedChanges(elements) { break; } + const startedInsideWrapper = collectLeadingCommentStartIds(mergedElements); + const absorbTrailingMarkers = shouldAbsorbTrailingMarkersIntoWrapper(pendingComments, startedInsideWrapper); + if (absorbTrailingMarkers) { + mergedElements.push(...pendingComments); + pendingComments.length = 0; + } + if (didMerge) { result.push({ name: tcName, attributes: { ...current.attributes }, elements: mergedElements }); result.push(...pendingComments); } else { - result.push(current); + result.push( + absorbTrailingMarkers + ? { name: tcName, attributes: { ...current.attributes }, elements: mergedElements } + : current, + ); result.push(...pendingComments); } i = j; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index 7121fb383c..e62a351aae 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js'; +import { buildTrackedChangeIdMap } from '@converter/v2/importer/trackedChangeIdMapper.js'; import { generateParagraphProperties } from './generate-paragraph-properties.js'; vi.mock('@converter/v2/exporter/helpers/index.js', () => ({ @@ -98,8 +99,13 @@ describe('translateParagraphNode', () => { attributes: { 'w:id': id, 'w:author': 'a', 'w:date': 'd' }, elements: [{ name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: innerText }] }] }], }); + const buildInsWrapper = (id, innerText) => ({ + name: 'w:ins', + attributes: { 'w:id': id, 'w:author': 'a', 'w:date': 'd' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: innerText }] }] }], + }); - it('folds a leading commentRangeStart into the following tracked-change wrapper (SD-2528)', () => { + it('folds a leading commentRangeStart into the following tracked-change wrapper and keeps the closing markers there', () => { const params = baseParams(); generateParagraphProperties.mockReturnValue(null); translateChildNodes.mockReturnValue([ @@ -114,10 +120,10 @@ describe('translateParagraphNode', () => { const result = translateParagraphNode(params); const names = result.elements.map((e) => e.name); - expect(names).toEqual(['w:r', 'w:del', 'w:commentRangeEnd', 'w:r', 'w:r']); + expect(names).toEqual(['w:r', 'w:del', 'w:r']); const delNode = result.elements.find((e) => e.name === 'w:del'); - expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r']); + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r', 'w:commentRangeEnd', 'w:r']); expect(delNode.elements[0].attributes['w:id']).toBe('0'); }); @@ -183,5 +189,38 @@ describe('translateParagraphNode', () => { expect(result.elements[0].elements).toHaveLength(1); expect(result.elements[2].elements).toHaveLength(1); }); + + it('absorbs trailing deleted-side comment markers so replacement siblings remain pairable', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + commentRangeStart, + buildDelWrapper('1', 'old'), + commentRangeEnd, + commentReferenceRun, + buildInsWrapper('2', 'new'), + ]); + + const result = translateParagraphNode(params); + const names = result.elements.map((e) => e.name); + + expect(names).toEqual(['w:del', 'w:ins']); + + const delNode = result.elements[0]; + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r', 'w:commentRangeEnd', 'w:r']); + + const idMap = buildTrackedChangeIdMap({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [result] }], + }, + ], + }, + }); + + expect(idMap.get('1')).toBe(idMap.get('2')); + }); }); }); From 08a49c6f4b135cf7b41b81578c008b205b72e331 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 10:25:59 -0700 Subject: [PATCH 022/280] chore: more fixes --- .../src/__tests__/conformance/scenarios.ts | 37 ++++++- .../src/__tests__/lib/error-mapping.test.ts | 33 ++++++ apps/cli/src/lib/error-mapping.ts | 44 +++++++- apps/cli/src/lib/generic-dispatch.ts | 1 + apps/cli/src/lib/mutation-orchestrator.ts | 11 +- apps/cli/src/lib/operation-executor.ts | 1 + apps/cli/src/lib/read-orchestrator.ts | 9 +- .../track-changes-wrappers.test.ts | 103 +++++++++++++++++- .../plan-engine/track-changes-wrappers.ts | 31 ++---- 9 files changed, 227 insertions(+), 43 deletions(-) diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index d49ba0bff6..5cf9a1a8de 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -36,7 +36,7 @@ function skippedSuccessScenario(operationId: CliOperationId) { }); } -type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise; +type ScenarioFactory = (harness: ConformanceHarness) => Promise; function deferredRuntimeScenario( operationId: CliOperationId, @@ -3334,7 +3334,7 @@ export const SUCCESS_SCENARIOS = { await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session'); return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] }; }, -} as const satisfies Partial>; +} as const satisfies Partial>; const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set([ 'doc.toc.markEntry', @@ -3358,8 +3358,9 @@ const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set([ ]); const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[]; +const SUCCESS_SCENARIOS_BY_OPERATION: Partial> = SUCCESS_SCENARIOS; const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter( - (operationId) => SUCCESS_SCENARIOS[operationId] == null, + (operationId) => SUCCESS_SCENARIOS_BY_OPERATION[operationId] == null, ); const RUNTIME_CONFORMANCE_SKIP = new Set([ @@ -3367,13 +3368,37 @@ const RUNTIME_CONFORMANCE_SKIP = new Set([ ...AUTO_SKIPPED_OPERATION_IDS, ]); +const FAILURE_SCENARIOS: Partial> = { + 'doc.trackChanges.decide': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-trackChanges-decide-missing-id'); + const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-trackChanges-decide-missing-id'); + return { + stateDir, + args: [ + ...commandTokens('doc.trackChanges.decide'), + fixture.docPath, + '--decision', + 'accept', + '--target-json', + JSON.stringify({ id: 'missing-track-change-id' }), + '--out', + harness.createOutputPath('doc-trackChanges-decide-missing-id-output'), + ], + }; + }, +}; + +const EXPECTED_FAILURE_CODES: Partial> = { + 'doc.trackChanges.decide': ['TARGET_NOT_FOUND'], +}; + export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => { - const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId); + const success = SUCCESS_SCENARIOS_BY_OPERATION[operationId] ?? skippedSuccessScenario(operationId); const scenario: OperationScenario = { operationId, success, - failure: genericInvalidArgumentFailure(operationId), - expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'], + failure: FAILURE_SCENARIOS[operationId] ?? genericInvalidArgumentFailure(operationId), + expectedFailureCodes: EXPECTED_FAILURE_CODES[operationId] ?? ['INVALID_ARGUMENT', 'MISSING_REQUIRED'], ...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}), }; return scenario; diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index 52f5384b25..88b3303fd9 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -25,6 +25,21 @@ describe('mapInvokeError', () => { expect(mapped.code).toBe('TARGET_NOT_FOUND'); expect(mapped.details).toEqual({ operationId: 'trackChanges.decide', details: { id: 'tc-1' } }); }); + + test('keeps track-changes accept/reject helper missing ids backward compatible', () => { + const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), { + code: 'TARGET_NOT_FOUND', + details: { id: 'tc-1' }, + }); + + const accept = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes accept' }); + const reject = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes reject' }); + const canonical = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes decide' }); + + expect(accept.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(reject.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(canonical.code).toBe('TARGET_NOT_FOUND'); + }); }); // --------------------------------------------------------------------------- @@ -216,6 +231,24 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => { expect(result!.code).toBe('COMMAND_FAILED'); }); + test('maps helper trackChanges.decide TARGET_NOT_FOUND receipts to TRACK_CHANGE_NOT_FOUND', () => { + const receipt = { + success: false, + failure: { + code: 'TARGET_NOT_FOUND', + message: 'Tracked change "tc-1" was not found.', + }, + }; + + const helper = mapFailedReceipt('trackChanges.decide' as any, receipt, { commandName: 'track-changes accept' }); + const canonical = mapFailedReceipt('trackChanges.decide' as any, receipt, { + commandName: 'track-changes decide', + }); + + expect(helper?.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(canonical?.code).toBe('TARGET_NOT_FOUND'); + }); + test('plan-engine code MATCH_NOT_FOUND passes through with structured details', () => { const receipt = { success: false, diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index d45a9fe287..177f06ab11 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -13,6 +13,16 @@ import type { CliExposedOperationId } from '../cli/operation-set.js'; import { OPERATION_FAMILY, type OperationFamily } from '../cli/operation-hints.js'; import { CliError, type AdapterLikeError, type CliErrorCode } from './errors.js'; +type ErrorMappingContext = { + commandName?: string; +}; + +const TRACK_CHANGES_REVIEW_HELPER_COMMANDS = new Set(['track-changes accept', 'track-changes reject']); + +function isTrackChangesReviewHelper(operationId: CliExposedOperationId, context?: ErrorMappingContext): boolean { + return operationId === 'trackChanges.decide' && TRACK_CHANGES_REVIEW_HELPER_COMMANDS.has(context?.commandName ?? ''); +} + // --------------------------------------------------------------------------- // Error code extraction // --------------------------------------------------------------------------- @@ -37,11 +47,19 @@ function extractErrorDetails(error: unknown): unknown { // Per-family error mappers (thrown errors) // --------------------------------------------------------------------------- -function mapTrackChangesError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { +function mapTrackChangesError( + operationId: CliExposedOperationId, + error: unknown, + code: string | undefined, + context?: ErrorMappingContext, +): CliError { const message = extractErrorMessage(error); const details = extractErrorDetails(error); if (operationId === 'trackChanges.decide' && code === 'TARGET_NOT_FOUND') { + if (isTrackChangesReviewHelper(operationId, context)) { + return new CliError('TRACK_CHANGE_NOT_FOUND', message, { operationId, details }); + } return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); } @@ -358,7 +376,12 @@ function mapDiffError(operationId: CliExposedOperationId, error: unknown, code: const FAMILY_MAPPERS: Record< OperationFamily, - (operationId: CliExposedOperationId, error: unknown, code: string | undefined) => CliError + ( + operationId: CliExposedOperationId, + error: unknown, + code: string | undefined, + context?: ErrorMappingContext, + ) => CliError > = { trackChanges: mapTrackChangesError, comments: mapCommentsError, @@ -389,11 +412,15 @@ function resolveOperationFamily(operationId: CliExposedOperationId): OperationFa * Maps an invoke() exception to a CLI error with the appropriate error code. * Called by the generic dispatch path after every invoke() failure. */ -export function mapInvokeError(operationId: CliExposedOperationId, error: unknown): CliError { +export function mapInvokeError( + operationId: CliExposedOperationId, + error: unknown, + context?: ErrorMappingContext, +): CliError { if (error instanceof CliError) return error; const code = extractErrorCode(error); const family = resolveOperationFamily(operationId); - return FAMILY_MAPPERS[family](operationId, error, code); + return FAMILY_MAPPERS[family](operationId, error, code, context); } // --------------------------------------------------------------------------- @@ -422,7 +449,11 @@ function isReceiptLike(value: unknown): value is ReceiptLike { * Returns null if the result is not a failed receipt (either successful or * not receipt-shaped at all). */ -export function mapFailedReceipt(operationId: CliExposedOperationId, result: unknown): CliError | null { +export function mapFailedReceipt( + operationId: CliExposedOperationId, + result: unknown, + context?: ErrorMappingContext, +): CliError | null { if (!isReceiptLike(result)) return null; if (result.success) return null; @@ -444,6 +475,9 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk // Track-changes family if (family === 'trackChanges') { if (operationId === 'trackChanges.decide' && failureCode === 'TARGET_NOT_FOUND') { + if (isTrackChangesReviewHelper(operationId, context)) { + return new CliError('TRACK_CHANGE_NOT_FOUND', failureMessage, { operationId, failure }); + } return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure }); } if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { diff --git a/apps/cli/src/lib/generic-dispatch.ts b/apps/cli/src/lib/generic-dispatch.ts index 205f38d7dc..b39c1cf0b1 100644 --- a/apps/cli/src/lib/generic-dispatch.ts +++ b/apps/cli/src/lib/generic-dispatch.ts @@ -15,6 +15,7 @@ export type DocOperationRequest = { operationId: CliExposedOperationId; input: Record; context: CommandContext; + commandName?: string; }; /** diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index c140fa1af9..8db24c01f0 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -54,6 +54,7 @@ function invokeOperation( operationId: CliExposedOperationId, input: Record, options?: Record, + commandName?: string, ): unknown { const apiInput = extractInvokeInput(operationId, input); const preHook = PRE_INVOKE_HOOKS[operationId]; @@ -67,11 +68,11 @@ function invokeOperation( options, }); } catch (error) { - throw mapInvokeError(operationId, error); + throw mapInvokeError(operationId, error, { commandName }); } // Check for failed receipts (non-throwing failure path) - const failedReceiptError = mapFailedReceipt(operationId, result); + const failedReceiptError = mapFailedReceipt(operationId, result, { commandName }); if (failedReceiptError) throw failedReceiptError; const postHook = POST_INVOKE_HOOKS[operationId]; @@ -120,7 +121,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr const changeMode = readChangeMode(input); const force = readBoolean(input, 'force'); const expectedRevision = readOptionalNumber(input, 'expectedRevision'); - const commandName = deriveCommandName(operationId); + const commandName = request.commandName ?? deriveCommandName(operationId); const catalog = COMMAND_CATALOG[operationId]; const invokeOptions: Record = {}; @@ -152,7 +153,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr const source = doc === '-' ? 'stdin' : 'path'; const opened = await openDocument(doc, context.io); try { - const result = invokeOperation(opened.editor, operationId, input, invokeOptions); + const result = invokeOperation(opened.editor, operationId, input, invokeOptions, commandName); const document: DocumentPayload = { path: source === 'path' ? doc : undefined, source, @@ -204,7 +205,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr }); try { - const result = invokeOperation(opened.editor, operationId, input, invokeOptions); + const result = invokeOperation(opened.editor, operationId, input, invokeOptions, commandName); if (dryRun) { const document: DocumentPayload = { diff --git a/apps/cli/src/lib/operation-executor.ts b/apps/cli/src/lib/operation-executor.ts index 0ea224ac6c..76bda7bcf6 100644 --- a/apps/cli/src/lib/operation-executor.ts +++ b/apps/cli/src/lib/operation-executor.ts @@ -238,6 +238,7 @@ export async function executeOperation(request: ExecuteOperationRequest): Promis operationId: docApiId as CliExposedOperationId, input, context: effectiveContext, + commandName, }); } diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index cdc3efc429..51cfa4355d 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -35,6 +35,7 @@ function invokeOperation( editor: EditorWithDoc, operationId: CliExposedOperationId, input: Record, + commandName?: string, ): unknown { const apiInput = extractInvokeInput(operationId, input); const preHook = PRE_INVOKE_HOOKS[operationId]; @@ -47,7 +48,7 @@ function invokeOperation( input: transformedInput, }); } catch (error) { - throw mapInvokeError(operationId, error); + throw mapInvokeError(operationId, error, { commandName }); } const postHook = POST_INVOKE_HOOKS[operationId]; @@ -102,13 +103,13 @@ export async function executeReadOperation(request: DocOperationRequest): Promis // snapshot sync) from running past a drift failure. const envelopeKey = resolveResponseEnvelopeKey(operationId); const doc = readOptionalString(input, 'doc'); - const commandName = deriveCommandName(operationId); + const commandName = request.commandName ?? deriveCommandName(operationId); if (doc) { const source = doc === '-' ? 'stdin' : 'path'; const opened = await openDocument(doc, context.io); try { - const result = invokeOperation(opened.editor, operationId, input); + const result = invokeOperation(opened.editor, operationId, input, commandName); const document: DocumentPayload = { path: source === 'path' ? doc : undefined, source, @@ -140,7 +141,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis }); try { - const result = invokeOperation(opened.editor, operationId, input); + const result = invokeOperation(opened.editor, operationId, input, commandName); // For oneshot collab reads, sync snapshot to keep working.docx current const isHostMode = context.executionMode === 'host' && context.sessionPool != null; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 8ee30f44b9..a57e7aff90 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; -const mocks = { +const mocks = vi.hoisted(() => ({ checkRevision: vi.fn(), getRevision: vi.fn(() => '0'), executeDomainCommand: vi.fn(), resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), -}; +})); vi.mock('./revision-tracker.js', () => ({ checkRevision: mocks.checkRevision, @@ -395,6 +395,105 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { + it('keeps default paired replacements as one aggregate public list item', () => { + const editor = makeEditor(); + const replacementSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'oldnew', + wordRevisionIds: { insert: '11', delete: '10' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-1', + hasInsert: true, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [replacementSnapshot]), + getAll: vi.fn(() => [replacementSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + id: 'tc-replacement-1', + type: 'insert', + grouping: 'aggregate', + wordRevisionIds: { insert: '11', delete: '10' }, + }); + expect(result.items[0]?.id).not.toContain('#'); + + const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); + expect(deleteFiltered.total).toBe(0); + expect(deleteFiltered.items).toEqual([]); + }); + + it('keeps independent replacement sides paired without aggregating them', () => { + const editor = makeEditor(); + const insertedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-insert' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-insert' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-insert', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#inserted', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 7 }, + }; + const deletedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-delete' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-delete' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'old', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-delete', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#deleted', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 7, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + getAll: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.total).toBe(2); + expect(result.items.map((item) => [item.id, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-insert', 'replacement-pair', 'tc-delete'], + ['tc-delete', 'replacement-pair', 'tc-insert'], + ]); + }); + it('caches list ids for reuse on the same editor revision', () => { const editor = makeEditor(); const snapshot = { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 385ed46fd4..d3d22cbd4b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -127,8 +127,16 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { + return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; +} + +function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { + return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); +} + function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; + return snapshotToProjected(snapshot).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -154,26 +162,7 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - const insertedId = `${snapshot.address.entityId}#inserted`; - const deletedId = `${snapshot.address.entityId}#deleted`; - projected.push( - buildProjectedInfo(snapshot, { - id: insertedId, - type: 'insert', - grouping: 'replacement-pair', - pairedWithChangeId: deletedId, - handleSuffix: '#inserted', - }), - ); - projected.push( - buildProjectedInfo(snapshot, { - id: deletedId, - type: 'delete', - grouping: 'replacement-pair', - pairedWithChangeId: insertedId, - handleSuffix: '#deleted', - }), - ); + projected.push(snapshotToProjected(snapshot)); continue; } From ce018530eb5afa217d118090ee374afbda49f0fa Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 10:37:24 -0700 Subject: [PATCH 023/280] chore: type fixes --- shared/common/identity.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/shared/common/identity.ts b/shared/common/identity.ts index acd0b29f25..d5c171c243 100644 --- a/shared/common/identity.ts +++ b/shared/common/identity.ts @@ -1,4 +1,10 @@ -type IdentityLike = Readonly> | null | undefined; +type IdentityFields = Readonly<{ + id?: unknown; + email?: unknown; + name?: unknown; +}>; + +type IdentityLike = IdentityFields | Readonly> | null | undefined; export interface NormalizedActorIdentity { readonly id: string; @@ -37,7 +43,7 @@ export const normalizeActorName = (value: unknown): string => { }; export const getActorIdentity = (value: IdentityLike): NormalizedActorIdentity => { - const record = (value ?? {}) as Record; + const record = value ?? {}; const id = normalizeActorId(record.id); const email = normalizeActorEmail(record.email); const name = typeof record.name === 'string' ? record.name : ''; From 7bf291a47ecc62011ad33db911f10e3cd030ac52 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 14:41:51 -0700 Subject: [PATCH 024/280] fix: replacement pair --- .../track-changes-wrappers.test.ts | 29 +++++++++++------ .../plan-engine/track-changes-wrappers.ts | 31 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index a57e7aff90..36db781c64 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -395,7 +395,7 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('keeps default paired replacements as one aggregate public list item', () => { + it('projects combined replacement snapshots as public paired insert/delete rows', () => { const editor = makeEditor(); const replacementSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, @@ -424,19 +424,30 @@ describe('track-changes-wrappers projected id cache', () => { const result = trackChangesListWrapper(editor); - expect(result.total).toBe(1); - expect(result.items).toHaveLength(1); + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-replacement-1#inserted', 'insert', 'replacement-pair', 'tc-replacement-1#deleted'], + ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], + ]); + expect(result.items.map((item) => item.handle.ref)).toEqual([ + 'tc::body::tc-replacement-1#inserted', + 'tc::body::tc-replacement-1#deleted', + ]); expect(result.items[0]).toMatchObject({ - id: 'tc-replacement-1', - type: 'insert', - grouping: 'aggregate', + address: { entityId: 'tc-replacement-1#inserted' }, + wordRevisionIds: { insert: '11', delete: '10' }, + }); + expect(result.items[1]).toMatchObject({ + address: { entityId: 'tc-replacement-1#deleted' }, wordRevisionIds: { insert: '11', delete: '10' }, }); - expect(result.items[0]?.id).not.toContain('#'); const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); - expect(deleteFiltered.total).toBe(0); - expect(deleteFiltered.items).toEqual([]); + expect(deleteFiltered.total).toBe(1); + expect(deleteFiltered.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], + ]); }); it('keeps independent replacement sides paired without aggregating them', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index d3d22cbd4b..385ed46fd4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -127,16 +127,8 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } -function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { - return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; -} - -function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { - return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); -} - function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return snapshotToProjected(snapshot).info; + return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -162,7 +154,26 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - projected.push(snapshotToProjected(snapshot)); + const insertedId = `${snapshot.address.entityId}#inserted`; + const deletedId = `${snapshot.address.entityId}#deleted`; + projected.push( + buildProjectedInfo(snapshot, { + id: insertedId, + type: 'insert', + grouping: 'replacement-pair', + pairedWithChangeId: deletedId, + handleSuffix: '#inserted', + }), + ); + projected.push( + buildProjectedInfo(snapshot, { + id: deletedId, + type: 'delete', + grouping: 'replacement-pair', + pairedWithChangeId: insertedId, + handleSuffix: '#deleted', + }), + ); continue; } From bc9f9bb0061cba456867c3345cb2ccece834f400 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 15:22:03 -0700 Subject: [PATCH 025/280] chore: fix regression --- .../src/track-changes/track-changes.ts | 8 ++--- .../track-changes-wrappers.test.ts | 29 ++++++----------- .../plan-engine/track-changes-wrappers.ts | 31 ++++++------------- 3 files changed, 23 insertions(+), 45 deletions(-) diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index facf82203d..fd5f303b96 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -48,10 +48,10 @@ export interface TrackChangesRangeInput { /** * Canonical decide input shape per - * `plans/tracked-changes-comments/tracked-changes-spec.md` § 9. The legacy - * `{ id }` and `{ scope: 'all' }` aliases are preserved during the migration - * window so existing headless callers keep working; the executor normalizes - * them into the canonical `{ kind: ... }` form before dispatch. + * `../labs/tests/requirements/specs/tracked-changes-comments/v3/tracked-changes-spec.md` + * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during + * the migration window so existing headless callers keep working; the executor + * normalizes them into the canonical `{ kind: ... }` form before dispatch. */ export type ReviewDecisionTarget = | { kind: 'id'; id: string; story?: StoryLocator } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 36db781c64..a57e7aff90 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -395,7 +395,7 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('projects combined replacement snapshots as public paired insert/delete rows', () => { + it('keeps default paired replacements as one aggregate public list item', () => { const editor = makeEditor(); const replacementSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, @@ -424,30 +424,19 @@ describe('track-changes-wrappers projected id cache', () => { const result = trackChangesListWrapper(editor); - expect(result.total).toBe(2); - expect(result.items).toHaveLength(2); - expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-replacement-1#inserted', 'insert', 'replacement-pair', 'tc-replacement-1#deleted'], - ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], - ]); - expect(result.items.map((item) => item.handle.ref)).toEqual([ - 'tc::body::tc-replacement-1#inserted', - 'tc::body::tc-replacement-1#deleted', - ]); + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ - address: { entityId: 'tc-replacement-1#inserted' }, - wordRevisionIds: { insert: '11', delete: '10' }, - }); - expect(result.items[1]).toMatchObject({ - address: { entityId: 'tc-replacement-1#deleted' }, + id: 'tc-replacement-1', + type: 'insert', + grouping: 'aggregate', wordRevisionIds: { insert: '11', delete: '10' }, }); + expect(result.items[0]?.id).not.toContain('#'); const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); - expect(deleteFiltered.total).toBe(1); - expect(deleteFiltered.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], - ]); + expect(deleteFiltered.total).toBe(0); + expect(deleteFiltered.items).toEqual([]); }); it('keeps independent replacement sides paired without aggregating them', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 385ed46fd4..d3d22cbd4b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -127,8 +127,16 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { + return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; +} + +function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { + return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); +} + function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; + return snapshotToProjected(snapshot).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -154,26 +162,7 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - const insertedId = `${snapshot.address.entityId}#inserted`; - const deletedId = `${snapshot.address.entityId}#deleted`; - projected.push( - buildProjectedInfo(snapshot, { - id: insertedId, - type: 'insert', - grouping: 'replacement-pair', - pairedWithChangeId: deletedId, - handleSuffix: '#inserted', - }), - ); - projected.push( - buildProjectedInfo(snapshot, { - id: deletedId, - type: 'delete', - grouping: 'replacement-pair', - pairedWithChangeId: insertedId, - handleSuffix: '#deleted', - }), - ); + projected.push(snapshotToProjected(snapshot)); continue; } From 986abeaaa16e8672947087d361e16609d7578ba4 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 00:32:07 -0700 Subject: [PATCH 026/280] chore: ui and more --- .../_open-document-track-changes-worker.ts | 81 ++++++ ...-document-track-changes-forwarding.test.ts | 32 +++ .../lib/operation-runtime-metadata.test.ts | 28 ++ apps/cli/src/cli/operation-params.ts | 19 +- apps/cli/src/cli/types.ts | 2 + apps/cli/src/commands/open.ts | 12 +- apps/cli/src/lib/document.ts | 7 + .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/extract.mdx | 3 +- .../reference/track-changes/get.mdx | 8 +- .../reference/track-changes/list.mdx | 9 +- apps/mcp/src/generated/catalog.ts | 6 +- .../src/contract/contract.test.ts | 48 ++++ .../src/contract/operation-definitions.ts | 10 +- packages/document-api/src/contract/schemas.ts | 16 +- .../src/track-changes/track-changes.ts | 2 +- .../document-api/src/types/extract.types.ts | 12 +- .../document-api/src/types/sd-sections.ts | 2 +- .../src/types/track-changes.types.ts | 4 +- .../painters/dom/src/index.test.ts | 123 +++++++++ .../painters/dom/src/renderer.ts | 26 +- .../layout-engine/painters/dom/src/styles.ts | 26 ++ .../pm-adapter/src/marks/application.test.ts | 76 ++++++ .../pm-adapter/src/marks/application.ts | 117 ++++++-- .../__tests__/transport-common.test.ts | 22 ++ .../Editor.track-changes-dispatch.test.js | 54 ++++ .../src/editors/v1/core/Editor.ts | 21 +- .../src/editors/v1/core/types/EditorConfig.ts | 9 + ...xtract-adapter.consumer-simulation.test.ts | 28 +- .../extract-adapter.test.ts | 52 +++- .../helpers/tracked-change-resolver.test.ts | 73 ++++- .../helpers/tracked-change-resolver.ts | 70 ++++- .../track-changes-wrappers.test.ts | 258 +++++++++++++++++- .../plan-engine/track-changes-wrappers.ts | 116 ++++++-- .../super-editor/src/ui/entity-at.test.ts | 55 ++++ packages/super-editor/src/ui/entity-at.ts | 30 +- .../behavior/helpers/story-tracked-changes.ts | 38 ++- .../programmatic-tracked-change.spec.ts | 6 +- ...ment-update-preserves-deleted-text.spec.ts | 2 +- .../tracked-change-replacement-bubble.spec.ts | 12 +- ...ed-change-sidebar-targeted-refresh.spec.ts | 2 +- 41 files changed, 1376 insertions(+), 143 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts create mode 100644 apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts diff --git a/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts b/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts new file mode 100644 index 0000000000..ad5d410c7c --- /dev/null +++ b/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts @@ -0,0 +1,81 @@ +/** + * Subprocess worker for the openDocument track-changes forwarding test. + */ +import { mock } from 'bun:test'; + +let editorOpenCalled = false; +let capturedTrackChanges: unknown; + +const MockEditor = { + open: mock(async (_source: unknown, options: Record = {}) => { + editorOpenCalled = true; + capturedTrackChanges = (options.modules as { trackChanges?: unknown } | undefined)?.trackChanges; + return { + destroy: () => {}, + exportDocument: async () => new Uint8Array(), + }; + }), +}; + +mock.module('superdoc/super-editor', () => ({ + Editor: MockEditor, + BLANK_DOCX_BASE64: '', + DocxEncryptionError: class DocxEncryptionError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + }, + getDocumentApiAdapters: () => ({}), + markdownToPmDoc: () => null, + initPartsRuntime: () => ({ dispose: () => {} }), + syncCommentEntitiesFromCollaboration: () => new Set(), +})); + +mock.module('@superdoc/document-api', () => ({ + createDocumentApi: () => ({}), +})); + +mock.module('happy-dom', () => ({ + Window: class { + document = { + createElement: () => ({}), + body: { appendChild: () => {}, innerHTML: '' }, + }; + happyDOM = { abort: () => {} }; + close() {} + }, +})); + +async function main() { + const { openDocument } = await import('../../lib/document'); + + const io = { + stdout: () => {}, + stderr: () => {}, + readStdinBytes: async () => new Uint8Array(), + }; + + let opened: { dispose(): void } | undefined; + try { + opened = await openDocument(undefined, io, { + editorOpenOptions: { + modules: { + trackChanges: { + replacements: 'independent', + }, + }, + }, + }); + } finally { + opened?.dispose(); + } + + console.log(JSON.stringify({ editorOpenCalled, capturedTrackChanges })); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts b/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts new file mode 100644 index 0000000000..6df793eb59 --- /dev/null +++ b/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts @@ -0,0 +1,32 @@ +/** + * Verifies that `openDocument` forwards `editorOpenOptions.modules.trackChanges` + * through to `Editor.open()`. + * + * This runs in a subprocess so `mock.module` cannot leak into other tests. + */ +import { describe, expect, test } from 'bun:test'; +import { join } from 'path'; + +const WORKER_SCRIPT = join(import.meta.dir, '_open-document-track-changes-worker.ts'); + +describe('openDocument track changes forwarding', () => { + test('trackChanges replacement mode reaches Editor.open()', async () => { + const proc = Bun.spawn(['bun', 'run', WORKER_SCRIPT], { + cwd: join(import.meta.dir, '../../..'), + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + throw new Error(`Worker failed (code ${exitCode}):\n${stderr || stdout}`); + } + + const result = JSON.parse(stdout.trim()); + expect(result.editorOpenCalled).toBe(true); + expect(result.capturedTrackChanges).toEqual({ replacements: 'independent' }); + }); +}); diff --git a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts index c1a4cada5c..364883d0b6 100644 --- a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts +++ b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts @@ -75,4 +75,32 @@ describe('operation runtime metadata', () => { const optionNames = openOptions.map((o) => o.name); expect(optionNames).toContain('password'); }); + + test('doc.open metadata includes trackChanges JSON param', () => { + const openMeta = CLI_OPERATION_METADATA['doc.open']; + const trackChangesParam = openMeta.params.find((p) => p.name === 'trackChanges'); + + expect(trackChangesParam).toBeDefined(); + expect(trackChangesParam!.kind).toBe('jsonFlag'); + expect(trackChangesParam!.type).toBe('json'); + expect(trackChangesParam!.flag).toBe('track-changes-json'); + expect(trackChangesParam!.schema).toEqual({ + type: 'object', + properties: { + replacements: { + type: 'string', + enum: ['paired', 'independent'], + description: 'Tracked replacement grouping mode.', + }, + }, + }); + }); + + test('doc.open option specs include track-changes-json flag', () => { + const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open']; + const trackChangesOption = openOptions.find((o) => o.name === 'track-changes-json'); + + expect(trackChangesOption).toBeDefined(); + expect(trackChangesOption!.type).toBe('string'); + }); }); diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index 30e19c65a1..101d042a26 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -68,7 +68,7 @@ const CHANGE_MODE_PARAM: CliOperationParamSpec = { kind: 'flag', flag: 'change-mode', type: 'string', - schema: { enum: ['direct', 'tracked'] } as CliTypeSpec, + schema: { type: 'string', enum: ['direct', 'tracked'] } as CliTypeSpec, description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', }; const EXPECTED_REVISION_PARAM: CliOperationParamSpec = { @@ -1059,6 +1059,23 @@ const CLI_ONLY_METADATA: Record = { { name: 'overrideType', kind: 'flag', flag: 'override-type', type: 'string' }, { name: 'onMissing', kind: 'flag', flag: 'on-missing', type: 'string' }, { name: 'bootstrapSettlingMs', kind: 'flag', flag: 'bootstrap-settling-ms', type: 'number' }, + { + name: 'trackChanges', + kind: 'jsonFlag', + flag: 'track-changes-json', + type: 'json', + description: 'Track-changes open configuration.', + schema: { + type: 'object', + properties: { + replacements: { + type: 'string', + enum: ['paired', 'independent'], + description: 'Tracked replacement grouping mode.', + }, + }, + } as CliTypeSpec, + }, USER_NAME_PARAM, USER_EMAIL_PARAM, PASSWORD_PARAM, diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts index 39384c68bb..93a485fc91 100644 --- a/apps/cli/src/cli/types.ts +++ b/apps/cli/src/cli/types.ts @@ -11,6 +11,7 @@ type TypeSpecBase = { description?: string; + enum?: readonly unknown[]; }; export type CliTypeSpec = @@ -25,6 +26,7 @@ export type CliTypeSpec = type: 'object'; properties: Record; required?: readonly string[]; + additionalProperties?: boolean | CliTypeSpec; } & TypeSpecBase); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index e7b5d50845..9519d76e66 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -27,7 +27,7 @@ const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']); const VALID_ON_MISSING = new Set(['seedFromDoc', 'blank', 'error']); export async function runOpen(tokens: string[], context: CommandContext): Promise { - const { parsed, help } = parseOperationArgs('doc.open', tokens, { + const { parsed, args, help } = parseOperationArgs('doc.open', tokens, { commandName: 'open', extraOptionSpecs: [{ name: 'collaboration-file', type: 'string' }], }); @@ -67,6 +67,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const bootstrapSettlingMs = getNumberOption(parsed, 'bootstrap-settling-ms'); const userName = getStringOption(parsed, 'user-name'); const userEmail = getStringOption(parsed, 'user-email'); + const trackChanges = args.trackChanges as { replacements?: 'paired' | 'independent' } | undefined; const allowEnvFallback = context.executionMode !== 'host'; const password = resolvePassword(getStringOption(parsed, 'password'), allowEnvFallback); @@ -142,7 +143,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const user = userName != null || userEmail != null ? { name: userName ?? 'CLI', email: userEmail ?? '' } : undefined; // Build editor open options from override params and password. - const editorOpenOptions: EditorPassThroughOptions & Record = {}; + const editorOpenOptions: EditorPassThroughOptions & { markdown?: string; html?: string; plainText?: string } = {}; if (contentOverride != null && overrideType) { if (overrideType === 'markdown') { editorOpenOptions.markdown = contentOverride; @@ -157,6 +158,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis if (password != null) { editorOpenOptions.password = password; } + if (trackChanges?.replacements != null) { + editorOpenOptions.modules = { + trackChanges: { + replacements: trackChanges.replacements, + }, + }; + } return withContextLock( context.io, diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index bd01d7347d..164078fe58 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -50,9 +50,16 @@ interface ContentOverrideOptions { plainText?: string; } +type TrackChangesReplacementMode = 'paired' | 'independent'; + /** Options passed through to Editor.open() alongside content overrides. */ export interface EditorPassThroughOptions { password?: string; + modules?: { + trackChanges?: { + replacements?: TrackChangesReplacementMode; + }; + }; } interface OpenDocumentOptions { diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index ec0b18a6bd..6ad45e09d8 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1079,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "cecf48b9ef043255c73faeed6b35f236bca0cd969d19779a21952f9c15037705" + "sourceHash": "1a411ac1bb94bc869de87173008a88a06a95f73dbf3bbc0906ec420af73ee88d" } diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx index 00057d1914..1f39be85d6 100644 --- a/apps/docs/document-api/reference/extract.mdx +++ b/apps/docs/document-api/reference/extract.mdx @@ -304,10 +304,11 @@ _No fields._ "type": "string" }, "type": { - "description": "Aggregate type at the entity level. In paired replacement mode, a delete+insert pair shares one entity and this collapses to 'insert'; per-half type lives on block.textSpans[].trackedChanges[].", + "description": "Entity-level type. In paired replacement mode, a delete+insert pair shares one entity with type 'replacement'; per-half type lives on block.textSpans[].trackedChanges[].", "enum": [ "insert", "delete", + "replacement", "format" ], "type": "string" diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index 2b63b81477..2dd2539378 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -20,7 +20,7 @@ Retrieve a single tracked change by ID. ## Expected result -Returns a TrackChangeInfo object with the change type, author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available. +Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `replacement`, `format`), author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available. ## Input fields @@ -56,11 +56,11 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `date` | string | no | | | `deletedText` | string | no | | | `excerpt` | string | no | | -| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"aggregate"`, `"unknown"` | +| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"unknown"` | | `id` | string | yes | | | `insertedText` | string | no | | | `pairedWithChangeId` | any | no | | -| `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | +| `type` | enum | yes | `"insert"`, `"delete"`, `"replacement"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | | `wordRevisionIds.format` | string | no | | @@ -146,7 +146,6 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "enum": [ "standalone", "replacement-pair", - "aggregate", "unknown" ] }, @@ -166,6 +165,7 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "enum": [ "insert", "delete", + "replacement", "format" ] }, diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 51216270d0..fc9e039d74 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -20,7 +20,7 @@ List all tracked changes in the document. ## Expected result -Returns a TrackChangesListResult with tracked change entries, total count, and raw imported Word OOXML revision IDs (`w:id`) when available. +Returns a TrackChangesListResult with tracked change entries (`insert`, `delete`, `replacement`, `format`), total count, and raw imported Word OOXML revision IDs (`w:id`) when available. ## Input fields @@ -29,7 +29,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r | `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` | | `limit` | integer | no | | | `offset` | integer | no | | -| `type` | enum | no | `"insert"`, `"delete"`, `"format"` | +| `type` | enum | no | `"insert"`, `"delete"`, `"replacement"`, `"format"` | ### Example request @@ -123,10 +123,11 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "type": "integer" }, "type": { - "description": "Filter by change type: 'insert', 'delete', or 'format'.", + "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'.", "enum": [ "insert", "delete", + "replacement", "format" ] } @@ -173,7 +174,6 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "enum": [ "standalone", "replacement-pair", - "aggregate", "unknown" ] }, @@ -196,6 +196,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "enum": [ "insert", "delete", + "replacement", "format" ] }, diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index e95c818b37..26ec5cffd8 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2423,7 +2423,7 @@ export const MCP_TOOL_CATALOG = { { toolName: 'superdoc_track_changes', description: - 'Review and resolve tracked changes (insertions, deletions, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""} or all changes at once with {scope:"all"}. Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"insert","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"scope":"all"}}', + 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""} or all changes at once with {scope:"all"}. Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"scope":"all"}}', inputSchema: { type: 'object', properties: { @@ -2442,9 +2442,9 @@ export const MCP_TOOL_CATALOG = { "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", }, type: { - enum: ['insert', 'delete', 'format'], + enum: ['insert', 'delete', 'replacement', 'format'], description: - "Filter by change type: 'insert', 'delete', or 'format'. Only for action 'list'. Omit for other actions.", + "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions.", }, force: { type: 'boolean', diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index a061509e3b..c6ce294626 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -331,6 +331,54 @@ describe('document-api contract catalog', () => { ); }); + it('publishes replacement as a first-class tracked-change type in list/get/extract schemas', () => { + const schemas = buildInternalContractSchemas(); + const trackChangesListInput = schemas.operations['trackChanges.list'].input as { + properties?: { + type?: { + enum?: string[]; + }; + }; + }; + const trackChangesGetOutput = schemas.operations['trackChanges.get'].output as { + properties?: { + type?: { + enum?: string[]; + }; + grouping?: { + enum?: string[]; + }; + }; + }; + const extractOutput = schemas.operations.extract.output as { + properties?: { + trackedChanges?: { + items?: { + properties?: { + type?: { + enum?: string[]; + }; + }; + }; + }; + }; + }; + + expect(trackChangesListInput.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(trackChangesGetOutput.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(trackChangesGetOutput.properties?.grouping?.enum).toEqual( + expect.arrayContaining(['standalone', 'replacement-pair', 'unknown']), + ); + expect(trackChangesGetOutput.properties?.grouping?.enum).not.toContain('aggregate'); + expect(extractOutput.properties?.trackedChanges?.items?.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + }); + it('includes global.history in capabilities.get output schema', () => { const schemas = buildInternalContractSchemas(); const capabilitiesOutput = schemas.operations['capabilities.get'].output as { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index ce232460cb..c2bf48f17a 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -416,14 +416,14 @@ export const INTENT_GROUP_META: Record = { track_changes: { toolName: 'superdoc_track_changes', description: - 'Review and resolve tracked changes (insertions, deletions, format changes) in the document. ' + - 'Action "list" returns all tracked changes with optional filtering by type (insert, delete, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. ' + + 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. ' + + 'Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. ' + 'Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. ' + 'Target a single change with {id:""} or all changes at once with {scope:"all"}. ' + 'Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.', inputExamples: [ { action: 'list' }, - { action: 'list', type: 'insert', limit: 10 }, + { action: 'list', type: 'replacement', limit: 10 }, { action: 'decide', decision: 'accept', target: { id: '' } }, { action: 'decide', decision: 'reject', target: { scope: 'all' } }, ], @@ -2473,7 +2473,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'trackChanges.list', description: 'List all tracked changes in the document.', expectedResult: - 'Returns a TrackChangesListResult with tracked change entries, total count, and raw imported Word OOXML revision IDs (`w:id`) when available.', + 'Returns a TrackChangesListResult with tracked change entries (`insert`, `delete`, `replacement`, `format`), total count, and raw imported Word OOXML revision IDs (`w:id`) when available.', requiresDocumentContext: true, metadata: readOperation({ idempotency: 'idempotent', @@ -2488,7 +2488,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'trackChanges.get', description: 'Retrieve a single tracked change by ID.', expectedResult: - 'Returns a TrackChangeInfo object with the change type, author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available.', + 'Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `replacement`, `format`), author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available.', requiresDocumentContext: true, metadata: readOperation({ idempotency: 'idempotent', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 25b78fbc2f..9f902ca0dc 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1557,8 +1557,8 @@ const trackChangeInfoSchema = objectSchema( { address: trackedChangeAddressSchema, id: { type: 'string' }, - type: { enum: ['insert', 'delete', 'format'] }, - grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, @@ -1575,8 +1575,8 @@ const trackChangeInfoSchema = objectSchema( const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, - type: { enum: ['insert', 'delete', 'format'] }, - grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, @@ -3165,9 +3165,9 @@ const operationSchemas: Record = { }, type: { type: 'string', - enum: ['insert', 'delete', 'format'], + enum: ['insert', 'delete', 'replacement', 'format'], description: - "Aggregate type at the entity level. In paired replacement mode, a delete+insert pair shares one entity and this collapses to 'insert'; per-half type lives on block.textSpans[].trackedChanges[].", + "Entity-level type. In paired replacement mode, a delete+insert pair shares one entity with type 'replacement'; per-half type lives on block.textSpans[].trackedChanges[].", }, blockIds: { type: 'array', @@ -5025,8 +5025,8 @@ const operationSchemas: Record = { limit: { type: 'integer', description: 'Maximum number of tracked changes to return.' }, offset: { type: 'integer', description: 'Number of tracked changes to skip for pagination.' }, type: { - enum: ['insert', 'delete', 'format'], - description: "Filter by change type: 'insert', 'delete', or 'format'.", + enum: ['insert', 'delete', 'replacement', 'format'], + description: "Filter by change type: 'insert', 'delete', 'replacement', or 'format'.", }, in: { oneOf: [storyLocatorSchema, { const: 'all' }], diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index fd5f303b96..e7b74b47fa 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -48,7 +48,7 @@ export interface TrackChangesRangeInput { /** * Canonical decide input shape per - * `../labs/tests/requirements/specs/tracked-changes-comments/v3/tracked-changes-spec.md` + * `../labs/tests/requirements/specs/tracked-changes-comments/tracked-changes-spec.md` * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during * the migration window so existing headless callers keep working; the executor * normalizes them into the canonical `{ kind: ... }` form before dispatch. diff --git a/packages/document-api/src/types/extract.types.ts b/packages/document-api/src/types/extract.types.ts index 3799337dc6..6d0dc06e49 100644 --- a/packages/document-api/src/types/extract.types.ts +++ b/packages/document-api/src/types/extract.types.ts @@ -43,7 +43,11 @@ export interface ExtractTableContext { export interface ExtractTextSpanTrackedChange { /** Tracked change entity ID. */ entityId: string; - /** The mark type carried on this run: insert, delete, or format. */ + /** + * The mark type carried on this run: insert, delete, or format. + * Entity-level paired replacements surface as `replacement` only on + * `ExtractResult.trackedChanges[]`, not on span marks. + */ type: TrackChangeType; } @@ -123,9 +127,9 @@ export interface ExtractTrackedChange { * In paired replacement mode (the default: set * `modules.trackChanges.replacements: 'independent'` for one entity per * `` / `` instead), a delete + insert pair shares one entity - * and the aggregate `type` collapses to `'insert'`. Per-half information - * lives on `block.textSpans[].trackedChanges[].type`, which is the source - * of truth for what each run actually represents. + * and `type` is `'replacement'`. Per-half information still lives on + * `block.textSpans[].trackedChanges[].type`, which is the source of truth + * for what each run actually represents. * * In independent mode every revision is its own entity and `type` is the * entity's only type. diff --git a/packages/document-api/src/types/sd-sections.ts b/packages/document-api/src/types/sd-sections.ts index c30969321b..bd7c23cce6 100644 --- a/packages/document-api/src/types/sd-sections.ts +++ b/packages/document-api/src/types/sd-sections.ts @@ -107,7 +107,7 @@ export interface SDCommentThread { export interface SDTrackedChange { id: string; - type: 'insert' | 'delete' | 'format'; + type: 'insert' | 'delete' | 'replacement' | 'format'; author?: string; date?: string; target?: SDAnchorRange; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 87d9b3a685..9b054cef8c 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -2,9 +2,9 @@ import type { TrackedChangeAddress } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; import type { StoryLocator } from './story.types.js'; -export type TrackChangeType = 'insert' | 'delete' | 'format'; +export type TrackChangeType = 'insert' | 'delete' | 'replacement' | 'format'; export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; -export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'aggregate' | 'unknown'; +export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'unknown'; export interface TrackChangeOverlapLayer { id: string; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index cb06daf793..6a9fdcb253 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -16,6 +16,9 @@ import type { ResolvedLayout, TableBlock, TableMeasure, + TextRun, + TrackedChangeMeta, + TrackedChangesMode, } from '@superdoc/contracts'; const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] }; @@ -227,6 +230,39 @@ const buildSingleParagraphData = (blockId: string, runLength: number) => { return { paragraphMeasure, paragraphLayout }; }; +const createOverlapTrackedBlock = (mode: TrackedChangesMode): FlowBlock => { + const parentInsert: TrackedChangeMeta = { + kind: 'insert', + id: 'ins-parent', + relationship: 'parent', + author: 'Author 1', + }; + const childDelete: TrackedChangeMeta = { + kind: 'delete', + id: 'del-child', + relationship: 'child', + overlapParentId: parentInsert.id, + author: 'Reviewer 2', + }; + const run: TextRun = { + text: 'Overlapped text', + fontFamily: 'Arial', + fontSize: 16, + trackedChange: parentInsert, + trackedChanges: [parentInsert, childDelete], + }; + + return { + kind: 'paragraph', + id: `overlap-block-${mode}`, + runs: [run], + attrs: { + trackedChangesMode: mode, + trackedChangesEnabled: true, + }, + }; +}; + const createResolvedTestLine = (textLength: number, overrides: Partial = {}): Line => ({ fromRun: 0, fromChar: 0, @@ -4686,6 +4722,93 @@ describe('DomPainter', () => { expect(span.dataset.trackChangeAuthorEmail).toBe('reviewer@example.com'); }); + it('renders overlapping parent insert and child delete as an insertion with delete strikethrough metadata', () => { + const overlapBlock = createOverlapTrackedBlock('review'); + const { paragraphMeasure, paragraphLayout } = buildSingleParagraphData( + overlapBlock.id, + (overlapBlock.runs[0] as TextRun).text.length, + ); + + const painter = createTestPainter({ blocks: [overlapBlock], measures: [paragraphMeasure] }); + painter.paint(paragraphLayout, mount); + + const span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-insert-dec')).toBe(true); + expect(span.classList.contains('track-delete-dec')).toBe(true); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('highlighted')).toBe(true); + expect(span.dataset.trackChangeKind).toBe('insert'); + expect(span.dataset.trackChangeIds).toBe('ins-parent,del-child'); + expect(span.dataset.trackChangeKinds).toBe('insert,delete'); + expect(span.dataset.trackChangePreferredTargetId).toBe('del-child'); + + const styleEl = document.head.querySelector('[data-superdoc-track-change-styles="true"]') as HTMLStyleElement; + expect(styleEl).toBeTruthy(); + const cssText = styleEl.textContent ?? ''; + const deleteRuleIndex = cssText.indexOf('.superdoc-layout .track-delete-dec.highlighted'); + const overlapRuleIndex = cssText.indexOf( + '.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted', + ); + expect(deleteRuleIndex).toBeGreaterThanOrEqual(0); + expect(overlapRuleIndex).toBeGreaterThan(deleteRuleIndex); + + const overlapRule = + cssText.match( + /\.superdoc-layout \.track-overlap-insert-delete-dec\.track-insert-dec\.track-delete-dec\.highlighted\s*\{([\s\S]*?)\}/, + )?.[1] ?? ''; + expect(overlapRule).toContain('var(--sd-tracked-changes-insert-border, #00853d)'); + expect(overlapRule).toContain('background-color: var(--sd-tracked-changes-insert-background, #399c7222);'); + expect(overlapRule).toContain('color: var(--sd-tracked-changes-insert-text, currentColor);'); + expect(overlapRule).toMatch( + /text-decoration:\s*line-through\s+solid\s+var\(--sd-tracked-changes-delete-text,\s*#cb0e47\)\s+var\(--sd-tracked-changes-delete-decoration-thickness,\s*2px\)\s*!important;/, + ); + + const overlapFocusRule = + cssText.match( + /\.superdoc-layout \.track-overlap-insert-delete-dec\.track-insert-dec\.track-delete-dec\.highlighted\.track-change-focused\s*\{([\s\S]*?)\}/, + )?.[1] ?? ''; + expect(overlapFocusRule).toContain( + 'background-color: var(--sd-tracked-changes-insert-background-focused, #399c7244);', + ); + expect(overlapFocusRule).toContain('border-top-style: solid;'); + expect(overlapFocusRule).toContain('border-bottom-style: solid;'); + expect(overlapFocusRule).toMatch( + /text-decoration:\s*line-through\s+solid\s+var\(--sd-tracked-changes-delete-text,\s*#cb0e47\)\s+var\(--sd-tracked-changes-delete-decoration-thickness,\s*2px\)\s*!important;/, + ); + }); + + it('keeps overlap visibility driven by all revision layers', () => { + const originalBlock = createOverlapTrackedBlock('original'); + const finalBlock = createOverlapTrackedBlock('final'); + const { paragraphMeasure: originalMeasure, paragraphLayout: originalLayout } = buildSingleParagraphData( + originalBlock.id, + (originalBlock.runs[0] as TextRun).text.length, + ); + const { paragraphMeasure: finalMeasure, paragraphLayout: finalLayout } = buildSingleParagraphData( + finalBlock.id, + (finalBlock.runs[0] as TextRun).text.length, + ); + + const painter = createTestPainter({ blocks: [originalBlock], measures: [originalMeasure] }); + painter.paint(originalLayout, mount); + + let span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('hidden')).toBe(true); + + painter.setData([finalBlock], [finalMeasure]); + painter.paint(finalLayout, mount); + + span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('normal')).toBe(true); + expect(span.classList.contains('hidden')).toBe(true); + expect(span.classList.contains('highlighted')).toBe(false); + }); + it('stamps comment metadata on tracked-change text', () => { const trackedCommentBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 36bd23e8e6..655f235f36 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1001,6 +1001,7 @@ const TRACK_CHANGE_BASE_CLASS: Record = { delete: 'track-delete-dec', format: 'track-format-dec', }; +const TRACK_CHANGE_OVERLAP_INSERT_DELETE_CLASS = 'track-overlap-insert-delete-dec'; // TRACK_CHANGE_FOCUSED_CLASS moved to CommentHighlightDecorator (super-editor). const TRACK_CHANGE_MODIFIER_CLASS: Record> = { @@ -1029,6 +1030,11 @@ type TrackedChangesRenderConfig = { enabled: boolean; }; +type InsertDeleteOverlap = { + parentInsert: TrackedChangeMeta; + childDelete: TrackedChangeMeta; +}; + const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { return run.trackedChanges; @@ -1036,6 +1042,19 @@ const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { return run.trackedChange ? [run.trackedChange] : []; }; +const resolveInsertDeleteOverlap = (layers: TrackedChangeMeta[]): InsertDeleteOverlap | undefined => { + for (const parentInsert of layers) { + if (parentInsert.kind !== 'insert') { + continue; + } + const childDelete = layers.find((layer) => layer.kind === 'delete' && layer.overlapParentId === parentInsert.id); + if (childDelete) { + return { parentInsert, childDelete }; + } + } + return undefined; +}; + /** * Sanitize a URL to prevent XSS attacks. * Only allows http, https, mailto, tel, and internal anchors. @@ -7088,7 +7107,8 @@ export class DomPainter { if (layers.length === 0) { return; } - const meta = textRun.trackedChange ?? layers[0]; + const overlap = resolveInsertDeleteOverlap(layers); + const meta = overlap?.parentInsert ?? textRun.trackedChange ?? layers[0]; layers.forEach((layer) => { const baseClass = TRACK_CHANGE_BASE_CLASS[layer.kind]; @@ -7102,6 +7122,10 @@ export class DomPainter { } }); + if (overlap) { + elem.classList.add(TRACK_CHANGE_OVERLAP_INSERT_DELETE_CLASS); + elem.dataset.trackChangePreferredTargetId = overlap.childDelete.id; + } elem.dataset.trackChangeId = meta.id; elem.dataset.trackChangeKind = meta.kind; elem.dataset.trackChangeIds = layers.map((layer) => layer.id).join(','); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index cd916debff..440334df98 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -310,6 +310,32 @@ const TRACK_CHANGE_STYLES = ` background-color: var(--sd-tracked-changes-delete-background-focused, #cb0e4744); } +.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted { + border-top: var(--sd-tracked-changes-insert-border-width, 1px) dashed var(--sd-tracked-changes-insert-border, #00853d); + border-bottom: var(--sd-tracked-changes-insert-border-width, 1px) dashed var(--sd-tracked-changes-insert-border, #00853d); + background-color: var(--sd-tracked-changes-insert-background, #399c7222); + color: var(--sd-tracked-changes-insert-text, currentColor); + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px) !important; +} + +.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted.track-change-focused { + border-left: none; + border-right: none; + border-top-style: solid; + border-bottom-style: solid; + background-color: var(--sd-tracked-changes-insert-background-focused, #399c7244); + color: var(--sd-tracked-changes-insert-text, currentColor); + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px) !important; +} + .superdoc-layout .track-format-dec.highlighted.track-change-focused { background-color: var(--sd-tracked-changes-format-background-focused, #ffd70033); } diff --git a/packages/layout-engine/pm-adapter/src/marks/application.test.ts b/packages/layout-engine/pm-adapter/src/marks/application.test.ts index 4f9bbda87a..29e8c35937 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.test.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.test.ts @@ -411,6 +411,56 @@ describe('mark application', () => { expect(trackedChangesCompatible(a, b)).toBe(false); }); + + it('returns true for the same overlap layers in different order', () => { + const insertParent: TrackedChangeMeta = { kind: 'insert', id: 'ins-parent' }; + const deleteChild: TrackedChangeMeta = { + kind: 'delete', + id: 'del-child', + overlapParentId: 'ins-parent', + relationship: 'child', + }; + const a: TextRun = { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [deleteChild, insertParent], + }; + const b: TextRun = { + text: 'World', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [insertParent, deleteChild], + }; + + expect(trackedChangesCompatible(a, b)).toBe(true); + }); + + it('returns false when one run has an extra overlap layer', () => { + const insertParent: TrackedChangeMeta = { kind: 'insert', id: 'ins-parent' }; + const a: TextRun = { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [insertParent], + }; + const b: TextRun = { + text: 'World', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [ + insertParent, + { + kind: 'delete', + id: 'del-child', + overlapParentId: 'ins-parent', + relationship: 'child', + }, + ], + }; + + expect(trackedChangesCompatible(a, b)).toBe(false); + }); }); describe('collectTrackedChangeFromMarks', () => { @@ -1041,6 +1091,31 @@ describe('mark application', () => { expect(run.trackedChange?.author).toBe('John'); }); + it('normalizes overlapping tracked change layers with parent insert first', () => { + const run: TextRun = { text: 'Hello', fontFamily: 'Arial', fontSize: 12 }; + applyMarksToRun(run, [ + { + type: TRACK_DELETE_MARK, + attrs: { id: 'del-child', overlapParentId: 'ins-parent', author: 'Reviewer' }, + }, + { + type: TRACK_INSERT_MARK, + attrs: { id: 'ins-parent', author: 'Author' }, + }, + ]); + + expect(run.trackedChanges?.map((layer) => `${layer.kind}:${layer.id}`)).toEqual([ + 'insert:ins-parent', + 'delete:del-child', + ]); + expect(run.trackedChanges?.[0].relationship).toBe('parent'); + expect(run.trackedChanges?.[1].relationship).toBe('child'); + expect(run.trackedChanges?.[1].overlapParentId).toBe('ins-parent'); + expect(run.trackedChange).toBe(run.trackedChanges?.[0]); + expect(run.trackedChange?.kind).toBe('insert'); + expect(run.trackedChange?.id).toBe('ins-parent'); + }); + it('applies link mark with enableRichHyperlinks disabled', () => { const run: TextRun = { text: 'Hello', fontFamily: 'Arial', fontSize: 12 }; applyMarksToRun(run, [{ type: 'link', attrs: { href: 'https://example.com' } }], { enableRichHyperlinks: false }); @@ -1072,6 +1147,7 @@ describe('mark application', () => { // Insert should take priority over format expect(run.trackedChange?.kind).toBe('insert'); + expect(run.trackedChanges?.map((layer) => layer.kind)).toEqual(['insert', 'format']); }); it('ignores unknown mark types', () => { diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 58f50df191..d1ea4c9c31 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -508,11 +508,85 @@ export const selectTrackedChangeMeta = ( const trackedChangeLayerKey = (meta: TrackedChangeMeta): string => `${meta.kind}:${meta.id}`; +const trackedChangeLayerIdentity = (meta: TrackedChangeMeta): string => + [meta.kind, meta.id, meta.overlapParentId ?? ''].join(':'); + +const compareTrackedChangeLayerStrings = (a: string, b: string): number => { + if (a < b) return -1; + if (a > b) return 1; + return 0; +}; + +const trackedChangeContentRank = (meta: TrackedChangeMeta): number => (meta.kind === 'format' ? 1 : 0); + +const trackedChangeRelationshipRank = (meta: TrackedChangeMeta): number => { + if (meta.relationship === 'parent') return 0; + if (meta.relationship === 'child') return 2; + return 1; +}; + +const compareTrackedChangeLayers = (a: TrackedChangeMeta, b: TrackedChangeMeta): number => { + const aIsParentOfB = a.id === b.overlapParentId; + const bIsParentOfA = b.id === a.overlapParentId; + if (aIsParentOfB && !bIsParentOfA) return -1; + if (bIsParentOfA && !aIsParentOfB) return 1; + + const contentRank = trackedChangeContentRank(a) - trackedChangeContentRank(b); + if (contentRank !== 0) return contentRank; + + const relationshipRank = trackedChangeRelationshipRank(a) - trackedChangeRelationshipRank(b); + if (relationshipRank !== 0) return relationshipRank; + + const idOrder = compareTrackedChangeLayerStrings(a.id, b.id); + if (idOrder !== 0) return idOrder; + + const kindOrder = compareTrackedChangeLayerStrings(a.kind, b.kind); + if (kindOrder !== 0) return kindOrder; + + const parentOrder = compareTrackedChangeLayerStrings(a.overlapParentId ?? '', b.overlapParentId ?? ''); + if (parentOrder !== 0) return parentOrder; + + return compareTrackedChangeLayerStrings(a.storyKey ?? '', b.storyKey ?? ''); +}; + +const normalizeTrackedChangeLayerList = (layers: TrackedChangeMeta[]): TrackedChangeMeta[] => { + if (layers.length === 0) return []; + + const uniqueLayers = new Map(); + layers.forEach((layer) => { + const key = trackedChangeLayerKey(layer); + if (!uniqueLayers.has(key)) { + uniqueLayers.set(key, { ...layer }); + } + }); + + const parentIds = new Set(); + uniqueLayers.forEach((layer) => { + if (layer.overlapParentId) { + parentIds.add(layer.overlapParentId); + } + }); + + return Array.from(uniqueLayers.values()) + .map((layer) => { + const normalized = { ...layer }; + if (normalized.overlapParentId) { + normalized.relationship = 'child'; + } else if (parentIds.has(normalized.id)) { + normalized.relationship = 'parent'; + } else { + delete normalized.relationship; + } + return normalized; + }) + .sort(compareTrackedChangeLayers); +}; + const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { - return run.trackedChanges; + return normalizeTrackedChangeLayerList(run.trackedChanges); } - return run.trackedChange ? [run.trackedChange] : []; + return run.trackedChange ? normalizeTrackedChangeLayerList([run.trackedChange]) : []; }; const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void => { @@ -521,13 +595,15 @@ const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void = if (!layers.some((layer) => trackedChangeLayerKey(layer) === key)) { layers.push(meta); } - run.trackedChanges = layers; - run.trackedChange = selectTrackedChangeMeta(run.trackedChange, meta); + const normalizedLayers = normalizeTrackedChangeLayerList(layers); + run.trackedChanges = normalizedLayers; + run.trackedChange = normalizedLayers[0]; }; /** * Checks if two text runs have compatible tracked change metadata for merging. - * Runs are compatible if they have the same kind and ID, or both have no metadata. + * Runs are compatible if their normalized tracked-change layer identities match, + * or both have no metadata. * * @param a - First text run * @param b - Second text run @@ -539,26 +615,10 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { if (aLayers.length !== bLayers.length) return false; return aLayers.every((aMeta, index) => { const bMeta = bLayers[index]; - return Boolean(bMeta && aMeta.kind === bMeta.kind && aMeta.id === bMeta.id); + return Boolean(bMeta && trackedChangeLayerIdentity(aMeta) === trackedChangeLayerIdentity(bMeta)); }); }; -/** - * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. - * When multiple tracked change marks are present, returns the highest-priority one. - * - * @param marks - Array of ProseMirror marks to process - * @returns The highest-priority TrackedChangeMeta, or undefined if none found - */ -export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { - if (!marks || !marks.length) return undefined; - return marks.reduce((current, mark) => { - const meta = buildTrackedChangeMetaFromMark(mark, storyKey); - if (!meta) return current; - return selectTrackedChangeMeta(current, meta); - }, undefined); -}; - export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta[] => { if (!marks || !marks.length) return []; const seen = new Set(); @@ -571,7 +631,18 @@ export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: stri seen.add(key); trackedChanges.push(meta); }); - return trackedChanges; + return normalizeTrackedChangeLayerList(trackedChanges); +}; + +/** + * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. + * When multiple tracked change marks are present, returns the first normalized layer. + * + * @param marks - Array of ProseMirror marks to process + * @returns The primary TrackedChangeMeta, or undefined if none found + */ +export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { + return collectTrackedChangesFromMarks(marks, storyKey)[0]; }; /** diff --git a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts index 5e9daf7541..0177be9f8c 100644 --- a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts +++ b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts @@ -350,4 +350,26 @@ describe('buildOperationArgv with real generated contract', () => { const argv = buildOperationArgv(realOpenOp, { doc: 'plain.docx' }, {}, undefined); expect(argv).not.toContain('--password'); }); + + test('generated doc.open spec includes trackChanges JSON param', () => { + const trackChangesParam = realOpenOp.params.find((p) => p.name === 'trackChanges'); + + expect(trackChangesParam).toBeDefined(); + expect(trackChangesParam!.kind).toBe('jsonFlag'); + expect(trackChangesParam!.type).toBe('json'); + expect(trackChangesParam!.flag).toBe('track-changes-json'); + }); + + test('trackChanges replacement mode emits --track-changes-json with real doc.open spec', () => { + const argv = buildOperationArgv( + realOpenOp, + { doc: 'test.docx', trackChanges: { replacements: 'independent' } }, + {}, + undefined, + ); + const flagIndex = argv.indexOf('--track-changes-json'); + + expect(flagIndex).toBeGreaterThan(-1); + expect(JSON.parse(argv[flagIndex + 1])).toEqual({ replacements: 'independent' }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 8aceeb7d04..3789c316c0 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { initTestEditor } from '@tests/helpers/helpers.js'; +import { Editor } from '@core/Editor.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { TrackDeleteMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/trackChangesBasePlugin.js'; @@ -10,6 +14,11 @@ const FIXED_DATE = '2026-05-21T00:00:00.000Z'; const FOREIGN_INSERT_ID = 'foreign-insert'; const INSERTED_TEXT = 'here is my new text, do you like it?'; const INSERTED_TAIL = 'do you like it?'; +const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +const WORD_REPLACEMENT_FIXTURE = resolve( + CURRENT_DIR, + '../../../../../../tests/behavior/tests/comments/fixtures/sd-1960-word-replacement-no-comments.docx', +); const findTextRange = (editor, text) => { let found = null; @@ -250,6 +259,51 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + it('normalizes modules.trackChanges.replacements for direct Editor.open callers', async () => { + const opened = await Editor.open(undefined, { + isHeadless: true, + modules: { trackChanges: { replacements: 'independent' } }, + }); + + try { + expect(opened.options.trackedChanges?.replacements).toBe('independent'); + } finally { + opened.destroy(); + } + }); + + it('uses modules.trackChanges.replacements during Word replacement import projection', async () => { + const fixture = await readFile(WORD_REPLACEMENT_FIXTURE); + const paired = await Editor.open(fixture, { + isHeadless: true, + modules: { trackChanges: { replacements: 'paired' } }, + }); + const independent = await Editor.open(fixture, { + isHeadless: true, + modules: { trackChanges: { replacements: 'independent' } }, + }); + + try { + const pairedItems = paired.doc.trackChanges.list().items; + const independentItems = independent.doc.trackChanges.list().items; + + expect(pairedItems).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'replacement', grouping: 'replacement-pair' })]), + ); + expect(independentItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'insert', grouping: 'standalone' }), + expect.objectContaining({ type: 'delete', grouping: 'standalone' }), + ]), + ); + expect(independentItems.some((item) => item.grouping === 'replacement-pair')).toBe(false); + expect(independentItems.length).toBeGreaterThan(pairedItems.length); + } finally { + paired.destroy(); + independent.destroy(); + } + }); + it('protects another user tracked insertion from direct delete while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 0e6ee04913..7343a9b040 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -100,6 +100,25 @@ import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewM import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; +type TrackChangesRuntimeConfig = NonNullable; + +function normalizeEditorTrackChangesOptions(options: Partial): Partial { + const canonical = options.modules?.trackChanges; + const legacy = options.trackedChanges; + + if (!canonical && !legacy) return options; + + const normalized: TrackChangesRuntimeConfig = { + ...(legacy ?? {}), + ...(canonical ?? {}), + }; + + return { + ...options, + trackedChanges: normalized, + }; +} + type ConverterWithInternalWordIdAllocator = EditorConverterSurface & { wordIdAllocator?: { getSourceIdMap?: () => Record>; @@ -640,7 +659,7 @@ export class Editor extends EventEmitter { constructor(options: Partial) { super(); - const resolvedOptions = { ...options }; + const resolvedOptions = normalizeEditorTrackChangesOptions(options); const domAvailable = canUseDOM(); const isHeadlessRequested = Boolean(resolvedOptions.isHeadless); const mountRequested = Boolean(resolvedOptions.element || resolvedOptions.selector); diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 9e039bf953..4487329bfd 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -379,6 +379,15 @@ export interface EditorOptions { /** Comment highlight configuration */ comments?: CommentConfig; + /** + * Public SuperDoc module configuration accepted by direct/headless + * `Editor.open()` callers. SuperDoc app config normalizes this before it + * reaches the editor; CLI/SDK headless callers pass it directly. + */ + modules?: { + trackChanges?: EditorOptions['trackedChanges'] | null; + }; + /** * Track-changes runtime configuration forwarded from the SuperDoc-level * `modules.trackChanges` config. Read by the TrackChanges extension and diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts index c2f980ff2e..6e42edc6b3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts @@ -13,9 +13,8 @@ * replacement. SuperDoc's importer (trackedChangeIdMapper.js) maps * adjacent w:del + w:ins with the same author/date to one internal * raw mark id, so both halves share a single entityId at the public - * API. Spans carry the per-half type. The aggregate `type` field on - * `trackedChanges[]` is best-effort (insert wins over delete); span - * type is the source of truth. + * API. Spans carry the per-half type, while the entity-level `type` + * on `trackedChanges[]` is `replacement`. * 2. "[del:Delete me]" — a paragraph that is entirely a deletion. */ @@ -63,7 +62,7 @@ type ChunkForEmbedding = | { kind: 'tracked-change'; entityId: string; - type: 'insert' | 'delete' | 'format'; + type: 'insert' | 'delete' | 'replacement' | 'format'; blockIds: string[]; content: string; }; @@ -148,14 +147,14 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(rendered).toContain('Here is a MS Word'); expect(rendered).toContain('sentence'); - // Word-authored replacement halves remain separate source-wrapper entities, - // while each span still resolves to the correct public tracked-change entry. + // Word-authored replacement halves keep distinct span-side markers, but + // in paired mode they resolve to one public tracked-change entity. const delEntity = deleteSpans[0].trackedChanges!.find((c) => c.type === 'delete')!.entityId; const insEntity = insertSpans[0].trackedChanges!.find((c) => c.type === 'insert')!.entityId; const entitiesById = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); - expect(insEntity).not.toBe(delEntity); + expect(insEntity).toBe(delEntity); expect(entitiesById.get(delEntity)?.wordRevisionIds?.delete).toBeTruthy(); - expect(entitiesById.get(insEntity)?.wordRevisionIds?.insert).toBeTruthy(); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.insert).toBeTruthy(); }); it('attaches every tracked change to the blocks it lives in via blockIds', async () => { @@ -210,10 +209,9 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { // the consumer could render a marker but couldn't look up author/date or // pass the id to scrollToElement(). // - // We don't assert span.type === aggregate.type. For paired changes (one - // entity covering both a delete half and an insert half) the aggregate - // type field collapses to "insert" by pre-existing convention. Span type - // is the per-half source of truth; the aggregate is for navigation. + // We don't assert span.type === entity.type. For paired changes, the + // entity-level type is "replacement" while the spans still carry the + // per-half delete/insert truth needed for rendering. const indexByEntity = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); for (const entityId of entityIdsInSpans) { expect(indexByEntity.get(entityId), `entityId ${entityId} should appear in trackedChanges[]`).toBeDefined(); @@ -258,13 +256,11 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(insertEntry).toBeDefined(); const [deleteEntityId, deleteSegments] = deleteEntry!; const [insertEntityId, insertSegments] = insertEntry!; - expect(deleteEntityId).not.toBe(insertEntityId); + expect(deleteEntityId).toBe(insertEntityId); const deleteEntity = result.trackedChanges.find((tc) => tc.entityId === deleteEntityId)!; - const insertEntity = result.trackedChanges.find((tc) => tc.entityId === insertEntityId)!; expect(deleteEntity.wordRevisionIds?.delete).toBeTruthy(); - expect(insertEntity.wordRevisionIds?.insert).toBeTruthy(); - expect(deleteEntity.blockIds).toEqual(insertEntity.blockIds); + expect(deleteEntity.wordRevisionIds?.insert).toBeTruthy(); expect(deleteSegments.filter((s) => s.type === 'delete').map((s) => s.text)).toEqual(['basic ']); expect( diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts index 79bcec9537..8bce5692cb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts @@ -66,11 +66,12 @@ function trackDeleteMark( id: string, author = 'Author', date = '2026-01-01T00:00:00Z', + attrs: Record = {}, ): { type: string; attrs: Record; } { - return { type: 'trackDelete', attrs: { id, author, date } }; + return { type: 'trackDelete', attrs: { id, author, date, ...attrs } }; } function trackFormatMark( @@ -479,6 +480,52 @@ describe('extract-adapter tracked-change spans', () => { } }); + it('preserves overlapping insert + delete marks on a single span', async () => { + const parentInsertId = 'raw-overlap-ins'; + const childDeleteId = 'raw-overlap-del'; + const doc: SchemaDoc = { + type: 'doc', + content: [ + paragraphRuns([ + 'plain ', + { + text: 'review', + marks: [ + trackInsertMark(parentInsertId, 'Insert Author'), + trackDeleteMark(childDeleteId, 'Delete Author', '2026-01-01T00:00:00Z', { + overlapParentId: parentInsertId, + }), + ], + }, + ' tail', + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const block = result.blocks[0]; + + expect(block.text).toBe('plain review tail'); + expect(block.textSpans).toBeDefined(); + expect(block.textSpans!.map((s) => s.text).join('')).toBe(block.text); + + const overlapSpan = block.textSpans!.find((s) => s.text === 'review'); + expect(overlapSpan).toBeDefined(); + expect(overlapSpan!.trackedChanges).toBeDefined(); + expect(overlapSpan!.trackedChanges).toHaveLength(2); + expect(overlapSpan!.trackedChanges!.map((tc) => tc.type).sort()).toEqual(['delete', 'insert']); + + const entityIds = new Set(overlapSpan!.trackedChanges!.map((tc) => tc.entityId)); + expect(entityIds.size).toBe(2); + expect(result.trackedChanges.map((tc) => tc.type).sort()).toEqual(['delete', 'insert']); + for (const tc of result.trackedChanges) { + expect(tc.blockIds).toEqual([block.nodeId]); + } + }); + it('attaches spans inside table cells without breaking tableContext', async () => { const doc: SchemaDoc = { type: 'doc', @@ -513,7 +560,7 @@ describe('extract-adapter tracked-change spans', () => { expect(result.trackedChanges[0].blockIds).toEqual([tagged.nodeId]); }); - it('suppresses the aggregate excerpt for in-app paired replacements with no OOXML sourceId', async () => { + it('suppresses the paired replacement excerpt for in-app tracked replacements with no OOXML sourceId', async () => { // Reproduces the codex-bot finding on PR #2973: paired replacements // created via in-app tracked editing have no `sourceId` on the marks, // so `wordRevisionIds` is empty. Paired detection must come from the @@ -538,6 +585,7 @@ describe('extract-adapter tracked-change spans', () => { expect(result.trackedChanges).toHaveLength(1); const entry = result.trackedChanges[0]; + expect(entry.type).toBe('replacement'); expect(entry.wordRevisionIds).toBeUndefined(); // The whole point: even without OOXML provenance, the concatenated // excerpt is suppressed because the spans showed both insert and delete. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index a17873b2df..e4ede58fee 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -18,8 +18,9 @@ vi.mock('../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', getTrackChanges: vi.fn(), })); -function makeEditor(): Editor { +function makeEditor(options: Record = { trackedChanges: {} }): Editor { return { + options, state: { doc: { content: { size: 100 }, @@ -55,8 +56,8 @@ describe('resolveTrackedChangeType', () => { expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: true })).toBe('format'); }); - it('returns insert when both hasInsert and hasDelete are true (no format)', () => { - expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: false })).toBe('insert'); + it('returns replacement when both hasInsert and hasDelete are true (no format)', () => { + expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: false })).toBe('replacement'); }); }); @@ -209,6 +210,51 @@ describe('groupTrackedChanges', () => { expect(childChange?.excerpt).toBe('review'); }); + it('attaches overlap visual layers to the parent insertion with child deletion as the context target', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent-overlap', { author: 'Insert Author' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child-overlap', { + author: 'Delete Author', + overlapParentId: 'parent-overlap', + }); + const node = { text: 'review', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...child, node, from: 1, to: 7 }, + { ...parent, node, from: 1, to: 7 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.rawId === 'parent-overlap'); + const childChange = grouped.find((change) => change.rawId === 'child-overlap'); + + expect(parentChange).toBeDefined(); + expect(childChange).toBeDefined(); + expect(parentChange!.overlap?.visualLayers).toEqual([ + { + id: parentChange!.id, + rawId: 'parent-overlap', + commandRawId: 'parent-overlap', + type: 'insert', + relationship: 'parent', + }, + { + id: childChange!.id, + rawId: 'child-overlap', + commandRawId: 'child-overlap', + type: 'delete', + relationship: 'child', + }, + ]); + expect(parentChange!.overlap?.preferredContextTargetId).toBe(childChange!.id); + expect(parentChange!.overlap?.preferredContextTarget).toEqual({ + id: childChange!.id, + rawId: 'child-overlap', + commandRawId: 'child-overlap', + type: 'delete', + relationship: 'child', + }); + expect(childChange!.overlap).toBeUndefined(); + }); + it('preserves significant Word revision whitespace in explicit excerpts', () => { const mark = makeTrackMark(TrackDeleteMarkName, 'delete-with-space', { sourceId: '4' }); vi.mocked(getTrackChanges).mockReturnValue([ @@ -308,11 +354,30 @@ describe('buildTrackedChangeCanonicalIdMap', () => { const insertChange = grouped.find((change) => change.rawId === `word:${TrackInsertMarkName}:11`); const deleteChange = grouped.find((change) => change.rawId === `word:${TrackDeleteMarkName}:10`); + expect(insertChange).toBeDefined(); + expect(deleteChange).toBeDefined(); + expect(map.get(`word:${TrackInsertMarkName}:11`)).toBe(insertChange!.id); + expect(map.get(`word:${TrackDeleteMarkName}:10`)).toBe(insertChange!.id); + expect(map.get('tc-1')).toBe(insertChange!.id); + }); + + it('keeps split replacement aliases separate in independent mode', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, + ] as never); + + const editor = makeEditor({ trackedChanges: { replacements: 'independent' } }); + const map = buildTrackedChangeCanonicalIdMap(editor); + const grouped = groupTrackedChanges(editor); + const insertChange = grouped.find((change) => change.rawId === `word:${TrackInsertMarkName}:11`); + const deleteChange = grouped.find((change) => change.rawId === `word:${TrackDeleteMarkName}:10`); + expect(insertChange).toBeDefined(); expect(deleteChange).toBeDefined(); expect(map.get(`word:${TrackInsertMarkName}:11`)).toBe(insertChange!.id); expect(map.get(`word:${TrackDeleteMarkName}:10`)).toBe(deleteChange!.id); - expect(map.get('tc-1')).toBeUndefined(); + expect(map.get('tc-1')).toBe(deleteChange!.id); }); it('returns empty map when no tracked changes exist', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index e163db05ae..734a76ae12 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -119,11 +119,17 @@ function deriveTrackedChangeId(editor: Editor, change: Omit(); +type ReplacementsMode = 'paired' | 'independent'; + +function readReplacementsMode(editor: Editor): ReplacementsMode { + return editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +} function mergeWordRevisionId( target: TrackChangeWordRevisionIds | undefined, @@ -169,11 +175,55 @@ function getTrackedChangeAliasCandidates(change: GroupedTrackedChange): string[] change.commandRawId, change.id, toNonEmptyString(change.attrs.id), + toNonEmptyString(change.attrs.replacementGroupId), toNonEmptyString(change.attrs.sourceId), ]; return Array.from(new Set(candidates.filter((value): value is string => Boolean(value)))); } +function replacementPairKey(change: GroupedTrackedChange): string | null { + if (change.hasInsert === change.hasDelete) return null; + const replacementGroupId = toNonEmptyString(change.attrs.replacementGroupId); + if (replacementGroupId) return `group:${replacementGroupId}`; + if (change.commandRawId) return `command:${change.commandRawId}`; + return null; +} + +function buildPublicTrackedChangeIdMap( + grouped: ReadonlyArray, + replacements: ReplacementsMode, +): Map { + const publicIdByChange = new Map(); + + if (replacements === 'paired') { + const byPairKey = new Map(); + for (const change of grouped) { + if (change.hasInsert && change.hasDelete) continue; + const key = replacementPairKey(change); + if (!key) continue; + const bucket = byPairKey.get(key) ?? []; + bucket.push(change); + byPairKey.set(key, bucket); + } + + for (const group of byPairKey.values()) { + const inserted = group.find((change) => change.hasInsert && !change.hasDelete); + const deleted = group.find((change) => change.hasDelete && !change.hasInsert); + if (!inserted || !deleted) continue; + publicIdByChange.set(inserted, inserted.id); + publicIdByChange.set(deleted, inserted.id); + } + } + + for (const change of grouped) { + if (!publicIdByChange.has(change)) { + publicIdByChange.set(change, change.id); + } + } + + return publicIdByChange; +} + function layerFromChange( change: GroupedTrackedChange, relationship: TrackChangeOverlapLayer['relationship'], @@ -362,21 +412,25 @@ export function resolveTrackedChange(editor: Editor, id: string): GroupedTracked export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { const { baseId, side } = splitProjectedTrackedChangeId(rawId); - const grouped = groupTrackedChanges(editor); - const canonical = - grouped.find((item) => item.rawId === baseId || item.commandRawId === baseId || item.id === baseId)?.id ?? null; + const canonical = buildTrackedChangeCanonicalIdMap(editor).get(baseId) ?? null; if (!canonical) return null; return side ? `${canonical}#${side}` : canonical; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { const grouped = groupTrackedChanges(editor); + const publicIdByChange = buildPublicTrackedChangeIdMap(grouped, readReplacementsMode(editor)); const map = new Map(); for (const change of grouped) { - map.set(change.rawId, change.id); - map.set(change.id, change.id); - map.set(`${change.id}#inserted`, change.id); - map.set(`${change.id}#deleted`, change.id); + const publicId = publicIdByChange.get(change) ?? change.id; + map.set(change.rawId, publicId); + map.set(change.id, publicId); + if (change.commandRawId) map.set(change.commandRawId, publicId); + const replacementGroupId = toNonEmptyString(change.attrs.replacementGroupId); + if (replacementGroupId) map.set(replacementGroupId, publicId); + map.set(publicId, publicId); + map.set(`${publicId}#inserted`, publicId); + map.set(`${publicId}#deleted`, publicId); } return map; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index a57e7aff90..b28622ffbe 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -37,6 +37,7 @@ import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, + trackChangesGetWrapper, trackChangesListWrapper, getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; @@ -47,9 +48,13 @@ function expectTrackChangesDecideReceiptCodeDeclared(code: string): void { expect(COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes).toContain(code); } -function makeEditor(commands: Record = {}): Editor { +function makeEditor( + commands: Record = {}, + options: Record = { trackedChanges: {} }, +): Editor { return { commands, + options, state: { doc: { textBetween: vi.fn(() => '') } }, } as unknown as Editor; } @@ -395,13 +400,13 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('keeps default paired replacements as one aggregate public list item', () => { + it('keeps default paired replacements as one replacement public list item', () => { const editor = makeEditor(); const replacementSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-1' }, story: { kind: 'story', storyType: 'body' }, - type: 'insert', + type: 'replacement', excerpt: 'oldnew', wordRevisionIds: { insert: '11', delete: '10' }, storyLabel: 'Body', @@ -428,18 +433,159 @@ describe('track-changes-wrappers projected id cache', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ id: 'tc-replacement-1', - type: 'insert', - grouping: 'aggregate', + type: 'replacement', + grouping: 'replacement-pair', wordRevisionIds: { insert: '11', delete: '10' }, }); expect(result.items[0]?.id).not.toContain('#'); + const replacementFiltered = trackChangesListWrapper(editor, { type: 'replacement' }); + expect(replacementFiltered.total).toBe(1); + expect(replacementFiltered.items).toHaveLength(1); + + const insertFiltered = trackChangesListWrapper(editor, { type: 'insert' }); + expect(insertFiltered.total).toBe(0); + expect(insertFiltered.items).toEqual([]); + const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); expect(deleteFiltered.total).toBe(0); expect(deleteFiltered.items).toEqual([]); }); - it('keeps independent replacement sides paired without aggregating them', () => { + it('returns replacement type for paired replacement get lookups', () => { + const editor = makeEditor(); + const replacementSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'replacement', + excerpt: 'oldnew', + wordRevisionIds: { insert: '11', delete: '10' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-1', + hasInsert: true, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor, + story: { kind: 'story', storyType: 'body' }, + runtimeRef: replacementSnapshot.runtimeRef, + change: { + id: 'tc-replacement-1', + rawId: 'tc-replacement-1', + from: 4, + to: 10, + hasInsert: true, + hasDelete: true, + hasFormat: false, + attrs: {}, + }, + }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [replacementSnapshot]), + getAll: vi.fn(() => [replacementSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + expect(trackChangesGetWrapper(editor, { id: 'tc-replacement-1' })).toMatchObject({ + id: 'tc-replacement-1', + type: 'replacement', + grouping: 'replacement-pair', + wordRevisionIds: { insert: '11', delete: '10' }, + }); + }); + + it('preserves overlap metadata in trackChanges.list and trackChanges.get output', () => { + const editor = makeEditor(); + const overlap = { + visualLayers: [ + { id: 'tc-parent', type: 'insert', relationship: 'parent' }, + { id: 'tc-child', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'tc-child', + preferredContextTarget: { id: 'tc-child', type: 'delete', relationship: 'child' }, + }; + const parentSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-parent' }, + runtimeRef: { storyKey: 'body', rawId: 'parent-raw' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'review', + overlap, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::parent-raw', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + const childSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-child' }, + runtimeRef: { storyKey: 'body', rawId: 'child-raw' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'review', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::child-raw', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor, + story: { kind: 'story', storyType: 'body' }, + runtimeRef: parentSnapshot.runtimeRef, + change: { + id: 'tc-parent', + rawId: 'parent-raw', + from: 4, + to: 10, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: {}, + overlap, + }, + }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [parentSnapshot, childSnapshot]), + getAll: vi.fn(() => [parentSnapshot, childSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const listResult = trackChangesListWrapper(editor); + + expect(listResult.items).toHaveLength(2); + expect(listResult.items[0]).toMatchObject({ + id: 'tc-parent', + type: 'insert', + grouping: 'standalone', + overlap, + }); + + expect(trackChangesGetWrapper(editor, { id: 'tc-parent' })).toMatchObject({ + id: 'tc-parent', + type: 'insert', + grouping: 'standalone', + overlap, + }); + }); + + it('collapses split paired replacements into one public replacement item by default', () => { const editor = makeEditor(); const insertedSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-insert' }, @@ -487,13 +633,107 @@ describe('track-changes-wrappers projected id cache', () => { const result = trackChangesListWrapper(editor); + expect(result.total).toBe(1); + expect(result.items[0]).toMatchObject({ + id: 'tc-insert', + type: 'replacement', + grouping: 'replacement-pair', + pairedWithChangeId: undefined, + insertedText: 'new', + deletedText: 'old', + }); + }); + + it('keeps split replacement sides separate in independent mode', () => { + const editor = makeEditor({}, { trackedChanges: { replacements: 'independent' } }); + const insertedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-insert' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-insert' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-insert', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#inserted', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 7 }, + }; + const deletedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-delete' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-delete' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'old', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-delete', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#deleted', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 7, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + getAll: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + expect(result.total).toBe(2); - expect(result.items.map((item) => [item.id, item.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-insert', 'replacement-pair', 'tc-delete'], - ['tc-delete', 'replacement-pair', 'tc-insert'], + expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-insert', 'insert', 'standalone', undefined], + ['tc-delete', 'delete', 'standalone', undefined], ]); }); + it('projects combined replacement snapshots as replacement even when raw type is format', () => { + const editor = makeEditor(); + const snapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-2' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-2' }, + story: { kind: 'story', storyType: 'body' }, + type: 'format', + excerpt: 'native replacement', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-2', + hasInsert: true, + hasDelete: true, + hasFormat: true, + range: { from: 10, to: 30 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [snapshot]), + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.items[0]).toMatchObject({ + id: 'tc-replacement-2', + type: 'replacement', + grouping: 'replacement-pair', + }); + }); + it('caches list ids for reuse on the same editor revision', () => { const editor = makeEditor(); const snapshot = { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index d3d22cbd4b..3d0558effb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -72,6 +72,21 @@ type ProjectedTrackedChangeCacheEntry = { }; const projectedTrackedChangeCache = new WeakMap(); +type ReplacementsMode = 'paired' | 'independent'; + +function readReplacementsMode(editor: Editor): ReplacementsMode { + return editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +} + +function buildChangedTextFields( + type: TrackChangeType, + excerpt: string | undefined, +): Pick | Record { + if (!excerpt) return {}; + if (type === 'insert') return { insertedText: excerpt }; + if (type === 'delete') return { deletedText: excerpt }; + return {}; +} function buildProjectedInfo( snapshot: TrackedChangeSnapshot, @@ -85,9 +100,6 @@ function buildProjectedInfo( ): ProjectedTrackChange { const id = options.id ?? snapshot.address.entityId; const type = options.type ?? snapshot.type; - const changedText = snapshot.excerpt - ? { [type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } - : {}; return { info: { address: { @@ -105,7 +117,7 @@ function buildProjectedInfo( authorImage: snapshot.authorImage, date: snapshot.date, excerpt: snapshot.excerpt, - ...(type === 'format' ? {} : changedText), + ...buildChangedTextFields(type, snapshot.excerpt), }, handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, snapshot, @@ -113,7 +125,7 @@ function buildProjectedInfo( } function isCombinedReplacementSnapshot(snapshot: TrackedChangeSnapshot): boolean { - return snapshot.hasInsert && snapshot.hasDelete && !snapshot.hasFormat; + return snapshot.hasInsert && snapshot.hasDelete; } function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { @@ -127,19 +139,59 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function projectedSnapshotType(snapshot: TrackedChangeSnapshot): TrackChangeType { + return isCombinedReplacementSnapshot(snapshot) ? 'replacement' : snapshot.type; +} + function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { - return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; + return isCombinedReplacementSnapshot(snapshot) ? 'replacement-pair' : 'standalone'; } function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { - return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); + return buildProjectedInfo(snapshot, { + type: projectedSnapshotType(snapshot), + grouping: snapshotGrouping(snapshot), + pairedWithChangeId: null, + }); } function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { return snapshotToProjected(snapshot).info; } -export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { +function mergeWordRevisionIdsFromPair( + inserted: TrackedChangeSnapshot, + deleted: TrackedChangeSnapshot, +): TrackChangeWordRevisionIds | undefined { + return normalizeWordRevisionIds({ + insert: inserted.wordRevisionIds?.insert, + delete: deleted.wordRevisionIds?.delete, + format: inserted.wordRevisionIds?.format ?? deleted.wordRevisionIds?.format, + }); +} + +function projectSplitReplacementPair(group: ReadonlyArray): ProjectedTrackChange | null { + const inserted = group.find((snapshot) => snapshot.type === 'insert'); + const deleted = group.find((snapshot) => snapshot.type === 'delete'); + if (!inserted || !deleted) return null; + + const projected = buildProjectedInfo(inserted, { + type: 'replacement', + grouping: 'replacement-pair', + pairedWithChangeId: null, + }); + + projected.info.wordRevisionIds = mergeWordRevisionIdsFromPair(inserted, deleted); + projected.info.insertedText = inserted.excerpt; + projected.info.deletedText = deleted.excerpt; + + return projected; +} + +export function projectSnapshots( + snapshots: ReadonlyArray, + replacements: ReplacementsMode = 'paired', +): ProjectedTrackChange[] { const byPairKey = new Map(); for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) continue; @@ -150,29 +202,35 @@ export function projectSnapshots(snapshots: ReadonlyArray byPairKey.set(key, group); } - const pairedById = new Map(); - for (const group of byPairKey.values()) { - const inserts = group.filter((snapshot) => snapshot.type === 'insert'); - const deletes = group.filter((snapshot) => snapshot.type === 'delete'); - if (inserts.length !== 1 || deletes.length !== 1) continue; - pairedById.set(inserts[0].address.entityId, deletes[0].address.entityId); - pairedById.set(deletes[0].address.entityId, inserts[0].address.entityId); + const collapsedByPairKey = new Map(); + if (replacements === 'paired') { + for (const [key, group] of byPairKey.entries()) { + const collapsed = projectSplitReplacementPair(group); + if (collapsed) collapsedByPairKey.set(key, collapsed); + } } const projected: ProjectedTrackChange[] = []; + const emittedPairKeys = new Set(); for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { projected.push(snapshotToProjected(snapshot)); continue; } - const pairedWithChangeId = pairedById.get(snapshot.address.entityId) ?? null; - projected.push( - buildProjectedInfo(snapshot, { - grouping: pairedWithChangeId ? 'replacement-pair' : 'standalone', - pairedWithChangeId, - }), - ); + const pairKey = replacementPairKey(snapshot); + if (pairKey && replacements === 'paired') { + const collapsed = collapsedByPairKey.get(pairKey); + if (collapsed) { + if (!emittedPairKeys.has(pairKey)) { + emittedPairKeys.add(pairKey); + projected.push(collapsed); + } + continue; + } + } + + projected.push(buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null })); } return projected; @@ -263,14 +321,14 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList rawSnapshots = index.get(scope.story); } - const projected = projectSnapshots(rawSnapshots); + const projected = projectSnapshots(rawSnapshots, readReplacementsMode(editor)); const evaluatedRevision = getRevision(editor); cacheProjectedTrackedChanges(editor, projected, evaluatedRevision); const filtered = filterProjectedByType(projected, input?.type); const paged = paginate(filtered, input?.offset, input?.limit); // Track-changes discovery uses a document-level revision token across every // scope. Part commits also advance the host revision, so one shared token - // correctly guards body, story-scoped, and aggregate review flows. + // correctly guards body, story-scoped, and replacement-aware review flows. const items = paged.items.map((row) => { const info = row.info; @@ -332,7 +390,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp const anchorKey = makeTrackedChangeAnchorKey(resolved.runtimeRef); const snapshots = storyKey === BODY_STORY_KEY ? index.get({ kind: 'story', storyType: 'body' }) : index.get(resolved.story); - const projected = projectSnapshots(snapshots); + const projected = projectSnapshots(snapshots, readReplacementsMode(editor)); const projectedMatch = projected.find((row) => row.info.id === id); if (projectedMatch) return projectedMatch.info; @@ -346,7 +404,10 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp const excerpt = (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')); - const changedText = excerpt ? { [type === 'delete' ? 'deletedText' : 'insertedText']: excerpt } : {}; + const grouping = + resolved.change.hasInsert && resolved.change.hasDelete && !resolved.change.hasFormat + ? 'replacement-pair' + : undefined; return { address: { @@ -357,6 +418,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp }, id: resolved.change.id, type, + grouping, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), overlap: resolved.change.overlap, author: toNonEmptyString(resolved.change.attrs.author), @@ -364,7 +426,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp authorImage: toNonEmptyString(resolved.change.attrs.authorImage), date: toNonEmptyString(resolved.change.attrs.date), excerpt, - ...(type === 'format' ? {} : changedText), + ...buildChangedTextFields(type, excerpt), }; } diff --git a/packages/super-editor/src/ui/entity-at.test.ts b/packages/super-editor/src/ui/entity-at.test.ts index 1e42b6cd19..91eba0565d 100644 --- a/packages/super-editor/src/ui/entity-at.test.ts +++ b/packages/super-editor/src/ui/entity-at.test.ts @@ -17,6 +17,8 @@ import type { */ type ChainLayer = { trackChangeId?: string; + trackChangeIds?: string; + trackChangePreferredTargetId?: string; commentIds?: string; sdtId?: string; sdtType?: string; @@ -26,6 +28,10 @@ type ChainLayer = { function applyLayer(el: HTMLElement, layer: ChainLayer): void { if (layer.trackChangeId) el.dataset.trackChangeId = layer.trackChangeId; + if (layer.trackChangeIds) el.dataset.trackChangeIds = layer.trackChangeIds; + if (layer.trackChangePreferredTargetId) { + el.dataset.trackChangePreferredTargetId = layer.trackChangePreferredTargetId; + } if (layer.commentIds) el.dataset.commentIds = layer.commentIds; if (layer.sdtId) el.dataset.sdtId = layer.sdtId; if (layer.sdtType) el.dataset.sdtType = layer.sdtType; @@ -59,6 +65,40 @@ describe('collectEntityHitsFromChain', () => { ]); }); + it('orders the preferred tracked-change target before remaining multi-layer ids', () => { + const inner = buildPaintedChain([ + { trackChangeIds: 'ins-parent,del-child', trackChangePreferredTargetId: 'del-child' }, + ]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'del-child' }, + { type: 'trackedChange', id: 'ins-parent' }, + ]); + }); + + it('falls back to legacy single tracked-change id data attributes', () => { + const inner = buildPaintedChain([{ trackChangeId: 'legacy-tc' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'trackedChange', id: 'legacy-tc' }]); + }); + + it('falls back to the legacy id when the multi-layer tracked-change list is empty', () => { + const inner = buildPaintedChain([{ trackChangeId: 'legacy-tc' }]); + inner.dataset.trackChangeIds = ',,'; + + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'trackedChange', id: 'legacy-tc' }]); + }); + + it('skips empty tracked-change ids and deduplicates malformed comma lists across the chain', () => { + const inner = buildPaintedChain([{ trackChangeIds: ',tc-1,,tc-2,tc-1,' }, { trackChangeIds: 'tc-2,tc-3' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'tc-1' }, + { type: 'trackedChange', id: 'tc-2' }, + { type: 'trackedChange', id: 'tc-3' }, + ]); + }); + it('expands comma-separated comment ids into one hit per id', () => { const inner = buildPaintedChain([{ commentIds: 'c-1,c-2,c-3' }]); @@ -85,6 +125,21 @@ describe('collectEntityHitsFromChain', () => { ]); }); + it('keeps inner multi-layer tracked changes before outer comments and content controls', () => { + const inner = buildPaintedChain([ + { trackChangeIds: 'ins-parent,del-child', trackChangePreferredTargetId: 'del-child' }, + { commentIds: 'c-outer' }, + { sdtId: 'sdt-outer', sdtType: 'structuredContent', sdtScope: 'inline' }, + ]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'del-child' }, + { type: 'trackedChange', id: 'ins-parent' }, + { type: 'comment', id: 'c-outer' }, + { type: 'contentControl', id: 'sdt-outer', scope: 'inline' }, + ]); + }); + it('returns [] when the chain has no painted entities', () => { const inner = buildPaintedChain([{}]); diff --git a/packages/super-editor/src/ui/entity-at.ts b/packages/super-editor/src/ui/entity-at.ts index baa4841e96..c7862107f3 100644 --- a/packages/super-editor/src/ui/entity-at.ts +++ b/packages/super-editor/src/ui/entity-at.ts @@ -10,6 +10,29 @@ import type { ViewportEntityHit } from './types.js'; +function parseCommaSeparatedIds(value: string): string[] { + return value + .split(',') + .map((id) => id.trim()) + .filter(Boolean); +} + +function getTrackChangeIds(node: { getAttribute(name: string): string | null }): string[] { + const trackChangeIds = node.getAttribute('data-track-change-ids'); + if (trackChangeIds !== null) { + const ids = parseCommaSeparatedIds(trackChangeIds); + if (ids.length > 0) return ids; + } + + const trackChangeId = node.getAttribute('data-track-change-id'); + return trackChangeId ? [trackChangeId] : []; +} + +function orderTrackChangeIds(ids: string[], preferredTargetId: string | null): string[] { + if (!preferredTargetId || !ids.includes(preferredTargetId)) return ids; + return [preferredTargetId, ...ids.filter((id) => id !== preferredTargetId)]; +} + /** * Read painted entities off `el` and every ancestor up to the document * root. Innermost-first ordering: a tracked change inside a comment @@ -31,8 +54,11 @@ export function collectEntityHitsFromChain(start: Element | null): ViewportEntit let el: Element | null = start; while (el) { const node = el as { getAttribute(name: string): string | null }; - const trackChangeId = node.getAttribute('data-track-change-id'); - if (trackChangeId) { + const trackChangeIds = orderTrackChangeIds( + getTrackChangeIds(node), + node.getAttribute('data-track-change-preferred-target-id'), + ); + for (const trackChangeId of trackChangeIds) { const key = `trackedChange:${trackChangeId}`; if (!seen.has(key)) { seen.add(key); diff --git a/tests/behavior/helpers/story-tracked-changes.ts b/tests/behavior/helpers/story-tracked-changes.ts index 2513b16035..eab646ea11 100644 --- a/tests/behavior/helpers/story-tracked-changes.ts +++ b/tests/behavior/helpers/story-tracked-changes.ts @@ -23,11 +23,36 @@ function normalizeTrackedChangeExcerpt(change: TrackChangeInfo): string { return String(change.excerpt ?? '').trim(); } -function mapTrackChangeTypeToCommentType(type: TrackChangeType | undefined): string | null { - if (!type) return null; - if (type === 'insert') return 'trackInsert'; - if (type === 'delete') return 'trackDelete'; - return 'trackFormat'; +function matchesTrackedChangeCommentType( + comment: TrackedChangeCommentSnapshot, + type: TrackChangeType | undefined, +): boolean { + if (!type) return true; + + const trackedChangeType = comment.trackedChangeType ?? null; + const trackedChangeDisplayType = comment.trackedChangeDisplayType ?? null; + if (type === 'insert') { + return ( + trackedChangeType === 'trackInsert' || trackedChangeType === 'insert' || trackedChangeDisplayType === 'insert' + ); + } + if (type === 'delete') { + return ( + trackedChangeType === 'trackDelete' || trackedChangeType === 'delete' || trackedChangeDisplayType === 'delete' + ); + } + if (type === 'replacement') { + return ( + trackedChangeType === 'replacement' || + trackedChangeType === 'both' || + trackedChangeDisplayType === 'replacement' || + ((trackedChangeType === 'trackInsert' || + trackedChangeType === 'insert' || + trackedChangeDisplayType === 'insert') && + comment.deletedText != null) + ); + } + return trackedChangeType === 'trackFormat' || trackedChangeType === 'format' || trackedChangeDisplayType === 'format'; } function sameStory(left: StoryLocator | null | undefined, right: StoryLocator | null | undefined): boolean { @@ -110,13 +135,12 @@ export async function findTrackedChangeComment( type?: TrackChangeType; }, ): Promise { - const commentType = mapTrackChangeTypeToCommentType(input.type); const comments = await getCommentsSnapshot(page); const matched = comments.find((comment) => { if (comment.trackedChange !== true) return false; if (!sameStory(comment.trackedChangeStory ?? null, input.story)) return false; if (input.id && !trackedChangeIdMatches(comment, input.id)) return false; - if (commentType && comment.trackedChangeType !== commentType) return false; + if (!matchesTrackedChangeCommentType(comment, input.type)) return false; if (input.excerpt) { const haystack = [comment.trackedChangeText, comment.deletedText].filter(Boolean).join(' '); if (!haystack.includes(input.excerpt)) return false; diff --git a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts index 5bb8420cb4..b149e4ae36 100644 --- a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts +++ b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts @@ -16,7 +16,7 @@ test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }) async function assertTrackChangeTypeCount( superdoc: { page: Page }, - type: 'insert' | 'delete' | 'format', + type: 'insert' | 'delete' | 'replacement' | 'format', minimumCount = 1, ): Promise { await expect @@ -61,12 +61,12 @@ test('tracked replace via document-api', async ({ superdoc }) => { await superdoc.waitForStable(); // word-diff (PR #2817) fragments multi-word tracked replacements into per-word - // insert/delete pairs, so "new fancy" appears as two separate inserts around + // replacement chunks, so "new fancy" appears as separate replacements around // the surviving space token. Assert both inserted words are present rather // than a contiguous substring, which was the pre-word-diff assumption. await expect.poll(() => getDocumentText(superdoc.page)).toContain('new'); await expect.poll(() => getDocumentText(superdoc.page)).toContain('fancy'); - await assertTrackChangeTypeCount(superdoc, 'insert'); + await assertTrackChangeTypeCount(superdoc, 'replacement'); await superdoc.snapshot('programmatic-tc-replaced'); }); diff --git a/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts b/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts index da6be9553f..1853b7973d 100644 --- a/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts +++ b/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts @@ -21,7 +21,7 @@ test('SD-2509 replacement bubble preserves deleted text after follow-up edits', // Wait for the tracked change to appear await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total) .toBeGreaterThanOrEqual(1); // The bubble should show both the deleted and inserted text diff --git a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts index 2b7a5f02cf..42006b6f32 100644 --- a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts @@ -1,8 +1,11 @@ import { test, expect } from '../../fixtures/superdoc.js'; import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); +const BODY_STORY = { kind: 'story', storyType: 'body' } as const; + test('SD-1739 tracked change replacement does not duplicate text in bubble', async ({ superdoc }) => { await assertDocumentApiReady(superdoc.page); @@ -19,10 +22,17 @@ test('SD-1739 tracked change replacement does not duplicate text in bubble', asy await superdoc.waitForStable(); await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total) .toBeGreaterThanOrEqual(1); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); + const replacementComment = await findTrackedChangeComment(superdoc.page, { + story: BODY_STORY, + excerpt: 'redlining', + type: 'replacement', + }); + expect(replacementComment.deletedText).toContain('editing'); + // The floating dialog should show the tracked change with correct text // (Bug SD-1739 would show "Added: redliningg" with duplicated trailing char) const dialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { diff --git a/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts b/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts index b972adf55d..3949014d87 100644 --- a/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts @@ -41,7 +41,7 @@ test('typing inside an existing tracked replacement refreshes inserted text and await superdoc.type('replacement'); await superdoc.waitForStable(); - await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total).toBe(1); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total).toBe(1); await expect(insertedBubbleText(superdoc)).toContainText('replacement'); await expect(deletedBubbleText(superdoc)).toContainText('original'); From be86fb7566d6b1fc94e6824fded3d13b2941acc5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 14:39:47 -0700 Subject: [PATCH 027/280] fix: add ui for overlapping delete, other fixes --- apps/docs/document-api/migration.mdx | 6 +- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- .../reference/track-changes/decide.mdx | 40 +++++- apps/docs/document-engine/sdks.mdx | 4 +- apps/mcp/src/generated/catalog.ts | 36 +++++- .../src/contract/operation-definitions.ts | 12 +- packages/document-api/src/contract/schemas.ts | 21 +++- packages/document-api/src/index.test.ts | 23 +++- .../src/track-changes/track-changes.test.ts | 28 +++++ .../src/track-changes/track-changes.ts | 119 +++++++++++------- .../plan-engine/comments-wrappers.test.ts | 43 ++----- .../plan-engine/comments-wrappers.ts | 68 +--------- .../track-changes-wrappers.test.ts | 47 +++++++ .../plan-engine/track-changes-wrappers.ts | 24 ++-- .../extensions/track-changes/track-changes.js | 1 + .../behavior/tests/navigation/extract.spec.ts | 2 +- 17 files changed, 312 insertions(+), 166 deletions(-) diff --git a/apps/docs/document-api/migration.mdx b/apps/docs/document-api/migration.mdx index ee609c342f..daaae7b46d 100644 --- a/apps/docs/document-api/migration.mdx +++ b/apps/docs/document-api/migration.mdx @@ -1,7 +1,7 @@ --- title: Migrate to the Document API sidebarTitle: Migrate to Document API -keywords: "migrate commands, document api migration, editor commands deprecated, prosemirror deprecated, editor.doc" +keywords: 'migrate commands, document api migration, editor commands deprecated, prosemirror deprecated, editor.doc' --- `editor.commands`, `editor.state`, `editor.view`, and direct ProseMirror access are deprecated and will be removed in a future version. The [Document API](/document-api/overview) (`editor.doc`) is the replacement for all programmatic document operations. @@ -101,8 +101,8 @@ editor.commands.rejectTrackedChange(changeId); // After editor.doc.trackChanges.list(); -editor.doc.trackChanges.decide({ id: changeId, decision: 'accept' }); -editor.doc.trackChanges.decide({ id: changeId, decision: 'reject' }); +editor.doc.trackChanges.decide({ decision: 'accept', target: { id: changeId } }); +editor.doc.trackChanges.decide({ decision: 'reject', target: { id: changeId } }); ``` ### Tables diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 6ad45e09d8..28cbaa4786 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1079,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "1a411ac1bb94bc869de87173008a88a06a95f73dbf3bbc0906ec420af73ee88d" + "sourceHash": "b154213fe6dbcb96de30f11c473aca2946148af73775fbb642b5c750c6a0bc46" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index f3a0afcbee..e590891c7c 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -244,7 +244,7 @@ The tables below are grouped by namespace. | --- | --- | --- | | trackChanges.list | editor.doc.trackChanges.list(...) | List all tracked changes in the document. | | trackChanges.get | editor.doc.trackChanges.get(...) | Retrieve a single tracked change by ID. | -| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject tracked changes by ID, range, or scope: all. | +| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### Query diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index 4cc7f7de74..e1f3723ca9 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -1,14 +1,14 @@ --- title: trackChanges.decide sidebarTitle: trackChanges.decide -description: "Accept or reject tracked changes by ID, range, or scope: all." +description: "Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story)." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Accept or reject tracked changes by ID, range, or scope: all. +Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). - Operation ID: `trackChanges.decide` - API member path: `editor.doc.trackChanges.decide(...)` @@ -27,7 +27,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | enum | yes | `"accept"`, `"reject"` | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \\| object(kind="range") \\| object | yes | One of: object, object(kind="range"), object | ### Example request @@ -134,6 +134,29 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "range" + }, + "part": { + "description": "Optional part discriminator for the range target.", + "type": "string" + }, + "range": { + "$ref": "#/$defs/TextTarget" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + } + }, + "required": [ + "kind", + "range" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -141,6 +164,17 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "enum": [ "all" ] + }, + "story": { + "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "const": "all" + } + ] } }, "required": [ diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 1d2513fd33..1954e2b2e5 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1007,7 +1007,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.trackChanges.list` | `track-changes list` | List all tracked changes in the document. | | `doc.trackChanges.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all. | +| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### History @@ -1486,7 +1486,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.track_changes.list` | `track-changes list` | List all tracked changes in the document. | | `doc.track_changes.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.track_changes.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all. | +| `doc.track_changes.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### History diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 26ec5cffd8..7b994255c4 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2423,7 +2423,7 @@ export const MCP_TOOL_CATALOG = { { toolName: 'superdoc_track_changes', description: - 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""} or all changes at once with {scope:"all"}. Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"scope":"all"}}', + 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"kind":"range","range":{"kind":"text","segments":[{"blockId":"","range":{"start":0,"end":5}}]}}}\n 5. {"action":"decide","decision":"reject","target":{"scope":"all"}}', inputSchema: { type: 'object', properties: { @@ -2475,12 +2475,46 @@ export const MCP_TOOL_CATALOG = { additionalProperties: false, required: ['id'], }, + { + type: 'object', + properties: { + kind: { + const: 'range', + type: 'string', + }, + range: { + $ref: '#/$defs/TextTarget', + }, + story: { + $ref: '#/$defs/StoryLocator', + }, + part: { + type: 'string', + description: 'Optional part discriminator for the range target.', + }, + }, + additionalProperties: false, + required: ['kind', 'range'], + }, { type: 'object', properties: { scope: { enum: ['all'], }, + story: { + oneOf: [ + { + $ref: '#/$defs/StoryLocator', + }, + { + const: 'all', + type: 'string', + }, + ], + description: + "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + }, }, additionalProperties: false, required: ['scope'], diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index c2bf48f17a..5214f96fa8 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -419,12 +419,20 @@ export const INTENT_GROUP_META: Record = { 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. ' + 'Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. ' + 'Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. ' + - 'Target a single change with {id:""} or all changes at once with {scope:"all"}. ' + + 'Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). ' + 'Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.', inputExamples: [ { action: 'list' }, { action: 'list', type: 'replacement', limit: 10 }, { action: 'decide', decision: 'accept', target: { id: '' } }, + { + action: 'decide', + decision: 'reject', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: '', range: { start: 0, end: 5 } }] }, + }, + }, { action: 'decide', decision: 'reject', target: { scope: 'all' } }, ], }, @@ -2499,7 +2507,7 @@ export const OPERATION_DEFINITIONS = { }, 'trackChanges.decide': { memberPath: 'trackChanges.decide', - description: 'Accept or reject tracked changes by ID, range, or scope: all.', + description: 'Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story).', expectedResult: 'Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved and typed failures for unsupported or denied tracked-change decisions.', requiresDocumentContext: true, diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 9f902ca0dc..6d4ab5188a 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -5048,7 +5048,26 @@ const operationSchemas: Record = { target: { oneOf: [ objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), - objectSchema({ scope: { enum: ['all'] } }, ['scope']), + objectSchema( + { + kind: { const: 'range' }, + range: textTargetSchema, + story: storyLocatorSchema, + part: { type: 'string', description: 'Optional part discriminator for the range target.' }, + }, + ['kind', 'range'], + ), + objectSchema( + { + scope: { enum: ['all'] }, + story: { + oneOf: [storyLocatorSchema, { const: 'all' }], + description: + "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + }, + }, + ['scope'], + ), ], }, }, diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 3644439682..8e35d8bdd0 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -891,11 +891,16 @@ describe('createDocumentApi', () => { }, }); const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } }); + const acceptAllInStoryResult = api.trackChanges.decide({ + decision: 'accept', + target: { scope: 'all', story: footnoteStory }, + }); const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); expect(acceptResult.success).toBe(true); expect(rejectResult.success).toBe(true); expect(acceptAllResult.success).toBe(true); + expect(acceptAllInStoryResult.success).toBe(true); expect(rejectAllResult.success).toBe(true); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); @@ -909,6 +914,7 @@ describe('createDocumentApi', () => { undefined, ); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); + expect(trackAdpt.acceptAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); @@ -1050,7 +1056,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: 'tc-1' } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1059,7 +1065,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: null } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1068,7 +1074,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { foo: 'bar' } } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1077,7 +1083,16 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { id: '' } } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + 'non-empty id', + ); + }); + + it('rejects ambiguous targets that mix id and scope', () => { + const api = makeApi(); + expectError( + () => api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1', scope: 'all' } } as any), + 'INVALID_TARGET', + 'exactly one', ); }); }); diff --git a/packages/document-api/src/track-changes/track-changes.test.ts b/packages/document-api/src/track-changes/track-changes.test.ts index 8e1a65f9d6..12cb75bc73 100644 --- a/packages/document-api/src/track-changes/track-changes.test.ts +++ b/packages/document-api/src/track-changes/track-changes.test.ts @@ -88,4 +88,32 @@ describe('executeTrackChangesDecide validation', () => { failure: { code: 'CAPABILITY_UNAVAILABLE' }, }); }); + + it('routes scope: "all" targets with an explicit story filter to acceptAll/rejectAll', () => { + const adapter = stubAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; + + const accept = executeTrackChangesDecide(adapter, { + decision: 'accept', + target: { scope: 'all', story: footnoteStory }, + }); + const reject = executeTrackChangesDecide(adapter, { + decision: 'reject', + target: { scope: 'all', story: footnoteStory }, + }); + + expect(accept.success).toBe(true); + expect(reject.success).toBe(true); + expect(adapter.acceptAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); + expect(adapter.rejectAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); + }); + + it('rejects ambiguous targets that mix id and scope', () => { + expect(() => + executeTrackChangesDecide(stubAdapter(), { + decision: 'accept', + target: { id: 'tc1', scope: 'all' }, + } as any), + ).toThrow(/exactly one/); + }); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index e7b74b47fa..d4734b1ce1 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -3,6 +3,7 @@ import type { StoryLocator } from '../types/story.types.js'; import type { TextTarget } from '../types/address.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; +import { validateStoryLocator } from '../validation/story-validator.js'; export type TrackChangesListInput = TrackChangesListQuery; @@ -24,9 +25,23 @@ export interface TrackChangesRejectInput { story?: StoryLocator; } -export type TrackChangesAcceptAllInput = Record; +export interface TrackChangesAcceptAllInput { + /** + * Optional explicit bulk filter. Omit or pass `'all'` to operate across + * every revision-capable story; pass a StoryLocator to scope the decision + * to one story. + */ + story?: StoryLocator | 'all'; +} -export type TrackChangesRejectAllInput = Record; +export interface TrackChangesRejectAllInput { + /** + * Optional explicit bulk filter. Omit or pass `'all'` to operate across + * every revision-capable story; pass a StoryLocator to scope the decision + * to one story. + */ + story?: StoryLocator | 'all'; +} /** * Range target for partial-range decisions. @@ -47,20 +62,18 @@ export interface TrackChangesRangeInput { // --------------------------------------------------------------------------- /** - * Canonical decide input shape per - * `../labs/tests/requirements/specs/tracked-changes-comments/tracked-changes-spec.md` - * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during - * the migration window so existing headless callers keep working; the executor - * normalizes them into the canonical `{ kind: ... }` form before dispatch. + * Public decide target surface: + * - `{ id, story? }` for a single logical tracked change + * - `{ kind: 'range', range, story? }` for partial-range decisions + * - `{ scope: 'all', story? }` for bulk decisions, optionally filtered by story + * + * The executor also accepts internal legacy aliases (`kind: 'id'` / + * `kind: 'all'`) so JS-only callers keep working during the migration. */ export type ReviewDecisionTarget = - | { kind: 'id'; id: string; story?: StoryLocator } + | { id: string; story?: StoryLocator } | { kind: 'range'; range: TextTarget; story?: StoryLocator; part?: string } - | { kind: 'all'; story?: StoryLocator | 'all' } - // Legacy aliases — kept for backwards compatibility with the previous - // call shape. Emitted as deprecation diagnostics during normalization. - | { id: string; story?: StoryLocator; kind?: undefined } - | { scope: 'all'; kind?: undefined }; + | { scope: 'all'; story?: StoryLocator | 'all' }; export type ReviewDecideInput = | { decision: 'accept'; target: ReviewDecisionTarget } @@ -75,9 +88,9 @@ export interface TrackChangesAdapter { accept(input: TrackChangesAcceptInput, options?: RevisionGuardOptions): Receipt; /** Reject a tracked change, reverting it from the document. */ reject(input: TrackChangesRejectInput, options?: RevisionGuardOptions): Receipt; - /** Accept all tracked changes in the document. */ + /** Accept all tracked changes matching the requested bulk filter. */ acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt; - /** Reject all tracked changes in the document. */ + /** Reject all tracked changes matching the requested bulk filter. */ rejectAll(input: TrackChangesRejectAllInput, options?: RevisionGuardOptions): Receipt; /** * Accept or reject a tracked-change selection range. Adapters @@ -162,29 +175,45 @@ export function executeTrackChangesDecide( if (typeof input.target !== 'object' || input.target == null) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must be an object with { kind: "id" | "range" | "all" }.', + 'trackChanges.decide target must be an object with { id }, { kind: "range", range }, or { scope: "all" }.', { field: 'target', value: input.target }, ); } const target = input.target as Record; - const story = (target as { story?: StoryLocator }).story; const decision = input.decision as 'accept' | 'reject'; + const rawStory = target.story; - // Canonical shape: `{ kind: 'id' | 'range' | 'all' }`. - if (target.kind === 'id') { - if (typeof target.id !== 'string' || target.id.length === 0) { + if (rawStory !== undefined && rawStory !== 'all') { + validateStoryLocator(rawStory, 'target.story'); + } + + const story = rawStory as StoryLocator | undefined; + const bulkStory = rawStory as StoryLocator | 'all' | undefined; + + if ((target.scope === 'all' || target.kind === 'all') && (target.id !== undefined || target.kind === 'id')) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must specify exactly one of { id }, { kind: "range", range }, or { scope: "all" }.', + { field: 'target', value: input.target }, + ); + } + + if (target.kind === 'range') { + if (target.id !== undefined || target.scope !== undefined) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target.kind = "id" requires a non-empty id.', + 'trackChanges.decide range targets must not include id or scope fields.', { field: 'target', value: input.target }, ); } - if (decision === 'accept') return adapter.accept({ id: target.id, ...(story ? { story } : {}) }, options); - return adapter.reject({ id: target.id, ...(story ? { story } : {}) }, options); - } - - if (target.kind === 'range') { + if (rawStory === 'all') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide range targets do not support story: "all".', + { field: 'target.story', value: rawStory }, + ); + } if (typeof adapter.decideRange !== 'function') { return { success: false, @@ -212,28 +241,34 @@ export function executeTrackChangesDecide( return adapter.decideRange({ decision, range, ...(story ? { story } : {}) }, options); } - if (target.kind === 'all') { - if (decision === 'accept') return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.rejectAll({} as TrackChangesRejectAllInput, options); + if (target.scope === 'all' || target.kind === 'all') { + if (decision === 'accept') { + return adapter.acceptAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); + } + return adapter.rejectAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); } - // Legacy aliases — `{ id }` / `{ scope: 'all' }`. Preserved for backwards - // compatibility per the closed product decision in `phase0-checkpoint.md`. - const isAll = target.scope === 'all'; - if (!isAll) { - if (typeof target.id !== 'string' || target.id.length === 0) { + if (target.kind === 'id' || target.id !== undefined) { + if (rawStory === 'all') { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must have { kind: "id" | "range" | "all" } or the legacy { id } / { scope: "all" } shape.', - { field: 'target', value: input.target }, + 'trackChanges.decide id targets do not support story: "all".', + { field: 'target.story', value: rawStory }, ); } + if (typeof target.id !== 'string' || target.id.length === 0) { + throw new DocumentApiValidationError('INVALID_TARGET', 'trackChanges.decide id targets require a non-empty id.', { + field: 'target', + value: input.target, + }); + } + if (decision === 'accept') return adapter.accept({ id: target.id, ...(story ? { story } : {}) }, options); + return adapter.reject({ id: target.id, ...(story ? { story } : {}) }, options); } - if (decision === 'accept') { - if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); - } - if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); - return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must have { id }, { kind: "range", range }, or { scope: "all" }.', + { field: 'target', value: input.target }, + ); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index 3cb7828b68..8e0b5d911d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -759,7 +759,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 19, to: 27 }); }); - it('creates a tracked-change-linked comment without a text selection when a deletion target cannot be resolved live', () => { + it('fails closed when a tracked-change target cannot be resolved live', () => { const editor = { ...makeWriteEditor(), emit: vi.fn(), @@ -777,39 +777,18 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { }); const wrapper = createCommentsWrapper(editor); - const receipt = wrapper.add({ - text: 'comment on deletion', - target: { - trackedChangeId: 'tc-del-1#deleted', - } as Parameters[0]['target'], - }); - - expect(receipt.success).toBe(true); + expect(() => + wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1#deleted', + } as Parameters[0]['target'], + }), + ).toThrowError(/Comment target could not be resolved/); expect(editor.commands!.setTextSelection as ReturnType).not.toHaveBeenCalled(); expect(editor.commands!.addComment as ReturnType).not.toHaveBeenCalled(); - expect(editor.converter!.comments).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - commentId: receipt.id, - commentText: 'comment on deletion', - trackedChange: true, - trackedChangeParentId: 'tc-del-1', - trackedChangeType: 'delete', - }), - ]), - ); - expect((editor as { emit: ReturnType }).emit).toHaveBeenCalledWith( - 'commentsUpdate', - expect.objectContaining({ - type: 'add', - activeCommentId: receipt.id, - comment: expect.objectContaining({ - commentId: receipt.id, - trackedChangeParentId: 'tc-del-1', - trackedChangeType: 'delete', - }), - }), - ); + expect(editor.converter!.comments).toEqual([]); + expect((editor as { emit: ReturnType }).emit).not.toHaveBeenCalled(); }); it('treats a TextAddress with an undefined `segments` field as TextAddress, not TextTarget', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index 08539ad15d..4e7a47e952 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -61,7 +61,7 @@ import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; -import { resolveTrackedChangeInStory, splitProjectedTrackedChangeId } from '../helpers/tracked-change-resolver.js'; +import { resolveTrackedChangeInStory } from '../helpers/tracked-change-resolver.js'; import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- @@ -343,58 +343,6 @@ function applyTextSelection(editor: Editor, from: number, to: number): boolean { return false; } -function addDetachedTrackedChangeComment( - editor: Editor, - input: AddCommentInput, - target: Extract, - options?: RevisionGuardOptions, -): CommentsCreateReceipt { - if (options?.expectedRevision) { - checkRevision(editor, options.expectedRevision); - } - - const commentId = uuidv4(); - const now = Date.now(); - const store = getCommentEntityStore(editor); - const user = (editor.options?.user ?? {}) as EditorUserIdentity; - const { baseId, side } = splitProjectedTrackedChangeId(target.trackedChangeId); - const trackedChangeType = side === 'deleted' ? 'delete' : side === 'inserted' ? 'insert' : null; - const trackedChangeStory = target.story ?? ({ kind: 'story', storyType: 'body' } as StoryLocator); - - upsertCommentEntity(store, commentId, { - commentId, - commentText: input.text, - commentJSON: buildCommentJsonFromText(input.text), - parentCommentId: undefined, - createdTime: now, - creatorName: user.name, - creatorEmail: user.email, - creatorImage: user.image, - isDone: false, - isInternal: false, - fileId: editor.options?.documentId, - documentId: editor.options?.documentId, - trackedChange: true, - trackedChangeParentId: baseId, - trackedChangeType, - trackedChangeDisplayType: null, - trackedChangeStory, - trackedChangeStoryKind: trackedChangeStory.kind === 'story' ? trackedChangeStory.storyType : null, - trackedChangeStoryLabel: - trackedChangeStory.kind === 'story' && trackedChangeStory.storyType === 'body' ? 'Body' : null, - trackedChangeAnchorKey: null, - trackedChangeText: trackedChangeType === 'delete' ? '' : null, - deletedText: trackedChangeType === 'delete' ? '' : null, - }); - - const stored = findCommentEntity(store, commentId); - if (stored) { - emitCommentAdd(editor, buildCommentLifecyclePayload(stored), commentId); - } - - return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; -} - function resolveCommentIdentity( editor: Editor, commentId: string, @@ -1081,19 +1029,7 @@ function addCommentHandler( }, }; } - let resolvedTarget: CommentTargetResolution; - try { - resolvedTarget = resolveCommentTarget(editor, target); - } catch (error) { - if ( - isTrackedChangeCommentTargetShape(target) && - error instanceof DocumentApiAdapterError && - error.code === 'TARGET_NOT_FOUND' - ) { - return addDetachedTrackedChangeComment(editor, input, target, options); - } - throw error; - } + const resolvedTarget = resolveCommentTarget(editor, target); if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index b28622ffbe..16bdbb0981 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -271,6 +271,53 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); }); + it('scopes accept-all to the requested story when a bulk story filter is provided', () => { + const hostEditor = makeEditor(); + const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const footnoteEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const bodyCommit = vi.fn(); + const footnoteCommit = vi.fn(); + + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const snapshots = [ + { + story: bodyStory, + runtimeRef: { storyKey: 'body', rawId: 'raw-body' }, + }, + { + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-fn' }, + }, + ]; + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => snapshots), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.getTrackedChangeIndex.mockReturnValue(index); + mocks.resolveStoryRuntime.mockImplementation((_host: Editor, story: StoryLocator) => { + if (story.storyType === 'body') { + return { editor: bodyEditor, storyKey: 'body', locator: story, kind: 'body', commit: bodyCommit }; + } + + return { editor: footnoteEditor, storyKey: 'fn:5', locator: story, kind: 'note', commit: footnoteCommit }; + }); + + const receipt = trackChangesAcceptAllWrapper(hostEditor, { story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(mocks.executeDomainCommand).toHaveBeenCalledTimes(1); + expect(mocks.executeDomainCommand).toHaveBeenCalledWith(footnoteEditor, expect.any(Function)); + expect(bodyCommit).not.toHaveBeenCalled(); + expect(footnoteCommit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledTimes(1); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); + it('resolves range targets against v1 sdBlockId attributes', () => { const acceptTrackedChangesBetween = vi.fn(() => true); const invalidate = vi.fn(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 3d0558effb..6d3822507d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -501,17 +501,27 @@ export function trackChangesRejectWrapper( return decideSingle(editor, 'reject', input.id, input.story, options); } -function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGuardOptions | undefined): Receipt { +function decideAll( + editor: Editor, + decision: ReviewDecision, + input: TrackChangesAcceptAllInput | TrackChangesRejectAllInput, + options: RevisionGuardOptions | undefined, +): Receipt { const index = getTrackedChangeIndex(editor); + const requestedStoryKey = input.story && input.story !== 'all' ? buildStoryKey(input.story) : null; const allSnapshots = index.getAll(); - if (allSnapshots.length === 0) { + const matchingSnapshots = requestedStoryKey + ? allSnapshots.filter((snapshot) => snapshot.runtimeRef.storyKey === requestedStoryKey) + : allSnapshots; + + if (matchingSnapshots.length === 0) { return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); } checkRevision(editor, options?.expectedRevision); const byStoryKey = new Map(); - for (const snapshot of allSnapshots) { + for (const snapshot of matchingSnapshots) { const key = snapshot.runtimeRef.storyKey; const entry = byStoryKey.get(key); if (entry) { @@ -569,18 +579,18 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu export function trackChangesAcceptAllWrapper( editor: Editor, - _input: TrackChangesAcceptAllInput, + input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions, ): Receipt { - return decideAll(editor, 'accept', options); + return decideAll(editor, 'accept', input, options); } export function trackChangesRejectAllWrapper( editor: Editor, - _input: TrackChangesRejectAllInput, + input: TrackChangesRejectAllInput, options?: RevisionGuardOptions, ): Receipt { - return decideAll(editor, 'reject', options); + return decideAll(editor, 'reject', input, options); } // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 34ec5ee8c5..1c239d3046 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -1,3 +1,4 @@ +// @ts-check import { Extension } from '@core/Extension.js'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; diff --git a/tests/behavior/tests/navigation/extract.spec.ts b/tests/behavior/tests/navigation/extract.spec.ts index 83e58eb6b9..35cfc14eb2 100644 --- a/tests/behavior/tests/navigation/extract.spec.ts +++ b/tests/behavior/tests/navigation/extract.spec.ts @@ -101,7 +101,7 @@ test('@behavior SD-2525: doc.extract returns tracked changes', async ({ superdoc expect(result.trackedChanges.length).toBeGreaterThanOrEqual(1); const tc = result.trackedChanges[0]; expect(tc.entityId).toBeTruthy(); - expect(['insert', 'delete', 'format']).toContain(tc.type); + expect(['insert', 'delete', 'replacement', 'format']).toContain(tc.type); }); test('@behavior SD-2525: extract nodeIds work with scrollToElement', async ({ superdoc }) => { From 540a6a7f0f54055af31dd7ae4978c10d31d5d9b1 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 15:50:53 -0700 Subject: [PATCH 028/280] chore: type fixes --- .../__tests__/lib/special-handlers.test.ts | 57 +++++- apps/cli/src/lib/special-handlers.ts | 29 +++ .../src/types/track-changes.types.ts | 9 + .../v1/core/super-converter/SuperConverter.js | 9 + .../v2/importer/docxImporter.js | 40 +++- .../v2/importer/docxImporter.test.js | 120 +++++++++++ .../helpers/comment-entity-store.test.ts | 42 +++- .../helpers/comment-entity-store.ts | 77 ++++++- .../plan-engine/comments-wrappers.ts | 9 +- .../track-changes-wrappers.test.ts | 191 ++++++++++++++++++ .../plan-engine/track-changes-wrappers.ts | 135 +++++++++++++ .../tracked-changes/tracked-change-index.ts | 2 + .../tracked-change-snapshot.ts | 5 + .../track-changes/plugins/index.d.ts | 1 + .../track-changes/review-model/edit-intent.js | 2 +- .../extensions/track-changes/track-changes.js | 53 +++-- .../trackChangesHelpers/types.js | 2 +- .../src/editors/v1/utils/comment-content.ts | 47 ++--- 18 files changed, 770 insertions(+), 60 deletions(-) diff --git a/apps/cli/src/__tests__/lib/special-handlers.test.ts b/apps/cli/src/__tests__/lib/special-handlers.test.ts index fbff2a2e9e..0f0bccfe86 100644 --- a/apps/cli/src/__tests__/lib/special-handlers.test.ts +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { POST_INVOKE_HOOKS } from '../../lib/special-handlers'; +import { POST_INVOKE_HOOKS, PRE_INVOKE_HOOKS } from '../../lib/special-handlers'; const rawTrackChangesList = { evaluatedRevision: '0', @@ -86,4 +86,59 @@ describe('special track-changes handlers', () => { expect(result.overlap.preferredContextTargetId).toBe(childId); expect(result.overlap.preferredContextTarget.id).toBe(childId); }); + + test('flattens formatRange receipts for CLI response validation', () => { + const hook = POST_INVOKE_HOOKS.formatRange; + if (!hook) throw new Error('formatRange post hook must be registered'); + + const result = hook( + { + resolution: { + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 1, end: 4 } }] }, + }, + applied: true, + }, + { editor: {} as never }, + ) as { + target: unknown; + resolvedRange: unknown; + receipt: { applied: boolean }; + }; + + expect(result.target).toEqual({ kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }); + expect(result.resolvedRange).toEqual({ + kind: 'text', + segments: [{ blockId: 'p1', range: { start: 1, end: 4 } }], + }); + expect(result.receipt.applied).toBe(true); + }); + + test('translates stable trackedChangeId comment targets back to raw ids before invoke', () => { + const hook = PRE_INVOKE_HOOKS['comments.create']; + if (!hook) throw new Error('comments.create pre hook must be registered'); + + const normalizedList = POST_INVOKE_HOOKS['trackChanges.list']?.(rawTrackChangesList, { + editor: {} as never, + }) as { items: TrackChangeItem[] }; + const stableId = normalizedList.items[0]?.id; + if (!stableId) throw new Error('expected normalized list to contain a stable id'); + + const result = hook( + { + target: { trackedChangeId: stableId }, + text: 'comment', + }, + { + editor: { + doc: { + invoke: () => rawTrackChangesList, + }, + }, + } as never, + ) as { target: { trackedChangeId: string }; text: string }; + + expect(result.target.trackedChangeId).toBe('raw-parent'); + expect(result.text).toBe('comment'); + }); }); diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index e7b3dadb07..a26a8b235c 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -27,6 +27,7 @@ type PreInvokeHook = (input: unknown, context: HookContext) => unknown; type PostInvokeHook = (result: unknown, context: HookContext) => unknown; const FORMAT_RECEIPT_OPERATION_IDS: readonly CliExposedOperationId[] = [ + 'formatRange', 'format.apply', ...INLINE_PROPERTY_REGISTRY.map((entry) => `format.${entry.key}` as CliExposedOperationId), ]; @@ -212,6 +213,31 @@ const resolveReviewDecideId: PreInvokeHook = (input, context) => { return { ...record, target: { ...target, id: rawId } }; }; +/** + * Comment target shapes can carry trackedChangeId values copied from + * `trackChanges.list`, which the CLI normalizes to stable SHA-1 IDs. The + * adapter expects raw/runtime ids, so translate the target id before invoke. + */ +const resolveCommentTrackedChangeTargetId: PreInvokeHook = (input, context) => { + const record = asRecord(input); + if (!record) return input; + + const target = asRecord(record.target); + if (!target) return input; + + const stableId = typeof target.trackedChangeId === 'string' ? target.trackedChangeId : undefined; + if (!stableId) return input; + + const listResult = context.editor.doc.invoke({ + operationId: 'trackChanges.list' as const, + input: {}, + }); + const { stableToRawId } = buildStableIdMappings(listResult); + const rawId = stableToRawId.get(stableId) ?? stableId; + + return { ...record, target: { ...target, trackedChangeId: rawId } }; +}; + // --------------------------------------------------------------------------- // Post-invoke hooks // --------------------------------------------------------------------------- @@ -283,6 +309,9 @@ export const PRE_INVOKE_HOOKS: Partial { + try { + const relationships = docx?.['_rels/.rels']?.elements?.find((el) => matchesElementName(el?.name, 'Relationships')); + return Array.isArray(relationships?.elements) + ? relationships.elements.filter((el) => matchesElementName(el?.name, 'Relationship')) + : []; + } catch { + return []; + } +}; + +const looksLikeGoogleDocsMinimalPackage = (docx) => { + const packageRelationships = listPackageRelationships(docx); + if (packageRelationships.length !== 1) return false; + if (packageRelationships[0]?.attributes?.Type !== OFFICE_DOCUMENT_RELATIONSHIP) return false; + return !docx?.['docProps/app.xml'] && !docx?.['docProps/core.xml'] && !docx?.['word/webSettings.xml']; +}; + const detectDocumentOrigin = (docx) => { + const storedOrigin = readCustomProperty(docx, SUPERDOC_DOCUMENT_ORIGIN_PROPERTY); + if (storedOrigin && STORED_DOCUMENT_ORIGINS.has(storedOrigin)) { + return storedOrigin; + } + + if (readCustomProperty(docx, 'SuperdocVersion') || readCustomProperty(docx, TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY)) { + return 'superdoc'; + } + const commentsExtended = docx['word/commentsExtended.xml']; if (commentsExtended) { const { elements: initialElements = [] } = commentsExtended; @@ -93,6 +125,10 @@ const detectDocumentOrigin = (docx) => { return 'google-docs'; } + if (looksLikeGoogleDocsMinimalPackage(docx)) { + return 'google-docs'; + } + return 'unknown'; }; @@ -101,7 +137,7 @@ const matchesElementName = (name, localName) => { return name === localName || name.endsWith(`:${localName}`); }; -const readCustomProperty = (docx, propertyName) => { +function readCustomProperty(docx, propertyName) { try { const customXml = docx?.['docProps/custom.xml']; const properties = customXml?.elements?.find((el) => matchesElementName(el?.name, 'Properties')); @@ -113,7 +149,7 @@ const readCustomProperty = (docx, propertyName) => { } catch { return null; } -}; +} const parseTrackedChangeSourceIdMap = (raw) => { if (typeof raw !== 'string' || raw.length === 0) return new Map(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js index 5a5b41f8aa..d5f8d93449 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + createDocumentJson, collapseWhitespaceNextToInlinePassthrough, defaultNodeListHandler, filterOutRootInlineNodes, @@ -10,6 +11,92 @@ import { const n = (type, attrs = {}) => ({ type, attrs, marks: [] }); +const makeCustomPropertyDocx = (propertyName, textValue) => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: [ + { + name: 'property', + attributes: { name: propertyName }, + elements: [ + { + name: 'vt:lpwstr', + elements: [{ type: 'text', text: textValue }], + }, + ], + }, + ], + }, + ], + }, +}); + +const makeCustomPropertiesDocx = (properties) => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: Object.entries(properties).map(([propertyName, textValue]) => ({ + name: 'property', + attributes: { name: propertyName }, + elements: [ + { + name: 'vt:lpwstr', + elements: [{ type: 'text', text: textValue }], + }, + ], + })), + }, + ], + }, +}); + +const makeGoogleDocsLikeDocx = () => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + '_rels/.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rId1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', + Target: 'word/document.xml', + }, + }, + ], + }, + ], + }, +}); + describe('filterOutRootInlineNodes', () => { it('removes inline nodes at the root and keeps block nodes', () => { const input = [ @@ -113,6 +200,39 @@ describe('filterOutRootInlineNodes', () => { }); }); +describe('createDocumentJson document origin detection', () => { + it('prefers the stored SuperDoc document origin over the package-level SuperdocVersion marker', () => { + const converter = {}; + + createDocumentJson( + makeCustomPropertiesDocx({ + SuperdocVersion: '1.2.3', + SuperdocDocumentOrigin: 'google-docs', + }), + converter, + undefined, + ); + + expect(converter.documentOrigin).toBe('google-docs'); + }); + + it('classifies SuperDoc-authored packages via the stored SuperdocVersion custom property', () => { + const converter = {}; + + createDocumentJson(makeCustomPropertyDocx('SuperdocVersion', '1.2.3'), converter, undefined); + + expect(converter.documentOrigin).toBe('superdoc'); + }); + + it('classifies minimal Google Docs-like packages when the OPC root lacks metadata parts', () => { + const converter = {}; + + createDocumentJson(makeGoogleDocsLikeDocx(), converter, undefined); + + expect(converter.documentOrigin).toBe('google-docs'); + }); +}); + describe('collapseWhitespaceNextToInlinePassthrough', () => { const paragraph = (content) => ({ type: 'paragraph', content }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index 61712ea60c..f96dc5baca 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -8,6 +8,8 @@ import { getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + restoreStashedCommentEntityTree, + stashRemovedCommentEntities, syncCommentEntitiesFromCollaboration, toCommentInfo, upsertCommentEntity, @@ -125,6 +127,32 @@ describe('removeCommentEntityTree', () => { }); }); +describe('stashRemovedCommentEntities / restoreStashedCommentEntityTree', () => { + it('restores a removed root comment and its reply payloads', () => { + const editor = makeEditorWithConverter([ + { commentId: 'c1', commentText: 'Root' }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Reply' }, + ]); + const store = getCommentEntityStore(editor); + + const removed = removeCommentEntityTree(store, 'c1'); + expect(store).toEqual([]); + + stashRemovedCommentEntities(editor, removed); + const restored = restoreStashedCommentEntityTree(editor, 'c1'); + + expect(restored.map((entry) => entry.commentId).sort()).toEqual(['c1', 'c2']); + expect(store).toHaveLength(2); + expect(findCommentEntity(store, 'c1')?.commentText).toBe('Root'); + expect(findCommentEntity(store, 'c2')?.commentText).toBe('Reply'); + }); + + it('returns empty when there is no stashed tree for the comment id', () => { + const editor = makeEditorWithConverter(); + expect(restoreStashedCommentEntityTree(editor, 'missing')).toEqual([]); + }); +}); + describe('extractCommentText', () => { it('returns commentText when available', () => { expect(extractCommentText({ commentText: 'Hello' })).toBe('Hello'); @@ -169,17 +197,21 @@ describe('buildCommentJsonFromText', () => { ]); }); - it('strips HTML tags from input', () => { + it('preserves literal markup-looking text as plain text', () => { const result = buildCommentJsonFromText('Bold text'); expect(result[0]).toMatchObject({ - content: [{ content: [{ text: 'Bold text' }] }], + content: [{ content: [{ text: 'Bold text' }] }], }); }); - it('replaces   with spaces', () => { - const result = buildCommentJsonFromText('Hello world'); + it('preserves paragraph boundaries from newline-delimited plain text', () => { + const result = buildCommentJsonFromText('Hello\nworld'); + expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ - content: [{ content: [{ text: 'Hello world' }] }], + content: [{ content: [{ text: 'Hello' }] }], + }); + expect(result[1]).toMatchObject({ + content: [{ content: [{ text: 'world' }] }], }); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index ef380d3e57..f7ef1425e3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -11,6 +11,7 @@ export { buildCommentJsonFromText } from '../../utils/comment-content.js'; import { buildCommentJsonFromText } from '../../utils/comment-content.js'; const FALLBACK_STORE_KEY = '__documentApiComments'; +const DELETED_COMMENT_SNAPSHOT_KEY = '__documentApiDeletedCommentSnapshots'; export interface CommentEntityRecord { commentId?: string; @@ -51,10 +52,7 @@ type EditorWithCommentStorage = Editor & { }; function ensureFallbackStore(editor: EditorWithCommentStorage): CommentEntityRecord[] { - if (!editor.storage) { - (editor as unknown as Record).storage = {}; - } - const storage = editor.storage as Record; + const storage = ensureEditorStorage(editor); if (!Array.isArray(storage[FALLBACK_STORE_KEY])) { storage[FALLBACK_STORE_KEY] = []; @@ -63,6 +61,24 @@ function ensureFallbackStore(editor: EditorWithCommentStorage): CommentEntityRec return storage[FALLBACK_STORE_KEY] as CommentEntityRecord[]; } +function ensureEditorStorage(editor: EditorWithCommentStorage): Record { + if (!editor.storage) { + (editor as unknown as Record).storage = {}; + } + return editor.storage as Record; +} + +function getDeletedCommentSnapshotStore(editor: Editor): Map { + const storage = ensureEditorStorage(editor as EditorWithCommentStorage); + const existing = storage[DELETED_COMMENT_SNAPSHOT_KEY]; + if (existing instanceof Map) { + return existing as Map; + } + const created = new Map(); + storage[DELETED_COMMENT_SNAPSHOT_KEY] = created; + return created; +} + export function getCommentEntityStore(editor: Editor): CommentEntityRecord[] { const mutableEditor = editor as EditorWithCommentStorage; const converter = mutableEditor.converter as ConverterWithComments | undefined; @@ -128,6 +144,59 @@ export function removeCommentEntityTree(store: CommentEntityRecord[], commentId: return removed; } +function commentEntityIdentity(entry: CommentEntityRecord | undefined): string | undefined { + return toNonEmptyString(entry?.commentId) ?? toNonEmptyString(entry?.importedId); +} + +export function stashRemovedCommentEntities(editor: Editor, removed: ReadonlyArray): void { + if (removed.length === 0) return; + const snapshots = getDeletedCommentSnapshotStore(editor); + for (const entry of removed) { + const identity = commentEntityIdentity(entry); + if (!identity) continue; + snapshots.set(identity, { ...entry }); + } +} + +export function restoreStashedCommentEntityTree(editor: Editor, rootCommentId: string): CommentEntityRecord[] { + const snapshots = getDeletedCommentSnapshotStore(editor); + const store = getCommentEntityStore(editor); + const root = Array.from(snapshots.values()).find( + (entry) => + toNonEmptyString(entry.commentId) === rootCommentId || toNonEmptyString(entry.importedId) === rootCommentId, + ); + if (!root) return []; + + const restoreIds = new Set(); + const rootIdentity = commentEntityIdentity(root); + if (!rootIdentity) return []; + restoreIds.add(rootIdentity); + + let changed = true; + while (changed) { + changed = false; + for (const entry of snapshots.values()) { + const identity = commentEntityIdentity(entry); + if (!identity || restoreIds.has(identity)) continue; + const parentId = toNonEmptyString(entry.parentCommentId); + if (!parentId || !restoreIds.has(parentId)) continue; + restoreIds.add(identity); + changed = true; + } + } + + const restored: CommentEntityRecord[] = []; + for (const identity of restoreIds) { + const snapshot = snapshots.get(identity); + if (!snapshot) continue; + upsertCommentEntity(store, identity, { ...snapshot }); + snapshots.delete(identity); + restored.push(snapshot); + } + + return restored; +} + function collectTextFragments(value: unknown, sink: string[]): void { if (!value) return; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index 4e7a47e952..36b7dd1196 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -53,6 +53,7 @@ import { getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + restoreStashedCommentEntityTree, toCommentInfo, upsertCommentEntity, } from '../helpers/comment-entity-store.js'; @@ -631,6 +632,12 @@ function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map anchor.commentId))); + for (const commentId of anchoredCommentIds) { + restoreStashedCommentEntityTree(editor, commentId); + } + const store = getCommentEntityStore(editor); const infosById = new Map(); @@ -640,7 +647,7 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { infosById.set(commentId, toCommentInfo({ ...entry, commentId })); } - const canonicalByCommentId = mergeAnchorData(editor, infosById, listCommentAnchorsSafe(editor)); + const canonicalByCommentId = mergeAnchorData(editor, infosById, anchors); mergeTrackedChangeCommentInfos(editor, infosById); for (const [commentId, canonical] of canonicalByCommentId.entries()) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 16bdbb0981..7c4fc2a917 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), + resolveCommentAnchorsById: vi.fn(), })); vi.mock('./revision-tracker.js', () => ({ @@ -33,12 +34,17 @@ vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ resolveStoryRuntime: mocks.resolveStoryRuntime, })); +vi.mock('../helpers/comment-target-resolver.js', () => ({ + resolveCommentAnchorsById: mocks.resolveCommentAnchorsById, +})); + import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, trackChangesGetWrapper, trackChangesListWrapper, + trackChangesRejectWrapper, getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; @@ -137,9 +143,47 @@ beforeEach(() => { subscribe: vi.fn(), dispose: vi.fn(), }); + mocks.resolveCommentAnchorsById.mockReturnValue([{ commentId: 'comment-1' }]); }); describe('track-changes-wrappers revision guard', () => { + it('surfaces tracked-change provenance fields on list results', () => { + const hostEditor = makeEditor(); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'canon-1' }, + runtimeRef: { storyKey: 'body', rawId: 'raw-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new text', + origin: 'google-docs', + imported: true, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::raw-1', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 1, to: 9 }, + }, + ]), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(hostEditor, {}); + + expect(result.items[0]).toMatchObject({ + id: 'canon-1', + origin: 'google-docs', + imported: true, + }); + }); + it('checks expectedRevision on the host editor before accepting a non-body tracked change', () => { const hostEditor = makeEditor(); const storyEditor = makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }); @@ -222,6 +266,153 @@ describe('track-changes-wrappers revision guard', () => { }); }); + it('removes deleted tracked-change-linked comments from the host store after a successful decision', () => { + const hostEditor = { + ...makeEditor(), + converter: { + comments: [ + { commentId: 'comment-1', trackedChange: true, trackedChangeParentId: 'canon-1' }, + { commentId: 'reply-1', parentCommentId: 'comment-1' }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ rejectTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: { + deletedComments: [{ id: 'comment-1' }], + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesRejectWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments).toEqual([]); + }); + + it('detaches surviving comments from tracked-change threading when the decision receipt says to detach them', () => { + const hostEditor = { + ...makeEditor(), + converter: { + comments: [ + { + commentId: 'comment-2', + trackedChange: true, + trackedChangeParentId: 'canon-1', + trackedChangeType: 'delete', + trackedChangeAnchorKey: 'tc::body::canon-1', + trackedChangeText: 'deleted text', + deletedText: 'deleted text', + }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: { + detachedComments: [{ id: 'comment-2' }], + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments[0]).toMatchObject({ + commentId: 'comment-2', + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeAnchorKey: null, + trackedChangeText: null, + deletedText: null, + }); + }); + + it('prunes tracked-change comment roots whose anchors disappear even when the decision receipt does not enumerate them', () => { + const hostEditor = { + ...makeEditor(), + options: { trackedChanges: {}, documentId: 'doc-1' }, + emit: vi.fn(), + converter: { + comments: [ + { commentId: 'comment-3', trackedChange: true, trackedChangeParentId: 'canon-1' }, + { commentId: 'reply-3', parentCommentId: 'comment-3' }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: null, + }, + }, + } as unknown as Editor; + + mocks.resolveCommentAnchorsById.mockReturnValue([]); + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments).toEqual([]); + expect(hostEditor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: 'deleted', + comment: { + commentId: 'comment-3', + documentId: 'doc-1', + fileId: 'doc-1', + }, + }); + }); + it('checks expectedRevision once on the host editor for accept-all across multiple stories', () => { const hostEditor = makeEditor(); const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 6d3822507d..c1bce814cc 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -31,6 +31,14 @@ import type { } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; +import { + type CommentEntityRecord, + findCommentEntity, + getCommentEntityStore, + removeCommentEntityTree, + stashRemovedCommentEntities, +} from '../helpers/comment-entity-store.js'; +import { resolveCommentAnchorsById } from '../helpers/comment-target-resolver.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; @@ -118,6 +126,8 @@ function buildProjectedInfo( date: snapshot.date, excerpt: snapshot.excerpt, ...buildChangedTextFields(type, snapshot.excerpt), + origin: snapshot.origin, + imported: snapshot.imported, }, handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, snapshot, @@ -300,6 +310,122 @@ function decisionFailureReceipt(editor: Editor, fallbackMessage: string, fallbac }; } +type DecisionCommentEffectReceipt = { + deletedComments?: Array<{ id?: string | null }>; + detachedComments?: Array<{ id?: string | null }>; +}; + +function commentEntityId(record: CommentEntityRecord): string | null { + return toNonEmptyString(record.commentId) ?? toNonEmptyString(record.importedId); +} + +function isTrackedChangeLinkedRootComment(record: CommentEntityRecord): boolean { + return ( + !toNonEmptyString(record.parentCommentId) && + (record.trackedChange === true || + toNonEmptyString(record.trackedChangeParentId) != null || + record.trackedChangeType != null || + record.trackedChangeAnchorKey != null) + ); +} + +function trackedCommentRootHasLiveAnchors(editor: Editor, commentId: string, record: CommentEntityRecord): boolean { + const aliases = new Set([commentId, toNonEmptyString(record.importedId)].filter((value): value is string => !!value)); + for (const alias of aliases) { + if (resolveCommentAnchorsById(editor, alias).length > 0) return true; + } + return false; +} + +function emitDeletedCommentUpdate(editor: Editor, commentId: string): void { + const emitter = (editor as unknown as { emit?: (event: string, payload: unknown) => void }).emit; + if (typeof emitter !== 'function') return; + + const documentId = toNonEmptyString(editor.options?.documentId) ?? null; + emitter.call(editor, 'commentsUpdate', { + type: 'deleted', + comment: { + commentId, + documentId, + fileId: documentId, + }, + }); +} + +function pruneMissingTrackedCommentRoots(editor: Editor): string[] { + const store = getCommentEntityStore(editor); + const rootIds = Array.from( + new Set( + store + .filter(isTrackedChangeLinkedRootComment) + .map((record) => commentEntityId(record)) + .filter((commentId): commentId is string => commentId != null), + ), + ); + const removedIds = new Set(); + + for (const commentId of rootIds) { + const record = findCommentEntity(store, commentId); + if (!record) continue; + if (trackedCommentRootHasLiveAnchors(editor, commentId, record)) continue; + const removed = removeCommentEntityTree(store, commentId); + stashRemovedCommentEntities(editor, removed); + for (const removedRecord of removed) { + const removedId = commentEntityId(removedRecord); + if (removedId) removedIds.add(removedId); + } + } + + return Array.from(removedIds); +} + +function applyDecisionCommentEffects(hostEditor: Editor, decisionEditor: Editor): void { + const storage = ( + decisionEditor as { + storage?: { + trackChanges?: { + lastDecisionReceipt?: DecisionCommentEffectReceipt | null; + }; + }; + } + ).storage; + const store = getCommentEntityStore(hostEditor); + const receipt = storage?.trackChanges?.lastDecisionReceipt ?? null; + const deletedCommentIds = new Set( + (receipt?.deletedComments ?? []).map((entry) => toNonEmptyString(entry?.id)).filter(Boolean), + ); + const detachedCommentIds = new Set( + (receipt?.detachedComments ?? []) + .map((entry) => toNonEmptyString(entry?.id)) + .filter((id): id is string => Boolean(id) && !deletedCommentIds.has(id)), + ); + + for (const commentId of deletedCommentIds) { + const removed = removeCommentEntityTree(store, commentId); + stashRemovedCommentEntities(hostEditor, removed); + } + + for (const commentId of detachedCommentIds) { + const record = findCommentEntity(store, commentId); + if (!record) continue; + record.trackedChange = false; + record.trackedChangeParentId = null; + record.trackedChangeType = null; + record.trackedChangeDisplayType = null; + record.trackedChangeStory = null; + record.trackedChangeStoryKind = null; + record.trackedChangeStoryLabel = null; + record.trackedChangeAnchorKey = null; + record.trackedChangeText = null; + record.deletedText = null; + } + + const prunedCommentIds = pruneMissingTrackedCommentRoots(hostEditor); + for (const commentId of prunedCommentIds) { + emitDeletedCommentUpdate(hostEditor, commentId); + } +} + function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { if (!input || input.in === undefined) return 'body'; if (input.in === 'all') return 'all'; @@ -347,6 +473,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList excerpt, insertedText, deletedText, + origin, + imported, } = info; return buildDiscoveryItem(info.id, handle, { address, @@ -362,6 +490,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList excerpt, insertedText, deletedText, + origin, + imported, }); }); @@ -427,6 +557,8 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp date: toNonEmptyString(resolved.change.attrs.date), excerpt, ...buildChangedTextFields(type, excerpt), + origin: toNonEmptyString(resolved.change.attrs.origin) as TrackChangeInfo['origin'], + imported: Boolean(toNonEmptyString(resolved.change.attrs.sourceId)), }; } @@ -481,6 +613,7 @@ function decideSingle( } getTrackedChangeIndex(hostEditor).invalidate(resolved.story); + applyDecisionCommentEffects(hostEditor, resolved.editor); return { success: true }; } @@ -565,6 +698,7 @@ function decideAll( runtime.commit(editor); } index.invalidate(story); + applyDecisionCommentEffects(editor, runtime.editor); } if (!anyApplied) { @@ -683,5 +817,6 @@ export function trackChangesDecideRangeWrapper( }); } getTrackedChangeIndex(editor).invalidate({ kind: 'story', storyType: 'body' }); + applyDecisionCommentEffects(editor, editor); return { success: true }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index 5bd2bebb72..9fff279c03 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -281,6 +281,8 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { date: toNonEmptyString(change.attrs.date), excerpt, wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + origin: toNonEmptyString(change.attrs.origin) as TrackedChangeSnapshot['origin'], + imported: Boolean(toNonEmptyString(change.attrs.sourceId)), overlap: copyOverlapInfo(change.overlap, canonicalIdByAlias), storyLabel, storyKind, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts index fb307f6d8a..1d4a08583a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -7,6 +7,7 @@ import type { StoryLocator, TrackedChangeAddress, + TrackChangeProvenanceOrigin, TrackChangeType, TrackChangeOverlapInfo, TrackChangeWordRevisionIds, @@ -34,6 +35,10 @@ export interface TrackedChangeSnapshot { excerpt?: string; /** Raw imported Word revision IDs, if present. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Source application or package family detected on import. */ + origin?: TrackChangeProvenanceOrigin; + /** True when this tracked change came from an imported document revision. */ + imported?: boolean; /** Overlap metadata for nested tracked changes that share the same text range. */ overlap?: TrackChangeOverlapInfo; /** Human-readable label for sidebar cards ("Footer · Section 3", "Footnote 12"). */ diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts b/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts index 156678d428..1a0c4e2d65 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts @@ -1 +1,2 @@ export const TrackChangesBasePluginKey: any; +export function TrackChangesBasePlugin(): any; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js index c7b1acff4d..63be895c44 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -73,7 +73,7 @@ const isFiniteNonNeg = (value) => typeof value === 'number' && Number.isFinite(v * * @param {*} schema * @param {string} text - * @param {Array} [marks] + * @param {readonly import('prosemirror-model').Mark[]} [marks] * @returns {import('prosemirror-model').Slice} */ export const sliceFromText = (schema, text, marks) => { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 1c239d3046..e839e0b236 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -15,6 +15,26 @@ import { } from './review-model/edit-intent.js'; import { decideTrackedChanges, buildDecisionBubbleEvents } from './review-model/decision-engine.js'; +/** + * @typedef {{ code: string, message: string, details?: unknown }} TrackChangesFailure + * @typedef {{ + * lastCompilerFailure: TrackChangesFailure | null, + * lastDecisionFailure: TrackChangesFailure | null, + * lastDecisionReceipt: unknown, + * }} TrackChangesStorage + */ + +/** + * @param {unknown} editor + * @returns {TrackChangesStorage | null} + */ +const getTrackChangesStorage = (editor) => { + const storage = /** @type {{ storage?: { trackChanges?: unknown } } | null | undefined} */ (editor)?.storage + ?.trackChanges; + if (!storage || typeof storage !== 'object') return null; + return /** @type {TrackChangesStorage} */ (storage); +}; + /** * Reads the `replacements` mode from editor.options.trackedChanges. * Defaults to `'paired'` when unset; anything other than the exact @@ -28,8 +48,10 @@ const readReplacementsMode = (editor) => * emits bubble lifecycle events from the decision receipt. */ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) => { - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastDecisionFailure = null; + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = null; + trackChangesStorage.lastDecisionReceipt = null; } const result = decideTrackedChanges({ state, @@ -38,12 +60,12 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = target, replacements: readReplacementsMode(editor), }); - if (!result.ok) { + if (result.ok === false) { // Fail closed (do NOT mutate) for hard errors. NO_OP and // CAPABILITY_UNAVAILABLE return `false` so toolbar wrappers can decide // how to surface the result. - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastDecisionFailure = { + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = { code: result.code, message: result.message, details: result.details, @@ -51,6 +73,9 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = } return { applied: false, failure: result }; } + if (trackChangesStorage) { + trackChangesStorage.lastDecisionReceipt = result.receipt ?? null; + } if (dispatch) { // Compute the post-dispatch state locally so we can derive update events // for partial decisions (where a change has remaining tracked text on the @@ -124,7 +149,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = * (mark.attrs.splitFromId === originalId). * * @param {{ state: import('prosemirror-state').EditorState, originalId: string }} options - * @returns {Array<{ from: number, to: number, mark: import('prosemirror-model').Mark, node: import('prosemirror-model').Node }>} + * @returns {import('./trackChangesHelpers/types.js').TrackedMarkRange[]} */ const collectRemainingForLogicalId = ({ state, originalId }) => { const all = getTrackChanges(state); @@ -224,6 +249,7 @@ export const TrackChanges = Extension.create({ return { lastCompilerFailure: null, lastDecisionFailure: null, + lastDecisionReceipt: null, }; }, @@ -285,7 +311,7 @@ export const TrackChanges = Extension.create({ }, acceptTrackedChangeFromContextMenu: - ({ from, to, trackedChangeId = null } = {}) => + ({ from = null, to = null, trackedChangeId = null } = {}) => ({ state, commands, editor }) => { return resolveTrackedChangeAction({ action: 'accept', @@ -372,7 +398,7 @@ export const TrackChanges = Extension.create({ }, rejectTrackedChangeFromContextMenu: - ({ from, to, trackedChangeId = null } = {}) => + ({ from = null, to = null, trackedChangeId = null } = {}) => ({ state, commands, editor }) => { return resolveTrackedChangeAction({ action: 'reject', @@ -726,8 +752,9 @@ const dispatchCompiledInsertTrackedChange = ({ const replacements = readReplacementsMode(editor); const tr = state.tr; const schema = state.schema; - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastCompilerFailure = null; + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = null; } const activeMarks = state.storedMarks ?? state.doc.resolve(from).marks(); let intent; @@ -776,9 +803,9 @@ const dispatchCompiledInsertTrackedChange = ({ replacements, }); - if (!result.ok) { - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastCompilerFailure = { + if (result.ok === false) { + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = { code: result.code, message: result.message, details: result.details, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js index 05251a6b67..f28b01f8e4 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js @@ -34,7 +34,7 @@ * @typedef {{ node: PmNode; pos: number }} NodePosEntry * The standard `findChildren` / `nodesBetween` result shape. * - * @typedef {{ from: number; to: number; mark: PmMark }} TrackedMarkRange + * @typedef {{ from: number; to: number; mark: PmMark; node?: PmNode }} TrackedMarkRange * A live ProseMirror mark located in a `[from, to]` document range. * Used as `findTrackedMarkBetween`'s non-null return and as the * element shape of `getTrackChanges`'s result array. diff --git a/packages/super-editor/src/editors/v1/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts index 73ed8b71e3..aee15f2cb9 100644 --- a/packages/super-editor/src/editors/v1/utils/comment-content.ts +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -1,35 +1,18 @@ -/** - * Strips HTML tags from a comment text string using simple regex replacement. - * - * This is only intended for normalizing comment content that was already authored - * within the editor. It is NOT a security sanitizer and must not be used to - * neutralize untrusted or user-supplied HTML. - */ -export function stripHtmlToText(value: string): string { - return value - .replace(/<[^>]+>/g, ' ') - .replace(/ /gi, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - export function buildCommentJsonFromText(text: string): unknown[] { - const normalized = stripHtmlToText(text); + const normalized = text.replace(/\r\n?/g, '\n'); - return [ - { - type: 'paragraph', - content: [ - { - type: 'run', - content: [ - { - type: 'text', - text: normalized, - }, - ], - }, - ], - }, - ]; + return normalized.split('\n').map((paragraphText) => ({ + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: paragraphText, + }, + ], + }, + ], + })); } From d74a395109c9b6b81a920982c1ddf1ac04cdbc00 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 18:36:31 -0700 Subject: [PATCH 029/280] chore: soec fixes --- apps/mcp/src/generated/catalog.ts | 8357 +++++++++-------- .../generated/intent-dispatch.generated.ts | 207 +- .../sdk/langs/browser/src/intent-dispatch.ts | 207 +- .../Editor.track-changes-dispatch.test.js | 90 +- .../src/editors/v1/core/Editor.ts | 101 +- 5 files changed, 4859 insertions(+), 4103 deletions(-) diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 7b994255c4..b40215a3fe 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1,5192 +1,6021 @@ // Auto-generated from packages/sdk/tools/catalog.json // Do not edit manually — re-run generate:all to update. export const MCP_TOOL_CATALOG = { - contractVersion: '0.1.0', - generatedAt: null, - toolCount: 10, - tools: [ + "contractVersion": "0.1.0", + "generatedAt": null, + "toolCount": 10, + "tools": [ { - toolName: 'superdoc_get_content', - description: - 'Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action "blocks" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action "blocks" with includeText:true so you can identify the correct block and then target it by nodeId. Action "text" and "markdown" return the full document as plain text or Markdown. Action "html" returns HTML. Action "info" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The "blocks" action supports pagination via "offset" and "limit", and filtering via "nodeTypes". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {"action":"blocks"}\n 2. {"action":"blocks","includeText":true,"offset":0,"limit":20}\n 3. {"action":"blocks","offset":0,"limit":20,"nodeTypes":["heading","paragraph"]}\n 4. {"action":"text"}\n 5. {"action":"info"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['blocks', 'extract', 'html', 'info', 'markdown', 'text'], - description: 'The action to perform. One of: blocks, extract, html, info, markdown, text.', - }, - unflattenLists: { - type: 'boolean', - description: - "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions.", - }, - offset: { - type: 'number', - minimum: 0, - description: "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions.", - }, - limit: { - type: 'number', - minimum: 1, - description: - "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions.", - }, - nodeTypes: { - type: 'array', - items: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "toolName": "superdoc_get_content", + "description": "Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action \"blocks\" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action \"blocks\" with includeText:true so you can identify the correct block and then target it by nodeId. Action \"text\" and \"markdown\" return the full document as plain text or Markdown. Action \"html\" returns HTML. Action \"info\" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The \"blocks\" action supports pagination via \"offset\" and \"limit\", and filtering via \"nodeTypes\". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {\"action\":\"blocks\"}\n 2. {\"action\":\"blocks\",\"includeText\":true,\"offset\":0,\"limit\":20}\n 3. {\"action\":\"blocks\",\"offset\":0,\"limit\":20,\"nodeTypes\":[\"heading\",\"paragraph\"]}\n 4. {\"action\":\"text\"}\n 5. {\"action\":\"info\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "blocks", + "extract", + "html", + "info", + "markdown", + "text" + ], + "description": "The action to perform. One of: blocks, extract, html, info, markdown, text." + }, + "unflattenLists": { + "type": "boolean", + "description": "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions." + }, + "offset": { + "type": "number", + "minimum": 0, + "description": "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions." + }, + "limit": { + "type": "number", + "minimum": 1, + "description": "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions." + }, + "nodeTypes": { + "type": "array", + "items": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, - description: - "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions.", - }, - includeText: { - type: 'boolean', - description: - "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions.", + "description": "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions." }, + "includeText": { + "type": "boolean", + "description": "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.getText', - intentAction: 'text', + "operationId": "doc.getText", + "intentAction": "text" }, { - operationId: 'doc.getMarkdown', - intentAction: 'markdown', + "operationId": "doc.getMarkdown", + "intentAction": "markdown" }, { - operationId: 'doc.getHtml', - intentAction: 'html', + "operationId": "doc.getHtml", + "intentAction": "html" }, { - operationId: 'doc.info', - intentAction: 'info', + "operationId": "doc.info", + "intentAction": "info" }, { - operationId: 'doc.extract', - intentAction: 'extract', + "operationId": "doc.extract", + "intentAction": "extract" }, { - operationId: 'doc.blocks.list', - intentAction: 'blocks', - }, - ], + "operationId": "doc.blocks.list", + "intentAction": "blocks" + } + ] }, { - toolName: 'superdoc_edit', - description: - 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"undo"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['delete', 'insert', 'redo', 'replace', 'undo'], - description: 'The action to perform. One of: delete, insert, redo, replace, undo.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - target: { - oneOf: [ + "toolName": "superdoc_edit", + "description": "The primary tool for inserting content into documents. ALWAYS use action \"insert\" with type \"markdown\" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with \"target\" (a BlockNodeAddress like {kind:\"block\", nodeType, nodeId}) and \"placement\" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts \"inline\" (fontFamily, fontSize, bold, underline, color), \"alignment\", and \"scope\" in the same step. Use scope: \"block\" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a \"ref\" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:\"block\", nodeType, nodeId} from superdoc_get_content action \"blocks\" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports \"dryRun\" to preview changes and \"changeMode: tracked\" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build \"target\" objects manually when a ref is available; prefer \"ref\" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {\"action\":\"insert\",\"type\":\"markdown\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"placement\":\"before\",\"value\":\"# Executive Summary\\n\\nThis agreement sets forth the principal terms...\"}\n 2. {\"action\":\"insert\",\"type\":\"markdown\",\"value\":\"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*.\"}\n 3. {\"action\":\"replace\",\"ref\":\"\",\"text\":\"new text here\"}\n 4. {\"action\":\"delete\",\"ref\":\"\"}\n 5. {\"action\":\"undo\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "insert", + "redo", + "replace", + "undo" + ], + "description": "The action to perform. One of: delete, insert, redo, replace, undo." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/BlockNodeAddress', - description: - "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "$ref": "#/$defs/BlockNodeAddress", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], + "required": [ + "kind", + "start", + "end" + ] }, { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], - }, - ], - }, + "required": [ + "kind", + "start", + "end" + ] + } + ] + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", - }, + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - value: { - type: 'string', - description: "Text content to insert. Only for action 'insert'. Omit for other actions.", - }, - type: { - type: 'string', - description: - "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", - enum: ['text', 'markdown', 'html'], - }, - ref: { - oneOf: [ + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "value": { + "type": "string", + "description": "Text content to insert. Only for action 'insert'. Omit for other actions." + }, + "type": { + "type": "string", + "description": "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", + "enum": [ + "text", + "markdown", + "html" + ] + }, + "ref": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'string', - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "type": "string", + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, - content: { - oneOf: [ + "content": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'object', + "type": "object" }, { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "array", + "items": { + "type": "object" + } + } ], - description: 'Document fragment to insert (structured content).', + "description": "Document fragment to insert (structured content)." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: {}, + "type": "object", + "properties": {} }, { - type: 'array', - items: { - type: 'object', - properties: {}, - }, - }, + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } ], - description: 'Document fragment to replace with (structured content).', - }, + "description": "Document fragment to replace with (structured content)." + } ], - description: - "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions.", - }, - placement: { - enum: ['before', 'after', 'insideStart', 'insideEnd'], - description: - "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions.", + "description": "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions." + }, + "placement": { + "enum": [ + "before", + "after", + "insideStart", + "insideEnd" + ], + "description": "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions." }, - nestingPolicy: { - oneOf: [ + "nestingPolicy": { + "oneOf": [ { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - additionalProperties: false, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", + "additionalProperties": false, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." }, { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", - }, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." + } ], - description: - "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions.", - }, - text: { - type: 'string', - description: "Replacement text content. Only for action 'replace'. Omit for other actions.", + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions." }, - behavior: { - $ref: '#/$defs/DeleteBehavior', - description: - "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions.", + "text": { + "type": "string", + "description": "Replacement text content. Only for action 'replace'. Omit for other actions." }, + "behavior": { + "$ref": "#/$defs/DeleteBehavior", + "description": "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.insert', - intentAction: 'insert', - requiredOneOf: [['target', 'value'], ['ref', 'value'], ['value'], ['content']], + "operationId": "doc.insert", + "intentAction": "insert", + "requiredOneOf": [ + [ + "target", + "value" + ], + [ + "ref", + "value" + ], + [ + "value" + ], + [ + "content" + ] + ] }, { - operationId: 'doc.replace', - intentAction: 'replace', - requiredOneOf: [ - ['target', 'text'], - ['ref', 'text'], - ['target', 'content'], - ['ref', 'content'], - ], + "operationId": "doc.replace", + "intentAction": "replace", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "ref", + "text" + ], + [ + "target", + "content" + ], + [ + "ref", + "content" + ] + ] }, { - operationId: 'doc.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['ref']], + "operationId": "doc.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "ref" + ] + ] }, { - operationId: 'doc.history.undo', - intentAction: 'undo', + "operationId": "doc.history.undo", + "intentAction": "undo" }, { - operationId: 'doc.history.redo', - intentAction: 'redo', - }, - ], + "operationId": "doc.history.redo", + "intentAction": "redo" + } + ] }, { - toolName: 'superdoc_format', - description: - 'Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require "all" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action "inline" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via "ref". Action "set_style" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions "set_alignment", "set_indentation", "set_spacing", "set_direction", and "set_flow_options" change paragraph-level properties and require a block target: {kind:"block", nodeType:"paragraph", nodeId:""}, NOT a ref. Use "set_flow_options" with pageBreakBefore:true to start a paragraph on a new page. Supports "dryRun" and "changeMode: tracked" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:"block", start:{kind:"nodeEdge",...}} or selection-like structures for paragraph actions. ONLY {kind:"block", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {"action":"inline","ref":"","inline":{"bold":true}}\n 2. {"action":"inline","ref":"","inline":{"fontFamily":"Calibri","fontSize":11,"color":"#000000","bold":false}}\n 3. {"action":"set_alignment","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"alignment":"center"}\n 4. {"action":"set_flow_options","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"pageBreakBefore":true}\n 5. {"action":"set_spacing","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"lineSpacing":{"rule":"auto","value":1.5}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'inline', - 'set_alignment', - 'set_direction', - 'set_flow_options', - 'set_indentation', - 'set_spacing', - 'set_style', + "toolName": "superdoc_format", + "description": "Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require \"all\" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action \"inline\" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via \"ref\". Action \"set_style\" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions \"set_alignment\", \"set_indentation\", \"set_spacing\", \"set_direction\", and \"set_flow_options\" change paragraph-level properties and require a block target: {kind:\"block\", nodeType:\"paragraph\", nodeId:\"\"}, NOT a ref. Use \"set_flow_options\" with pageBreakBefore:true to start a paragraph on a new page. Supports \"dryRun\" and \"changeMode: tracked\" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:\"block\", start:{kind:\"nodeEdge\",...}} or selection-like structures for paragraph actions. ONLY {kind:\"block\", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"bold\":true}}\n 2. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"fontFamily\":\"Calibri\",\"fontSize\":11,\"color\":\"#000000\",\"bold\":false}}\n 3. {\"action\":\"set_alignment\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"alignment\":\"center\"}\n 4. {\"action\":\"set_flow_options\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"pageBreakBefore\":true}\n 5. {\"action\":\"set_spacing\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"lineSpacing\":{\"rule\":\"auto\",\"value\":1.5}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "inline", + "set_alignment", + "set_direction", + "set_flow_options", + "set_indentation", + "set_spacing", + "set_style" ], - description: - 'The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style.', + "description": "The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'." }, - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ - { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "stylisticSets": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - description: - "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions.", - }, - ref: { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions.", - }, - styleId: { - type: 'string', - minLength: 1, - description: - "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'.", - }, - alignment: { - enum: ['left', 'center', 'right', 'justify'], - description: - "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'.", - }, - left: { - type: 'integer', - minimum: 0, - description: - "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - right: { - type: 'integer', - minimum: 0, - description: - "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - firstLine: { - type: 'integer', - minimum: 0, - description: - "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions.", - }, - hanging: { - type: 'integer', - minimum: 0, - description: - "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions.", - }, - before: { - type: 'integer', - minimum: 0, - description: - "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - after: { - type: 'integer', - minimum: 0, - description: - "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - line: { - type: 'integer', - minimum: 1, - description: - "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions.", - }, - lineRule: { - enum: ['auto', 'exact', 'atLeast'], - description: - "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions.", - }, - contextualSpacing: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - pageBreakBefore: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - suppressAutoHyphens: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - direction: { - type: 'string', - enum: ['ltr', 'rtl'], - description: "Required for action 'set_direction'.", - }, - alignmentPolicy: { - type: 'string', - enum: ['preserve', 'matchDirection'], - description: "Only for action 'set_direction'. Omit for other actions.", + "additionalProperties": false, + "minProperties": 1, + "description": "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions." + }, + "ref": { + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions." + }, + "styleId": { + "type": "string", + "minLength": 1, + "description": "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'." + }, + "alignment": { + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'." + }, + "left": { + "type": "integer", + "minimum": 0, + "description": "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "right": { + "type": "integer", + "minimum": 0, + "description": "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "firstLine": { + "type": "integer", + "minimum": 0, + "description": "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions." + }, + "hanging": { + "type": "integer", + "minimum": 0, + "description": "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions." + }, + "before": { + "type": "integer", + "minimum": 0, + "description": "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "after": { + "type": "integer", + "minimum": 0, + "description": "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions." + }, + "lineRule": { + "enum": [ + "auto", + "exact", + "atLeast" + ], + "description": "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions." + }, + "contextualSpacing": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "pageBreakBefore": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." }, + "suppressAutoHyphens": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "direction": { + "type": "string", + "enum": [ + "ltr", + "rtl" + ], + "description": "Required for action 'set_direction'." + }, + "alignmentPolicy": { + "type": "string", + "enum": [ + "preserve", + "matchDirection" + ], + "description": "Only for action 'set_direction'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.format.apply', - intentAction: 'inline', - requiredOneOf: [ - ['target', 'inline'], - ['ref', 'inline'], - ], + "operationId": "doc.format.apply", + "intentAction": "inline", + "requiredOneOf": [ + [ + "target", + "inline" + ], + [ + "ref", + "inline" + ] + ] }, { - operationId: 'doc.styles.paragraph.setStyle', - intentAction: 'set_style', - required: ['target', 'styleId'], + "operationId": "doc.styles.paragraph.setStyle", + "intentAction": "set_style", + "required": [ + "target", + "styleId" + ] }, { - operationId: 'doc.format.paragraph.setAlignment', - intentAction: 'set_alignment', - required: ['target', 'alignment'], + "operationId": "doc.format.paragraph.setAlignment", + "intentAction": "set_alignment", + "required": [ + "target", + "alignment" + ] }, { - operationId: 'doc.format.paragraph.setIndentation', - intentAction: 'set_indentation', - required: ['target'], + "operationId": "doc.format.paragraph.setIndentation", + "intentAction": "set_indentation", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setSpacing', - intentAction: 'set_spacing', - required: ['target'], + "operationId": "doc.format.paragraph.setSpacing", + "intentAction": "set_spacing", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setFlowOptions', - intentAction: 'set_flow_options', - requiredOneOf: [ - ['target', 'contextualSpacing'], - ['target', 'pageBreakBefore'], - ['target', 'suppressAutoHyphens'], - ], + "operationId": "doc.format.paragraph.setFlowOptions", + "intentAction": "set_flow_options", + "requiredOneOf": [ + [ + "target", + "contextualSpacing" + ], + [ + "target", + "pageBreakBefore" + ], + [ + "target", + "suppressAutoHyphens" + ] + ] }, { - operationId: 'doc.format.paragraph.setDirection', - intentAction: 'set_direction', - required: ['target', 'direction'], - }, - ], + "operationId": "doc.format.paragraph.setDirection", + "intentAction": "set_direction", + "required": [ + "target", + "direction" + ] + } + ] }, { - toolName: 'superdoc_create', - description: - 'IMPORTANT: For headings and paragraphs, use superdoc_edit with type "markdown" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a "heading", use action "heading" with a level (default 1). Use action "paragraph" for regular body text. Position with "at": {kind:"documentEnd"} (default), {kind:"documentStart"}, or {kind:"after"/"before", target:{kind:"block", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next "at" target to maintain correct ordering. Do NOT use newlines in "text" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {"action":"paragraph","text":"New paragraph content.","at":{"kind":"documentEnd"}}\n 2. {"action":"heading","text":"Section Title","level":2,"at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 3. {"action":"paragraph","text":"Chained item.","at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 4. {"action":"table","rows":3,"columns":4,"at":{"kind":"documentEnd"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['heading', 'paragraph', 'table'], - description: 'The action to perform. One of: heading, paragraph, table.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - at: { - oneOf: [ + "toolName": "superdoc_create", + "description": "IMPORTANT: For headings and paragraphs, use superdoc_edit with type \"markdown\" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a \"heading\", use action \"heading\" with a level (default 1). Use action \"paragraph\" for regular body text. Position with \"at\": {kind:\"documentEnd\"} (default), {kind:\"documentStart\"}, or {kind:\"after\"/\"before\", target:{kind:\"block\", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next \"at\" target to maintain correct ordering. Do NOT use newlines in \"text\" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {\"action\":\"paragraph\",\"text\":\"New paragraph content.\",\"at\":{\"kind\":\"documentEnd\"}}\n 2. {\"action\":\"heading\",\"text\":\"Section Title\",\"level\":2,\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 3. {\"action\":\"paragraph\",\"text\":\"Chained item.\",\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 4. {\"action\":\"table\",\"rows\":3,\"columns\":4,\"at\":{\"kind\":\"documentEnd\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "heading", + "paragraph", + "table" + ], + "description": "The action to perform. One of: heading, paragraph, table." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "at": { + "oneOf": [ { - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - ], + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + } + ] }, { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - ], - }, + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + } + ] + } ], - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement." }, - text: { - oneOf: [ + "text": { + "oneOf": [ { - type: 'string', - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "type": "string", + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, { - type: 'string', - description: 'Heading text content.', - }, + "type": "string", + "description": "Heading text content." + } ], - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, - input: { - oneOf: [ + "input": { + "oneOf": [ { - type: 'object', - description: 'Full paragraph input as JSON (alternative to individual text/at params).', + "type": "object", + "description": "Full paragraph input as JSON (alternative to individual text/at params)." }, { - type: 'object', - description: 'Full heading input as JSON (alternative to individual text/level/at params).', - }, + "type": "object", + "description": "Full heading input as JSON (alternative to individual text/level/at params)." + } ], - description: 'Full paragraph input as JSON (alternative to individual text/at params).', - }, - level: { - type: 'integer', - minimum: 1, - maximum: 6, - description: "Heading level (1-6). Required for action 'heading'.", - }, - rows: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, - columns: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, + "description": "Full paragraph input as JSON (alternative to individual text/at params)." + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6). Required for action 'heading'." + }, + "rows": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + }, + "columns": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.create.paragraph', - intentAction: 'paragraph', + "operationId": "doc.create.paragraph", + "intentAction": "paragraph" }, { - operationId: 'doc.create.heading', - intentAction: 'heading', - required: ['level'], + "operationId": "doc.create.heading", + "intentAction": "heading", + "required": [ + "level" + ] }, { - operationId: 'doc.create.table', - intentAction: 'table', - required: ['rows', 'columns'], - }, - ], + "operationId": "doc.create.table", + "intentAction": "table", + "required": [ + "rows", + "columns" + ] + } + ] }, { - toolName: 'superdoc_list', - description: - 'Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:"block", nodeType:"listItem", nodeId:""}. Exceptions: "create" and "attach" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:"blocks"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• "create": make a NEW list from paragraphs. Two modes: mode:"empty" with at:{kind:"block", nodeType:"paragraph", nodeId} converts a single paragraph; mode:"fromParagraphs" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset ("disc"|"circle"|"square"|"dash" for bullets; "decimal"|"decimalParenthesis"|"lowerLetter"|"upperLetter"|"lowerRoman"|"upperRoman" for ordered) or a custom style. Use "create" to start a fresh list: NOT to extend an existing one (use "attach" for that).\n• "attach": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:"block", nodeType:"listItem", nodeId:""} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see "join" below).\n• "set_type": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:"ordered" or "bullet". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• "detach": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• "insert": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:"before"|"after" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• "indent" / "outdent": bump the target item\'s nesting level by one (0-8 range). Pass target:{listItem}.\n• "set_level": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• "set_value": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• "continue_previous": make the target\'s sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• "merge": merge the target\'s sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:"withPrevious" or "withNext". Absorbed items adopt the absorbing sequence\'s numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• "split": split the target\'s sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {"action":"create","mode":"fromParagraphs","preset":"disc","target":{"from":{"kind":"block","nodeType":"paragraph","nodeId":""},"to":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 2. {"action":"set_type","target":{"kind":"block","nodeType":"listItem","nodeId":""},"kind":"ordered"}\n 3. {"action":"insert","target":{"kind":"block","nodeType":"listItem","nodeId":""},"position":"after","text":"New list item"}\n 4. {"action":"indent","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 5. {"action":"merge","target":{"kind":"block","nodeType":"listItem","nodeId":""},"direction":"withPrevious"}\n 6. {"action":"split","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 7. {"action":"set_value","target":{"kind":"block","nodeType":"listItem","nodeId":""},"value":1}\n 8. {"action":"continue_previous","target":{"kind":"block","nodeType":"listItem","nodeId":""}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'attach', - 'continue_previous', - 'create', - 'delete', - 'detach', - 'indent', - 'insert', - 'merge', - 'outdent', - 'set_level', - 'set_type', - 'set_value', - 'split', + "toolName": "superdoc_list", + "description": "Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}. Exceptions: \"create\" and \"attach\" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:\"blocks\"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• \"create\": make a NEW list from paragraphs. Two modes: mode:\"empty\" with at:{kind:\"block\", nodeType:\"paragraph\", nodeId} converts a single paragraph; mode:\"fromParagraphs\" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset (\"disc\"|\"circle\"|\"square\"|\"dash\" for bullets; \"decimal\"|\"decimalParenthesis\"|\"lowerLetter\"|\"upperLetter\"|\"lowerRoman\"|\"upperRoman\" for ordered) or a custom style. Use \"create\" to start a fresh list: NOT to extend an existing one (use \"attach\" for that).\n• \"attach\": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see \"join\" below).\n• \"set_type\": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:\"ordered\" or \"bullet\". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• \"detach\": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• \"insert\": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:\"before\"|\"after\" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• \"indent\" / \"outdent\": bump the target item's nesting level by one (0-8 range). Pass target:{listItem}.\n• \"set_level\": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• \"set_value\": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• \"continue_previous\": make the target's sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• \"merge\": merge the target's sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:\"withPrevious\" or \"withNext\". Absorbed items adopt the absorbing sequence's numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• \"split\": split the target's sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"mode\":\"fromParagraphs\",\"preset\":\"disc\",\"target\":{\"from\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"to\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 2. {\"action\":\"set_type\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"kind\":\"ordered\"}\n 3. {\"action\":\"insert\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"position\":\"after\",\"text\":\"New list item\"}\n 4. {\"action\":\"indent\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 5. {\"action\":\"merge\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"direction\":\"withPrevious\"}\n 6. {\"action\":\"split\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 7. {\"action\":\"set_value\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"value\":1}\n 8. {\"action\":\"continue_previous\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "attach", + "continue_previous", + "create", + "delete", + "detach", + "indent", + "insert", + "merge", + "outdent", + "set_level", + "set_type", + "set_value", + "split" ], - description: - 'The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split.', + "description": "The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ListItemAddress', - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "$ref": "#/$defs/ListItemAddress", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - description: - "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}.", - }, + "$ref": "#/$defs/BlockAddressOrRange", + "description": "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}." + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - }, + "$ref": "#/$defs/BlockAddressOrRange" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } + ], + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'." + }, + "position": { + "enum": [ + "before", + "after" + ], + "description": "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'." + }, + "text": { + "type": "string", + "description": "Text content for the new list item. Only for action 'insert'. Omit for other actions." + }, + "input": { + "type": "object", + "description": "Operation input as JSON object." + }, + "nodeId": { + "type": "string", + "description": "Node ID of the target list item." + }, + "mode": { + "enum": [ + "empty", + "fromParagraphs" ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'.", - }, - position: { - enum: ['before', 'after'], - description: - "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'.", - }, - text: { - type: 'string', - description: "Text content for the new list item. Only for action 'insert'. Omit for other actions.", - }, - input: { - type: 'object', - description: 'Operation input as JSON object.', - }, - nodeId: { - type: 'string', - description: 'Node ID of the target list item.', - }, - mode: { - enum: ['empty', 'fromParagraphs'], - description: - "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'.", - }, - at: { - $ref: '#/$defs/BlockAddress', - description: - "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions.", - }, - kind: { - enum: ['ordered', 'bullet'], - description: - "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'.", - }, - level: { - oneOf: [ + "description": "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'." + }, + "at": { + "$ref": "#/$defs/BlockAddress", + "description": "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions." + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ], + "description": "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'." + }, + "level": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'integer', - minimum: 0, - maximum: 8, - description: 'List nesting level (0-8). 0 is the top level.', + "type": "integer", + "minimum": 0, + "maximum": 8, + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: 'List nesting level (0-8). 0 is the top level.', + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: "List nesting level (0-8). 0 is the top level. Required for action 'set_level'.", - }, - preset: { - enum: [ - 'decimal', - 'decimalParenthesis', - 'lowerLetter', - 'upperLetter', - 'lowerRoman', - 'upperRoman', - 'disc', - 'circle', - 'square', - 'dash', + "description": "List nesting level (0-8). 0 is the top level. Required for action 'set_level'." + }, + "preset": { + "enum": [ + "decimal", + "decimalParenthesis", + "lowerLetter", + "upperLetter", + "lowerRoman", + "upperRoman", + "disc", + "circle", + "square", + "dash" ], - description: - "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions.", - }, - style: { - type: 'object', - properties: { - version: { - const: 1, - type: 'number', + "description": "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions." + }, + "style": { + "type": "object", + "properties": { + "version": { + "const": 1, + "type": "number" }, - levels: { - type: 'array', - items: { - type: 'object', - properties: { - level: { - type: 'integer', - minimum: 0, - maximum: 8, + "levels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 0, + "maximum": 8 }, - numFmt: { - type: 'string', + "numFmt": { + "type": "string" }, - lvlText: { - type: 'string', + "lvlText": { + "type": "string" }, - start: { - type: 'integer', + "start": { + "type": "integer" }, - alignment: { - enum: ['left', 'center', 'right'], + "alignment": { + "enum": [ + "left", + "center", + "right" + ] }, - indents: { - type: 'object', - properties: { - left: { - type: 'integer', - }, - hanging: { - type: 'integer', + "indents": { + "type": "object", + "properties": { + "left": { + "type": "integer" }, - firstLine: { - type: 'integer', + "hanging": { + "type": "integer" }, + "firstLine": { + "type": "integer" + } }, - additionalProperties: false, + "additionalProperties": false }, - trailingCharacter: { - enum: ['tab', 'space', 'nothing'], + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] }, - markerFont: { - type: 'string', + "markerFont": { + "type": "string" }, - pictureBulletId: { - type: 'integer', + "pictureBulletId": { + "type": "integer" }, - tabStopAt: { - type: ['integer', 'null'], - }, - }, - additionalProperties: false, - required: ['level'], - }, - }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + } + } }, - additionalProperties: false, - required: ['version', 'levels'], - description: "Only for action 'create'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "version", + "levels" + ], + "description": "Only for action 'create'. Omit for other actions." }, - sequence: { - oneOf: [ + "sequence": { + "oneOf": [ { - type: 'object', - properties: { - mode: { - const: 'new', - type: 'string', - }, - startAt: { - type: 'integer', - minimum: 1, - }, + "type": "object", + "properties": { + "mode": { + "const": "new", + "type": "string" + }, + "startAt": { + "type": "integer", + "minimum": 1 + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, { - type: 'object', - properties: { - mode: { - const: 'continuePrevious', - type: 'string', - }, + "type": "object", + "properties": { + "mode": { + "const": "continuePrevious", + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } ], - description: "Only for action 'create'. Omit for other actions.", + "description": "Only for action 'create'. Omit for other actions." }, - attachTo: { - $ref: '#/$defs/ListItemAddress', - description: "Required for action 'attach'.", + "attachTo": { + "$ref": "#/$defs/ListItemAddress", + "description": "Required for action 'attach'." }, - direction: { - enum: ['withPrevious', 'withNext'], - description: "Required for action 'merge'.", - }, - restartNumbering: { - type: 'boolean', - description: "Only for action 'split'. Omit for other actions.", + "direction": { + "enum": [ + "withPrevious", + "withNext" + ], + "description": "Required for action 'merge'." }, - value: { - type: ['integer', 'null'], - description: "Required for action 'set_value'.", + "restartNumbering": { + "type": "boolean", + "description": "Only for action 'split'. Omit for other actions." }, - continuity: { - enum: ['preserve', 'none'], - description: - "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions.", + "value": { + "type": [ + "integer", + "null" + ], + "description": "Required for action 'set_value'." }, + "continuity": { + "enum": [ + "preserve", + "none" + ], + "description": "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.lists.insert', - intentAction: 'insert', - required: ['target', 'position'], + "operationId": "doc.lists.insert", + "intentAction": "insert", + "required": [ + "target", + "position" + ] }, { - operationId: 'doc.lists.create', - intentAction: 'create', - required: ['mode'], + "operationId": "doc.lists.create", + "intentAction": "create", + "required": [ + "mode" + ] }, { - operationId: 'doc.lists.attach', - intentAction: 'attach', - required: ['target', 'attachTo'], + "operationId": "doc.lists.attach", + "intentAction": "attach", + "required": [ + "target", + "attachTo" + ] }, { - operationId: 'doc.lists.detach', - intentAction: 'detach', - required: ['target'], + "operationId": "doc.lists.detach", + "intentAction": "detach", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.delete', - intentAction: 'delete', - required: ['target'], + "operationId": "doc.lists.delete", + "intentAction": "delete", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.indent', - intentAction: 'indent', - required: ['target'], + "operationId": "doc.lists.indent", + "intentAction": "indent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.outdent', - intentAction: 'outdent', - required: ['target'], + "operationId": "doc.lists.outdent", + "intentAction": "outdent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.merge', - intentAction: 'merge', - required: ['target', 'direction'], + "operationId": "doc.lists.merge", + "intentAction": "merge", + "required": [ + "target", + "direction" + ] }, { - operationId: 'doc.lists.split', - intentAction: 'split', - required: ['target'], + "operationId": "doc.lists.split", + "intentAction": "split", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setLevel', - intentAction: 'set_level', - required: ['target', 'level'], + "operationId": "doc.lists.setLevel", + "intentAction": "set_level", + "required": [ + "target", + "level" + ] }, { - operationId: 'doc.lists.setValue', - intentAction: 'set_value', - required: ['target', 'value'], + "operationId": "doc.lists.setValue", + "intentAction": "set_value", + "required": [ + "target", + "value" + ] }, { - operationId: 'doc.lists.continuePrevious', - intentAction: 'continue_previous', - required: ['target'], + "operationId": "doc.lists.continuePrevious", + "intentAction": "continue_previous", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setType', - intentAction: 'set_type', - required: ['target', 'kind'], - }, - ], + "operationId": "doc.lists.setType", + "intentAction": "set_type", + "required": [ + "target", + "kind" + ] + } + ] }, { - toolName: 'superdoc_comment', - description: - 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['create', 'delete', 'get', 'list', 'update'], - description: 'The action to perform. One of: create, delete, get, list, update.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - text: { - oneOf: [ + "toolName": "superdoc_comment", + "description": "Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action \"create\" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:\"text\", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:\"text\", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass \"parentId\" with the parent comment ID. Action \"list\" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action \"get\" retrieves a single comment by ID. Action \"update\" changes status to \"resolved\" or marks as internal. Action \"delete\" removes a comment or reply by ID. Do NOT pass \"ref\", \"id\", or \"parentId\" when creating a new top-level comment; only \"action\", \"text\", and \"target\" are needed.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"text\":\"Please review this section.\",\"target\":{\"kind\":\"text\",\"blockId\":\"\",\"range\":{\"start\":5,\"end\":25}}}\n 2. {\"action\":\"list\",\"limit\":20,\"offset\":0}\n 3. {\"action\":\"update\",\"id\":\"\",\"status\":\"resolved\"}\n 4. {\"action\":\"delete\",\"id\":\"\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create", + "delete", + "get", + "list", + "update" + ], + "description": "The action to perform. One of: create, delete, get, list, update." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "text": { + "oneOf": [ { - type: 'string', - description: 'Comment text content.', + "type": "string", + "description": "Comment text content." }, { - type: 'string', - description: 'Updated comment text.', - }, + "type": "string", + "description": "Updated comment text." + } ], - description: "Comment text content. Required for action 'create'.", + "description": "Comment text content. Required for action 'create'." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextAddress" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/SelectionTarget', + "$ref": "#/$defs/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } ], - description: - "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextAddress" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/SelectionTarget', + "$ref": "#/$defs/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, - ], - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] + } ], - description: - "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions." }, - parentId: { - type: 'string', - description: - "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions.", + "parentId": { + "type": "string", + "description": "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions." }, - id: { - type: 'string', - description: "Required for actions 'delete', 'get'.", + "id": { + "type": "string", + "description": "Required for actions 'delete', 'get'." }, - status: { - enum: ['resolved', 'active'], - description: - "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions.", - }, - isInternal: { - type: 'boolean', - description: - "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions.", + "status": { + "enum": [ + "resolved", + "active" + ], + "description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions." }, - includeResolved: { - type: 'boolean', - description: - "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions.", + "isInternal": { + "type": "boolean", + "description": "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions." }, - limit: { - type: 'integer', - description: "Maximum number of comments to return. Only for action 'list'. Omit for other actions.", + "includeResolved": { + "type": "boolean", + "description": "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions." }, - offset: { - type: 'integer', - description: "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions.", + "limit": { + "type": "integer", + "description": "Maximum number of comments to return. Only for action 'list'. Omit for other actions." }, + "offset": { + "type": "integer", + "description": "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.comments.create', - intentAction: 'create', - required: ['text'], + "operationId": "doc.comments.create", + "intentAction": "create", + "required": [ + "text" + ] }, { - operationId: 'doc.comments.patch', - intentAction: 'update', + "operationId": "doc.comments.patch", + "intentAction": "update" }, { - operationId: 'doc.comments.delete', - intentAction: 'delete', - required: ['id'], + "operationId": "doc.comments.delete", + "intentAction": "delete", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.get', - intentAction: 'get', - required: ['id'], + "operationId": "doc.comments.get", + "intentAction": "get", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.list', - intentAction: 'list', - }, - ], + "operationId": "doc.comments.list", + "intentAction": "list" + } + ] }, { - toolName: 'superdoc_track_changes', - description: - 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"kind":"range","range":{"kind":"text","segments":[{"blockId":"","range":{"start":0,"end":5}}]}}}\n 5. {"action":"decide","decision":"reject","target":{"scope":"all"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['decide', 'list'], - description: 'The action to perform. One of: decide, list.', - }, - limit: { - type: 'integer', - description: "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions.", - }, - offset: { - type: 'integer', - description: - "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", - }, - type: { - enum: ['insert', 'delete', 'replacement', 'format'], - description: - "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'decide'. Omit for other actions.", - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: - 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions. Only for action \'decide\'. Omit for other actions.', - }, - decision: { - enum: ['accept', 'reject'], - description: "Required for action 'decide'.", - }, - target: { - oneOf: [ + "toolName": "superdoc_track_changes", + "description": "Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action \"list\" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action \"decide\" accepts or rejects changes. Pass decision:\"accept\" to apply the change permanently, or decision:\"reject\" to discard it. Target a single change with {id:\"\"}, a partial selection with {kind:\"range\", range:{...}}, or all changes at once with {scope:\"all\"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {\"action\":\"list\"}\n 2. {\"action\":\"list\",\"type\":\"replacement\",\"limit\":10}\n 3. {\"action\":\"decide\",\"decision\":\"accept\",\"target\":{\"id\":\"\"}}\n 4. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"kind\":\"range\",\"range\":{\"kind\":\"text\",\"segments\":[{\"blockId\":\"\",\"range\":{\"start\":0,\"end\":5}}]}}}\n 5. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"scope\":\"all\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "decide", + "list" + ], + "description": "The action to perform. One of: decide, list." + }, + "limit": { + "type": "integer", + "description": "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions." + }, + "offset": { + "type": "integer", + "description": "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions." + }, + "type": { + "enum": [ + "insert", + "delete", + "replacement", + "format" + ], + "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'decide'. Omit for other actions." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions. Only for action 'decide'. Omit for other actions." + }, + "decision": { + "enum": [ + "accept", + "reject" + ], + "description": "Required for action 'decide'." + }, + "target": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + } }, - additionalProperties: false, - required: ['id'], + "additionalProperties": false, + "required": [ + "id" + ] }, { - type: 'object', - properties: { - kind: { - const: 'range', - type: 'string', - }, - range: { - $ref: '#/$defs/TextTarget', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, - part: { - type: 'string', - description: 'Optional part discriminator for the range target.', - }, + "type": "object", + "properties": { + "kind": { + "const": "range", + "type": "string" + }, + "range": { + "$ref": "#/$defs/TextTarget" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + }, + "part": { + "type": "string", + "description": "Optional part discriminator for the range target." + } }, - additionalProperties: false, - required: ['kind', 'range'], + "additionalProperties": false, + "required": [ + "kind", + "range" + ] }, { - type: 'object', - properties: { - scope: { - enum: ['all'], - }, - story: { - oneOf: [ + "type": "object", + "properties": { + "scope": { + "enum": [ + "all" + ] + }, + "story": { + "oneOf": [ { - $ref: '#/$defs/StoryLocator', + "$ref": "#/$defs/StoryLocator" }, { - const: 'all', - type: 'string', - }, + "const": "all", + "type": "string" + } ], - description: - "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", - }, + "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story." + } }, - additionalProperties: false, - required: ['scope'], - }, + "additionalProperties": false, + "required": [ + "scope" + ] + } ], - description: "Required for action 'decide'.", - }, + "description": "Required for action 'decide'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.trackChanges.list', - intentAction: 'list', + "operationId": "doc.trackChanges.list", + "intentAction": "list" }, { - operationId: 'doc.trackChanges.decide', - intentAction: 'decide', - required: ['decision', 'target'], - }, - ], + "operationId": "doc.trackChanges.decide", + "intentAction": "decide", + "required": [ + "decision", + "target" + ] + } + ] }, { - toolName: 'superdoc_search', - description: - 'Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in "where" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The "require" parameter controls match cardinality: "first" returns one match, "all" returns every match, "exactlyOne" fails if not exactly one match. Supports scoping via "within" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {"select":{"type":"text","pattern":"Introduction"},"require":"first"}\n 2. {"select":{"type":"text","pattern":"total amount"},"require":"all"}\n 3. {"select":{"type":"node","nodeType":"heading"},"require":"all"}\n 4. {"select":{"type":"text","pattern":"contract"},"within":{"kind":"block","nodeType":"paragraph","nodeId":"abc123"},"require":"first"}', - inputSchema: { - type: 'object', - properties: { - select: { - description: - "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", - oneOf: [ + "toolName": "superdoc_search", + "description": "Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in \"where\" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The \"require\" parameter controls match cardinality: \"first\" returns one match, \"all\" returns every match, \"exactlyOne\" fails if not exactly one match. Supports scoping via \"within\" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {\"select\":{\"type\":\"text\",\"pattern\":\"Introduction\"},\"require\":\"first\"}\n 2. {\"select\":{\"type\":\"text\",\"pattern\":\"total amount\"},\"require\":\"all\"}\n 3. {\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"}\n 4. {\"select\":{\"type\":\"text\",\"pattern\":\"contract\"},\"within\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"abc123\"},\"require\":\"first\"}", + "inputSchema": { + "type": "object", + "properties": { + "select": { + "description": "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', - }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" + }, + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." + }, + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress", + "description": "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "require": { + "enum": [ + "any", + "first", + "exactlyOne", + "all" ], + "description": "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0)." }, - within: { - $ref: '#/$defs/BlockNodeAddress', - description: "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - require: { - enum: ['any', 'first', 'exactlyOne', 'all'], - description: - "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0).", - }, - mode: { - enum: ['strict', 'candidates'], - description: - "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches).", - }, - includeNodes: { - type: 'boolean', - description: 'When true, includes full node data in results. Default: false.', - }, - limit: { - type: 'integer', - minimum: 1, - description: 'Maximum number of matches to return.', - }, - offset: { - type: 'integer', - minimum: 0, - description: 'Number of matches to skip for pagination.', - }, + "mode": { + "enum": [ + "strict", + "candidates" + ], + "description": "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches)." + }, + "includeNodes": { + "type": "boolean", + "description": "When true, includes full node data in results. Default: false." + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matches to return." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Number of matches to skip for pagination." + } }, - required: ['select'], - additionalProperties: false, + "required": [ + "select" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.query.match', - intentAction: 'match', - required: ['select'], - }, - ], + "operationId": "doc.query.match", + "intentAction": "match", + "required": [ + "select" + ] + } + ] }, { - toolName: 'superdoc_mutations', - description: - 'All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"} or {by:"ref", ref:"..."} or {by:"block", nodeType:"paragraph", nodeId:"..."}), and "args" with operation-specific parameters. Use {by:"block", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action "blocks" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:"blocks" and includeText:true, then rewrite the matching block by nodeId. Use {by:"select"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, "where" targets an existing anchor block and args.position ("before" or "after") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require "all", use a node selector to format every heading or paragraph at once: {by:"select", select:{type:"node", nodeType:"heading"}, require:"all"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action "preview" dry-runs the plan. Action "apply" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {"action":"apply","atomic":true,"changeMode":"direct","steps":[{"id":"s1","op":"text.rewrite","where":{"by":"select","select":{"type":"text","pattern":"old term"},"require":"all"},"args":{"replacement":{"text":"new term"}}},{"id":"s2","op":"text.delete","where":{"by":"select","select":{"type":"text","pattern":" (deprecated)"},"require":"all"},"args":{}}]}\n 2. {"action":"apply","steps":[{"id":"r1","op":"text.rewrite","where":{"by":"block","nodeType":"paragraph","nodeId":""},"args":{"replacement":{"text":"Updated clause text."}}},{"id":"f1","op":"format.apply","where":{"by":"select","select":{"type":"node","nodeType":"heading"},"require":"all"},"args":{"inline":{"color":"#FF0000"}}},{"id":"f2","op":"format.apply","where":{"by":"select","select":{"type":"text","pattern":"Confidential Information"},"require":"all"},"args":{"inline":{"bold":true}}}]}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['apply', 'preview'], - description: 'The action to perform. One of: apply, preview.', - }, - expectedRevision: { - type: 'string', - description: - "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions.", - }, - atomic: { - const: true, - type: 'boolean', - description: 'Must be true. All steps execute as one atomic transaction.', - }, - changeMode: { - enum: ['direct', 'tracked'], - description: - "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided.", - }, - steps: { - type: 'array', - items: { - oneOf: [ + "toolName": "superdoc_mutations", + "description": "All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a \"where\" clause for targeting ({by:\"select\", select:{...}, require:\"first\"|\"exactlyOne\"|\"all\"} or {by:\"ref\", ref:\"...\"} or {by:\"block\", nodeType:\"paragraph\", nodeId:\"...\"}), and \"args\" with operation-specific parameters. Use {by:\"block\", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action \"blocks\" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:\"blocks\" and includeText:true, then rewrite the matching block by nodeId. Use {by:\"select\"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, \"where\" targets an existing anchor block and args.position (\"before\" or \"after\") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require \"all\", use a node selector to format every heading or paragraph at once: {by:\"select\", select:{type:\"node\", nodeType:\"heading\"}, require:\"all\"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action \"preview\" dry-runs the plan. Action \"apply\" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {\"action\":\"apply\",\"atomic\":true,\"changeMode\":\"direct\",\"steps\":[{\"id\":\"s1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"old term\"},\"require\":\"all\"},\"args\":{\"replacement\":{\"text\":\"new term\"}}},{\"id\":\"s2\",\"op\":\"text.delete\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\" (deprecated)\"},\"require\":\"all\"},\"args\":{}}]}\n 2. {\"action\":\"apply\",\"steps\":[{\"id\":\"r1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"args\":{\"replacement\":{\"text\":\"Updated clause text.\"}}},{\"id\":\"f1\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"},\"args\":{\"inline\":{\"color\":\"#FF0000\"}}},{\"id\":\"f2\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"Confidential Information\"},\"require\":\"all\"},\"args\":{\"inline\":{\"bold\":true}}}]}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "apply", + "preview" + ], + "description": "The action to perform. One of: apply, preview." + }, + "expectedRevision": { + "type": "string", + "description": "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions." + }, + "atomic": { + "const": true, + "type": "boolean", + "description": "Must be true. All steps execute as one atomic transaction." + }, + "changeMode": { + "enum": [ + "direct", + "tracked" + ], + "description": "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided." + }, + "steps": { + "type": "array", + "items": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.rewrite', - type: 'string', + "op": { + "const": "text.rewrite", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - ref: { - type: 'string', - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - replacement: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "replacement": { + "oneOf": [ { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, { - type: 'object', - properties: { - blocks: { - type: 'array', - items: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "blocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], - }, - }, + "additionalProperties": false, + "required": [ + "text" + ] + } + } }, - additionalProperties: false, - required: ['blocks'], - }, - ], + "additionalProperties": false, + "required": [ + "blocks" + ] + } + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear', 'merge'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear", + "merge" + ], + "type": "string" }, - requireUniform: { - type: 'boolean', + "requireUniform": { + "type": "boolean" }, - onNonUniform: { - enum: ['error', 'useLeadingRun', 'majority', 'union'], + "onNonUniform": { + "enum": [ + "error", + "useLeadingRun", + "majority", + "union" + ] }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, - paragraph: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear'], - type: 'string', - }, + "paragraph": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear" + ], + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['replacement'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "replacement" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.insert', - type: 'string', + "op": { + "const": "text.insert", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - position: { - enum: ['before', 'after'], + "args": { + "type": "object", + "properties": { + "position": { + "enum": [ + "before", + "after" + ] }, - content: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "content": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['inherit', 'set', 'clear'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "inherit", + "set", + "clear" + ], + "type": "string" }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['position', 'content'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "position", + "content" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.delete', - type: 'string', + "op": { + "const": "text.delete", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - ref: { - type: 'string', - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - behavior: { - $ref: '#/$defs/DeleteBehavior', - }, + "args": { + "type": "object", + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } }, - additionalProperties: false, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'format.apply', - type: 'string', + "op": { + "const": "format.apply", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - within: { - $ref: '#/$defs/BlockNodeAddress', - }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ + "stylisticSets": { + "oneOf": [ { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - alignment: { - type: 'string', - enum: ['left', 'center', 'right', 'justify'], - description: - 'Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step.', + "additionalProperties": false, + "minProperties": 1 }, - scope: { - type: 'string', - enum: ['match', 'block'], - description: - 'When "block", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use "block" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: "match".', + "alignment": { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step." }, + "scope": { + "type": "string", + "enum": [ + "match", + "block" + ], + "description": "When \"block\", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use \"block\" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: \"match\"." + } }, - additionalProperties: false, - minProperties: 1, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "minProperties": 1 + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'assert', - type: 'string', + "op": { + "const": "assert", + "type": "string" }, - where: { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "where": { + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'select'], + "additionalProperties": false, + "required": [ + "by", + "select" + ] }, - args: { - type: 'object', - properties: { - expectCount: { - type: 'number', - }, + "args": { + "type": "object", + "properties": { + "expectCount": { + "type": "number" + } }, - additionalProperties: false, - required: ['expectCount'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], - }, - ], + "additionalProperties": false, + "required": [ + "expectCount" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] + } + ] }, - description: - "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'apply'. Omit for other actions.", + "description": "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause." }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'apply'. Omit for other actions." + } }, - required: ['action', 'atomic', 'changeMode', 'steps'], - additionalProperties: false, + "required": [ + "action", + "atomic", + "changeMode", + "steps" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.mutations.preview', - intentAction: 'preview', - required: ['atomic', 'changeMode', 'steps'], + "operationId": "doc.mutations.preview", + "intentAction": "preview", + "required": [ + "atomic", + "changeMode", + "steps" + ] }, { - operationId: 'doc.mutations.apply', - intentAction: 'apply', - required: ['atomic', 'steps', 'changeMode'], - }, - ], + "operationId": "doc.mutations.apply", + "intentAction": "apply", + "required": [ + "atomic", + "steps", + "changeMode" + ] + } + ] }, { - toolName: 'superdoc_table', - description: - 'Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:"blocks"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: "" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:"first"|"last" with no columnIndex. Otherwise columnIndex + position:"left"|"right".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also "auto"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a "make it look [X]" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don\'t disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:"blocks"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST ("make it look [X]" / "style the whole table") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That\'s the #1 cause of "no visual change" complaints.\n Color palette: discover the document\'s palette by reading superdoc_get_content({action:"blocks"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue "1F3864" or dark grey "444444" for accents and "F2F2F2" / "E7E6E6" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:"all" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:"set_alignment". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:"inline". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document\'s default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like "Label: value", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:"table" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:"delete", target:{kind:"block", nodeType:"listItem", nodeId:""}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:"delete" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {"action":"insert_row","nodeId":""}\n 2. {"action":"insert_column","nodeId":"","position":"last"}\n 3. {"action":"merge_cells","nodeId":"","start":{"rowIndex":0,"columnIndex":0},"end":{"rowIndex":1,"columnIndex":1}}\n 4. {"action":"set_cell_text","nodeId":"","rowIndex":0,"columnIndex":0,"text":"Q1 Revenue"}\n 5. {"action":"set_row","nodeId":"","rowIndex":0,"heightPt":24,"rule":"atLeast"}\n 6. {"action":"set_borders","nodeId":"","mode":"applyTo","applyTo":"all","border":{"lineStyle":"single","lineWeightPt":1,"color":"#000000"}}\n 7. {"action":"set_shading","target":{"kind":"block","nodeType":"tableCell","nodeId":""},"color":"#E3F2FD"}\n 8. {"action":"set_style_options","nodeId":"","styleOptions":{"headerRow":true,"bandedRows":true}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'delete', - 'delete_column', - 'delete_row', - 'insert_column', - 'insert_row', - 'merge_cells', - 'set_borders', - 'set_cell', - 'set_cell_text', - 'set_column', - 'set_layout', - 'set_options', - 'set_row', - 'set_row_options', - 'set_shading', - 'set_style_options', - 'unmerge_cells', + "toolName": "superdoc_table", + "description": "Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:\"blocks\"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: \"\" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:\"first\"|\"last\" with no columnIndex. Otherwise columnIndex + position:\"left\"|\"right\".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also \"auto\"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a \"make it look [X]\" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don't disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:\"blocks\"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST (\"make it look [X]\" / \"style the whole table\") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That's the #1 cause of \"no visual change\" complaints.\n Color palette: discover the document's palette by reading superdoc_get_content({action:\"blocks\"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue \"1F3864\" or dark grey \"444444\" for accents and \"F2F2F2\" / \"E7E6E6\" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:\"all\" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:\"set_alignment\". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:\"inline\". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document's default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like \"Label: value\", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:\"table\" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:\"delete\", target:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:\"delete\" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {\"action\":\"insert_row\",\"nodeId\":\"\"}\n 2. {\"action\":\"insert_column\",\"nodeId\":\"\",\"position\":\"last\"}\n 3. {\"action\":\"merge_cells\",\"nodeId\":\"\",\"start\":{\"rowIndex\":0,\"columnIndex\":0},\"end\":{\"rowIndex\":1,\"columnIndex\":1}}\n 4. {\"action\":\"set_cell_text\",\"nodeId\":\"\",\"rowIndex\":0,\"columnIndex\":0,\"text\":\"Q1 Revenue\"}\n 5. {\"action\":\"set_row\",\"nodeId\":\"\",\"rowIndex\":0,\"heightPt\":24,\"rule\":\"atLeast\"}\n 6. {\"action\":\"set_borders\",\"nodeId\":\"\",\"mode\":\"applyTo\",\"applyTo\":\"all\",\"border\":{\"lineStyle\":\"single\",\"lineWeightPt\":1,\"color\":\"#000000\"}}\n 7. {\"action\":\"set_shading\",\"target\":{\"kind\":\"block\",\"nodeType\":\"tableCell\",\"nodeId\":\"\"},\"color\":\"#E3F2FD\"}\n 8. {\"action\":\"set_style_options\",\"nodeId\":\"\",\"styleOptions\":{\"headerRow\":true,\"bandedRows\":true}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "delete_column", + "delete_row", + "insert_column", + "insert_row", + "merge_cells", + "set_borders", + "set_cell", + "set_cell_text", + "set_column", + "set_layout", + "set_options", + "set_row", + "set_row_options", + "set_shading", + "set_style_options", + "unmerge_cells" ], - description: - 'The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells.', + "description": "The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableAddress', + "$ref": "#/$defs/TableAddress" }, { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableCellAddress', - }, - ], + "$ref": "#/$defs/TableCellAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableOrCellAddress', - }, - ], + "$ref": "#/$defs/TableOrCellAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, + "$ref": "#/$defs/BlockNodeAddress" + } ], - description: - "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}.", - }, - nodeId: { - type: 'string', - }, - preferredWidth: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - alignment: { - enum: ['left', 'center', 'right'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - leftIndentPt: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - autoFitMode: { - enum: ['fixedWidth', 'fitContents', 'fitWindow'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - tableDirection: { - enum: ['ltr', 'rtl'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - position: { - enum: ['above', 'below', 'left', 'right', 'first', 'last'], - description: "Required for action 'insert_column'.", - }, - count: { - type: 'integer', - minimum: 1, - description: "Only for actions 'insert_row', 'insert_column'. Omit for other actions.", - }, - rowIndex: { - type: 'integer', - minimum: 0, - description: - "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions.", - }, - heightPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_row'.", - }, - rule: { - enum: ['atLeast', 'exact', 'auto'], - description: "Required for action 'set_row'.", - }, - allowBreakAcrossPages: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - repeatHeader: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - columnIndex: { - type: 'integer', - minimum: 0, - description: "Required for actions 'delete_column', 'set_column'.", - }, - widthPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_column'.", - }, - start: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "description": "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}." + }, + "nodeId": { + "type": "string" + }, + "preferredWidth": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "alignment": { + "enum": [ + "left", + "center", + "right" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "leftIndentPt": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "autoFitMode": { + "enum": [ + "fixedWidth", + "fitContents", + "fitWindow" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "tableDirection": { + "enum": [ + "ltr", + "rtl" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "position": { + "enum": [ + "above", + "below", + "left", + "right", + "first", + "last" + ], + "description": "Required for action 'insert_column'." + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Only for actions 'insert_row', 'insert_column'. Omit for other actions." + }, + "rowIndex": { + "type": "integer", + "minimum": 0, + "description": "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions." + }, + "heightPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_row'." + }, + "rule": { + "enum": [ + "atLeast", + "exact", + "auto" + ], + "description": "Required for action 'set_row'." + }, + "allowBreakAcrossPages": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "repeatHeader": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "columnIndex": { + "type": "integer", + "minimum": 0, + "description": "Required for actions 'delete_column', 'set_column'." + }, + "widthPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_column'." + }, + "start": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", - }, - end: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." + }, + "end": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." }, - preferredWidthPt: { - type: 'number', - description: "Only for action 'set_cell'. Omit for other actions.", + "preferredWidthPt": { + "type": "number", + "description": "Only for action 'set_cell'. Omit for other actions." }, - verticalAlign: { - enum: ['top', 'center', 'bottom'], - description: "Only for action 'set_cell'. Omit for other actions.", + "verticalAlign": { + "enum": [ + "top", + "center", + "bottom" + ], + "description": "Only for action 'set_cell'. Omit for other actions." }, - wrapText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "wrapText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - fitText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "fitText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - text: { - type: 'string', - description: "Required for action 'set_cell_text'.", + "text": { + "type": "string", + "description": "Required for action 'set_cell_text'." }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" }, { - type: 'null', - }, + "type": "null" + } ], - description: "Required for action 'set_shading'.", + "description": "Required for action 'set_shading'." }, - styleId: { - type: 'string', - description: "Only for action 'set_style_options'. Omit for other actions.", + "styleId": { + "type": "string", + "description": "Only for action 'set_style_options'. Omit for other actions." }, - styleOptions: { - type: 'object', - properties: { - headerRow: { - type: 'boolean', + "styleOptions": { + "type": "object", + "properties": { + "headerRow": { + "type": "boolean" }, - lastRow: { - type: 'boolean', + "lastRow": { + "type": "boolean" }, - totalRow: { - type: 'boolean', + "totalRow": { + "type": "boolean" }, - firstColumn: { - type: 'boolean', + "firstColumn": { + "type": "boolean" }, - lastColumn: { - type: 'boolean', + "lastColumn": { + "type": "boolean" }, - bandedRows: { - type: 'boolean', - }, - bandedColumns: { - type: 'boolean', + "bandedRows": { + "type": "boolean" }, + "bandedColumns": { + "type": "boolean" + } }, - additionalProperties: false, - description: "Only for action 'set_style_options'. Omit for other actions.", - }, - mode: { - enum: ['applyTo', 'edges'], - description: "Required for action 'set_borders'.", + "additionalProperties": false, + "description": "Only for action 'set_style_options'. Omit for other actions." }, - applyTo: { - enum: ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'], - description: "Only for action 'set_borders'. Omit for other actions.", + "mode": { + "enum": [ + "applyTo", + "edges" + ], + "description": "Required for action 'set_borders'." + }, + "applyTo": { + "enum": [ + "all", + "outside", + "inside", + "top", + "bottom", + "left", + "right", + "insideH", + "insideV" + ], + "description": "Only for action 'set_borders'. Omit for other actions." }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', - }, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" + }, + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 + }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_borders'. Omit for other actions.", - }, - edges: { - type: 'object', - properties: { - top: { - oneOf: [ - { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "edges": { + "type": "object", + "properties": { + "top": { + "oneOf": [ + { + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bottom: { - oneOf: [ + "bottom": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - left: { - oneOf: [ + "left": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - right: { - oneOf: [ + "right": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideH: { - oneOf: [ + "insideH": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideV: { - oneOf: [ + "insideV": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], - }, - { - type: 'null', - }, - ], - }, + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] + }, + { + "type": "null" + } + ] + } }, - additionalProperties: false, - description: "Only for action 'set_borders'. Omit for other actions.", - }, - defaultCellMargins: { - type: 'object', - properties: { - topPt: { - type: 'number', - minimum: 0, - }, - rightPt: { - type: 'number', - minimum: 0, + "additionalProperties": false, + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "defaultCellMargins": { + "type": "object", + "properties": { + "topPt": { + "type": "number", + "minimum": 0 }, - bottomPt: { - type: 'number', - minimum: 0, + "rightPt": { + "type": "number", + "minimum": 0 }, - leftPt: { - type: 'number', - minimum: 0, + "bottomPt": { + "type": "number", + "minimum": 0 }, + "leftPt": { + "type": "number", + "minimum": 0 + } }, - additionalProperties: false, - required: ['topPt', 'rightPt', 'bottomPt', 'leftPt'], - description: "Only for action 'set_options'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "topPt", + "rightPt", + "bottomPt", + "leftPt" + ], + "description": "Only for action 'set_options'. Omit for other actions." }, - cellSpacingPt: { - oneOf: [ + "cellSpacingPt": { + "oneOf": [ { - type: 'number', - minimum: 0, + "type": "number", + "minimum": 0 }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_options'. Omit for other actions.", - }, + "description": "Only for action 'set_options'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.tables.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setLayout', - intentAction: 'set_layout', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setLayout", + "intentAction": "set_layout", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.insertRow', - intentAction: 'insert_row', - requiredOneOf: [ - ['target', 'position'], - ['target', 'rowIndex', 'position'], - ['nodeId', 'rowIndex', 'position'], - ['target'], - ['nodeId'], - ], + "operationId": "doc.tables.insertRow", + "intentAction": "insert_row", + "requiredOneOf": [ + [ + "target", + "position" + ], + [ + "target", + "rowIndex", + "position" + ], + [ + "nodeId", + "rowIndex", + "position" + ], + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteRow', - intentAction: 'delete_row', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.deleteRow", + "intentAction": "delete_row", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.setRowHeight', - intentAction: 'set_row', - requiredOneOf: [ - ['target', 'heightPt', 'rule'], - ['target', 'rowIndex', 'heightPt', 'rule'], - ['nodeId', 'rowIndex', 'heightPt', 'rule'], - ], + "operationId": "doc.tables.setRowHeight", + "intentAction": "set_row", + "requiredOneOf": [ + [ + "target", + "heightPt", + "rule" + ], + [ + "target", + "rowIndex", + "heightPt", + "rule" + ], + [ + "nodeId", + "rowIndex", + "heightPt", + "rule" + ] + ] }, { - operationId: 'doc.tables.setRowOptions', - intentAction: 'set_row_options', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.setRowOptions", + "intentAction": "set_row_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.insertColumn', - intentAction: 'insert_column', - requiredOneOf: [ - ['position', 'target'], - ['position', 'nodeId'], - ], + "operationId": "doc.tables.insertColumn", + "intentAction": "insert_column", + "requiredOneOf": [ + [ + "position", + "target" + ], + [ + "position", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteColumn', - intentAction: 'delete_column', - requiredOneOf: [ - ['columnIndex', 'target'], - ['columnIndex', 'nodeId'], - ], + "operationId": "doc.tables.deleteColumn", + "intentAction": "delete_column", + "requiredOneOf": [ + [ + "columnIndex", + "target" + ], + [ + "columnIndex", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setColumnWidth', - intentAction: 'set_column', - requiredOneOf: [ - ['columnIndex', 'widthPt', 'target'], - ['columnIndex', 'widthPt', 'nodeId'], - ], + "operationId": "doc.tables.setColumnWidth", + "intentAction": "set_column", + "requiredOneOf": [ + [ + "columnIndex", + "widthPt", + "target" + ], + [ + "columnIndex", + "widthPt", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.mergeCells', - intentAction: 'merge_cells', - requiredOneOf: [ - ['start', 'end', 'target'], - ['start', 'end', 'nodeId'], - ], + "operationId": "doc.tables.mergeCells", + "intentAction": "merge_cells", + "requiredOneOf": [ + [ + "start", + "end", + "target" + ], + [ + "start", + "end", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.unmergeCells', - intentAction: 'unmerge_cells', - requiredOneOf: [ - ['target'], - ['nodeId'], - ['target', 'rowIndex', 'columnIndex'], - ['nodeId', 'rowIndex', 'columnIndex'], - ], + "operationId": "doc.tables.unmergeCells", + "intentAction": "unmerge_cells", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ], + [ + "target", + "rowIndex", + "columnIndex" + ], + [ + "nodeId", + "rowIndex", + "columnIndex" + ] + ] }, { - operationId: 'doc.tables.setCellProperties', - intentAction: 'set_cell', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setCellProperties", + "intentAction": "set_cell", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setCellText', - intentAction: 'set_cell_text', - requiredOneOf: [ - ['target', 'text'], - ['nodeId', 'text'], - ['target', 'rowIndex', 'columnIndex', 'text'], - ['nodeId', 'rowIndex', 'columnIndex', 'text'], - ], + "operationId": "doc.tables.setCellText", + "intentAction": "set_cell_text", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "nodeId", + "text" + ], + [ + "target", + "rowIndex", + "columnIndex", + "text" + ], + [ + "nodeId", + "rowIndex", + "columnIndex", + "text" + ] + ] }, { - operationId: 'doc.tables.setShading', - intentAction: 'set_shading', - requiredOneOf: [ - ['color', 'target'], - ['color', 'nodeId'], - ], + "operationId": "doc.tables.setShading", + "intentAction": "set_shading", + "requiredOneOf": [ + [ + "color", + "target" + ], + [ + "color", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.applyStyle', - intentAction: 'set_style_options', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.applyStyle", + "intentAction": "set_style_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setBorders', - intentAction: 'set_borders', - requiredOneOf: [ - ['mode', 'applyTo', 'border', 'target'], - ['mode', 'applyTo', 'border', 'nodeId'], - ['mode', 'edges', 'target'], - ['mode', 'edges', 'nodeId'], - ], + "operationId": "doc.tables.setBorders", + "intentAction": "set_borders", + "requiredOneOf": [ + [ + "mode", + "applyTo", + "border", + "target" + ], + [ + "mode", + "applyTo", + "border", + "nodeId" + ], + [ + "mode", + "edges", + "target" + ], + [ + "mode", + "edges", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setTableOptions', - intentAction: 'set_options', - requiredOneOf: [['target'], ['nodeId']], - }, - ], - }, - ], + "operationId": "doc.tables.setTableOptions", + "intentAction": "set_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] + } + ] + } + ] } as const; diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index 82bbd96b37..caaba98191 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 5a5a9d24e7..990e1a01d1 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 3789c316c0..80b467e89f 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -14,6 +14,7 @@ const FIXED_DATE = '2026-05-21T00:00:00.000Z'; const FOREIGN_INSERT_ID = 'foreign-insert'; const INSERTED_TEXT = 'here is my new text, do you like it?'; const INSERTED_TAIL = 'do you like it?'; +const INSERTED_TEXT_AFTER_DIRECT_DELETE = INSERTED_TEXT.replace(INSERTED_TAIL, ''); const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); const WORD_REPLACEMENT_FIXTURE = resolve( CURRENT_DIR, @@ -304,7 +305,7 @@ describe('Editor dispatch tracked-change meta', () => { } }); - it('protects another user tracked insertion from direct delete while local track mode is off', () => { + it('mutates another user tracked insertion in place on direct delete while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -318,19 +319,11 @@ describe('Editor dispatch tracked-change meta', () => { deleteText(editor, INSERTED_TAIL); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - authorEmail: BOB.email, - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects anonymous live tracked insertion from direct delete without a configured editor user', () => { + it('mutates an anonymous live tracked insertion in place on direct delete without a configured editor user', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -343,20 +336,11 @@ describe('Editor dispatch tracked-change meta', () => { deleteText(editor, INSERTED_TAIL); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: '', - authorEmail: '', - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects anonymous live tracked insertion from document-api direct delete', () => { + it('mutates an anonymous live tracked insertion in place from document-api direct delete', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -367,20 +351,11 @@ describe('Editor dispatch tracked-change meta', () => { const receipt = editor.doc.delete({ ref: getFirstMatchRef(editor, INSERTED_TAIL) }, { changeMode: 'direct' }); expect(receipt.success).toBe(true); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: '', - authorEmail: '', - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects tracked insertion created by document-api insert from document-api direct delete', () => { + it('mutates tracked insertion created by document-api insert from document-api direct delete', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -406,31 +381,19 @@ describe('Editor dispatch tracked-change meta', () => { const deleteReceipt = editor.doc.delete({ ref: getFirstMatchRef(editor, 'review') }, { changeMode: 'direct' }); expect(deleteReceipt.success).toBe(true); - expect(editor.state.doc.textContent).toContain('live-review-comment'); - - expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live-review-comment'); + expect(editor.state.doc.textContent).toContain('live--comment'); - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'review'); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: 'CLI', - authorId: 'cli', - authorEmail: '', - overlapParentId: insertMark?.mark.attrs.id, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live--comment'); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); const trackedChanges = editor.doc.trackChanges.list(); - expect(trackedChanges.total).toBe(2); - expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type).sort()).toEqual(['delete', 'insert']); + expect(trackedChanges.total).toBe(1); + expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type)).toEqual(['insert']); const comments = editor.doc.comments.list(); - expect(comments.total).toBe(2); + expect(comments.total).toBe(1); expect(comments.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live-review-comment' }), - expect.objectContaining({ trackedChangeType: 'delete', deletedText: 'review' }), - ]), + expect.arrayContaining([expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live--comment' })]), ); }); @@ -506,7 +469,7 @@ describe('Editor dispatch tracked-change meta', () => { ); }); - it('emits review comment state for a protected child deletion instead of only truncating the parent', () => { + it('does not emit a child deletion review comment on direct delete inside a tracked insertion', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -526,13 +489,16 @@ describe('Editor dispatch tracked-change meta', () => { payload?.trackedChangeType === TrackDeleteMarkName, )?.[1]; - expect(childDeletionEvent).toEqual( - expect.objectContaining({ - deletedText: expect.stringContaining(INSERTED_TAIL), - authorEmail: BOB.email, - }), + expect(childDeletionEvent).toBeUndefined(); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(editor.doc.comments.list().items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + trackedChangeType: 'insert', + trackedChangeText: INSERTED_TEXT_AFTER_DIRECT_DELETE, + }), + ]), ); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); }); it('still allows direct deletion of untracked plain text while local track mode is off', () => { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 7343a9b040..e34646807c 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1,7 +1,7 @@ import type { EditorState, Transaction, Plugin } from 'prosemirror-state'; import { AddMarkStep, RemoveMarkStep, ReplaceAroundStep, ReplaceStep, Transform } from 'prosemirror-transform'; import type { EditorView as PmEditorView } from 'prosemirror-view'; -import type { Node as PmNode, Schema } from 'prosemirror-model'; +import type { Mark as PmMark, Node as PmNode, Schema } from 'prosemirror-model'; import type { Doc as YDoc, XmlFragment as YXmlFragment } from 'yjs'; import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js'; import type { EditorHelpers, ExtensionStorage, ProseMirrorJSON, PageStyles, Toolbar } from './types/EditorTypes.js'; @@ -181,6 +181,95 @@ const rangeHasTrackedReviewMark = (doc: PmNode, from: number, to: number): boole return found; }; +const rangeIsTrackedInsertionOnly = (doc: PmNode, from: number, to: number): boolean => { + if (from >= to) return false; + + const docStart = 0; + const docEnd = doc.content.size; + const clampedFrom = Math.max(docStart, Math.min(docEnd, from)); + const clampedTo = Math.max(docStart, Math.min(docEnd, to)); + if (clampedFrom >= clampedTo) return false; + + let sawTrackedInsertion = false; + let sawUntrackedInline = false; + let sawNonInsertionTrackedMark = false; + + doc.nodesBetween(clampedFrom, clampedTo, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (pos + node.nodeSize <= clampedFrom || pos >= clampedTo) return; + + const trackedMarks = (node.marks ?? []).filter(isTrackedReviewMark); + if (!trackedMarks.length) { + sawUntrackedInline = true; + return; + } + + sawTrackedInsertion = true; + if (!trackedMarks.every((mark) => mark.type?.name === TrackInsertMarkName)) { + sawNonInsertionTrackedMark = true; + } + }); + + return sawTrackedInsertion && !sawUntrackedInline && !sawNonInsertionTrackedMark; +}; + +const getSingleTrackedInsertionMarkInRange = ( + doc: PmNode, + from: number, + to: number, +): PmMark | null => { + if (!rangeIsTrackedInsertionOnly(doc, from, to)) return null; + + /** @type {import('prosemirror-model').Mark[]} */ + const insertionMarks = []; + const seenIds = new Set(); + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (pos + node.nodeSize <= from || pos >= to) return; + + const insertionMark = (node.marks ?? []).find((mark) => mark.type?.name === TrackInsertMarkName); + const id = typeof insertionMark?.attrs?.id === 'string' ? insertionMark.attrs.id : null; + if (!insertionMark || !id || seenIds.has(id)) return; + seenIds.add(id); + insertionMarks.push(insertionMark); + }); + + return insertionMarks.length === 1 ? insertionMarks[0] : null; +}; + +const resolveDirectInsertionDeleteCommentMeta = ( + state: EditorState, + tr: Transaction, +): + | { + insertedMark: PmMark; + deletionMark: null; + formatMark: null; + deletionNodes: []; + step: ReplaceStep; + emitCommentEvent: true; + } + | null => { + if (!tr.docChanged || tr.steps.length !== 1) return null; + + const [step] = tr.steps; + if (!(step instanceof ReplaceStep) || step.from === step.to || step.slice.content.size !== 0) return null; + + const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; + const docBeforeStep = docs[0] ?? state.doc; + const insertedMark = getSingleTrackedInsertionMarkInRange(docBeforeStep, step.from, step.to); + if (!insertedMark) return null; + + return { + insertedMark, + deletionMark: null, + formatMark: null, + deletionNodes: [], + step, + emitCommentEvent: true, + }; +}; + const collapsedPositionIsInsideTrackedReviewMark = (doc: PmNode, pos: number): boolean => { const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); const $pos = doc.resolve(boundedPos); @@ -223,6 +312,12 @@ const collapsedInsertionExtendsTrackedReviewMark = ( const stepTouchesTrackedReviewState = (step: unknown, doc: PmNode): boolean => { if (step instanceof ReplaceStep) { + // Direct deletes wholly inside an unresolved insertion mutate that + // insertion in place to match Word. They should not be rerouted into a + // synthetic tracked delete. + if (step.from !== step.to && step.slice.content.size === 0 && rangeIsTrackedInsertionOnly(doc, step.from, step.to)) { + return false; + } if (rangeHasTrackedReviewMark(doc, step.from, step.to)) return true; if (step.from === step.to && step.slice.content.size > 0) { return ( @@ -2930,6 +3025,7 @@ export class Editor extends EventEmitter { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; + const directInsertionDeleteCommentMeta = resolveDirectInsertionDeleteCommentMeta(prevState, transactionToApply); const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); if (protectsExistingTrackedReviewState && skipTrackChanges) { transactionToApply.setMeta('protectTrackedReviewState', true); @@ -2937,6 +3033,9 @@ export class Editor extends EventEmitter { const shouldTrack = ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; + if (!shouldTrack && directInsertionDeleteCommentMeta && !transactionToApply.getMeta(TrackChangesBasePluginKey)) { + transactionToApply.setMeta(TrackChangesBasePluginKey, directInsertionDeleteCommentMeta); + } if (shouldTrack && forceTrackChanges && !this.options.user) { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); } From 359677c7d88791bdb196785b7ae6ba2127fe1d68 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 25 May 2026 20:51:03 -0700 Subject: [PATCH 030/280] chore: more fixes --- apps/cli/src/__tests__/cli.test.ts | 33 ++++ .../src/cli/cli-only-operation-definitions.ts | 4 +- apps/cli/src/cli/operation-params.ts | 8 + apps/cli/src/commands/save.ts | 62 ++++++- apps/cli/src/commands/session-save.ts | 8 +- apps/cli/src/lib/document.ts | 9 +- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/comments/get.mdx | 3 +- .../document-api/reference/comments/list.mdx | 1 + apps/docs/document-engine/sdks.mdx | 4 +- .../src/contract/contract.test.ts | 45 +++++ packages/document-api/src/contract/schemas.ts | 16 +- .../pm-adapter/src/tracked-changes.test.ts | 43 +++++ .../pm-adapter/src/tracked-changes.ts | 47 ++++-- .../v3/handlers/w/del/del-translator.js | 5 + .../v3/handlers/w/del/del-translator.test.js | 32 ++++ .../v3/handlers/w/ins/ins-translator.js | 4 + .../v3/handlers/w/ins/ins-translator.test.js | 33 ++++ .../w/p/helpers/translate-paragraph-node.js | 53 ++++++ .../helpers/translate-paragraph-node.test.js | 45 +++++ .../w/r/helpers/track-change-helpers.js | 10 +- .../w/r/helpers/track-change-helpers.test.js | 24 +++ .../v3/handlers/w/r/r-translator.js | 5 +- .../v3/handlers/w/r/r-translator.test.js | 51 ++++++ .../v3/node-translator/node-translator.js | 1 + .../helpers/comment-entity-store.test.ts | 66 ++++++++ .../helpers/comment-entity-store.ts | 81 ++++++++- .../history-adapter.test.ts | 69 ++++++++ .../document-api-adapters/history-adapter.ts | 65 ++++++++ .../plan-engine/comments-wrappers.test.ts | 109 ++++++++---- .../plan-engine/comments-wrappers.ts | 64 +++++-- .../plan-engine/images-wrappers.test.ts | 157 +++++++++++++++++- .../plan-engine/images-wrappers.ts | 54 ++++++ .../review-model/overlap-compiler.js | 20 ++- .../review-model/overlap-compiler.test.js | 64 +++++++ .../src/ui/create-super-doc-ui.ts | 2 + packages/super-editor/src/ui/document.test.ts | 25 +++ packages/super-editor/src/ui/types.ts | 8 + packages/superdoc/src/SuperDoc.test.js | 29 ++++ packages/superdoc/src/SuperDoc.vue | 1 + .../superdoc/src/stores/comments-store.js | 50 +++++- .../src/stores/comments-store.test.js | 89 ++++++++++ .../tests/images/move-roundtrip.ts | 110 ++++++++++++ 43 files changed, 1521 insertions(+), 90 deletions(-) create mode 100644 tests/doc-api-stories/tests/images/move-roundtrip.ts diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index c62df7233f..1fbda397fa 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -65,6 +65,10 @@ const ENCRYPTED_FIXTURE_SOURCE = join( REPO_ROOT, 'packages/super-editor/src/editors/v1/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx', ); +const TRACKED_CHANGE_FIXTURE_SOURCE = join( + REPO_ROOT, + 'packages/super-editor/src/editors/v1/tests/data/basic-tracked-change.docx', +); const execFileAsync = promisify(execFile); const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; @@ -2334,6 +2338,35 @@ describe('superdoc CLI', () => { expect(verifyEnvelope.data.result.total).toBeGreaterThan(0); }); + test('save --mode final exports accepted OOXML instead of copying review-preserving revisions', async () => { + const trackedSource = join(TEST_DIR, 'save-final-source.docx'); + const savedOut = join(TEST_DIR, 'save-final-output.docx'); + await copyFile(TRACKED_CHANGE_FIXTURE_SOURCE, trackedSource); + + const openResult = await runCli(['open', trackedSource, '--session', 'final-export']); + expect(openResult.code).toBe(0); + + const saveResult = await runCli([ + 'save', + '--session', + 'final-export', + '--mode', + 'final', + '--out', + savedOut, + '--force', + ]); + expect(saveResult.code).toBe(0); + + const saveEnvelope = parseJsonOutput>(saveResult); + expect(saveEnvelope.data.mode).toBe('final'); + expect(saveEnvelope.data.output.path).toBe(savedOut); + + const documentXml = await readDocxPart(savedOut, 'word/document.xml'); + expect(documentXml).not.toContain(' { const driftSource = join(TEST_DIR, 'drift-source.docx'); await copyFile(SAMPLE_DOC, driftSource); diff --git a/apps/cli/src/cli/cli-only-operation-definitions.ts b/apps/cli/src/cli/cli-only-operation-definitions.ts index 8707f70ef3..4e40695649 100644 --- a/apps/cli/src/cli/cli-only-operation-definitions.ts +++ b/apps/cli/src/cli/cli-only-operation-definitions.ts @@ -87,7 +87,8 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record = { docRequirement: 'none', params: [ SESSION_PARAM, + SAVE_MODE_PARAM, OUT_PARAM, FORCE_PARAM, { name: 'inPlace', kind: 'flag', flag: 'in-place', type: 'boolean' }, diff --git a/apps/cli/src/commands/save.ts b/apps/cli/src/commands/save.ts index ea9c4c2f1e..7e7c256ecb 100644 --- a/apps/cli/src/commands/save.ts +++ b/apps/cli/src/commands/save.ts @@ -10,26 +10,34 @@ import { withActiveContext, writeContextMetadata, } from '../lib/context'; -import { openSessionDocument } from '../lib/document'; +import { exportToPath, openSessionDocument } from '../lib/document'; import { syncCollaborativeSessionSnapshot } from '../lib/session-collab'; import type { CommandContext, CommandExecution } from '../lib/types'; + +type SaveMode = 'review-preserving' | 'final'; + function validateSaveMode( inPlace: boolean, outPath: string | undefined, force: boolean, + mode: string | undefined, ): { inPlace: boolean; outPath?: string; force: boolean; + mode: SaveMode; } { if (inPlace && outPath) { throw new CliError('INVALID_ARGUMENT', 'save: use either --in-place or --out, not both.'); } + const resolvedMode = mode === 'final' ? 'final' : 'review-preserving'; + return { inPlace, outPath, force, + mode: resolvedMode, }; } @@ -40,9 +48,12 @@ export async function runSave(tokens: string[], context: CommandContext): Promis return { command: 'save', data: { - usage: ['superdoc save [--in-place] [--out ] [--force]'], + usage: ['superdoc save [--mode ] [--in-place] [--out ] [--force]'], }, - pretty: ['Usage:', ' superdoc save [--in-place] [--out ] [--force]'].join('\n'), + pretty: [ + 'Usage:', + ' superdoc save [--mode ] [--in-place] [--out ] [--force]', + ].join('\n'), }; } @@ -50,6 +61,7 @@ export async function runSave(tokens: string[], context: CommandContext): Promis getBooleanOption(parsed, 'in-place'), getStringOption(parsed, 'out'), getBooleanOption(parsed, 'force'), + getStringOption(parsed, 'mode'), ); return withActiveContext( @@ -87,9 +99,15 @@ export async function runSave(tokens: string[], context: CommandContext): Promis if (isInPlace && !sourcePath) { throw new CliError('MISSING_REQUIRED', 'save: --in-place requires a source path; use --out .'); } + if (mode.mode !== 'review-preserving' && isInPlace) { + throw new CliError( + 'INVALID_ARGUMENT', + 'save: final-mode export requires --out ; in-place final export would desynchronize the live session.', + ); + } let output: { path: string; byteLength: number }; - if (isInPlace) { + if (mode.mode === 'review-preserving' && isInPlace) { const drift = await detectSourceDrift(effectiveMetadata); if (drift.drifted && !mode.force) { throw new CliError('SOURCE_DRIFT_DETECTED', 'Source document changed since open. Refusing to overwrite.', { @@ -102,8 +120,41 @@ export async function runSave(tokens: string[], context: CommandContext): Promis } output = await copyWorkingDocumentToPath(paths, sourcePath!, true); - } else { + } else if (mode.mode === 'review-preserving') { output = await copyWorkingDocumentToPath(paths, targetPath, mode.force); + } else { + const opened = await openSessionDocument(paths.workingDocPath, context.io, effectiveMetadata, { + sessionId: context.sessionId ?? effectiveMetadata.contextId, + executionMode: context.executionMode, + sessionPool: context.sessionPool, + }); + try { + output = await exportToPath(opened.editor, targetPath, mode.force, { isFinalDoc: true }); + } finally { + opened.dispose(); + } + + return { + command: 'save', + data: { + contextId: effectiveMetadata.contextId, + saved: true, + inPlace: false, + mode: mode.mode, + document: { + path: effectiveMetadata.sourcePath, + source: effectiveMetadata.source, + revision: effectiveMetadata.revision, + }, + context: { + dirty: effectiveMetadata.dirty, + revision: effectiveMetadata.revision, + lastSavedAt: effectiveMetadata.lastSavedAt, + }, + output, + }, + pretty: `Exported final document to ${output.path}`, + }; } const nextSourcePath = isInPlace ? sourcePath! : targetPath; @@ -124,6 +175,7 @@ export async function runSave(tokens: string[], context: CommandContext): Promis contextId: updatedMetadata.contextId, saved: true, inPlace: isInPlace, + mode: mode.mode, document: { path: updatedMetadata.sourcePath, source: updatedMetadata.source, diff --git a/apps/cli/src/commands/session-save.ts b/apps/cli/src/commands/session-save.ts index efbf422d63..581f540075 100644 --- a/apps/cli/src/commands/session-save.ts +++ b/apps/cli/src/commands/session-save.ts @@ -37,14 +37,14 @@ export async function runSessionSave(tokens: string[], context: CommandContext): command: 'session save', data: { usage: [ - 'superdoc session save [--in-place] [--out ] [--force]', - 'superdoc session save --session [--in-place] [--out ] [--force]', + 'superdoc session save [--mode ] [--in-place] [--out ] [--force]', + 'superdoc session save --session [--mode ] [--in-place] [--out ] [--force]', ], }, pretty: [ 'Usage:', - ' superdoc session save [--in-place] [--out ] [--force]', - ' superdoc session save --session [--in-place] [--out ] [--force]', + ' superdoc session save [--mode ] [--in-place] [--out ] [--force]', + ' superdoc session save --session [--mode ] [--in-place] [--out ] [--force]', ].join('\n'), }; } diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 164078fe58..cd2d193d70 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -494,7 +494,12 @@ export async function exportOptionalSessionOutput( } } -export async function exportToPath(editor: Editor, outputPath: string, force = false): Promise { +export async function exportToPath( + editor: Editor, + outputPath: string, + force = false, + options: { isFinalDoc?: boolean } = {}, +): Promise { const exists = await pathExists(outputPath); if (exists && !force) { throw new CliError('OUTPUT_EXISTS', `Output path already exists: ${outputPath}`, { @@ -505,7 +510,7 @@ export async function exportToPath(editor: Editor, outputPath: string, force = f let exported: unknown; try { - exported = await editor.exportDocument(); + exported = await editor.exportDocument({ isFinalDoc: options.isFinalDoc }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new CliError('DOCUMENT_EXPORT_FAILED', 'Failed to export document.', { diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 28cbaa4786..9ed6001303 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1079,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "b154213fe6dbcb96de30f11c473aca2946148af73775fbb642b5c750c6a0bc46" + "sourceHash": "2cba047f028916729fa2c6c33b90070023085a295522e2097a916cdc90634096" } diff --git a/apps/docs/document-api/reference/comments/get.mdx b/apps/docs/document-api/reference/comments/get.mdx index f2e4741461..ad4a760c4c 100644 --- a/apps/docs/document-api/reference/comments/get.mdx +++ b/apps/docs/document-api/reference/comments/get.mdx @@ -65,7 +65,7 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `trackedChangeLink` | CommentTrackedChangeLink \\| null | no | One of: CommentTrackedChangeLink, null | | `trackedChangeStory` | StoryLocator \\| null | no | One of: StoryLocator, null | | `trackedChangeText` | any | no | | -| `trackedChangeType` | enum | no | `"insert"`, `"delete"`, `"format"` | +| `trackedChangeType` | enum | no | `"insert"`, `"delete"`, `"replacement"`, `"format"` | ### Example response @@ -205,6 +205,7 @@ Returns a CommentInfo object with the comment text, author, date, and thread met "enum": [ "insert", "delete", + "replacement", "format" ] } diff --git a/apps/docs/document-api/reference/comments/list.mdx b/apps/docs/document-api/reference/comments/list.mdx index d622a5bbd5..baf327be4a 100644 --- a/apps/docs/document-api/reference/comments/list.mdx +++ b/apps/docs/document-api/reference/comments/list.mdx @@ -221,6 +221,7 @@ Returns a CommentsListResult with an array of comment threads and total count. "enum": [ "insert", "delete", + "replacement", "format" ] } diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 1954e2b2e5..979ff60b7c 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1022,7 +1022,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `client.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.save` | `save` | Save the current session to the original file or a new path. Supports explicit review-preserving and final export modes. | | `doc.close` | `close` | Close the active editing session and clean up resources. | #### Client @@ -1501,7 +1501,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `client.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.save` | `save` | Save the current session to the original file or a new path. Supports explicit review-preserving and final export modes. | | `doc.close` | `close` | Close the active editing session and clean up resources. | #### Client diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index c6ce294626..8adf39d4d4 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -240,6 +240,51 @@ describe('document-api contract catalog', () => { expect(listVariants.some((variant) => variant.type === 'null')).toBe(true); }); + it('publishes replacement in comment tracked-change enums for get/list and link defs', () => { + const schemas = buildInternalContractSchemas(); + const commentInfoSchema = schemas.operations['comments.get'].output as { + properties?: { + trackedChangeType?: { + enum?: string[]; + }; + }; + }; + const commentsListSchema = schemas.operations['comments.list'].output as { + properties?: { + items?: { + items?: { + properties?: { + trackedChangeType?: { + enum?: string[]; + }; + }; + }; + }; + }; + }; + const defs = schemas.$defs as Record< + string, + { + properties?: Record< + string, + { + enum?: string[]; + } + >; + } + >; + + expect(commentInfoSchema.properties?.trackedChangeType?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(commentsListSchema.properties?.items?.items?.properties?.trackedChangeType?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(defs.CommentTrackedChangeLink?.properties?.trackedChangeType?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + }); + it('requires id on comments.create success receipts', () => { const schemas = buildInternalContractSchemas(); const createOutputSchema = schemas.operations['comments.create'].output as { diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 6d4ab5188a..c6cf1048e0 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -18,6 +18,8 @@ import { Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from '../ima type JsonSchema = Record; +const trackChangeTypeValues = ['insert', 'delete', 'replacement', 'format'] as const; + /** JSON Schema descriptors for a single operation's input, output, and result variants. */ export interface OperationSchemaSet { /** Schema describing the operation's accepted input payload. */ @@ -283,7 +285,7 @@ const SHARED_DEFS: Record = { ), CommentTrackedChangeLink: objectSchema({ trackedChange: { const: true }, - trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeType: { enum: [...trackChangeTypeValues] }, trackedChangeDisplayType: { type: ['string', 'null'] }, trackedChangeStory: { oneOf: [ref('StoryLocator'), { type: 'null' }] }, trackedChangeAnchorKey: { type: ['string', 'null'] }, @@ -1493,7 +1495,7 @@ const commentInfoSchema = objectSchema( creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, trackedChange: { type: 'boolean' }, - trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeType: { enum: [...trackChangeTypeValues] }, trackedChangeDisplayType: { type: ['string', 'null'] }, trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, trackedChangeAnchorKey: { type: ['string', 'null'] }, @@ -1518,7 +1520,7 @@ const commentDomainItemSchema = discoveryItemSchema( creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, trackedChange: { type: 'boolean' }, - trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeType: { enum: [...trackChangeTypeValues] }, trackedChangeDisplayType: { type: ['string', 'null'] }, trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, trackedChangeAnchorKey: { type: ['string', 'null'] }, @@ -1557,7 +1559,7 @@ const trackChangeInfoSchema = objectSchema( { address: trackedChangeAddressSchema, id: { type: 'string' }, - type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + type: { enum: [...trackChangeTypeValues] }, grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, @@ -1575,7 +1577,7 @@ const trackChangeInfoSchema = objectSchema( const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, - type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + type: { enum: [...trackChangeTypeValues] }, grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, @@ -3165,7 +3167,7 @@ const operationSchemas: Record = { }, type: { type: 'string', - enum: ['insert', 'delete', 'replacement', 'format'], + enum: [...trackChangeTypeValues], description: "Entity-level type. In paired replacement mode, a delete+insert pair shares one entity with type 'replacement'; per-half type lives on block.textSpans[].trackedChanges[].", }, @@ -5025,7 +5027,7 @@ const operationSchemas: Record = { limit: { type: 'integer', description: 'Maximum number of tracked changes to return.' }, offset: { type: 'integer', description: 'Number of tracked changes to skip for pagination.' }, type: { - enum: ['insert', 'delete', 'replacement', 'format'], + enum: [...trackChangeTypeValues], description: "Filter by change type: 'insert', 'delete', 'replacement', or 'format'.", }, in: { diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts index cb84f6ffe6..81983d1b5e 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts @@ -92,6 +92,21 @@ describe('tracked-changes', () => { expect(() => stripTrackedChangeFromRun(tabRun)).not.toThrow(); }); + it('should remove tracked change layers from BreakRun', () => { + const run: Run = { + kind: 'break', + trackedChange: { kind: 'insert', id: 'ins-1' }, + trackedChanges: [ + { kind: 'insert', id: 'ins-1' }, + { kind: 'delete', id: 'del-1' }, + ], + }; + + stripTrackedChangeFromRun(run); + expect(run.trackedChange).toBeUndefined(); + expect(run.trackedChanges).toBeUndefined(); + }); + it('should not error on TextRun without trackedChange', () => { const run: TextRun = { text: 'Hello', @@ -887,6 +902,34 @@ describe('tracked-changes', () => { expect((result[1] as TextRun).text).toBe('More'); }); + it('should hide runs with overlapped child deletions in final mode', () => { + const runs: Run[] = [ + { + text: 'HELLO', + fontFamily: 'Arial', + fontSize: 12, + trackedChange: { kind: 'insert', id: 'ins-parent' }, + trackedChanges: [ + { kind: 'insert', id: 'ins-parent', relationship: 'parent' }, + { + kind: 'delete', + id: 'del-child', + overlapParentId: 'ins-parent', + relationship: 'child', + }, + ], + }, + { text: 'XYZ', fontFamily: 'Arial', fontSize: 12, trackedChange: { kind: 'insert', id: 'ins-2' } }, + ]; + const config: TrackedChangesConfig = { enabled: true, mode: 'final' }; + const hyperlinkConfig: HyperlinkConfig = { enableRichHyperlinks: false }; + const applyMarksToRun = vi.fn(); + + const result = applyTrackedChangesModeToRuns(runs, config, hyperlinkConfig, applyMarksToRun); + expect(result).toHaveLength(1); + expect((result[0] as TextRun).text).toBe('XYZ'); + }); + it('should strip metadata from remaining runs in final mode after filtering deletions', () => { const runs: Run[] = [ { text: 'Keep', fontFamily: 'Arial', fontSize: 12, trackedChange: { kind: 'insert', id: 'ins-1' } }, diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index 731573df54..6de09ca50c 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -67,7 +67,6 @@ export const isTextRun = (run: Run): run is TextRun => { * @param run - The run to strip tracked change from */ export const stripTrackedChangeFromRun = (run: Run): void => { - if (!isTextRun(run)) return; if ('trackedChange' in run && run.trackedChange) { delete run.trackedChange; } @@ -272,13 +271,41 @@ export const selectTrackedChangeMeta = ( return existing; }; -const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { +const normalizeTrackedChangeLayers = (run: TextRun | BreakRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { return run.trackedChanges; } return run.trackedChange ? [run.trackedChange] : []; }; +const isTrackedChangeRun = (run: Run): run is TextRun | BreakRun => { + return isTextRun(run) || run.kind === 'break'; +}; + +const runHasTrackedChangeKind = (run: Run, kind: TrackedChangeKind): boolean => { + if (!isTrackedChangeRun(run)) return false; + return normalizeTrackedChangeLayers(run).some((layer) => layer.kind === kind); +}; + +const stripTrackedChangeKindsFromRun = (run: Run, kinds: readonly TrackedChangeKind[]): void => { + if (!isTrackedChangeRun(run)) return; + + const hiddenKinds = new Set(kinds); + const remainingLayers = normalizeTrackedChangeLayers(run).filter((layer) => !hiddenKinds.has(layer.kind)); + + if (remainingLayers.length === 0) { + stripTrackedChangeFromRun(run); + return; + } + + run.trackedChange = remainingLayers[0]; + if (remainingLayers.length > 1) { + run.trackedChanges = remainingLayers; + } else { + delete run.trackedChanges; + } +}; + /** * Checks if two text runs have compatible tracked change metadata for merging. * Runs are compatible if they have the same kind and ID, or both have no metadata. @@ -488,15 +515,14 @@ export const applyTrackedChangesModeToRuns = ( filtered.push(run); return; } - const tracked = run.trackedChange; - if (!tracked) { + if (!isTrackedChangeRun(run) || normalizeTrackedChangeLayers(run).length === 0) { filtered.push(run); return; } - if (hideInsertions && tracked.kind === 'insert') { + if (hideInsertions && runHasTrackedChangeKind(run, 'insert')) { return; } - if (hideDeletions && tracked.kind === 'delete') { + if (hideDeletions && runHasTrackedChangeKind(run, 'delete')) { return; } filtered.push(run); @@ -527,13 +553,8 @@ export const applyTrackedChangesModeToRuns = ( // Note: We only strip 'insert' and 'delete' kinds, not 'format' kind which should remain visible. if ((config.mode === 'original' || config.mode === 'final') && config.enabled) { filtered.forEach((run) => { - if ( - isTextRun(run) && - run.trackedChange && - (run.trackedChange.kind === 'insert' || run.trackedChange.kind === 'delete') - ) { - delete run.trackedChange; - delete run.trackedChanges; + if (runHasTrackedChangeKind(run, 'insert') || runHasTrackedChangeKind(run, 'delete')) { + stripTrackedChangeKindsFromRun(run, ['insert', 'delete']); } }); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index adc2baae43..cb7339e376 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -99,6 +99,11 @@ function decode(params) { node.marks = marks.filter((m) => m.type !== 'trackDelete'); const translatedTextNode = exportSchemaToJson({ ...params, node }); + + if (params.isFinalDoc) { + return null; + } + // ECMA-376 renames w:t → w:delText inside . Other inline content — // w:noBreakHyphen, w:tab, w:br, etc. — stays as-is; the deletion is // conveyed by the wrapper alone. Guard the rename so non-text diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index a5e614a3dc..4aceb41c3d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -197,6 +197,38 @@ describe('w:del translator', () => { expect(result.attributes['w:id']).toBe('456'); }); + it('drops deleted content entirely in final-doc export mode', () => { + const mockTrackedMark = { + type: 'trackDelete', + attrs: { + id: '123', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + + exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t', text: 'deleted text' }] }); + + const node = { + type: 'text', + text: 'deleted text', + marks: [mockTrackedMark, { type: 'italic', attrs: { value: true } }], + }; + + const result = config.decode({ node, isFinalDoc: true }); + + expect(result).toBeNull(); + expect(exportSchemaToJson).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ + marks: [{ type: 'italic', attrs: { value: true } }], + }), + }), + ); + }); + it('returns null if node is missing or invalid', () => { expect(config.decode({ node: null })).toBeNull(); expect(config.decode({ node: {} })).toBeNull(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index a5cc935776..8d10c3805d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -99,6 +99,10 @@ function decode(params) { const translatedTextNode = exportSchemaToJson({ ...params, node }); + if (params.isFinalDoc) { + return translatedTextNode; + } + return { name: 'w:ins', attributes: { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index 445e99f8cf..766a2c1de2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -215,6 +215,39 @@ describe('w:ins translator', () => { expect(result.attributes['w:id']).toBe('456'); }); + it('returns accepted content directly in final-doc export mode', () => { + const mockTrackedMark = { + type: 'trackInsert', + attrs: { + id: '123', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + const mockTranslatedNode = { name: 'w:r', elements: [{ name: 'w:t', text: 'added text' }] }; + + exportSchemaToJson.mockReturnValue(mockTranslatedNode); + + const node = { + type: 'text', + text: 'added text', + marks: [mockTrackedMark, { type: 'bold' }], + }; + + const result = config.decode({ node, isFinalDoc: true }); + + expect(result).toEqual(mockTranslatedNode); + expect(exportSchemaToJson).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ + marks: [{ type: 'bold' }], + }), + }), + ); + }); + it('allocates Word ids in the current OOXML part namespace under overlap', () => { const mockTrackedMark = { type: 'trackInsert', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index ce5faec085..66a3ea83f6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -2,6 +2,55 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js'; import { generateParagraphProperties } from './generate-paragraph-properties.js'; const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del'; +const stableStringify = (value) => JSON.stringify(value ?? null); + +const getMergeableTextRunParts = (element) => { + if (element?.name !== 'w:r' || !Array.isArray(element.elements) || element.elements.length === 0) return null; + + const [first, second, ...rest] = element.elements; + if (rest.length > 0) return null; + + const runProperties = first?.name === 'w:rPr' ? first : null; + const textNode = runProperties ? second : first; + if (textNode?.name !== 'w:t' || !Array.isArray(textNode.elements) || textNode.elements.length !== 1) return null; + + const textChild = textNode.elements[0]; + if (typeof textChild?.text !== 'string') return null; + + return { + runProperties, + textNode, + textChild, + }; +}; + +function mergeAdjacentFinalTextRuns(elements) { + if (!Array.isArray(elements) || elements.length < 2) return elements; + + const result = []; + + elements.forEach((element) => { + const previous = result[result.length - 1]; + const previousParts = getMergeableTextRunParts(previous); + const currentParts = getMergeableTextRunParts(element); + + const canMerge = + previousParts && + currentParts && + stableStringify(previous?.attributes) === stableStringify(element?.attributes) && + stableStringify(previousParts.runProperties) === stableStringify(currentParts.runProperties) && + stableStringify(previousParts.textNode?.attributes) === stableStringify(currentParts.textNode?.attributes); + + if (!canMerge) { + result.push(element); + return; + } + + previousParts.textChild.text += currentParts.textChild.text; + }); + + return result; +} const getCommentReferenceNode = (el) => { if (el?.name !== 'w:r' || !Array.isArray(el.elements)) return null; @@ -172,6 +221,10 @@ export function translateParagraphNode(params) { // Merge consecutive tracked changes with the same ID, including comment markers between them elements = mergeConsecutiveTrackedChanges(elements); + if (params.isFinalDoc) { + elements = mergeAdjacentFinalTextRuns(elements); + } + // Replace current paragraph with content of html annotation const htmlAnnotationChild = elements.find((element) => element.name === 'htmlAnnotation'); if (htmlAnnotationChild) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index e62a351aae..e4862f616c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -223,4 +223,49 @@ describe('translateParagraphNode', () => { expect(idMap.get('1')).toBe(idMap.get('2')); }); }); + + it('merges adjacent plain text runs in final-doc export mode after tracked wrappers are removed', () => { + const params = { + ...baseParams(), + isFinalDoc: true, + }; + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'ABC' }] }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'XYZ' }] }] }, + ]); + + const result = translateParagraphNode(params); + + expect(result.elements).toHaveLength(1); + expect(result.elements[0]).toEqual({ + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'ABCXYZ' }] }], + }); + }); + + it('does not merge adjacent final-doc runs when run properties differ', () => { + const params = { + ...baseParams(), + isFinalDoc: true, + }; + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'ABC' }] }], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { name: 'w:t', elements: [{ type: 'text', text: 'XYZ' }] }, + ], + }, + ]); + + const result = translateParagraphNode(params); + + expect(result.elements).toHaveLength(2); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js index 4f0bd07748..036b869bad 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js @@ -78,9 +78,11 @@ const renameTextElementsForDeletion = (node) => { if (Array.isArray(node.elements)) node.elements.forEach(renameTextElementsForDeletion); }; -export const ensureTrackedWrapper = (runs, trackingMarksByType = new Map()) => { +export const ensureTrackedWrapper = (runs, trackingMarksByType = new Map(), options = {}) => { if (!Array.isArray(runs) || !runs.length) return runs; + const { isFinalDoc = false } = options; + const firstRun = runs[0]; if (firstRun?.name === 'w:ins' || firstRun?.name === 'w:del') { return runs; @@ -89,6 +91,9 @@ export const ensureTrackedWrapper = (runs, trackingMarksByType = new Map()) => { if (!trackingMarksByType.size) return runs; if (trackingMarksByType.has(TrackInsertMarkName)) { + if (isFinalDoc) { + return runs; + } const mark = trackingMarksByType.get(TrackInsertMarkName); const clonedRuns = cloneRuns(runs); const wrapper = { @@ -108,6 +113,9 @@ export const ensureTrackedWrapper = (runs, trackingMarksByType = new Map()) => { } if (trackingMarksByType.has(TrackDeleteMarkName)) { + if (isFinalDoc) { + return []; + } const mark = trackingMarksByType.get(TrackDeleteMarkName); const clonedRuns = cloneRuns(runs); clonedRuns.forEach(renameTextElementsForDeletion); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js index 0fb52beaf0..ea96fb5c6b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js @@ -117,5 +117,29 @@ describe('track-change-helpers', () => { expect(wrapper.elements[0].elements[0].name).toBe('w:delText'); expect(run.elements[0].name).toBe('w:t'); }); + + it('unwraps insert runs in final-doc export mode', () => { + const runs = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: 'Accepted', type: 'text' }] }] }]; + const mark = { + type: TrackInsertMarkName, + attrs: { id: 'track-4', author: 'Carol', authorEmail: 'carol@example.com', date: '2024-01-01T00:00:00Z' }, + }; + const trackingMap = new Map([[TrackInsertMarkName, mark]]); + + const result = ensureTrackedWrapper(runs, trackingMap, { isFinalDoc: true }); + + expect(result).toBe(runs); + expect(result[0].name).toBe('w:r'); + }); + + it('drops delete runs in final-doc export mode', () => { + const runs = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: 'Rejected', type: 'text' }] }] }]; + const mark = { type: TrackDeleteMarkName, attrs: { id: 'track-5' } }; + const trackingMap = new Map([[TrackDeleteMarkName, mark]]); + + const result = ensureTrackedWrapper(runs, trackingMap, { isFinalDoc: true }); + + expect(result).toEqual([]); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js index 9f9e82a6a3..c499b43546 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js @@ -489,9 +489,12 @@ const decode = (params, decodedAttrs = {}) => { runs.push(runWrapper); }); - const trackedRuns = ensureTrackedWrapper(runs, trackingMarksByType); + const trackedRuns = ensureTrackedWrapper(runs, trackingMarksByType, { isFinalDoc: Boolean(params?.isFinalDoc) }); if (!trackedRuns.length) { + if (params?.isFinalDoc) { + return trackedRuns; + } const emptyRun = { name: XML_NODE_NAME, elements: [] }; applyBaseRunProps(emptyRun); trackedRuns.push(emptyRun); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.test.js index 486ce989c4..93f6c09b49 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.test.js @@ -429,6 +429,57 @@ describe('w:r r-translator (node)', () => { }), ); }); + + it('unwraps run-level tracked insertions in final-doc export mode', () => { + const result = translator.decode({ + node: { + type: 'run', + attrs: {}, + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'insert-1', + author: 'Missy Fox', + authorEmail: '', + date: '2026-01-07T20:24:39Z', + }, + }, + ], + content: [{ type: 'text', text: 'ABCXYZ', marks: [] }], + }, + editor: { extensionService: { extensions: [] } }, + isFinalDoc: true, + }); + + expect(result?.name).toBe('w:r'); + expect(result?.elements?.some((element) => element?.name === 'w:t')).toBe(true); + }); + + it('drops run-level tracked deletions in final-doc export mode', () => { + const result = translator.decode({ + node: { + type: 'run', + attrs: {}, + marks: [ + { + type: 'trackDelete', + attrs: { + id: 'delete-1', + author: 'Vivienne Salisbury', + authorEmail: '', + date: '2026-01-07T20:24:39Z', + }, + }, + ], + content: [{ type: 'text', text: 'HELLO', marks: [] }], + }, + editor: { extensionService: { extensions: [] } }, + isFinalDoc: true, + }); + + expect(result).toEqual([]); + }); }); describe('w:r r-translator decode (export only inline run properties)', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/node-translator/node-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/node-translator/node-translator.js index e085162635..dabc05113d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/node-translator/node-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/node-translator/node-translator.js @@ -33,6 +33,7 @@ export const TranslatorTypes = Object.freeze({ * @property {any[]} [relationships] * @property {Comment[]} [comments] * @property {'external' | 'clean'} [commentsExportType] + * @property {boolean} [isFinalDoc] * @property {any[]} [exportedCommentDefs] * @property {Map} [statFieldCacheMap] * @property {Record} [extraParams] diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index f96dc5baca..099b8bbf85 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -7,6 +7,7 @@ import { findCommentEntity, getCommentEntityStore, isCommentResolved, + reconcileCommentEntityStoreWithAnchors, removeCommentEntityTree, restoreStashedCommentEntityTree, stashRemovedCommentEntities, @@ -153,6 +154,42 @@ describe('stashRemovedCommentEntities / restoreStashedCommentEntityTree', () => }); }); +describe('reconcileCommentEntityStoreWithAnchors', () => { + it('stashes a dangling open root thread when its anchor disappears and restores it when the anchor returns', () => { + const editor = makeEditorWithConverter([ + { commentId: 'c1', commentText: 'Root' }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Reply' }, + ]); + + const firstPass = reconcileCommentEntityStoreWithAnchors(editor, []); + expect(firstPass.restored).toEqual([]); + expect(firstPass.removed.map((entry) => entry.commentId).sort()).toEqual(['c1', 'c2']); + expect(getCommentEntityStore(editor)).toEqual([]); + + const secondPass = reconcileCommentEntityStoreWithAnchors(editor, ['c1']); + expect(secondPass.removed).toEqual([]); + expect(secondPass.restored.map((entry) => entry.commentId).sort()).toEqual(['c1', 'c2']); + expect(findCommentEntity(getCommentEntityStore(editor), 'c1')?.commentText).toBe('Root'); + expect(findCommentEntity(getCommentEntityStore(editor), 'c2')?.commentText).toBe('Reply'); + }); + + it('does not prune resolved or tracked-change-linked root comments when they are temporarily unanchored', () => { + const editor = makeEditorWithConverter([ + { commentId: 'resolved-root', commentText: 'Resolved', isDone: true }, + { commentId: 'tracked-root', commentText: 'Tracked', trackedChangeParentId: 'tc-1' }, + ]); + + const result = reconcileCommentEntityStoreWithAnchors(editor, []); + + expect(result.removed).toEqual([]); + expect( + getCommentEntityStore(editor) + .map((entry) => entry.commentId) + .sort(), + ).toEqual(['resolved-root', 'tracked-root']); + }); +}); + describe('extractCommentText', () => { it('returns commentText when available', () => { expect(extractCommentText({ commentText: 'Hello' })).toBe('Hello'); @@ -355,6 +392,35 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { }); }); + it('normalizes legacy collaboration tracked change types before storing them', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'track-insert', + commentText: 'insert comment', + trackedChange: true, + trackedChangeType: 'trackInsert', + }, + { + commentId: 'replacement', + commentText: 'replacement comment', + trackedChange: true, + trackedChangeType: 'both', + }, + ]); + + const store = getCommentEntityStore(editor); + expect(findCommentEntity(store, 'track-insert')?.trackedChangeType).toBe('insert'); + expect(findCommentEntity(store, 'replacement')?.trackedChangeType).toBe('replacement'); + + const insertInfo = toCommentInfo(findCommentEntity(store, 'track-insert')!); + const replacementInfo = toCommentInfo(findCommentEntity(store, 'replacement')!); + expect(insertInfo.trackedChangeType).toBe('insert'); + expect(insertInfo.trackedChangeLink?.trackedChangeType).toBe('insert'); + expect(replacementInfo.trackedChangeType).toBe('replacement'); + expect(replacementInfo.trackedChangeLink?.trackedChangeType).toBe('replacement'); + }); + it('skips entries without a commentId', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [{ creatorName: 'orphan' }, { commentId: 'c-ok', creatorName: 'X' }]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index f7ef1425e3..3233fa25da 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -197,6 +197,51 @@ export function restoreStashedCommentEntityTree(editor: Editor, rootCommentId: s return restored; } +function isDanglingOpenRootComment(entry: CommentEntityRecord, anchoredIds: ReadonlySet): boolean { + const commentId = toNonEmptyString(entry.commentId); + const importedId = toNonEmptyString(entry.importedId); + if (!commentId) return false; + if (toNonEmptyString(entry.parentCommentId)) return false; + if (isCommentResolved(entry)) return false; + if (entry.trackedChange === true || toNonEmptyString(entry.trackedChangeParentId)) return false; + if (anchoredIds.has(commentId)) return false; + if (importedId && anchoredIds.has(importedId)) return false; + return true; +} + +export function reconcileCommentEntityStoreWithAnchors( + editor: Editor, + anchoredCommentIds: Iterable, +): { restored: CommentEntityRecord[]; removed: CommentEntityRecord[] } { + const anchoredIds = new Set( + Array.from(anchoredCommentIds, (value) => toNonEmptyString(value)).filter((value): value is string => !!value), + ); + + const restored: CommentEntityRecord[] = []; + for (const anchoredId of anchoredIds) { + restored.push(...restoreStashedCommentEntityTree(editor, anchoredId)); + } + + const store = getCommentEntityStore(editor); + const removed: CommentEntityRecord[] = []; + const prunedRootIds = new Set(); + + for (const entry of [...store]) { + const commentId = toNonEmptyString(entry.commentId); + if (!commentId || prunedRootIds.has(commentId)) continue; + if (!isDanglingOpenRootComment(entry, anchoredIds)) continue; + + const removedTree = removeCommentEntityTree(store, commentId); + if (!removedTree.length) continue; + + stashRemovedCommentEntities(editor, removedTree); + removed.push(...removedTree); + prunedRootIds.add(commentId); + } + + return { restored, removed }; +} + function collectTextFragments(value: unknown, sink: string[]): void { if (!value) return; @@ -361,7 +406,8 @@ export function syncCommentEntitiesFromCollaboration( if (typeof raw.createdTime === 'number') patch.createdTime = raw.createdTime; // Tracked-change linkage if (typeof raw.trackedChange === 'boolean') patch.trackedChange = raw.trackedChange; - if (typeof raw.trackedChangeType === 'string') patch.trackedChangeType = raw.trackedChangeType as TrackChangeType; + const normalizedTrackedChangeType = normalizeTrackedChangeType(raw.trackedChangeType); + if (normalizedTrackedChangeType !== undefined) patch.trackedChangeType = normalizedTrackedChangeType; if (raw.trackedChangeType === null) patch.trackedChangeType = null; if (typeof raw.trackedChangeDisplayType === 'string') patch.trackedChangeDisplayType = raw.trackedChangeDisplayType; if (raw.trackedChangeDisplayType === null) patch.trackedChangeDisplayType = null; @@ -411,6 +457,35 @@ function toNonEmptyString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function normalizeTrackedChangeType(value: unknown): TrackChangeType | undefined { + switch (toNonEmptyString(value)) { + case 'insert': + case 'trackInsert': + return 'insert'; + case 'delete': + case 'trackDelete': + return 'delete'; + case 'replacement': + case 'both': + return 'replacement'; + case 'format': + case 'trackFormat': + return 'format'; + default: + return undefined; + } +} + +function normalizeTrackedChangeLink( + link: CommentTrackedChangeLink | null | undefined, +): CommentTrackedChangeLink | null | undefined { + if (!link) return link; + return { + ...link, + trackedChangeType: normalizeTrackedChangeType(link.trackedChangeType), + }; +} + export function toCommentInfo( entry: CommentEntityRecord, options: { @@ -424,7 +499,7 @@ export function toCommentInfo( const status = options.status ?? (isCommentResolved(entry) ? 'resolved' : 'open'); const trackedChangeLink = options.trackedChangeLink !== undefined - ? options.trackedChangeLink + ? normalizeTrackedChangeLink(options.trackedChangeLink) : entry.trackedChange === true || entry.trackedChangeType != null || entry.trackedChangeAnchorKey != null || @@ -432,7 +507,7 @@ export function toCommentInfo( entry.deletedText != null ? { trackedChange: true, - trackedChangeType: entry.trackedChangeType ?? undefined, + trackedChangeType: normalizeTrackedChangeType(entry.trackedChangeType), trackedChangeDisplayType: entry.trackedChangeDisplayType ?? null, trackedChangeStory: entry.trackedChangeStory ?? null, trackedChangeAnchorKey: entry.trackedChangeAnchorKey ?? null, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.test.ts index 795e1e07cf..48359f1aea 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.test.ts @@ -8,6 +8,11 @@ const { undoDepthMock, redoDepthMock, yGetStateMock } = vi.hoisted(() => ({ yGetStateMock: vi.fn(() => undefined), })); +const { listCommentAnchorsMock, reconcileCommentEntityStoreWithAnchorsMock } = vi.hoisted(() => ({ + listCommentAnchorsMock: vi.fn(() => []), + reconcileCommentEntityStoreWithAnchorsMock: vi.fn(() => ({ restored: [], removed: [] })), +})); + vi.mock('prosemirror-history', () => ({ undoDepth: undoDepthMock, redoDepth: redoDepthMock, @@ -19,10 +24,19 @@ vi.mock('y-prosemirror', () => ({ }, })); +vi.mock('./helpers/comment-target-resolver.js', () => ({ + listCommentAnchors: listCommentAnchorsMock, +})); + +vi.mock('./helpers/comment-entity-store.js', () => ({ + reconcileCommentEntityStoreWithAnchors: reconcileCommentEntityStoreWithAnchorsMock, +})); + function makeEditor(overrides: Partial = {}): Editor { return { options: {}, state: { tr: {} } as Editor['state'], + emit: vi.fn(), commands: { undo: vi.fn(() => true), redo: vi.fn(() => true), @@ -59,6 +73,8 @@ describe('createHistoryAdapter', () => { undoDepthMock.mockReturnValue(0); redoDepthMock.mockReturnValue(0); yGetStateMock.mockReturnValue(undefined); + listCommentAnchorsMock.mockReturnValue([]); + reconcileCommentEntityStoreWithAnchorsMock.mockReturnValue({ restored: [], removed: [] }); }); it('reads undo/redo depth from PM history in non-collab mode', () => { @@ -226,6 +242,59 @@ describe('createHistoryAdapter', () => { expect(rootEditor.commands.redo).not.toHaveBeenCalled(); }); + it('emits deleted comment lifecycle events when undo prunes a just-created orphaned root comment', () => { + undoDepthMock.mockReturnValue(1); + reconcileCommentEntityStoreWithAnchorsMock.mockReturnValue({ + restored: [], + removed: [{ commentId: 'c1', commentText: 'Root', documentId: 'doc-1' }], + }); + const editor = makeEditor({ options: { documentId: 'doc-1' } as Editor['options'] }); + + const adapter = createHistoryAdapter(editor); + const result = adapter.undo(); + + expect(result.noop).toBe(false); + expect(listCommentAnchorsMock).toHaveBeenCalledWith(editor); + expect(reconcileCommentEntityStoreWithAnchorsMock).toHaveBeenCalledWith(editor, expect.any(Set)); + expect(editor.emit as ReturnType).toHaveBeenCalledWith('commentsUpdate', { + type: 'deleted', + comment: { + commentId: 'c1', + commentText: 'Root', + text: 'Root', + documentId: 'doc-1', + }, + }); + }); + + it('emits add comment lifecycle events when redo restores a stashed comment thread', () => { + redoDepthMock.mockReturnValue(1); + listCommentAnchorsMock.mockReturnValue([{ commentId: 'c1', importedId: 'imp-1' }]); + reconcileCommentEntityStoreWithAnchorsMock.mockReturnValue({ + removed: [], + restored: [{ commentId: 'c1', importedId: 'imp-1', commentText: 'Restored root', createdTime: 123 }], + }); + const editor = makeEditor(); + + const adapter = createHistoryAdapter(editor); + const result = adapter.redo(); + + expect(result.noop).toBe(false); + expect(reconcileCommentEntityStoreWithAnchorsMock).toHaveBeenCalledWith(editor, expect.any(Set)); + const anchoredIds = reconcileCommentEntityStoreWithAnchorsMock.mock.calls[0]?.[1] as Set; + expect(Array.from(anchoredIds).sort()).toEqual(['c1', 'imp-1']); + expect(editor.emit as ReturnType).toHaveBeenCalledWith('commentsUpdate', { + type: 'add', + comment: { + commentId: 'c1', + importedId: 'imp-1', + commentText: 'Restored root', + text: 'Restored root', + createdTime: 123, + }, + }); + }); + it('keeps sub-editor adapters surface-scoped even when a PresentationEditor exists', () => { undoDepthMock.mockReturnValue(1); redoDepthMock.mockReturnValue(0); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.ts index 71bf024ef0..d1056933a0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/history-adapter.ts @@ -4,6 +4,8 @@ import type { Editor } from '../core/Editor.js'; import { getRevision } from './plan-engine/revision-tracker.js'; import { DocumentApiAdapterError } from './errors.js'; import { readEditorHistorySnapshot, type DocumentHistoryState } from '../core/presentation-editor/history/index.js'; +import { reconcileCommentEntityStoreWithAnchors, type CommentEntityRecord } from './helpers/comment-entity-store.js'; +import { listCommentAnchors } from './helpers/comment-target-resolver.js'; /** * Minimal PresentationEditor surface the history adapter needs. @@ -67,6 +69,67 @@ function runHistoryCommand(editor: Editor, action: 'undo' | 'redo'): boolean { return Boolean(command()); } +function buildAnchoredCommentIdSet(editor: Editor): Set { + const ids = new Set(); + try { + for (const anchor of listCommentAnchors(editor)) { + if (typeof anchor.commentId === 'string' && anchor.commentId.length > 0) ids.add(anchor.commentId); + if (typeof anchor.importedId === 'string' && anchor.importedId.length > 0) ids.add(anchor.importedId); + } + } catch { + return ids; + } + return ids; +} + +function buildCommentLifecyclePayload(record: CommentEntityRecord): Record { + const payload: Record = { + commentId: record.commentId, + }; + + if (record.importedId !== undefined) payload.importedId = record.importedId; + if (record.parentCommentId !== undefined) payload.parentCommentId = record.parentCommentId; + if (record.commentText !== undefined) { + payload.commentText = record.commentText; + payload.text = record.commentText; + } + if (record.commentJSON !== undefined) payload.commentJSON = record.commentJSON; + if (record.creatorName !== undefined) payload.creatorName = record.creatorName; + if (record.creatorEmail !== undefined) payload.creatorEmail = record.creatorEmail; + if (record.creatorImage !== undefined) payload.creatorImage = record.creatorImage; + if (record.createdTime !== undefined) payload.createdTime = record.createdTime; + if (record.isInternal !== undefined) payload.isInternal = record.isInternal; + if (record.isDone !== undefined) payload.isDone = record.isDone; + if (record.resolvedTime !== undefined) payload.resolvedTime = record.resolvedTime; + if (record.fileId !== undefined) payload.fileId = record.fileId; + if (record.documentId !== undefined) payload.documentId = record.documentId; + + return payload; +} + +function emitCommentLifecycleEvents( + editor: Editor, + type: 'add' | 'deleted', + records: ReadonlyArray, +): void { + const emitter = (editor as Editor & { emit?: (event: string, payload: Record) => void }).emit; + if (typeof emitter !== 'function') return; + + for (const record of records) { + emitter.call(editor, 'commentsUpdate', { + type, + comment: buildCommentLifecyclePayload(record), + }); + } +} + +function reconcileCommentHistorySideEffects(editor: Editor): void { + const anchorIds = buildAnchoredCommentIdSet(editor); + const { restored, removed } = reconcileCommentEntityStoreWithAnchors(editor, anchorIds); + if (removed.length > 0) emitCommentLifecycleEvents(editor, 'deleted', removed); + if (restored.length > 0) emitCommentLifecycleEvents(editor, 'add', restored); +} + export function createHistoryAdapter(editor: Editor): HistoryAdapter { return { get(): HistoryState { @@ -87,6 +150,7 @@ export function createHistoryAdapter(editor: Editor): HistoryAdapter { return { noop: true, reason: 'EMPTY_UNDO_STACK', revision: { before: revBefore, after: revBefore } }; } const success = runHistoryCommand(editor, 'undo'); + if (success) reconcileCommentHistorySideEffects(editor); const revAfter = getRevision(editor); return { noop: !success, @@ -102,6 +166,7 @@ export function createHistoryAdapter(editor: Editor): HistoryAdapter { return { noop: true, reason: 'EMPTY_REDO_STACK', revision: { before: revBefore, after: revBefore } }; } const success = runHistoryCommand(editor, 'redo'); + if (success) reconcileCommentHistorySideEffects(editor); const revAfter = getRevision(editor); return { noop: !success, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index 8e0b5d911d..9f72704991 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -10,6 +10,7 @@ vi.mock('../helpers/comment-target-resolver.js', () => ({ })); vi.mock('../helpers/index-cache.js', () => ({ + getBlockIndex: vi.fn(() => ({ candidates: [] })), getInlineIndex: vi.fn(() => ({ byType: new Map() })), clearIndexCache: vi.fn(), })); @@ -37,11 +38,13 @@ vi.mock('../helpers/adapter-utils.js', () => ({ })); import { listCommentAnchors } from '../helpers/comment-target-resolver.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; import { resolveTextTarget } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; const listCommentAnchorsMock = listCommentAnchors as unknown as ReturnType; +const getBlockIndexMock = getBlockIndex as unknown as ReturnType; const resolveTextTargetMock = resolveTextTarget as unknown as ReturnType; const executeDomainCommandMock = executeDomainCommand as unknown as ReturnType; const getTrackedChangeIndexMock = getTrackedChangeIndex as unknown as ReturnType; @@ -83,6 +86,7 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { beforeEach(() => { listCommentAnchorsMock.mockReturnValue([]); + getBlockIndexMock.mockReturnValue({ candidates: [] }); getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [] } as never); }); @@ -617,6 +621,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { doc: { content: { size: 200 }, textBetween: vi.fn(() => ''), + nodesBetween: vi.fn(), }, }, commands: { @@ -628,8 +633,15 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { } as unknown as Editor; } + function mockBlockOrder(...blockIds: string[]): void { + getBlockIndexMock.mockReturnValue({ + candidates: blockIds.map((nodeId, index) => ({ nodeId, pos: index * 10 })), + }); + } + it('rejects a multi-segment target with segments out of document order', () => { const editor = makeWriteEditor(); + mockBlockOrder('pB', 'pA'); // segments[0] resolves to a later PM range than segments[1] — the // caller built an out-of-order TextTarget (e.g. stitched two // selections together backwards). @@ -658,17 +670,14 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(editor.commands!.addComment).not.toHaveBeenCalled(); }); - it('rejects a multi-segment target with a non-empty text gap between segments', () => { + it('rejects a multi-segment target when it skips blocks in document order', () => { const editor = makeWriteEditor(); - // segments[0] and segments[1] are in order, but there is text - // between them (pm positions 10..20 are selected, 30..40 selected, - // positions 20..30 have real text the caller did not select). + mockBlockOrder('p1', 'p2', 'p3'); resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'p1') return { from: 10, to: 20 }; if (target.blockId === 'p3') return { from: 30, to: 40 }; return null; }); - (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => 'unselected text'); const wrapper = createCommentsWrapper(editor); const receipt = wrapper.add({ @@ -690,15 +699,12 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { it('accepts a contiguous multi-segment target and spans the full PM range', () => { const editor = makeWriteEditor(); - // Two adjacent textblocks — the flattened PM gap between them is - // just block-boundary tokens, which textBetween(prev.to, curr.from, '') - // renders as an empty string. + mockBlockOrder('pA', 'pB'); resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 10, to: 20 }; if (target.blockId === 'pB') return { from: 22, to: 30 }; return null; }); - (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => ''); // Simulate a successful plan execution so the handler reaches the // success branch after validation + applyTextSelection. executeDomainCommandMock.mockReturnValue({ @@ -723,6 +729,62 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(receipt.success).toBe(true); }); + it('accepts a multi-paragraph target when the gap contains only ignorable anchor atoms', () => { + const editor = makeWriteEditor(); + mockBlockOrder('pA', 'pB'); + resolveTextTargetMock.mockImplementation((_editor, target) => { + if (target.blockId === 'pA') return { from: 10, to: 20 }; + if (target.blockId === 'pB') return { from: 22, to: 30 }; + return null; + }); + executeDomainCommandMock.mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const receipt = wrapper.add({ + text: 'comment', + target: { + kind: 'text', + segments: [ + { blockId: 'pA', range: { start: 0, end: 10 } }, + { blockId: 'pB', range: { start: 0, end: 8 } }, + ], + }, + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 10, to: 30 }); + }); + + it('accepts contiguous block segments even when boundary blocks leave text between them', () => { + const editor = makeWriteEditor(); + mockBlockOrder('pA', 'pB'); + resolveTextTargetMock.mockImplementation((_editor, target) => { + if (target.blockId === 'pA') return { from: 10, to: 18 }; + if (target.blockId === 'pB') return { from: 31, to: 45 }; + return null; + }); + executeDomainCommandMock.mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const receipt = wrapper.add({ + text: 'comment', + target: { + kind: 'text', + segments: [ + { blockId: 'pA', range: { start: 10, end: 18 } }, + { blockId: 'pB', range: { start: 12, end: 26 } }, + ], + }, + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 10, to: 45 }); + }); + it('accepts trackedChangeId targets from the tracked-change index when live resolution misses a body deletion', () => { const editor = makeWriteEditor(); const bodyDeletionSnapshot = { @@ -949,26 +1011,13 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(editor.commands!.addComment).not.toHaveBeenCalled(); }); - it('rejects a multi-segment TextTarget whose gap contains only an inline atom', () => { - // Regression: `textBetween(prev.to, curr.from, '')` returns '' when - // the gap is composed entirely of inline atoms (images, math, etc), - // because PM omits leaves from textBetween by default. The contiguity - // check must use a leafText callback so atom-only gaps still reject. + it('rejects a multi-segment TextTarget that repeats the same block', () => { + // Split same-block segments would silently anchor a larger range than + // the caller explicitly passed. Callers should collapse them into one + // TextAddress/TextSegment before invoking comments.create. const editor = makeWriteEditor(); - resolveTextTargetMock.mockImplementation((_editor, target) => { - if (target.blockId === 'p1') return { from: 5, to: 10 }; - if (target.blockId === 'p1-after-image') return { from: 12, to: 17 }; - return null; - }); - // Simulate a gap that contains an inline atom: textBetween with - // empty blockSeparator but a leafText callback returns the leaf - // sentinel for the atom. - (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn( - (_from: number, _to: number, blockSep: string, leafText?: () => string) => { - if (typeof leafText === 'function') return leafText(); - return blockSep ?? ''; - }, - ); + mockBlockOrder('p1'); + resolveTextTargetMock.mockReturnValueOnce({ from: 5, to: 10 }).mockReturnValueOnce({ from: 12, to: 17 }); const wrapper = createCommentsWrapper(editor); const receipt = wrapper.add({ @@ -977,14 +1026,14 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { kind: 'text', segments: [ { blockId: 'p1', range: { start: 0, end: 5 } }, - { blockId: 'p1-after-image', range: { start: 0, end: 5 } }, + { blockId: 'p1', range: { start: 7, end: 12 } }, ], }, }); expect(receipt.success).toBe(false); expect(receipt.failure?.code).toBe('INVALID_TARGET'); - expect(receipt.failure?.message).toContain('atoms'); + expect(receipt.failure?.message).toContain('document order'); expect(editor.commands!.addComment).not.toHaveBeenCalled(); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index 36b7dd1196..b637cf6c64 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -40,7 +40,7 @@ import { TextSelection } from 'prosemirror-state'; import { v4 as uuidv4 } from 'uuid'; import { DocumentApiAdapterError } from '../errors.js'; import { requireEditorCommand } from '../helpers/mutation-helpers.js'; -import { clearIndexCache } from '../helpers/index-cache.js'; +import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js'; import { checkRevision, getRevision } from './revision-tracker.js'; import { resolveTextTarget, paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; @@ -709,6 +709,35 @@ type CommentTargetResolution = | { ok: true; value: ResolvedCommentTarget } | { ok: false; failure: Extract['failure'] }; +function validateCommentTargetSegmentOrder( + editor: Editor, + segments: readonly TextSegment[], +): { ok: true } | { ok: false; reason: 'document-order' | 'contiguous-blocks' } { + const orderedCandidates = [...getBlockIndex(editor).candidates].sort((left, right) => left.pos - right.pos); + const orderByBlockId = new Map(); + + for (let i = 0; i < orderedCandidates.length; i += 1) { + const candidate = orderedCandidates[i]; + if (!orderByBlockId.has(candidate.nodeId)) { + orderByBlockId.set(candidate.nodeId, i); + } + } + + let lastOrder = -1; + for (const segment of segments) { + const currentOrder = orderByBlockId.get(segment.blockId); + if (currentOrder === undefined || currentOrder <= lastOrder) { + return { ok: false, reason: 'document-order' }; + } + if (lastOrder >= 0 && currentOrder !== lastOrder + 1) { + return { ok: false, reason: 'contiguous-blocks' }; + } + lastOrder = currentOrder; + } + + return { ok: true }; +} + function buildTrackedChangeEntityFields(snapshot: TrackedChangeSnapshot | null): Record { if (!snapshot) { return { @@ -965,28 +994,32 @@ function resolveCommentTarget(editor: Editor, target: CommentTarget): CommentTar }); } - const docForGap = editor.state?.doc; - for (let i = 1; i < resolvedSegments.length; i += 1) { - const prev = resolvedSegments[i - 1]!; - const curr = resolvedSegments[i]!; - if (prev.to > curr.from) { + if (segments.length > 1) { + const segmentOrder = validateCommentTargetSegmentOrder(editor, segments); + if (segmentOrder.ok === false) { return { ok: false, failure: { code: 'INVALID_TARGET', - message: 'Comment target segments must be in document order.', + message: + segmentOrder.reason === 'document-order' + ? 'Comment target segments must be in document order.' + : 'Comment target segments must cover contiguous blocks in document order.', details: { target }, }, }; } - const gap = docForGap ? docForGap.textBetween(prev.to, curr.from, '', () => '\u0001') : ''; - if (gap.length > 0) { + } + + for (let i = 1; i < resolvedSegments.length; i += 1) { + const prev = resolvedSegments[i - 1]!; + const curr = resolvedSegments[i]!; + if (prev.to > curr.from) { return { ok: false, failure: { code: 'INVALID_TARGET', - message: - 'Comment target segments must be contiguous — non-selected text or atoms between segments is not supported.', + message: 'Comment target segments must be in document order.', details: { target }, }, }; @@ -1022,10 +1055,11 @@ function addCommentHandler( options?: RevisionGuardOptions, ): CommentsCreateReceipt { // The target can be either a single-block TextAddress or a multi-segment - // TextTarget. For a TextTarget, resolve each segment and require they - // cover a contiguous PM range in document order — out-of-order or - // disjoint segments would otherwise silently anchor the comment over - // intervening text the caller never selected. + // TextTarget. For a TextTarget, resolve each segment and require the + // segments to walk contiguous blocks in document order. The resulting + // comment span intentionally covers the full PM range between the first + // and last segment, matching SelectionTarget / Word-style multi-block + // anchors even when the boundary blocks contribute unselected text. const target = input.target; if (!target) { return { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.test.ts index 2ee67fc8b9..b596b8c18c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.test.ts @@ -1,8 +1,16 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; import { describe, expect, it, beforeEach, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { registerBuiltInExecutors } from './register-executors.js'; -import { imagesScaleWrapper, imagesReplaceSourceWrapper, imagesSetAltTextWrapper } from './images-wrappers.js'; +import { + imagesListWrapper, + imagesMoveWrapper, + imagesScaleWrapper, + imagesReplaceSourceWrapper, + imagesSetAltTextWrapper, +} from './images-wrappers.js'; // Ensure the domain.command executor is registered for executeDomainCommand registerBuiltInExecutors(); @@ -141,6 +149,75 @@ function makeImageEditor(imageAttrs: Record = {}): { }; } +function makeRealImageMoveEditor(options: { sourceText?: string } = {}): Editor { + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { + paraId: { default: null }, + sdBlockId: { default: null }, + paragraphProperties: { default: null }, + }, + }, + image: { + group: 'inline', + inline: true, + attrs: { + sdImageId: { default: null }, + src: { default: null }, + isAnchor: { default: false }, + wrap: { default: null }, + size: { default: null }, + }, + }, + text: { group: 'inline' }, + }, + }); + + const sourceParagraphChildren = []; + if (options.sourceText) { + sourceParagraphChildren.push(schema.text(options.sourceText)); + } + sourceParagraphChildren.push( + schema.nodes.image.create({ + sdImageId: 'img-1', + src: 'data:image/png;base64,ABC', + isAnchor: false, + wrap: { type: 'Inline' }, + size: { width: 200, height: 100 }, + }), + ); + + const doc = schema.nodes.doc.create({}, [ + schema.nodes.paragraph.create( + { paraId: 'p-source', sdBlockId: 'p-source', paragraphProperties: { styleId: 'Normal' } }, + sourceParagraphChildren, + ), + schema.nodes.paragraph.create( + { paraId: 'p-target', sdBlockId: 'p-target', paragraphProperties: { styleId: 'Normal' } }, + schema.text('Target paragraph'), + ), + ]); + + let state = EditorState.create({ schema, doc }); + const editor = { + get state() { + return state; + }, + dispatch: vi.fn((tr: Parameters[0]) => { + state = state.apply(tr as any); + }), + commands: {}, + options: {}, + on: () => {}, + } as unknown as Editor; + + return editor; +} + // --------------------------------------------------------------------------- // Regression: images.scale must not produce zero dimensions // --------------------------------------------------------------------------- @@ -177,6 +254,34 @@ describe('imagesScaleWrapper', () => { }); }); +describe('imagesListWrapper', () => { + it('projects the containing paragraph block id for a moved inline image', () => { + const editor = makeRealImageMoveEditor(); + + const moveResult = imagesMoveWrapper( + editor, + { + imageId: 'img-1', + to: { + kind: 'inParagraph', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-target' }, + offset: 0, + }, + }, + { changeMode: 'direct' }, + ); + + expect(moveResult.success).toBe(true); + const result = imagesListWrapper(editor, {}); + + expect(result.total).toBe(1); + expect(result.items[0]).toMatchObject({ + sdImageId: 'img-1', + anchor: { blockId: 'p-target' }, + }); + }); +}); + // --------------------------------------------------------------------------- // Regression: images.replaceSource must clear originalSrc/originalExtension // --------------------------------------------------------------------------- @@ -268,6 +373,56 @@ describe('imagesSetAltTextWrapper', () => { }); }); +describe('imagesMoveWrapper', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('removes the now-empty top-level source paragraph when moving a sole image into another paragraph', () => { + const editor = makeRealImageMoveEditor(); + + const result = imagesMoveWrapper( + editor, + { + imageId: 'img-1', + to: { + kind: 'inParagraph', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-target' }, + offset: 0, + }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(editor.state.doc.childCount).toBe(1); + expect(editor.state.doc.child(0).attrs.paraId).toBe('p-target'); + expect(editor.state.doc.child(0).childCount).toBe(2); + expect(editor.state.doc.child(0).child(0).type.name).toBe('image'); + expect(editor.state.doc.child(0).child(0).attrs.sdImageId).toBe('img-1'); + }); + + it('keeps the source paragraph when the image was not its only content', () => { + const editor = makeRealImageMoveEditor({ sourceText: 'Source text ' }); + + const result = imagesMoveWrapper( + editor, + { + imageId: 'img-1', + to: { + kind: 'inParagraph', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-target' }, + offset: 0, + }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(editor.state.doc.childCount).toBe(2); + expect(editor.state.doc.child(0).attrs.paraId).toBe('p-source'); + expect(editor.state.doc.child(0).textContent).toBe('Source text '); + }); +}); + // --------------------------------------------------------------------------- // create.image — story routing regression test // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.ts index 63b43db29b..0476f650f3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/images-wrappers.ts @@ -123,11 +123,24 @@ function hasCaptionSibling(editor: Editor, imagePos: number): boolean { } } +function resolveImageAnchorBlockId(editor: Editor, imagePos: number): string | undefined { + try { + const paragraph = findContainingParagraph(editor, imagePos); + const attrs = (paragraph.node.attrs ?? {}) as Record; + const blockId = attrs.sdBlockId ?? attrs.paraId ?? attrs.id ?? attrs.blockId; + return typeof blockId === 'string' && blockId.length > 0 ? blockId : undefined; + } catch { + return undefined; + } +} + function buildImageSummary(editor: Editor, candidate: ImageCandidate): ImageSummary { const attrs = candidate.node.attrs; + const anchorBlockId = resolveImageAnchorBlockId(editor, candidate.pos); return { sdImageId: candidate.sdImageId, address: buildImageAddress(candidate), + ...(anchorBlockId ? { anchor: { blockId: anchorBlockId } } : {}), properties: { src: attrs.src ?? undefined, alt: attrs.alt ?? undefined, @@ -200,6 +213,38 @@ function filterWrapAttrs(type: string, attrs: Record): Record= paragraphContentStart && targetPos <= paragraphContentEnd) { + return null; + } + + return { + pos: paragraph.pos, + end: paragraph.pos + paragraph.node.nodeSize, + }; +} + // --------------------------------------------------------------------------- // Read operations // --------------------------------------------------------------------------- @@ -379,10 +424,19 @@ export function imagesMoveWrapper( const { pos, node } = image; const attrs = { ...node.attrs }; const tr = editor.state.tr; + const sourceParagraphCleanup = resolveImageMoveSourceParagraphCleanup(editor, pos, targetPos); // Delete the source image first. tr.delete(pos, pos + node.nodeSize); + if (sourceParagraphCleanup) { + const cleanupFrom = tr.mapping.map(sourceParagraphCleanup.pos); + const cleanupTo = tr.mapping.map(sourceParagraphCleanup.end); + if (cleanupTo > cleanupFrom) { + tr.delete(cleanupFrom, cleanupTo); + } + } + // Map the pre-resolved target through the delete mapping so it remains // accurate after the deletion step shifts positions. const mappedPos = tr.mapping.map(targetPos); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index ced0855fed..717e7ead95 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -256,6 +256,20 @@ const findSegmentAcrossEmptyStructuralGap = (ctx, pos) => { return nearest; }; +const findAdjacentInsertedSegment = (ctx, pos) => { + const left = ctx.graph.segments.find( + (segment) => segment.side === SegmentSide.Inserted && segment.to === pos && isSameUserForRefinement(ctx, segment), + ); + if (left) return left; + + return ( + ctx.graph.segments.find( + (segment) => + segment.side === SegmentSide.Inserted && segment.from === pos && isSameUserForRefinement(ctx, segment), + ) ?? null + ); +}; + const isEmptyStructuralGap = (ctx, from, to) => { if (to <= from) return false; if (!sharesTextblock(ctx.tr.doc, from, to)) return false; @@ -395,6 +409,7 @@ const compileTextInsert = (ctx, intent) => { const boundaryAdjacent = !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; const emptyGapAdjacent = !overlapParent && !boundaryAdjacent ? findSegmentAcrossEmptyStructuralGap(ctx, at) : null; + const exactAdjacentInserted = findAdjacentInsertedSegment(ctx, at); // Same-user refinement targets: own insertion that strictly contains `at`, // an own-insertion edge we are adjacent to, OR the same edge separated only @@ -404,8 +419,9 @@ const compileTextInsert = (ctx, intent) => { // matching the legacy `findTrackedMarkBetween({ authorEmail: '' })` // behavior. Permission and overlap-parent decisions still go through the // high-confidence `classifySegment` gate. - const refinementTarget = - overlapParent && overlapParent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, overlapParent) + const refinementTarget = exactAdjacentInserted + ? exactAdjacentInserted + : overlapParent && overlapParent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, overlapParent) ? overlapParent : boundaryAdjacent && boundaryAdjacent.side === SegmentSide.Inserted && diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index b74633eb24..a1d00645bc 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -300,6 +300,70 @@ describe('overlap-compiler: different-user child insertion inside other-user ins expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); expect(childChange.authorEmail).toBe(ALICE.email); }); + + it('continues the same child change across contiguous typing inside the parent insertion', () => { + const parentId = 'ins-bob'; + const { state: initialState } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + + const first = runCompile({ + state: initialState, + intent: makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }), + }); + expect(first.ok).toBe(true); + const childId = first.createdChangeIds[0]; + + const secondState = EditorState.create({ schema, doc: first.tr.doc }); + const second = runCompile({ + state: secondState, + intent: makeTextInsertIntent({ + at: first.selection.pos, + content: sliceFromText(schema, 'Y'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }), + }); + expect(second.ok).toBe(true); + + const thirdState = EditorState.create({ schema, doc: second.tr.doc }); + const third = runCompile({ + state: thirdState, + intent: makeTextInsertIntent({ + at: second.selection.pos, + content: sliceFromText(schema, 'Z'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }), + }); + expect(third.ok).toBe(true); + expect(textOf(third.tr)).toBe('Hi woXYZrld'); + + expect(second.createdChangeIds).toEqual([]); + expect(second.updatedChangeIds).toEqual([childId]); + expect(third.createdChangeIds).toEqual([]); + expect(third.updatedChangeIds).toEqual([childId]); + + const graph = buildReviewGraph({ state: { doc: third.tr.doc } }); + expect(graph.changes.size).toBe(2); + const childChange = Array.from(graph.changes.values()).find((change) => change.id === childId); + expect(childChange).toBeDefined(); + expect(childChange.authorEmail).toBe(ALICE.email); + expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + expect(childChange.insertedSegments.map((segment) => segment.text).join('')).toBe('XYZ'); + }); }); describe('overlap-compiler: text-insert at exact location inside other-user deletion (SD-3210)', () => { diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index abbc9b3593..21fe27bc96 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -2175,8 +2175,10 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const emit = editor.emit; if (typeof emit === 'function') { try { + const replacedFile = editor.options?.replacedFile === true ? true : undefined; emit.call(editor, 'commentsLoaded', { editor, + ...(replacedFile ? { replacedFile } : {}), comments: editor.converter?.comments ?? [], }); } catch (err) { diff --git a/packages/super-editor/src/ui/document.test.ts b/packages/super-editor/src/ui/document.test.ts index 99e08453bf..e9b25d5044 100644 --- a/packages/super-editor/src/ui/document.test.ts +++ b/packages/super-editor/src/ui/document.test.ts @@ -416,6 +416,31 @@ describe('ui.document', () => { ui.destroy(); }); + it('replaceFile preserves the replacedFile flag when it re-emits commentsLoaded', async () => { + const { superdoc, editor } = makeStubs(); + const replaceFile = vi.fn(async (_file: File) => undefined); + const emit = vi.fn(); + (editor as unknown as { replaceFile: typeof replaceFile }).replaceFile = replaceFile; + (editor as unknown as { emit: typeof emit }).emit = emit; + (editor as unknown as { converter: { comments: unknown[] } }).converter = { + comments: [{ id: 'c1', text: 'imported' }], + }; + editor.options.replacedFile = true; + + const ui = createSuperDocUI({ superdoc }); + const file = new File(['stub'], 'sample.docx'); + + await ui.document.replaceFile(file); + + expect(emit).toHaveBeenCalledWith('commentsLoaded', { + editor, + replacedFile: true, + comments: [{ id: 'c1', text: 'imported' }], + }); + + ui.destroy(); + }); + it('replaceFile rejects when activeEditor has no replaceFile', async () => { const { superdoc } = makeStubs(); const ui = createSuperDocUI({ superdoc }); diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index aafaada319..805f82cdf5 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -74,6 +74,14 @@ export interface SuperDocEditorLike { on?(event: string, handler: (...args: unknown[]) => void): unknown; off?(event: string, handler: (...args: unknown[]) => void): unknown; emit?(event: string, payload: unknown): void; + /** + * Minimal editor options surface the controller reads today. + * Keep this narrow so test doubles do not need to model the full + * editor options bag. + */ + options?: { + replacedFile?: boolean; + }; /** * Replace the current document file. Consumed by `ui.document.replaceFile` * to give consumers a typed import path without reaching into the host diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 6cb72a51a6..3526a114dc 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1527,6 +1527,35 @@ describe('SuperDoc.vue', () => { expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); }); + it('forwards replacedFile comment loads to the comments store', async () => { + const superdocStub = createSuperdocStub(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + const editor = { + options: { + documentId: 'doc-1', + shouldLoadComments: false, + }, + }; + + options.onCommentsLoaded({ + editor, + comments: [{ commentId: 'c-1' }], + replacedFile: true, + }); + await nextTick(); + + expect(commentsStoreStub.processLoadedDocxComments).toHaveBeenCalledWith({ + superdoc: superdocStub, + editor, + comments: [{ commentId: 'c-1' }], + documentId: 'doc-1', + replacedFile: true, + }); + }); + it('clears tracked-change positions for non-body tracked-change updates when viewing-mode comments are hidden', async () => { const superdocStub = createSuperdocStub(); superdocStub.config.documentMode = 'viewing'; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 67e6ef93fe..b1b5cd39da 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -373,6 +373,7 @@ const onCommentsLoaded = ({ editor, comments, replacedFile }) => { editor, comments, documentId: editor.options.documentId, + replacedFile, }); }); } diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 8b46743a7f..96b6aaaa09 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -1207,6 +1207,44 @@ export const useCommentsStore = defineStore('comments', () => { syncStoryTrackedChangeComments({ superdoc, editor }); }; + /** + * Replace-file swaps reuse the same document id. Drop the previous document's + * imported threads before hydrating the replacement so stale comments never + * survive long enough to render beside the new content. + * + * @param {string | null | undefined} documentId + * @returns {void} + */ + function resetCommentsForReplacedDocument(documentId) { + const activeDocumentId = documentId != null ? String(documentId) : null; + if (!activeDocumentId) return; + + const removedComments = commentsList.value.filter((comment) => + belongsToDocument(comment, activeDocumentId, { allowSingleDocumentMismatch: true }), + ); + if (!removedComments.length) return; + + const removedAliasIds = new Set(); + removedComments.forEach((comment) => { + getCommentAliasIds(comment).forEach((id) => removedAliasIds.add(id)); + }); + + commentsList.value = commentsList.value.filter((comment) => !removedComments.includes(comment)); + + if (removedAliasIds.size) { + const nextPositions = { ...(editorCommentPositions.value || {}) }; + removedAliasIds.forEach((id) => { + delete nextPositions[id]; + }); + editorCommentPositions.value = nextPositions; + } + + const activeCommentId = activeComment.value != null ? String(activeComment.value) : null; + if (activeCommentId && removedAliasIds.has(activeCommentId)) { + clearActiveCommentSelection(); + } + } + /** * Initialize loaded comments into SuperDoc by mapping the imported * comment data to SuperDoc useComment objects. @@ -1216,14 +1254,19 @@ export const useCommentsStore = defineStore('comments', () => { * @param {Object} param0 * @param {Array} param0.comments The comments to be loaded * @param {String} param0.documentId The document ID + * @param {boolean} [param0.replacedFile] Whether this load replaces an existing document in place * @returns {void} */ - const processLoadedDocxComments = async ({ superdoc, editor, comments, documentId }) => { + const processLoadedDocxComments = async ({ superdoc, editor, comments, documentId, replacedFile = false }) => { const document = superdocStore.getDocument(documentId); if (document?.commentThreadingProfile) { document.commentThreadingProfile.value = editor?.converter?.commentThreadingProfile || null; } + if (replacedFile) { + resetCommentsForReplacedDocument(documentId); + } + comments.forEach((comment) => { const textElements = Array.isArray(comment.elements) ? comment.elements : []; const htmlContent = getHtmlFromComment(textElements); @@ -1271,6 +1314,11 @@ export const useCommentsStore = defineStore('comments', () => { addComment({ superdoc, comment: newComment }); }); + if (replacedFile) { + bootstrapImportedTrackedChangeComments(editor, superdoc); + return; + } + setTimeout(() => { // Do not block the first rendering of the doc. Rebuild tracked-change // threads asynchronously once the editor is ready for comment sync. diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index de7bc80cee..ddae0e63bb 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1642,6 +1642,95 @@ describe('comments-store', () => { expect(editorDispatch).toHaveBeenCalledWith(tr); }); + it('replaces stale document threads immediately when a new file is loaded into the same document id', async () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + converter: { commentThreadingProfile: 'range-based' }, + state: {}, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + + trackChangesHelpersMock.getTrackChanges.mockReturnValue([{ mark: { attrs: { id: 'tc-new' } } }]); + groupChangesMock.mockReturnValue([{ insertedMark: { mark: { attrs: { id: 'tc-new' } } } }]); + + store.commentsList = [ + { + commentId: 'tc-old', + trackedChange: true, + trackedChangeText: 'Old tracked text', + fileId: 'doc-1', + }, + { + commentId: 'tc-old-reply', + parentCommentId: 'tc-old', + fileId: 'doc-1', + }, + { + commentId: 'regular-old', + commentText: 'Old regular comment', + fileId: 'doc-1', + }, + ]; + + store.processLoadedDocxComments({ + superdoc: __mockSuperdoc, + editor, + comments: [ + { + commentId: 'regular-new', + creatorName: 'Imported Author', + creatorEmail: 'imported@example.com', + createdTime: 123, + elements: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'Replacement comment', + attrs: { + type: 'element', + attributes: {}, + }, + }, + ], + }, + ], + }, + ], + }, + ], + documentId: 'doc-1', + replacedFile: true, + }); + + expect(store.commentsList).toHaveLength(2); + expect(store.commentsList.find((comment) => comment.commentId === 'tc-old')).toBeUndefined(); + expect(store.commentsList.find((comment) => comment.commentId === 'tc-old-reply')).toBeUndefined(); + expect(store.commentsList.find((comment) => comment.commentId === 'regular-old')).toBeUndefined(); + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'regular-new', + fileId: 'doc-1', + docxCommentJSON: expect.any(Array), + }), + expect.objectContaining({ + commentId: 'tc-new', + trackedChange: true, + trackedChangeText: 'tracked-tc-new', + fileId: 'doc-1', + }), + ]); + expect(createOrUpdateTrackedChangeCommentMock).toHaveBeenCalledTimes(1); + expect(tr.setMeta).toHaveBeenCalledWith('CommentsPluginKey', { type: 'force' }); + expect(editorDispatch).toHaveBeenCalledWith(tr); + }); + it('reopens resolved tracked-change comments when synced marks reappear', () => { const editorDispatch = vi.fn(); const tr = { setMeta: vi.fn() }; diff --git a/tests/doc-api-stories/tests/images/move-roundtrip.ts b/tests/doc-api-stories/tests/images/move-roundtrip.ts new file mode 100644 index 0000000000..82bbd60ec1 --- /dev/null +++ b/tests/doc-api-stories/tests/images/move-roundtrip.ts @@ -0,0 +1,110 @@ +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const TEST_IMAGE_PATH = path.resolve(import.meta.dirname, 'assets/test-image.webp'); + +function sid(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function getParagraphBlocks(result: any): any[] { + const blocks = Array.isArray(result?.blocks) ? result.blocks : []; + return blocks.filter((block) => block?.nodeType === 'paragraph'); +} + +async function imageDataUri(): Promise { + const buf = await readFile(TEST_IMAGE_PATH); + return `data:image/webp;base64,${buf.toString('base64')}`; +} + +describe('document-api story: images.move roundtrip', () => { + const { client, outPath } = useStoryHarness('images/move-roundtrip', { + preserveResults: true, + }); + + it('removes the empty source paragraph and preserves structure after save + reopen', async () => { + const sessionId = sid('images-move'); + const reopenSessionId = sid('images-move-reopen'); + const savedDocPath = outPath('images-move-roundtrip.docx'); + + await client.doc.open({ sessionId }); + + const createdImage = unwrap( + await client.doc.create.image({ + sessionId, + src: await imageDataUri(), + alt: 'roundtrip move image', + at: { kind: 'documentEnd' }, + }), + ); + expect(createdImage?.success).toBe(true); + + const imagesBeforeMove = unwrap(await client.doc.images.list({ sessionId })); + const imageId = imagesBeforeMove?.items?.[0]?.sdImageId; + expect(typeof imageId).toBe('string'); + + const createdParagraph = unwrap( + await client.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'Destination paragraph', + }), + ); + expect(createdParagraph?.success).toBe(true); + + const targetParagraph = createdParagraph?.paragraph; + expect(targetParagraph).toMatchObject({ + kind: 'block', + nodeType: 'paragraph', + }); + + const blocksBeforeMove = unwrap(await client.doc.blocks.list({ sessionId, limit: 20, includeText: true })); + const paragraphsBeforeMove = getParagraphBlocks(blocksBeforeMove); + const sourceParagraph = paragraphsBeforeMove.find( + (paragraph) => paragraph?.nodeId !== targetParagraph.nodeId && paragraph?.isEmpty === false, + ); + expect(typeof sourceParagraph?.nodeId).toBe('string'); + + const moveResult = unwrap( + await client.doc.images.move({ + sessionId, + imageId, + to: { + kind: 'inParagraph', + target: targetParagraph, + offset: 0, + }, + }), + ); + expect(moveResult?.success).toBe(true); + + const blocksAfterMove = unwrap(await client.doc.blocks.list({ sessionId, limit: 20, includeText: true })); + const paragraphsAfterMove = getParagraphBlocks(blocksAfterMove); + expect(paragraphsAfterMove.some((paragraph) => paragraph?.nodeId === sourceParagraph?.nodeId)).toBe(false); + expect(paragraphsAfterMove.some((paragraph) => paragraph?.nodeId === targetParagraph.nodeId)).toBe(true); + const emptyParagraphCountAfterMove = paragraphsAfterMove.filter((paragraph) => paragraph?.isEmpty === true).length; + + await client.doc.save({ + sessionId, + out: savedDocPath, + force: true, + }); + + await client.doc.open({ + sessionId: reopenSessionId, + doc: savedDocPath, + }); + + const blocksAfterReopen = unwrap( + await client.doc.blocks.list({ sessionId: reopenSessionId, limit: 20, includeText: true }), + ); + const paragraphsAfterReopen = getParagraphBlocks(blocksAfterReopen); + expect(paragraphsAfterReopen).toHaveLength(paragraphsAfterMove.length); + expect(paragraphsAfterReopen.filter((paragraph) => paragraph?.isEmpty === true)).toHaveLength( + emptyParagraphCountAfterMove, + ); + expect(paragraphsAfterReopen.some((paragraph) => paragraph?.text === 'Destination paragraph')).toBe(true); + }); +}); From a3fd0421d9bea4cf7e7e247ef91d383f384ec031 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 26 May 2026 09:24:47 -0700 Subject: [PATCH 031/280] fix: more bugs --- .../__tests__/lib/special-handlers.test.ts | 4 +- apps/cli/src/lib/special-handlers.ts | 22 +- .../Editor.track-changes-dispatch.test.js | 202 +++++++++++++++++- .../src/editors/v1/core/Editor.ts | 106 +++++++-- .../helpers/selection-info-resolver.test.ts | 9 +- .../helpers/selection-info-resolver.ts | 12 +- .../helpers/tracked-change-resolver.test.ts | 14 +- .../helpers/tracked-change-resolver.ts | 63 ++---- .../plan-engine/executor.test.ts | 32 +++ .../plan-engine/executor.ts | 47 +++- .../v1/document-api-adapters/write-adapter.ts | 37 ++++ .../tests/data/basic-text-insertion-open.docx | Bin 0 -> 2989 bytes 12 files changed, 431 insertions(+), 117 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/data/basic-text-insertion-open.docx diff --git a/apps/cli/src/__tests__/lib/special-handlers.test.ts b/apps/cli/src/__tests__/lib/special-handlers.test.ts index 0f0bccfe86..e6095a16ff 100644 --- a/apps/cli/src/__tests__/lib/special-handlers.test.ts +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -54,8 +54,8 @@ describe('special track-changes handlers', () => { const parent = result.items[0] as TrackChangeItem & { overlap: Overlap }; const child = result.items[1] as TrackChangeItem; - expect(parent.id).not.toBe('raw-parent'); - expect(child.id).not.toBe('raw-child'); + expect(parent.id).toBe('raw-parent'); + expect(child.id).toBe('raw-child'); expect(parent.overlap.visualLayers[0].id).toBe(parent.id); expect(parent.overlap.visualLayers[1].id).toBe(child.id); expect(parent.overlap.preferredContextTargetId).toBe(child.id); diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index a26a8b235c..46b3792303 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -8,7 +8,6 @@ * capability should move into document-api. */ -import { createHash } from 'node:crypto'; import { INLINE_PROPERTY_REGISTRY } from '@superdoc/document-api'; import type { CliExposedOperationId } from '../cli/operation-set.js'; import type { EditorWithDoc } from './document.js'; @@ -59,15 +58,6 @@ function asTrackChangeAddress(value: unknown): { kind: string; entityType: strin }; } -function stableTrackChangeSignature(change: TrackChangeLike): string { - const type = typeof change.type === 'string' ? change.type : ''; - const author = typeof change.author === 'string' ? change.author : ''; - const authorEmail = typeof change.authorEmail === 'string' ? change.authorEmail : ''; - const date = typeof change.date === 'string' ? change.date : ''; - const excerpt = typeof change.excerpt === 'string' ? change.excerpt : ''; - return `${type}|${author}|${authorEmail}|${date}|${excerpt}`; -} - function normalizeStableTrackChangeId(value: unknown, rawToStableId: ReadonlyMap): unknown { if (typeof value !== 'string' || value.length === 0) return value; return rawToStableId.get(value) ?? value; @@ -103,7 +93,10 @@ function normalizeTrackChangeOverlap(value: unknown, rawToStableId: ReadonlyMap< /** * Builds stable-ID ↔ raw-ID mappings from a track-changes list result. - * The CLI uses SHA-1-based stable IDs instead of adapter raw IDs. + * The CLI preserves the adapter's logical ids when they are present. + * The mapping is still retained so pre-invoke hooks can translate ids from + * list/get payloads back into the exact raw/runtime ids that mutating + * adapters expect. */ function buildStableIdMappings(rawListResult: unknown): { normalizedResult: unknown; @@ -117,7 +110,6 @@ function buildStableIdMappings(rawListResult: unknown): { const stableToRawId = new Map(); const rawToStableId = new Map(); - const signatureCounts = new Map(); const entries = asArray(record.items) .map((entry) => asRecord(entry)) @@ -128,11 +120,7 @@ function buildStableIdMappings(rawListResult: unknown): { asTrackChangeAddress(entry.address)?.entityId; if (!rawId) return { entry }; - const signature = stableTrackChangeSignature(entry); - const hash = createHash('sha1').update(signature).digest('hex').slice(0, 24); - const nextCount = (signatureCounts.get(hash) ?? 0) + 1; - signatureCounts.set(hash, nextCount); - const stableId = nextCount === 1 ? hash : `${hash}-${nextCount}`; + const stableId = rawId; stableToRawId.set(stableId, rawId); rawToStableId.set(rawId, stableId); diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 80b467e89f..8beea3507a 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -15,11 +15,16 @@ const FOREIGN_INSERT_ID = 'foreign-insert'; const INSERTED_TEXT = 'here is my new text, do you like it?'; const INSERTED_TAIL = 'do you like it?'; const INSERTED_TEXT_AFTER_DIRECT_DELETE = INSERTED_TEXT.replace(INSERTED_TAIL, ''); +const DIRECT_INSERT_TEXT = 'MID'; +const IMPORTED_TRACKED_INSERTION_TEXT = 'lazy '; +const IMPORTED_TRACKED_INSERTION_TEXT_AFTER_DIRECT_INSERT = 'laMIDzy '; +const TEST_PARAGRAPH_BLOCK_ID = 'test-paragraph-1'; const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); const WORD_REPLACEMENT_FIXTURE = resolve( CURRENT_DIR, '../../../../../../tests/behavior/tests/comments/fixtures/sd-1960-word-replacement-no-comments.docx', ); +const BASIC_TEXT_INSERTION_OPEN_FIXTURE = resolve(CURRENT_DIR, '../tests/data/basic-text-insertion-open.docx'); const findTextRange = (editor, text) => { let found = null; @@ -62,7 +67,7 @@ const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_ const doc = schema.nodes.doc.create( {}, schema.nodes.paragraph.create( - {}, + { sdBlockId: TEST_PARAGRAPH_BLOCK_ID }, schema.nodes.run.create({}, [ schema.text('hello there '), schema.text(INSERTED_TEXT, [insertMark]), @@ -79,19 +84,23 @@ const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_ ); }; -const setDocumentWithSeparateRunTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_INSERT_ID } = {}) => { +const setDocumentWithSeparateRunTrackedInsertion = ( + editor, + { author = ALICE, id = FOREIGN_INSERT_ID, sourceId = '' } = {}, +) => { const { schema } = editor; const insertMark = schema.marks[TrackInsertMarkName].create({ id, author: author.name, authorEmail: author.email, date: FIXED_DATE, + sourceId, }); const doc = schema.nodes.doc.create( {}, - schema.nodes.paragraph.create({}, [ + schema.nodes.paragraph.create({ sdBlockId: TEST_PARAGRAPH_BLOCK_ID }, [ schema.nodes.run.create({}, schema.text('The quick brown fox jumps over the ')), - schema.nodes.run.create({}, schema.text('lazy ', [insertMark])), + schema.nodes.run.create({}, schema.text(IMPORTED_TRACKED_INSERTION_TEXT, [insertMark])), schema.nodes.run.create({}, schema.text('dog.')), ]), ); @@ -104,6 +113,7 @@ const setDocumentWithSeparateRunTrackedInsertion = (editor, { author = ALICE, id ); }; +const firstBlockId = (editor) => editor.state.doc.firstChild?.attrs?.sdBlockId ?? TEST_PARAGRAPH_BLOCK_ID; const deleteText = (editor, text) => { const { from, to } = findTextRange(editor, text); editor.dispatch(editor.state.tr.delete(from, to).setMeta('inputType', 'deleteContentBackward')); @@ -393,7 +403,9 @@ describe('Editor dispatch tracked-change meta', () => { const comments = editor.doc.comments.list(); expect(comments.total).toBe(1); expect(comments.items).toEqual( - expect.arrayContaining([expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live--comment' })]), + expect.arrayContaining([ + expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live--comment' }), + ]), ); }); @@ -430,6 +442,186 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + it('mutates another user tracked insertion in place on direct insert while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const insertPos = findTextRange(editor, INSERTED_TEXT).from + 4; + editor.dispatch(editor.state.tr.insertText('ZZ', insertPos).setMeta('inputType', 'insertText')); + + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe( + 'hereZZ is my new text, do you like it?', + ); + expect( + markEntries(editor, TrackInsertMarkName).filter(({ mark }) => mark.attrs?.id !== FOREIGN_INSERT_ID), + ).toHaveLength(0); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('updates the existing insertion review item on direct insert inside another user tracked insertion', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const emitSpy = vi.spyOn(editor, 'emit'); + const insertPos = findTextRange(editor, INSERTED_TEXT).from + 4; + editor.dispatch(editor.state.tr.insertText('ZZ', insertPos).setMeta('inputType', 'insertText')); + + expect(editor.doc.comments.list().items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + trackedChangeType: 'insert', + trackedChangeText: 'hereZZ is my new text, do you like it?', + }), + ]), + ); + + const addChildInsertionEvent = emitSpy.mock.calls.find( + ([eventName, payload]) => + eventName === 'commentsUpdate' && + payload?.type === 'trackedChange' && + payload?.event === 'add' && + payload?.trackedChangeType === TrackInsertMarkName && + payload?.trackedChangeText === 'ZZ', + )?.[1]; + + expect(addChildInsertionEvent).toBeUndefined(); + }); + + it('keeps the tracked-change id and linked review comment id stable on direct insert inside a live tracked insertion', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: { id: 'cli', name: 'CLI' }, + useImmediateSetTimeout: false, + })); + + const insertReceipt = editor.doc.insert({ value: 'live-review-comment' }, { changeMode: 'tracked' }); + expect(insertReceipt.success).toBe(true); + + const beforeTrackedChanges = editor.doc.trackChanges.list().items; + const beforeComments = editor.doc.comments.list().items; + + expect(beforeTrackedChanges).toHaveLength(1); + expect(beforeComments).toHaveLength(1); + + const beforeChangeId = beforeTrackedChanges[0].id; + const beforeCommentId = beforeComments[0].commentId; + + const reviewStart = editor.state.doc.textContent.indexOf('review'); + expect(reviewStart).toBeGreaterThanOrEqual(0); + const insertOffset = reviewStart + 2; + + const receipt = editor.doc.insert( + { + value: 'ZZ', + target: { + kind: 'selection', + start: { kind: 'text', blockId: firstBlockId(editor), offset: insertOffset }, + end: { kind: 'text', blockId: firstBlockId(editor), offset: insertOffset }, + }, + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + + const afterTrackedChanges = editor.doc.trackChanges.list().items; + const afterComments = editor.doc.comments.list().items; + + expect(afterTrackedChanges).toHaveLength(1); + expect(afterComments).toHaveLength(1); + expect(afterTrackedChanges[0].id).toBe(beforeChangeId); + expect(afterComments[0].commentId).toBe(beforeCommentId); + expect(afterTrackedChanges[0].insertedText).toBe('live-reZZview-comment'); + expect(afterComments[0].trackedChangeText).toBe('live-reZZview-comment'); + }); + + it('mutates an imported-style separate-run tracked insertion in place from document-api direct insert', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithSeparateRunTrackedInsertion(editor, { sourceId: '11' }); + + const beforeTrackedChanges = editor.doc.trackChanges.list().items; + expect(beforeTrackedChanges).toHaveLength(1); + const beforeChangeId = beforeTrackedChanges[0].id; + + const insertOffset = 'The quick brown fox jumps over the la'.length; + const receipt = editor.doc.insert( + { + value: DIRECT_INSERT_TEXT, + target: { + kind: 'selection', + start: { kind: 'text', blockId: firstBlockId(editor), offset: insertOffset }, + end: { kind: 'text', blockId: firstBlockId(editor), offset: insertOffset }, + }, + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe( + IMPORTED_TRACKED_INSERTION_TEXT_AFTER_DIRECT_INSERT, + ); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + + const afterTrackedChanges = editor.doc.trackChanges.list().items; + expect(afterTrackedChanges).toHaveLength(1); + expect(afterTrackedChanges[0].id).toBe(beforeChangeId); + expect(afterTrackedChanges[0].insertedText).toBe(IMPORTED_TRACKED_INSERTION_TEXT_AFTER_DIRECT_INSERT); + }); + + it('keeps imported tracked insertions semantically updated after document-api direct insert inside the imported text', async () => { + const fixture = await readFile(BASIC_TEXT_INSERTION_OPEN_FIXTURE); + editor = await Editor.open(fixture, { + isHeadless: true, + user: BOB, + }); + + const beforeTrackedChanges = editor.doc.trackChanges.list().items; + expect(beforeTrackedChanges).toHaveLength(1); + const beforeChangeId = beforeTrackedChanges[0].id; + + const match = editor.doc.query.match({ + select: { type: 'text', pattern: 'zy ', caseSensitive: true }, + require: 'first', + includeNodes: true, + }); + const targetStart = match?.items?.[0]?.target?.start; + expect(targetStart).toBeDefined(); + + const receipt = editor.doc.insert( + { + value: DIRECT_INSERT_TEXT, + target: { + kind: 'selection', + start: targetStart, + end: targetStart, + }, + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + const afterTrackedChanges = editor.doc.trackChanges.list().items; + expect(afterTrackedChanges).toHaveLength(1); + expect(afterTrackedChanges[0].id).toBe(beforeChangeId); + expect(afterTrackedChanges[0].insertedText).toBe(IMPORTED_TRACKED_INSERTION_TEXT_AFTER_DIRECT_INSERT); + }); + it('protects an imported-style separate-run tracked insertion from direct doc.replace', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index e34646807c..16e66dbd28 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -213,11 +213,7 @@ const rangeIsTrackedInsertionOnly = (doc: PmNode, from: number, to: number): boo return sawTrackedInsertion && !sawUntrackedInline && !sawNonInsertionTrackedMark; }; -const getSingleTrackedInsertionMarkInRange = ( - doc: PmNode, - from: number, - to: number, -): PmMark | null => { +const getSingleTrackedInsertionMarkInRange = (doc: PmNode, from: number, to: number): PmMark | null => { if (!rangeIsTrackedInsertionOnly(doc, from, to)) return null; /** @type {import('prosemirror-model').Mark[]} */ @@ -237,27 +233,41 @@ const getSingleTrackedInsertionMarkInRange = ( return insertionMarks.length === 1 ? insertionMarks[0] : null; }; -const resolveDirectInsertionDeleteCommentMeta = ( +const getSingleTrackedInsertionMarkAtCollapsedPosition = (doc: PmNode, pos: number): PmMark | null => { + const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); + const $pos = doc.resolve(boundedPos); + const beforeInsert = $pos.nodeBefore?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const afterInsert = $pos.nodeAfter?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const beforeId = typeof beforeInsert?.attrs?.id === 'string' ? beforeInsert.attrs.id : null; + const afterId = typeof afterInsert?.attrs?.id === 'string' ? afterInsert.attrs.id : null; + if (!beforeInsert || !afterInsert || !beforeId || beforeId !== afterId) return null; + return beforeInsert; +}; + +const resolveDirectInsertionMutationCommentMeta = ( state: EditorState, tr: Transaction, -): - | { - insertedMark: PmMark; - deletionMark: null; - formatMark: null; - deletionNodes: []; - step: ReplaceStep; - emitCommentEvent: true; - } - | null => { +): { + insertedMark: PmMark; + deletionMark: null; + formatMark: null; + deletionNodes: []; + step: ReplaceStep; + emitCommentEvent: true; +} | null => { if (!tr.docChanged || tr.steps.length !== 1) return null; const [step] = tr.steps; - if (!(step instanceof ReplaceStep) || step.from === step.to || step.slice.content.size !== 0) return null; + if (!(step instanceof ReplaceStep)) return null; const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; const docBeforeStep = docs[0] ?? state.doc; - const insertedMark = getSingleTrackedInsertionMarkInRange(docBeforeStep, step.from, step.to); + const insertedMark = + step.from !== step.to && step.slice.content.size === 0 + ? getSingleTrackedInsertionMarkInRange(docBeforeStep, step.from, step.to) + : step.from === step.to && step.slice.content.size > 0 + ? getSingleTrackedInsertionMarkAtCollapsedPosition(docBeforeStep, step.from) + : null; if (!insertedMark) return null; return { @@ -297,6 +307,37 @@ const fragmentHasTrackedReviewMark = (fragment: { return found; }; +const collapsedInsertionExtendsTrackedInsertion = ( + doc: PmNode, + pos: number, + slice: { content?: { descendants?: (fn: (node: PmNode) => false | void) => void } }, +): boolean => { + const enclosingInsert = getSingleTrackedInsertionMarkAtCollapsedPosition(doc, pos); + if (!enclosingInsert) return false; + if (!slice.content) return true; + + const enclosingInsertId = typeof enclosingInsert.attrs?.id === 'string' ? enclosingInsert.attrs.id : null; + if (!enclosingInsertId) return false; + + let compatible = true; + slice.content.descendants?.((node) => { + if (!compatible || !node.isInline) return compatible ? undefined : false; + const trackedMarks = (node.marks ?? []).filter(isTrackedReviewMark); + if (!trackedMarks.length) return; + + const allMatchEnclosingInsertion = trackedMarks.every( + (mark) => mark.type?.name === TrackInsertMarkName && mark.attrs?.id === enclosingInsertId, + ); + if (!allMatchEnclosingInsertion) { + compatible = false; + return false; + } + return undefined; + }); + + return compatible; +}; + const collapsedInsertionExtendsTrackedReviewMark = ( doc: PmNode, pos: number, @@ -315,7 +356,21 @@ const stepTouchesTrackedReviewState = (step: unknown, doc: PmNode): boolean => { // Direct deletes wholly inside an unresolved insertion mutate that // insertion in place to match Word. They should not be rerouted into a // synthetic tracked delete. - if (step.from !== step.to && step.slice.content.size === 0 && rangeIsTrackedInsertionOnly(doc, step.from, step.to)) { + if ( + step.from !== step.to && + step.slice.content.size === 0 && + rangeIsTrackedInsertionOnly(doc, step.from, step.to) + ) { + return false; + } + // Direct typing strictly inside a single unresolved insertion should + // extend that insertion in place. Re-routing through trackedTransaction + // would create a child insertion and split the parent suggestion. + if ( + step.from === step.to && + step.slice.content.size > 0 && + collapsedInsertionExtendsTrackedInsertion(doc, step.from, step.slice) + ) { return false; } if (rangeHasTrackedReviewMark(doc, step.from, step.to)) return true; @@ -3025,7 +3080,10 @@ export class Editor extends EventEmitter { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; - const directInsertionDeleteCommentMeta = resolveDirectInsertionDeleteCommentMeta(prevState, transactionToApply); + const directInsertionMutationCommentMeta = resolveDirectInsertionMutationCommentMeta( + prevState, + transactionToApply, + ); const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); if (protectsExistingTrackedReviewState && skipTrackChanges) { transactionToApply.setMeta('protectTrackedReviewState', true); @@ -3033,8 +3091,12 @@ export class Editor extends EventEmitter { const shouldTrack = ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; - if (!shouldTrack && directInsertionDeleteCommentMeta && !transactionToApply.getMeta(TrackChangesBasePluginKey)) { - transactionToApply.setMeta(TrackChangesBasePluginKey, directInsertionDeleteCommentMeta); + if ( + !shouldTrack && + directInsertionMutationCommentMeta && + !transactionToApply.getMeta(TrackChangesBasePluginKey) + ) { + transactionToApply.setMeta(TrackChangesBasePluginKey, directInsertionMutationCommentMeta); } if (shouldTrack && forceTrackChanges && !this.options.user) { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts index 47a163823b..c9ad3485d3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts @@ -458,11 +458,10 @@ describe('resolveCurrentSelectionInfo > entity ids', () => { }); it('collects changeIds from trackInsert/trackDelete/trackFormat marks (translated through canonical resolver)', () => { - // Raw mark ids and canonical Document API ids differ: the canonical - // id is a derived hash from `groupTrackedChanges`. We mock that map - // so the resolver sees raw 'tc1' / 'tc2' / 'tc3' and returns the - // canonical 'tcA' / 'tcB' / 'tcC' that consumers see in - // `trackChanges.list().items[].id`. + // Raw mark ids and canonical Document API ids can differ. We mock + // that map so the resolver sees raw 'tc1' / 'tc2' / 'tc3' and + // returns the canonical 'tcA' / 'tcB' / 'tcC' that consumers see + // in `trackChanges.list().items[].id`. setTrackedChangeMapping([ { rawId: 'tc1', canonical: 'tcA' }, { rawId: 'tc2', canonical: 'tcB' }, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts index 8c6d90f3c0..0cfc8c150e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts @@ -49,13 +49,11 @@ export function resolveCurrentSelectionInfo(editor: Editor, input: SelectionCurr const activeMarks = collectActiveMarks(state, from, to); const { commentIds: activeCommentIds, changeIds: activeChangeRawIds } = collectActiveEntityIds(state, from, to); - // Tracked-change marks store their PM `attrs.id` (raw id), but the - // Document API's canonical id (`trackChanges.list().items[].id`) is a - // derived hash from `groupTrackedChanges`. Consumers compare the - // active ids against `list()` output to highlight the active sidebar - // card; returning raw ids would silently miss every match. Translate - // raw → canonical here so `activeChangeIds` matches the public - // contract. + // Tracked-change marks store their PM `attrs.id` (raw id), while the + // Document API can expose a different stable canonical id (for example + // imported Word revisions use a source-wrapper key and paired changes may + // collapse multiple aliases to one public id). Translate raw → canonical + // here so `activeChangeIds` matches the public contract. const activeChangeIds = mapRawChangeIdsToCanonical(editor, activeChangeRawIds); const info: SelectionInfo = { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index e4ede58fee..90975c151a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -77,11 +77,13 @@ describe('groupTrackedChanges', () => { expect(grouped).toHaveLength(2); expect(grouped[0]?.rawId).toBe(`word:${TrackInsertMarkName}:11`); + expect(grouped[0]?.id).toBe(`word:${TrackInsertMarkName}:11`); expect(grouped[0]?.commandRawId).toBe('tc-1'); expect(grouped[0]?.hasInsert).toBe(true); expect(grouped[0]?.hasDelete).toBe(false); expect(grouped[0]?.wordRevisionIds).toEqual({ insert: '11' }); expect(grouped[1]?.rawId).toBe(`word:${TrackDeleteMarkName}:10`); + expect(grouped[1]?.id).toBe(`word:${TrackDeleteMarkName}:10`); expect(grouped[1]?.commandRawId).toBe('tc-1'); expect(grouped[1]?.hasInsert).toBe(false); expect(grouped[1]?.hasDelete).toBe(true); @@ -98,6 +100,7 @@ describe('groupTrackedChanges', () => { expect(grouped).toHaveLength(1); expect(grouped[0]?.rawId).toBe('tc-1'); + expect(grouped[0]?.id).toBe('tc-1'); expect(grouped[0]?.from).toBe(1); expect(grouped[0]?.to).toBe(10); expect(grouped[0]?.hasInsert).toBe(true); @@ -114,7 +117,7 @@ describe('groupTrackedChanges', () => { expect(grouped).toHaveLength(2); }); - it('generates deterministic stable ids', () => { + it('keeps stable ids tied to the logical grouped raw id', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { author: 'Ada' }), from: 2, to: 5 }, ] as never); @@ -128,7 +131,8 @@ describe('groupTrackedChanges', () => { }; const second = groupTrackedChanges(editor); - expect(first[0]?.id).toBe(second[0]?.id); + expect(first[0]?.id).toBe('tc-1'); + expect(second[0]?.id).toBe('tc-1'); }); it('caches results by document reference', () => { @@ -281,7 +285,7 @@ describe('resolveTrackedChange', () => { vi.clearAllMocks(); }); - it('finds a grouped change by derived id', () => { + it('finds a grouped change by canonical id', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, ] as never); @@ -306,7 +310,7 @@ describe('toCanonicalTrackedChangeId', () => { vi.clearAllMocks(); }); - it('maps a raw id to its canonical derived id', () => { + it('maps a raw id to its canonical stable id', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, ] as never); @@ -314,7 +318,7 @@ describe('toCanonicalTrackedChangeId', () => { const editor = makeEditor(); const canonical = toCanonicalTrackedChangeId(editor, 'tc-1'); expect(typeof canonical).toBe('string'); - expect(canonical).not.toBe('tc-1'); + expect(canonical).toBe('tc-1'); }); it('returns null for unknown raw ids', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index 734a76ae12..48b66e5d21 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -14,13 +14,11 @@ import { TrackInsertMarkName, } from '../../extensions/track-changes/constants.js'; import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; -import { normalizeExcerpt, toNonEmptyString } from './value-utils.js'; +import { toNonEmptyString } from './value-utils.js'; import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; import { buildStoryKey, BODY_STORY_KEY } from '../story-runtime/story-key.js'; import type { TrackedChangeRuntimeRef } from './tracked-change-runtime-ref.js'; -const DERIVED_ID_LENGTH = 24; - type RawTrackedMark = { mark: { type: { name: string }; @@ -65,56 +63,19 @@ function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { } /** - * Browser-safe hash producing a {@link DERIVED_ID_LENGTH}-char hex string. + * Returns the stable public id for one grouped tracked change. * - * Uses FNV-1a-inspired mixing across three independent accumulators to produce - * a 96-bit (24-hex-char) digest. This is NOT cryptographic — it only needs to - * be deterministic with low collision probability for tracked-change IDs. - */ -function portableHash(input: string): string { - let h1 = 0x811c9dc5; - let h2 = 0x01000193; - let h3 = 0xdeadbeef; - - for (let i = 0; i < input.length; i++) { - const c = input.charCodeAt(i); - h1 = Math.imul(h1 ^ c, 0x01000193); - h2 = Math.imul(h2 ^ c, 0x5bd1e995); - h3 = Math.imul(h3 ^ c, 0x1b873593); - } - - h1 = Math.imul(h1 ^ (h1 >>> 16), 0x85ebca6b); - h2 = Math.imul(h2 ^ (h2 >>> 16), 0xcc9e2d51); - h3 = Math.imul(h3 ^ (h3 >>> 16), 0x1b873593); - - return ( - (h1 >>> 0).toString(16).padStart(8, '0') + - (h2 >>> 0).toString(16).padStart(8, '0') + - (h3 >>> 0).toString(16).padStart(8, '0') - ).slice(0, DERIVED_ID_LENGTH); -} - -/** - * Derives a deterministic ID for a tracked change from the current document state. + * Track-change ids must survive in-place refinement of the same logical change + * (for example direct editing inside an open insertion). The grouped raw id is + * already the stable logical identity: + * - native changes → raw mark id + * - imported Word changes → source-wrapper key (`word:trackInsert:1`, etc.) * - * The ID is computed from the change type, ProseMirror positions, author, - * date, and a text excerpt. It is stable for a given document state but will - * change if the document is edited, since positions shift. These are NOT - * persistent identifiers — they are ephemeral keys valid only for the - * current transaction snapshot. + * Using the raw group key keeps the public id stable while still allowing the + * canonical-id map to collapse paired replacement aliases at the API edge. */ -function deriveTrackedChangeId(editor: Editor, change: Omit): string { - const type = resolveTrackedChangeType(change); - const excerpt = - (change.excerpt !== undefined ? change.excerpt : undefined) ?? - normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? - ''; - const author = toNonEmptyString(change.attrs.author) ?? ''; - const authorEmail = toNonEmptyString(change.attrs.authorEmail) ?? ''; - const date = toNonEmptyString(change.attrs.date) ?? ''; - const signature = `${type}|${change.from}|${change.to}|${author}|${authorEmail}|${date}|${excerpt}`; - - return portableHash(signature); +function deriveTrackedChangeId(change: Omit): string { + return change.rawId; } export function resolveTrackedChangeType(change: ChangeTypeInput): TrackChangeType { @@ -391,7 +352,7 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { }; return { ...withExcerpt, - id: deriveTrackedChangeId(editor, withExcerpt), + id: deriveTrackedChangeId(withExcerpt), }; }) .sort((a, b) => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index eea4510f58..c66f93dcfa 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -456,6 +456,38 @@ describe('executeTextInsert: setMarks tri-state directives', () => { expect(insertedMarks.find((mark) => mark.type.name === 'bold')?.attrs).toEqual({ value: '0' }); expect(insertedMarks.find((mark) => mark.type.name === 'underline')?.attrs).toEqual({ underlineType: 'none' }); }); + + it('preserves a shared trackInsert mark for direct inserts strictly inside an open insertion', () => { + const { editor, tr } = makeEditor(); + const trackInsert = createTestMark('trackInsert', { id: 'ins-1', authorEmail: 'alice@example.com' }); + const textStyle = createTestMark('textStyle', { bold: true }); + + (tr as any).getMeta = vi.fn((key: string) => (key === 'skipTrackChanges' ? true : undefined)); + (tr as any).doc.resolve = vi.fn(() => ({ + marks: () => [textStyle], + nodeBefore: { type: { name: 'text' }, nodeSize: 2, marks: [trackInsert, textStyle] }, + nodeAfter: { type: { name: 'text' }, nodeSize: 3, marks: [trackInsert, textStyle] }, + parent: { type: { contentMatch: { matchType: () => ({}) } } }, + })); + + const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any; + const step: TextInsertStep = { + id: 'insert-direct-track-inherit', + op: 'text.insert', + where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' }, + args: { position: 'before', content: { text: 'MID' } }, + } as any; + + const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any); + + expect(outcome).toEqual({ changed: true }); + const insertedNode = tr.insert.mock.calls[0][1]; + const insertedMarks = insertedNode.marks as Array<{ type: { name: string }; attrs: Record }>; + expect(insertedMarks.map((mark) => mark.type.name)).toEqual(expect.arrayContaining(['textStyle', 'trackInsert'])); + expect(insertedMarks.find((mark) => mark.type.name === 'trackInsert')?.attrs).toEqual( + expect.objectContaining({ id: 'ins-1', authorEmail: 'alice@example.com' }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 4eaee5b0d0..5a2330695a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -1021,20 +1021,61 @@ function resolveInheritedMarksAt(editor: Editor, tr: Transaction, absPos: number const state = editor.state as unknown as { doc: { resolve?: unknown } }; if (typeof state?.doc?.resolve !== 'function') { const $pos = tr.doc.resolve(absPos); - return $pos.marks(); + return mergeDirectInsertTrackedInsertionMark(tr, absPos, $pos.marks()); } const resolved = getFormattingStateAtPos( editor.state as unknown as import('prosemirror-state').EditorState, absPos, editor as unknown as undefined, ); - return (resolved?.resolvedMarks as ProseMirrorMark[]) ?? []; + return mergeDirectInsertTrackedInsertionMark(tr, absPos, (resolved?.resolvedMarks as ProseMirrorMark[]) ?? []); } catch { const $pos = tr.doc.resolve(absPos); - return $pos.marks(); + return mergeDirectInsertTrackedInsertionMark(tr, absPos, $pos.marks()); } } +function getSharedTrackedInsertionMarkAt(tr: Transaction, absPos: number): ProseMirrorMark | null { + const maxPos = typeof tr.doc.content?.size === 'number' ? tr.doc.content.size : absPos; + const boundedPos = Math.max(0, Math.min(maxPos, absPos)); + const $pos = tr.doc.resolve(boundedPos); + const beforeInsert = $pos.nodeBefore?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const afterInsert = $pos.nodeAfter?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const beforeId = typeof beforeInsert?.attrs?.id === 'string' ? beforeInsert.attrs.id : null; + const afterId = typeof afterInsert?.attrs?.id === 'string' ? afterInsert.attrs.id : null; + + if (!beforeInsert || !afterInsert || !beforeId || beforeId !== afterId) { + return null; + } + + return beforeInsert; +} + +function mergeDirectInsertTrackedInsertionMark( + tr: Transaction, + absPos: number, + marks: readonly ProseMirrorMark[], +): readonly ProseMirrorMark[] { + if (tr.getMeta?.('skipTrackChanges') !== true) { + return marks; + } + + const sharedInsert = getSharedTrackedInsertionMarkAt(tr, absPos); + if (!sharedInsert) { + return marks; + } + + const sharedInsertId = typeof sharedInsert.attrs?.id === 'string' ? sharedInsert.attrs.id : null; + if ( + sharedInsertId && + marks.some((mark) => mark.type?.name === TrackInsertMarkName && mark.attrs?.id === sharedInsertId) + ) { + return marks; + } + + return [...marks, sharedInsert]; +} + export function executeTextInsert( editor: Editor, tr: Transaction, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index 30e2f092a8..4c3c97ab15 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -1,7 +1,9 @@ import { v4 as uuidv4 } from 'uuid'; import type { Editor } from '../core/Editor.js'; import type { MutationOptions, ReceiptFailure, TextAddress, TextMutationReceipt } from '@superdoc/document-api'; +import type { Mark as ProseMirrorMark } from 'prosemirror-model'; import { DocumentApiAdapterError } from './errors.js'; +import { TrackInsertMarkName } from '../extensions/track-changes/constants.js'; import { ensureTrackedCapability } from './helpers/mutation-helpers.js'; import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js'; import { checkRevision } from './plan-engine/revision-tracker.js'; @@ -46,6 +48,31 @@ type LegacyDeleteWriteRequest = { type LegacyWriteRequest = LegacyInsertWriteRequest | LegacyReplaceWriteRequest | LegacyDeleteWriteRequest; +function getSharedTrackedInsertionMarkAtPosition(editor: Editor, pos: number): ProseMirrorMark | null { + const boundedPos = Math.max(0, Math.min(editor.state.doc.content.size, pos)); + const $pos = editor.state.doc.resolve(boundedPos); + const beforeInsert = $pos.nodeBefore?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const afterInsert = $pos.nodeAfter?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; + const beforeId = typeof beforeInsert?.attrs?.id === 'string' ? beforeInsert.attrs.id : null; + const afterId = typeof afterInsert?.attrs?.id === 'string' ? afterInsert.attrs.id : null; + + if (!beforeInsert || !afterInsert || !beforeId || beforeId !== afterId) { + return null; + } + + return beforeInsert; +} + +function resolveDirectInsertMarks(editor: Editor, pos: number): ProseMirrorMark[] { + const boundedPos = Math.max(0, Math.min(editor.state.doc.content.size, pos)); + const marks = [...editor.state.doc.resolve(boundedPos).marks()]; + const trackedInsert = getSharedTrackedInsertionMarkAtPosition(editor, boundedPos); + + if (!trackedInsert) return marks; + if (marks.some((mark) => mark.eq(trackedInsert))) return marks; + return [...marks, trackedInsert]; +} + function resolveLegacyWriteTarget(editor: Editor, request: LegacyWriteRequest): ResolvedWrite | null { // Target-less insert → default document-end insertion if (request.kind === 'insert' && !request.target) { @@ -217,6 +244,16 @@ function applyDirectWrite( return { success: true, resolution: resolvedTarget.resolution }; } + if (request.kind === 'insert') { + const marks = resolveDirectInsertMarks(editor, resolvedTarget.range.from); + if (marks.length > 0) { + const textNode = editor.state.schema.text(request.text ?? '', marks); + const tr = applyDirectMutationMeta(editor.state.tr.insert(resolvedTarget.range.from, textNode)); + editor.dispatch(tr); + return { success: true, resolution: resolvedTarget.resolution }; + } + } + // text is guaranteed non-empty for insert/replace after validateWriteRequest const tr = applyDirectMutationMeta( editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to), diff --git a/packages/super-editor/src/editors/v1/tests/data/basic-text-insertion-open.docx b/packages/super-editor/src/editors/v1/tests/data/basic-text-insertion-open.docx new file mode 100644 index 0000000000000000000000000000000000000000..f953d052a07ba5c2ce48953c88aed9499e24adb0 GIT binary patch literal 2989 zcmZ`*2{=>>8=f&^SEfQYWQl|a-tRL(LZEyg5Qqbm99eF2l6Fj1 z2@C>tKtP}az}w3@UYf!EMjTZI6OM}=99F+>Tr#osDFV#UVX+^5f9q_=1@?OGhKnj{tj z{i4vAPWql4qA_jRh>h>nodG&4s@{^TjRi+jMMuYeDufz^$lYuis+$4#wz>KgosWSg z3O*F{R=yEwr>K6rK*j!}H{XJE6Ve76Z=R#;CcuTp zr!P*{Q3CDas0XvSE3BSfr5z7znUIUVr|>Bk^7exSf02iBYa?;>@#1Zl!jG8|5)BWh z2s~m-8eH}2N3@;>xQ3{*chex-p)r~BYpa?sBENE!TGsPL3_c+!FL4Qc4veJ6nfp=S z9pb8K&5w_h+nQ-N>F>m;kCQ8Ty_YymeqdPE0M|AFFngW_1mXtn_TFd@tfIosl}ufT5idY zld(=aDgRTP!n)F`K$d^}6E(QGbWpmfGq-+8shxawz>zM(>DU@U^b2=K7=11J^&a5| zsxsK#Y=sof+>UN1sWol&x49UvvQ6hknI4b2sK<*~_*18MzYY}6{L;)zpM&V1>Y1>_ z6AC}c35=O8GBl`nu7Ep+S=;Y;%-mzLH!4^q{ z^Wnc5(nB+DFcP}`a-7BxFrxHW(Sl^biSqkR}e-2w^Lj?E%2lbXttj28Cv zagq%Qx=Ngy*KN5g6zDVFB4de-;$|wpmg5MsjfrmN*OIW9@j-Wp0Bd#5nsh4GjIbw{ zF5?>lA=ErLcG2F(?f9Wp{ih`SqGBamI&Cr1P}Aasb1%2f2|>D+)iTPhvhomArJyq$ z8Wksn(!L;vOPP14nw*lzZ_JD+Vw54eGY?1%RAPEvAVVG5ooh`!t)=eL$roZEg)!;x zM_6<)MX8vE&hyI-3a3Y`IYhJ~R`PLKj`ggpswSbz#{!}|#aBcht=x9i3r*JP5&86} z6MpNr?HUV~4;!P|savGqbnG_7bM}5wUk;URZn?Y)zuaz+LYtP1^`ic4N3zN?TFUrL zjrZ;f+uuSx`c+kg^)-BLs=;Cl^rHf7li&g}z`PKk0_=MQ9IsvbCP0T4_j5Q8e!Rgn zsNb0PNVgGmQ7RxUNC88Nl9w^!Tl+}n_%P@cFfN{LtAV#06~{^zuXtFyu`*w3Qo z%u6B3mxS5+4``G19iDm4ikso&_Fdu!)$~~=mMi18_}Q9!2xZA}OKsKDkg{scFcEs_NR-23N7KU*a#!rN#6Q7#W6sGH#){h=xj$jC=2Sm%gjA zS{{9U2K-}bzVhS6w}GtTl`vUx-nfYQHgod zCbOyz1CLuhV|0hDF{mU)^>lx{pCDomtp~pO?gYJAA2l|j;ZH_rw}?7aR_Ac zu2^gjh1rf6B+{W*t$9jQj=fyEQFX}hR^>;r1P5F~tK)H@+)J`47)T}^84E*LIDX(! zI$t3OuSx6FZh!Sh#ly}Z^69rXL-Gwof2TZL5)iRxB^vB6#|Pj?KBp;q=xlOiB3-Hp z%NCPwPX##soGn(Dr5Loq$ySnkHbS^#D}4Uv`nRs#t|b2CQ-ZAg#bB;L;p!NP*Q zvR{ag!*ruMCNBKZB$u!zA59f%di}J^Sof?mrEgI^VKyyGa5CyldC1TNyRbS>0xkN) z3o@5&@xd>CD8`n%t$SOC*Sdx|Iq=l8xgrt`JKFB}EF@dWD1*|yCL`0QVSkC;zSbNy z!a$i(x91LTd>}5gNp2gyv~=*r8XMGmy5iX-pL^2eFIsl7#X_>SkxCS<#1e?zrUK0- zZ1vI6H^LLfDkC-}PF~K;9?OvHsZp0T ztNRF)$mm-@507DwS2!IS2wvg2IdU)3idJ5~IsdlCPHYqaZ2)FU%`ndS32bWdsj9yqKFN-eFFLNtAqkAJBQLhPc-+#PnY0L|ny)!efC2>08 zH=PFq(^byQ_fuH~>3E{mJrgQBE9v?`FolRbm(C(K?Y{@vUS?@Hi=&)t-B%cJ!I(1n zm}E6av^npA$|K^NqdCN>bsL%`TippgluWdq?;8q9 z7qu50#<}1-dT1mFJKwc(qR_{4Xc2s4m*QttvWK%4&G+-8O|kC|Qg zMw{!6Yi!{x|r$_F;xIJ9QVH c&+!-hKYVL~WCi|iXD#OdZ35zCaPB<)4-b^`VgLXD literal 0 HcmV?d00001 From c7c9008250d156eabb696a4cc5872a743af76040 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 26 May 2026 09:52:32 -0700 Subject: [PATCH 032/280] chore: run generate:all and fixes --- .../reference/_generated-manifest.json | 1 - .../reference/authorities/entries-insert.mdx | 2 +- .../reference/authorities/entries-list.mdx | 2 +- .../reference/authorities/entries-update.mdx | 2 +- .../reference/authorities/insert.mdx | 2 +- .../document-api/reference/blocks/delete.mdx | 2 +- .../citations/bibliography-insert.mdx | 2 +- .../reference/comments/create.mdx | 2 +- .../document-api/reference/comments/get.mdx | 12 +- .../document-api/reference/comments/patch.mdx | 2 +- .../document-api/reference/create/heading.mdx | 2 +- .../document-api/reference/create/image.mdx | 2 +- .../reference/create/paragraph.mdx | 2 +- .../reference/create/section-break.mdx | 2 +- .../reference/create/table-of-contents.mdx | 2 +- .../document-api/reference/create/table.mdx | 2 +- .../reference/cross-refs/insert.mdx | 2 +- .../reference/custom-xml/parts/get.mdx | 2 +- .../reference/custom-xml/parts/patch.mdx | 6 +- .../reference/custom-xml/parts/remove.mdx | 4 +- apps/docs/document-api/reference/find.mdx | 2 +- .../reference/footnotes/configure.mdx | 2 +- .../document-api/reference/format/apply.mdx | 172 +++++++++--------- .../document-api/reference/format/b-cs.mdx | 4 +- .../document-api/reference/format/bold.mdx | 4 +- .../document-api/reference/format/border.mdx | 4 +- .../document-api/reference/format/caps.mdx | 4 +- .../reference/format/char-scale.mdx | 4 +- .../document-api/reference/format/color.mdx | 4 +- .../format/contextual-alternates.mdx | 4 +- .../docs/document-api/reference/format/cs.mdx | 4 +- .../document-api/reference/format/dstrike.mdx | 4 +- .../reference/format/east-asian-layout.mdx | 4 +- .../docs/document-api/reference/format/em.mdx | 4 +- .../document-api/reference/format/emboss.mdx | 4 +- .../reference/format/fit-text.mdx | 4 +- .../reference/format/font-family.mdx | 4 +- .../reference/format/font-size-cs.mdx | 4 +- .../reference/format/font-size.mdx | 4 +- .../reference/format/highlight.mdx | 4 +- .../document-api/reference/format/i-cs.mdx | 4 +- .../document-api/reference/format/imprint.mdx | 4 +- .../document-api/reference/format/italic.mdx | 4 +- .../document-api/reference/format/kerning.mdx | 4 +- .../document-api/reference/format/lang.mdx | 4 +- .../reference/format/letter-spacing.mdx | 4 +- .../reference/format/ligatures.mdx | 4 +- .../reference/format/num-form.mdx | 4 +- .../reference/format/num-spacing.mdx | 4 +- .../document-api/reference/format/o-math.mdx | 4 +- .../document-api/reference/format/outline.mdx | 4 +- .../format/paragraph/clear-alignment.mdx | 8 +- .../format/paragraph/clear-all-tab-stops.mdx | 8 +- .../format/paragraph/clear-border.mdx | 8 +- .../format/paragraph/clear-direction.mdx | 8 +- .../format/paragraph/clear-indentation.mdx | 8 +- .../format/paragraph/clear-shading.mdx | 8 +- .../format/paragraph/clear-spacing.mdx | 8 +- .../format/paragraph/clear-tab-stop.mdx | 8 +- .../paragraph/reset-direct-formatting.mdx | 8 +- .../format/paragraph/set-alignment.mdx | 8 +- .../reference/format/paragraph/set-border.mdx | 8 +- .../format/paragraph/set-direction.mdx | 8 +- .../format/paragraph/set-flow-options.mdx | 12 +- .../format/paragraph/set-indentation.mdx | 14 +- .../format/paragraph/set-keep-options.mdx | 12 +- .../format/paragraph/set-outline-level.mdx | 10 +- .../format/paragraph/set-shading.mdx | 12 +- .../format/paragraph/set-spacing.mdx | 14 +- .../format/paragraph/set-tab-stop.mdx | 8 +- .../reference/format/position.mdx | 4 +- .../document-api/reference/format/r-fonts.mdx | 4 +- .../document-api/reference/format/r-style.mdx | 4 +- .../document-api/reference/format/rtl.mdx | 4 +- .../document-api/reference/format/shading.mdx | 4 +- .../document-api/reference/format/shadow.mdx | 4 +- .../reference/format/small-caps.mdx | 4 +- .../reference/format/snap-to-grid.mdx | 4 +- .../reference/format/spec-vanish.mdx | 4 +- .../document-api/reference/format/strike.mdx | 4 +- .../reference/format/stylistic-sets.mdx | 4 +- .../reference/format/underline.mdx | 4 +- .../document-api/reference/format/vanish.mdx | 4 +- .../reference/format/vert-align.mdx | 4 +- .../reference/format/web-hidden.mdx | 4 +- .../reference/header-footers/get.mdx | 4 +- .../reference/header-footers/list.mdx | 2 +- .../reference/hyperlinks/patch.mdx | 12 +- .../document-api/reference/images/move.mdx | 2 +- .../reference/images/set-hyperlink.mdx | 4 +- .../document-api/reference/index/insert.mdx | 2 +- apps/docs/document-api/reference/insert.mdx | 6 +- .../reference/lists/set-level-layout.mdx | 2 +- .../reference/lists/set-level-restart.mdx | 4 +- .../reference/lists/set-value.mdx | 4 +- .../document-api/reference/lists/split.mdx | 4 +- .../reference/mutations/apply.mdx | 2 +- .../reference/mutations/preview.mdx | 2 +- .../document-api/reference/query/match.mdx | 4 +- .../document-api/reference/ranges/resolve.mdx | 6 +- apps/docs/document-api/reference/replace.mdx | 10 +- .../document-api/reference/sections/get.mdx | 10 +- .../reference/sections/set-page-borders.mdx | 10 +- .../reference/selection/current.mdx | 2 +- .../document-api/reference/styles/apply.mdx | 112 ++++++------ .../styles/paragraph/clear-style.mdx | 8 +- .../reference/styles/paragraph/set-style.mdx | 8 +- .../reference/tables/convert-from-text.mdx | 4 +- .../reference/tables/get-properties.mdx | 12 +- .../reference/tables/get-styles.mdx | 14 +- .../document-api/reference/tables/move.mdx | 4 +- .../reference/tables/set-borders.mdx | 28 +-- .../reference/tables/set-shading.mdx | 4 +- .../reference/tables/set-table-options.mdx | 4 +- .../reference/track-changes/decide.mdx | 2 +- .../reference/track-changes/get.mdx | 4 +- .../reference/track-changes/list.mdx | 4 +- .../lib/reference-docs-artifacts.test.ts | 49 +++++ .../scripts/lib/reference-docs-artifacts.ts | 149 +++++++++++++-- 119 files changed, 622 insertions(+), 457 deletions(-) create mode 100644 packages/document-api/scripts/lib/reference-docs-artifacts.test.ts diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 9ed6001303..dc4acccd0a 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -152,7 +152,6 @@ "apps/docs/document-api/reference/footnotes/remove.mdx", "apps/docs/document-api/reference/footnotes/update.mdx", "apps/docs/document-api/reference/format/apply.mdx", - "apps/docs/document-api/reference/format/apply.mdx", "apps/docs/document-api/reference/format/b-cs.mdx", "apps/docs/document-api/reference/format/bold.mdx", "apps/docs/document-api/reference/format/border.mdx", diff --git a/apps/docs/document-api/reference/authorities/entries-insert.mdx b/apps/docs/document-api/reference/authorities/entries-insert.mdx index 1549427879..f6379b3631 100644 --- a/apps/docs/document-api/reference/authorities/entries-insert.mdx +++ b/apps/docs/document-api/reference/authorities/entries-insert.mdx @@ -32,7 +32,7 @@ Returns an AuthorityEntryMutationResult indicating success with the entry addres | `at.story` | StoryLocator | no | StoryLocator | | `entry` | object | yes | | | `entry.bold` | boolean | no | | -| `entry.category` | string \\| integer | yes | One of: string, integer | +| `entry.category` | string \| integer | yes | One of: string, integer | | `entry.italic` | boolean | no | | | `entry.longCitation` | string | yes | | | `entry.shortCitation` | string | no | | diff --git a/apps/docs/document-api/reference/authorities/entries-list.mdx b/apps/docs/document-api/reference/authorities/entries-list.mdx index c6580e9f67..ff8ef8163b 100644 --- a/apps/docs/document-api/reference/authorities/entries-list.mdx +++ b/apps/docs/document-api/reference/authorities/entries-list.mdx @@ -26,7 +26,7 @@ Returns an AuthorityEntryListResult containing discovered entries with address a | Field | Type | Required | Description | | --- | --- | --- | --- | -| `category` | string \\| integer | no | One of: string, integer | +| `category` | string \| integer | no | One of: string, integer | | `limit` | integer | no | | | `offset` | integer | no | | diff --git a/apps/docs/document-api/reference/authorities/entries-update.mdx b/apps/docs/document-api/reference/authorities/entries-update.mdx index 1e8ecf1666..9280e15ae9 100644 --- a/apps/docs/document-api/reference/authorities/entries-update.mdx +++ b/apps/docs/document-api/reference/authorities/entries-update.mdx @@ -28,7 +28,7 @@ Returns an AuthorityEntryMutationResult indicating success or a failure. | --- | --- | --- | --- | | `patch` | object | yes | | | `patch.bold` | boolean | no | | -| `patch.category` | string \\| integer | no | One of: string, integer | +| `patch.category` | string \| integer | no | One of: string, integer | | `patch.italic` | boolean | no | | | `patch.longCitation` | string | no | | | `patch.shortCitation` | string | no | | diff --git a/apps/docs/document-api/reference/authorities/insert.mdx b/apps/docs/document-api/reference/authorities/insert.mdx index b24f3e80b3..976551468e 100644 --- a/apps/docs/document-api/reference/authorities/insert.mdx +++ b/apps/docs/document-api/reference/authorities/insert.mdx @@ -26,7 +26,7 @@ Returns an AuthoritiesMutationResult indicating success with the TOA address or | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `config` | object | no | | | `config.category` | integer | no | | | `config.entryPageSeparator` | string | no | | diff --git a/apps/docs/document-api/reference/blocks/delete.mdx b/apps/docs/document-api/reference/blocks/delete.mdx index 421ced1695..c156286cc3 100644 --- a/apps/docs/document-api/reference/blocks/delete.mdx +++ b/apps/docs/document-api/reference/blocks/delete.mdx @@ -55,7 +55,7 @@ Returns a BlocksDeleteResult receipt confirming the block was removed, including | `deletedBlock.nodeId` | string | no | | | `deletedBlock.nodeType` | string | no | | | `deletedBlock.ordinal` | number | no | | -| `deletedBlock.textPreview` | string \\| null | no | One of: string, null | +| `deletedBlock.textPreview` | string \| null | no | One of: string, null | | `success` | `true` | yes | Constant: `true` | ### Example response diff --git a/apps/docs/document-api/reference/citations/bibliography-insert.mdx b/apps/docs/document-api/reference/citations/bibliography-insert.mdx index 993b5fed34..212d564db9 100644 --- a/apps/docs/document-api/reference/citations/bibliography-insert.mdx +++ b/apps/docs/document-api/reference/citations/bibliography-insert.mdx @@ -26,7 +26,7 @@ Returns a BibliographyMutationResult indicating success with the bibliography ad | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `style` | string | no | | ### Example request diff --git a/apps/docs/document-api/reference/comments/create.mdx b/apps/docs/document-api/reference/comments/create.mdx index 2b0df61bad..89532d34f1 100644 --- a/apps/docs/document-api/reference/comments/create.mdx +++ b/apps/docs/document-api/reference/comments/create.mdx @@ -27,7 +27,7 @@ Returns a Receipt confirming the comment was created, including the new comment | Field | Type | Required | Description | | --- | --- | --- | --- | | `parentCommentId` | string | no | | -| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | +| `target` | TextAddress \| TextTarget \| SelectionTarget \| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | yes | | ### Example request diff --git a/apps/docs/document-api/reference/comments/get.mdx b/apps/docs/document-api/reference/comments/get.mdx index ad4a760c4c..8224e77ea9 100644 --- a/apps/docs/document-api/reference/comments/get.mdx +++ b/apps/docs/document-api/reference/comments/get.mdx @@ -49,7 +49,7 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `createdTime` | number | no | | | `creatorEmail` | string | no | | | `creatorName` | string | no | | -| `deletedText` | any | no | | +| `deletedText` | string \| null | no | | | `importedId` | string | no | | | `isInternal` | boolean | no | | | `parentCommentId` | string | no | | @@ -60,11 +60,11 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `target.story` | StoryLocator | no | StoryLocator | | `text` | string | no | | | `trackedChange` | boolean | no | | -| `trackedChangeAnchorKey` | any | no | | -| `trackedChangeDisplayType` | any | no | | -| `trackedChangeLink` | CommentTrackedChangeLink \\| null | no | One of: CommentTrackedChangeLink, null | -| `trackedChangeStory` | StoryLocator \\| null | no | One of: StoryLocator, null | -| `trackedChangeText` | any | no | | +| `trackedChangeAnchorKey` | string \| null | no | | +| `trackedChangeDisplayType` | string \| null | no | | +| `trackedChangeLink` | CommentTrackedChangeLink \| null | no | One of: CommentTrackedChangeLink, null | +| `trackedChangeStory` | StoryLocator \| null | no | One of: StoryLocator, null | +| `trackedChangeText` | string \| null | no | | | `trackedChangeType` | enum | no | `"insert"`, `"delete"`, `"replacement"`, `"format"` | ### Example response diff --git a/apps/docs/document-api/reference/comments/patch.mdx b/apps/docs/document-api/reference/comments/patch.mdx index e689e20c1b..375a1cb9de 100644 --- a/apps/docs/document-api/reference/comments/patch.mdx +++ b/apps/docs/document-api/reference/comments/patch.mdx @@ -29,7 +29,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields | `commentId` | string | yes | | | `isInternal` | boolean | no | | | `status` | enum | no | `"resolved"`, `"active"` | -| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | +| `target` | TextAddress \| TextTarget \| SelectionTarget \| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | no | | ### Example request diff --git a/apps/docs/document-api/reference/create/heading.mdx b/apps/docs/document-api/reference/create/heading.mdx index 06bbbdc051..5f96c10022 100644 --- a/apps/docs/document-api/reference/create/heading.mdx +++ b/apps/docs/document-api/reference/create/heading.mdx @@ -26,7 +26,7 @@ Returns a CreateHeadingResult with the new heading block ID and address. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `in` | StoryLocator | no | StoryLocator | | `level` | integer | yes | | | `text` | string | no | | diff --git a/apps/docs/document-api/reference/create/image.mdx b/apps/docs/document-api/reference/create/image.mdx index 2bfad8561b..058683138b 100644 --- a/apps/docs/document-api/reference/create/image.mdx +++ b/apps/docs/document-api/reference/create/image.mdx @@ -27,7 +27,7 @@ Returns a CreateImageResult with the new image address. | Field | Type | Required | Description | | --- | --- | --- | --- | | `alt` | string | no | | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="inParagraph") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") \| object(kind="inParagraph") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | | `in` | StoryLocator | no | StoryLocator | | `size` | object | no | | | `size.height` | number | no | | diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx index a50a5990f5..4b9cfdbaa6 100644 --- a/apps/docs/document-api/reference/create/paragraph.mdx +++ b/apps/docs/document-api/reference/create/paragraph.mdx @@ -26,7 +26,7 @@ Returns a CreateParagraphResult with the new paragraph block ID and address. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `in` | StoryLocator | no | StoryLocator | | `text` | string | no | | diff --git a/apps/docs/document-api/reference/create/section-break.mdx b/apps/docs/document-api/reference/create/section-break.mdx index ecf3a7911b..3e0ea33a32 100644 --- a/apps/docs/document-api/reference/create/section-break.mdx +++ b/apps/docs/document-api/reference/create/section-break.mdx @@ -26,7 +26,7 @@ Returns a CreateSectionBreakResult with the new section break position and secti | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `breakType` | enum | no | `"continuous"`, `"nextPage"`, `"evenPage"`, `"oddPage"` | | `headerFooterMargins` | object | no | | | `headerFooterMargins.footer` | number | no | | diff --git a/apps/docs/document-api/reference/create/table-of-contents.mdx b/apps/docs/document-api/reference/create/table-of-contents.mdx index d6d19b7787..3eeaf62bf7 100644 --- a/apps/docs/document-api/reference/create/table-of-contents.mdx +++ b/apps/docs/document-api/reference/create/table-of-contents.mdx @@ -26,7 +26,7 @@ Returns a CreateTableOfContentsResult with the new TOC block address. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `config` | object | no | | | `config.hideInWebView` | boolean | no | | | `config.hyperlinks` | boolean | no | | diff --git a/apps/docs/document-api/reference/create/table.mdx b/apps/docs/document-api/reference/create/table.mdx index 95baf6d48f..082ca428f9 100644 --- a/apps/docs/document-api/reference/create/table.mdx +++ b/apps/docs/document-api/reference/create/table.mdx @@ -26,7 +26,7 @@ Returns a CreateTableResult with the new table block ID and address. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="before") \\| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | | `columns` | integer | yes | | | `rows` | integer | yes | | diff --git a/apps/docs/document-api/reference/cross-refs/insert.mdx b/apps/docs/document-api/reference/cross-refs/insert.mdx index 04e9cdc54e..13cc8a301e 100644 --- a/apps/docs/document-api/reference/cross-refs/insert.mdx +++ b/apps/docs/document-api/reference/cross-refs/insert.mdx @@ -31,7 +31,7 @@ Returns a CrossRefMutationResult indicating success with the cross-reference add | `at.segments` | TextSegment[] | yes | | | `at.story` | StoryLocator | no | StoryLocator | | `display` | enum | yes | `"content"`, `"pageNumber"`, `"noteNumber"`, `"labelAndNumber"`, `"aboveBelow"`, `"numberOnly"`, `"numberFullContext"`, `"styledContent"`, `"styledPageNumber"` | -| `target` | object(kind="bookmark") \\| object(kind="heading") \\| object(kind="note") \\| object(kind="caption") \\| object(kind="numberedItem") \\| object(kind="styledParagraph") | yes | One of: object(kind="bookmark"), object(kind="heading"), object(kind="note"), object(kind="caption"), object(kind="numberedItem"), object(kind="styledParagraph") | +| `target` | object(kind="bookmark") \| object(kind="heading") \| object(kind="note") \| object(kind="caption") \| object(kind="numberedItem") \| object(kind="styledParagraph") | yes | One of: object(kind="bookmark"), object(kind="heading"), object(kind="note"), object(kind="caption"), object(kind="numberedItem"), object(kind="styledParagraph") | ### Example request diff --git a/apps/docs/document-api/reference/custom-xml/parts/get.mdx b/apps/docs/document-api/reference/custom-xml/parts/get.mdx index 8f47051e2a..a7fc0404be 100644 --- a/apps/docs/document-api/reference/custom-xml/parts/get.mdx +++ b/apps/docs/document-api/reference/custom-xml/parts/get.mdx @@ -26,7 +26,7 @@ Returns a CustomXmlPartInfo with id, partName, namespaces, schemaRefs, and conte | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Example request diff --git a/apps/docs/document-api/reference/custom-xml/parts/patch.mdx b/apps/docs/document-api/reference/custom-xml/parts/patch.mdx index 8fdde7ce28..ac9bd1635e 100644 --- a/apps/docs/document-api/reference/custom-xml/parts/patch.mdx +++ b/apps/docs/document-api/reference/custom-xml/parts/patch.mdx @@ -29,14 +29,14 @@ Returns a CustomXmlPartsMutationResult indicating success with the resolved targ | Field | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Variant 2 (required: schemaRefs) | Field | Type | Required | Description | | --- | --- | --- | --- | | `schemaRefs` | string[] | yes | | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Example request @@ -57,7 +57,7 @@ Returns a CustomXmlPartsMutationResult indicating success with the resolved targ | --- | --- | --- | --- | | `id` | string | no | | | `success` | `true` | yes | Constant: `true` | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Variant 2 (success=false) diff --git a/apps/docs/document-api/reference/custom-xml/parts/remove.mdx b/apps/docs/document-api/reference/custom-xml/parts/remove.mdx index 1b723f4ce8..2c19eedd2d 100644 --- a/apps/docs/document-api/reference/custom-xml/parts/remove.mdx +++ b/apps/docs/document-api/reference/custom-xml/parts/remove.mdx @@ -26,7 +26,7 @@ Returns a CustomXmlPartsMutationResult indicating success or a failure. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Example request @@ -46,7 +46,7 @@ Returns a CustomXmlPartsMutationResult indicating success or a failure. | --- | --- | --- | --- | | `id` | string | no | | | `success` | `true` | yes | Constant: `true` | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \| object | yes | One of: object, object | ### Variant 2 (success=false) diff --git a/apps/docs/document-api/reference/find.mdx b/apps/docs/document-api/reference/find.mdx index 4b939f036b..733f2fafa8 100644 --- a/apps/docs/document-api/reference/find.mdx +++ b/apps/docs/document-api/reference/find.mdx @@ -33,7 +33,7 @@ Returns an SDFindResult envelope (\{ total, limit, offset, items \}). Each item | `options.includeContext` | boolean | no | | | `options.includeProvenance` | boolean | no | | | `options.includeResolved` | boolean | no | | -| `select` | object(type="text") \\| object(type="node") | yes | One of: object(type="text"), object(type="node") | +| `select` | object(type="text") \| object(type="node") | yes | One of: object(type="text"), object(type="node") | | `within` | BlockNodeAddress | no | BlockNodeAddress | | `within.kind` | `"block"` | no | Constant: `"block"` | | `within.nodeId` | string | no | | diff --git a/apps/docs/document-api/reference/footnotes/configure.mdx b/apps/docs/document-api/reference/footnotes/configure.mdx index 55698c5647..8bea5c1875 100644 --- a/apps/docs/document-api/reference/footnotes/configure.mdx +++ b/apps/docs/document-api/reference/footnotes/configure.mdx @@ -31,7 +31,7 @@ Returns a FootnoteConfigResult indicating success or a failure. | `numbering.position` | enum | no | `"pageBottom"`, `"beneathText"`, `"sectionEnd"`, `"documentEnd"` | | `numbering.restartPolicy` | enum | no | `"continuous"`, `"eachSection"`, `"eachPage"` | | `numbering.start` | integer | no | | -| `scope` | object(kind="document") \\| object(kind="section") | yes | One of: object(kind="document"), object(kind="section") | +| `scope` | object(kind="document") \| object(kind="section") | yes | One of: object(kind="document"), object(kind="section") | | `type` | enum | yes | `"footnote"`, `"endnote"` | ### Example request diff --git a/apps/docs/document-api/reference/format/apply.mdx b/apps/docs/document-api/reference/format/apply.mdx index 465ba0192b..ccfff56f6d 100644 --- a/apps/docs/document-api/reference/format/apply.mdx +++ b/apps/docs/document-api/reference/format/apply.mdx @@ -30,49 +30,49 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe | --- | --- | --- | --- | | `in` | StoryLocator | no | StoryLocator | | `inline` | object | yes | | -| `inline.bCs` | boolean \\| null | no | One of: boolean, null | -| `inline.bold` | boolean \\| null | no | One of: boolean, null | -| `inline.border` | object \\| null | no | One of: object, null | -| `inline.caps` | boolean \\| null | no | One of: boolean, null | -| `inline.charScale` | number \\| null | no | One of: number, null | -| `inline.color` | string \\| null | no | One of: string, null | -| `inline.contextualAlternates` | boolean \\| null | no | One of: boolean, null | -| `inline.cs` | boolean \\| null | no | One of: boolean, null | -| `inline.dstrike` | boolean \\| null | no | One of: boolean, null | -| `inline.eastAsianLayout` | object \\| null | no | One of: object, null | -| `inline.em` | string \\| null | no | One of: string, null | -| `inline.emboss` | boolean \\| null | no | One of: boolean, null | -| `inline.fitText` | object \\| null | no | One of: object, null | -| `inline.fontFamily` | string \\| null | no | One of: string, null | -| `inline.fontSize` | number \\| null | no | One of: number, null | -| `inline.fontSizeCs` | number \\| null | no | One of: number, null | -| `inline.highlight` | string \\| null | no | One of: string, null | -| `inline.iCs` | boolean \\| null | no | One of: boolean, null | -| `inline.imprint` | boolean \\| null | no | One of: boolean, null | -| `inline.italic` | boolean \\| null | no | One of: boolean, null | -| `inline.kerning` | number \\| null | no | One of: number, null | -| `inline.lang` | object \\| null | no | One of: object, null | -| `inline.letterSpacing` | number \\| null | no | One of: number, null | -| `inline.ligatures` | string \\| null | no | One of: string, null | -| `inline.numForm` | string \\| null | no | One of: string, null | -| `inline.numSpacing` | string \\| null | no | One of: string, null | -| `inline.oMath` | boolean \\| null | no | One of: boolean, null | -| `inline.outline` | boolean \\| null | no | One of: boolean, null | -| `inline.position` | number \\| null | no | One of: number, null | -| `inline.rFonts` | object \\| null | no | One of: object, null | -| `inline.rStyle` | string \\| null | no | One of: string, null | -| `inline.rtl` | boolean \\| null | no | One of: boolean, null | -| `inline.shading` | object \\| null | no | One of: object, null | -| `inline.shadow` | boolean \\| null | no | One of: boolean, null | -| `inline.smallCaps` | boolean \\| null | no | One of: boolean, null | -| `inline.snapToGrid` | boolean \\| null | no | One of: boolean, null | -| `inline.specVanish` | boolean \\| null | no | One of: boolean, null | -| `inline.strike` | boolean \\| null | no | One of: boolean, null | -| `inline.stylisticSets` | object[] \\| null | no | One of: object[], null | -| `inline.underline` | boolean \\| null \\| object | no | One of: boolean, null, object | -| `inline.vanish` | boolean \\| null | no | One of: boolean, null | -| `inline.vertAlign` | enum \\| null | no | One of: enum, null | -| `inline.webHidden` | boolean \\| null | no | One of: boolean, null | +| `inline.bCs` | boolean \| null | no | One of: boolean, null | +| `inline.bold` | boolean \| null | no | One of: boolean, null | +| `inline.border` | object \| null | no | One of: object, null | +| `inline.caps` | boolean \| null | no | One of: boolean, null | +| `inline.charScale` | number \| null | no | One of: number, null | +| `inline.color` | string \| null | no | One of: string, null | +| `inline.contextualAlternates` | boolean \| null | no | One of: boolean, null | +| `inline.cs` | boolean \| null | no | One of: boolean, null | +| `inline.dstrike` | boolean \| null | no | One of: boolean, null | +| `inline.eastAsianLayout` | object \| null | no | One of: object, null | +| `inline.em` | string \| null | no | One of: string, null | +| `inline.emboss` | boolean \| null | no | One of: boolean, null | +| `inline.fitText` | object \| null | no | One of: object, null | +| `inline.fontFamily` | string \| null | no | One of: string, null | +| `inline.fontSize` | number \| null | no | One of: number, null | +| `inline.fontSizeCs` | number \| null | no | One of: number, null | +| `inline.highlight` | string \| null | no | One of: string, null | +| `inline.iCs` | boolean \| null | no | One of: boolean, null | +| `inline.imprint` | boolean \| null | no | One of: boolean, null | +| `inline.italic` | boolean \| null | no | One of: boolean, null | +| `inline.kerning` | number \| null | no | One of: number, null | +| `inline.lang` | object \| null | no | One of: object, null | +| `inline.letterSpacing` | number \| null | no | One of: number, null | +| `inline.ligatures` | string \| null | no | One of: string, null | +| `inline.numForm` | string \| null | no | One of: string, null | +| `inline.numSpacing` | string \| null | no | One of: string, null | +| `inline.oMath` | boolean \| null | no | One of: boolean, null | +| `inline.outline` | boolean \| null | no | One of: boolean, null | +| `inline.position` | number \| null | no | One of: number, null | +| `inline.rFonts` | object \| null | no | One of: object, null | +| `inline.rStyle` | string \| null | no | One of: string, null | +| `inline.rtl` | boolean \| null | no | One of: boolean, null | +| `inline.shading` | object \| null | no | One of: object, null | +| `inline.shadow` | boolean \| null | no | One of: boolean, null | +| `inline.smallCaps` | boolean \| null | no | One of: boolean, null | +| `inline.snapToGrid` | boolean \| null | no | One of: boolean, null | +| `inline.specVanish` | boolean \| null | no | One of: boolean, null | +| `inline.strike` | boolean \| null | no | One of: boolean, null | +| `inline.stylisticSets` | object[] \| null | no | One of: object[], null | +| `inline.underline` | boolean \| null \| object | no | One of: boolean, null, object | +| `inline.vanish` | boolean \| null | no | One of: boolean, null | +| `inline.vertAlign` | enum \| null | no | One of: enum, null | +| `inline.webHidden` | boolean \| null | no | One of: boolean, null | | `target` | SelectionTarget | yes | SelectionTarget | | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | @@ -84,49 +84,49 @@ Returns a TextMutationReceipt confirming inline styles were applied to the targe | --- | --- | --- | --- | | `in` | StoryLocator | no | StoryLocator | | `inline` | object | yes | | -| `inline.bCs` | boolean \\| null | no | One of: boolean, null | -| `inline.bold` | boolean \\| null | no | One of: boolean, null | -| `inline.border` | object \\| null | no | One of: object, null | -| `inline.caps` | boolean \\| null | no | One of: boolean, null | -| `inline.charScale` | number \\| null | no | One of: number, null | -| `inline.color` | string \\| null | no | One of: string, null | -| `inline.contextualAlternates` | boolean \\| null | no | One of: boolean, null | -| `inline.cs` | boolean \\| null | no | One of: boolean, null | -| `inline.dstrike` | boolean \\| null | no | One of: boolean, null | -| `inline.eastAsianLayout` | object \\| null | no | One of: object, null | -| `inline.em` | string \\| null | no | One of: string, null | -| `inline.emboss` | boolean \\| null | no | One of: boolean, null | -| `inline.fitText` | object \\| null | no | One of: object, null | -| `inline.fontFamily` | string \\| null | no | One of: string, null | -| `inline.fontSize` | number \\| null | no | One of: number, null | -| `inline.fontSizeCs` | number \\| null | no | One of: number, null | -| `inline.highlight` | string \\| null | no | One of: string, null | -| `inline.iCs` | boolean \\| null | no | One of: boolean, null | -| `inline.imprint` | boolean \\| null | no | One of: boolean, null | -| `inline.italic` | boolean \\| null | no | One of: boolean, null | -| `inline.kerning` | number \\| null | no | One of: number, null | -| `inline.lang` | object \\| null | no | One of: object, null | -| `inline.letterSpacing` | number \\| null | no | One of: number, null | -| `inline.ligatures` | string \\| null | no | One of: string, null | -| `inline.numForm` | string \\| null | no | One of: string, null | -| `inline.numSpacing` | string \\| null | no | One of: string, null | -| `inline.oMath` | boolean \\| null | no | One of: boolean, null | -| `inline.outline` | boolean \\| null | no | One of: boolean, null | -| `inline.position` | number \\| null | no | One of: number, null | -| `inline.rFonts` | object \\| null | no | One of: object, null | -| `inline.rStyle` | string \\| null | no | One of: string, null | -| `inline.rtl` | boolean \\| null | no | One of: boolean, null | -| `inline.shading` | object \\| null | no | One of: object, null | -| `inline.shadow` | boolean \\| null | no | One of: boolean, null | -| `inline.smallCaps` | boolean \\| null | no | One of: boolean, null | -| `inline.snapToGrid` | boolean \\| null | no | One of: boolean, null | -| `inline.specVanish` | boolean \\| null | no | One of: boolean, null | -| `inline.strike` | boolean \\| null | no | One of: boolean, null | -| `inline.stylisticSets` | object[] \\| null | no | One of: object[], null | -| `inline.underline` | boolean \\| null \\| object | no | One of: boolean, null, object | -| `inline.vanish` | boolean \\| null | no | One of: boolean, null | -| `inline.vertAlign` | enum \\| null | no | One of: enum, null | -| `inline.webHidden` | boolean \\| null | no | One of: boolean, null | +| `inline.bCs` | boolean \| null | no | One of: boolean, null | +| `inline.bold` | boolean \| null | no | One of: boolean, null | +| `inline.border` | object \| null | no | One of: object, null | +| `inline.caps` | boolean \| null | no | One of: boolean, null | +| `inline.charScale` | number \| null | no | One of: number, null | +| `inline.color` | string \| null | no | One of: string, null | +| `inline.contextualAlternates` | boolean \| null | no | One of: boolean, null | +| `inline.cs` | boolean \| null | no | One of: boolean, null | +| `inline.dstrike` | boolean \| null | no | One of: boolean, null | +| `inline.eastAsianLayout` | object \| null | no | One of: object, null | +| `inline.em` | string \| null | no | One of: string, null | +| `inline.emboss` | boolean \| null | no | One of: boolean, null | +| `inline.fitText` | object \| null | no | One of: object, null | +| `inline.fontFamily` | string \| null | no | One of: string, null | +| `inline.fontSize` | number \| null | no | One of: number, null | +| `inline.fontSizeCs` | number \| null | no | One of: number, null | +| `inline.highlight` | string \| null | no | One of: string, null | +| `inline.iCs` | boolean \| null | no | One of: boolean, null | +| `inline.imprint` | boolean \| null | no | One of: boolean, null | +| `inline.italic` | boolean \| null | no | One of: boolean, null | +| `inline.kerning` | number \| null | no | One of: number, null | +| `inline.lang` | object \| null | no | One of: object, null | +| `inline.letterSpacing` | number \| null | no | One of: number, null | +| `inline.ligatures` | string \| null | no | One of: string, null | +| `inline.numForm` | string \| null | no | One of: string, null | +| `inline.numSpacing` | string \| null | no | One of: string, null | +| `inline.oMath` | boolean \| null | no | One of: boolean, null | +| `inline.outline` | boolean \| null | no | One of: boolean, null | +| `inline.position` | number \| null | no | One of: number, null | +| `inline.rFonts` | object \| null | no | One of: object, null | +| `inline.rStyle` | string \| null | no | One of: string, null | +| `inline.rtl` | boolean \| null | no | One of: boolean, null | +| `inline.shading` | object \| null | no | One of: object, null | +| `inline.shadow` | boolean \| null | no | One of: boolean, null | +| `inline.smallCaps` | boolean \| null | no | One of: boolean, null | +| `inline.snapToGrid` | boolean \| null | no | One of: boolean, null | +| `inline.specVanish` | boolean \| null | no | One of: boolean, null | +| `inline.strike` | boolean \| null | no | One of: boolean, null | +| `inline.stylisticSets` | object[] \| null | no | One of: object[], null | +| `inline.underline` | boolean \| null \| object | no | One of: boolean, null, object | +| `inline.vanish` | boolean \| null | no | One of: boolean, null | +| `inline.vertAlign` | enum \| null | no | One of: enum, null | +| `inline.webHidden` | boolean \| null | no | One of: boolean, null | | `ref` | string | yes | | ### Example request diff --git a/apps/docs/document-api/reference/format/b-cs.mdx b/apps/docs/document-api/reference/format/b-cs.mdx index 525a817907..683e9e4671 100644 --- a/apps/docs/document-api/reference/format/b-cs.mdx +++ b/apps/docs/document-api/reference/format/b-cs.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/bold.mdx b/apps/docs/document-api/reference/format/bold.mdx index 68233ad335..e0d085546b 100644 --- a/apps/docs/document-api/reference/format/bold.mdx +++ b/apps/docs/document-api/reference/format/bold.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/border.mdx b/apps/docs/document-api/reference/format/border.mdx index 6aed19ffee..5b6cd31fad 100644 --- a/apps/docs/document-api/reference/format/border.mdx +++ b/apps/docs/document-api/reference/format/border.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/caps.mdx b/apps/docs/document-api/reference/format/caps.mdx index 7270336de5..390e8db00b 100644 --- a/apps/docs/document-api/reference/format/caps.mdx +++ b/apps/docs/document-api/reference/format/caps.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/char-scale.mdx b/apps/docs/document-api/reference/format/char-scale.mdx index 1b3a2f2f27..54be8eb00f 100644 --- a/apps/docs/document-api/reference/format/char-scale.mdx +++ b/apps/docs/document-api/reference/format/char-scale.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/color.mdx b/apps/docs/document-api/reference/format/color.mdx index 08fb49df38..5a4d95c476 100644 --- a/apps/docs/document-api/reference/format/color.mdx +++ b/apps/docs/document-api/reference/format/color.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/contextual-alternates.mdx b/apps/docs/document-api/reference/format/contextual-alternates.mdx index 40e99851f0..3bad3cb710 100644 --- a/apps/docs/document-api/reference/format/contextual-alternates.mdx +++ b/apps/docs/document-api/reference/format/contextual-alternates.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/cs.mdx b/apps/docs/document-api/reference/format/cs.mdx index 47c7b40bc2..dea5d2a9fe 100644 --- a/apps/docs/document-api/reference/format/cs.mdx +++ b/apps/docs/document-api/reference/format/cs.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/dstrike.mdx b/apps/docs/document-api/reference/format/dstrike.mdx index 75f7e578ba..4c1e106f08 100644 --- a/apps/docs/document-api/reference/format/dstrike.mdx +++ b/apps/docs/document-api/reference/format/dstrike.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/east-asian-layout.mdx b/apps/docs/document-api/reference/format/east-asian-layout.mdx index 3272975ada..d8f4192336 100644 --- a/apps/docs/document-api/reference/format/east-asian-layout.mdx +++ b/apps/docs/document-api/reference/format/east-asian-layout.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/em.mdx b/apps/docs/document-api/reference/format/em.mdx index 2bff8f3293..6fd59e41d0 100644 --- a/apps/docs/document-api/reference/format/em.mdx +++ b/apps/docs/document-api/reference/format/em.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/emboss.mdx b/apps/docs/document-api/reference/format/emboss.mdx index 5d06da187f..462ba225e8 100644 --- a/apps/docs/document-api/reference/format/emboss.mdx +++ b/apps/docs/document-api/reference/format/emboss.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/fit-text.mdx b/apps/docs/document-api/reference/format/fit-text.mdx index 51e1ba6483..4e055b7f8d 100644 --- a/apps/docs/document-api/reference/format/fit-text.mdx +++ b/apps/docs/document-api/reference/format/fit-text.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/font-family.mdx b/apps/docs/document-api/reference/format/font-family.mdx index b890b0a9b1..feec7d0695 100644 --- a/apps/docs/document-api/reference/format/font-family.mdx +++ b/apps/docs/document-api/reference/format/font-family.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/font-size-cs.mdx b/apps/docs/document-api/reference/format/font-size-cs.mdx index 63acc1a1df..43a79b9063 100644 --- a/apps/docs/document-api/reference/format/font-size-cs.mdx +++ b/apps/docs/document-api/reference/format/font-size-cs.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/font-size.mdx b/apps/docs/document-api/reference/format/font-size.mdx index be9d5c09c5..4e2811ffaf 100644 --- a/apps/docs/document-api/reference/format/font-size.mdx +++ b/apps/docs/document-api/reference/format/font-size.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/highlight.mdx b/apps/docs/document-api/reference/format/highlight.mdx index d4d29f4a46..49617fe9ec 100644 --- a/apps/docs/document-api/reference/format/highlight.mdx +++ b/apps/docs/document-api/reference/format/highlight.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/i-cs.mdx b/apps/docs/document-api/reference/format/i-cs.mdx index 46323eab3c..ef200d3015 100644 --- a/apps/docs/document-api/reference/format/i-cs.mdx +++ b/apps/docs/document-api/reference/format/i-cs.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/imprint.mdx b/apps/docs/document-api/reference/format/imprint.mdx index 368ee601e2..154312de2b 100644 --- a/apps/docs/document-api/reference/format/imprint.mdx +++ b/apps/docs/document-api/reference/format/imprint.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/italic.mdx b/apps/docs/document-api/reference/format/italic.mdx index 8006aa0900..c84e9a14ed 100644 --- a/apps/docs/document-api/reference/format/italic.mdx +++ b/apps/docs/document-api/reference/format/italic.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/kerning.mdx b/apps/docs/document-api/reference/format/kerning.mdx index 256c210660..727bd0b09f 100644 --- a/apps/docs/document-api/reference/format/kerning.mdx +++ b/apps/docs/document-api/reference/format/kerning.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/lang.mdx b/apps/docs/document-api/reference/format/lang.mdx index b8fb75db95..b500fcccc8 100644 --- a/apps/docs/document-api/reference/format/lang.mdx +++ b/apps/docs/document-api/reference/format/lang.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/letter-spacing.mdx b/apps/docs/document-api/reference/format/letter-spacing.mdx index 590ebab57a..5851a698f9 100644 --- a/apps/docs/document-api/reference/format/letter-spacing.mdx +++ b/apps/docs/document-api/reference/format/letter-spacing.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/ligatures.mdx b/apps/docs/document-api/reference/format/ligatures.mdx index c38a7719b7..040e0134f4 100644 --- a/apps/docs/document-api/reference/format/ligatures.mdx +++ b/apps/docs/document-api/reference/format/ligatures.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/num-form.mdx b/apps/docs/document-api/reference/format/num-form.mdx index f4c76c5015..278daf5d83 100644 --- a/apps/docs/document-api/reference/format/num-form.mdx +++ b/apps/docs/document-api/reference/format/num-form.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/num-spacing.mdx b/apps/docs/document-api/reference/format/num-spacing.mdx index e023787d37..fa90536af1 100644 --- a/apps/docs/document-api/reference/format/num-spacing.mdx +++ b/apps/docs/document-api/reference/format/num-spacing.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/o-math.mdx b/apps/docs/document-api/reference/format/o-math.mdx index 1760cbc4e8..a11cd4e4ac 100644 --- a/apps/docs/document-api/reference/format/o-math.mdx +++ b/apps/docs/document-api/reference/format/o-math.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/outline.mdx b/apps/docs/document-api/reference/format/outline.mdx index e2edb904cc..2cc8476ba2 100644 --- a/apps/docs/document-api/reference/format/outline.mdx +++ b/apps/docs/document-api/reference/format/outline.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/paragraph/clear-alignment.mdx b/apps/docs/document-api/reference/format/paragraph/clear-alignment.mdx index 44fc665b23..228ed9796b 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-alignment.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-alignment.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct alignment is set. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct alignment is set. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct alignment is set. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-all-tab-stops.mdx b/apps/docs/document-api/reference/format/paragraph/clear-all-tab-stops.mdx index 3652ddfcac..6d8ee1ddeb 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-all-tab-stops.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-all-tab-stops.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stops exist. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stops exist. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stops exist. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-border.mdx b/apps/docs/document-api/reference/format/paragraph/clear-border.mdx index f51f90ab0f..57e18ded7a 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-border.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-border.mdx @@ -27,7 +27,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the border is already absent | Field | Type | Required | Description | | --- | --- | --- | --- | | `side` | enum | yes | `"top"`, `"bottom"`, `"left"`, `"right"`, `"between"`, `"bar"`, `"all"` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -49,9 +49,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the border is already absent | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -62,7 +62,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the border is already absent | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx b/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx index 30f326db8b..adf2cb9fd2 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-direction.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direction is set. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no direction is set. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direction is set. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-indentation.mdx b/apps/docs/document-api/reference/format/paragraph/clear-indentation.mdx index 3ecfd8c7a0..3852c38d38 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-indentation.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-indentation.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct indentation is set | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct indentation is set | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct indentation is set | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-shading.mdx b/apps/docs/document-api/reference/format/paragraph/clear-shading.mdx index f8e2431711..c7f8a23ca1 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-shading.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-shading.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no shading is set. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no shading is set. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no shading is set. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-spacing.mdx b/apps/docs/document-api/reference/format/paragraph/clear-spacing.mdx index e9db4b454a..e6bf7a46da 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-spacing.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-spacing.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct spacing is set. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct spacing is set. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct spacing is set. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/clear-tab-stop.mdx b/apps/docs/document-api/reference/format/paragraph/clear-tab-stop.mdx index edda7f202f..6ace2aad9a 100644 --- a/apps/docs/document-api/reference/format/paragraph/clear-tab-stop.mdx +++ b/apps/docs/document-api/reference/format/paragraph/clear-tab-stop.mdx @@ -27,7 +27,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stop exists at that p | Field | Type | Required | Description | | --- | --- | --- | --- | | `position` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -49,9 +49,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stop exists at that p | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -62,7 +62,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no tab stop exists at that p | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/reset-direct-formatting.mdx b/apps/docs/document-api/reference/format/paragraph/reset-direct-formatting.mdx index a1bd86e328..8538af93bc 100644 --- a/apps/docs/document-api/reference/format/paragraph/reset-direct-formatting.mdx +++ b/apps/docs/document-api/reference/format/paragraph/reset-direct-formatting.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct formatting is pres | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct formatting is pres | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no direct formatting is pres | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx b/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx index 7730402c6b..399602fd3c 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx @@ -27,7 +27,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the alignment already matche | Field | Type | Required | Description | | --- | --- | --- | --- | | `alignment` | enum | yes | `"left"`, `"center"`, `"right"`, `"justify"` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -49,9 +49,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the alignment already matche | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -62,7 +62,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the alignment already matche | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-border.mdx b/apps/docs/document-api/reference/format/paragraph/set-border.mdx index e1abf4812b..f7df79cf6b 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-border.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-border.mdx @@ -31,7 +31,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the border already matches. | `size` | integer | no | | | `space` | integer | no | | | `style` | string | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -56,9 +56,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the border already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -69,7 +69,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the border already matches. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-direction.mdx b/apps/docs/document-api/reference/format/paragraph/set-direction.mdx index 23732c28d4..f6cb6bb3bd 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-direction.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-direction.mdx @@ -28,7 +28,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the direction already matche | --- | --- | --- | --- | | `alignmentPolicy` | enum | no | `"preserve"`, `"matchDirection"` | | `direction` | enum | yes | `"ltr"`, `"rtl"` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -51,9 +51,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the direction already matche | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -64,7 +64,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the direction already matche | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx b/apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx index d282d931ff..f077998021 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx @@ -29,21 +29,21 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | Field | Type | Required | Description | | --- | --- | --- | --- | | `contextualSpacing` | boolean | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (required: pageBreakBefore) | Field | Type | Required | Description | | --- | --- | --- | --- | | `pageBreakBefore` | boolean | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 3 (required: suppressAutoHyphens) | Field | Type | Required | Description | | --- | --- | --- | --- | | `suppressAutoHyphens` | boolean | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -65,9 +65,9 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -78,7 +78,7 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-indentation.mdx b/apps/docs/document-api/reference/format/paragraph/set-indentation.mdx index bc9c117a69..a037ef0e72 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-indentation.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-indentation.mdx @@ -29,28 +29,28 @@ Returns a ParagraphMutationResult; reports NO_OP if indentation already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `left` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (required: right) | Field | Type | Required | Description | | --- | --- | --- | --- | | `right` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 3 (required: firstLine) | Field | Type | Required | Description | | --- | --- | --- | --- | | `firstLine` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 4 (required: hanging) | Field | Type | Required | Description | | --- | --- | --- | --- | | `hanging` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -72,9 +72,9 @@ Returns a ParagraphMutationResult; reports NO_OP if indentation already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -85,7 +85,7 @@ Returns a ParagraphMutationResult; reports NO_OP if indentation already matches. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx b/apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx index 7c56034b2b..574a597d05 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx @@ -29,20 +29,20 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | Field | Type | Required | Description | | --- | --- | --- | --- | | `keepNext` | boolean | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (required: keepLines) | Field | Type | Required | Description | | --- | --- | --- | --- | | `keepLines` | boolean | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 3 (required: widowControl) | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `widowControl` | boolean | yes | | ### Example request @@ -65,9 +65,9 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -78,7 +78,7 @@ Returns a ParagraphMutationResult; reports NO_OP if all flags already match. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-outline-level.mdx b/apps/docs/document-api/reference/format/paragraph/set-outline-level.mdx index 4b01ce6d62..99597b460b 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-outline-level.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-outline-level.mdx @@ -26,8 +26,8 @@ Returns a ParagraphMutationResult; reports NO_OP if outline level already matche | Field | Type | Required | Description | | --- | --- | --- | --- | -| `outlineLevel` | integer \\| null | yes | One of: integer, null | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `outlineLevel` | integer \| null | yes | One of: integer, null | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -49,9 +49,9 @@ Returns a ParagraphMutationResult; reports NO_OP if outline level already matche | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -62,7 +62,7 @@ Returns a ParagraphMutationResult; reports NO_OP if outline level already matche | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-shading.mdx b/apps/docs/document-api/reference/format/paragraph/set-shading.mdx index 3e113ed8eb..f97c1537fc 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-shading.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-shading.mdx @@ -29,21 +29,21 @@ Returns a ParagraphMutationResult; reports NO_OP if the shading already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `fill` | string | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (required: color) | Field | Type | Required | Description | | --- | --- | --- | --- | | `color` | string | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 3 (required: pattern) | Field | Type | Required | Description | | --- | --- | --- | --- | | `pattern` | string | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -65,9 +65,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the shading already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -78,7 +78,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the shading already matches. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-spacing.mdx b/apps/docs/document-api/reference/format/paragraph/set-spacing.mdx index 31cb6812dc..ebb1545a98 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-spacing.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-spacing.mdx @@ -29,28 +29,28 @@ Returns a ParagraphMutationResult; reports NO_OP if spacing already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `before` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (required: after) | Field | Type | Required | Description | | --- | --- | --- | --- | | `after` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 3 (required: line) | Field | Type | Required | Description | | --- | --- | --- | --- | | `line` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 4 (required: lineRule) | Field | Type | Required | Description | | --- | --- | --- | --- | | `lineRule` | enum | yes | `"auto"`, `"exact"`, `"atLeast"` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -72,9 +72,9 @@ Returns a ParagraphMutationResult; reports NO_OP if spacing already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -85,7 +85,7 @@ Returns a ParagraphMutationResult; reports NO_OP if spacing already matches. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/paragraph/set-tab-stop.mdx b/apps/docs/document-api/reference/format/paragraph/set-tab-stop.mdx index 16db8393c7..292e5ce29b 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-tab-stop.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-tab-stop.mdx @@ -29,7 +29,7 @@ Returns a ParagraphMutationResult; reports NO_OP if an identical tab stop alread | `alignment` | enum | yes | `"left"`, `"center"`, `"right"`, `"decimal"`, `"bar"` | | `leader` | enum | no | `"none"`, `"dot"`, `"hyphen"`, `"underscore"`, `"heavy"`, `"middleDot"` | | `position` | integer | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -53,9 +53,9 @@ Returns a ParagraphMutationResult; reports NO_OP if an identical tab stop alread | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -66,7 +66,7 @@ Returns a ParagraphMutationResult; reports NO_OP if an identical tab stop alread | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/format/position.mdx b/apps/docs/document-api/reference/format/position.mdx index 901b2c3edc..d7fd88bac4 100644 --- a/apps/docs/document-api/reference/format/position.mdx +++ b/apps/docs/document-api/reference/format/position.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | number \\| null | yes | One of: number, null | +| `value` | number \| null | yes | One of: number, null | ### Example request diff --git a/apps/docs/document-api/reference/format/r-fonts.mdx b/apps/docs/document-api/reference/format/r-fonts.mdx index c88eb13d72..e3e53e0785 100644 --- a/apps/docs/document-api/reference/format/r-fonts.mdx +++ b/apps/docs/document-api/reference/format/r-fonts.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/r-style.mdx b/apps/docs/document-api/reference/format/r-style.mdx index b1f5d3d2e5..43fa56d4a3 100644 --- a/apps/docs/document-api/reference/format/r-style.mdx +++ b/apps/docs/document-api/reference/format/r-style.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | string \\| null | yes | One of: string, null | +| `value` | string \| null | yes | One of: string, null | ### Example request diff --git a/apps/docs/document-api/reference/format/rtl.mdx b/apps/docs/document-api/reference/format/rtl.mdx index 6ea1a06590..c41f383def 100644 --- a/apps/docs/document-api/reference/format/rtl.mdx +++ b/apps/docs/document-api/reference/format/rtl.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming only the inline run property patch was | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/shading.mdx b/apps/docs/document-api/reference/format/shading.mdx index 15f63eae56..429a44105f 100644 --- a/apps/docs/document-api/reference/format/shading.mdx +++ b/apps/docs/document-api/reference/format/shading.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object \\| null | yes | One of: object, null | +| `value` | object \| null | yes | One of: object, null | ### Example request diff --git a/apps/docs/document-api/reference/format/shadow.mdx b/apps/docs/document-api/reference/format/shadow.mdx index bc39e333d8..eacd97b135 100644 --- a/apps/docs/document-api/reference/format/shadow.mdx +++ b/apps/docs/document-api/reference/format/shadow.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/small-caps.mdx b/apps/docs/document-api/reference/format/small-caps.mdx index 8fb0db43e1..a22d96ef83 100644 --- a/apps/docs/document-api/reference/format/small-caps.mdx +++ b/apps/docs/document-api/reference/format/small-caps.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/snap-to-grid.mdx b/apps/docs/document-api/reference/format/snap-to-grid.mdx index 75d98d2f73..fa33253a48 100644 --- a/apps/docs/document-api/reference/format/snap-to-grid.mdx +++ b/apps/docs/document-api/reference/format/snap-to-grid.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/spec-vanish.mdx b/apps/docs/document-api/reference/format/spec-vanish.mdx index 42f51debe3..05eb650992 100644 --- a/apps/docs/document-api/reference/format/spec-vanish.mdx +++ b/apps/docs/document-api/reference/format/spec-vanish.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/strike.mdx b/apps/docs/document-api/reference/format/strike.mdx index d80b195d50..710c566f0d 100644 --- a/apps/docs/document-api/reference/format/strike.mdx +++ b/apps/docs/document-api/reference/format/strike.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/stylistic-sets.mdx b/apps/docs/document-api/reference/format/stylistic-sets.mdx index c3b20172bb..a950ff435f 100644 --- a/apps/docs/document-api/reference/format/stylistic-sets.mdx +++ b/apps/docs/document-api/reference/format/stylistic-sets.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | object[] \\| null | yes | One of: object[], null | +| `value` | object[] \| null | yes | One of: object[], null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | object[] \\| null | yes | One of: object[], null | +| `value` | object[] \| null | yes | One of: object[], null | ### Example request diff --git a/apps/docs/document-api/reference/format/underline.mdx b/apps/docs/document-api/reference/format/underline.mdx index 253427ece2..313a28c602 100644 --- a/apps/docs/document-api/reference/format/underline.mdx +++ b/apps/docs/document-api/reference/format/underline.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null \\| object | no | One of: boolean, null, object | +| `value` | boolean \| null \| object | no | One of: boolean, null, object | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null \\| object | no | One of: boolean, null, object | +| `value` | boolean \| null \| object | no | One of: boolean, null, object | ### Example request diff --git a/apps/docs/document-api/reference/format/vanish.mdx b/apps/docs/document-api/reference/format/vanish.mdx index 0566cd541e..c29ade86fc 100644 --- a/apps/docs/document-api/reference/format/vanish.mdx +++ b/apps/docs/document-api/reference/format/vanish.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/format/vert-align.mdx b/apps/docs/document-api/reference/format/vert-align.mdx index db5ffe9c30..693884cfcf 100644 --- a/apps/docs/document-api/reference/format/vert-align.mdx +++ b/apps/docs/document-api/reference/format/vert-align.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | enum \\| null | yes | One of: enum, null | +| `value` | enum \| null | yes | One of: enum, null | ### Variant 2 (required: ref, value) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | enum \\| null | yes | One of: enum, null | +| `value` | enum \| null | yes | One of: enum, null | ### Example request diff --git a/apps/docs/document-api/reference/format/web-hidden.mdx b/apps/docs/document-api/reference/format/web-hidden.mdx index 4460c64535..c9ebe4f069 100644 --- a/apps/docs/document-api/reference/format/web-hidden.mdx +++ b/apps/docs/document-api/reference/format/web-hidden.mdx @@ -32,14 +32,14 @@ Returns a TextMutationReceipt confirming the inline run property patch was appli | `target.end` | SelectionPoint | yes | SelectionPoint | | `target.kind` | `"selection"` | yes | Constant: `"selection"` | | `target.start` | SelectionPoint | yes | SelectionPoint | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Variant 2 (required: ref) | Field | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | | -| `value` | boolean \\| null | no | One of: boolean, null | +| `value` | boolean \| null | no | One of: boolean, null | ### Example request diff --git a/apps/docs/document-api/reference/header-footers/get.mdx b/apps/docs/document-api/reference/header-footers/get.mdx index b1f8015839..c3f6b7a526 100644 --- a/apps/docs/document-api/reference/header-footers/get.mdx +++ b/apps/docs/document-api/reference/header-footers/get.mdx @@ -56,7 +56,7 @@ Returns a HeaderFooterSlotEntry for the targeted section slot. | --- | --- | --- | --- | | `isExplicit` | boolean | yes | | | `kind` | enum | yes | `"header"`, `"footer"` | -| `refId` | any | no | | +| `refId` | string \| null | no | | | `section` | SectionAddress | yes | SectionAddress | | `section.kind` | `"section"` | yes | Constant: `"section"` | | `section.sectionId` | string | yes | | @@ -69,7 +69,7 @@ Returns a HeaderFooterSlotEntry for the targeted section slot. { "isExplicit": true, "kind": "header", - "refId": {}, + "refId": null, "section": { "kind": "section", "sectionId": "example" diff --git a/apps/docs/document-api/reference/header-footers/list.mdx b/apps/docs/document-api/reference/header-footers/list.mdx index b7d71e2ad6..3d21b7c7e5 100644 --- a/apps/docs/document-api/reference/header-footers/list.mdx +++ b/apps/docs/document-api/reference/header-footers/list.mdx @@ -72,7 +72,7 @@ Returns a paginated DiscoveryOutput of HeaderFooterSlotEntry items. "id": "id-001", "isExplicit": true, "kind": "header", - "refId": {}, + "refId": null, "section": { "kind": "section", "sectionId": "example" diff --git a/apps/docs/document-api/reference/hyperlinks/patch.mdx b/apps/docs/document-api/reference/hyperlinks/patch.mdx index e2e88cddf9..3bc0534cbe 100644 --- a/apps/docs/document-api/reference/hyperlinks/patch.mdx +++ b/apps/docs/document-api/reference/hyperlinks/patch.mdx @@ -27,12 +27,12 @@ Returns a HyperlinkMutationResult with the updated hyperlink address on success, | Field | Type | Required | Description | | --- | --- | --- | --- | | `patch` | object | yes | | -| `patch.anchor` | string \\| null | no | One of: string, null | -| `patch.docLocation` | string \\| null | no | One of: string, null | -| `patch.href` | string \\| null | no | One of: string, null | -| `patch.rel` | string \\| null | no | One of: string, null | -| `patch.target` | string \\| null | no | One of: string, null | -| `patch.tooltip` | string \\| null | no | One of: string, null | +| `patch.anchor` | string \| null | no | One of: string, null | +| `patch.docLocation` | string \| null | no | One of: string, null | +| `patch.href` | string \| null | no | One of: string, null | +| `patch.rel` | string \| null | no | One of: string, null | +| `patch.target` | string \| null | no | One of: string, null | +| `patch.tooltip` | string \| null | no | One of: string, null | | `target` | object(kind="inline") | yes | | | `target.anchor` | InlineAnchor | yes | InlineAnchor | | `target.anchor.end` | Position | yes | Position | diff --git a/apps/docs/document-api/reference/images/move.mdx b/apps/docs/document-api/reference/images/move.mdx index 3651f7e91c..8071762611 100644 --- a/apps/docs/document-api/reference/images/move.mdx +++ b/apps/docs/document-api/reference/images/move.mdx @@ -27,7 +27,7 @@ Returns an ImagesMutationResult indicating success or failure. | Field | Type | Required | Description | | --- | --- | --- | --- | | `imageId` | string | yes | | -| `to` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="inParagraph") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | +| `to` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") \| object(kind="inParagraph") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | ### Example request diff --git a/apps/docs/document-api/reference/images/set-hyperlink.mdx b/apps/docs/document-api/reference/images/set-hyperlink.mdx index fc6502fc41..0f384b709a 100644 --- a/apps/docs/document-api/reference/images/set-hyperlink.mdx +++ b/apps/docs/document-api/reference/images/set-hyperlink.mdx @@ -28,7 +28,7 @@ Returns an ImagesMutationResult; reports NO_OP if unchanged. | --- | --- | --- | --- | | `imageId` | string | yes | | | `tooltip` | string | no | | -| `url` | any | yes | | +| `url` | string \| null | yes | | ### Example request @@ -36,7 +36,7 @@ Returns an ImagesMutationResult; reports NO_OP if unchanged. { "imageId": "example", "tooltip": "example", - "url": {} + "url": "example" } ``` diff --git a/apps/docs/document-api/reference/index/insert.mdx b/apps/docs/document-api/reference/index/insert.mdx index 864ad1a029..24cc07ddc7 100644 --- a/apps/docs/document-api/reference/index/insert.mdx +++ b/apps/docs/document-api/reference/index/insert.mdx @@ -26,7 +26,7 @@ Returns an IndexMutationResult indicating success with the index address or a fa | Field | Type | Required | Description | | --- | --- | --- | --- | -| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | +| `at` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after") | | `config` | object | no | | | `config.accentedSorting` | boolean | no | | | `config.columns` | integer | no | | diff --git a/apps/docs/document-api/reference/insert.mdx b/apps/docs/document-api/reference/insert.mdx index 3f0d0fc77e..d44754769d 100644 --- a/apps/docs/document-api/reference/insert.mdx +++ b/apps/docs/document-api/reference/insert.mdx @@ -57,7 +57,7 @@ Returns an SDMutationReceipt with applied status; resolution reports the inserte | Field | Type | Required | Description | | --- | --- | --- | --- | -| `content` | object \\| object[] | yes | One of: object, object[] | +| `content` | object \| object[] | yes | One of: object, object[] | | `in` | StoryLocator | no | StoryLocator | | `nestingPolicy` | object | no | | | `nestingPolicy.tables` | enum | no | `"forbid"`, `"allow"` | @@ -106,7 +106,7 @@ Returns an SDMutationReceipt with applied status; resolution reports the inserte | `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | | `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | | `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | -| `resolution.target` | TextAddress \\| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | +| `resolution.target` | TextAddress \| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | | `success` | `true` | yes | Constant: `true` | ### Variant 2 (success=false) @@ -128,7 +128,7 @@ Returns an SDMutationReceipt with applied status; resolution reports the inserte | `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | | `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | | `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | -| `resolution.target` | TextAddress \\| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | +| `resolution.target` | TextAddress \| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/lists/set-level-layout.mdx b/apps/docs/document-api/reference/lists/set-level-layout.mdx index 1a944edf04..a1a563126c 100644 --- a/apps/docs/document-api/reference/lists/set-level-layout.mdx +++ b/apps/docs/document-api/reference/lists/set-level-layout.mdx @@ -30,7 +30,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if all values already mat | `layout.alignedAt` | integer | no | | | `layout.alignment` | enum | no | `"left"`, `"center"`, `"right"` | | `layout.followCharacter` | enum | no | `"tab"`, `"space"`, `"nothing"` | -| `layout.tabStopAt` | any | no | | +| `layout.tabStopAt` | integer \| null | no | | | `layout.textIndentAt` | integer | no | | | `level` | integer | yes | | | `target` | ListItemAddress | yes | ListItemAddress | diff --git a/apps/docs/document-api/reference/lists/set-level-restart.mdx b/apps/docs/document-api/reference/lists/set-level-restart.mdx index 0dc738f657..a8c59a8c6c 100644 --- a/apps/docs/document-api/reference/lists/set-level-restart.mdx +++ b/apps/docs/document-api/reference/lists/set-level-restart.mdx @@ -27,7 +27,7 @@ Returns a ListsMutateItemResult receipt. | Field | Type | Required | Description | | --- | --- | --- | --- | | `level` | integer | yes | | -| `restartAfterLevel` | any | yes | | +| `restartAfterLevel` | integer \| null | yes | | | `scope` | enum | no | `"definition"`, `"instance"` | | `target` | ListItemAddress | yes | ListItemAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | @@ -39,7 +39,7 @@ Returns a ListsMutateItemResult receipt. ```json { "level": 1, - "restartAfterLevel": {}, + "restartAfterLevel": 1, "scope": "definition", "target": { "kind": "block", diff --git a/apps/docs/document-api/reference/lists/set-value.mdx b/apps/docs/document-api/reference/lists/set-value.mdx index d6aaab7836..1ccd2795f4 100644 --- a/apps/docs/document-api/reference/lists/set-value.mdx +++ b/apps/docs/document-api/reference/lists/set-value.mdx @@ -30,7 +30,7 @@ Returns a ListsMutateItemResult receipt. | `target.kind` | `"block"` | yes | Constant: `"block"` | | `target.nodeId` | string | yes | | | `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | -| `value` | any | yes | | +| `value` | integer \| null | yes | | ### Example request @@ -41,7 +41,7 @@ Returns a ListsMutateItemResult receipt. "nodeId": "node-def456", "nodeType": "listItem" }, - "value": {} + "value": 1 } ``` diff --git a/apps/docs/document-api/reference/lists/split.mdx b/apps/docs/document-api/reference/lists/split.mdx index 9b3b534228..91390f7158 100644 --- a/apps/docs/document-api/reference/lists/split.mdx +++ b/apps/docs/document-api/reference/lists/split.mdx @@ -53,7 +53,7 @@ Returns a ListsSplitResult with the new listId, numId, and the restart value app | --- | --- | --- | --- | | `listId` | string | yes | | | `numId` | integer | yes | | -| `restartedAt` | any | yes | | +| `restartedAt` | integer \| null | yes | | | `success` | `true` | yes | Constant: `true` | ### Variant 2 (success=false) @@ -72,7 +72,7 @@ Returns a ListsSplitResult with the new listId, numId, and the restart value app { "listId": "example", "numId": 1, - "restartedAt": {}, + "restartedAt": 1, "success": true } ``` diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 75d5b836a2..4708aa2a0e 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -105,7 +105,7 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo | `changeMode` | enum | yes | `"direct"`, `"tracked"` | | `expectedRevision` | string | no | | | `in` | StoryLocator | no | StoryLocator | -| `steps` | object(op="text.rewrite") \\| object(op="text.insert") \\| object(op="text.delete") \\| object(op="format.apply") \\| object(op="assert")[] | yes | | +| `steps` | object(op="text.rewrite") \| object(op="text.insert") \| object(op="text.delete") \| object(op="format.apply") \| object(op="assert")[] | yes | | ### Example request diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index 774acdad91..03dce35e61 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -105,7 +105,7 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo | `changeMode` | enum | yes | `"direct"`, `"tracked"` | | `expectedRevision` | string | no | | | `in` | StoryLocator | no | StoryLocator | -| `steps` | object(op="text.rewrite") \\| object(op="text.insert") \\| object(op="text.delete") \\| object(op="format.apply") \\| object(op="assert")[] | yes | | +| `steps` | object(op="text.rewrite") \| object(op="text.insert") \| object(op="text.delete") \| object(op="format.apply") \| object(op="assert")[] | yes | | ### Example request diff --git a/apps/docs/document-api/reference/query/match.mdx b/apps/docs/document-api/reference/query/match.mdx index a0723c244b..365d3c9e3c 100644 --- a/apps/docs/document-api/reference/query/match.mdx +++ b/apps/docs/document-api/reference/query/match.mdx @@ -32,7 +32,7 @@ Returns a QueryMatchOutput with the resolved target address and cardinality meta | `mode` | enum | no | `"strict"`, `"candidates"` | | `offset` | integer | no | | | `require` | enum | no | `"any"`, `"first"`, `"exactlyOne"`, `"all"` | -| `select` | object(type="text") \\| object(type="node") | yes | One of: object(type="text"), object(type="node") | +| `select` | object(type="text") \| object(type="node") | yes | One of: object(type="text"), object(type="node") | | `within` | BlockNodeAddress | no | BlockNodeAddress | | `within.kind` | `"block"` | no | Constant: `"block"` | | `within.nodeId` | string | no | | @@ -65,7 +65,7 @@ Returns a QueryMatchOutput with the resolved target address and cardinality meta | Field | Type | Required | Description | | --- | --- | --- | --- | | `evaluatedRevision` | string | yes | | -| `items` | object(matchKind="text") \\| object(matchKind="node")[] | yes | | +| `items` | object(matchKind="text") \| object(matchKind="node")[] | yes | | | `meta` | object | yes | | | `meta.effectiveResolved` | boolean | yes | | | `page` | PageInfo | yes | PageInfo | diff --git a/apps/docs/document-api/reference/ranges/resolve.mdx b/apps/docs/document-api/reference/ranges/resolve.mdx index 35513aaa32..66a3d6c31f 100644 --- a/apps/docs/document-api/reference/ranges/resolve.mdx +++ b/apps/docs/document-api/reference/ranges/resolve.mdx @@ -26,9 +26,9 @@ Returns a ResolveRangeOutput with evaluatedRevision, handle.ref, target (Selecti | Field | Type | Required | Description | | --- | --- | --- | --- | -| `end` | object(kind="document") \\| object(kind="point") \\| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | +| `end` | object(kind="document") \| object(kind="point") \| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | | `expectedRevision` | string | no | | -| `start` | object(kind="document") \\| object(kind="point") \\| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | +| `start` | object(kind="document") \| object(kind="point") \| object(kind="ref") | yes | One of: object(kind="document"), object(kind="point"), object(kind="ref") | ### Example request @@ -53,7 +53,7 @@ Returns a ResolveRangeOutput with evaluatedRevision, handle.ref, target (Selecti | `evaluatedRevision` | string | yes | | | `handle` | object(refStability="ephemeral") | yes | | | `handle.coversFullTarget` | boolean | yes | | -| `handle.ref` | string \\| null | yes | One of: string, null | +| `handle.ref` | string \| null | yes | One of: string, null | | `handle.refStability` | `"ephemeral"` | yes | Constant: `"ephemeral"` | | `preview` | object | yes | | | `preview.blocks` | object[] | yes | | diff --git a/apps/docs/document-api/reference/replace.mdx b/apps/docs/document-api/reference/replace.mdx index de53ace0bb..ce17ecd20a 100644 --- a/apps/docs/document-api/reference/replace.mdx +++ b/apps/docs/document-api/reference/replace.mdx @@ -47,17 +47,17 @@ Returns an SDMutationReceipt with applied status; receipt reports NO_OP if the t | Field | Type | Required | Description | | --- | --- | --- | --- | -| `content` | object \\| object[] | yes | One of: object, object[] | +| `content` | object \| object[] | yes | One of: object, object[] | | `in` | StoryLocator | no | StoryLocator | | `nestingPolicy` | object | no | | | `nestingPolicy.tables` | enum | no | `"forbid"`, `"allow"` | -| `target` | BlockNodeAddress \\| SelectionTarget | yes | One of: BlockNodeAddress, SelectionTarget | +| `target` | BlockNodeAddress \| SelectionTarget | yes | One of: BlockNodeAddress, SelectionTarget | ### Variant 2.2 (required: ref, content) | Field | Type | Required | Description | | --- | --- | --- | --- | -| `content` | object \\| object[] | yes | One of: object, object[] | +| `content` | object \| object[] | yes | One of: object, object[] | | `in` | StoryLocator | no | StoryLocator | | `nestingPolicy` | object | no | | | `nestingPolicy.tables` | enum | no | `"forbid"`, `"allow"` | @@ -101,7 +101,7 @@ Returns an SDMutationReceipt with applied status; receipt reports NO_OP if the t | `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | | `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | | `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | -| `resolution.target` | TextAddress \\| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | +| `resolution.target` | TextAddress \| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | | `success` | `true` | yes | Constant: `true` | ### Variant 2 (success=false) @@ -123,7 +123,7 @@ Returns an SDMutationReceipt with applied status; receipt reports NO_OP if the t | `resolution.selectionTarget.end` | SelectionPoint | no | SelectionPoint | | `resolution.selectionTarget.kind` | `"selection"` | no | Constant: `"selection"` | | `resolution.selectionTarget.start` | SelectionPoint | no | SelectionPoint | -| `resolution.target` | TextAddress \\| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | +| `resolution.target` | TextAddress \| BlockNodeAddress | no | One of: TextAddress, BlockNodeAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/sections/get.mdx b/apps/docs/document-api/reference/sections/get.mdx index a436a7e8ac..fba6042315 100644 --- a/apps/docs/document-api/reference/sections/get.mdx +++ b/apps/docs/document-api/reference/sections/get.mdx @@ -78,8 +78,8 @@ Returns a SectionInfo object with full section properties including margins, col | `margins.right` | number | no | | | `margins.top` | number | no | | | `oddEvenHeadersFooters` | boolean | no | | -| `pageBorders` | any \\| any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any, any | -| `pageBorders.bottom` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `pageBorders` | any \| any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any, any | +| `pageBorders.bottom` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `pageBorders.bottom.color` | string | no | | | `pageBorders.bottom.frame` | boolean | no | | | `pageBorders.bottom.shadow` | boolean | no | | @@ -87,7 +87,7 @@ Returns a SectionInfo object with full section properties including margins, col | `pageBorders.bottom.space` | number | no | | | `pageBorders.bottom.style` | string | no | | | `pageBorders.display` | enum | no | `"allPages"`, `"firstPage"`, `"notFirstPage"` | -| `pageBorders.left` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `pageBorders.left` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `pageBorders.left.color` | string | no | | | `pageBorders.left.frame` | boolean | no | | | `pageBorders.left.shadow` | boolean | no | | @@ -95,14 +95,14 @@ Returns a SectionInfo object with full section properties including margins, col | `pageBorders.left.space` | number | no | | | `pageBorders.left.style` | string | no | | | `pageBorders.offsetFrom` | enum | no | `"page"`, `"text"` | -| `pageBorders.right` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `pageBorders.right` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `pageBorders.right.color` | string | no | | | `pageBorders.right.frame` | boolean | no | | | `pageBorders.right.shadow` | boolean | no | | | `pageBorders.right.size` | number | no | | | `pageBorders.right.space` | number | no | | | `pageBorders.right.style` | string | no | | -| `pageBorders.top` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `pageBorders.top` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `pageBorders.top.color` | string | no | | | `pageBorders.top.frame` | boolean | no | | | `pageBorders.top.shadow` | boolean | no | | diff --git a/apps/docs/document-api/reference/sections/set-page-borders.mdx b/apps/docs/document-api/reference/sections/set-page-borders.mdx index 1648bacdaa..b2400f62f2 100644 --- a/apps/docs/document-api/reference/sections/set-page-borders.mdx +++ b/apps/docs/document-api/reference/sections/set-page-borders.mdx @@ -26,8 +26,8 @@ Returns a SectionMutationResult receipt; reports NO_OP if page border configurat | Field | Type | Required | Description | | --- | --- | --- | --- | -| `borders` | any \\| any \\| any \\| any \\| any \\| any \\| any | yes | One of: any, any, any, any, any, any, any | -| `borders.bottom` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `borders` | any \| any \| any \| any \| any \| any \| any | yes | One of: any, any, any, any, any, any, any | +| `borders.bottom` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `borders.bottom.color` | string | no | | | `borders.bottom.frame` | boolean | no | | | `borders.bottom.shadow` | boolean | no | | @@ -35,7 +35,7 @@ Returns a SectionMutationResult receipt; reports NO_OP if page border configurat | `borders.bottom.space` | number | no | | | `borders.bottom.style` | string | no | | | `borders.display` | enum | no | `"allPages"`, `"firstPage"`, `"notFirstPage"` | -| `borders.left` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `borders.left` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `borders.left.color` | string | no | | | `borders.left.frame` | boolean | no | | | `borders.left.shadow` | boolean | no | | @@ -43,14 +43,14 @@ Returns a SectionMutationResult receipt; reports NO_OP if page border configurat | `borders.left.space` | number | no | | | `borders.left.style` | string | no | | | `borders.offsetFrom` | enum | no | `"page"`, `"text"` | -| `borders.right` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `borders.right` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `borders.right.color` | string | no | | | `borders.right.frame` | boolean | no | | | `borders.right.shadow` | boolean | no | | | `borders.right.size` | number | no | | | `borders.right.space` | number | no | | | `borders.right.style` | string | no | | -| `borders.top` | any \\| any \\| any \\| any \\| any \\| any | no | One of: any, any, any, any, any, any | +| `borders.top` | any \| any \| any \| any \| any \| any | no | One of: any, any, any, any, any, any | | `borders.top.color` | string | no | | | `borders.top.frame` | boolean | no | | | `borders.top.shadow` | boolean | no | | diff --git a/apps/docs/document-api/reference/selection/current.mdx b/apps/docs/document-api/reference/selection/current.mdx index 0ed358f60a..8ff7442d59 100644 --- a/apps/docs/document-api/reference/selection/current.mdx +++ b/apps/docs/document-api/reference/selection/current.mdx @@ -44,7 +44,7 @@ Returns a SelectionInfo with `empty`, `target` (TextTarget or null), `activeMark | `activeCommentIds` | string[] | yes | | | `activeMarks` | string[] | yes | | | `empty` | boolean | yes | | -| `target` | TextTarget \\| null | yes | One of: TextTarget, null | +| `target` | TextTarget \| null | yes | One of: TextTarget, null | | `text` | string | no | | ### Example response diff --git a/apps/docs/document-api/reference/styles/apply.mdx b/apps/docs/document-api/reference/styles/apply.mdx index 3feb22777f..6e03d01f36 100644 --- a/apps/docs/document-api/reference/styles/apply.mdx +++ b/apps/docs/document-api/reference/styles/apply.mdx @@ -286,59 +286,59 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | `after.autoSpaceDN` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.bold` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.boldCs` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.borders` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `after.color` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.borders` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `after.color` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.contextualSpacing` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.dstrike` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.eastAsianLayout` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `after.effect` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `after.em` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.eastAsianLayout` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `after.effect` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `after.em` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `after.emboss` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.fitText` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `after.fontFamily` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `after.fontSize` | number \\| `"inherit"` | no | One of: number, `"inherit"` | -| `after.fontSizeCs` | number \\| `"inherit"` | no | One of: number, `"inherit"` | -| `after.framePr` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.fitText` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `after.fontFamily` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `after.fontSize` | number \| `"inherit"` | no | One of: number, `"inherit"` | +| `after.fontSizeCs` | number \| `"inherit"` | no | One of: number, `"inherit"` | +| `after.framePr` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.iCs` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.imprint` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.indent` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.indent` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.italic` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.justification` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.justification` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `after.keepLines` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.keepNext` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.kern` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.kern` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `after.kinsoku` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.lang` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `after.letterSpacing` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.lang` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `after.letterSpacing` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `after.mirrorIndents` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.noProof` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.numberingProperties` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.numberingProperties` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.outline` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.outlineLvl` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.outlineLvl` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `after.overflowPunct` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.pageBreakBefore` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.position` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.position` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `after.rightToLeft` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.shading` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.shading` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.shadow` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.smallCaps` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.snapToGrid` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.spacing` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.spacing` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.specVanish` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.strike` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.suppressAutoHyphens` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.suppressLineNumbers` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.suppressOverlap` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.tabStops` | array \\| `"inherit"` | no | One of: array, `"inherit"` | -| `after.textAlignment` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `after.textDirection` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `after.textTransform` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `after.textboxTightWrap` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.tabStops` | array \| `"inherit"` | no | One of: array, `"inherit"` | +| `after.textAlignment` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textDirection` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textTransform` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textboxTightWrap` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `after.topLinePunct` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.underline` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.underline` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `after.vanish` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `after.vertAlign` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `after.w` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.vertAlign` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `after.w` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `after.webHidden` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.widowControl` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.wordWrap` | enum | no | `"on"`, `"off"`, `"inherit"` | @@ -348,59 +348,59 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | `before.autoSpaceDN` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.bold` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.boldCs` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.borders` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `before.color` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.borders` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `before.color` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.contextualSpacing` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.dstrike` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.eastAsianLayout` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `before.effect` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `before.em` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.eastAsianLayout` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `before.effect` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `before.em` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `before.emboss` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.fitText` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `before.fontFamily` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `before.fontSize` | number \\| `"inherit"` | no | One of: number, `"inherit"` | -| `before.fontSizeCs` | number \\| `"inherit"` | no | One of: number, `"inherit"` | -| `before.framePr` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.fitText` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `before.fontFamily` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `before.fontSize` | number \| `"inherit"` | no | One of: number, `"inherit"` | +| `before.fontSizeCs` | number \| `"inherit"` | no | One of: number, `"inherit"` | +| `before.framePr` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.iCs` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.imprint` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.indent` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.indent` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.italic` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.justification` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.justification` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `before.keepLines` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.keepNext` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.kern` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.kern` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `before.kinsoku` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.lang` | object \\| `"inherit"` | no | One of: object, `"inherit"` | -| `before.letterSpacing` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.lang` | object \| `"inherit"` | no | One of: object, `"inherit"` | +| `before.letterSpacing` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `before.mirrorIndents` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.noProof` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.numberingProperties` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.numberingProperties` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.outline` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.outlineLvl` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.outlineLvl` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `before.overflowPunct` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.pageBreakBefore` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.position` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.position` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `before.rightToLeft` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.shading` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.shading` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.shadow` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.smallCaps` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.snapToGrid` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.spacing` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.spacing` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.specVanish` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.strike` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.suppressAutoHyphens` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.suppressLineNumbers` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.suppressOverlap` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.tabStops` | array \\| `"inherit"` | no | One of: array, `"inherit"` | -| `before.textAlignment` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `before.textDirection` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `before.textTransform` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `before.textboxTightWrap` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.tabStops` | array \| `"inherit"` | no | One of: array, `"inherit"` | +| `before.textAlignment` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textDirection` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textTransform` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textboxTightWrap` | string \| `"inherit"` | no | One of: string, `"inherit"` | | `before.topLinePunct` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.underline` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.underline` | object \| `"inherit"` | no | One of: object, `"inherit"` | | `before.vanish` | enum | no | `"on"`, `"off"`, `"inherit"` | -| `before.vertAlign` | string \\| `"inherit"` | no | One of: string, `"inherit"` | -| `before.w` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.vertAlign` | string \| `"inherit"` | no | One of: string, `"inherit"` | +| `before.w` | number \| `"inherit"` | no | One of: number, `"inherit"` | | `before.webHidden` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.widowControl` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.wordWrap` | enum | no | `"on"`, `"off"`, `"inherit"` | diff --git a/apps/docs/document-api/reference/styles/paragraph/clear-style.mdx b/apps/docs/document-api/reference/styles/paragraph/clear-style.mdx index 68acc8e661..e571bd9526 100644 --- a/apps/docs/document-api/reference/styles/paragraph/clear-style.mdx +++ b/apps/docs/document-api/reference/styles/paragraph/clear-style.mdx @@ -26,7 +26,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no style is set. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -47,9 +47,9 @@ Returns a ParagraphMutationResult; reports NO_OP if no style is set. | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -60,7 +60,7 @@ Returns a ParagraphMutationResult; reports NO_OP if no style is set. | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/styles/paragraph/set-style.mdx b/apps/docs/document-api/reference/styles/paragraph/set-style.mdx index a80b4c15ec..6c65c1c104 100644 --- a/apps/docs/document-api/reference/styles/paragraph/set-style.mdx +++ b/apps/docs/document-api/reference/styles/paragraph/set-style.mdx @@ -27,7 +27,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the style already matches. W | Field | Type | Required | Description | | --- | --- | --- | --- | | `styleId` | string | yes | | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Example request @@ -49,9 +49,9 @@ Returns a ParagraphMutationResult; reports NO_OP if the style already matches. W | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | object | yes | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `true` | yes | Constant: `true` | -| `target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | ### Variant 2 (success=false) @@ -62,7 +62,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the style already matches. W | `failure.details` | any | no | | | `failure.message` | string | yes | | | `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \\| HeadingAddress \\| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | +| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | | `success` | `false` | yes | Constant: `false` | ### Example response diff --git a/apps/docs/document-api/reference/tables/convert-from-text.mdx b/apps/docs/document-api/reference/tables/convert-from-text.mdx index a8a2dca7bd..547579fd77 100644 --- a/apps/docs/document-api/reference/tables/convert-from-text.mdx +++ b/apps/docs/document-api/reference/tables/convert-from-text.mdx @@ -29,7 +29,7 @@ Returns a TableMutationResult receipt confirming text was converted into a table | Field | Type | Required | Description | | --- | --- | --- | --- | | `columns` | integer | no | | -| `delimiter` | enum \\| object | no | One of: enum, object | +| `delimiter` | enum \| object | no | One of: enum, object | | `inferColumns` | boolean | no | | | `target` | BlockNodeAddress | yes | BlockNodeAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | @@ -41,7 +41,7 @@ Returns a TableMutationResult receipt confirming text was converted into a table | Field | Type | Required | Description | | --- | --- | --- | --- | | `columns` | integer | no | | -| `delimiter` | enum \\| object | no | One of: enum, object | +| `delimiter` | enum \| object | no | One of: enum, object | | `inferColumns` | boolean | no | | | `nodeId` | string | yes | | diff --git a/apps/docs/document-api/reference/tables/get-properties.mdx b/apps/docs/document-api/reference/tables/get-properties.mdx index 1c05263439..c377dc227c 100644 --- a/apps/docs/document-api/reference/tables/get-properties.mdx +++ b/apps/docs/document-api/reference/tables/get-properties.mdx @@ -62,12 +62,12 @@ Returns a TablesGetPropertiesOutput with direct table layout and style state, in | `alignment` | enum | no | `"left"`, `"center"`, `"right"` | | `autoFitMode` | enum | no | `"fixedWidth"`, `"fitContents"`, `"fitWindow"` | | `borders` | object | no | | -| `borders.bottom` | object \\| null | no | One of: object, null | -| `borders.insideH` | object \\| null | no | One of: object, null | -| `borders.insideV` | object \\| null | no | One of: object, null | -| `borders.left` | object \\| null | no | One of: object, null | -| `borders.right` | object \\| null | no | One of: object, null | -| `borders.top` | object \\| null | no | One of: object, null | +| `borders.bottom` | object \| null | no | One of: object, null | +| `borders.insideH` | object \| null | no | One of: object, null | +| `borders.insideV` | object \| null | no | One of: object, null | +| `borders.left` | object \| null | no | One of: object, null | +| `borders.right` | object \| null | no | One of: object, null | +| `borders.top` | object \| null | no | One of: object, null | | `cellSpacingPt` | number | no | | | `defaultCellMargins` | object | no | | | `defaultCellMargins.bottomPt` | number | no | | diff --git a/apps/docs/document-api/reference/tables/get-styles.mdx b/apps/docs/document-api/reference/tables/get-styles.mdx index 4804efe649..94e636b5f1 100644 --- a/apps/docs/document-api/reference/tables/get-styles.mdx +++ b/apps/docs/document-api/reference/tables/get-styles.mdx @@ -37,8 +37,8 @@ _No fields._ | Field | Type | Required | Description | | --- | --- | --- | --- | | `effectiveDefaultSource` | string | yes | | -| `effectiveDefaultStyleId` | any | yes | | -| `explicitDefaultStyleId` | any | yes | | +| `effectiveDefaultStyleId` | string \| null | yes | | +| `explicitDefaultStyleId` | string \| null | yes | | | `styles` | object[] | yes | | ### Example response @@ -46,11 +46,11 @@ _No fields._ ```json { "effectiveDefaultSource": "example", - "effectiveDefaultStyleId": {}, - "explicitDefaultStyleId": {}, + "effectiveDefaultStyleId": "example", + "explicitDefaultStyleId": "example", "styles": [ { - "basedOn": {}, + "basedOn": "example", "conditionalRegions": [ "example" ], @@ -58,9 +58,9 @@ _No fields._ "id": "id-001", "isCustom": true, "isDefault": true, - "name": {}, + "name": "example", "quickFormat": true, - "uiPriority": {} + "uiPriority": 1 } ] } diff --git a/apps/docs/document-api/reference/tables/move.mdx b/apps/docs/document-api/reference/tables/move.mdx index fe7e1ebba7..4991c33f61 100644 --- a/apps/docs/document-api/reference/tables/move.mdx +++ b/apps/docs/document-api/reference/tables/move.mdx @@ -28,7 +28,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the table is already at | Field | Type | Required | Description | | --- | --- | --- | --- | -| `destination` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="before") \\| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | +| `destination` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | | `target` | TableAddress | yes | TableAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | | `target.nodeId` | string | yes | | @@ -38,7 +38,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the table is already at | Field | Type | Required | Description | | --- | --- | --- | --- | -| `destination` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="before") \\| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | +| `destination` | object(kind="documentStart") \| object(kind="documentEnd") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | | `nodeId` | string | yes | | ### Example request diff --git a/apps/docs/document-api/reference/tables/set-borders.mdx b/apps/docs/document-api/reference/tables/set-borders.mdx index 2ea83a268b..7270f68829 100644 --- a/apps/docs/document-api/reference/tables/set-borders.mdx +++ b/apps/docs/document-api/reference/tables/set-borders.mdx @@ -29,7 +29,7 @@ Returns a TableMutationResult receipt. Does not perform NO_OP detection. | Field | Type | Required | Description | | --- | --- | --- | --- | | `applyTo` | enum | yes | `"all"`, `"outside"`, `"inside"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"insideH"`, `"insideV"` | -| `border` | object \\| null | yes | One of: object, null | +| `border` | object \| null | yes | One of: object, null | | `mode` | `"applyTo"` | yes | Constant: `"applyTo"` | | `target` | BlockNodeAddress | yes | BlockNodeAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | @@ -41,7 +41,7 @@ Returns a TableMutationResult receipt. Does not perform NO_OP detection. | Field | Type | Required | Description | | --- | --- | --- | --- | | `applyTo` | enum | yes | `"all"`, `"outside"`, `"inside"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"insideH"`, `"insideV"` | -| `border` | object \\| null | yes | One of: object, null | +| `border` | object \| null | yes | One of: object, null | | `mode` | `"applyTo"` | yes | Constant: `"applyTo"` | | `nodeId` | string | yes | | @@ -50,12 +50,12 @@ Returns a TableMutationResult receipt. Does not perform NO_OP detection. | Field | Type | Required | Description | | --- | --- | --- | --- | | `edges` | object | yes | | -| `edges.bottom` | object \\| null | no | One of: object, null | -| `edges.insideH` | object \\| null | no | One of: object, null | -| `edges.insideV` | object \\| null | no | One of: object, null | -| `edges.left` | object \\| null | no | One of: object, null | -| `edges.right` | object \\| null | no | One of: object, null | -| `edges.top` | object \\| null | no | One of: object, null | +| `edges.bottom` | object \| null | no | One of: object, null | +| `edges.insideH` | object \| null | no | One of: object, null | +| `edges.insideV` | object \| null | no | One of: object, null | +| `edges.left` | object \| null | no | One of: object, null | +| `edges.right` | object \| null | no | One of: object, null | +| `edges.top` | object \| null | no | One of: object, null | | `mode` | `"edges"` | yes | Constant: `"edges"` | | `target` | BlockNodeAddress | yes | BlockNodeAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | @@ -67,12 +67,12 @@ Returns a TableMutationResult receipt. Does not perform NO_OP detection. | Field | Type | Required | Description | | --- | --- | --- | --- | | `edges` | object | yes | | -| `edges.bottom` | object \\| null | no | One of: object, null | -| `edges.insideH` | object \\| null | no | One of: object, null | -| `edges.insideV` | object \\| null | no | One of: object, null | -| `edges.left` | object \\| null | no | One of: object, null | -| `edges.right` | object \\| null | no | One of: object, null | -| `edges.top` | object \\| null | no | One of: object, null | +| `edges.bottom` | object \| null | no | One of: object, null | +| `edges.insideH` | object \| null | no | One of: object, null | +| `edges.insideV` | object \| null | no | One of: object, null | +| `edges.left` | object \| null | no | One of: object, null | +| `edges.right` | object \| null | no | One of: object, null | +| `edges.top` | object \| null | no | One of: object, null | | `mode` | `"edges"` | yes | Constant: `"edges"` | | `nodeId` | string | yes | | diff --git a/apps/docs/document-api/reference/tables/set-shading.mdx b/apps/docs/document-api/reference/tables/set-shading.mdx index 587a3a1b97..ea15f7c243 100644 --- a/apps/docs/document-api/reference/tables/set-shading.mdx +++ b/apps/docs/document-api/reference/tables/set-shading.mdx @@ -28,7 +28,7 @@ Returns a TableMutationResult receipt; reports NO_OP if shading already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `color` | string \\| null | yes | One of: string, null | +| `color` | string \| null | yes | One of: string, null | | `target` | TableOrCellAddress | yes | TableOrCellAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | | `target.nodeId` | string | yes | | @@ -38,7 +38,7 @@ Returns a TableMutationResult receipt; reports NO_OP if shading already matches. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `color` | string \\| null | yes | One of: string, null | +| `color` | string \| null | yes | One of: string, null | | `nodeId` | string | yes | | ### Example request diff --git a/apps/docs/document-api/reference/tables/set-table-options.mdx b/apps/docs/document-api/reference/tables/set-table-options.mdx index 620802a82a..79cae4a8be 100644 --- a/apps/docs/document-api/reference/tables/set-table-options.mdx +++ b/apps/docs/document-api/reference/tables/set-table-options.mdx @@ -28,7 +28,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the provided values alre | Field | Type | Required | Description | | --- | --- | --- | --- | -| `cellSpacingPt` | number \\| null | no | One of: number, null | +| `cellSpacingPt` | number \| null | no | One of: number, null | | `defaultCellMargins` | object | no | | | `defaultCellMargins.bottomPt` | number | no | | | `defaultCellMargins.leftPt` | number | no | | @@ -43,7 +43,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the provided values alre | Field | Type | Required | Description | | --- | --- | --- | --- | -| `cellSpacingPt` | number \\| null | no | One of: number, null | +| `cellSpacingPt` | number \| null | no | One of: number, null | | `defaultCellMargins` | object | no | | | `defaultCellMargins.bottomPt` | number | no | | | `defaultCellMargins.leftPt` | number | no | | diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index e1f3723ca9..d44816985b 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -27,7 +27,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | enum | yes | `"accept"`, `"reject"` | -| `target` | object \\| object(kind="range") \\| object | yes | One of: object, object(kind="range"), object | +| `target` | object \| object(kind="range") \| object | yes | One of: object, object(kind="range"), object | ### Example request diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index 2dd2539378..6ed1052cbb 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -59,7 +59,7 @@ Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `repl | `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"unknown"` | | `id` | string | yes | | | `insertedText` | string | no | | -| `pairedWithChangeId` | any | no | | +| `pairedWithChangeId` | string \| null | no | | | `type` | enum | yes | `"insert"`, `"delete"`, `"replacement"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | @@ -81,7 +81,7 @@ Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `repl }, "grouping": "standalone", "id": "id-001", - "pairedWithChangeId": {}, + "pairedWithChangeId": null, "type": "insert" } ``` diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index fc9e039d74..2725a23a83 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -26,7 +26,7 @@ Returns a TrackChangesListResult with tracked change entries (`insert`, `delete` | Field | Type | Required | Description | | --- | --- | --- | --- | -| `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` | +| `in` | StoryLocator \| `"all"` | no | One of: StoryLocator, `"all"` | | `limit` | integer | no | | | `offset` | integer | no | | | `type` | enum | no | `"insert"`, `"delete"`, `"replacement"`, `"format"` | @@ -75,7 +75,7 @@ Returns a TrackChangesListResult with tracked change entries (`insert`, `delete` "targetKind": "text" }, "id": "id-001", - "pairedWithChangeId": {}, + "pairedWithChangeId": null, "type": "insert" } ], diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts new file mode 100644 index 0000000000..9f9c7363aa --- /dev/null +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { buildReferenceDocsArtifacts } from './reference-docs-artifacts.js'; + +function artifactContentByPath(): Map { + return new Map(buildReferenceDocsArtifacts().map((file) => [file.path, file.content])); +} + +describe('reference docs artifacts', () => { + it('renders nullable primitive schema fields with valid type labels and example values', () => { + const artifacts = artifactContentByPath(); + + const trackedChangeGet = artifacts.get('apps/docs/document-api/reference/track-changes/get.mdx'); + expect(trackedChangeGet).toBeDefined(); + expect(trackedChangeGet!).toContain('| `pairedWithChangeId` | string \\| null | no | |'); + expect(trackedChangeGet!).toContain('"pairedWithChangeId": null'); + + const trackedChangeList = artifacts.get('apps/docs/document-api/reference/track-changes/list.mdx'); + expect(trackedChangeList).toBeDefined(); + expect(trackedChangeList!).toContain('| `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` |'); + expect(trackedChangeList!).toContain('"pairedWithChangeId": null'); + + const commentsGet = artifacts.get('apps/docs/document-api/reference/comments/get.mdx'); + expect(commentsGet).toBeDefined(); + expect(commentsGet!).toContain('| `deletedText` | string \\| null | no | |'); + expect(commentsGet!).toContain('| `trackedChangeAnchorKey` | string \\| null | no | |'); + expect(commentsGet!).toContain('| `trackedChangeDisplayType` | string \\| null | no | |'); + expect(commentsGet!).toContain( + '| `trackedChangeLink` | CommentTrackedChangeLink \\| null | no | One of: CommentTrackedChangeLink, null |', + ); + expect(commentsGet!).toContain('| `trackedChangeText` | string \\| null | no | |'); + }); + + it('emits one generated file per reference doc path and keeps canonical content on shared pages', () => { + const artifacts = artifactContentByPath(); + + const manifest = JSON.parse(artifacts.get('apps/docs/document-api/reference/_generated-manifest.json') ?? '{}') as { + files?: string[]; + }; + const applyEntries = (manifest.files ?? []).filter( + (path) => path === 'apps/docs/document-api/reference/format/apply.mdx', + ); + expect(applyEntries).toHaveLength(1); + + const formatApply = artifacts.get('apps/docs/document-api/reference/format/apply.mdx'); + expect(formatApply).toBeDefined(); + expect(formatApply!).toContain('title: format.apply'); + expect(formatApply!).toContain('- Operation ID: `format.apply`'); + }); +}); diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts index ac63c74c72..900ca2b009 100644 --- a/packages/document-api/scripts/lib/reference-docs-artifacts.ts +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts @@ -36,6 +36,10 @@ interface OperationGroup { }>; } +interface ExampleGenerationOptions { + preferNullForNullable?: boolean; +} + function formatMemberPath(memberPath: string): string { return `editor.doc.${memberPath}${memberPath === 'capabilities' ? '()' : '(...)'}`; } @@ -176,6 +180,31 @@ function refName(schema: JsonSchema): string | undefined { return match ? match[1] : undefined; } +function schemaTypeList(schema: JsonSchema): string[] { + if (typeof schema.type === 'string') return [schema.type]; + if (Array.isArray(schema.type)) { + return schema.type.filter((entry): entry is string => typeof entry === 'string'); + } + return []; +} + +function schemaWithType(schema: JsonSchema, type: string | string[]): JsonSchema { + return { + ...schema, + type: Array.isArray(type) && type.length === 1 ? type[0] : type, + }; +} + +function schemaWithoutType(schema: JsonSchema, omittedType: string): JsonSchema { + const remainingTypes = schemaTypeList(schema).filter((type) => type !== omittedType); + if (remainingTypes.length === 0) { + const clone = { ...schema }; + delete clone.type; + return clone; + } + return schemaWithType(schema, remainingTypes); +} + // --------------------------------------------------------------------------- // Field table rendering // --------------------------------------------------------------------------- @@ -197,7 +226,7 @@ interface FieldSection { * Looks for a `const` property that acts as a type discriminator (e.g., `type: "text"`). */ function objectDiscriminatorLabel(schema: JsonSchema): string | undefined { - if (schema.type !== 'object' || !schema.properties) return undefined; + if (!schemaTypeList(schema).includes('object') || !schema.properties) return undefined; const properties = schema.properties as Record; for (const [key, prop] of Object.entries(properties)) { if (prop.const !== undefined && typeof prop.const === 'string') { @@ -213,6 +242,15 @@ function schemaTypeLabel(schema: JsonSchema, $defs: Defs): string { const rn = refName(schema); if (rn) return rn; + const types = schemaTypeList(schema); + if (types.length > 1) { + const labels = types.map((type) => { + if (type === 'null') return 'null'; + return schemaTypeLabel(schemaWithType(schema, type), $defs); + }); + return [...new Set(labels)].join(' | '); + } + // const if (schema.const !== undefined) return `\`${JSON.stringify(schema.const)}\``; @@ -240,7 +278,7 @@ function schemaTypeLabel(schema: JsonSchema, $defs: Defs): string { } return base; }); - return labels.join(' \\| '); + return labels.join(' | '); } } @@ -262,7 +300,7 @@ function schemaTypeLabel(schema: JsonSchema, $defs: Defs): string { } // primitive - if (typeof schema.type === 'string') return schema.type as string; + if (types.length === 1) return types[0]; return 'any'; } @@ -310,7 +348,7 @@ function collectConstDiscriminators( if (resolved.const !== undefined && prefix) { return [{ path: prefix.replace(/\.$/u, ''), value: resolved.const }]; } - if (!properties || resolved.type !== 'object') return []; + if (!properties || !schemaTypeList(resolved).includes('object')) return []; const discriminators: Array<{ path: string; value: unknown }> = []; for (const key of Object.keys(properties)) { @@ -411,7 +449,7 @@ function buildFieldRows(schema: JsonSchema, $defs: Defs, prefix = '', parentRequ const { resolved } = resolveRef(schema, $defs); const flat = flattenAllOf(resolved, $defs); const properties = flat.properties as Record | undefined; - if (!properties || flat.type !== 'object') return []; + if (!properties || !schemaTypeList(flat).includes('object')) return []; const requiredSet = new Set(Array.isArray(flat.required) ? (flat.required as string[]) : []); const rows: FieldRow[] = []; @@ -626,7 +664,13 @@ function applyNumericBounds(value: number, schema: JsonSchema, type: 'integer' | * Generate a deterministic example value from a JSON Schema node. * `fieldName` is used to pick contextual string/integer values. */ -function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, depth = 0): unknown { +function generateExample( + schema: JsonSchema, + $defs: Defs, + fieldName?: string, + depth = 0, + options: ExampleGenerationOptions = {}, +): unknown { if (depth > 10) return {}; // const value @@ -635,18 +679,31 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de // enum: first value if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]; + const types = schemaTypeList(schema); + if (types.length > 1) { + if (types.includes('null') && options.preferNullForNullable) { + return null; + } + + if (types.includes('null')) { + return generateExample(schemaWithoutType(schema, 'null'), $defs, fieldName, depth, options); + } + + return generateExample(schemaWithType(schema, types[0]), $defs, fieldName, depth, options); + } + // $ref: resolve and recurse const rn = refName(schema); if (rn) { const { resolved } = resolveRef(schema, $defs); - return generateExample(resolved, $defs, fieldName, depth); + return generateExample(resolved, $defs, fieldName, depth, options); } // array: single item if (schema.type === 'array') { const items = schema.items as JsonSchema | undefined; if (schema.maxItems === 0) return []; - if (items) return [generateExample(items, $defs, undefined, depth + 1)]; + if (items) return [generateExample(items, $defs, undefined, depth + 1, options)]; return []; } @@ -701,9 +758,15 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de for (const key of keys) { if (excludedByVariant.has(key)) continue; if (requiredSet.has(key)) { - result[key] = generateExample(properties[key], $defs, key, depth + 1); + result[key] = generateExample(properties[key], $defs, key, depth + 1, { + ...options, + preferNullForNullable: false, + }); } else if (optionalCount < 2) { - result[key] = generateExample(properties[key], $defs, key, depth + 1); + result[key] = generateExample(properties[key], $defs, key, depth + 1, { + ...options, + preferNullForNullable: true, + }); optionalCount++; } } @@ -717,7 +780,7 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de const merged: Record = {}; for (const member of schema.allOf as JsonSchema[]) { const { resolved } = resolveRef(member, $defs); - const memberExample = generateExample(resolved, $defs, fieldName, depth + 1); + const memberExample = generateExample(resolved, $defs, fieldName, depth + 1, options); if (typeof memberExample === 'object' && memberExample !== null && !Array.isArray(memberExample)) { Object.assign(merged, memberExample as Record); } @@ -729,7 +792,7 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de for (const keyword of ['oneOf', 'anyOf'] as const) { const variants = schema[keyword]; if (Array.isArray(variants) && variants.length > 0) { - return generateExample(variants[0] as JsonSchema, $defs, fieldName, depth); + return generateExample(variants[0] as JsonSchema, $defs, fieldName, depth, options); } } @@ -751,6 +814,63 @@ function generateExample(schema: JsonSchema, $defs: Defs, fieldName?: string, de return {}; } +function compareReferenceDocPrimary(left: ContractOperationSnapshot, right: ContractOperationSnapshot): number { + const leftSkipRank = left.skipAsATool ? 1 : 0; + const rightSkipRank = right.skipAsATool ? 1 : 0; + if (leftSkipRank !== rightSkipRank) { + return leftSkipRank - rightSkipRank; + } + + const leftMemberDepth = left.memberPath.split('.').length; + const rightMemberDepth = right.memberPath.split('.').length; + if (leftMemberDepth !== rightMemberDepth) { + return rightMemberDepth - leftMemberDepth; + } + + return left.operationId.localeCompare(right.operationId); +} + +function selectPrimaryOperationForDocPath( + docPath: string, + operations: ContractOperationSnapshot[], +): ContractOperationSnapshot { + if (operations.length === 1) { + return operations[0]; + } + + const sorted = [...operations].sort(compareReferenceDocPrimary); + const [primary, secondary] = sorted; + if (secondary && compareReferenceDocPrimary(primary, secondary) === 0) { + throw new Error( + `Ambiguous reference doc path "${docPath}" shared by operations: ${sorted.map((entry) => entry.operationId).join(', ')}.`, + ); + } + + return primary; +} + +function buildOperationDocFiles( + operations: ContractOperationSnapshot[], + snapshot: ReturnType, +): GeneratedFile[] { + const operationsByPath = new Map(); + + for (const operation of operations) { + const docPath = toOperationDocPath(operation.operationId); + const existing = operationsByPath.get(docPath); + if (existing) { + existing.push(operation); + continue; + } + operationsByPath.set(docPath, [operation]); + } + + return [...operationsByPath.entries()].map(([path, entries]) => ({ + path, + content: renderOperationPage(selectPrimaryOperationForDocPath(path, entries), snapshot), + })); +} + function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -1320,10 +1440,7 @@ export function buildReferenceDocsArtifacts(): GeneratedFile[] { const snapshot = buildContractSnapshot(); const groups = buildOperationGroups(snapshot.operations); - const operationFiles = snapshot.operations.map((operation) => ({ - path: toOperationDocPath(operation.operationId), - content: renderOperationPage(operation, snapshot), - })); + const operationFiles = buildOperationDocFiles(snapshot.operations, snapshot); const groupFiles = groups.map((group) => ({ path: group.pagePath, From 5bc5a80b0c4a3bce217abcfb035ebca7360da502 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 14:01:51 -0300 Subject: [PATCH 033/280] fix(scripts): jsdoc-hygiene-ts handles private-identifier symbols + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups addressing review feedback on the scanner PR: enclosingSymbolName() only recognized Identifier names, so private methods like `#privateMethod` collapsed onto their enclosing class's key. Verified empirically: the previous baseline had 12 SuperDoc:: entries (6 distinct `SuperDoc::param` with occurrenceIndex 0-5), but the SuperDoc class itself only carries @extends and @implements JSDoc. The other 10 were private methods (#applyDocumentMode, #abortUpgrade, etc.) all keyed as SuperDoc, which would have churned unrelated indexes when any one of them was edited. Fix: - renderName() helper recognizes ts.isPrivateIdentifier (returns #name via nameNode.text so the leading # is preserved without depending on escapedText escape rules across TS versions) and string/numeric literal property names alongside identifiers. - enclosingSymbolName() uses renderName() for direct .name, variable declarations, and variable statements. Regenerated baseline. SuperDoc::SuperDoc:: entries dropped from 12 to 2 (the legit class-level @extends and @implements); 13 private-method entries now anchor to their actual #name. Total count unchanged at 85 — same violations, more honest keys. packages/superdoc/scripts/README.md updated with a row for check-jsdoc-hygiene-ts.cjs alongside check-jsdoc.cjs, and the wrapper-stages paragraph now lists jsdoc-hygiene-ts alongside the other policy gates. Scanner failure-message scope wording aligned with reality: was "public contract surface," now "packages/superdoc/src or packages/super-editor/src," matching the actual scan scope. Verified: 13/13 scanner tests pass; check:public:superdoc --skip-build PASS (10 ran, 1 skipped, 138.5s); single-# rendering on private identifiers confirmed in baseline output. --- packages/superdoc/scripts/README.md | 5 ++- .../scripts/check-jsdoc-hygiene-ts.cjs | 44 +++++++++++++++---- .../scripts/jsdoc-hygiene-ts-baseline.json | 26 +++++------ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 8ca6c952a2..dc81bd79fe 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -104,6 +104,7 @@ it stopped running. | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Derives the expected name set directly from the facade source file under `packages/superdoc/src/public/**`; rejects `export *` / `export * as X` in facade sources so the contract stays explicit. | Symbol set drift ships silently; CJS shims diverge from ESM; a wildcard re-export silently widens the public surface. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | | `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | +| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 4 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts.test.cjs` (13 in-memory fixtures). Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | The repo also has a top-level public-contract tier gate. One script, `scripts/report-public-contract.mjs`, with two modes: @@ -148,8 +149,8 @@ what an actual consumer would see — not the workspace source. Of these, six run as wrapper stages of `check:public:superdoc` after the cheap policy gates (`contract-tiers-test`, -`contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`) and -`build`: `consumer-typecheck-matrix`, +`contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, +`public-method-coverage`) and `build`: `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. `consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs index 4d478daf8c..f634754ca3 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -114,6 +114,28 @@ function listTsFiles(rootRel) { // ─── AST walk ───────────────────────────────────────────────────────── +/** + * Render a member name from the AST node that carries it. Handles + * `Identifier`, `PrivateIdentifier` (returns `#name` so private + * methods don't collapse onto their enclosing class's key), and + * string/numeric literal property names. + * + * Returns null when the name shape is not one we render — caller + * falls through to the next ancestor. + */ +function renderName(nameNode) { + if (!nameNode) return null; + if (ts.isIdentifier(nameNode)) return String(nameNode.escapedText); + if (ts.isPrivateIdentifier(nameNode)) { + // `nameNode.text` is the original source ('#name'). escapedText + // may also include the leading '#' depending on TS version; using + // .text avoids depending on that detail. + return nameNode.text; + } + if (ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) return String(nameNode.text); + return null; +} + /** * Walk up from a node to find the nearest named declaration ancestor. * Used to anchor each violation to a stable enclosing-symbol name so @@ -129,18 +151,22 @@ function enclosingSymbolName(node) { // Direct .name on the node (FunctionDeclaration, MethodDeclaration, // ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, // PropertyDeclaration / PropertySignature, GetAccessorDeclaration, - // EnumDeclaration, etc.). - if (n.name && ts.isIdentifier(n.name)) { - return String(n.name.escapedText); + // EnumDeclaration, etc.). Includes PrivateIdentifier so `#method` + // doesn't collapse onto its enclosing class's key. + if (n.name) { + const rendered = renderName(n.name); + if (rendered) return rendered; } // VariableStatement -> VariableDeclarationList -> VariableDeclaration[]. // Use the first declared name as the anchor. if (ts.isVariableStatement(n) && n.declarationList && n.declarationList.declarations.length > 0) { const d = n.declarationList.declarations[0]; - if (d.name && ts.isIdentifier(d.name)) return String(d.name.escapedText); + const rendered = renderName(d.name); + if (rendered) return rendered; } - if (ts.isVariableDeclaration(n) && n.name && ts.isIdentifier(n.name)) { - return String(n.name.escapedText); + if (ts.isVariableDeclaration(n)) { + const rendered = renderName(n.name); + if (rendered) return rendered; } n = n.parent; } @@ -309,9 +335,9 @@ function main() { } console.log(''); console.log( - 'Type-bearing JSDoc is not allowed in .ts source on the public contract surface.\n' + - 'Use TypeScript for shape (signatures, interfaces, `as Type` casts) and prose-only\n' + - 'JSDoc for documentation. See ' + + 'Type-bearing JSDoc is not allowed in .ts source under packages/superdoc/src\n' + + 'or packages/super-editor/src. Use TypeScript for shape (signatures, interfaces,\n' + + '`as Type` casts) and prose-only JSDoc for documentation. See ' + POLICY_RELATIVE + ' for the rule and fix patterns.\n', ); diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json index c95c481356..fadeef1522 100644 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -1,7 +1,7 @@ { "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file::enclosingSymbol::tagName::class::occurrenceIndex\"; line numbers are NOT part of the key, so the baseline survives unrelated edits that shift line numbers. The gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", "knownViolations": [ - "packages/super-editor/src/editors/v1/core/Editor.ts::Editor::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Editor.ts::#withState::template::declaration-doc-type::0", "packages/super-editor/src/editors/v1/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", "packages/super-editor/src/editors/v1/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", "packages/super-editor/src/editors/v1/core/EventEmitter.ts::off::returns::declaration-doc-type::0", @@ -36,25 +36,25 @@ "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::1", "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::2", "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts::getExtensionConfigField::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#focusEditorAfterImageSelection::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#updateSelection::returns::declaration-doc-type::0", "packages/superdoc/src/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", "packages/superdoc/src/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", "packages/superdoc/src/core/EventEmitter.ts::off::returns::declaration-doc-type::0", "packages/superdoc/src/core/EventEmitter.ts::on::returns::declaration-doc-type::0", "packages/superdoc/src/core/EventEmitter.ts::once::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#abortUpgrade::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::#initCollaboration::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#log::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#patchNaiveUIStyles::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#requireSuperdocStore::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#resolveSourceEditor::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#triggerCollaborationSaves::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#validateUpgradePrerequisites::param::declaration-doc-type::0", "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::extends::declaration-doc-type::0", "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::implements::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::2", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::3", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::4", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::5", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::2", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::type::inline-fake-cast::0", "packages/superdoc/src/core/SuperDoc.ts::addCommentsList::param::declaration-doc-type::0", "packages/superdoc/src/core/SuperDoc.ts::addSharedUser::param::declaration-doc-type::0", "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorBeforeCreate::param::declaration-doc-type::0", From 89e75fbde564712fd31690d750f37a2d897c58fc Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 14:20:07 -0300 Subject: [PATCH 034/280] docs(scripts): clarify wrapper-stages prose + add ts-jsdoc to check:public summary Two doc tweaks following review: README.md wrapper-stages paragraph was correct in its count but the prose ('Of these, six run as wrapper stages ... after [parenthetical including public-method-coverage] and build: [list of five post-build stages]') could be parsed as a mismatch. Rewrote to break public- method-coverage out as 'runs alongside the cheap policy gates' so the 'five post-build' list reads cleanly as a subset rather than as the total. AGENTS.md check:public summary bullet listed every other gate but omitted the new ts-jsdoc hygiene one; added it alongside 'jsdoc ratchet'. The detailed check:public:superdoc bullet was already correct. Verified: file diffs only, no script changes; baseline + scanner tests unchanged. --- AGENTS.md | 2 +- packages/superdoc/scripts/README.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50ead1ba8c..ddc0eb8c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. -- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. +- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. - `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index dc81bd79fe..c3783b1d0f 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -147,12 +147,12 @@ what an actual consumer would see — not the workspace source. | `check-root-classification-closure.mjs` | Asserts no `supported-root` or `legacy-root` export references an `internal-candidate` symbol in its public declared type. | Closure rule from SD-3212. | | `check-public-method-coverage.mjs` | Obligation-based ratchet over public `SuperDoc` methods + getters. For each member the AST computes which obligations are meaningful (`parameters`, `returns`, or `call`); the gate fails when any required obligation is unsatisfied by a fixture under `src/` AND not on the debt snapshot. Catches the `search(text: string)` regression class — call sites do NOT satisfy `parameters`/`returns` on their own. | Snapshot at `public-method-coverage-debt-snapshot.json`; allowlist at `public-method-coverage-allowlist.cjs` (each entry validated: key must match a real member, value must be a non-empty reason). Refresh with `--write`. | -Of these, six run as wrapper stages of `check:public:superdoc` -after the cheap policy gates (`contract-tiers-test`, -`contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, -`public-method-coverage`) and `build`: `consumer-typecheck-matrix`, -`deep-type-audit-supported-root`, `package-shape`, -`export-snapshots`, `root-classification-closure`. +Six of these run as wrapper stages of `check:public:superdoc`. +`public-method-coverage` runs alongside the cheap policy gates +(`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, +`jsdoc-hygiene-ts`) before `build`. The other five run after `build`: +`consumer-typecheck-matrix`, `deep-type-audit-supported-root`, +`package-shape`, `export-snapshots`, `root-classification-closure`. `consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into the consumer fixture. The rest reuse what matrix produced: `deep-type-audit-supported-root`, `export-snapshots`, and From 8e242e2dd2b3c9cd09f58088ac15b1f3551f6973 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 26 May 2026 10:29:20 -0700 Subject: [PATCH 035/280] chore: typefix --- .../src/user-comment-mode-apis.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/consumer-typecheck/src/user-comment-mode-apis.ts b/tests/consumer-typecheck/src/user-comment-mode-apis.ts index 6144ee00e1..a79e782e0f 100644 --- a/tests/consumer-typecheck/src/user-comment-mode-apis.ts +++ b/tests/consumer-typecheck/src/user-comment-mode-apis.ts @@ -13,7 +13,7 @@ * - `getComment(commentId)` → `Record | null` * - `setDocumentMode(type)` → `void` * - `addSharedUser(user)` → `void` - * - `removeSharedUser(email)` → `void` + * - `removeSharedUser(userOrEmail)` → `void` * * The mutation methods have no declared return type in source; TS * infers `void`, which the emitted `.d.ts` ships. The `void` @@ -26,6 +26,11 @@ * SuperDoc-internal `User` interface that re-declared the public * super-editor `User`. This PR unifies the two so the imported * `User` from `superdoc` is the same symbol the methods accept. + * + * `removeSharedUser` intentionally accepts either a full `User` or a + * legacy email string. The implementation removes by actor identity + * when a `User` is provided, but preserves the email-only call shape + * for backward compatibility. */ import type { DocumentMode, SuperDoc, User } from 'superdoc'; @@ -59,9 +64,14 @@ const _addSharedUserReturnOk: AssertEqual, sd.addSharedUser(realUser); // ─── removeSharedUser ─────────────────────────────────────────────── -// Removes by email match. Silent on no-match. Return is `void`. -const _removeSharedUserParamsOk: AssertEqual, [email: string]> = true; +// Removes by actor identity when given a User, while preserving the +// legacy email-string call shape. Silent on no-match. Return is `void`. +const _removeSharedUserParamsOk: AssertEqual< + Parameters, + [userOrEmail: User | string] +> = true; const _removeSharedUserReturnOk: AssertEqual, void> = true; +sd.removeSharedUser(realUser); sd.removeSharedUser('user@example.com'); void [ From 0e5d7e47afe008e2a10ddf614b11b25482f42e0d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 26 May 2026 11:25:27 -0700 Subject: [PATCH 036/280] chore: more fixes --- .../tests/PresentationEditor.test.ts | 29 ++---------------- .../v1/document-api-adapters/write-adapter.ts | 30 ++++++++++++++++--- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 350da2f310..ba3b4bbf97 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -3323,35 +3323,12 @@ describe('PresentationEditor', () => { expect(sessionEditor?.view.focus).toHaveBeenCalled(); }); - it('resolves public Word tracked-change ids to runtime mark ids during story navigation', async () => { + it('uses the public Word tracked-change id during story navigation when the note session supports it', async () => { const { sessionEditor } = await activateFootnoteSession(); - const setCursorById = vi.fn((id: string) => id === 'runtime-note-1'); + const setCursorById = vi.fn((id: string) => id === 'word:trackInsert:101'); if (sessionEditor?.commands) { sessionEditor.commands.setCursorById = setCursorById; } - if (sessionEditor?.state?.doc) { - sessionEditor.state.doc.descendants = vi.fn((callback: (node: unknown, pos: number) => void) => { - callback( - { - isInline: true, - nodeSize: 13, - text: 'FN_TC_CHARLIE', - marks: [ - { - type: { name: 'trackInsert' }, - attrs: { - id: 'runtime-note-1', - sourceId: '101', - author: 'Story Harness', - date: '2024-01-01T00:00:00Z', - }, - }, - ], - }, - 2, - ); - }); - } const didNavigate = await editor.navigateTo({ kind: 'entity', @@ -3361,10 +3338,10 @@ describe('PresentationEditor', () => { }); expect(didNavigate).toBe(true); + expect(setCursorById).toHaveBeenCalledTimes(1); expect(setCursorById).toHaveBeenCalledWith('word:trackInsert:101', { preferredActiveThreadId: 'word:trackInsert:101', }); - expect(setCursorById).toHaveBeenCalledWith('runtime-note-1', { preferredActiveThreadId: 'runtime-note-1' }); expect(sessionEditor?.view.focus).toHaveBeenCalled(); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index 4c3c97ab15..c711a8b292 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -49,8 +49,22 @@ type LegacyDeleteWriteRequest = { type LegacyWriteRequest = LegacyInsertWriteRequest | LegacyReplaceWriteRequest | LegacyDeleteWriteRequest; function getSharedTrackedInsertionMarkAtPosition(editor: Editor, pos: number): ProseMirrorMark | null { - const boundedPos = Math.max(0, Math.min(editor.state.doc.content.size, pos)); - const $pos = editor.state.doc.resolve(boundedPos); + const stateDoc = editor?.state?.doc as + | { + content?: { size?: number }; + resolve?: (pos: number) => { + nodeBefore?: { marks?: ProseMirrorMark[] }; + nodeAfter?: { marks?: ProseMirrorMark[] }; + }; + } + | undefined; + if (typeof stateDoc?.resolve !== 'function') { + return null; + } + + const maxPos = typeof stateDoc.content?.size === 'number' ? stateDoc.content.size : pos; + const boundedPos = Math.max(0, Math.min(maxPos, pos)); + const $pos = stateDoc.resolve(boundedPos); const beforeInsert = $pos.nodeBefore?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; const afterInsert = $pos.nodeAfter?.marks?.find((mark) => mark.type?.name === TrackInsertMarkName) ?? null; const beforeId = typeof beforeInsert?.attrs?.id === 'string' ? beforeInsert.attrs.id : null; @@ -64,8 +78,16 @@ function getSharedTrackedInsertionMarkAtPosition(editor: Editor, pos: number): P } function resolveDirectInsertMarks(editor: Editor, pos: number): ProseMirrorMark[] { - const boundedPos = Math.max(0, Math.min(editor.state.doc.content.size, pos)); - const marks = [...editor.state.doc.resolve(boundedPos).marks()]; + const stateDoc = editor?.state?.doc as + | { + content?: { size?: number }; + resolve?: (pos: number) => { marks?: () => readonly ProseMirrorMark[] }; + } + | undefined; + const maxPos = typeof stateDoc?.content?.size === 'number' ? stateDoc.content.size : pos; + const boundedPos = Math.max(0, Math.min(maxPos, pos)); + const resolved = typeof stateDoc?.resolve === 'function' ? stateDoc.resolve(boundedPos) : null; + const marks = resolved?.marks ? [...resolved.marks()] : []; const trackedInsert = getSharedTrackedInsertionMarkAtPosition(editor, boundedPos); if (!trackedInsert) return marks; From 254da78b52470b980ad8da2a45e4cd3cc833ebd0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 15:43:15 -0300 Subject: [PATCH 037/280] fix(scripts): rename jsdoc-hygiene-ts self-tests + wire into CI (fixes vitest discovery on #3511) CI failure on this PR: the unit-tests (other-packages) job runs vitest across all non-superdoc packages, and the @superdoc package's vitest config picked up packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs via the *.test.* glob. The file is a standalone Node runner (not a vitest suite), so vitest fails with: Error: No test suite found in file packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs Test Files 1 failed | 284 passed (285) Local check:public:superdoc didn't catch this because it doesn't run the broader vitest sweep. Two-part fix: - Rename check-jsdoc-hygiene-ts.test.cjs -> check-jsdoc-hygiene-ts-tests.cjs. The new name doesn't match vitest's *.test.* glob. - Wire the self-tests in as wrapper stage 4 (jsdoc-hygiene-ts-test), running immediately before jsdoc-hygiene-ts. Self-tests had been manual-only; AST drift would have surfaced as a silent zero-result downstream. The stage runs the standalone Node script and asserts 13 in-memory fixtures pass. File header updated to reflect the rename and the stage wiring. AGENTS.md / CLAUDE.md (symlinked) and scripts/check-public-contract.mjs header now show 12 stages (was 11) with stage 4 = jsdoc-hygiene-ts-test. packages/superdoc/scripts/README.md row updated for the rename plus the wrapper-stages paragraph now includes the new stage. Verified: - pnpm check:types -> PASS - node check-jsdoc-hygiene-ts-tests.cjs -> 13/13 pass - pnpm check:public:superdoc --skip-build -> PASS (11 ran, 1 skipped, 131.1s; was 10 before this commit added the test stage) - pnpm --filter superdoc test --run -> PASS (1054/1054, 77 files; was 1 file failing before the rename) --- AGENTS.md | 2 +- packages/superdoc/scripts/README.md | 5 +- ...t.cjs => check-jsdoc-hygiene-ts-tests.cjs} | 11 ++++- scripts/check-public-contract.mjs | 47 ++++++++++++++----- 4 files changed, 48 insertions(+), 17 deletions(-) rename packages/superdoc/scripts/{check-jsdoc-hygiene-ts.test.cjs => check-jsdoc-hygiene-ts-tests.cjs} (94%) diff --git a/AGENTS.md b/AGENTS.md index ddc0eb8c10..bf219229e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. - `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. -- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. +- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps twelve stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. - `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs. diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index c3783b1d0f..77026c52e6 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -104,7 +104,7 @@ it stopped running. | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Derives the expected name set directly from the facade source file under `packages/superdoc/src/public/**`; rejects `export *` / `export * as X` in facade sources so the contract stays explicit. | Symbol set drift ships silently; CJS shims diverge from ESM; a wildcard re-export silently widens the public surface. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | | `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | -| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 4 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts.test.cjs` (13 in-memory fixtures). Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | +| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 5 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts-tests.cjs` (13 in-memory fixtures; name avoids the `*.test.*` glob so vitest doesn't pick it up as a unit-test suite). The self-test suite runs as wrapper stage 4 (`jsdoc-hygiene-ts-test`) immediately before this gate. Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | The repo also has a top-level public-contract tier gate. One script, `scripts/report-public-contract.mjs`, with two modes: @@ -150,7 +150,8 @@ what an actual consumer would see — not the workspace source. Six of these run as wrapper stages of `check:public:superdoc`. `public-method-coverage` runs alongside the cheap policy gates (`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, -`jsdoc-hygiene-ts`) before `build`. The other five run after `build`: +`jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`) before `build`. The other +five run after `build`: `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. `consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs similarity index 94% rename from packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs rename to packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs index d99118fdaa..70d2bbcb84 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs @@ -14,7 +14,16 @@ * Each fixture is an in-memory TypeScript source snippet plus the * expected list of (tag, class) pairs the scanner should emit. * - * Run with: node packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs + * Wired into check:public:superdoc as the `jsdoc-hygiene-ts-test` + * stage, which runs immediately before `jsdoc-hygiene-ts` so AST + * drift surfaces here, not as a silent zero-result downstream. + * + * File is intentionally named `*-tests.cjs` (not `*.test.cjs`) so + * vitest doesn't pick it up via the `*.test.*` glob — this is a + * standalone Node runner, not a vitest suite. + * + * Run manually with: + * node packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs */ const { findViolations } = require('./check-jsdoc-hygiene-ts.cjs'); diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index c5c4492ed9..b7943ac07b 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,7 +29,15 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. jsdoc-hygiene-ts - type-bearing JSDoc gate for .ts + * 4. jsdoc-hygiene-ts-test - self-test suite for the + * jsdoc-hygiene-ts scanner; 13 + * in-memory fixtures verifying + * detector correctness. Runs + * immediately before the scanner + * stage so AST-shape drift surfaces + * here, not as a silent zero-result + * downstream. + * 5. jsdoc-hygiene-ts - type-bearing JSDoc gate for .ts * source under packages/superdoc/src * and packages/super-editor/src. * Companion to jsdoc-ratchet on the @@ -39,7 +47,7 @@ * baseline; fails on net-new * type-bearing JSDoc. See * packages/superdoc/scripts/type-hygiene.md. - * 5. public-method-coverage - obligation-based ratchet over + * 6. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + * getters. For each member the * AST computes which obligations @@ -52,7 +60,7 @@ * on their own — that's why * `search(text: string)` shipped * under v1 of this gate. - * 6. build - vite build + the postbuild + * 7. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -63,29 +71,29 @@ * Skipped when `--skip-build` is * passed (CI calls `pnpm run build` * separately in its own step). - * 7. consumer-typecheck-matrix - packs superdoc + installs the + * 8. consumer-typecheck-matrix - packs superdoc + installs the * tarball into * tests/consumer-typecheck/ * node_modules/, then runs every * consumer scenario. - * 8. deep-type-audit-supported-root - strict gate on the supported- + * 9. deep-type-audit-supported-root - strict gate on the supported- * root public surface; fails on any * `any` leak. Reuses the install - * from stage 7. - * 9. package-shape - publint + attw against the packed + * from stage 8. + * 10. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 7. - * 10. export-snapshots - super-editor / legacy / root + * from stage 8. + * 11. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 11. root-classification-closure - no supported-root or legacy-root + * 12. root-classification-closure - no supported-root or legacy-root * export references an internal- * candidate type in its public * declared shape (SD-3212 A1b). * - * Why stage 7 runs before 8-11: stage 7 packs `superdoc.tgz` and - * installs the tarball into the consumer fixture once. Stages 8, 10, - * and 11 reuse the installed fixture; stage 9 reuses the packed tarball + * Why stage 8 runs before 9-12: stage 8 packs `superdoc.tgz` and + * installs the tarball into the consumer fixture once. Stages 9, 11, + * and 12 reuse the installed fixture; stage 10 reuses the packed tarball * directly. Without this ordering each downstream stage would `--pack` * separately and multiply the work. * @@ -145,6 +153,19 @@ const stages = [ 'fails when new public-reachable JSDoc files land without // @ts-check. ' + 'Cheap; runs before the slow build so JSDoc drift fails fast.', }, + { + name: 'jsdoc-hygiene-ts-test', + cwd: REPO_ROOT, + cmd: 'node', + args: ['packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs'], + blurb: + 'Self-test suite for the jsdoc-hygiene-ts scanner. 13 in-memory ' + + 'fixtures verifying detector correctness across negative control, ' + + 'mixed-tag blocks, prose-vs-typed forms, and each tag class. ' + + 'Fast-fails before the scanner runs so AST-shape drift or detector ' + + 'logic bugs surface here rather than as silent zero-result ' + + 'false-passes downstream.', + }, { name: 'jsdoc-hygiene-ts', cwd: REPO_ROOT, From 9777d984f8893e846fae4c97be962ad98de20111 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 26 May 2026 12:40:18 -0700 Subject: [PATCH 038/280] chore: fixes --- .../src/editors/v1/document-api-adapters/write-adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index c711a8b292..23aed36990 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -53,8 +53,8 @@ function getSharedTrackedInsertionMarkAtPosition(editor: Editor, pos: number): P | { content?: { size?: number }; resolve?: (pos: number) => { - nodeBefore?: { marks?: ProseMirrorMark[] }; - nodeAfter?: { marks?: ProseMirrorMark[] }; + nodeBefore?: { marks?: readonly ProseMirrorMark[] }; + nodeAfter?: { marks?: readonly ProseMirrorMark[] }; }; } | undefined; From e5ac0877609eb7c8e868fa038d89a68520e562bb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 17:37:02 -0300 Subject: [PATCH 039/280] refactor(types): drain 85 type-bearing JSDoc entries from .ts source (SD-673) Drains the jsdoc-hygiene-ts baseline from 85 entries to 0. Companion to the scanner PR (#3511) that grandfathered these entries; this is the bulk mechanical cleanup. The zero-baseline flip lands in a third PR after this. declaration-doc-type (78 entries): - @param {T} name description -> @param name description (drop braces, keep prose). - @returns {T} description -> @returns description (drop braces, keep prose). - @template T - description -> @typeParam T - description. TSDoc-canonical replacement; prose preserved (16 entries across Mark / Node / OxmlNode / defineMark / defineNode / Extension / EventEmitter / helpers). - @extends {Base} / @implements {Iface} -> deleted (TS has native extends / implements syntax on the declaration). - Empty @returns tags removed (10 dead documentation lines across both EventEmitters and PresentationEditor where the brace strip left a tag carrying no information). inline-fake-cast (6 entries: 5 from original baseline + 1 new on Editor.ts:219 that landed post-#3511): - #abortUpgrade, readyEditors, pendingCollaborationSaves: the @type was redundant next to the TS field annotation. Dropped the tag, kept the prose. - /** @type {RuntimeDocument} */ ... and /** @type {string} */ doc.id in SuperDoc.ts: real-intent casts that did nothing in .ts. Converted to 'as RuntimeDocument' and 'as string'. - /** @type {YMapEvent} */ on a Yjs observe callback param in SuperDoc.ts: converted to a TS param annotation (event: Y.YMapEvent). - /** @type {Mark[]} */ on insertionMarks in Editor.ts:219 (new in main since the scanner PR): converted to a TS local var annotation (PmMark[] is already imported). - The InternalConfig doc example in types/index.ts was teaching the /** @type */ cast pattern explicitly. Rewrote to teach '(value as Type)' instead. typedef-style (2 entries): rewrites. The @typedef tags lived inside @example blocks in defineMark.ts / defineNode.ts teaching JS consumers how to type-hint. Replaced literal /** @typedef */ syntax with prose describing the pattern; preserves teaching value without triggering the scanner. Verified: - node check-jsdoc-hygiene-ts.cjs -> OK, 0 violations - node check-jsdoc-hygiene-ts-tests.cjs -> 13/13 pass - pnpm check:types -> PASS - pnpm check:public:superdoc --skip-build -> PASS (11 ran, 1 skipped, 135.3s) - pnpm --filter superdoc test --run -> PASS (1064/1064, 79 files) --- .../src/editors/v1/core/Editor.ts | 4 +- .../src/editors/v1/core/EventEmitter.ts | 7 +- .../src/editors/v1/core/Extension.ts | 5 +- .../super-editor/src/editors/v1/core/Mark.ts | 14 +-- .../super-editor/src/editors/v1/core/Node.ts | 14 +-- .../src/editors/v1/core/OxmlNode.ts | 16 ++-- .../src/editors/v1/core/defineMark.ts | 19 ++-- .../src/editors/v1/core/defineNode.ts | 19 ++-- .../core/helpers/getExtensionConfigField.ts | 3 +- .../presentation-editor/PresentationEditor.ts | 2 - .../scripts/jsdoc-hygiene-ts-baseline.json | 88 +------------------ packages/superdoc/src/core/EventEmitter.ts | 5 -- packages/superdoc/src/core/SuperDoc.ts | 87 +++++++++--------- packages/superdoc/src/core/types/index.ts | 4 +- 14 files changed, 96 insertions(+), 191 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 16e66dbd28..3e4e024364 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -216,8 +216,7 @@ const rangeIsTrackedInsertionOnly = (doc: PmNode, from: number, to: number): boo const getSingleTrackedInsertionMarkInRange = (doc: PmNode, from: number, to: number): PmMark | null => { if (!rangeIsTrackedInsertionOnly(doc, from, to)) return null; - /** @type {import('prosemirror-model').Mark[]} */ - const insertionMarks = []; + const insertionMarks: PmMark[] = []; const seenIds = new Set(); doc.nodesBetween(from, to, (node, pos) => { if (!node.isInline || !node.isLeaf) return; @@ -993,7 +992,6 @@ export class Editor extends EventEmitter { * This prevents race conditions and ensures the editor is always in a valid state, * even when operations fail. * - * @template T - The return type of the operation * @param during - State to set while the operation is running * @param success - State to set if the operation succeeds * @param failure - State to set if the operation fails diff --git a/packages/super-editor/src/editors/v1/core/EventEmitter.ts b/packages/super-editor/src/editors/v1/core/EventEmitter.ts index 63e1b630de..df613eb8b4 100644 --- a/packages/super-editor/src/editors/v1/core/EventEmitter.ts +++ b/packages/super-editor/src/editors/v1/core/EventEmitter.ts @@ -23,7 +23,8 @@ export type EventCallback = (...args: Args) /** * EventEmitter class is used to emit and subscribe to events. - * @template EventMap - Map of event names to their argument types + * + * @typeParam EventMap - Map of event names to their argument types. */ export class EventEmitter { #events = new Map(); @@ -32,7 +33,6 @@ export class EventEmitter { * Subscribe to the event. * @param name Event name. * @param fn Callback. - * @returns {void} */ on(name: K, fn: EventCallback): void { const callbacks = this.#events.get(name); @@ -44,7 +44,6 @@ export class EventEmitter { * Emit event. * @param name Event name. * @param args Arguments to pass to each listener. - * @returns {void} */ emit(name: K, ...args: EventMap[K]): void { const callbacks = this.#events.get(name); @@ -78,7 +77,6 @@ export class EventEmitter { * or all event subscriptions. * @param name Event name. * @param fn Callback. - * @returns {void} */ off(name: K, fn?: EventCallback): void { const callbacks = this.#events.get(name); @@ -94,7 +92,6 @@ export class EventEmitter { * Subscribe to an event that will be called only once. * @param name Event name. * @param fn Callback. - * @returns {void} */ once(name: K, fn: EventCallback): void { const wrapper = (...args: EventMap[K]) => { diff --git a/packages/super-editor/src/editors/v1/core/Extension.ts b/packages/super-editor/src/editors/v1/core/Extension.ts index 28f1d06962..95223fb595 100644 --- a/packages/super-editor/src/editors/v1/core/Extension.ts +++ b/packages/super-editor/src/editors/v1/core/Extension.ts @@ -4,6 +4,9 @@ import type { MaybeGetter } from './utilities/callOrGet.js'; /** * Base configuration for extensions. + * + * @typeParam Options - Type for extension options. + * @typeParam Storage - Type for extension storage. */ export interface ExtensionConfig< Options extends Record = Record, @@ -24,8 +27,6 @@ export interface ExtensionConfig< /** * Extension class is used to create extensions. - * @template Options - Type for extension options - * @template Storage - Type for extension storage */ export class Extension< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/Mark.ts b/packages/super-editor/src/editors/v1/core/Mark.ts index b6557a60ed..b9d2334af9 100644 --- a/packages/super-editor/src/editors/v1/core/Mark.ts +++ b/packages/super-editor/src/editors/v1/core/Mark.ts @@ -6,9 +6,10 @@ import type { AttributeSpec } from './Attribute.js'; /** * Configuration for Mark extensions. - * @template Options - Type for mark options - * @template Storage - Type for mark storage - * @template Attrs - Type for mark attributes (optional, enables typed addAttributes) + * + * @typeParam Options - Type for mark options. + * @typeParam Storage - Type for mark storage. + * @typeParam Attrs - Type for mark attributes (optional, enables typed addAttributes). */ export interface MarkConfig< Options extends Record = Record, @@ -39,9 +40,10 @@ export interface MarkConfig< /** * Mark class is used to create Mark extensions. - * @template Options - Type for mark options - * @template Storage - Type for mark storage - * @template Attrs - Type for mark attributes (enables typed attribute access) + * + * @typeParam Options - Type for mark options. + * @typeParam Storage - Type for mark storage. + * @typeParam Attrs - Type for mark attributes (enables typed attribute access). */ export class Mark< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/Node.ts b/packages/super-editor/src/editors/v1/core/Node.ts index 4e5fd164e9..0cb1e6c433 100644 --- a/packages/super-editor/src/editors/v1/core/Node.ts +++ b/packages/super-editor/src/editors/v1/core/Node.ts @@ -69,9 +69,10 @@ export type RenderDOMFn = (props: { /** * Configuration for Node extensions. - * @template Options - Type for node options - * @template Storage - Type for node storage - * @template Attrs - Type for node attributes (optional, enables typed addAttributes) + * + * @typeParam Options - Type for node options. + * @typeParam Storage - Type for node storage. + * @typeParam Attrs - Type for node attributes (optional, enables typed addAttributes). */ export interface NodeConfig< Options extends Record = Record, @@ -189,9 +190,10 @@ export interface NodeConfig< /** * Node class is used to create Node extensions. - * @template Options - Type for node options - * @template Storage - Type for node storage - * @template Attrs - Type for node attributes (enables typed attribute access) + * + * @typeParam Options - Type for node options. + * @typeParam Storage - Type for node storage. + * @typeParam Attrs - Type for node attributes (enables typed attribute access). */ export class Node< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/OxmlNode.ts b/packages/super-editor/src/editors/v1/core/OxmlNode.ts index 949042b9d5..0c86734c52 100644 --- a/packages/super-editor/src/editors/v1/core/OxmlNode.ts +++ b/packages/super-editor/src/editors/v1/core/OxmlNode.ts @@ -2,10 +2,11 @@ import { Node } from './Node.js'; import type { NodeConfig } from './Node.js'; /** - * Configuration for OXML Node extensions (extends NodeConfig) - * @template Options - Type for node options - * @template Storage - Type for node storage - * @template Attrs - Type for node attributes (optional, enables typed addAttributes) + * Configuration for OXML Node extensions (extends NodeConfig). + * + * @typeParam Options - Type for node options. + * @typeParam Storage - Type for node storage. + * @typeParam Attrs - Type for node attributes (optional, enables typed addAttributes). */ export interface OxmlNodeConfig< Options extends Record = Record, @@ -21,9 +22,10 @@ export interface OxmlNodeConfig< /** * OxmlNode class extends Node with OXML-specific properties. - * @template Options - Type for node options - * @template Storage - Type for node storage - * @template Attrs - Type for node attributes (enables typed attribute access) + * + * @typeParam Options - Type for node options. + * @typeParam Storage - Type for node storage. + * @typeParam Attrs - Type for node attributes (enables typed attribute access). */ export class OxmlNode< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/defineMark.ts b/packages/super-editor/src/editors/v1/core/defineMark.ts index f1ce80f3bf..89748581b1 100644 --- a/packages/super-editor/src/editors/v1/core/defineMark.ts +++ b/packages/super-editor/src/editors/v1/core/defineMark.ts @@ -29,13 +29,12 @@ * * @example * ```javascript - * // In a JavaScript file with JSDoc: + * // In a JavaScript file with JSDoc: alias the BoldAttrs type at the top + * // of the file via a standard `typedef` JSDoc block pointing at + * // '@extensions/types/mark-attributes.js', then defineMark picks up the + * // attribute shape from there. * import { defineMark } from '@core/defineMark.js'; * - * /** - * * @typedef {import('@extensions/types/mark-attributes.js').BoldAttrs} BoldAttrs - * *\/ - * * export const Bold = defineMark({ * name: 'bold', * // ... @@ -50,11 +49,11 @@ import { Mark, type MarkConfig } from './Mark.js'; /** * Type-safe factory for creating Mark extensions. * - * @template Options - Extension options type - * @template Storage - Extension storage type - * @template Attrs - Mark attributes type - * @param config - Mark configuration object - * @returns A new Mark instance with the specified types + * @typeParam Options - Extension options type. + * @typeParam Storage - Extension storage type. + * @typeParam Attrs - Mark attributes type. + * @param config - Mark configuration object. + * @returns A new Mark instance with the specified types. */ export function defineMark< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/defineNode.ts b/packages/super-editor/src/editors/v1/core/defineNode.ts index b81093481b..ed32750575 100644 --- a/packages/super-editor/src/editors/v1/core/defineNode.ts +++ b/packages/super-editor/src/editors/v1/core/defineNode.ts @@ -28,13 +28,12 @@ * * @example * ```javascript - * // In a JavaScript file with JSDoc: + * // In a JavaScript file with JSDoc: alias ParagraphAttrs at the top of + * // the file via a standard `typedef` JSDoc block pointing at + * // '@extensions/types/node-attributes.js', then defineNode picks up the + * // attribute shape from there. * import { defineNode } from '@core/defineNode.js'; * - * /** - * * @typedef {import('@extensions/types/node-attributes.js').ParagraphAttrs} ParagraphAttrs - * *\/ - * * export const Paragraph = defineNode({ * name: 'paragraph', * // ... @@ -49,11 +48,11 @@ import { Node, type NodeConfig } from './Node.js'; /** * Type-safe factory for creating Node extensions. * - * @template Options - Extension options type - * @template Storage - Extension storage type - * @template Attrs - Node attributes type - * @param config - Node configuration object - * @returns A new Node instance with the specified types + * @typeParam Options - Extension options type. + * @typeParam Storage - Extension storage type. + * @typeParam Attrs - Node attributes type. + * @param config - Node configuration object. + * @returns A new Node instance with the specified types. */ export function defineNode< Options extends Record = Record, diff --git a/packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts b/packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts index cf0565e604..1cac56e327 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts +++ b/packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts @@ -27,11 +27,12 @@ export interface ExtensionLike { /** * Get extension config field. * If the field is a function, it will be bound to the provided context. + * + * @typeParam T - The expected return type of the config field. * @param extension The Editor extension. * @param field The config field name. * @param context The context object to bind to function. * @returns The config field value or bound function. - * @template T The expected return type of the config field. */ export function getExtensionConfigField( extension: ExtensionLike, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 803e8d459e..a8f54c81fd 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4940,7 +4940,6 @@ export class PresentationEditor extends EventEmitter { * This method encapsulates the common focus and blur logic used when * selecting both inline and block images. * @private - * @returns {void} */ #focusEditorAfterImageSelection(): void { this.#shouldScrollSelectionIntoView = true; @@ -6918,7 +6917,6 @@ export class PresentationEditor extends EventEmitter { * This method is called after layout completes to ensure cursor positioning * is based on stable layout data. * - * @returns {void} * * @remarks * Edge cases handled: diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json index fadeef1522..a86c1abd54 100644 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -1,90 +1,4 @@ { "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file::enclosingSymbol::tagName::class::occurrenceIndex\"; line numbers are NOT part of the key, so the baseline survives unrelated edits that shift line numbers. The gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", - "knownViolations": [ - "packages/super-editor/src/editors/v1/core/Editor.ts::#withState::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts::off::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts::on::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts::once::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/defineMark.ts::::typedef::typedef-style::0", - "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/defineNode.ts::::typedef::typedef-style::0", - "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::1", - "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::2", - "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts::getExtensionConfigField::template::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#focusEditorAfterImageSelection::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#updateSelection::returns::declaration-doc-type::0", - "packages/superdoc/src/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", - "packages/superdoc/src/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", - "packages/superdoc/src/core/EventEmitter.ts::off::returns::declaration-doc-type::0", - "packages/superdoc/src/core/EventEmitter.ts::on::returns::declaration-doc-type::0", - "packages/superdoc/src/core/EventEmitter.ts::once::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#abortUpgrade::type::inline-fake-cast::0", - "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::#initCollaboration::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#log::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#patchNaiveUIStyles::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#requireSuperdocStore::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#resolveSourceEditor::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#triggerCollaborationSaves::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::#validateUpgradePrerequisites::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::extends::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::implements::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::addCommentsList::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::addSharedUser::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorBeforeCreate::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorCreate::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::event::type::inline-fake-cast::0", - "packages/superdoc/src/core/SuperDoc.ts::export::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::exportEditorsToDOCX::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::getHTML::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::getZoom::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::navigateTo::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::openSurface::template::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::pendingCollaborationSaves::type::inline-fake-cast::0", - "packages/superdoc/src/core/SuperDoc.ts::readyEditors::type::inline-fake-cast::0", - "packages/superdoc/src/core/SuperDoc.ts::removeSharedUser::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::requiredNumberOfEditors::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::search::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::search::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::setActiveEditor::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::setTrackedChangesPreferences::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::setZoom::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::upgradeToCollaboration::returns::declaration-doc-type::0", - "packages/superdoc/src/core/types/index.ts::InternalConfig::type::inline-fake-cast::0" - ] + "knownViolations": [] } diff --git a/packages/superdoc/src/core/EventEmitter.ts b/packages/superdoc/src/core/EventEmitter.ts index 6bce2438fb..1295087820 100644 --- a/packages/superdoc/src/core/EventEmitter.ts +++ b/packages/superdoc/src/core/EventEmitter.ts @@ -22,7 +22,6 @@ export type EventCallback = (...args: Args) /** * EventEmitter class is used to emit and subscribe to events. - * @template EventMap - Map of event names to their argument types */ export class EventEmitter { #events = new Map(); @@ -31,7 +30,6 @@ export class EventEmitter { * Subscribe to the event. * @param name Event name. * @param fn Callback. - * @returns {void} */ on(name: K, fn: EventCallback): void { const callbacks = this.#events.get(name); @@ -46,7 +44,6 @@ export class EventEmitter { * Emit event. * @param name Event name. * @param args Arguments to pass to each listener. - * @returns {void} */ emit(name: K, ...args: EventMap[K]): void { const callbacks = this.#events.get(name); @@ -61,7 +58,6 @@ export class EventEmitter { * or all event subscriptions. * @param name Event name. * @param fn Callback. - * @returns {void} */ off(name: K, fn?: EventCallback): void { const callbacks = this.#events.get(name); @@ -77,7 +73,6 @@ export class EventEmitter { * Subscribe to an event that will be called only once. * @param name Event name. * @param fn Callback. - * @returns {void} */ once(name: K, fn: EventCallback): void { const wrapper = (...args: EventMap[K]) => { diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 77834f1817..8e62fb5fc3 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -173,8 +173,6 @@ interface SuperDocEventMap { * Expects a config object * * @class - * @extends {EventEmitter} - * @implements {SuperDocLike} */ export class SuperDoc extends EventEmitter { static allowedTypes = [DOCX, PDF, HTML]; @@ -183,7 +181,7 @@ export class SuperDoc extends EventEmitter { #isUpgrading = false; - /** @type {(() => void) | null} — aborts an in-flight upgrade (sync wait or ready wait) */ + /** Aborts an in-flight upgrade (sync wait or ready wait). */ #abortUpgrade: (() => void) | null = null; #mountWrapper: HTMLDivElement | null = null; @@ -318,10 +316,10 @@ export class SuperDoc extends EventEmitter { /** Pinia store root for the SuperDoc Vue app. Set in `#initVueApp`. */ pinia: ReturnType['pinia'] | undefined; - /** @type {number} Count of editors that have signaled `editorCreate`. */ + /** Count of editors that have signaled `editorCreate`. */ readyEditors = 0; - /** @type {number} Outstanding async saves waiting for collaboration ack. */ + /** Outstanding async saves waiting for collaboration ack. */ pendingCollaborationSaves = 0; // ─── Runtime fields populated by `#init` ────────────────────────────── @@ -631,7 +629,7 @@ export class SuperDoc extends EventEmitter { /** * Get the number of editors that are required for this superdoc - * @returns {number} The number of required editors + * @returns The number of required editors */ get requiredNumberOfEditors() { return this.#requireSuperdocStore('requiredNumberOfEditors').documents.filter( @@ -698,7 +696,7 @@ export class SuperDoc extends EventEmitter { const cspNonce = this.config.cspNonce; const originalCreateElement = document.createElement; - /** @param {string} tagName */ + /** @param tagName */ document.createElement = function (tagName: string) { const element = originalCreateElement.call(this, tagName); if (tagName.toLowerCase() === 'style') { @@ -858,7 +856,7 @@ export class SuperDoc extends EventEmitter { * Initialize collaboration if configured. Accepts the full * `Config.modules` block so it can read both the collaboration * subkey and the comments subkey at once. - * @returns {Promise} The processed documents with collaboration enabled. Caller awaits for side effects; the return value is informational. + * @returns The processed documents with collaboration enabled. Caller awaits for side effects; the return value is informational. */ async #initCollaboration( { collaboration: collaborationModuleConfig, comments: commentsConfig = {} }: Modules = {} as Modules, @@ -1031,7 +1029,7 @@ export class SuperDoc extends EventEmitter { * - External `{ ydoc, provider }` collaboration * - Overwrite-and-upgrade only (no merge semantics) * - * @returns {Promise} Resolves once the collaborative runtime is ready + * @returns Resolves once the collaborative runtime is ready */ async upgradeToCollaboration({ ydoc, provider }: UpgradeToCollaborationOptions): Promise { this.#validateUpgradePrerequisites({ ydoc, provider }); @@ -1112,7 +1110,7 @@ export class SuperDoc extends EventEmitter { * "instance not yet ready" failure mode explicit instead of a * generic TypeError on `.documents`. * - * @param {string} methodName The public method name surfaced in + * @param methodName The public method name surfaced in * the error so consumers know which call needed the ready state. */ #requireSuperdocStore(methodName: string) { @@ -1328,7 +1326,7 @@ export class SuperDoc extends EventEmitter { * Validate that the instance is in a valid state for a collaboration upgrade. * Throws descriptive errors for each invalid condition. * - * @param {{ ydoc: unknown, provider: unknown }} options + * @param options */ #validateUpgradePrerequisites({ ydoc, provider }: UpgradeToCollaborationOptions) { if (this.#destroyed) { @@ -1360,7 +1358,7 @@ export class SuperDoc extends EventEmitter { /** * Resolve the source editor from the DOCX document entry. * - * @returns {Editor} The editor instance for the source document + * @returns The editor instance for the source document * @throws {Error} If the editor is not yet created */ #resolveSourceEditor() { @@ -1384,7 +1382,7 @@ export class SuperDoc extends EventEmitter { * ready; pre-ready mutations would be silently overwritten by the * `this.users = this.config.users || []` re-seed inside `#init`. * - * @param {User} user The user to add + * @param user The user to add */ addSharedUser(user: User) { this.#requireReady('addSharedUser'); @@ -1398,7 +1396,7 @@ export class SuperDoc extends EventEmitter { * to be ready for the same reason as `addSharedUser`. Accepts * either a user-like object or a legacy email string. * - * @param {User | string} userOrEmail The user or email of the user to remove + * @param userOrEmail The user or email of the user to remove */ removeSharedUser(userOrEmail: User | string) { this.#requireReady('removeSharedUser'); @@ -1427,9 +1425,9 @@ export class SuperDoc extends EventEmitter { // The errored editor came from `superdocStore.documents`, so the find // by its `documentId` is expected to hit. Cast the find result to a // RuntimeDocument to assert non-null at the consumer callback. - const doc = /** @type {RuntimeDocument} */ this.#requireSuperdocStore('onContentError').documents.find( + const doc = this.#requireSuperdocStore('onContentError').documents.find( (d: RuntimeDocument) => d.id === documentId, - ); + ) as RuntimeDocument; // `onContentError` is typed as optional on the public Config typedef // because consumers don't have to wire a handler. The class field // initializer installs a `() => null` default, but `#init` spreads @@ -1444,7 +1442,7 @@ export class SuperDoc extends EventEmitter { this.config.onContentError?.({ error, editor, - documentId: /** @type {string} */ doc.id, + documentId: doc.id as string, file: doc.data, }); } @@ -1467,7 +1465,7 @@ export class SuperDoc extends EventEmitter { /** * Triggered before an editor is created - * @param {Editor} editor The editor that is about to be created + * @param editor The editor that is about to be created */ broadcastEditorBeforeCreate(editor: Editor) { this.emit('editorBeforeCreate', { editor: createDeprecatedEditorProxy(editor) }); @@ -1475,7 +1473,7 @@ export class SuperDoc extends EventEmitter { /** * Triggered when an editor is created - * @param {Editor} editor The editor that was created + * @param editor The editor that was created */ broadcastEditorCreate(editor: Editor) { this.readyEditors++; @@ -1497,14 +1495,14 @@ export class SuperDoc extends EventEmitter { this.emit('sidebar-toggle', isOpened); } - /** @param {unknown[]} args */ + /** @param args */ #log(...args: unknown[]) { (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); } /** * Set the active editor - * @param {Editor} editor The editor to set as active + * @param editor The editor to set as active */ setActiveEditor(editor: Editor) { this.activeEditor = editor; @@ -1635,7 +1633,7 @@ export class SuperDoc extends EventEmitter { /** * Add a comments list to the superdoc * Requires the comments module to be enabled - * @param {HTMLElement} element The DOM element to render the comments list in + * @param element The DOM element to render the comments list in */ addCommentsList(element: HTMLElement) { if (!this.config?.modules?.comments || this.config.role === 'viewer') return; @@ -1658,9 +1656,9 @@ export class SuperDoc extends EventEmitter { /** * Scroll the document to a given comment by id. * - * @param {string} commentId The comment id - * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] - * @returns {boolean} Whether a matching element was found + * @param commentId The comment id + * @param [options] + * @returns Whether a matching element was found */ scrollToComment(commentId: string, options: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition } = {}) { const commentsConfig = this.config?.modules?.comments; @@ -1687,7 +1685,7 @@ export class SuperDoc extends EventEmitter { * Story-aware navigation is currently supported for bookmark and tracked * change targets. Block and comment targets are body-only. * - * @returns {Promise} Whether the target was found and navigated to. + * @returns Whether the target was found and navigated to. */ async navigateTo(target: NavigableAddress): Promise { const storeDocs = this.superdocStore?.documents; @@ -1704,8 +1702,8 @@ export class SuperDoc extends EventEmitter { * change entityId. The method resolves the element type automatically * and scrolls to it. * - * @param {string} elementId - The element's stable ID. - * @returns {Promise} Whether the element was found and scrolled to. + * @param elementId - The element's stable ID. + * @returns Whether the element was found and scrolled to. * * @example * // Navigate to a paragraph by its nodeId @@ -1817,8 +1815,8 @@ export class SuperDoc extends EventEmitter { /** * Set the document mode on a document's editor (PresentationEditor or Editor). * Tries PresentationEditor first, falls back to Editor for backward compatibility. - * @param {RuntimeDocument} doc - The document object - * @param {DocumentMode} mode - The document mode ('editing', 'viewing', 'suggesting') + * @param doc - The document object + * @param mode - The document mode ('editing', 'viewing', 'suggesting') */ #applyDocumentMode(doc: RuntimeDocument, mode: DocumentMode) { const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; @@ -1836,7 +1834,7 @@ export class SuperDoc extends EventEmitter { * Force PresentationEditor instances to render a specific tracked-changes mode * or disable tracked-change metadata entirely. * - * @param {{ mode?: 'review' | 'original' | 'final' | 'off', enabled?: boolean }} [preferences] + * @param [preferences] */ setTrackedChangesPreferences(preferences?: { mode?: 'review' | 'original' | 'final' | 'off'; enabled?: boolean }) { const normalized = preferences && Object.keys(preferences).length ? { ...preferences } : undefined; @@ -1956,8 +1954,8 @@ export class SuperDoc extends EventEmitter { * returns the array of matches the underlying search command produced * (possibly empty). * - * @param {string | RegExp} text The text or regex to search for - * @returns {import('./types/index.js').SearchMatch[] | undefined} The search results + * @param text The text or regex to search for + * @returns The search results */ search(text: string | RegExp): SearchMatch[] | undefined { return this.activeEditor?.commands.search(text, { searchModel: 'visible' }); @@ -1970,8 +1968,8 @@ export class SuperDoc extends EventEmitter { * runtime resolves its current document position via the embedded * tracker ids. * - * @param {import('./types/index.js').SearchMatch} match The match object returned by `superdoc.search()`. - * @returns {boolean | undefined} Whether the command dispatched, or `undefined` if no active editor. + * @param match The match object returned by `superdoc.search()`. + * @returns Whether the command dispatched, or `undefined` if no active editor. */ goToSearchResult(match: SearchMatch) { return this.activeEditor?.commands.goToSearchResult(match); @@ -1979,7 +1977,7 @@ export class SuperDoc extends EventEmitter { /** * Get the current zoom level as a percentage (e.g., 100 for 100%) - * @returns {number} The current zoom level as a percentage + * @returns The current zoom level as a percentage * @example * const zoom = superdoc.getZoom(); // Returns 100, 150, 200, etc. */ @@ -1991,7 +1989,7 @@ export class SuperDoc extends EventEmitter { * Set the zoom level for all documents. * Updates the centralized activeZoom state, which propagates to all * presentation editors, PDF viewers, and whiteboard layers via the Vue watcher. - * @param {number} percent - The zoom level as a percentage (e.g., 100, 150, 200) + * @param percent - The zoom level as a percentage (e.g., 100, 150, 200) * @example * superdoc.setZoom(150); // Set zoom to 150% * superdoc.setZoom(50); // Set zoom to 50% @@ -2031,7 +2029,7 @@ export class SuperDoc extends EventEmitter { /** * Get the HTML content of all editors - * @returns {Array} The HTML content of all editors + * @returns The HTML content of all editors */ getHTML(options: Parameters[0] = {}) { const editors: Editor[] = []; @@ -2048,8 +2046,8 @@ export class SuperDoc extends EventEmitter { /** * Lock the current superdoc and emit the `locked` event. * - * @param {boolean} [isLocked] Whether the superdoc is locked. Defaults to `false`. - * @param {User | null} [lockedBy] The user who locked the superdoc, or `null` + * @param [isLocked] Whether the superdoc is locked. Defaults to `false`. + * @param [lockedBy] The user who locked the superdoc, or `null` * when unlocking (or when no user is known). Defaults to `null`. */ lockSuperdoc(isLocked: boolean = false, lockedBy: User | null = null): void { @@ -2061,7 +2059,7 @@ export class SuperDoc extends EventEmitter { /** * Export the superdoc to a file - * @param {ExportParams} params - Export configuration + * @param params - Export configuration */ async export( { @@ -2111,7 +2109,7 @@ export class SuperDoc extends EventEmitter { /** * Export editors to DOCX format. - * @param {{ commentsType?: string, isFinalDoc?: boolean, fieldsHighlightColor?: string | null }} [options] + * @param [options] */ async exportEditorsToDOCX({ commentsType, @@ -2201,7 +2199,7 @@ export class SuperDoc extends EventEmitter { /** * Request an immediate save from all collaboration documents - * @returns {Promise} Resolves when all documents have saved + * @returns Resolves when all documents have saved */ async #triggerCollaborationSaves() { this.#log('🦋 [superdoc] Triggering collaboration saves'); @@ -2214,7 +2212,7 @@ export class SuperDoc extends EventEmitter { this.pendingCollaborationSaves++; this.#log(`After increment - Doc ${index}: pending = ${this.pendingCollaborationSaves}`); const metaMap = doc.ydoc.getMap('meta'); - metaMap.observe((/** @type {import('yjs').YMapEvent} */ event) => { + metaMap.observe((event: Y.YMapEvent) => { if (event.changes.keys.has('immediate-save-finished')) { this.pendingCollaborationSaves--; if (this.pendingCollaborationSaves <= 0) { @@ -2285,7 +2283,6 @@ export class SuperDoc extends EventEmitter { /** * Open a surface (dialog or floating) above the document content. * - * @template [TResult=unknown] */ openSurface(request: SurfaceRequest): SurfaceHandle { return this.#surfaceManager.open(request) as SurfaceHandle; diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 6674145a2b..cf3835b6a8 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1759,8 +1759,8 @@ export interface Config { * call sites cast `this.config` to this type so they can access these * invariants without per-site null guards. * - * Use this from internal SuperDoc callsites that need the augmented shape - * (e.g. `/** @type {InternalConfig} *\/ (this.config).socket = ...`). + * Use this from internal SuperDoc callsites that need the augmented + * shape, e.g. `(this.config as InternalConfig).socket = ...`. */ export interface InternalConfig extends Config { /** From 1c7285fa290bac126d931bfeb5ba3e07541fdb44 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 18:20:49 -0300 Subject: [PATCH 040/280] fix(scripts): flip jsdoc-hygiene-ts to strict-zero gate (SD-673) Removes the ratchet / baseline machinery from the jsdoc-hygiene-ts gate. The baseline was already drained to zero in the preceding cleanup PR; this PR makes re-introducing grandfathering impossible. Scanner changes (check-jsdoc-hygiene-ts.cjs): - Removed loadBaseline / writeBaseline / violationKey functions and the BASELINE_PATH constant. - main() now fails on allViolations.length > 0 instead of comparing against a baseline. - --write is explicitly rejected with exit 2 and a pointer to the policy doc, so contributors don't accidentally re-introduce grandfathering. The flag is well-known from the old ratchet phase; a hard rejection beats a silent no-op. - Failure message reworded to 'Zero allowed - fix each violation in place' instead of 'new type-bearing JSDoc tag(s) ... grandfathered'. - Status line drops the 'Grandfathered:' and 'Baseline at:' fields. - Dropped occurrenceIndex / occurrenceCounter: those existed to make the baseline key stable across line shifts. With strict-zero there's no key, so the counter is dead machinery. Failure messages only need (file, line, tag, class, symbol); sort is now by (file, line, tag) which is the natural display order. Deleted packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json. The file was empty ("knownViolations": []) but kept the ratchet alive as a concept. Removing it makes the strict-zero stance explicit on disk. Docs updated to remove ratchet / baseline language: - type-hygiene.md: replaced 'Refreshing the baseline' section with an 'Enforcement' section explaining strict zero and pointing at the fix patterns above. - packages/superdoc/scripts/README.md: scanner row now says 'Strict-zero gate (no grandfathered baseline, no --write)' and removes the refresh-with-`--write` instruction. - scripts/check-public-contract.mjs: stage header comment and blurb updated. Scanner self-tests unchanged - they test detector logic on in-memory fixtures, not baseline behavior. Verified: - node check-jsdoc-hygiene-ts.cjs -> OK, 0 violations - node check-jsdoc-hygiene-ts.cjs --write -> rejected, exit 2 - node check-jsdoc-hygiene-ts-tests.cjs -> 13/13 pass - Canary test (typed @param / @returns added to a new file) -> fails with exit 1 and the new 'Zero allowed' wording - pnpm check:types -> PASS - pnpm check:public:superdoc --skip-build -> PASS (11 ran, 1 skipped, 137.4s) --- packages/superdoc/scripts/README.md | 2 +- .../scripts/check-jsdoc-hygiene-ts.cjs | 164 +++++------------- .../scripts/jsdoc-hygiene-ts-baseline.json | 4 - packages/superdoc/scripts/type-hygiene.md | 19 +- scripts/check-public-contract.mjs | 15 +- 5 files changed, 61 insertions(+), 143 deletions(-) delete mode 100644 packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 77026c52e6..f27f6f0332 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -104,7 +104,7 @@ it stopped running. | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Derives the expected name set directly from the facade source file under `packages/superdoc/src/public/**`; rejects `export *` / `export * as X` in facade sources so the contract stays explicit. | Symbol set drift ships silently; CJS shims diverge from ESM; a wildcard re-export silently widens the public surface. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | | `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | -| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 5 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts-tests.cjs` (13 in-memory fixtures; name avoids the `*.test.*` glob so vitest doesn't pick it up as a unit-test suite). The self-test suite runs as wrapper stage 4 (`jsdoc-hygiene-ts-test`) immediately before this gate. Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | +| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 5 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Strict-zero gate (no grandfathered baseline, no `--write`): walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and fails on any type-bearing JSDoc tag — `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Self-tested via `check-jsdoc-hygiene-ts-tests.cjs` (13 in-memory fixtures; name avoids the `*.test.*` glob so vitest doesn't pick it up as a unit-test suite). The self-test suite runs as wrapper stage 4 (`jsdoc-hygiene-ts-test`) immediately before this gate. Policy at `type-hygiene.md`. Fix violations in place using the patterns there; `--write` was removed when the gate flipped to strict zero. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | The repo also has a top-level public-contract tier gate. One script, `scripts/report-public-contract.mjs`, with two modes: diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs index f634754ca3..6815537915 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -26,17 +26,9 @@ * - Every other JSDoc tag is ignored (allows `@deprecated`, * `@example`, `@throws`, `@see`, `@typeParam`, etc.). * - * Snapshot/ratchet pattern mirrors `check-jsdoc.cjs`: - * - * - Existing violations are recorded in - * `jsdoc-hygiene-ts-baseline.json`. New violations on top of the - * baseline fail. - * - Stale baseline entries (file removed, JSDoc cleaned up) also - * fail; rerun with --write to refresh. - * - * Refreshing the baseline: - * - * node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write + * Strict-zero gate. Every type-bearing JSDoc tag in scope is a + * violation; the script exits non-zero on any violation. There is no + * baseline, grandfathering, or --write mode. * * Scope: see `type-hygiene.md` § Scope. Excludes test files and * declaration files. @@ -49,7 +41,6 @@ const ts = require('typescript'); const scriptDir = __dirname; const repoRoot = path.resolve(scriptDir, '..', '..', '..'); -const BASELINE_PATH = path.join(scriptDir, 'jsdoc-hygiene-ts-baseline.json'); const POLICY_RELATIVE = 'packages/superdoc/scripts/type-hygiene.md'; const SCAN_ROOTS = ['packages/superdoc/src', 'packages/super-editor/src']; @@ -138,9 +129,9 @@ function renderName(nameNode) { /** * Walk up from a node to find the nearest named declaration ancestor. - * Used to anchor each violation to a stable enclosing-symbol name so - * the baseline key survives line shifts caused by edits elsewhere in - * the file. + * Used to render each violation's enclosing-symbol name in the + * failure message so a contributor can jump straight to the + * declaration that owns the bad JSDoc. * * Returns '' when no named ancestor is found (e.g. inline * `/** @type *​/` cast inside an anonymous expression at module scope). @@ -175,19 +166,13 @@ function enclosingSymbolName(node) { /** * Walk a source file's AST and emit one violation per type-bearing - * JSDoc tag. - * - * Each violation's identity is the stable tuple - * `{file, symbol, tag, occurrenceIndex}`. `line` is captured for - * human-readable display only; it is NOT part of the baseline key - * because line numbers shift under unrelated edits and would produce - * spurious stale-plus-new pairs on the ratchet. + * JSDoc tag. Returns findings with `{file, line, tag, class, symbol}` + * for display in the failure message. */ function findViolations(filePath, sourceText) { const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true); const findings = []; const seenPositions = new Set(); - const occurrenceCounter = new Map(); function visit(node) { const tags = ts.getJSDocTags(node); @@ -202,12 +187,6 @@ function findViolations(filePath, sourceText) { const positionKey = `${tag.pos}:${tag.end}:${name}`; if (seenPositions.has(positionKey)) continue; seenPositions.add(positionKey); - // Occurrence index within (symbol, tag) so multiple `@param`s - // on the same method are distinguished without depending on - // line numbers. - const counterKey = `${symbol}::${name}`; - const occurrenceIndex = occurrenceCounter.get(counterKey) || 0; - occurrenceCounter.set(counterKey, occurrenceIndex + 1); const { line } = sf.getLineAndCharacterOfPosition(tag.pos); findings.push({ file: filePath, @@ -215,7 +194,6 @@ function findViolations(filePath, sourceText) { tag: name, class: violation, symbol, - occurrenceIndex, }); } } @@ -225,9 +203,9 @@ function findViolations(filePath, sourceText) { findings.sort( (a, b) => - a.symbol.localeCompare(b.symbol) || - a.tag.localeCompare(b.tag) || - a.occurrenceIndex - b.occurrenceIndex, + a.file.localeCompare(b.file) || + a.line - b.line || + a.tag.localeCompare(b.tag), ); return findings; } @@ -243,47 +221,22 @@ function classifyTag(name, tag) { return null; } -// ─── Baseline serialization ─────────────────────────────────────────── - -function loadBaseline() { - if (!fs.existsSync(BASELINE_PATH)) { - return { $comment: '', knownViolations: [] }; - } - const raw = fs.readFileSync(BASELINE_PATH, 'utf8'); - try { - return JSON.parse(raw); - } catch (e) { - throw new Error(`Failed to parse baseline at ${BASELINE_PATH}: ${e.message}`); - } -} - -function violationKey(v) { - // Stable across line shifts and re-orderings of unrelated code. - // `line` is intentionally excluded: editing above a JSDoc block - // would otherwise produce one stale + one new entry for an - // unchanged violation, and the ratchet would be noisy. - return `${v.file}::${v.symbol}::${v.tag}::${v.class}::${v.occurrenceIndex}`; -} - -function writeBaseline(violations) { - const data = { - $comment: - 'Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. ' + - 'Each entry is "file::enclosingSymbol::tagName::class::occurrenceIndex"; ' + - 'line numbers are NOT part of the key, so the baseline survives unrelated ' + - 'edits that shift line numbers. The gate grandfathers these and fails on ' + - 'net-new violations. Refresh after intentional cleanup with --write. ' + - 'Goal is to drain to zero, then flip to "zero allowed" in a separate PR. ' + - 'See packages/superdoc/scripts/type-hygiene.md.', - knownViolations: violations.map(violationKey).sort(), - }; - fs.writeFileSync(BASELINE_PATH, JSON.stringify(data, null, 2) + '\n'); -} - // ─── Main ───────────────────────────────────────────────────────────── function main() { - const writeMode = process.argv.includes('--write'); + // --write was used during the ratchet phase to refresh the + // grandfathered baseline. Strict-zero mode rejects it loudly so + // contributors don't accidentally re-introduce grandfathering. + if (process.argv.includes('--write')) { + console.error( + '[jsdoc-hygiene-ts] --write is no longer supported. The gate is\n' + + 'strict zero — every type-bearing JSDoc tag in scope must be\n' + + 'fixed, not grandfathered. See ' + + POLICY_RELATIVE + + ' for fix patterns.', + ); + process.exit(2); + } const files = []; for (const root of SCAN_ROOTS) { @@ -299,68 +252,35 @@ function main() { allViolations.push(...violations); } - const currentKeys = new Set(allViolations.map(violationKey)); - const baseline = loadBaseline(); - const baselineKeys = new Set(baseline.knownViolations); - - const newViolations = allViolations.filter((v) => !baselineKeys.has(violationKey(v))); - const staleEntries = [...baselineKeys].filter((k) => !currentKeys.has(k)).sort(); - - if (writeMode) { - writeBaseline(allViolations); - console.log(`[jsdoc-hygiene-ts] wrote ${BASELINE_PATH} (${allViolations.length} entries).`); - return; - } - - // Report - const total = allViolations.length; - const grandfathered = total - newViolations.length; - console.log(`[jsdoc-hygiene-ts] type-bearing JSDoc scanner`); + console.log(`[jsdoc-hygiene-ts] type-bearing JSDoc scanner (strict zero)`); console.log('========================================================================'); console.log(`Scope: ${SCAN_ROOTS.join(', ')} (excludes test files / .d.ts)`); console.log(`Files scanned: ${files.length}`); - console.log(`Total violations: ${total}`); - console.log(`Grandfathered: ${grandfathered}`); - console.log(`Baseline at: ${path.relative(repoRoot, BASELINE_PATH)}`); + console.log(`Total violations: ${allViolations.length}`); - if (newViolations.length > 0 || staleEntries.length > 0) { + if (allViolations.length > 0) { console.log(''); - if (newViolations.length > 0) { - console.log(`FAIL ${newViolations.length} new type-bearing JSDoc tag(s) in .ts source:`); - for (const v of newViolations.slice(0, 30)) { - console.log(` - ${v.file}:${v.line} @${v.tag} on \`${v.symbol}\` [${v.class}]`); - } - if (newViolations.length > 30) { - console.log(` ... and ${newViolations.length - 30} more.`); - } - console.log(''); - console.log( - 'Type-bearing JSDoc is not allowed in .ts source under packages/superdoc/src\n' + - 'or packages/super-editor/src. Use TypeScript for shape (signatures, interfaces,\n' + - '`as Type` casts) and prose-only JSDoc for documentation. See ' + - POLICY_RELATIVE + - ' for the rule and fix patterns.\n', - ); + console.log(`FAIL ${allViolations.length} type-bearing JSDoc tag(s) in .ts source:`); + for (const v of allViolations.slice(0, 30)) { + console.log(` - ${v.file}:${v.line} @${v.tag} on \`${v.symbol}\` [${v.class}]`); } - if (staleEntries.length > 0) { - console.log(`FAIL ${staleEntries.length} stale baseline entry/entries (no longer violating):`); - for (const k of staleEntries.slice(0, 30)) { - console.log(` - ${k}`); - } - if (staleEntries.length > 30) { - console.log(` ... and ${staleEntries.length - 30} more.`); - } - console.log(''); - console.log( - 'These entries were cleaned up but the baseline still records them.\n' + - 'Run with --write to refresh the snapshot and lock in the win.\n', - ); + if (allViolations.length > 30) { + console.log(` ... and ${allViolations.length - 30} more.`); } + console.log(''); + console.log( + 'Type-bearing JSDoc is not allowed in .ts source under packages/superdoc/src\n' + + 'or packages/super-editor/src. Use TypeScript for shape (signatures, interfaces,\n' + + '`as Type` casts) and prose-only JSDoc for documentation. Zero allowed — fix\n' + + 'each violation in place. See ' + + POLICY_RELATIVE + + ' for the rule and fix patterns.\n', + ); process.exit(1); } console.log(''); - console.log(`OK ${total} violation(s) tracked as baseline; no net-new entries.`); + console.log(`OK zero type-bearing JSDoc in .ts source.`); } // Export the AST + classification helpers so the test runner can diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json deleted file mode 100644 index a86c1abd54..0000000000 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file::enclosingSymbol::tagName::class::occurrenceIndex\"; line numbers are NOT part of the key, so the baseline survives unrelated edits that shift line numbers. The gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", - "knownViolations": [] -} diff --git a/packages/superdoc/scripts/type-hygiene.md b/packages/superdoc/scripts/type-hygiene.md index 30e4dca7ac..88587ce8b9 100644 --- a/packages/superdoc/scripts/type-hygiene.md +++ b/packages/superdoc/scripts/type-hygiene.md @@ -164,18 +164,19 @@ type system via `// @ts-check`; that is enforced separately by `check-jsdoc.cjs`. Both gates can coexist: TS files use TS syntax for types, JS files use JSDoc for types, neither uses both. -## Refreshing the baseline +## Enforcement -The scanner snapshot lives at -`packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json`. It records -the existing violations the gate grandfathers. New violations on top -of the baseline fail CI. +Strict zero. Every type-bearing JSDoc tag in scope is a violation; +CI fails on any. There is no baseline, grandfathering, or `--write` +mode. -To refresh after intentional cleanup: +Run the gate locally with: ```sh -node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write +node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs ``` -The goal is to drain the baseline to zero, then flip the gate to -"zero allowed" (separate PR). +If it fires on a tag you can't easily fix, fix the tag (see the patterns +above) rather than reaching for a grandfathering escape hatch. The +gate's failure message links back to this doc so the fix path is +always one click away. diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index b7943ac07b..e3515b6242 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -43,9 +43,9 @@ * Companion to jsdoc-ratchet on the * .ts side: enforces TS syntax as * the single source of truth for - * shape. Grandfathers an existing - * baseline; fails on net-new - * type-bearing JSDoc. See + * shape. Strict-zero gate (no + * grandfathered baseline); fails + * on any type-bearing JSDoc. See * packages/superdoc/scripts/type-hygiene.md. * 6. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + @@ -173,11 +173,12 @@ const stages = [ args: ['packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs'], blurb: 'Type-bearing JSDoc gate for .ts source under packages/superdoc/src and ' + - 'packages/super-editor/src. Grandfathers an existing baseline of violations ' + - 'and fails on net-new type-bearing JSDoc tags (@param {T}, @returns {T}, ' + + 'packages/super-editor/src. Strict-zero gate (no grandfathered baseline, ' + + 'no --write): fails on any type-bearing JSDoc tag (@param {T}, @returns {T}, ' + '@type, @typedef, @template, etc.). See packages/superdoc/scripts/' + - 'type-hygiene.md for the rule. Cheap; complements jsdoc-ratchet (which ' + - 'covers .js files) by enforcing TS-as-single-source on the .ts side.', + 'type-hygiene.md for the rule and fix patterns. Cheap; complements ' + + 'jsdoc-ratchet (which covers .js files) by enforcing TS-as-single-source ' + + 'on the .ts side.', }, { name: 'public-method-coverage', From bb0e8e7643e5e6f6e07afdb666435df1f58f6f53 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 18:46:38 -0300 Subject: [PATCH 041/280] feat(examples): footnote tool agent New example at examples/ai/footnote-tool-agent/. Real LLM tool-calling against a live SuperDoc document: the user types a natural-language request, an OpenAI model picks the addFootnoteCitation tool, the browser executes it against editor.doc, and the document updates. The API key never leaves the server. The browser owns tool execution because editor.doc lives there. The server owns the model conversation, the tool JSON schema, and the key. Adding another tool is two edits: a handler entry in src/App.tsx and a schema entry in server.mjs TOOLS. Files in the order a reader would scan them: - src/tool.ts wraps selection.current + footnotes.insert + footnotes.list behind one addFootnoteCitation(api, { sourceText }) function with a typed receipt. Adapter exceptions are caught and translated into { ok: false, reason }. - src/agent.ts is the SDK-agnostic tool-use loop. runAgentTurn posts the user message, dispatches any tool_calls to local handlers, sends the results back, and emits events to the UI. Capped at six iterations. - src/App.tsx is the UI binding. It mounts SuperDoc, builds a handlers map with editor.doc bound in, calls runAgentTurn, and renders chat rows. - server.mjs proxies POST /api/turn to openai.chat.completions.create with strict mode and parallel_tool_calls disabled. The OpenAI client is lazy so the server boots without OPENAI_API_KEY and serves a friendly 500 on first request. Mirrors the sibling shape from examples/ai/streaming: same React + Vite + Node http stack, .env / .env.example pattern, concurrently in the dev script. Verified end-to-end with gpt-4o-mini against a temporary gitignored .env (deleted after): - User: 'Add a footnote citing Doe, Cloud Reliability Patterns, 2024.' - Chat: user bubble, used addFootnoteCitation ok, assistant 'The footnote has been added successfully.' - Document: superscript marker at the cursor position. - pnpm exec tsc --noEmit clean; pnpm exec vite build clean. - Missing-key boot path verified: GET /api/health 200, POST /api/turn returns the OPENAI_API_KEY-not-set error instead of crashing. --- examples/README.md | 1 + examples/ai/footnote-tool-agent/.env.example | 5 + examples/ai/footnote-tool-agent/.gitignore | 3 + examples/ai/footnote-tool-agent/README.md | 61 +++++ examples/ai/footnote-tool-agent/index.html | 16 ++ examples/ai/footnote-tool-agent/package.json | 27 +++ examples/ai/footnote-tool-agent/server.mjs | 120 ++++++++++ examples/ai/footnote-tool-agent/src/App.tsx | 220 ++++++++++++++++++ examples/ai/footnote-tool-agent/src/agent.ts | 87 +++++++ examples/ai/footnote-tool-agent/src/main.tsx | 10 + examples/ai/footnote-tool-agent/src/tool.ts | 55 +++++ examples/ai/footnote-tool-agent/tsconfig.json | 21 ++ .../ai/footnote-tool-agent/vite.config.ts | 14 ++ examples/manifest.json | 15 ++ pnpm-lock.yaml | 39 +++- 15 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 examples/ai/footnote-tool-agent/.env.example create mode 100644 examples/ai/footnote-tool-agent/.gitignore create mode 100644 examples/ai/footnote-tool-agent/README.md create mode 100644 examples/ai/footnote-tool-agent/index.html create mode 100644 examples/ai/footnote-tool-agent/package.json create mode 100644 examples/ai/footnote-tool-agent/server.mjs create mode 100644 examples/ai/footnote-tool-agent/src/App.tsx create mode 100644 examples/ai/footnote-tool-agent/src/agent.ts create mode 100644 examples/ai/footnote-tool-agent/src/main.tsx create mode 100644 examples/ai/footnote-tool-agent/src/tool.ts create mode 100644 examples/ai/footnote-tool-agent/tsconfig.json create mode 100644 examples/ai/footnote-tool-agent/vite.config.ts diff --git a/examples/README.md b/examples/README.md index d5fef41cf0..525e241510 100644 --- a/examples/README.md +++ b/examples/README.md @@ -119,6 +119,7 @@ Document editing through models and agents. | [bedrock](./ai/bedrock) | AWS Bedrock Converse API with tool use | | [streaming](./ai/streaming) | Stream model output into a visible editor | | [redlining](./ai/redlining) | LLM-driven tracked-change review (browser) | +| [footnote-tool-agent](./ai/footnote-tool-agent) | Real LLM tool-use loop: model picks `addFootnoteCitation`, browser executes against `editor.doc` | ## Advanced diff --git a/examples/ai/footnote-tool-agent/.env.example b/examples/ai/footnote-tool-agent/.env.example new file mode 100644 index 0000000000..fc9ce1aa30 --- /dev/null +++ b/examples/ai/footnote-tool-agent/.env.example @@ -0,0 +1,5 @@ +OPENAI_API_KEY=sk-... +# Optional. Defaults to gpt-4o-mini. +# OPENAI_MODEL=gpt-4o-mini +# Optional. Defaults to 8093. +# PORT=8093 diff --git a/examples/ai/footnote-tool-agent/.gitignore b/examples/ai/footnote-tool-agent/.gitignore new file mode 100644 index 0000000000..9c97bbd46d --- /dev/null +++ b/examples/ai/footnote-tool-agent/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/examples/ai/footnote-tool-agent/README.md b/examples/ai/footnote-tool-agent/README.md new file mode 100644 index 0000000000..4b9eb8daff --- /dev/null +++ b/examples/ai/footnote-tool-agent/README.md @@ -0,0 +1,61 @@ +# SuperDoc - Footnote tool agent + +Real LLM tool-calling against a live SuperDoc document. The user types a natural-language request; the model picks a tool; the browser executes it against the Document API; the document updates. The `OPENAI_API_KEY` never leaves the server. + +## Files, in the order you'd read them + +1. **`src/tool.ts`** — the Document API wrapper. One function: `addFootnoteCitation(api, { sourceText })`. Wraps `selection.current` + `footnotes.insert` + `footnotes.list` and returns a typed receipt. +2. **`src/agent.ts`** — the tool-use loop. `runAgentTurn` posts the user message, dispatches any `tool_calls` the model returns to local handlers, sends results back, and emits events to the UI. SDK-agnostic — speaks the Chat Completions message shape but doesn't import any provider SDK. +3. **`src/App.tsx`** — the UI. Mounts SuperDoc, captures the user prompt, binds `addFootnoteCitation` to the live `editor.doc` as a handler, calls `runAgentTurn`, renders chat rows. +4. **`server.mjs`** — the proxy. Declares the tool schema (`strict: true`, `parallel_tool_calls: false`), forwards turn requests to `openai.chat.completions.create`, returns the assistant message untouched. + +## Architecture + +``` +Browser ──POST /api/turn──▶ Node proxy ──▶ OpenAI + ▲ │ + │ message or ◀┘ + │ tool_calls + ▼ +editor.doc.* (tool execution lives here) + │ + └── POST /api/turn with the tool result, loop until the model returns text +``` + +| Surface | Owns | +| -------- | -------------------------------------- | +| Server | API key, model client, tool **schema** | +| Browser | Editor, Document API, tool **impl** | + +The browser owns tool execution because `editor.doc` lives there. The server has no editor. So the server runs the model conversation; the browser runs the document. + +## Adding more tools + +1. Add a handler in `src/App.tsx`'s `handlers` map. +2. Mirror the JSON schema in `server.mjs`'s `TOOLS` array. + +That's it. The loop and dispatch in `src/agent.ts` are tool-agnostic. + +## Run + +```bash +cp .env.example .env # then add your OPENAI_API_KEY +pnpm install +pnpm dev # Node proxy + Vite, run together +``` + +Open http://localhost:5181. Click into the paragraph, then send a message like: + +> Add a footnote citing Doe's 2024 cloud reliability paper. + +The chat shows: user → `used addFootnoteCitation · ok` → one-line assistant confirmation. The doc shows the superscript marker. + +## Notes + +- Non-streaming: each `/api/turn` call is request/response. For a streaming-token UX layered on top of tool calls, swap to the Responses API or SSE per-event delivery. +- Each `send` starts a fresh tool loop — prior turns are not preserved. For multi-turn conversations, lift `messages` into app state and append rather than replace. +- For production, add auth, rate limiting, a stricter iteration cap, and reject tool calls that aren't in the registry. + +## See also + +- [examples/ai/streaming](../streaming) — SSE token streaming into a document (no tool use). diff --git a/examples/ai/footnote-tool-agent/index.html b/examples/ai/footnote-tool-agent/index.html new file mode 100644 index 0000000000..8c5d071276 --- /dev/null +++ b/examples/ai/footnote-tool-agent/index.html @@ -0,0 +1,16 @@ + + + + + + SuperDoc - Footnote tool agent + + + +
+ + + diff --git a/examples/ai/footnote-tool-agent/package.json b/examples/ai/footnote-tool-agent/package.json new file mode 100644 index 0000000000..4436cf2af8 --- /dev/null +++ b/examples/ai/footnote-tool-agent/package.json @@ -0,0 +1,27 @@ +{ + "name": "@superdoc-examples/ai-footnote-tool-agent", + "private": true, + "type": "module", + "scripts": { + "predev": "pnpm --filter superdoc build", + "dev": "concurrently -k -n API,WEB -c blue,green \"node server.mjs\" \"vite --host 127.0.0.1 --port 5181 --strictPort\"", + "dev:server": "node server.mjs", + "dev:web": "vite", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "dotenv": "^17.4.2", + "openai": "^6.33.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "superdoc": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.1", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/ai/footnote-tool-agent/server.mjs b/examples/ai/footnote-tool-agent/server.mjs new file mode 100644 index 0000000000..36fd38122a --- /dev/null +++ b/examples/ai/footnote-tool-agent/server.mjs @@ -0,0 +1,120 @@ +import 'dotenv/config'; +import http from 'node:http'; +import OpenAI from 'openai'; + +const PORT = Number(process.env.PORT || 8093); +const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini'; + +// Tool schema the model is allowed to call. Mirror any change here in the +// browser's `handlers` map in src/App.tsx so the model's request can +// actually be executed. +const TOOLS = [ + { + type: 'function', + function: { + name: 'addFootnoteCitation', + description: + 'Insert a footnote at the current cursor position in the document. The footnote body is the provided source text. Word renders the superscript number.', + strict: true, + parameters: { + type: 'object', + properties: { + sourceText: { + type: 'string', + description: 'The full source citation text, e.g. "Doe, Cloud Reliability Patterns, 2024".', + }, + }, + required: ['sourceText'], + additionalProperties: false, + }, + }, + }, +]; + +// Lazy so the server can still boot and serve a friendly 500 from +// `handleTurn` when OPENAI_API_KEY is missing. Constructing at module +// scope would throw before the friendly path runs. +let _openai; +function getOpenAI() { + return _openai ?? (_openai = new OpenAI()); +} + +const server = http.createServer(async (req, res) => { + setCors(res); + if (req.method === 'OPTIONS') return res.writeHead(204).end(); + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + if (req.method === 'GET' && url.pathname === '/api/health') return sendJson(res, 200, { ok: true, model: MODEL }); + if (req.method === 'POST' && url.pathname === '/api/turn') return handleTurn(req, res); + sendJson(res, 404, { error: 'Not found' }); +}); + +server.listen(PORT, () => console.log(`[api] footnote tool agent on http://localhost:${PORT}`)); + +async function handleTurn(req, res) { + if (!process.env.OPENAI_API_KEY) { + return sendJson(res, 500, { error: 'OPENAI_API_KEY is not set. Copy .env.example to .env first.' }); + } + + let body; + try { body = await readJson(req); } + catch (err) { return sendJson(res, 400, { error: err.message }); } + + const messages = Array.isArray(body.messages) ? body.messages : null; + const system = typeof body.system === 'string' ? body.system : ''; + if (!messages) return sendJson(res, 400, { error: 'messages array is required' }); + + // Abort upstream if the client disconnects mid-call so we don't burn tokens. + const upstream = new AbortController(); + res.on('close', () => upstream.abort()); + + try { + const completion = await getOpenAI().chat.completions.create( + { + model: MODEL, + messages: system ? [{ role: 'system', content: system }, ...messages] : messages, + tools: TOOLS, + tool_choice: 'auto', + // One tool call per assistant turn keeps the demo lesson clean. + parallel_tool_calls: false, + }, + { signal: upstream.signal }, + ); + + const choice = completion.choices[0]; + sendJson(res, 200, { + type: 'message', + message: { + content: choice?.message?.content ?? null, + tool_calls: choice?.message?.tool_calls, + }, + }); + } catch (err) { + if (upstream.signal.aborted) return; + sendJson(res, 502, { error: err instanceof Error ? err.message : String(err) }); + } +} + +function setCors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +} + +async function readJson(req) { + const MAX = 256 * 1024; + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX) throw new Error('Request body too large'); + chunks.push(chunk); + } + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +function sendJson(res, status, payload) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +} diff --git a/examples/ai/footnote-tool-agent/src/App.tsx b/examples/ai/footnote-tool-agent/src/App.tsx new file mode 100644 index 0000000000..3989ad0e86 --- /dev/null +++ b/examples/ai/footnote-tool-agent/src/App.tsx @@ -0,0 +1,220 @@ +import { useEffect, useRef, useState } from 'react'; +import { SuperDoc } from 'superdoc'; +import type { DocumentApi } from 'superdoc'; +import { addFootnoteCitation } from './tool'; +import { runAgentTurn, type AssistantReply, type ChatMessage, type ToolHandler } from './agent'; + +const SYSTEM_PROMPT = `You are a writing assistant for a document the user is editing. +You have one tool, addFootnoteCitation, that inserts a footnote at the user's cursor. +When the user asks to cite a source or add a footnote, extract the source text from the request and call the tool. +Use only source details present in the user request. Do not invent authors, titles, publishers, or years. If the request is underspecified, pass the user's wording verbatim as sourceText. +If the tool returns ok: false, briefly tell the user the reason. If ok: true, confirm in one short sentence. +Do not narrate what you're about to do. Just call the tool, then summarize the result.`; + +const SEED = + '# Reliability brief\n\n' + + 'Cloud-native teams converged on a small set of reliability patterns over the past decade. ' + + 'Click into this paragraph to position the cursor, then ask the assistant to add a footnote citation.'; + +const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }; + +type ChatRow = + | { kind: 'user'; text: string } + | { kind: 'assistant'; text: string } + | { kind: 'tool'; name: string; ok: boolean; reason?: string }; + +export default function App() { + const [prompt, setPrompt] = useState('Add a footnote citing Doe, "Cloud Reliability Patterns," 2024.'); + const [running, setRunning] = useState(false); + const [editorReady, setEditorReady] = useState(false); + const [error, setError] = useState(null); + const [chat, setChat] = useState([]); + + const containerRef = useRef(null); + const superdocRef = useRef(null); + const abortRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + const sd = new SuperDoc({ + selector: containerRef.current, + documentMode: 'editing', + jsonOverride: EMPTY_DOC, + modules: { comments: false }, + telemetry: { enabled: false }, + onReady: ({ superdoc }) => { + const api = (superdoc as SuperDoc).activeEditor?.doc; + if (!api) return; + const cleared = api.clearContent({}); + if (!cleared.success && cleared.failure?.code !== 'NO_OP') return; + api.insert({ value: SEED, type: 'markdown' }); + setEditorReady(true); + }, + }); + superdocRef.current = sd; + return () => { + abortRef.current?.abort(); + sd.destroy(); + superdocRef.current = null; + }; + }, []); + + const send = async () => { + const api = superdocRef.current?.activeEditor?.doc; + if (!api || running || !prompt.trim()) return; + + const userText = prompt.trim(); + setPrompt(''); + setError(null); + setChat((c) => [...c, { kind: 'user', text: userText }]); + setRunning(true); + abortRef.current = new AbortController(); + + // Bind editor.doc once so handlers are plain (args) → result functions. + // Add more tools by appending entries to this object and mirroring + // their JSON schema in server.mjs TOOLS. + const handlers: Record = { + addFootnoteCitation: (args) => addFootnoteCitation(api, args as { sourceText: string }), + }; + + try { + await runAgentTurn({ + userText, + handlers, + postTurn: (messages, signal) => postTurn(messages, SYSTEM_PROMPT, signal), + onEvent: (event) => { + setChat((c) => + event.kind === 'assistant' + ? [...c, { kind: 'assistant', text: event.text }] + : [...c, { kind: 'tool', name: event.name, ok: event.result.ok, reason: event.result.ok ? undefined : event.result.reason }], + ); + }, + signal: abortRef.current.signal, + }); + } catch (err) { + if ((err as Error).name === 'AbortError') return; + setError((err as Error).message || String(err)); + } finally { + setRunning(false); + abortRef.current = null; + } + }; + + const stop = () => abortRef.current?.abort(); + + return ( +
+