diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index fc2312282f..60f5f76441 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -407,7 +407,7 @@ describe('superdoc CLI', () => { }); test('describe command paragraph format ops do not advertise text-range shortcuts', async () => { - const result = await runCli(['describe', 'command', 'doc.format.paragraph.setMarkRunProps', '--output', 'pretty']); + const result = await runCli(['describe', 'command', 'doc.format.paragraph.setAlignment', '--output', 'pretty']); expect(result.code).toBe(0); expect(result.stdout).toContain('--block-id'); expect(result.stdout).not.toContain('--start'); @@ -1696,30 +1696,6 @@ describe('superdoc CLI', () => { expect(await readDocxPart(footnotesOut, 'word/document.xml')).toContain('footnoteReference'); }); - test('paragraph mark run props surface CAPABILITY_UNAVAILABLE on the v1 runtime', async () => { - const formatSource = join(TEST_DIR, 'paragraph-mark-run-props-source.docx'); - const formatOut = join(TEST_DIR, 'paragraph-mark-run-props-out.docx'); - await copyFile(SAMPLE_DOC, formatSource); - - const target = await firstTextRange(['find', formatSource, '--type', 'text', '--pattern', 'Wilde']); - const result = await runCli([ - 'format', - 'paragraph', - 'set-mark-run-props', - formatSource, - '--block-id', - target.blockId, - '--mark-run-props-json', - '{"bold":true}', - '--out', - formatOut, - ]); - - expect(result.code).toBe(1); - const envelope = parseJsonOutput(result); - expect(envelope.error.code).toBe('CAPABILITY_UNAVAILABLE'); - }); - test('track-changes list is capability-aware', async () => { const result = await runCli(['track-changes', 'list', SAMPLE_DOC]); if (result.code === 0) { diff --git a/apps/cli/src/__tests__/lib/context.test.ts b/apps/cli/src/__tests__/lib/context.test.ts index a0a2b88495..557a06f65b 100644 --- a/apps/cli/src/__tests__/lib/context.test.ts +++ b/apps/cli/src/__tests__/lib/context.test.ts @@ -75,6 +75,28 @@ describe('normalizeContextMetadata', () => { }); }); + describe('runtime normalization', () => { + test('preserves the supported v1 runtime', () => { + const result = normalizeContextMetadata(makeMetadata({ runtime: 'v1' })); + expect(result.runtime).toBe('v1'); + }); + + test('defaults an absent runtime to v1', () => { + const result = normalizeContextMetadata(makeMetadata({ runtime: undefined as any })); + expect(result.runtime).toBe('v1'); + }); + + test('normalizes stale unsupported runtime metadata to v1', () => { + const metadata = makeMetadata({ runtime: 'legacy-runtime' as any }); + expect(normalizeContextMetadata(metadata).runtime).toBe('v1'); + }); + + test('normalizes any unknown runtime value to v1', () => { + const metadata = makeMetadata({ runtime: 'v3' as any }); + expect(normalizeContextMetadata(metadata).runtime).toBe('v1'); + }); + }); + describe('session type normalization', () => { test('normalizes unknown session type to local', () => { const metadata = makeMetadata({ sessionType: 'unknown' as any }); diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index 064118cf94..3cca4cde34 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -135,20 +135,17 @@ describe('mapInvokeError', () => { expect(canonical.code).toBe('TARGET_NOT_FOUND'); }); - test.each([ - 'blocks.split', - 'lists.apply', - 'format.paragraph.setMarkRunProps', - 'tables.moveRow', - 'fields.insert', - ] as const)('%s preserves CAPABILITY_UNAVAILABLE for unsupported v1/v2-gated paths', (operationId) => { - const error = Object.assign(new Error(`${operationId} is only available on v2-backed sessions.`), { - code: 'CAPABILITY_UNAVAILABLE', - }); + test.each(['fields.insert', 'footnotes.insert'] as const)( + '%s preserves CAPABILITY_UNAVAILABLE for adapter-gated paths', + (operationId) => { + const error = Object.assign(new Error(`${operationId} is not available on this adapter.`), { + code: 'CAPABILITY_UNAVAILABLE', + }); - const mapped = mapInvokeError(operationId as any, error); - expect(mapped.code).toBe('CAPABILITY_UNAVAILABLE'); - }); + const mapped = mapInvokeError(operationId as any, error); + expect(mapped.code).toBe('CAPABILITY_UNAVAILABLE'); + }, + ); }); // --------------------------------------------------------------------------- @@ -523,25 +520,22 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => { }, ); - test.each([ - 'blocks.split', - 'lists.apply', - 'format.paragraph.setMarkRunProps', - 'tables.moveRow', - 'footnotes.insert', - ] as const)('%s failed receipts preserve CAPABILITY_UNAVAILABLE', (operationId) => { - const receipt = { - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: `${operationId} is only available on v2-backed sessions.`, - }, - }; + test.each(['fields.insert', 'footnotes.insert'] as const)( + '%s failed receipts preserve CAPABILITY_UNAVAILABLE', + (operationId) => { + const receipt = { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: `${operationId} is not available on this adapter.`, + }, + }; - const result = mapFailedReceipt(operationId as any, receipt); - expect(result).toBeInstanceOf(CliError); - expect(result!.code).toBe('CAPABILITY_UNAVAILABLE'); - }); + const result = mapFailedReceipt(operationId as any, receipt); + expect(result).toBeInstanceOf(CliError); + expect(result!.code).toBe('CAPABILITY_UNAVAILABLE'); + }, + ); }); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/lib/invoke-input.test.ts b/apps/cli/src/__tests__/lib/invoke-input.test.ts index 177c4b9a70..c7f8e33a23 100644 --- a/apps/cli/src/__tests__/lib/invoke-input.test.ts +++ b/apps/cli/src/__tests__/lib/invoke-input.test.ts @@ -109,12 +109,9 @@ describe('extractInvokeInput', () => { }); test('converts paragraph format --block-id shortcuts into paragraph block targets', () => { - const input = extractInvokeInput('format.paragraph.setMarkRunProps', { + const input = extractInvokeInput('format.paragraph.setAlignment', { blockId: 'p1', - markRunProps: { - bold: true, - color: { model: 'rgb', value: 'FF0000' }, - }, + alignment: 'center', }) as Record; expect(input).toEqual({ @@ -123,22 +120,17 @@ describe('extractInvokeInput', () => { nodeType: 'paragraph', nodeId: 'p1', }, - markRunProps: { - bold: true, - color: { model: 'rgb', value: 'FF0000' }, - }, + alignment: 'center', }); }); test('rejects text-range shortcuts for paragraph format operations', () => { expect(() => - extractInvokeInput('format.paragraph.setMarkRunProps', { + extractInvokeInput('format.paragraph.setAlignment', { blockId: 'p1', start: 0, end: 5, - markRunProps: { - bold: true, - }, + alignment: 'center', }), ).toThrow(CliError); }); diff --git a/apps/cli/src/__tests__/lib/operation-args.test.ts b/apps/cli/src/__tests__/lib/operation-args.test.ts index 4623a5c915..1e7e04723f 100644 --- a/apps/cli/src/__tests__/lib/operation-args.test.ts +++ b/apps/cli/src/__tests__/lib/operation-args.test.ts @@ -5,11 +5,11 @@ import { parseOperationArgs } from '../../lib/operation-args'; describe('parseOperationArgs target validation', () => { test('rejects legacy text-address target-json payloads for paragraph format operations', () => { expect(() => - parseOperationArgs('doc.format.paragraph.setMarkRunProps', [ + parseOperationArgs('doc.format.paragraph.setAlignment', [ '--target-json', '{"kind":"text","blockId":"p1","range":{"start":0,"end":4}}', - '--mark-run-props-json', - '{"bold":true}', + '--alignment', + 'center', ]), ).toThrow(CliError); }); 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 b59740719c..f0dfbbecfe 100644 --- a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts +++ b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts @@ -104,6 +104,14 @@ describe('operation runtime metadata', () => { expect(trackChangesOption!.type).toBe('string'); }); + test('doc.open metadata does not expose a runtime selector', () => { + const openMeta = CLI_OPERATION_METADATA['doc.open']; + const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open']; + + expect(openMeta.params.map((p) => p.name)).not.toContain('runtime'); + expect(openOptions.map((o) => o.name)).not.toContain('runtime'); + }); + test('final recipe-provider parity operations expose their promoted CLI params', () => { const blocksListMeta = CLI_OPERATION_METADATA['doc.blocks.list']; expect(blocksListMeta.params.find((p) => p.name === 'in')?.flag).toBe('in-json'); @@ -118,12 +126,12 @@ describe('operation runtime metadata', () => { expect(fieldsInsertMeta.params.find((p) => p.name === 'cachedResultText')?.flag).toBe('cached-result-text'); expect(fieldsInsertMeta.params.find((p) => p.name === 'updatePolicy')?.flag).toBe('update-policy'); - const markRunMeta = CLI_OPERATION_METADATA['doc.format.paragraph.setMarkRunProps']; - expect(markRunMeta.params.find((p) => p.name === 'markRunProps')?.flag).toBe('mark-run-props-json'); + const alignmentMeta = CLI_OPERATION_METADATA['doc.format.paragraph.setAlignment']; + expect(alignmentMeta.params.find((p) => p.name === 'alignment')?.flag).toBe('alignment'); }); test('paragraph format operations expose block shortcuts without text-range flags', () => { - const paragraphFormatMeta = CLI_OPERATION_METADATA['doc.format.paragraph.setMarkRunProps']; + const paragraphFormatMeta = CLI_OPERATION_METADATA['doc.format.paragraph.setAlignment']; const paragraphParamNames = paragraphFormatMeta.params.map((p) => p.name); expect(paragraphParamNames).toContain('blockId'); diff --git a/apps/cli/src/__tests__/lib/v2-collaboration-runtime-boundaries.test.ts b/apps/cli/src/__tests__/lib/v2-collaboration-runtime-boundaries.test.ts deleted file mode 100644 index 8f636befea..0000000000 --- a/apps/cli/src/__tests__/lib/v2-collaboration-runtime-boundaries.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const V2_RUNTIME_PATH = join(import.meta.dir, '../../lib/collaboration/v2-runtime.ts'); - -describe('v2 collaboration runtime boundaries', () => { - test('stays as a public stub without V2 implementation or v1 collaboration runtime imports', () => { - const source = readFileSync(V2_RUNTIME_PATH, 'utf8'); - - expect(source).not.toMatch(/@superdoc\/collaboration-v2/); - expect(source).not.toMatch(/@superdoc\/headless/); - expect(source).not.toMatch(/from ['"]\.\/runtime['"]/); - expect(source).not.toMatch(/from ['"]superdoc\/super-editor['"]/); - expect(source).not.toMatch(/from ['"]@superdoc\/super-editor['"]/); - expect(source).not.toMatch(/y-prosemirror/); - expect(source).not.toMatch(/editors\/v1\/extensions\/collaboration/); - expect(source).not.toMatch(/document-api-adapters\//); - }); -}); diff --git a/apps/cli/src/__tests__/lib/v2-runtime-import-boundaries.test.ts b/apps/cli/src/__tests__/lib/v2-runtime-import-boundaries.test.ts deleted file mode 100644 index 3fee835f2e..0000000000 --- a/apps/cli/src/__tests__/lib/v2-runtime-import-boundaries.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const DOCUMENT_V2_PATH = join(import.meta.dir, '../../lib/document-v2.ts'); - -describe('v2 runtime import boundaries', () => { - test('document-v2 stays isolated from v1 runtime imports', () => { - const source = readFileSync(DOCUMENT_V2_PATH, 'utf8'); - - expect(source).not.toMatch(/from ['"]superdoc\/super-editor['"]/); - expect(source).not.toMatch(/from ['"]@superdoc\/super-editor['"]/); - expect(source).not.toMatch(/from ['"]superdoc\/super-editor\/blank-docx['"]/); - expect(source).not.toMatch(/import\s+\{[^}]*\}\s+from ['"]\.\/document\.js['"]/); - }); -}); diff --git a/apps/cli/src/__tests__/runtime-v2-open.test.ts b/apps/cli/src/__tests__/runtime-v2-open.test.ts deleted file mode 100644 index 1fb78031d8..0000000000 --- a/apps/cli/src/__tests__/runtime-v2-open.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { openV2CollaborativeDocument, openV2Document } from '../lib/document-v2'; -import { CliError } from '../lib/errors'; -import type { CliIO } from '../lib/types'; - -const io: CliIO = { - stdout() {}, - stderr() {}, - readStdinBytes: async () => new Uint8Array(), - now: () => 0, -}; - -async function expectV2Unavailable(action: () => Promise, feature: string): Promise { - try { - await action(); - throw new Error('Expected V2 runtime to be unavailable.'); - } catch (error) { - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).code).toBe('RUNTIME_V2_UNAVAILABLE'); - expect((error as CliError).details).toMatchObject({ runtime: 'v2', feature }); - } -} - -describe('CLI public v2 runtime stub', () => { - test('openV2Document fails with a named unavailable-runtime error', async () => { - await expectV2Unavailable(() => openV2Document(undefined, io), 'open'); - }); - - test('openV2CollaborativeDocument fails with a named unavailable-runtime error', async () => { - await expectV2Unavailable( - () => - openV2CollaborativeDocument(undefined, io, { - providerType: 'y-websocket', - url: 'ws://collab.example.test', - documentId: 'doc-1', - }), - 'collaborative-open', - ); - }); -}); diff --git a/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts b/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts index 56004a137d..c1d88a412d 100644 --- a/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts +++ b/apps/cli/src/cli/__tests__/schema-ref-resolution.test.ts @@ -245,7 +245,7 @@ describe('operation-params deriveParamsFromInputSchema with $ref', () => { }); describe('operation-params production metadata', () => { - test('trackChanges.decide exposes nested logical range targets used by v2 requirements', () => { + test('trackChanges.decide exposes nested logical range targets', () => { const targetParam = CLI_OPERATION_METADATA['doc.trackChanges.decide'].params.find( (param) => param.name === 'target', ); diff --git a/apps/cli/src/cli/cli-only-operation-definitions.ts b/apps/cli/src/cli/cli-only-operation-definitions.ts index 4a48231b41..22e0b72cb7 100644 --- a/apps/cli/src/cli/cli-only-operation-definitions.ts +++ b/apps/cli/src/cli/cli-only-operation-definitions.ts @@ -42,7 +42,7 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record> = ], // ── Paragraph Format ──────────────────────────────────────────────── - 'format.paragraph.setMarkRunProps': [ - 'superdoc format paragraph set-mark-run-props --block-id abc123 --mark-run-props-json \'{"bold":true,"italic":true,"color":{"model":"rgb","value":"FF0000"}}\'', - ], + 'format.paragraph.setAlignment': ['superdoc format paragraph set-alignment --block-id abc123 --alignment center'], }; diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index f0bd1685bc..1df621a37d 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -45,7 +45,6 @@ const PARAGRAPH_OPERATION_IDS = [ 'format.paragraph.clearBorder', 'format.paragraph.setShading', 'format.paragraph.clearShading', - 'format.paragraph.setMarkRunProps', 'format.paragraph.setNumbering', ] as const satisfies readonly CliExposedOperationId[]; @@ -90,9 +89,6 @@ export const SUCCESS_VERB: Record = { 'blocks.list': 'listed blocks', 'blocks.delete': 'deleted block', 'blocks.deleteRange': 'deleted block range', - 'blocks.split': 'split block', - 'blocks.merge': 'merged blocks', - 'blocks.move': 'moved block', formatRange: 'applied style', 'format.apply': 'applied style', ...buildFormatInlineAliasRecord('applied style'), @@ -104,9 +100,7 @@ export const SUCCESS_VERB: Record = { 'create.tableOfContents': 'created table of contents', 'lists.list': 'listed items', 'lists.get': 'resolved list item', - 'lists.getState': 'retrieved list state', 'lists.insert': 'inserted list item', - 'lists.apply': 'applied list', 'lists.indent': 'indented list item', 'lists.outdent': 'outdented list item', 'lists.create': 'created list', @@ -119,9 +113,6 @@ export const SUCCESS_VERB: Record = { 'lists.setValue': 'set list value', 'lists.continuePrevious': 'continued previous list', 'lists.canContinuePrevious': 'checked continue feasibility', - 'lists.continue': 'continued list', - 'lists.restart': 'restarted list', - 'lists.remove': 'removed list formatting', 'lists.setLevelRestart': 'set level restart', 'lists.applyTemplate': 'applied list template', 'lists.applyPreset': 'applied list preset', @@ -170,7 +161,6 @@ export const SUCCESS_VERB: Record = { 'tables.setLayout': 'updated table layout', 'tables.insertRow': 'inserted row', 'tables.deleteRow': 'deleted row', - 'tables.moveRow': 'moved row', 'tables.setRowHeight': 'set row height', 'tables.distributeRows': 'distributed rows', 'tables.setRowOptions': 'set row options', @@ -487,9 +477,6 @@ export const OUTPUT_FORMAT: Record = { 'blocks.list': 'plain', 'blocks.delete': 'plain', 'blocks.deleteRange': 'plain', - 'blocks.split': 'plain', - 'blocks.merge': 'plain', - 'blocks.move': 'plain', formatRange: 'mutationReceipt', 'format.apply': 'mutationReceipt', ...buildFormatInlineAliasRecord('mutationReceipt'), @@ -501,9 +488,7 @@ export const OUTPUT_FORMAT: Record = { 'create.tableOfContents': 'createResult', 'lists.list': 'listResult', 'lists.get': 'listItemInfo', - 'lists.getState': 'plain', 'lists.insert': 'listsMutationResult', - 'lists.apply': 'listsMutationResult', 'lists.indent': 'listsMutationResult', 'lists.outdent': 'listsMutationResult', 'lists.create': 'listsMutationResult', @@ -516,9 +501,6 @@ export const OUTPUT_FORMAT: Record = { 'lists.setValue': 'listsMutationResult', 'lists.continuePrevious': 'listsMutationResult', 'lists.canContinuePrevious': 'plain', - 'lists.continue': 'listsMutationResult', - 'lists.restart': 'listsMutationResult', - 'lists.remove': 'listsMutationResult', 'lists.setLevelRestart': 'listsMutationResult', 'lists.applyTemplate': 'listsMutationResult', 'lists.applyPreset': 'listsMutationResult', @@ -567,7 +549,6 @@ export const OUTPUT_FORMAT: Record = { 'tables.setLayout': 'tableMutationResult', 'tables.insertRow': 'tableMutationResult', 'tables.deleteRow': 'tableMutationResult', - 'tables.moveRow': 'tableMutationResult', 'tables.setRowHeight': 'tableMutationResult', 'tables.distributeRows': 'tableMutationResult', 'tables.setRowOptions': 'tableMutationResult', @@ -866,9 +847,6 @@ export const RESPONSE_ENVELOPE_KEY: Record 'blocks.list': 'result', 'blocks.delete': 'result', 'blocks.deleteRange': 'result', - 'blocks.split': 'result', - 'blocks.merge': 'result', - 'blocks.move': 'result', formatRange: null, 'format.apply': null, ...buildFormatInlineAliasRecord(null), @@ -880,9 +858,7 @@ export const RESPONSE_ENVELOPE_KEY: Record 'create.tableOfContents': 'result', 'lists.list': 'result', 'lists.get': 'item', - 'lists.getState': 'result', 'lists.insert': 'result', - 'lists.apply': 'result', 'lists.indent': 'result', 'lists.outdent': 'result', 'lists.create': 'result', @@ -895,9 +871,6 @@ export const RESPONSE_ENVELOPE_KEY: Record 'lists.setValue': 'result', 'lists.continuePrevious': 'result', 'lists.canContinuePrevious': 'result', - 'lists.continue': 'result', - 'lists.restart': 'result', - 'lists.remove': 'result', 'lists.setLevelRestart': 'result', 'lists.applyTemplate': 'result', 'lists.applyPreset': 'result', @@ -946,7 +919,6 @@ export const RESPONSE_ENVELOPE_KEY: Record 'tables.setLayout': 'result', 'tables.insertRow': 'result', 'tables.deleteRow': 'result', - 'tables.moveRow': 'result', 'tables.setRowHeight': 'result', 'tables.distributeRows': 'result', 'tables.setRowOptions': 'result', @@ -1278,9 +1250,6 @@ export const OPERATION_FAMILY: Record = 'blocks.list': 'blocks', 'blocks.delete': 'blocks', 'blocks.deleteRange': 'blocks', - 'blocks.split': 'blocks', - 'blocks.merge': 'blocks', - 'blocks.move': 'blocks', formatRange: 'textMutation', 'format.apply': 'textMutation', ...buildFormatInlineAliasRecord('textMutation'), @@ -1292,9 +1261,7 @@ export const OPERATION_FAMILY: Record = 'create.tableOfContents': 'create', 'lists.list': 'lists', 'lists.get': 'lists', - 'lists.getState': 'lists', 'lists.insert': 'lists', - 'lists.apply': 'lists', 'lists.indent': 'lists', 'lists.outdent': 'lists', 'lists.create': 'lists', @@ -1307,9 +1274,6 @@ export const OPERATION_FAMILY: Record = 'lists.setValue': 'lists', 'lists.continuePrevious': 'lists', 'lists.canContinuePrevious': 'lists', - 'lists.continue': 'lists', - 'lists.restart': 'lists', - 'lists.remove': 'lists', 'lists.setLevelRestart': 'lists', 'lists.applyTemplate': 'lists', 'lists.applyPreset': 'lists', @@ -1358,7 +1322,6 @@ export const OPERATION_FAMILY: Record = 'tables.setLayout': 'tables', 'tables.insertRow': 'tables', 'tables.deleteRow': 'tables', - 'tables.moveRow': 'tables', 'tables.setRowHeight': 'tables', 'tables.distributeRows': 'tables', 'tables.setRowOptions': 'tables', diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index b70e427264..06c3f4ff91 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -1004,13 +1004,12 @@ const CLI_ONLY_METADATA: Record = { oneOf: [ { type: 'object', - description: - 'WebSocket-based collaboration. `runtime: "v2"` supports only `y-websocket`; `hocuspocus` is legacy v1-only.', + description: 'WebSocket-based collaboration via `y-websocket` or `hocuspocus`.', properties: { providerType: { type: 'string', enum: ['y-websocket', 'hocuspocus'], - description: 'Collaboration provider. For `runtime: "v2"`, only `y-websocket` is supported.', + description: 'Collaboration provider.', }, url: { type: 'string', description: 'WebSocket server URL.' }, documentId: { @@ -1039,13 +1038,12 @@ const CLI_ONLY_METADATA: Record = { }, { type: 'object', - description: - 'Liveblocks collaboration with a public API key. Legacy v1-only; `runtime: "v2"` single-socket collaboration does not support Liveblocks.', + description: 'Liveblocks collaboration with a public API key.', properties: { providerType: { type: 'string', enum: ['liveblocks'], - description: 'Collaboration provider. Liveblocks is not supported on `runtime: "v2"`.', + description: 'Collaboration provider.', }, roomId: { type: 'string', description: 'Liveblocks room identifier.' }, publicApiKey: { type: 'string', description: 'Liveblocks public API key (pk_...).' }, @@ -1064,13 +1062,12 @@ const CLI_ONLY_METADATA: Record = { }, { type: 'object', - description: - 'Liveblocks collaboration with a custom auth endpoint. Legacy v1-only; `runtime: "v2"` single-socket collaboration does not support Liveblocks.', + description: 'Liveblocks collaboration with a custom auth endpoint.', properties: { providerType: { type: 'string', enum: ['liveblocks'], - description: 'Collaboration provider. Liveblocks is not supported on `runtime: "v2"`.', + description: 'Collaboration provider.', }, roomId: { type: 'string', description: 'Liveblocks room identifier.' }, authEndpoint: { type: 'string', description: 'Absolute URL of the auth endpoint.' }, @@ -1117,14 +1114,6 @@ 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: 'runtime', - kind: 'flag', - flag: 'runtime', - type: 'string', - schema: { type: 'string', enum: ['v1', 'v2'] } as CliTypeSpec, - description: "Runtime kind: 'v1' (default, legacy editor) or 'v2' (headless SD document session).", - }, USER_NAME_PARAM, USER_EMAIL_PARAM, PASSWORD_PARAM, diff --git a/apps/cli/src/commands/legacy-compat.ts b/apps/cli/src/commands/legacy-compat.ts index 781e793e0f..2a783b6fdd 100644 --- a/apps/cli/src/commands/legacy-compat.ts +++ b/apps/cli/src/commands/legacy-compat.ts @@ -373,11 +373,10 @@ export async function tryRunLegacyCompatCommand( } function assertV1Opened(opened: OpenedRuntimeDocument, label: string): OpenedDocument { - if (opened.runtime !== 'v1') { - throw new CliError('RUNTIME_V2_UNAVAILABLE', `${label}: this command is not available in the v2 runtime.`, { - runtime: opened.runtime, - command: label, - }); + // This branch is v1-only, so every opened document is editor-backed. Guard + // defensively against a runtime-neutral handle that lacks the v1 editor. + if (!('editor' in opened)) { + throw new CliError('COMMAND_FAILED', `${label}: expected a v1 editor-backed session.`); } return opened as OpenedDocument; } diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 662202635a..58e98dff85 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -16,7 +16,6 @@ import { writeContextMetadata, } from '../lib/context'; import { - loadV2Runtime, openCollaborativeDocument, openDocument, type OpenedRuntimeDocument, @@ -26,33 +25,11 @@ import { CliError } from '../lib/errors'; import { resolvePassword } from '../lib/open-password'; import { parseOperationArgs } from '../lib/operation-args'; import { generateSessionId } from '../lib/session'; -import { - ACCEPTED_RUNTIME_VALUES, - type CommandContext, - type CommandExecution, - type DocumentRuntimeKind, -} from '../lib/types'; +import { type CommandContext, type CommandExecution, type DocumentRuntimeKind } from '../lib/types'; const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']); const VALID_ON_MISSING = new Set(['seedFromDoc', 'blank', 'error']); -/** - * Normalize the `--runtime` value: accepts `'v1' | 'v2'`, treats absence as - * `'v1'`, and rejects everything else with the named INVALID_RUNTIME error - * code so consumers can tell client errors apart from engine errors. - */ -function validateRuntime(value: string | undefined, source: 'cli' | 'input' = 'cli'): DocumentRuntimeKind { - if (value == null || value.length === 0) return 'v1'; - if ((ACCEPTED_RUNTIME_VALUES as readonly string[]).includes(value)) { - return value as DocumentRuntimeKind; - } - throw new CliError( - 'INVALID_RUNTIME', - `open: --runtime must be one of: ${ACCEPTED_RUNTIME_VALUES.join(', ')}. Got "${value}".`, - { provided: value, acceptedValues: [...ACCEPTED_RUNTIME_VALUES], source }, - ); -} - function normalizeTrackChangesOpenConfig(value: unknown): EditorPassThroughOptions['trackChanges'] | undefined { if (value == null) return undefined; if (typeof value !== 'object' || Array.isArray(value)) { @@ -78,25 +55,6 @@ function normalizeTrackChangesOpenConfig(value: unknown): EditorPassThroughOptio }); } -function rejectUnsupportedV2CollaborationProvider( - runtime: DocumentRuntimeKind, - collaboration: ReturnType | undefined, -): void { - if (runtime !== 'v2' || !collaboration || collaboration.providerType === 'y-websocket') { - return; - } - - throw new CliError( - 'CAPABILITY_UNAVAILABLE', - `open: runtime v2 single-socket collaboration only supports the 'y-websocket' relay; provider '${collaboration.providerType}' is a legacy v1-only transport.`, - { - runtime, - providerType: collaboration.providerType, - documentId: collaboration.documentId, - }, - ); -} - export async function runOpen(tokens: string[], context: CommandContext): Promise { const { parsed, args, help } = parseOperationArgs('doc.open', tokens, { commandName: 'open', @@ -138,8 +96,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 runtimeArg = getStringOption(parsed, 'runtime'); - const runtime = validateRuntime(runtimeArg, context.argumentSource ?? 'cli'); + const runtime: DocumentRuntimeKind = 'v1'; const allowEnvFallback = context.executionMode !== 'host'; const password = resolvePassword(getStringOption(parsed, 'password'), allowEnvFallback); const trackChanges = normalizeTrackChangesOpenConfig(args.trackChanges); @@ -204,22 +161,6 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const collaboration = collaborationInput ? resolveCollaborationProfile(collaborationInput, sessionId) : undefined; const sessionType = collaboration ? 'collab' : 'local'; - rejectUnsupportedV2CollaborationProvider(runtime, collaboration); - - if (runtime === 'v2' && contentOverride != null) { - throw new CliError('RUNTIME_V2_UNAVAILABLE', 'open: --content-override is not available in the v2 runtime.', { - runtime: 'v2', - feature: 'content-override', - }); - } - - if (runtime === 'v2' && password != null) { - throw new CliError( - 'RUNTIME_V2_UNAVAILABLE', - 'open: encrypted DOCX inputs are not yet supported in the v2 runtime.', - { runtime: 'v2', feature: 'password' }, - ); - } if (!collaboration && (onMissing != null || bootstrapSettlingMs != null)) { throw new CliError( @@ -282,12 +223,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis } let opened: OpenedRuntimeDocument & { bootstrap?: unknown }; - if (runtime === 'v2') { - const v2Module = await loadV2Runtime(); - opened = collaboration - ? await v2Module.openV2CollaborativeDocument(doc, context.io, collaboration, { editorOpenOptions, user }) - : await v2Module.openV2Document(doc, context.io, { editorOpenOptions, user }); - } else if (collaboration) { + if (collaboration) { opened = await openCollaborativeDocument(doc, context.io, collaboration, { editorOpenOptions, user }); } else { opened = await openDocument(doc, context.io, { editorOpenOptions, user }); @@ -327,7 +263,6 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis if (context.executionMode === 'host' && context.sessionPool) { context.sessionPool.adoptFromOpen(sessionId, opened, { - runtime: metadata.runtime, sessionType: metadata.sessionType, workingDocPath: paths.workingDocPath, metadataRevision: metadata.revision, diff --git a/apps/cli/src/host/session-pool.test.ts b/apps/cli/src/host/session-pool.test.ts index 8f21f6a07f..b34bb77b59 100644 --- a/apps/cli/src/host/session-pool.test.ts +++ b/apps/cli/src/host/session-pool.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { InMemorySessionPool, type SessionPoolDeps } from './session-pool'; import type { OpenedDocument, OpenedRuntimeDocument } from '../lib/document'; -import type { CliIO, DocumentRuntimeKind } from '../lib/types'; +import type { CliIO } from '../lib/types'; // --------------------------------------------------------------------------- // Test helpers @@ -16,10 +16,7 @@ const TEST_IO: CliIO = { stderr: NOOP, }; -function createFakeOpened( - label = 'default', - runtime: DocumentRuntimeKind = 'v1', -): { +function createFakeOpened(label = 'default'): { opened: OpenedDocument; disposeCount: { count: number }; } { @@ -41,26 +38,6 @@ function createFakeOpened( }; } -function createFakeV2Opened(label = 'v2-default'): { - opened: OpenedRuntimeDocument; - disposeCount: { count: number }; -} { - const disposeCount = { count: 0 }; - return { - opened: { - runtime: 'v2', - meta: { source: 'path', path: `/tmp/${label}.docx`, byteLength: 1 }, - doc: { invoke: () => undefined }, - exportBytes: () => new Uint8Array(), - exportToPath: async (path) => ({ path, byteLength: 1 }), - dispose: () => { - disposeCount.count += 1; - }, - }, - disposeCount, - }; -} - function asV1Lease(opened: OpenedRuntimeDocument): OpenedDocument { return opened as OpenedDocument; } @@ -69,13 +46,11 @@ function createPool(overrides: SessionPoolDeps = {}): { pool: InMemorySessionPool; openLocalCalls: number[]; openCollabCalls: number[]; - openV2CollabCalls: number[]; openLocalOptions: Array<{ user?: unknown; editorOpenOptions?: unknown } | undefined>; openCollabOptions: Array<{ user?: unknown; editorOpenOptions?: unknown } | undefined>; } { const openLocalCalls: number[] = []; const openCollabCalls: number[] = []; - const openV2CollabCalls: number[] = []; const openLocalOptions: Array<{ user?: unknown; editorOpenOptions?: unknown } | undefined> = []; const openCollabOptions: Array<{ user?: unknown; editorOpenOptions?: unknown } | undefined> = []; @@ -90,10 +65,6 @@ function createPool(overrides: SessionPoolDeps = {}): { openCollabOptions.push(options); return createFakeOpened(`collab-${openCollabCalls.length}`).opened; }, - openV2Collaborative: async () => { - openV2CollabCalls.push(1); - return createFakeV2Opened(`v2-collab-${openV2CollabCalls.length}`).opened; - }, exportToPath: async (_opened, docPath) => ({ path: docPath, byteLength: 100 }), now: () => 1000, createTimer: overrides.createTimer ?? (() => 0 as unknown as ReturnType), @@ -101,11 +72,10 @@ function createPool(overrides: SessionPoolDeps = {}): { ...overrides, }); - return { pool, openLocalCalls, openCollabCalls, openV2CollabCalls, openLocalOptions, openCollabOptions }; + return { pool, openLocalCalls, openCollabCalls, openLocalOptions, openCollabOptions }; } const LOCAL_METADATA = { - runtime: 'v1' as const, sessionType: 'local' as const, workingDocPath: '/tmp/working.docx', metadataRevision: 1, @@ -118,22 +88,6 @@ const COLLAB_PROFILE = { }; const COLLAB_METADATA = { - runtime: 'v1' as const, - sessionType: 'collab' as const, - workingDocPath: '/tmp/working.docx', - metadataRevision: 1, - collaboration: COLLAB_PROFILE, -}; - -const V2_LOCAL_METADATA = { - runtime: 'v2' as const, - sessionType: 'local' as const, - workingDocPath: '/tmp/working.docx', - metadataRevision: 1, -}; - -const V2_COLLAB_METADATA = { - runtime: 'v2' as const, sessionType: 'collab' as const, workingDocPath: '/tmp/working.docx', metadataRevision: 1, @@ -267,65 +221,6 @@ describe('InMemorySessionPool', () => { expect(openLocalCalls.length).toBe(1); }); - - test('opens the v2 runtime for v2 local sessions', async () => { - const openV2Calls: number[] = []; - const pool = new InMemorySessionPool({ - openV2: async () => { - openV2Calls.push(1); - return createFakeV2Opened(`v2-${openV2Calls.length}`).opened; - }, - }); - - const opened = await pool.acquire('s1', V2_LOCAL_METADATA, TEST_IO); - - expect(openV2Calls.length).toBe(1); - expect(opened.runtime).toBe('v2'); - expect('editor' in opened).toBe(false); - }); - - test('opens the v2 collaboration runtime for v2 collaborative sessions', async () => { - const openV2CollabCalls: number[] = []; - const pool = new InMemorySessionPool({ - openV2Collaborative: async () => { - openV2CollabCalls.push(1); - return createFakeV2Opened(`v2-collab-${openV2CollabCalls.length}`).opened; - }, - }); - - const opened = await pool.acquire('s1', V2_COLLAB_METADATA, TEST_IO); - - expect(openV2CollabCalls.length).toBe(1); - expect(opened.runtime).toBe('v2'); - expect('editor' in opened).toBe(false); - }); - - test('reopens when pooled runtime does not match requested runtime', async () => { - const openLocalCalls: number[] = []; - const openV2Calls: number[] = []; - const first = createFakeOpened('runtime-drift-v1'); - const pool = new InMemorySessionPool({ - openLocal: async () => { - openLocalCalls.push(1); - return first.opened; - }, - openV2: async () => { - openV2Calls.push(1); - return createFakeV2Opened(`runtime-drift-v2-${openV2Calls.length}`).opened; - }, - }); - - const initial = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); - initial.dispose(); - - const reopened = await pool.acquire('s1', V2_LOCAL_METADATA, TEST_IO); - - expect(openLocalCalls.length).toBe(1); - expect(openV2Calls.length).toBe(1); - expect(first.disposeCount.count).toBe(1); - expect(reopened.runtime).toBe('v2'); - expect('editor' in reopened).toBe(false); - }); }); // ----------------------------------------------------------------------- @@ -500,7 +395,6 @@ describe('InMemorySessionPool', () => { const { opened } = createFakeOpened('adopted'); pool.adoptFromOpen('s1', opened, { - runtime: 'v1', sessionType: 'local', workingDocPath: '/tmp/working.docx', metadataRevision: 1, @@ -517,14 +411,12 @@ describe('InMemorySessionPool', () => { const { opened: second } = createFakeOpened('second'); pool.adoptFromOpen('s1', first, { - runtime: 'v1', sessionType: 'local', workingDocPath: '/tmp/working.docx', metadataRevision: 1, }); pool.adoptFromOpen('s1', second, { - runtime: 'v1', sessionType: 'local', workingDocPath: '/tmp/working.docx', metadataRevision: 1, @@ -535,20 +427,6 @@ describe('InMemorySessionPool', () => { const acquired = asV1Lease(await pool.acquire('s1', LOCAL_METADATA, TEST_IO)); expect(acquired.editor).toBe(second.editor); }); - - test('rejects adopted sessions whose runtime disagrees with metadata', () => { - const { pool } = createPool(); - const { opened } = createFakeV2Opened('bad-adopt'); - - expect(() => - pool.adoptFromOpen('s1', opened, { - runtime: 'v1', - sessionType: 'local', - workingDocPath: '/tmp/working.docx', - metadataRevision: 1, - }), - ).toThrow("opened runtime 'v2' does not match metadata runtime 'v1'"); - }); }); // ----------------------------------------------------------------------- @@ -561,7 +439,6 @@ describe('InMemorySessionPool', () => { const { opened: fake, disposeCount } = createFakeOpened('test'); pool.adoptFromOpen('s1', fake, { - runtime: 'v1', sessionType: 'local', workingDocPath: '/tmp/working.docx', metadataRevision: 1, @@ -714,7 +591,6 @@ describe('InMemorySessionPool', () => { }; const LIVEBLOCKS_METADATA = { - runtime: 'v1' as const, sessionType: 'collab' as const, workingDocPath: '/tmp/working.docx', metadataRevision: 1, diff --git a/apps/cli/src/host/session-pool.ts b/apps/cli/src/host/session-pool.ts index f743deebb2..5e84feb706 100644 --- a/apps/cli/src/host/session-pool.ts +++ b/apps/cli/src/host/session-pool.ts @@ -1,16 +1,14 @@ import { createHash } from 'node:crypto'; import type { CollaborationProfile } from '../lib/collaboration'; import { - loadV2Runtime, openCollaborativeDocument, openDocument, type EditorPassThroughOptions, type FileOutputMeta, type OpenedRuntimeDocument, } from '../lib/document'; -import { CliError } from '../lib/errors'; import type { TrackChangesOpenOptions } from '../lib/context'; -import type { CliIO, DocumentRuntimeKind, UserIdentity } from '../lib/types'; +import type { CliIO, UserIdentity } from '../lib/types'; // --------------------------------------------------------------------------- // Constants @@ -25,7 +23,6 @@ const AUTOSAVE_DEBOUNCE_MS = 3_000; export type SessionType = 'local' | 'collab'; export interface AcquireMetadata { - runtime: DocumentRuntimeKind; sessionType: SessionType; workingDocPath: string; metadataRevision: number; @@ -36,7 +33,6 @@ export interface AcquireMetadata { } export interface AdoptMetadata { - runtime: DocumentRuntimeKind; sessionType: SessionType; workingDocPath: string; metadataRevision: number; @@ -62,21 +58,9 @@ export interface SessionPoolDeps { profile: CollaborationProfile, options?: { user?: UserIdentity; editorOpenOptions?: EditorPassThroughOptions }, ) => Promise; - openV2?: ( - docPath: string, - io: CliIO, - options?: { user?: UserIdentity; editorOpenOptions?: EditorPassThroughOptions }, - ) => Promise; - openV2Collaborative?: ( - docPath: string | undefined, - io: CliIO, - profile: CollaborationProfile, - options?: { user?: UserIdentity; editorOpenOptions?: EditorPassThroughOptions }, - ) => Promise; /** - * Optional override for checkpointing. Receives the runtime-neutral opened - * document so v1 and v2 paths can share the contract. The default - * implementation calls {@link OpenedRuntimeDocument.exportToPath}. + * Optional override for checkpointing. Receives the opened document and + * defaults to {@link OpenedRuntimeDocument.exportToPath}. */ exportToPath?: (opened: OpenedRuntimeDocument, docPath: string, overwrite: boolean) => Promise; now?: () => number; @@ -158,7 +142,6 @@ function createSessionLocks(): { interface PooledSession { opened: OpenedRuntimeDocument; - runtime: DocumentRuntimeKind; sessionType: SessionType; dirty: boolean; leased: boolean; @@ -183,8 +166,6 @@ export class InMemorySessionPool implements SessionPool { private readonly openLocal: NonNullable; private readonly openCollaborative: NonNullable; - private readonly openV2: NonNullable; - private readonly openV2Collaborative: NonNullable; private readonly exportToPathFn: NonNullable; private readonly now: () => number; private readonly createTimer: NonNullable; @@ -193,18 +174,6 @@ export class InMemorySessionPool implements SessionPool { constructor(deps: SessionPoolDeps = {}) { this.openLocal = deps.openLocal ?? openDocument; this.openCollaborative = deps.openCollaborative ?? openCollaborativeDocument; - this.openV2 = - deps.openV2 ?? - (async (docPath, io, options) => { - const mod = await loadV2Runtime(); - return mod.openV2Document(docPath, io, options); - }); - this.openV2Collaborative = - deps.openV2Collaborative ?? - (async (docPath, io, profile, options) => { - const mod = await loadV2Runtime(); - return mod.openV2CollaborativeDocument(docPath, io, profile, options); - }); this.exportToPathFn = deps.exportToPath ?? ((opened, docPath, overwrite) => opened.exportToPath(docPath, overwrite)); this.now = deps.now ?? Date.now; @@ -244,14 +213,6 @@ export class InMemorySessionPool implements SessionPool { // ------------------------------------------------------------------------- adoptFromOpen(sessionId: string, opened: OpenedRuntimeDocument, metadata: AdoptMetadata): void { - if (opened.runtime !== metadata.runtime) { - throw new CliError( - 'RUNTIME_MISMATCH', - `Session pool adopt: opened runtime '${opened.runtime}' does not match metadata runtime '${metadata.runtime}'.`, - { sessionId, openedRuntime: opened.runtime, metadataRuntime: metadata.runtime }, - ); - } - const existing = this.sessions.get(sessionId); if (existing) { this.clearAutosaveTimer(existing); @@ -260,7 +221,6 @@ export class InMemorySessionPool implements SessionPool { this.sessions.set(sessionId, { opened, - runtime: metadata.runtime, sessionType: metadata.sessionType, dirty: false, leased: false, @@ -370,11 +330,6 @@ export class InMemorySessionPool implements SessionPool { // ------------------------------------------------------------------------- private isSessionValid(session: PooledSession, metadata: AcquireMetadata): boolean { - // Runtime drift always invalidates: a v1-pooled session cannot honor a - // v2 acquire request, and vice versa. Mismatched sessions are destroyed - // and reopened in {@link acquire}. - if (session.runtime !== metadata.runtime) return false; - if (session.sessionType === 'collab') { const incomingFingerprint = metadata.collaboration ? profileToFingerprint(metadata.collaboration) : undefined; return session.fingerprint === incomingFingerprint && session.workingDocPath === metadata.workingDocPath; @@ -392,19 +347,7 @@ export class InMemorySessionPool implements SessionPool { modules: { trackChanges: metadata.trackChanges }, } : undefined; - if (metadata.runtime === 'v2') { - if (metadata.sessionType === 'collab') { - if (!metadata.collaboration) { - throw new CliError('COMMAND_FAILED', 'Session is marked as collaborative but has no collaboration profile.'); - } - opened = await this.openV2Collaborative(metadata.workingDocPath, io, metadata.collaboration, { - user: metadata.user, - editorOpenOptions, - }); - } else { - opened = await this.openV2(metadata.workingDocPath, io, { user: metadata.user, editorOpenOptions }); - } - } else if (metadata.sessionType === 'collab' && metadata.collaboration) { + if (metadata.sessionType === 'collab' && metadata.collaboration) { opened = await this.openCollaborative(metadata.workingDocPath, io, metadata.collaboration, { user: metadata.user, editorOpenOptions, @@ -415,7 +358,6 @@ export class InMemorySessionPool implements SessionPool { return { opened, - runtime: metadata.runtime, sessionType: metadata.sessionType, dirty: false, leased: true, @@ -433,7 +375,7 @@ export class InMemorySessionPool implements SessionPool { private createLease(sessionId: string, session: PooledSession): OpenedRuntimeDocument { const pooled = session.opened; const lease: OpenedRuntimeDocument = { - runtime: pooled.runtime, + runtime: 'v1', doc: pooled.doc, meta: pooled.meta, exportBytes: (options) => pooled.exportBytes(options), @@ -445,7 +387,6 @@ export class InMemorySessionPool implements SessionPool { }; // Preserve the v1 `editor` reference on the lease when present so v1-only // code paths (collab sync helper, headless comment bridge) keep working. - // v2 leases never carry an editor. if (pooled.runtime === 'v1' && 'editor' in pooled) { (lease as { editor?: unknown }).editor = (pooled as unknown as { editor: unknown }).editor; } diff --git a/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts b/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts index 21e7d8cf17..8c890c653a 100644 --- a/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts +++ b/apps/cli/src/lib/__tests__/headless-comment-bridge.test.ts @@ -35,20 +35,20 @@ describe('Yjs write helpers', () => { it('updateYComment replaces existing comment by id', () => { const { ydoc, yArray } = createYEnv(); - addYComment(yArray, ydoc, { commentId: 'c1', text: 'v1' }); - updateYComment(yArray, ydoc, { commentId: 'c1', text: 'v2' }); + addYComment(yArray, ydoc, { commentId: 'c1', text: 'initial' }); + updateYComment(yArray, ydoc, { commentId: 'c1', text: 'updated' }); const arr = yArrayToJSON(yArray); expect(arr).toHaveLength(1); - expect(arr[0].text).toBe('v2'); + expect(arr[0].text).toBe('updated'); }); it('updateYComment is a no-op for unknown id', () => { const { ydoc, yArray } = createYEnv(); - addYComment(yArray, ydoc, { commentId: 'c1', text: 'v1' }); - updateYComment(yArray, ydoc, { commentId: 'c999', text: 'v2' }); + addYComment(yArray, ydoc, { commentId: 'c1', text: 'initial' }); + updateYComment(yArray, ydoc, { commentId: 'c999', text: 'updated' }); const arr = yArrayToJSON(yArray); expect(arr).toHaveLength(1); - expect(arr[0].text).toBe('v1'); + expect(arr[0].text).toBe('initial'); }); it('deleteYComment removes comment from array', () => { @@ -172,12 +172,12 @@ describe('buildHeadlessCommentBridge', () => { type: 'trackedChange', event: 'update', changeId: 'tc-1', - trackedChangeText: 'v2', + trackedChangeText: 'updated', }); const arr = yArrayToJSON(yArray); expect(arr).toHaveLength(1); - expect(arr[0].trackedChangeText).toBe('v2'); + expect(arr[0].trackedChangeText).toBe('updated'); }); it('falls back to Yjs for tracked-change updates when registry misses the id', () => { @@ -187,7 +187,7 @@ describe('buildHeadlessCommentBridge', () => { Object.entries({ commentId: 'tc-late', trackedChange: true, - trackedChangeText: 'v1', + trackedChangeText: 'initial', trackedChangeType: 'trackInsert', creatorName: 'Remote Author', }), @@ -198,13 +198,13 @@ describe('buildHeadlessCommentBridge', () => { type: 'trackedChange', event: 'update', changeId: 'tc-late', - trackedChangeText: 'v2', + trackedChangeText: 'updated', trackedChangeType: 'trackInsert', }); const arr = yArrayToJSON(yArray); expect(arr).toHaveLength(1); - expect(arr[0].trackedChangeText).toBe('v2'); + expect(arr[0].trackedChangeText).toBe('updated'); // Sparse updates should not clobber existing metadata. expect(arr[0].creatorName).toBe('Remote Author'); }); @@ -355,16 +355,16 @@ describe('buildHeadlessCommentBridge', () => { it('updates standard comment in yArray', () => { bridge.editorOptions.onCommentsUpdate({ type: 'add', - comment: { commentId: 'c-1', text: 'v1' }, + comment: { commentId: 'c-1', text: 'initial' }, }); bridge.editorOptions.onCommentsUpdate({ type: 'update', - comment: { commentId: 'c-1', text: 'v2' }, + comment: { commentId: 'c-1', text: 'updated' }, }); const arr = yArrayToJSON(yArray); expect(arr).toHaveLength(1); - expect(arr[0].text).toBe('v2'); + expect(arr[0].text).toBe('updated'); }); it('deletes standard comment from yArray', () => { diff --git a/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts b/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts index 5f7569b8c2..04f9a501c9 100644 --- a/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts +++ b/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts @@ -173,7 +173,7 @@ describeSharedYDoc('SD-3214: headless SDK reads browser-authored comment metadat new YMap( Object.entries({ commentId: 'c-update', - commentText: 'v2', + commentText: 'updated', creatorName: 'Browser User', creatorEmail: 'browser@example.com', createdTime: 1700000002000, @@ -188,7 +188,7 @@ describeSharedYDoc('SD-3214: headless SDK reads browser-authored comment metadat opened.dispose(); const item = result.items.find((c) => c.id === 'c-update'); - expect(item?.text).toBe('v2'); + expect(item?.text).toBe('updated'); }); }); diff --git a/apps/cli/src/lib/collaboration/__tests__/v2-runtime.test.ts b/apps/cli/src/lib/collaboration/__tests__/v2-runtime.test.ts deleted file mode 100644 index b062a45b66..0000000000 --- a/apps/cli/src/lib/collaboration/__tests__/v2-runtime.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { createCliV2SingleDocCollaborationRuntime } from '..'; -import { CliError } from '../../errors'; - -describe('createCliV2SingleDocCollaborationRuntime', () => { - test('fails closed while the public CLI does not bundle the V2 runtime', () => { - try { - createCliV2SingleDocCollaborationRuntime({ - providerType: 'y-websocket', - url: 'ws://collab.example.test', - documentId: 'doc-1', - }); - throw new Error('Expected V2 collaboration runtime to be unavailable.'); - } catch (error) { - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).code).toBe('RUNTIME_V2_UNAVAILABLE'); - expect((error as CliError).details).toMatchObject({ runtime: 'v2', feature: 'collaboration' }); - } - }); -}); diff --git a/apps/cli/src/lib/collaboration/index.ts b/apps/cli/src/lib/collaboration/index.ts index a511bd7675..a0e83b9e69 100644 --- a/apps/cli/src/lib/collaboration/index.ts +++ b/apps/cli/src/lib/collaboration/index.ts @@ -29,4 +29,3 @@ export { export { resolveCollaborationProfile, resolveCollaborationToken, toPublicCollaborationSummary } from './resolve'; export { createCollaborationRuntime } from './runtime'; -export { createCliV2SingleDocCollaborationRuntime, type CliV2CollaborationRuntime } from './v2-runtime'; diff --git a/apps/cli/src/lib/collaboration/v2-runtime.ts b/apps/cli/src/lib/collaboration/v2-runtime.ts deleted file mode 100644 index 05820871d7..0000000000 --- a/apps/cli/src/lib/collaboration/v2-runtime.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Public CLI V2 collaboration runtime stub. - * - * Real V2 collaboration is supplied by an external V2 runtime package, not by - * `superdoc/public`. This connection point remains so callers receive a - * deterministic capability error when they request V2 collaboration before - * that package is available. - */ - -import { CliError } from '../errors.js'; -import type { CollaborationProfile } from './types'; - -export type CliV2CollaborationRuntime = never; - -export function createCliV2SingleDocCollaborationRuntime(_profile: CollaborationProfile): CliV2CollaborationRuntime { - throw new CliError( - 'RUNTIME_V2_UNAVAILABLE', - 'The public CLI does not bundle the V2 collaboration runtime yet. Install and provide a V2 runtime package when one is available.', - { runtime: 'v2', feature: 'collaboration' }, - ); -} diff --git a/apps/cli/src/lib/context.ts b/apps/cli/src/lib/context.ts index a75b241aab..df05b7f0bc 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -8,13 +8,7 @@ import { ENV_VAR_NAME_PATTERN, type CollaborationProfile } from './collaboration import { CliError } from './errors'; import { asRecord, isRecord, pathExists } from './guards'; import { validateSessionId } from './session'; -import { - ACCEPTED_RUNTIME_VALUES, - type CliIO, - type DocumentRuntimeKind, - type ExecutionMode, - type UserIdentity, -} from './types'; +import { type CliIO, type DocumentRuntimeKind, type ExecutionMode, type UserIdentity } from './types'; const CONTEXT_VERSION = 'v1'; const ACTIVE_SESSION_FILENAME = 'active-session'; @@ -158,9 +152,9 @@ function normalizeSessionType(value: unknown): SessionType { } function normalizeRuntime(value: unknown): DocumentRuntimeKind { - if (typeof value === 'string' && (ACCEPTED_RUNTIME_VALUES as readonly string[]).includes(value)) { - return value as DocumentRuntimeKind; - } + // Session metadata may outlive older runtime labels, but this CLI no longer + // offers runtime selection. Rehydrate every session as v1. + void value; return 'v1'; } diff --git a/apps/cli/src/lib/document-v2.ts b/apps/cli/src/lib/document-v2.ts deleted file mode 100644 index 01e11692e9..0000000000 --- a/apps/cli/src/lib/document-v2.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Public CLI V2 runtime stub. - * - * The OSS/public CLI does not bundle the V2 document engine yet. Keep this - * file as the stable runtime connection point so `--runtime v2` fails with a - * named error instead of resolving unpublished implementation packages. - */ - -import type { CollaborationProfile } from './collaboration'; -import type { EditorPassThroughOptions, OpenedRuntimeDocument } from './document.js'; -import { CliError } from './errors.js'; -import type { CliIO, UserIdentity } from './types.js'; - -export interface OpenV2DocumentOptions { - /** User identity recorded as author on the v2 session. */ - user?: UserIdentity; - /** Editor-level pass-throughs preserved for the future V2 adapter. */ - editorOpenOptions?: EditorPassThroughOptions; -} - -function throwV2Unavailable(feature: string): never { - throw new CliError( - 'RUNTIME_V2_UNAVAILABLE', - 'The public CLI does not bundle the V2 runtime yet. Install and provide a V2 runtime package when one is available.', - { runtime: 'v2', feature }, - ); -} - -export async function openV2Document( - _doc: string | undefined, - _io: CliIO, - _options: OpenV2DocumentOptions = {}, -): Promise { - throwV2Unavailable('open'); -} - -export async function openV2CollaborativeDocument( - _doc: string | undefined, - _io: CliIO, - _profile: CollaborationProfile, - _options: OpenV2DocumentOptions = {}, -): Promise { - throwV2Unavailable('collaborative-open'); -} diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 69732dd260..a3b806298f 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -38,9 +38,8 @@ export type EditorWithDoc = Editor & { }; /** - * Runtime-neutral opened-document contract used by CLI orchestrators and the - * session pool. Engine specifics (v1 Editor / v2 SDDocumentSession) live - * inside the implementation, not on this surface. + * Opened-document contract used by CLI orchestrators and the session pool. + * Engine specifics live inside the implementation, not on this surface. */ export interface OpenedRuntimeDocument { runtime: DocumentRuntimeKind; @@ -516,7 +515,6 @@ export async function openSessionDocument( } = {}, ): Promise { const { executionMode, sessionPool, sessionId } = options; - const runtime: DocumentRuntimeKind = metadata.runtime ?? 'v1'; // Host mode: always go through pool (local AND collab) if (executionMode === 'host' && sessionPool) { @@ -524,7 +522,6 @@ export async function openSessionDocument( return sessionPool.acquire( resolvedSessionId, { - runtime, sessionType: metadata.sessionType, workingDocPath: metadata.workingDocPath ?? doc, metadataRevision: metadata.revision, @@ -543,19 +540,6 @@ export async function openSessionDocument( } : undefined; - // v2 session reopen — runtime-specific path. - if (runtime === 'v2') { - if (metadata.sessionType === 'collab') { - if (!metadata.collaboration) { - throw new CliError('COMMAND_FAILED', 'Session is marked as collaborative but has no collaboration profile.'); - } - const { openV2CollaborativeDocument } = await loadV2Runtime(); - return openV2CollaborativeDocument(doc, io, metadata.collaboration, { user: metadata.user, editorOpenOptions }); - } - const { openV2Document } = await loadV2Runtime(); - return openV2Document(doc, io, { user: metadata.user, editorOpenOptions }); - } - // Oneshot mode: open fresh, caller is responsible for dispose if (metadata.sessionType === 'collab') { if (!metadata.collaboration) { @@ -567,15 +551,6 @@ export async function openSessionDocument( return openDocument(doc, io, { user: metadata.user, editorOpenOptions }); } -// Lazy loader so the CLI does not pull v2 engine bytes into the v1-only path. -let v2RuntimeModulePromise: Promise | null = null; -export function loadV2Runtime(): Promise { - if (!v2RuntimeModulePromise) { - v2RuntimeModulePromise = import('./document-v2.js'); - } - return v2RuntimeModulePromise; -} - export async function getFileChecksum(path: string): Promise { let bytes: Uint8Array; try { @@ -604,8 +579,8 @@ export type OptionalExportResult = { * Attempts an optional session export, returning structured success/warning * data instead of throwing on failure. * - * Runtime-neutral: uses {@link OpenedRuntimeDocument.exportToPath} so v1 and - * v2 sessions both route through their own serializer. + * Uses {@link OpenedRuntimeDocument.exportToPath} so sessions route through + * their own serializer. * * @param opened - Runtime-neutral opened document * @param io - CLI I/O for diagnostic warnings diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 1a8998b83c..d82a4883b6 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -9,7 +9,6 @@ * without throwing. */ -import { V1_RUNTIME_UNAVAILABLE_OPERATION_IDS } from '@superdoc/document-api'; 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'; @@ -19,16 +18,11 @@ type ErrorMappingContext = { }; const TRACK_CHANGES_REVIEW_HELPER_COMMANDS = new Set(['track-changes accept', 'track-changes reject']); -const V1_RUNTIME_UNAVAILABLE_OPERATION_SET = new Set(V1_RUNTIME_UNAVAILABLE_OPERATION_IDS); function isTrackChangesReviewHelper(operationId: CliExposedOperationId, context?: ErrorMappingContext): boolean { return operationId === 'trackChanges.decide' && TRACK_CHANGES_REVIEW_HELPER_COMMANDS.has(context?.commandName ?? ''); } -function isV1RuntimeUnavailableOperation(operationId: CliExposedOperationId): boolean { - return V1_RUNTIME_UNAVAILABLE_OPERATION_SET.has(operationId); -} - // --------------------------------------------------------------------------- // Error code extraction // --------------------------------------------------------------------------- @@ -144,9 +138,6 @@ function mapListsError(operationId: CliExposedOperationId, error: unknown, code: } if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || code === 'CAPABILITY_UNAVAILABLE') { - if (code === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); } @@ -192,9 +183,6 @@ function mapImagesError(operationId: CliExposedOperationId, error: unknown, code } if (code === 'CAPABILITY_UNAVAILABLE' || code === 'COMMAND_UNAVAILABLE') { - if (code === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } return new CliError('COMMAND_FAILED', message, { operationId, details }); } @@ -219,9 +207,6 @@ function mapTablesError(operationId: CliExposedOperationId, error: unknown, code } if (code === 'CAPABILITY_UNAVAILABLE' || code === 'COMMAND_UNAVAILABLE') { - if (code === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } return new CliError('COMMAND_FAILED', message, { operationId, details }); } @@ -249,9 +234,6 @@ function mapTextMutationError(operationId: CliExposedOperationId, error: unknown } if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || code === 'CAPABILITY_UNAVAILABLE') { - if (code === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); } @@ -298,9 +280,6 @@ function mapCreateError(operationId: CliExposedOperationId, error: unknown, code } if (code === 'CAPABILITY_UNAVAILABLE') { - if (isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } const reason = (details as { reason?: string } | undefined)?.reason; if (reason === 'tracked_mode_unsupported') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); @@ -329,9 +308,6 @@ function mapBlocksError(operationId: CliExposedOperationId, error: unknown, code } if (code === 'CAPABILITY_UNAVAILABLE') { - if (isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); - } const reason = (details as { reason?: string } | undefined)?.reason; if (reason === 'tracked_mode_unsupported') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); @@ -704,9 +680,6 @@ export function mapFailedReceipt( return new CliError('INVALID_TARGET', failureMessage, { operationId, failure }); } if (failureCode === 'CAPABILITY_UNAVAILABLE') { - if (isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure }); - } return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', failureMessage, { operationId, failure }); } return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); @@ -718,9 +691,6 @@ export function mapFailedReceipt( return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure }); } if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || failureCode === 'CAPABILITY_UNAVAILABLE') { - if (failureCode === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure }); - } return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', failureMessage, { operationId, failure }); } if (failureCode === 'INVALID_TARGET') { @@ -734,9 +704,6 @@ export function mapFailedReceipt( if (failureCode === 'INVALID_TARGET') { return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure }); } - if (failureCode === 'CAPABILITY_UNAVAILABLE' && isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure }); - } return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); } @@ -791,9 +758,6 @@ export function mapFailedReceipt( return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure }); } if (failureCode === 'CAPABILITY_UNAVAILABLE') { - if (isV1RuntimeUnavailableOperation(operationId)) { - return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure }); - } return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); } return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index 390b688766..9550521173 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -35,10 +35,6 @@ export type CliErrorCode = | 'OPERATION_HINT_MISSING' | 'UNSUPPORTED_FORMAT' | 'TIMEOUT' - // Runtime-selection error codes - | 'INVALID_RUNTIME' - | 'RUNTIME_V2_UNAVAILABLE' - | 'RUNTIME_MISMATCH' // Plan-engine error codes — passed through from document-api adapters | 'REVISION_CHANGED_SINCE_COMPILE' | 'PLAN_CONFLICT_OVERLAP' diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index 44c0ca9be3..6ada6b961a 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -8,9 +8,8 @@ * Two branches: stateless (--doc) and session (unified local + collab, * host + oneshot). * - * Runtime-neutral: dispatches through {@link OpenedRuntimeDocument} so v1 - * (editor-backed) and v2 (SDDocumentSession-backed) sessions share the same - * orchestrator. Engine specifics (`editor.*`) MUST stay out of this file. + * Dispatches through {@link OpenedRuntimeDocument}; engine specifics + * (`editor.*`) MUST stay out of this file. */ import { COMMAND_CATALOG } from '@superdoc/document-api'; @@ -348,8 +347,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr updatedMetadata = synced.updatedMetadata; byteLength = synced.output.byteLength; } else { - // Oneshot local / v2: export to disk through the runtime-neutral - // contract. + // Oneshot local: export to disk through the opened-document contract. const workingOutput = await opened.exportToPath(paths.workingDocPath, true); updatedMetadata = markContextUpdated(context.io, metadata, { dirty: true, diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index 38b32dc177..db0335c4d0 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -4,9 +4,8 @@ * Replaces the per-operation runReadOperation() calls scattered across * operation-extra-invokers.ts with a single generic path. * - * Runtime-neutral: dispatches through {@link OpenedRuntimeDocument} so v1 - * (editor-backed) and v2 (SDDocumentSession-backed) sessions share the same - * orchestrator. Engine specifics (`editor.*`) MUST stay out of this file. + * Dispatches through {@link OpenedRuntimeDocument}; engine specifics + * (`editor.*`) MUST stay out of this file. */ import { SUCCESS_VERB } from '../cli/operation-hints.js'; diff --git a/apps/cli/src/lib/session-collab.ts b/apps/cli/src/lib/session-collab.ts index c7c13bdd6d..ef78e67231 100644 --- a/apps/cli/src/lib/session-collab.ts +++ b/apps/cli/src/lib/session-collab.ts @@ -4,11 +4,7 @@ import { exportToPath, getFileChecksum, type OpenedRuntimeDocument } from './doc import { CliError } from './errors'; import type { CliIO } from './types'; -/** - * v1-only helper. Collaborative sync depends on the live Yjs editor; v2 - * collaboration is not implemented at this plan close, so v2 sessions never - * reach this function (they reject at open). - */ +/** Collaborative sync depends on the live Yjs editor. */ export async function syncCollaborativeSessionSnapshot( io: CliIO, metadata: ContextMetadata, @@ -42,9 +38,8 @@ export async function syncCollaborativeSessionSnapshot( } /** - * Runtime-neutral entry: accepts an {@link OpenedRuntimeDocument} and routes - * to the runtime's own export path. This keeps v1 and v2 collaborative - * sessions aligned at the CLI sync checkpoint seam. + * Accepts an {@link OpenedRuntimeDocument} and routes to the runtime's own + * export path at the CLI sync checkpoint. */ export async function syncCollaborativeSessionSnapshotFromOpened( io: CliIO, diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index 8fc70ba226..8633d107b0 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -385,7 +385,7 @@ const resolveReviewDecideId: PreInvokeHook = (input, context) => { /** * comments.create/patch can target a tracked change by id. The CLI exposes * stable track-change ids, so translate them back to the raw adapter id before - * invoking the v2 comment adapter. + * invoking the comment adapter. */ const resolveCommentTrackedChangeTargetId: PreInvokeHook = (input, context) => { const record = asRecord(input); diff --git a/apps/cli/src/lib/types.ts b/apps/cli/src/lib/types.ts index 515e535f14..0507322c8b 100644 --- a/apps/cli/src/lib/types.ts +++ b/apps/cli/src/lib/types.ts @@ -49,13 +49,12 @@ export type FindOutput = DocumentApiFindOutput; export type UserIdentity = { name: string; email: string }; /** - * Runtime kind selected when opening a document. v1 wraps the legacy - * `Editor` + v1 Document API adapters; v2 wraps an `SDDocumentSession` - * plus v2 adapters. Defaults to v1 when omitted. + * Runtime kind selected when opening a document. This branch is v1-only: + * v1 wraps the legacy `Editor` + v1 Document API adapters. The field is + * retained for forward/backward compatibility of persisted session metadata, + * but `'v1'` is the only accepted value. Defaults to v1 when omitted. */ -export type DocumentRuntimeKind = 'v1' | 'v2'; - -export const ACCEPTED_RUNTIME_VALUES: readonly DocumentRuntimeKind[] = ['v1', 'v2']; +export type DocumentRuntimeKind = 'v1'; export type OutputMode = 'json' | 'pretty'; export type ExecutionMode = 'oneshot' | 'host'; diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 42517df1a4..98e129f091 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -14,7 +14,7 @@ Use the tables below to see what operations are available and where each one is | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | | Anchored Metadata | 6 | 0 | 6 | [Reference](/document-api/reference/metadata/index) | -| Blocks | 6 | 0 | 6 | [Reference](/document-api/reference/blocks/index) | +| Blocks | 3 | 0 | 3 | [Reference](/document-api/reference/blocks/index) | | Bookmarks | 5 | 0 | 5 | [Reference](/document-api/reference/bookmarks/index) | | Capabilities | 1 | 0 | 1 | [Reference](/document-api/reference/capabilities/index) | | Captions | 6 | 0 | 6 | [Reference](/document-api/reference/captions/index) | @@ -34,9 +34,9 @@ Use the tables below to see what operations are available and where each one is | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | | Images | 27 | 0 | 27 | [Reference](/document-api/reference/images/index) | | Index | 11 | 0 | 11 | [Reference](/document-api/reference/index/index) | -| Lists | 44 | 0 | 44 | [Reference](/document-api/reference/lists/index) | +| Lists | 39 | 0 | 39 | [Reference](/document-api/reference/lists/index) | | Mutations | 3 | 0 | 3 | [Reference](/document-api/reference/mutations/index) | -| Paragraph Formatting | 21 | 0 | 21 | [Reference](/document-api/reference/format/paragraph/index) | +| Paragraph Formatting | 20 | 0 | 20 | [Reference](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Reference](/document-api/reference/styles/paragraph/index) | | Permission Ranges | 5 | 0 | 5 | [Reference](/document-api/reference/permission-ranges/index) | | Protection | 3 | 0 | 3 | [Reference](/document-api/reference/protection/index) | @@ -47,7 +47,7 @@ Use the tables below to see what operations are available and where each one is | Styles | 1 | 0 | 1 | [Reference](/document-api/reference/styles/index) | | Table of Authorities | 11 | 0 | 11 | [Reference](/document-api/reference/authorities/index) | | Table of Contents | 10 | 0 | 10 | [Reference](/document-api/reference/toc/index) | -| Tables | 48 | 0 | 48 | [Reference](/document-api/reference/tables/index) | +| Tables | 47 | 0 | 47 | [Reference](/document-api/reference/tables/index) | | Templates | 1 | 0 | 1 | [Reference](/document-api/reference/templates/index) | | Track Changes | 3 | 0 | 3 | [Reference](/document-api/reference/track-changes/index) | @@ -62,9 +62,6 @@ Use the tables below to see what operations are available and where each one is | editor.doc.blocks.list(...) | [`blocks.list`](/document-api/reference/blocks/list) | | editor.doc.blocks.delete(...) | [`blocks.delete`](/document-api/reference/blocks/delete) | | editor.doc.blocks.deleteRange(...) | [`blocks.deleteRange`](/document-api/reference/blocks/delete-range) | -| editor.doc.blocks.split(...) | [`blocks.split`](/document-api/reference/blocks/split) | -| editor.doc.blocks.merge(...) | [`blocks.merge`](/document-api/reference/blocks/merge) | -| editor.doc.blocks.move(...) | [`blocks.move`](/document-api/reference/blocks/move) | | editor.doc.bookmarks.list(...) | [`bookmarks.list`](/document-api/reference/bookmarks/list) | | editor.doc.bookmarks.get(...) | [`bookmarks.get`](/document-api/reference/bookmarks/get) | | editor.doc.bookmarks.insert(...) | [`bookmarks.insert`](/document-api/reference/bookmarks/insert) | @@ -337,11 +334,6 @@ Use the tables below to see what operations are available and where each one is | editor.doc.lists.setLevelText(...) | [`lists.setLevelText`](/document-api/reference/lists/set-level-text) | | editor.doc.lists.setLevelStart(...) | [`lists.setLevelStart`](/document-api/reference/lists/set-level-start) | | editor.doc.lists.setLevelLayout(...) | [`lists.setLevelLayout`](/document-api/reference/lists/set-level-layout) | -| editor.doc.lists.getState(...) | [`lists.getState`](/document-api/reference/lists/get-state) | -| editor.doc.lists.apply(...) | [`lists.apply`](/document-api/reference/lists/apply) | -| editor.doc.lists.continue(...) | [`lists.continue`](/document-api/reference/lists/continue) | -| editor.doc.lists.restart(...) | [`lists.restart`](/document-api/reference/lists/restart) | -| editor.doc.lists.remove(...) | [`lists.remove`](/document-api/reference/lists/remove) | | editor.doc.mutations.preview(...) | [`mutations.preview`](/document-api/reference/mutations/preview) | | editor.doc.mutations.apply(...) | [`mutations.apply`](/document-api/reference/mutations/apply) | | editor.doc.plan.execute(...) | [`plan.execute`](/document-api/reference/mutations/plan-execute) | @@ -362,7 +354,6 @@ Use the tables below to see what operations are available and where each one is | editor.doc.format.paragraph.clearBorder(...) | [`format.paragraph.clearBorder`](/document-api/reference/format/paragraph/clear-border) | | editor.doc.format.paragraph.setShading(...) | [`format.paragraph.setShading`](/document-api/reference/format/paragraph/set-shading) | | editor.doc.format.paragraph.clearShading(...) | [`format.paragraph.clearShading`](/document-api/reference/format/paragraph/clear-shading) | -| editor.doc.format.paragraph.setMarkRunProps(...) | [`format.paragraph.setMarkRunProps`](/document-api/reference/format/paragraph/set-mark-run-props) | | editor.doc.format.paragraph.setDirection(...) | [`format.paragraph.setDirection`](/document-api/reference/format/paragraph/set-direction) | | editor.doc.format.paragraph.clearDirection(...) | [`format.paragraph.clearDirection`](/document-api/reference/format/paragraph/clear-direction) | | editor.doc.format.paragraph.setNumbering(...) | [`format.paragraph.setNumbering`](/document-api/reference/format/paragraph/set-numbering) | @@ -428,7 +419,6 @@ Use the tables below to see what operations are available and where each one is | editor.doc.tables.setLayout(...) | [`tables.setLayout`](/document-api/reference/tables/set-layout) | | editor.doc.tables.insertRow(...) | [`tables.insertRow`](/document-api/reference/tables/insert-row) | | editor.doc.tables.deleteRow(...) | [`tables.deleteRow`](/document-api/reference/tables/delete-row) | -| editor.doc.tables.moveRow(...) | [`tables.moveRow`](/document-api/reference/tables/move-row) | | editor.doc.tables.setRowHeight(...) | [`tables.setRowHeight`](/document-api/reference/tables/set-row-height) | | editor.doc.tables.distributeRows(...) | [`tables.distributeRows`](/document-api/reference/tables/distribute-rows) | | editor.doc.tables.setRowOptions(...) | [`tables.setRowOptions`](/document-api/reference/tables/set-row-options) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index dcc6d6ceb9..1b8bb06f03 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -17,9 +17,6 @@ "apps/docs/document-api/reference/blocks/delete.mdx", "apps/docs/document-api/reference/blocks/index.mdx", "apps/docs/document-api/reference/blocks/list.mdx", - "apps/docs/document-api/reference/blocks/merge.mdx", - "apps/docs/document-api/reference/blocks/move.mdx", - "apps/docs/document-api/reference/blocks/split.mdx", "apps/docs/document-api/reference/bookmarks/get.mdx", "apps/docs/document-api/reference/bookmarks/index.mdx", "apps/docs/document-api/reference/bookmarks/insert.mdx", @@ -200,7 +197,6 @@ "apps/docs/document-api/reference/format/paragraph/set-flow-options.mdx", "apps/docs/document-api/reference/format/paragraph/set-indentation.mdx", "apps/docs/document-api/reference/format/paragraph/set-keep-options.mdx", - "apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx", "apps/docs/document-api/reference/format/paragraph/set-numbering.mdx", "apps/docs/document-api/reference/format/paragraph/set-outline-level.mdx", "apps/docs/document-api/reference/format/paragraph/set-shading.mdx", @@ -294,19 +290,16 @@ "apps/docs/document-api/reference/lists/apply-preset.mdx", "apps/docs/document-api/reference/lists/apply-style.mdx", "apps/docs/document-api/reference/lists/apply-template.mdx", - "apps/docs/document-api/reference/lists/apply.mdx", "apps/docs/document-api/reference/lists/attach.mdx", "apps/docs/document-api/reference/lists/can-continue-previous.mdx", "apps/docs/document-api/reference/lists/can-join.mdx", "apps/docs/document-api/reference/lists/capture-template.mdx", "apps/docs/document-api/reference/lists/clear-level-overrides.mdx", "apps/docs/document-api/reference/lists/continue-previous.mdx", - "apps/docs/document-api/reference/lists/continue.mdx", "apps/docs/document-api/reference/lists/convert-to-text.mdx", "apps/docs/document-api/reference/lists/create.mdx", "apps/docs/document-api/reference/lists/delete.mdx", "apps/docs/document-api/reference/lists/detach.mdx", - "apps/docs/document-api/reference/lists/get-state.mdx", "apps/docs/document-api/reference/lists/get-style.mdx", "apps/docs/document-api/reference/lists/get.mdx", "apps/docs/document-api/reference/lists/indent.mdx", @@ -316,9 +309,7 @@ "apps/docs/document-api/reference/lists/list.mdx", "apps/docs/document-api/reference/lists/merge.mdx", "apps/docs/document-api/reference/lists/outdent.mdx", - "apps/docs/document-api/reference/lists/remove.mdx", "apps/docs/document-api/reference/lists/restart-at.mdx", - "apps/docs/document-api/reference/lists/restart.mdx", "apps/docs/document-api/reference/lists/separate.mdx", "apps/docs/document-api/reference/lists/set-level-alignment.mdx", "apps/docs/document-api/reference/lists/set-level-bullet.mdx", @@ -415,7 +406,6 @@ "apps/docs/document-api/reference/tables/insert-column.mdx", "apps/docs/document-api/reference/tables/insert-row.mdx", "apps/docs/document-api/reference/tables/merge-cells.mdx", - "apps/docs/document-api/reference/tables/move-row.mdx", "apps/docs/document-api/reference/tables/move.mdx", "apps/docs/document-api/reference/tables/set-alt-text.mdx", "apps/docs/document-api/reference/tables/set-border.mdx", @@ -483,14 +473,7 @@ { "aliasMemberPaths": [], "key": "blocks", - "operationIds": [ - "blocks.list", - "blocks.delete", - "blocks.deleteRange", - "blocks.split", - "blocks.merge", - "blocks.move" - ], + "operationIds": ["blocks.list", "blocks.delete", "blocks.deleteRange"], "pagePath": "apps/docs/document-api/reference/blocks/index.mdx", "title": "Blocks" }, @@ -643,12 +626,7 @@ "lists.setLevelNumberStyle", "lists.setLevelText", "lists.setLevelStart", - "lists.setLevelLayout", - "lists.getState", - "lists.apply", - "lists.continue", - "lists.restart", - "lists.remove" + "lists.setLevelLayout" ], "pagePath": "apps/docs/document-api/reference/lists/index.mdx", "title": "Lists" @@ -702,7 +680,6 @@ "format.paragraph.clearBorder", "format.paragraph.setShading", "format.paragraph.clearShading", - "format.paragraph.setMarkRunProps", "format.paragraph.setDirection", "format.paragraph.clearDirection", "format.paragraph.setNumbering" @@ -737,7 +714,6 @@ "tables.setLayout", "tables.insertRow", "tables.deleteRow", - "tables.moveRow", "tables.setRowHeight", "tables.distributeRows", "tables.setRowOptions", @@ -1114,5 +1090,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "a870119c850007e1b994fcaa36fcc58302b9e340657fe3634f589a83daf309fb" + "sourceHash": "b7a0382edb33e88b28f1af627a89d6a0b441890883e30993390a76c756214e4c" } diff --git a/apps/docs/document-api/reference/blocks/index.mdx b/apps/docs/document-api/reference/blocks/index.mdx index 31600b7d67..234633c4fa 100644 --- a/apps/docs/document-api/reference/blocks/index.mdx +++ b/apps/docs/document-api/reference/blocks/index.mdx @@ -15,7 +15,4 @@ Block-level structural operations. | blocks.list | `blocks.list` | No | `idempotent` | No | No | | blocks.delete | `blocks.delete` | Yes | `conditional` | Yes | Yes | | blocks.deleteRange | `blocks.deleteRange` | Yes | `conditional` | No | Yes | -| blocks.split | `blocks.split` | Yes | `non-idempotent` | Yes | No | -| blocks.merge | `blocks.merge` | Yes | `non-idempotent` | Yes | No | -| blocks.move | `blocks.move` | Yes | `non-idempotent` | Yes | No | diff --git a/apps/docs/document-api/reference/blocks/merge.mdx b/apps/docs/document-api/reference/blocks/merge.mdx deleted file mode 100644 index fdc1fb46d0..0000000000 --- a/apps/docs/document-api/reference/blocks/merge.mdx +++ /dev/null @@ -1,291 +0,0 @@ ---- -title: blocks.merge -sidebarTitle: blocks.merge -description: "Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `blocks.merge` -- API member path: `editor.doc.blocks.merge(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a BlocksMergeResult; on success carries the removed paragraph address plus affectedStories, textRangeShifts, and a txId. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `first` | BlockNodeAddress | yes | BlockNodeAddress | -| `first.kind` | `"block"` | yes | Constant: `"block"` | -| `first.nodeId` | string | yes | | -| `first.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `first.story` | StoryLocator | no | StoryLocator | -| `second` | BlockNodeAddress | yes | BlockNodeAddress | -| `second.kind` | `"block"` | yes | Constant: `"block"` | -| `second.nodeId` | string | yes | | -| `second.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `second.story` | StoryLocator | no | StoryLocator | - -### Example request - -```json -{ - "first": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "second": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } -} -``` - -## Output fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `affectedStories` | StoryLocator[] | no | | -| `remappedRefs` | AffectedRefRemapping[] | no | | -| `removed` | BlockNodeAddress | yes | BlockNodeAddress | -| `removed.kind` | `"block"` | yes | Constant: `"block"` | -| `removed.nodeId` | string | yes | | -| `removed.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `removed.story` | StoryLocator | no | StoryLocator | -| `success` | `true` | yes | Constant: `true` | -| `textRangeShifts` | TextRangeShift[] | no | | -| `txId` | string | no | | - -### Example response - -```json -{ - "affectedStories": [ - { - "kind": "story", - "storyType": "body" - } - ], - "remappedRefs": [ - { - "from": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - }, - "to": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - } - } - ], - "removed": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` -- `INVALID_INPUT` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` -- `INVALID_INPUT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "first": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "second": { - "$ref": "#/$defs/BlockNodeAddress" - } - }, - "required": [ - "first", - "second" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "removed": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "success": { - "const": true - }, - "textRangeShifts": { - "items": { - "$ref": "#/$defs/TextRangeShift" - }, - "type": "array" - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "removed" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "removed": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "success": { - "const": true - }, - "textRangeShifts": { - "items": { - "$ref": "#/$defs/TextRangeShift" - }, - "type": "array" - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "removed" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_TARGET", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/blocks/move.mdx b/apps/docs/document-api/reference/blocks/move.mdx deleted file mode 100644 index 5b027219a7..0000000000 --- a/apps/docs/document-api/reference/blocks/move.mdx +++ /dev/null @@ -1,287 +0,0 @@ ---- -title: blocks.move -sidebarTitle: blocks.move -description: "Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `blocks.move` -- API member path: `editor.doc.blocks.move(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a BlocksMoveResult; on success carries the moved paragraph address plus affectedStories and a txId. Tracked-mode results include `trackedChangeRefs` pointing at the paired move review entity. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `destination` | BlockNodeAddress | yes | BlockNodeAddress | -| `destination.kind` | `"block"` | yes | Constant: `"block"` | -| `destination.nodeId` | string | yes | | -| `destination.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `destination.story` | StoryLocator | no | StoryLocator | -| `placement` | enum | yes | `"before"`, `"after"` | -| `source` | BlockNodeAddress | yes | BlockNodeAddress | -| `source.kind` | `"block"` | yes | Constant: `"block"` | -| `source.nodeId` | string | yes | | -| `source.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `source.story` | StoryLocator | no | StoryLocator | - -### Example request - -```json -{ - "destination": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "placement": "before", - "source": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } -} -``` - -## Output fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `affectedStories` | StoryLocator[] | no | | -| `moved` | BlockNodeAddress | yes | BlockNodeAddress | -| `moved.kind` | `"block"` | yes | Constant: `"block"` | -| `moved.nodeId` | string | yes | | -| `moved.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `moved.story` | StoryLocator | no | StoryLocator | -| `remappedRefs` | AffectedRefRemapping[] | no | | -| `success` | `true` | yes | Constant: `true` | -| `txId` | string | no | | - -### Example response - -```json -{ - "affectedStories": [ - { - "kind": "story", - "storyType": "body" - } - ], - "moved": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "remappedRefs": [ - { - "from": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - }, - "to": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - } - } - ], - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` -- `INVALID_INPUT` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` -- `INVALID_INPUT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "destination": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "placement": { - "enum": [ - "before", - "after" - ] - }, - "source": { - "$ref": "#/$defs/BlockNodeAddress" - } - }, - "required": [ - "source", - "destination", - "placement" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "moved": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "success": { - "const": true - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "moved" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "moved": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "success": { - "const": true - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "moved" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_TARGET", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/blocks/split.mdx b/apps/docs/document-api/reference/blocks/split.mdx deleted file mode 100644 index f61334050c..0000000000 --- a/apps/docs/document-api/reference/blocks/split.mdx +++ /dev/null @@ -1,280 +0,0 @@ ---- -title: blocks.split -sidebarTitle: blocks.split -description: "Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `blocks.split` -- API member path: `editor.doc.blocks.split(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a BlocksSplitResult; on success carries the new tail paragraph address, affectedStories, and (for engines that report them) story-absolute textRangeShifts and a txId for history correlation. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `offset` | integer | yes | | -| `target` | BlockNodeAddress | yes | BlockNodeAddress | -| `target.kind` | `"block"` | yes | Constant: `"block"` | -| `target.nodeId` | string | yes | | -| `target.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `target.story` | StoryLocator | no | StoryLocator | - -### Example request - -```json -{ - "offset": 0, - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } -} -``` - -## Output fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `affectedStories` | StoryLocator[] | no | | -| `inserted` | BlockNodeAddress | yes | BlockNodeAddress | -| `inserted.kind` | `"block"` | yes | Constant: `"block"` | -| `inserted.nodeId` | string | yes | | -| `inserted.nodeType` | enum | yes | `"paragraph"`, `"heading"`, `"listItem"`, `"table"`, `"tableRow"`, `"tableCell"`, `"tableOfContents"`, `"image"`, `"sdt"` | -| `inserted.story` | StoryLocator | no | StoryLocator | -| `remappedRefs` | AffectedRefRemapping[] | no | | -| `success` | `true` | yes | Constant: `true` | -| `textRangeShifts` | TextRangeShift[] | no | | -| `txId` | string | no | | - -### Example response - -```json -{ - "affectedStories": [ - { - "kind": "story", - "storyType": "body" - } - ], - "inserted": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "remappedRefs": [ - { - "from": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - }, - "to": { - "blockId": "block-abc123", - "kind": "text", - "range": { - "end": 10, - "start": 0 - }, - "story": { - "kind": "story", - "storyType": "body" - } - } - } - ], - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` -- `INVALID_INPUT` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` -- `INVALID_INPUT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "offset": { - "minimum": 0, - "type": "integer" - }, - "target": { - "$ref": "#/$defs/BlockNodeAddress" - } - }, - "required": [ - "target", - "offset" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "inserted": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "success": { - "const": true - }, - "textRangeShifts": { - "items": { - "$ref": "#/$defs/TextRangeShift" - }, - "type": "array" - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "inserted" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "affectedStories": { - "items": { - "$ref": "#/$defs/StoryLocator" - }, - "type": "array" - }, - "inserted": { - "$ref": "#/$defs/BlockNodeAddress" - }, - "remappedRefs": { - "items": { - "$ref": "#/$defs/AffectedRefRemapping" - }, - "type": "array" - }, - "success": { - "const": true - }, - "textRangeShifts": { - "items": { - "$ref": "#/$defs/TextRangeShift" - }, - "type": "array" - }, - "txId": { - "type": "string" - } - }, - "required": [ - "success", - "inserted" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_TARGET", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index bd5009d69b..e0da7062f3 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -340,21 +340,6 @@ _No fields._ | `operations.blocks.list.dryRun` | boolean | yes | | | `operations.blocks.list.reasons` | enum[] | no | | | `operations.blocks.list.tracked` | boolean | yes | | -| `operations.blocks.merge` | object | yes | | -| `operations.blocks.merge.available` | boolean | yes | | -| `operations.blocks.merge.dryRun` | boolean | yes | | -| `operations.blocks.merge.reasons` | enum[] | no | | -| `operations.blocks.merge.tracked` | boolean | yes | | -| `operations.blocks.move` | object | yes | | -| `operations.blocks.move.available` | boolean | yes | | -| `operations.blocks.move.dryRun` | boolean | yes | | -| `operations.blocks.move.reasons` | enum[] | no | | -| `operations.blocks.move.tracked` | boolean | yes | | -| `operations.blocks.split` | object | yes | | -| `operations.blocks.split.available` | boolean | yes | | -| `operations.blocks.split.dryRun` | boolean | yes | | -| `operations.blocks.split.reasons` | enum[] | no | | -| `operations.blocks.split.tracked` | boolean | yes | | | `operations.bookmarks.get` | object | yes | | | `operations.bookmarks.get.available` | boolean | yes | | | `operations.bookmarks.get.dryRun` | boolean | yes | | @@ -1180,11 +1165,6 @@ _No fields._ | `operations.format.paragraph.setKeepOptions.dryRun` | boolean | yes | | | `operations.format.paragraph.setKeepOptions.reasons` | enum[] | no | | | `operations.format.paragraph.setKeepOptions.tracked` | boolean | yes | | -| `operations.format.paragraph.setMarkRunProps` | object | yes | | -| `operations.format.paragraph.setMarkRunProps.available` | boolean | yes | | -| `operations.format.paragraph.setMarkRunProps.dryRun` | boolean | yes | | -| `operations.format.paragraph.setMarkRunProps.reasons` | enum[] | no | | -| `operations.format.paragraph.setMarkRunProps.tracked` | boolean | yes | | | `operations.format.paragraph.setNumbering` | object | yes | | | `operations.format.paragraph.setNumbering.available` | boolean | yes | | | `operations.format.paragraph.setNumbering.dryRun` | boolean | yes | | @@ -1610,11 +1590,6 @@ _No fields._ | `operations.insert.dryRun` | boolean | yes | | | `operations.insert.reasons` | enum[] | no | | | `operations.insert.tracked` | boolean | yes | | -| `operations.lists.apply` | object | yes | | -| `operations.lists.apply.available` | boolean | yes | | -| `operations.lists.apply.dryRun` | boolean | yes | | -| `operations.lists.apply.reasons` | enum[] | no | | -| `operations.lists.apply.tracked` | boolean | yes | | | `operations.lists.applyPreset` | object | yes | | | `operations.lists.applyPreset.available` | boolean | yes | | | `operations.lists.applyPreset.dryRun` | boolean | yes | | @@ -1655,11 +1630,6 @@ _No fields._ | `operations.lists.clearLevelOverrides.dryRun` | boolean | yes | | | `operations.lists.clearLevelOverrides.reasons` | enum[] | no | | | `operations.lists.clearLevelOverrides.tracked` | boolean | yes | | -| `operations.lists.continue` | object | yes | | -| `operations.lists.continue.available` | boolean | yes | | -| `operations.lists.continue.dryRun` | boolean | yes | | -| `operations.lists.continue.reasons` | enum[] | no | | -| `operations.lists.continue.tracked` | boolean | yes | | | `operations.lists.continuePrevious` | object | yes | | | `operations.lists.continuePrevious.available` | boolean | yes | | | `operations.lists.continuePrevious.dryRun` | boolean | yes | | @@ -1690,11 +1660,6 @@ _No fields._ | `operations.lists.get.dryRun` | boolean | yes | | | `operations.lists.get.reasons` | enum[] | no | | | `operations.lists.get.tracked` | boolean | yes | | -| `operations.lists.getState` | object | yes | | -| `operations.lists.getState.available` | boolean | yes | | -| `operations.lists.getState.dryRun` | boolean | yes | | -| `operations.lists.getState.reasons` | enum[] | no | | -| `operations.lists.getState.tracked` | boolean | yes | | | `operations.lists.getStyle` | object | yes | | | `operations.lists.getStyle.available` | boolean | yes | | | `operations.lists.getStyle.dryRun` | boolean | yes | | @@ -1730,16 +1695,6 @@ _No fields._ | `operations.lists.outdent.dryRun` | boolean | yes | | | `operations.lists.outdent.reasons` | enum[] | no | | | `operations.lists.outdent.tracked` | boolean | yes | | -| `operations.lists.remove` | object | yes | | -| `operations.lists.remove.available` | boolean | yes | | -| `operations.lists.remove.dryRun` | boolean | yes | | -| `operations.lists.remove.reasons` | enum[] | no | | -| `operations.lists.remove.tracked` | boolean | yes | | -| `operations.lists.restart` | object | yes | | -| `operations.lists.restart.available` | boolean | yes | | -| `operations.lists.restart.dryRun` | boolean | yes | | -| `operations.lists.restart.reasons` | enum[] | no | | -| `operations.lists.restart.tracked` | boolean | yes | | | `operations.lists.restartAt` | object | yes | | | `operations.lists.restartAt.available` | boolean | yes | | | `operations.lists.restartAt.dryRun` | boolean | yes | | @@ -2175,11 +2130,6 @@ _No fields._ | `operations.tables.move.dryRun` | boolean | yes | | | `operations.tables.move.reasons` | enum[] | no | | | `operations.tables.move.tracked` | boolean | yes | | -| `operations.tables.moveRow` | object | yes | | -| `operations.tables.moveRow.available` | boolean | yes | | -| `operations.tables.moveRow.dryRun` | boolean | yes | | -| `operations.tables.moveRow.reasons` | enum[] | no | | -| `operations.tables.moveRow.tracked` | boolean | yes | | | `operations.tables.setAltText` | object | yes | | | `operations.tables.setAltText.available` | boolean | yes | | | `operations.tables.setAltText.dryRun` | boolean | yes | | @@ -2716,33 +2666,6 @@ _No fields._ "dryRun": false, "tracked": false }, - "blocks.merge": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, - "blocks.move": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, - "blocks.split": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, "bookmarks.get": { "available": true, "dryRun": false, @@ -3568,15 +3491,6 @@ _No fields._ "dryRun": true, "tracked": true }, - "format.paragraph.setMarkRunProps": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "DRY_RUN_UNAVAILABLE" - ], - "tracked": false - }, "format.paragraph.setNumbering": { "available": true, "dryRun": true, @@ -4002,15 +3916,6 @@ _No fields._ "dryRun": true, "tracked": true }, - "lists.apply": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, "lists.applyPreset": { "available": true, "dryRun": true, @@ -4051,15 +3956,6 @@ _No fields._ "dryRun": true, "tracked": false }, - "lists.continue": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, "lists.continuePrevious": { "available": true, "dryRun": true, @@ -4090,14 +3986,6 @@ _No fields._ "dryRun": false, "tracked": false }, - "lists.getState": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE" - ], - "tracked": false - }, "lists.getStyle": { "available": true, "dryRun": false, @@ -4133,24 +4021,6 @@ _No fields._ "dryRun": true, "tracked": true }, - "lists.remove": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, - "lists.restart": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "TRACKED_MODE_UNAVAILABLE" - ], - "tracked": false - }, "lists.restartAt": { "available": true, "dryRun": true, @@ -4586,15 +4456,6 @@ _No fields._ "dryRun": true, "tracked": false }, - "tables.moveRow": { - "available": false, - "dryRun": false, - "reasons": [ - "OPERATION_UNAVAILABLE", - "DRY_RUN_UNAVAILABLE" - ], - "tracked": false - }, "tables.setAltText": { "available": true, "dryRun": true, @@ -6933,111 +6794,6 @@ _No fields._ ], "type": "object" }, - "blocks.merge": { - "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" - }, - "blocks.move": { - "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" - }, - "blocks.split": { - "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" - }, "bookmarks.get": { "additionalProperties": false, "properties": { @@ -12813,41 +12569,6 @@ _No fields._ ], "type": "object" }, - "format.paragraph.setMarkRunProps": { - "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" - }, "format.paragraph.setNumbering": { "additionalProperties": false, "properties": { @@ -15823,41 +15544,6 @@ _No fields._ ], "type": "object" }, - "lists.apply": { - "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" - }, "lists.applyPreset": { "additionalProperties": false, "properties": { @@ -16138,41 +15824,6 @@ _No fields._ ], "type": "object" }, - "lists.continue": { - "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" - }, "lists.continuePrevious": { "additionalProperties": false, "properties": { @@ -16383,41 +16034,6 @@ _No fields._ ], "type": "object" }, - "lists.getState": { - "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" - }, "lists.getStyle": { "additionalProperties": false, "properties": { @@ -16663,76 +16279,6 @@ _No fields._ ], "type": "object" }, - "lists.remove": { - "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" - }, - "lists.restart": { - "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" - }, "lists.restartAt": { "additionalProperties": false, "properties": { @@ -19778,41 +19324,6 @@ _No fields._ ], "type": "object" }, - "tables.moveRow": { - "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" - }, "tables.setAltText": { "additionalProperties": false, "properties": { @@ -21058,9 +20569,6 @@ _No fields._ "blocks.list", "blocks.delete", "blocks.deleteRange", - "blocks.split", - "blocks.merge", - "blocks.move", "format.apply", "format.bold", "format.italic", @@ -21147,7 +20655,6 @@ _No fields._ "format.paragraph.clearBorder", "format.paragraph.setShading", "format.paragraph.clearShading", - "format.paragraph.setMarkRunProps", "format.paragraph.setDirection", "format.paragraph.clearDirection", "format.paragraph.setNumbering", @@ -21190,11 +20697,6 @@ _No fields._ "lists.setLevelText", "lists.setLevelStart", "lists.setLevelLayout", - "lists.getState", - "lists.apply", - "lists.continue", - "lists.restart", - "lists.remove", "comments.create", "comments.patch", "comments.delete", @@ -21220,7 +20722,6 @@ _No fields._ "tables.setLayout", "tables.insertRow", "tables.deleteRow", - "tables.moveRow", "tables.setRowHeight", "tables.distributeRows", "tables.setRowOptions", diff --git a/apps/docs/document-api/reference/comments/patch.mdx b/apps/docs/document-api/reference/comments/patch.mdx index 26b0533db4..c55a969693 100644 --- a/apps/docs/document-api/reference/comments/patch.mdx +++ b/apps/docs/document-api/reference/comments/patch.mdx @@ -1,14 +1,14 @@ --- title: comments.patch sidebarTitle: comments.patch -description: "Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: \"trackedChange\"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`." +description: "Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: \"trackedChange\"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. +Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. - Operation ID: `comments.patch` - API member path: `editor.doc.comments.patch(...)` @@ -123,7 +123,7 @@ Returns a Receipt with `updated` populated on success. Reports `NO_OP` for byte- "type": "string" }, "isInternal": { - "description": "Legacy v1/document-api compatibility field. Not a supported v2 behavior. V2 adapters MUST reject a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6).", + "description": "Legacy v1/document-api compatibility field. Not supported for new comment patch behavior. A `comments.patch` request containing `isInternal` fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6).", "type": "boolean" }, "status": { diff --git a/apps/docs/document-api/reference/footnotes/insert.mdx b/apps/docs/document-api/reference/footnotes/insert.mdx index 54e71550d4..6115b7512e 100644 --- a/apps/docs/document-api/reference/footnotes/insert.mdx +++ b/apps/docs/document-api/reference/footnotes/insert.mdx @@ -1,14 +1,14 @@ --- title: footnotes.insert sidebarTitle: footnotes.insert -description: "Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes." +description: "Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. +Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. - Operation ID: `footnotes.insert` - API member path: `editor.doc.footnotes.insert(...)` diff --git a/apps/docs/document-api/reference/footnotes/update.mdx b/apps/docs/document-api/reference/footnotes/update.mdx index 2d8670c6b3..d899cf2f22 100644 --- a/apps/docs/document-api/reference/footnotes/update.mdx +++ b/apps/docs/document-api/reference/footnotes/update.mdx @@ -1,14 +1,14 @@ --- title: footnotes.update sidebarTitle: footnotes.update -description: "Update the content of an existing footnote or endnote. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes." +description: "Update the content of an existing footnote or endnote. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Update the content of an existing footnote or endnote. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. +Update the content of an existing footnote or endnote. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. - Operation ID: `footnotes.update` - API member path: `editor.doc.footnotes.update(...)` diff --git a/apps/docs/document-api/reference/format/paragraph/index.mdx b/apps/docs/document-api/reference/format/paragraph/index.mdx index 8a36f16e2e..04f838c264 100644 --- a/apps/docs/document-api/reference/format/paragraph/index.mdx +++ b/apps/docs/document-api/reference/format/paragraph/index.mdx @@ -29,7 +29,6 @@ Paragraph-level direct formatting: alignment, indentation, spacing, borders, sha | format.paragraph.clearBorder | `format.paragraph.clearBorder` | Yes | `conditional` | Yes | Yes | | format.paragraph.setShading | `format.paragraph.setShading` | Yes | `conditional` | Yes | Yes | | format.paragraph.clearShading | `format.paragraph.clearShading` | Yes | `conditional` | Yes | Yes | -| format.paragraph.setMarkRunProps | `format.paragraph.setMarkRunProps` | Yes | `conditional` | No | Yes | | format.paragraph.setDirection | `format.paragraph.setDirection` | Yes | `conditional` | Yes | Yes | | format.paragraph.clearDirection | `format.paragraph.clearDirection` | Yes | `conditional` | Yes | Yes | | format.paragraph.setNumbering | `format.paragraph.setNumbering` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx b/apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx deleted file mode 100644 index 786ae43ebf..0000000000 --- a/apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx +++ /dev/null @@ -1,912 +0,0 @@ ---- -title: format.paragraph.setMarkRunProps -sidebarTitle: format.paragraph.setMarkRunProps -description: "Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `format.paragraph.setMarkRunProps` -- API member path: `editor.doc.format.paragraph.setMarkRunProps(...)` -- Mutates document: `yes` -- Idempotency: `conditional` -- Supports tracked mode: `no` -- Supports dry run: `yes` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ParagraphMutationResult; reports NO_OP if the encoded mark run properties already match. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `markRunProps` | object | yes | | -| `markRunProps.baselineShift` | number | no | | -| `markRunProps.bold` | boolean | no | | -| `markRunProps.boldCs` | boolean | no | | -| `markRunProps.border` | object | no | | -| `markRunProps.border.color` | object(model="rgb") \| object(model="theme") \| object(model="auto") | no | One of: object(model="rgb"), object(model="theme"), object(model="auto") | -| `markRunProps.border.frame` | boolean | no | | -| `markRunProps.border.shadow` | boolean | no | | -| `markRunProps.border.space` | number | no | | -| `markRunProps.border.style` | string | no | | -| `markRunProps.border.width` | number | no | | -| `markRunProps.caps` | boolean | no | | -| `markRunProps.characterScale` | number | no | | -| `markRunProps.characterSpacing` | number | no | | -| `markRunProps.color` | object(model="rgb") \| object(model="theme") \| object(model="auto") | no | One of: object(model="rgb"), object(model="theme"), object(model="auto") | -| `markRunProps.cs` | boolean | no | | -| `markRunProps.doubleStrikethrough` | boolean | no | | -| `markRunProps.emboss` | boolean | no | | -| `markRunProps.fitTextWidth` | number | no | | -| `markRunProps.fontFamily` | string | no | | -| `markRunProps.fontSize` | number | no | | -| `markRunProps.fontSizeCs` | number | no | | -| `markRunProps.fonts` | object | no | | -| `markRunProps.fonts.ascii` | string | no | | -| `markRunProps.fonts.asciiTheme` | string | no | | -| `markRunProps.fonts.cs` | string | no | | -| `markRunProps.fonts.csTheme` | string | no | | -| `markRunProps.fonts.eastAsia` | string | no | | -| `markRunProps.fonts.eastAsiaTheme` | string | no | | -| `markRunProps.fonts.hAnsi` | string | no | | -| `markRunProps.fonts.hAnsiTheme` | string | no | | -| `markRunProps.fonts.hint` | string | no | | -| `markRunProps.highlight` | string | no | | -| `markRunProps.imprint` | boolean | no | | -| `markRunProps.italic` | boolean | no | | -| `markRunProps.italicCs` | boolean | no | | -| `markRunProps.kern` | number | no | | -| `markRunProps.lang` | object | no | | -| `markRunProps.lang.bidi` | string | no | | -| `markRunProps.lang.eastAsia` | string | no | | -| `markRunProps.lang.val` | string | no | | -| `markRunProps.outline` | boolean | no | | -| `markRunProps.rtl` | boolean | no | | -| `markRunProps.shading` | object | no | | -| `markRunProps.shading.color` | object(model="rgb") \| object(model="theme") \| object(model="auto") | no | One of: object(model="rgb"), object(model="theme"), object(model="auto") | -| `markRunProps.shading.fill` | object(model="rgb") \| object(model="theme") \| object(model="auto") | no | One of: object(model="rgb"), object(model="theme"), object(model="auto") | -| `markRunProps.shading.pattern` | string | no | | -| `markRunProps.shadow` | boolean | no | | -| `markRunProps.smallCaps` | boolean | no | | -| `markRunProps.specVanish` | boolean | no | | -| `markRunProps.strikethrough` | boolean | no | | -| `markRunProps.textEffect` | string | no | | -| `markRunProps.underline` | object | no | | -| `markRunProps.underline.color` | object(model="rgb") \| object(model="theme") \| object(model="auto") | no | One of: object(model="rgb"), object(model="theme"), object(model="auto") | -| `markRunProps.underline.style` | string | no | | -| `markRunProps.vanish` | boolean | no | | -| `markRunProps.verticalAlign` | enum | no | `"baseline"`, `"superscript"`, `"subscript"` | -| `markRunProps.webHidden` | boolean | no | | -| `target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | yes | One of: ParagraphAddress, HeadingAddress, ListItemAddress | - -### Example request - -```json -{ - "markRunProps": { - "fontSizeCs": 12.5, - "specVanish": true - }, - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `resolution` | object | yes | | -| `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 | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"CAPABILITY_UNAVAILABLE"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `resolution` | object | no | | -| `resolution.target` | ParagraphAddress \| HeadingAddress \| ListItemAddress | no | One of: ParagraphAddress, HeadingAddress, ListItemAddress | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "resolution": { - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } - }, - "success": true, - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph", - "story": { - "kind": "story", - "storyType": "body" - } - } -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `INVALID_TARGET` -- `CAPABILITY_UNAVAILABLE` - -## Non-applied failure codes - -- `NO_OP` -- `CAPABILITY_UNAVAILABLE` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "markRunProps": { - "additionalProperties": false, - "description": "Paragraph-mark run properties (SDRunProps shape, e.g. fontSizeCs, specVanish).", - "minProperties": 1, - "properties": { - "baselineShift": { - "type": "number" - }, - "bold": { - "type": "boolean" - }, - "boldCs": { - "type": "boolean" - }, - "border": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "model": { - "const": "rgb" - }, - "value": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "model", - "value" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "theme" - }, - "shade": { - "type": "integer" - }, - "theme": { - "minLength": 1, - "type": "string" - }, - "tint": { - "type": "integer" - } - }, - "required": [ - "model", - "theme" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "auto" - } - }, - "required": [ - "model" - ], - "type": "object" - } - ] - }, - "frame": { - "type": "boolean" - }, - "shadow": { - "type": "boolean" - }, - "space": { - "type": "number" - }, - "style": { - "minLength": 1, - "type": "string" - }, - "width": { - "type": "number" - } - }, - "type": "object" - }, - "caps": { - "type": "boolean" - }, - "characterScale": { - "type": "number" - }, - "characterSpacing": { - "type": "number" - }, - "color": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "model": { - "const": "rgb" - }, - "value": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "model", - "value" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "theme" - }, - "shade": { - "type": "integer" - }, - "theme": { - "minLength": 1, - "type": "string" - }, - "tint": { - "type": "integer" - } - }, - "required": [ - "model", - "theme" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "auto" - } - }, - "required": [ - "model" - ], - "type": "object" - } - ] - }, - "cs": { - "type": "boolean" - }, - "doubleStrikethrough": { - "type": "boolean" - }, - "emboss": { - "type": "boolean" - }, - "fitTextWidth": { - "type": "number" - }, - "fontFamily": { - "minLength": 1, - "type": "string" - }, - "fonts": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "ascii": { - "minLength": 1, - "type": "string" - }, - "asciiTheme": { - "minLength": 1, - "type": "string" - }, - "cs": { - "minLength": 1, - "type": "string" - }, - "csTheme": { - "minLength": 1, - "type": "string" - }, - "eastAsia": { - "minLength": 1, - "type": "string" - }, - "eastAsiaTheme": { - "minLength": 1, - "type": "string" - }, - "hAnsi": { - "minLength": 1, - "type": "string" - }, - "hAnsiTheme": { - "minLength": 1, - "type": "string" - }, - "hint": { - "minLength": 1, - "type": "string" - } - }, - "type": "object" - }, - "fontSize": { - "type": "number" - }, - "fontSizeCs": { - "type": "number" - }, - "highlight": { - "minLength": 1, - "type": "string" - }, - "imprint": { - "type": "boolean" - }, - "italic": { - "type": "boolean" - }, - "italicCs": { - "type": "boolean" - }, - "kern": { - "type": "number" - }, - "lang": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "bidi": { - "minLength": 1, - "type": "string" - }, - "eastAsia": { - "minLength": 1, - "type": "string" - }, - "val": { - "minLength": 1, - "type": "string" - } - }, - "type": "object" - }, - "outline": { - "type": "boolean" - }, - "rtl": { - "type": "boolean" - }, - "shading": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "model": { - "const": "rgb" - }, - "value": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "model", - "value" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "theme" - }, - "shade": { - "type": "integer" - }, - "theme": { - "minLength": 1, - "type": "string" - }, - "tint": { - "type": "integer" - } - }, - "required": [ - "model", - "theme" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "auto" - } - }, - "required": [ - "model" - ], - "type": "object" - } - ] - }, - "fill": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "model": { - "const": "rgb" - }, - "value": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "model", - "value" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "theme" - }, - "shade": { - "type": "integer" - }, - "theme": { - "minLength": 1, - "type": "string" - }, - "tint": { - "type": "integer" - } - }, - "required": [ - "model", - "theme" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "auto" - } - }, - "required": [ - "model" - ], - "type": "object" - } - ] - }, - "pattern": { - "minLength": 1, - "type": "string" - } - }, - "type": "object" - }, - "shadow": { - "type": "boolean" - }, - "smallCaps": { - "type": "boolean" - }, - "specVanish": { - "type": "boolean" - }, - "strikethrough": { - "type": "boolean" - }, - "textEffect": { - "minLength": 1, - "type": "string" - }, - "underline": { - "additionalProperties": false, - "minProperties": 1, - "properties": { - "color": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "model": { - "const": "rgb" - }, - "value": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "model", - "value" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "theme" - }, - "shade": { - "type": "integer" - }, - "theme": { - "minLength": 1, - "type": "string" - }, - "tint": { - "type": "integer" - } - }, - "required": [ - "model", - "theme" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "model": { - "const": "auto" - } - }, - "required": [ - "model" - ], - "type": "object" - } - ] - }, - "style": { - "minLength": 1, - "type": "string" - } - }, - "type": "object" - }, - "vanish": { - "type": "boolean" - }, - "verticalAlign": { - "enum": [ - "baseline", - "superscript", - "subscript" - ] - }, - "webHidden": { - "type": "boolean" - } - }, - "type": "object" - }, - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "target", - "markRunProps" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "resolution": { - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" - }, - "success": { - "const": true - }, - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "success", - "target", - "resolution" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "NO_OP", - "CAPABILITY_UNAVAILABLE" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "resolution": { - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "resolution": { - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" - }, - "success": { - "const": true - }, - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "success", - "target", - "resolution" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "NO_OP", - "CAPABILITY_UNAVAILABLE" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "resolution": { - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "$ref": "#/$defs/ParagraphAddress" - }, - { - "$ref": "#/$defs/HeadingAddress" - }, - { - "$ref": "#/$defs/ListItemAddress" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index bf5cefc400..921329b718 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -20,21 +20,21 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | | Core | 14 | 0 | 14 | [Open](/document-api/reference/core/index) | -| Blocks | 6 | 0 | 6 | [Open](/document-api/reference/blocks/index) | +| Blocks | 3 | 0 | 3 | [Open](/document-api/reference/blocks/index) | | 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 | 45 | 1 | 46 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | -| Lists | 44 | 0 | 44 | [Open](/document-api/reference/lists/index) | +| Lists | 39 | 0 | 39 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | | Mutations | 3 | 0 | 3 | [Open](/document-api/reference/mutations/index) | -| Paragraph Formatting | 21 | 0 | 21 | [Open](/document-api/reference/format/paragraph/index) | +| Paragraph Formatting | 20 | 0 | 20 | [Open](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Open](/document-api/reference/styles/paragraph/index) | | Templates | 1 | 0 | 1 | [Open](/document-api/reference/templates/index) | -| Tables | 48 | 0 | 48 | [Open](/document-api/reference/tables/index) | +| Tables | 47 | 0 | 47 | [Open](/document-api/reference/tables/index) | | History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | | Table of Contents | 10 | 0 | 10 | [Open](/document-api/reference/toc/index) | | Images | 27 | 0 | 27 | [Open](/document-api/reference/images/index) | @@ -87,9 +87,6 @@ The tables below are grouped by namespace. | blocks.list | editor.doc.blocks.list(...) | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `. | | blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | blocks.deleteRange | editor.doc.blocks.deleteRange(...) | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | -| blocks.split | editor.doc.blocks.split(...) | Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| blocks.merge | editor.doc.blocks.merge(...) | Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| blocks.move | editor.doc.blocks.move(...) | Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | #### Capabilities @@ -231,18 +228,13 @@ The tables below are grouped by namespace. | lists.setLevelText | editor.doc.lists.setLevelText(...) | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | | lists.setLevelStart | editor.doc.lists.setLevelStart(...) | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | | lists.setLevelLayout | editor.doc.lists.setLevelLayout(...) | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates: omitted fields are left unchanged. Sequence-local: clones shared definitions. | -| lists.getState | editor.doc.lists.getState(...) | Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| lists.apply | editor.doc.lists.apply(...) | Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| lists.continue | editor.doc.lists.continue(...) | Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| lists.restart | editor.doc.lists.restart(...) | Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| lists.remove | editor.doc.lists.remove(...) | Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | #### Comments | Operation | API member path | Description | | --- | --- | --- | | comments.create | editor.doc.comments.create(...) | Create a new comment thread (or reply when parentCommentId is given). Accepts TextAddress / TextTarget anchors, or a TrackedChangeCommentTarget that names a logical tracked-change id. The tracked-change target accepts both the explicit kind form and the Labs-compatible trackedChangeId-only form; the adapter normalizes it to a Word-compatible content anchor on the requested revision side. | -| comments.patch | editor.doc.comments.patch(...) | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | +| comments.patch | editor.doc.comments.patch(...) | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | | comments.delete | editor.doc.comments.delete(...) | Remove a comment or reply by ID. Deleting a root cascades through every descendant reply. | | comments.get | editor.doc.comments.get(...) | Retrieve a single comment thread by ID. | | comments.list | editor.doc.comments.list(...) | List all comment threads in the document. | @@ -290,7 +282,6 @@ The tables below are grouped by namespace. | format.paragraph.clearBorder | editor.doc.format.paragraph.clearBorder(...) | Remove border for a specific side or all sides of a paragraph. | | format.paragraph.setShading | editor.doc.format.paragraph.setShading(...) | Set paragraph shading (background fill, pattern color, pattern type). | | format.paragraph.clearShading | editor.doc.format.paragraph.clearShading(...) | Remove all paragraph shading. | -| format.paragraph.setMarkRunProps | editor.doc.format.paragraph.setMarkRunProps(...) | Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | format.paragraph.setDirection | editor.doc.format.paragraph.setDirection(...) | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | | format.paragraph.clearDirection | editor.doc.format.paragraph.clearDirection(...) | Remove explicit paragraph direction, reverting to inherited or default (LTR). | | format.paragraph.setNumbering | editor.doc.format.paragraph.setNumbering(...) | Attach numbering (numId + level) to an existing paragraph or heading so it joins a numbered sequence. Numbering is a paragraph property; the node and its style are otherwise unchanged, though any direct paragraph indent is cleared so the numbering level controls indentation. Direct edits only; tracked mode is unsupported. | @@ -321,7 +312,6 @@ The tables below are grouped by namespace. | tables.setLayout | editor.doc.tables.setLayout(...) | Set the layout mode of the target table. | | tables.insertRow | editor.doc.tables.insertRow(...) | Insert a new row into the target table. The new row is cloned from an adjacent row, so it inherits the existing cell shading, borders, alignment, and padding. No follow-up styling call is needed unless the new row should look different from the rest of the table. | | tables.deleteRow | editor.doc.tables.deleteRow(...) | Delete a row from the target table. | -| tables.moveRow | editor.doc.tables.moveRow(...) | Move a row to a new position within the same table. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | tables.setRowHeight | editor.doc.tables.setRowHeight(...) | Set the height of a table row. | | tables.distributeRows | editor.doc.tables.distributeRows(...) | Distribute row heights evenly across the target table. | | tables.setRowOptions | editor.doc.tables.setRowOptions(...) | Set options on a table row such as header repeat or page break. | @@ -517,8 +507,8 @@ The tables below are grouped by namespace. | --- | --- | --- | | footnotes.list | editor.doc.footnotes.list(...) | List all footnotes and endnotes in the document. | | footnotes.get | editor.doc.footnotes.get(...) | Get detailed information about a specific footnote or endnote. | -| footnotes.insert | editor.doc.footnotes.insert(...) | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | -| footnotes.update | editor.doc.footnotes.update(...) | Update the content of an existing footnote or endnote. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | +| footnotes.insert | editor.doc.footnotes.insert(...) | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | +| footnotes.update | editor.doc.footnotes.update(...) | Update the content of an existing footnote or endnote. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | | footnotes.remove | editor.doc.footnotes.remove(...) | Remove a footnote or endnote from the document. | | footnotes.configure | editor.doc.footnotes.configure(...) | Configure numbering and placement for footnotes or endnotes. | diff --git a/apps/docs/document-api/reference/lists/apply.mdx b/apps/docs/document-api/reference/lists/apply.mdx deleted file mode 100644 index 1cc135081e..0000000000 --- a/apps/docs/document-api/reference/lists/apply.mdx +++ /dev/null @@ -1,300 +0,0 @@ ---- -title: lists.apply -sidebarTitle: lists.apply -description: "Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `lists.apply` -- API member path: `editor.doc.lists.apply(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ListsMutateItemResult receipt with txId; partsChanged includes numbering / content-types / rels when first-list materialization fires. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `ilvl` | integer | no | | -| `reuseNumId` | string | no | | -| `seed` | enum | yes | `"bullet"`, `"ordered"` | -| `target` | object(kind="block") \| object(kind="block") | yes | One of: object(kind="block"), object(kind="block") | - -### Example request - -```json -{ - "ilvl": 1, - "reuseNumId": "example", - "seed": "bullet", - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `item` | ListItemAddress | yes | ListItemAddress | -| `item.kind` | `"block"` | yes | Constant: `"block"` | -| `item.nodeId` | string | yes | | -| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | -| `item.story` | StoryLocator | no | StoryLocator | -| `success` | `true` | yes | Constant: `true` | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"INVALID_CONTEXT"`, `"INVALID_INPUT"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "item": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "listItem", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` -- `INVALID_INPUT` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` -- `INVALID_INPUT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "ilvl": { - "maximum": 8, - "minimum": 0, - "type": "integer" - }, - "reuseNumId": { - "type": "string" - }, - "seed": { - "enum": [ - "bullet", - "ordered" - ] - }, - "target": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "paragraph" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "listItem" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - } - ] - } - }, - "required": [ - "target", - "seed" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/lists/continue.mdx b/apps/docs/document-api/reference/lists/continue.mdx deleted file mode 100644 index 3dd3180786..0000000000 --- a/apps/docs/document-api/reference/lists/continue.mdx +++ /dev/null @@ -1,275 +0,0 @@ ---- -title: lists.continue -sidebarTitle: lists.continue -description: "Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `lists.continue` -- API member path: `editor.doc.lists.continue(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ListsMutateItemResult receipt with txId. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `target` | object(kind="block") \| object(kind="block") | yes | One of: object(kind="block"), object(kind="block") | - -### Example request - -```json -{ - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `item` | ListItemAddress | yes | ListItemAddress | -| `item.kind` | `"block"` | yes | Constant: `"block"` | -| `item.nodeId` | string | yes | | -| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | -| `item.story` | StoryLocator | no | StoryLocator | -| `success` | `true` | yes | Constant: `true` | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"INVALID_CONTEXT"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "item": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "listItem", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "paragraph" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "listItem" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/lists/get-state.mdx b/apps/docs/document-api/reference/lists/get-state.mdx deleted file mode 100644 index ee11cfc440..0000000000 --- a/apps/docs/document-api/reference/lists/get-state.mdx +++ /dev/null @@ -1,246 +0,0 @@ ---- -title: lists.getState -sidebarTitle: lists.getState -description: "Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `lists.getState` -- API member path: `editor.doc.lists.getState(...)` -- Mutates document: `no` -- Idempotency: `idempotent` -- Supports tracked mode: `no` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ListsGetStateResult with `isListItem` plus numId/ilvl/abstract/numFmt metadata when present. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `target` | object(kind="block") \| object(kind="block") | yes | One of: object(kind="block"), object(kind="block") | - -### Example request - -```json -{ - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `abstractNumId` | string \| null | no | | -| `ilvl` | integer | yes | | -| `isListItem` | boolean | yes | | -| `lvlText` | string \| null | no | | -| `numFmt` | string \| null | no | | -| `numId` | string \| null | no | | -| `seed` | enum \| null | no | `"bullet"`, `"ordered"`, `null` | -| `success` | `true` | yes | Constant: `true` | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "abstractNumId": null, - "ilvl": 1, - "isListItem": true, - "numId": null, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` - -## Non-applied failure codes - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "paragraph" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "listItem" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "abstractNumId": { - "type": [ - "string", - "null" - ] - }, - "ilvl": { - "minimum": 0, - "type": "integer" - }, - "isListItem": { - "type": "boolean" - }, - "lvlText": { - "type": [ - "string", - "null" - ] - }, - "numFmt": { - "type": [ - "string", - "null" - ] - }, - "numId": { - "type": [ - "string", - "null" - ] - }, - "seed": { - "enum": [ - "bullet", - "ordered", - null - ], - "type": [ - "string", - "null" - ] - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "isListItem", - "ilvl" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx index 4c88beea6c..04fff4d247 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -51,9 +51,4 @@ List inspection and list mutations. | lists.setLevelText | `lists.setLevelText` | Yes | `conditional` | No | Yes | | lists.setLevelStart | `lists.setLevelStart` | Yes | `conditional` | No | Yes | | lists.setLevelLayout | `lists.setLevelLayout` | Yes | `conditional` | No | Yes | -| lists.getState | `lists.getState` | No | `idempotent` | No | No | -| lists.apply | `lists.apply` | Yes | `non-idempotent` | Yes | No | -| lists.continue | `lists.continue` | Yes | `non-idempotent` | Yes | No | -| lists.restart | `lists.restart` | Yes | `non-idempotent` | Yes | No | -| lists.remove | `lists.remove` | Yes | `non-idempotent` | Yes | No | diff --git a/apps/docs/document-api/reference/lists/remove.mdx b/apps/docs/document-api/reference/lists/remove.mdx deleted file mode 100644 index e60a6de252..0000000000 --- a/apps/docs/document-api/reference/lists/remove.mdx +++ /dev/null @@ -1,275 +0,0 @@ ---- -title: lists.remove -sidebarTitle: lists.remove -description: "Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `lists.remove` -- API member path: `editor.doc.lists.remove(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ListsMutateItemResult receipt with txId. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `target` | object(kind="block") \| object(kind="block") | yes | One of: object(kind="block"), object(kind="block") | - -### Example request - -```json -{ - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `item` | ListItemAddress | yes | ListItemAddress | -| `item.kind` | `"block"` | yes | Constant: `"block"` | -| `item.nodeId` | string | yes | | -| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | -| `item.story` | StoryLocator | no | StoryLocator | -| `success` | `true` | yes | Constant: `true` | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"INVALID_CONTEXT"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "item": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "listItem", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "target": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "paragraph" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "listItem" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/lists/restart.mdx b/apps/docs/document-api/reference/lists/restart.mdx deleted file mode 100644 index cd31335776..0000000000 --- a/apps/docs/document-api/reference/lists/restart.mdx +++ /dev/null @@ -1,285 +0,0 @@ ---- -title: lists.restart -sidebarTitle: lists.restart -description: "Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `lists.restart` -- API member path: `editor.doc.lists.restart(...)` -- Mutates document: `yes` -- Idempotency: `non-idempotent` -- Supports tracked mode: `yes` -- Supports dry run: `no` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a ListsMutateItemResult receipt with txId; partsChanged includes /word/numbering.xml. - -## Input fields - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `startAt` | integer | no | | -| `target` | object(kind="block") \| object(kind="block") | yes | One of: object(kind="block"), object(kind="block") | - -### Example request - -```json -{ - "startAt": 1, - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "paragraph" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `item` | ListItemAddress | yes | ListItemAddress | -| `item.kind` | `"block"` | yes | Constant: `"block"` | -| `item.nodeId` | string | yes | | -| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | -| `item.story` | StoryLocator | no | StoryLocator | -| `success` | `true` | yes | Constant: `true` | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"INVALID_CONTEXT"`, `"INVALID_INPUT"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - -### Example response - -```json -{ - "item": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "listItem", - "story": { - "kind": "story", - "storyType": "body" - } - }, - "success": true -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` -- `INVALID_INPUT` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `TARGET_NOT_FOUND` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_CONTEXT` -- `INVALID_INPUT` - -## Raw schemas - - -```json -{ - "additionalProperties": false, - "properties": { - "startAt": { - "minimum": 1, - "type": "integer" - }, - "target": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "paragraph" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "block" - }, - "nodeId": { - "type": "string" - }, - "nodeType": { - "const": "listItem" - } - }, - "required": [ - "kind", - "nodeType", - "nodeId" - ], - "type": "object" - } - ] - } - }, - "required": [ - "target" - ], - "type": "object" -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "item": { - "$ref": "#/$defs/ListItemAddress" - }, - "success": { - "const": true - } - }, - "required": [ - "success", - "item" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE", - "INVALID_CONTEXT", - "INVALID_INPUT" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 48140f3b77..eb9f3af0f2 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -67,7 +67,6 @@ Use these values in `steps[].op` when authoring mutation plans. | tables.setLayout | Set table layout mode. | tables.setLayout | | tables.insertRow | Insert a row into the target table. | tables.insertRow | | tables.deleteRow | Delete a row from the target table. | tables.deleteRow | -| tables.moveRow | Move a row to a new position within the same table. | tables.moveRow | | tables.setRowHeight | Set row height in the target table. | tables.setRowHeight | | tables.distributeRows | Distribute row heights evenly. | tables.distributeRows | | tables.setRowOptions | Set row-level options (header repeat, page break, etc.). | tables.setRowOptions | diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index 0778afbae3..941de9441f 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -67,7 +67,6 @@ Use these values in `steps[].op` when authoring mutation plans. | tables.setLayout | Set table layout mode. | tables.setLayout | | tables.insertRow | Insert a row into the target table. | tables.insertRow | | tables.deleteRow | Delete a row from the target table. | tables.deleteRow | -| tables.moveRow | Move a row to a new position within the same table. | tables.moveRow | | tables.setRowHeight | Set row height in the target table. | tables.setRowHeight | | tables.distributeRows | Distribute row heights evenly. | tables.distributeRows | | tables.setRowOptions | Set row-level options (header repeat, page break, etc.). | tables.setRowOptions | diff --git a/apps/docs/document-api/reference/tables/index.mdx b/apps/docs/document-api/reference/tables/index.mdx index 961a723f2e..d0d249ae23 100644 --- a/apps/docs/document-api/reference/tables/index.mdx +++ b/apps/docs/document-api/reference/tables/index.mdx @@ -25,7 +25,6 @@ For non-destructive table-targeted mutations, reuse `result.table.nodeId` from t | tables.setLayout | `tables.setLayout` | Yes | `idempotent` | No | Yes | | tables.insertRow | `tables.insertRow` | Yes | `non-idempotent` | Yes | Yes | | tables.deleteRow | `tables.deleteRow` | Yes | `conditional` | Yes | Yes | -| tables.moveRow | `tables.moveRow` | Yes | `conditional` | No | Yes | | tables.setRowHeight | `tables.setRowHeight` | Yes | `idempotent` | No | Yes | | tables.distributeRows | `tables.distributeRows` | Yes | `conditional` | No | Yes | | tables.setRowOptions | `tables.setRowOptions` | Yes | `idempotent` | No | Yes | diff --git a/apps/docs/document-api/reference/tables/move-row.mdx b/apps/docs/document-api/reference/tables/move-row.mdx deleted file mode 100644 index a96acc2fa5..0000000000 --- a/apps/docs/document-api/reference/tables/move-row.mdx +++ /dev/null @@ -1,698 +0,0 @@ ---- -title: tables.moveRow -sidebarTitle: tables.moveRow -description: "Move a row to a new position within the same table. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`." ---- - -{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} - -## Summary - -Move a row to a new position within the same table. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. - -- Operation ID: `tables.moveRow` -- API member path: `editor.doc.tables.moveRow(...)` -- Mutates document: `yes` -- Idempotency: `conditional` -- Supports tracked mode: `no` -- Supports dry run: `yes` -- Deterministic target resolution: `yes` - -## Expected result - -Returns a TableMutationResult receipt; reports NO_OP if the row is already at the requested position. - -## Input fields - -### Variant 1 (target.nodeType="tableRow") - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `destination` | object(kind="first") \| object(kind="last") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="first"), object(kind="last"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | -| `target` | TableRowAddress | yes | TableRowAddress | -| `target.kind` | `"block"` | yes | Constant: `"block"` | -| `target.nodeId` | string | yes | | -| `target.nodeType` | `"tableRow"` | yes | Constant: `"tableRow"` | - -### Variant 2 (target.nodeType="table") - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `destination` | object(kind="first") \| object(kind="last") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="first"), object(kind="last"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | -| `rowIndex` | integer | yes | | -| `target` | TableAddress | yes | TableAddress | -| `target.kind` | `"block"` | yes | Constant: `"block"` | -| `target.nodeId` | string | yes | | -| `target.nodeType` | `"table"` | yes | Constant: `"table"` | - -### Variant 3 (required: nodeId, rowIndex, destination) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `destination` | object(kind="first") \| object(kind="last") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") \| object(kind="before") \| object(kind="after") | yes | One of: object(kind="first"), object(kind="last"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after"), object(kind="before"), object(kind="after") | -| `nodeId` | string | yes | | -| `rowIndex` | integer | yes | | - -### Example request - -```json -{ - "destination": { - "kind": "first" - }, - "target": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "tableRow" - } -} -``` - -## Output fields - -### Variant 1 (success=true) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `success` | `true` | yes | Constant: `true` | -| `table` | TableAddress | no | TableAddress | -| `table.kind` | `"block"` | no | Constant: `"block"` | -| `table.nodeId` | string | no | | -| `table.nodeType` | `"table"` | no | Constant: `"table"` | -| `trackedChangeRefs` | EntityAddress[] | no | | - -### Variant 2 (success=false) - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | -| `failure.details` | any | no | | -| `failure.message` | string | yes | | -| `success` | `false` | yes | Constant: `false` | - - -When present, `result.table` is the follow-up address to reuse after this call. For non-destructive table-targeted mutations, pass `result.table.nodeId` to the next table operation instead of re-running `find()`. Destructive operations may omit `table`. - - -### Example response - -```json -{ - "success": true, - "table": { - "kind": "block", - "nodeId": "node-def456", - "nodeType": "table" - }, - "trackedChangeRefs": [ - { - "entityId": "entity-789", - "entityType": "comment", - "kind": "entity" - } - ] -} -``` - -## Pre-apply throws - -- `TARGET_NOT_FOUND` -- `INVALID_TARGET` -- `CAPABILITY_UNAVAILABLE` -- `INVALID_TARGET` - -## Non-applied failure codes - -- `INVALID_TARGET` -- `NO_OP` -- `CAPABILITY_UNAVAILABLE` - -## Raw schemas - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "destination": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "first" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "last" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - } - ] - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "target", - "destination" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "destination": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "first" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "last" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - } - ] - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - }, - "target": { - "$ref": "#/$defs/TableAddress" - } - }, - "required": [ - "target", - "rowIndex", - "destination" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "destination": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "first" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "last" - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "kind", - "rowIndex" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "required": [ - "kind", - "target" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "before" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "kind": { - "const": "after" - }, - "nodeId": { - "type": "string" - } - }, - "required": [ - "kind", - "nodeId" - ], - "type": "object" - } - ] - }, - "nodeId": { - "type": "string" - }, - "rowIndex": { - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "nodeId", - "rowIndex", - "destination" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "success": { - "const": true - }, - "table": { - "$ref": "#/$defs/TableAddress" - }, - "trackedChangeRefs": { - "items": { - "$ref": "#/$defs/EntityAddress" - }, - "type": "array" - } - }, - "required": [ - "success" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "NO_OP", - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "success": { - "const": true - }, - "table": { - "$ref": "#/$defs/TableAddress" - }, - "trackedChangeRefs": { - "items": { - "$ref": "#/$defs/EntityAddress" - }, - "type": "array" - } - }, - "required": [ - "success" - ], - "type": "object" -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "enum": [ - "NO_OP", - "INVALID_TARGET", - "TARGET_NOT_FOUND", - "CAPABILITY_UNAVAILABLE" - ] - }, - "details": {}, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "success": { - "const": false - } - }, - "required": [ - "success", - "failure" - ], - "type": "object" -} -``` - diff --git a/apps/docs/document-engine/diffing.mdx b/apps/docs/document-engine/diffing.mdx index 75e5d790db..a1e9814f64 100644 --- a/apps/docs/document-engine/diffing.mdx +++ b/apps/docs/document-engine/diffing.mdx @@ -118,12 +118,13 @@ asyncio.run(main()) - `Doc3` is based on `Doc1`, not `Doc2` - Body edits from `Doc2` are replayed onto `Doc1` as tracked changes +- Header and footer differences are detected and applied as part of the same compare flow - Comments, styles, and numbering changes are replayed directly - The output stays as a reviewable redline until someone accepts or rejects the tracked changes ## Current v1 limits -- Header and footer content is not diffed in v1 +- Header and footer changes are applied directly — they do not appear as reviewable tracked-change entries and cannot be individually accepted or rejected - The diff payload is opaque and intended for replay, not semantic inspection - `diff.apply` checks that the current base document fingerprint still matches the diff's `baseFingerprint` diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index e7fdf68eb6..20499aad42 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -577,9 +577,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `. | | `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | `doc.blocks.deleteRange` | `blocks delete-range` | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | -| `doc.blocks.split` | `blocks split` | Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.blocks.merge` | `blocks merge` | Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.blocks.move` | `blocks move` | Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.templates.apply` | `templates apply` | Apply detected DOCX template/substrate (styles, numbering, settings, theme, font table, web settings, headers/footers, section defaults) from a source package onto the current document, preserving body content. Scopes are auto-detected from source package evidence. | | `doc.query.match` | `query match` | Deterministic selector-based search returning mutation-grade addresses and text ranges. Use this to discover targets before any mutation. | | `doc.ranges.resolve` | `ranges resolve` | Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. | @@ -665,8 +662,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.bookmarks.remove` | `bookmarks remove` | Remove a bookmark from the document. | | `doc.footnotes.list` | `footnotes list` | List all footnotes and endnotes in the document. | | `doc.footnotes.get` | `footnotes get` | Get detailed information about a specific footnote or endnote. | -| `doc.footnotes.insert` | `footnotes insert` | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | -| `doc.footnotes.update` | `footnotes update` | Update the content of an existing footnote or endnote. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | +| `doc.footnotes.insert` | `footnotes insert` | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | +| `doc.footnotes.update` | `footnotes update` | Update the content of an existing footnote or endnote. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | | `doc.footnotes.remove` | `footnotes remove` | Remove a footnote or endnote from the document. | | `doc.footnotes.configure` | `footnotes configure` | Configure numbering and placement for footnotes or endnotes. | | `doc.crossRefs.list` | `cross-refs list` | List all cross-reference fields in the document. | @@ -816,7 +813,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | | `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | -| `doc.format.paragraph.setMarkRunProps` | `format paragraph set-mark-run-props` | Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.format.paragraph.setDirection` | `format paragraph set-direction` | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | | `doc.format.paragraph.clearDirection` | `format paragraph clear-direction` | Remove explicit paragraph direction, reverting to inherited or default (LTR). | | `doc.format.paragraph.setNumbering` | `format paragraph set-numbering` | Attach numbering (numId + level) to an existing paragraph or heading so it joins a numbered sequence. Numbering is a paragraph property; the node and its style are otherwise unchanged, though any direct paragraph indent is cleared so the numbering level controls indentation. Direct edits only; tracked mode is unsupported. | @@ -898,11 +894,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.setLevelText` | `lists set-level-text` | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | | `doc.lists.setLevelStart` | `lists set-level-start` | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | | `doc.lists.setLevelLayout` | `lists set-level-layout` | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates: omitted fields are left unchanged. Sequence-local: clones shared definitions. | -| `doc.lists.getState` | `lists get-state` | Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.apply` | `lists apply` | Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.continue` | `lists continue` | Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.restart` | `lists restart` | Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.remove` | `lists remove` | Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | #### Tables @@ -917,7 +908,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.tables.setLayout` | `tables set-layout` | Set the layout mode of the target table. | | `doc.tables.insertRow` | `tables insert-row` | Insert a new row into the target table. The new row is cloned from an adjacent row, so it inherits the existing cell shading, borders, alignment, and padding. No follow-up styling call is needed unless the new row should look different from the rest of the table. | | `doc.tables.deleteRow` | `tables delete-row` | Delete a row from the target table. | -| `doc.tables.moveRow` | `tables move-row` | Move a row to a new position within the same table. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.tables.setRowHeight` | `tables set-row-height` | Set the height of a table row. | | `doc.tables.distributeRows` | `tables distribute-rows` | Distribute row heights evenly across the target table. | | `doc.tables.setRowOptions` | `tables set-row-options` | Set options on a table row such as header repeat or page break. | @@ -1009,7 +999,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `doc.comments.create` | `comments create` | Create a new comment thread (or reply when parentCommentId is given). Accepts TextAddress / TextTarget anchors, or a TrackedChangeCommentTarget that names a logical tracked-change id. The tracked-change target accepts both the explicit kind form and the Labs-compatible trackedChangeId-only form; the adapter normalizes it to a Word-compatible content anchor on the requested revision side. | -| `doc.comments.patch` | `comments patch` | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | +| `doc.comments.patch` | `comments patch` | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | | `doc.comments.delete` | `comments delete` | Remove a comment or reply by ID. Deleting a root cascades through every descendant reply. | | `doc.comments.get` | `comments get` | Retrieve a single comment thread by ID. | | `doc.comments.list` | `comments list` | List all comment threads in the document. | @@ -1034,7 +1024,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. Pass `runtime: 'v1'` (default) or `runtime: 'v2'` to select the underlying engine. V2 single-socket collaboration supports only `y-websocket`; `hocuspocus` and `liveblocks` remain legacy v1-only collaboration providers. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `client.open` | `open` | Open a document and create a persistent editing session. Collaboration supports the `y-websocket`, `hocuspocus`, and `liveblocks` providers. 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. Supports explicit review-preserving, final, and original export modes. | | `doc.close` | `close` | Close the active editing session and clean up resources. | @@ -1069,9 +1059,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.blocks.list` | `blocks list` | List top-level blocks in document order with IDs, types, text previews, and optional full text when includeText:true. Supports pagination via offset/limit, optional nodeType filtering, and single-story scoping via `in: `. | | `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | | `doc.blocks.delete_range` | `blocks delete-range` | Delete a contiguous range of top-level blocks between two endpoints (inclusive). Both endpoints must be direct children of the document node. Supports dry-run preview. | -| `doc.blocks.split` | `blocks split` | Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.blocks.merge` | `blocks merge` | Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.blocks.move` | `blocks move` | Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.templates.apply` | `templates apply` | Apply detected DOCX template/substrate (styles, numbering, settings, theme, font table, web settings, headers/footers, section defaults) from a source package onto the current document, preserving body content. Scopes are auto-detected from source package evidence. | | `doc.query.match` | `query match` | Deterministic selector-based search returning mutation-grade addresses and text ranges. Use this to discover targets before any mutation. | | `doc.ranges.resolve` | `ranges resolve` | Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. | @@ -1157,8 +1144,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.bookmarks.remove` | `bookmarks remove` | Remove a bookmark from the document. | | `doc.footnotes.list` | `footnotes list` | List all footnotes and endnotes in the document. | | `doc.footnotes.get` | `footnotes get` | Get detailed information about a specific footnote or endnote. | -| `doc.footnotes.insert` | `footnotes insert` | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | -| `doc.footnotes.update` | `footnotes update` | Update the content of an existing footnote or endnote. Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes. | +| `doc.footnotes.insert` | `footnotes insert` | Insert a new footnote or endnote at a target location or the current selection. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | +| `doc.footnotes.update` | `footnotes update` | Update the content of an existing footnote or endnote. Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`. | | `doc.footnotes.remove` | `footnotes remove` | Remove a footnote or endnote from the document. | | `doc.footnotes.configure` | `footnotes configure` | Configure numbering and placement for footnotes or endnotes. | | `doc.cross_refs.list` | `cross-refs list` | List all cross-reference fields in the document. | @@ -1308,7 +1295,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | | `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | -| `doc.format.paragraph.set_mark_run_props` | `format paragraph set-mark-run-props` | Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.format.paragraph.set_direction` | `format paragraph set-direction` | Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match. | | `doc.format.paragraph.clear_direction` | `format paragraph clear-direction` | Remove explicit paragraph direction, reverting to inherited or default (LTR). | | `doc.format.paragraph.set_numbering` | `format paragraph set-numbering` | Attach numbering (numId + level) to an existing paragraph or heading so it joins a numbered sequence. Numbering is a paragraph property; the node and its style are otherwise unchanged, though any direct paragraph indent is cleared so the numbering level controls indentation. Direct edits only; tracked mode is unsupported. | @@ -1390,11 +1376,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.set_level_text` | `lists set-level-text` | Set the level text pattern (e.g. "%1.", "(%1)") for a specific list level. Uses OOXML level-placeholder syntax. Sequence-local: clones shared definitions. | | `doc.lists.set_level_start` | `lists set-level-start` | Set the start value for a specific list level. Rejects bullet levels and non-positive values. Sequence-local: clones shared definitions. | | `doc.lists.set_level_layout` | `lists set-level-layout` | Set the layout properties (alignment, indentation, trailing character, tab stop) for a specific list level. Accepts partial updates: omitted fields are left unchanged. Sequence-local: clones shared definitions. | -| `doc.lists.get_state` | `lists get-state` | Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.apply` | `lists apply` | Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.continue` | `lists continue` | Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.restart` | `lists restart` | Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | -| `doc.lists.remove` | `lists remove` | Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | #### Tables @@ -1409,7 +1390,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.tables.set_layout` | `tables set-layout` | Set the layout mode of the target table. | | `doc.tables.insert_row` | `tables insert-row` | Insert a new row into the target table. The new row is cloned from an adjacent row, so it inherits the existing cell shading, borders, alignment, and padding. No follow-up styling call is needed unless the new row should look different from the rest of the table. | | `doc.tables.delete_row` | `tables delete-row` | Delete a row from the target table. | -| `doc.tables.move_row` | `tables move-row` | Move a row to a new position within the same table. Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`. | | `doc.tables.set_row_height` | `tables set-row-height` | Set the height of a table row. | | `doc.tables.distribute_rows` | `tables distribute-rows` | Distribute row heights evenly across the target table. | | `doc.tables.set_row_options` | `tables set-row-options` | Set options on a table row such as header repeat or page break. | @@ -1501,7 +1481,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `doc.comments.create` | `comments create` | Create a new comment thread (or reply when parentCommentId is given). Accepts TextAddress / TextTarget anchors, or a TrackedChangeCommentTarget that names a logical tracked-change id. The tracked-change target accepts both the explicit kind form and the Labs-compatible trackedChangeId-only form; the adapter normalizes it to a Word-compatible content anchor on the requested revision side. | -| `doc.comments.patch` | `comments patch` | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | +| `doc.comments.patch` | `comments patch` | Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`. | | `doc.comments.delete` | `comments delete` | Remove a comment or reply by ID. Deleting a root cascades through every descendant reply. | | `doc.comments.get` | `comments get` | Retrieve a single comment thread by ID. | | `doc.comments.list` | `comments list` | List all comment threads in the document. | @@ -1526,7 +1506,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. Pass `runtime: 'v1'` (default) or `runtime: 'v2'` to select the underlying engine. V2 single-socket collaboration supports only `y-websocket`; `hocuspocus` and `liveblocks` remain legacy v1-only collaboration providers. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `client.open` | `open` | Open a document and create a persistent editing session. Collaboration supports the `y-websocket`, `hocuspocus`, and `liveblocks` providers. 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. Supports explicit review-preserving, final, and original export modes. | | `doc.close` | `close` | Close the active editing session and clean up resources. | diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 36c64cbe7b..3c4d41ab09 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -4602,7 +4602,7 @@ export const MCP_TOOL_CATALOG = { }, { "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 comment text, re-anchors the thread, or changes status to \"resolved\". The legacy `isInternal` field remains in schema for v1 compatibility but the v2 surface rejects it with `CAPABILITY_UNAVAILABLE`. 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\":\"\"}", + "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 comment text, re-anchors the thread, or changes status to \"resolved\". The legacy `isInternal` field remains in schema for v1 compatibility but is not supported for new comment patch behavior. 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": { @@ -4744,7 +4744,7 @@ export const MCP_TOOL_CATALOG = { }, "isInternal": { "type": "boolean", - "description": "Legacy v1/document-api compatibility field. Not a supported v2 behavior. V2 adapters MUST reject a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6). Only for action 'update'. Omit for other actions." + "description": "Legacy v1/document-api compatibility field. Not supported for new comment patch behavior. A `comments.patch` request containing `isInternal` fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6). Only for action 'update'. Omit for other actions." }, "includeResolved": { "type": "boolean", @@ -7435,7 +7435,6 @@ export const MCP_TOOL_CATALOG = { "insert_column", "insert_row", "merge_cells", - "move_row", "set_borders", "set_cell", "set_cell_text", @@ -7448,7 +7447,7 @@ export const MCP_TOOL_CATALOG = { "set_style_options", "unmerge_cells" ], - "description": "The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, move_row, 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", @@ -7496,34 +7495,20 @@ export const MCP_TOOL_CATALOG = { "oneOf": [ { "oneOf": [ + { + "$ref": "#/$defs/TableAddress" + }, { "oneOf": [ - { - "$ref": "#/$defs/TableAddress" - }, { "oneOf": [ { - "oneOf": [ - { - "$ref": "#/$defs/TableRowAddress" - }, - { - "$ref": "#/$defs/TableAddress" - } - ] + "$ref": "#/$defs/TableRowAddress" }, { "$ref": "#/$defs/TableAddress" } ] - } - ] - }, - { - "oneOf": [ - { - "$ref": "#/$defs/TableRowAddress" }, { "$ref": "#/$defs/TableAddress" @@ -7699,142 +7684,7 @@ export const MCP_TOOL_CATALOG = { "rowIndex": { "type": "integer", "minimum": 0, - "description": "Only for actions 'insert_row', 'delete_row', 'move_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions." - }, - "destination": { - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "const": "first", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "kind" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "last", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "kind" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "before", - "type": "string" - }, - "rowIndex": { - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false, - "required": [ - "kind", - "rowIndex" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "after", - "type": "string" - }, - "rowIndex": { - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false, - "required": [ - "kind", - "rowIndex" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "before", - "type": "string" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "additionalProperties": false, - "required": [ - "kind", - "target" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "after", - "type": "string" - }, - "target": { - "$ref": "#/$defs/TableRowAddress" - } - }, - "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" - ] - } - ], - "description": "Required for action 'move_row'." + "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", @@ -8377,26 +8227,6 @@ export const MCP_TOOL_CATALOG = { ] ] }, - { - "operationId": "doc.tables.moveRow", - "intentAction": "move_row", - "requiredOneOf": [ - [ - "target", - "destination" - ], - [ - "target", - "rowIndex", - "destination" - ], - [ - "nodeId", - "rowIndex", - "destination" - ] - ] - }, { "operationId": "doc.tables.setRowHeight", "intentAction": "set_row", diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index f2d59cca10..caaba98191 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -107,7 +107,6 @@ export function dispatchIntentTool( 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 'move_row': return execute('doc.tables.moveRow', 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); diff --git a/devtools/word-benchmark-sidecar/README.md b/devtools/word-benchmark-sidecar/README.md new file mode 100644 index 0000000000..ae92c87b27 --- /dev/null +++ b/devtools/word-benchmark-sidecar/README.md @@ -0,0 +1,61 @@ +# Word Baseline Sidecar (Dev Only) + +> **Prerequisite:** The `superdoc-benchmark` CLI must be installed globally before using this sidecar: +> +> ```bash +> npm i -g @superdoc-dev/visual-benchmarks +> ``` + +Local sidecar for `SuperdocDev.vue` that: + +1. accepts an exported DOCX payload, +2. runs `superdoc-benchmark word ... --force`, +3. serves generated `page_XXXX.png` images back to the dev UI. + +## Run + +From repo root: + +```bash +pnpm word-benchmark-sidecar +``` + +If a healthy sidecar is already running on the configured host/port, this command exits successfully and reuses the existing instance. + +Or run together with Vite: + +```bash +pnpm dev +``` + +## Requirements + +- macOS +- Microsoft Word +- `superdoc-benchmark` installed and available in `PATH` + +## API + +- `GET /health` +- `POST /api/word-baseline` + - JSON body: `{ "fileName": "document.docx", "docxBase64": "..." }` + - Response: `{ "jobId": "...", "pageCount": 2, "pages": ["http://.../api/word-baseline/jobs//pages/page_0001.png", ...] }` +- `GET /api/word-baseline/jobs/:jobId/pages/:pageName` + +## Security + +This server is for **local development only**. It listens on plain HTTP (default `127.0.0.1:9185`) and has no TLS configuration. + +**Never expose this server to the network.** The default bind address is loopback-only. If you change `SUPERDOC_WORD_BASELINE_HOST`, terminate TLS at a reverse proxy in front of the server. + +Risk acceptance: HTTP-only transport is an accepted trade-off for this local dev tool. + +## Environment + +- `SUPERDOC_WORD_BASELINE_HOST` (default `127.0.0.1`) +- `SUPERDOC_WORD_BASELINE_PORT` (default `9185`) +- `SUPERDOC_WORD_BASELINE_DIR` (default `${TMPDIR}/superdoc-word-baselines`) +- `SUPERDOC_WORD_BASELINE_MAX_DOCX_BYTES` (default `41943040`) +- `SUPERDOC_WORD_BASELINE_MAX_REQUEST_BYTES` (default `83886080`) +- `SUPERDOC_WORD_BASELINE_CLEANUP_AGE_MS` (default `21600000`) +- `SUPERDOC_WORD_BASELINE_CLEANUP_INTERVAL_MS` (default `600000`) diff --git a/devtools/word-benchmark-sidecar/package.json b/devtools/word-benchmark-sidecar/package.json new file mode 100644 index 0000000000..3fd79666d0 --- /dev/null +++ b/devtools/word-benchmark-sidecar/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "type": "module", + "scripts": { + "test": "node --test *.test.mjs" + } +} diff --git a/devtools/word-benchmark-sidecar/server.js b/devtools/word-benchmark-sidecar/server.js new file mode 100644 index 0000000000..c27883ff6b --- /dev/null +++ b/devtools/word-benchmark-sidecar/server.js @@ -0,0 +1,464 @@ +import http from 'node:http'; +import { spawn, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { fileURLToPath, URL } from 'node:url'; + +const HOST = process.env.SUPERDOC_WORD_BASELINE_HOST ?? '127.0.0.1'; +const PORT = Number.parseInt(process.env.SUPERDOC_WORD_BASELINE_PORT ?? '9185', 10); +const STAY_ALIVE_ON_REUSE = + process.argv.includes('--stay-alive-on-reuse') || process.env.SUPERDOC_WORD_BASELINE_STAY_ALIVE_ON_REUSE === '1'; +const JOB_ROOT = path.resolve( + process.env.SUPERDOC_WORD_BASELINE_DIR ?? path.join(os.tmpdir(), 'superdoc-word-baselines'), +); +const MAX_DOCX_BYTES = Number.parseInt(process.env.SUPERDOC_WORD_BASELINE_MAX_DOCX_BYTES ?? `${40 * 1024 * 1024}`, 10); +const MAX_REQUEST_BYTES = Number.parseInt( + process.env.SUPERDOC_WORD_BASELINE_MAX_REQUEST_BYTES ?? `${80 * 1024 * 1024}`, + 10, +); +const CLEANUP_AGE_MS = Number.parseInt( + process.env.SUPERDOC_WORD_BASELINE_CLEANUP_AGE_MS ?? `${6 * 60 * 60 * 1000}`, + 10, +); +const CLEANUP_INTERVAL_MS = Number.parseInt( + process.env.SUPERDOC_WORD_BASELINE_CLEANUP_INTERVAL_MS ?? `${10 * 60 * 1000}`, + 10, +); +const BENCHMARK_INSTALL_COMMAND = 'npm install -g @superdoc-dev/visual-benchmarks'; +const MISSING_BENCHMARK_MESSAGE = [ + "'superdoc-benchmark' was not found in your PATH.", + `Install it globally: ${BENCHMARK_INSTALL_COMMAND}`, + 'Then restart your SuperDoc dev server.', +].join('\n'); + +/** @type {Map} */ +const jobs = new Map(); + +let queueTail = Promise.resolve(); + +const withCors = (res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +}; + +const writeJson = (res, statusCode, payload) => { + withCors(res); + res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +}; + +const toErrorMessage = (error) => { + if (error instanceof Error) return error.message; + return String(error); +}; + +const createHttpError = (statusCode, message) => { + const err = new Error(message); + err.statusCode = statusCode; + return err; +}; + +const ensureDocxBufferWithinLimits = (docxBuffer) => { + if (!Buffer.isBuffer(docxBuffer) || docxBuffer.length === 0) { + throw createHttpError(400, 'Decoded docx payload is empty'); + } + + if (docxBuffer.length > MAX_DOCX_BYTES) { + throw createHttpError(413, `DOCX exceeds max size (${MAX_DOCX_BYTES} bytes)`); + } +}; + +const readJsonBody = async (req, maxBytes) => { + const chunks = []; + let received = 0; + + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + received += buffer.length; + + if (received > maxBytes) { + throw createHttpError(413, `Request payload exceeds ${maxBytes} bytes`); + } + + chunks.push(buffer); + } + + if (chunks.length === 0) return {}; + + const bodyText = Buffer.concat(chunks).toString('utf8'); + + try { + return JSON.parse(bodyText); + } catch { + throw createHttpError(400, 'Invalid JSON payload'); + } +}; + +const enqueue = (task) => { + const next = queueTail.then(task, task); + queueTail = next.catch(() => {}); + return next; +}; + +const sanitizeFileName = (input) => { + const fallback = 'document.docx'; + if (!input || typeof input !== 'string') return fallback; + + const base = path + .basename(input) + .replace(/[^A-Za-z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, ''); + + if (!base) return fallback; + if (base.toLowerCase().endsWith('.docx')) return base; + return `${base}.docx`; +}; + +const normalizeBase64 = (input) => { + if (typeof input !== 'string' || !input.trim()) { + throw createHttpError(400, 'docxBase64 is required'); + } + + const trimmed = input.trim(); + const commaIndex = trimmed.indexOf(','); + return commaIndex >= 0 ? trimmed.slice(commaIndex + 1) : trimmed; +}; + +const runWordCapture = async (docxPath, outputRoot) => { + const args = ['word', docxPath, '--force']; + const env = { + ...process.env, + SUPERDOC_BENCHMARK_SKIP_UPDATE_CHECK: '1', + }; + + await new Promise((resolve, reject) => { + const child = spawn('superdoc-benchmark', args, { + cwd: outputRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + child.on('error', (error) => { + if (error && error.code === 'ENOENT') { + reject(createHttpError(500, MISSING_BENCHMARK_MESSAGE)); + return; + } + reject(createHttpError(500, `Failed to start superdoc-benchmark: ${toErrorMessage(error)}`)); + }); + + child.on('close', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + const details = stderr.trim().split('\n').slice(-8).join('\n'); + reject( + createHttpError( + 500, + `superdoc-benchmark failed (code=${code ?? 'unknown'}, signal=${signal ?? 'none'})${details ? `\n${details}` : ''}`, + ), + ); + }); + }); +}; + +const findCapturedPages = async (outputRoot) => { + const capturesRoot = path.join(outputRoot, 'reports', 'word-captures'); + + let entries = []; + try { + entries = await fs.readdir(capturesRoot, { withFileTypes: true }); + } catch { + throw createHttpError(500, 'No Word capture directory was created'); + } + + const candidates = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const dirPath = path.join(capturesRoot, entry.name); + const files = await fs.readdir(dirPath); + const pages = files.filter((name) => /^page_\d{4}\.png$/i.test(name)).sort((a, b) => a.localeCompare(b, 'en')); + + if (pages.length === 0) continue; + + const stat = await fs.stat(dirPath); + candidates.push({ dirPath, pages, mtimeMs: stat.mtimeMs }); + } + + if (candidates.length === 0) { + throw createHttpError(500, 'Word capture completed but no page PNG files were found'); + } + + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + return candidates[0]; +}; + +const createJobFromBuffer = async ({ fileName, docxBuffer, requestOrigin }) => { + ensureDocxBufferWithinLimits(docxBuffer); + + const safeFileName = sanitizeFileName(fileName); + const jobId = crypto.randomUUID(); + const jobDir = path.join(JOB_ROOT, jobId); + const inputDir = path.join(jobDir, 'input'); + const outputDir = path.join(jobDir, 'output'); + const docxPath = path.join(inputDir, safeFileName); + + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(docxPath, docxBuffer); + + await runWordCapture(docxPath, outputDir); + + const capture = await findCapturedPages(outputDir); + + jobs.set(jobId, { + id: jobId, + createdAt: Date.now(), + fileName: safeFileName, + jobDir, + pagesDir: capture.dirPath, + pages: capture.pages, + }); + + return { + jobId, + fileName: safeFileName, + pageCount: capture.pages.length, + pages: capture.pages.map((pageName) => { + const encodedName = encodeURIComponent(pageName); + return `${requestOrigin}/api/word-baseline/jobs/${jobId}/pages/${encodedName}`; + }), + }; +}; + +const createJobFromBase64Payload = async ({ fileName, docxBase64, requestOrigin }) => { + const normalizedBase64 = normalizeBase64(docxBase64); + const docxBuffer = Buffer.from(normalizedBase64, 'base64'); + return createJobFromBuffer({ fileName, docxBuffer, requestOrigin }); +}; + +const cleanupExpiredJobs = async () => { + const cutoff = Date.now() - CLEANUP_AGE_MS; + const removals = []; + + for (const [jobId, job] of jobs.entries()) { + if (job.createdAt >= cutoff) continue; + jobs.delete(jobId); + removals.push(fs.rm(job.jobDir, { recursive: true, force: true })); + } + + if (removals.length > 0) { + await Promise.allSettled(removals); + } +}; + +const getRequestOrigin = (req) => { + const host = req.headers.host || `${HOST}:${PORT}`; + return `http://${host}`; +}; + +const parsePageRoute = (pathname) => { + const match = pathname.match(/^\/api\/word-baseline\/jobs\/([^/]+)\/pages\/([^/]+)$/); + if (!match) return null; + return { + jobId: decodeURIComponent(match[1]), + pageName: decodeURIComponent(match[2]), + }; +}; + +const waitForShutdownSignal = () => + new Promise((resolve) => { + // Keep this process alive when reusing an existing sidecar so concurrently + // doesn't treat WORD as "finished" and shut down Vite. + const keepAliveTimer = setInterval(() => {}, 60_000); + + const handleShutdown = () => { + clearInterval(keepAliveTimer); + process.off('SIGINT', handleShutdown); + process.off('SIGTERM', handleShutdown); + resolve(); + }; + + process.on('SIGINT', handleShutdown); + process.on('SIGTERM', handleShutdown); + }); + +const probeExistingSidecar = async () => { + const healthUrl = `http://${HOST}:${PORT}/health`; + try { + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(1500) }); + if (!response.ok) { + return { isHealthySidecar: false }; + } + + const payload = await response.json().catch(() => ({})); + const isHealthySidecar = + payload && payload.status === 'ok' && String(payload.host ?? '').length > 0 && Number(payload.port) === PORT; + + return { isHealthySidecar, payload }; + } catch { + return { isHealthySidecar: false }; + } +}; + +const startServer = () => + new Promise((resolve, reject) => { + let settled = false; + + const handleError = (error) => { + if (settled) return; + settled = true; + server.off('listening', handleListening); + reject(error); + }; + + const handleListening = () => { + if (settled) return; + settled = true; + server.off('error', handleError); + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(PORT, HOST); + }); + +export const handleRequest = async (req, res) => { + const method = req.method || 'GET'; + const requestUrl = new URL(req.url || '/', `http://${req.headers.host || `${HOST}:${PORT}`}`); + const { pathname } = requestUrl; + + if (method === 'OPTIONS') { + withCors(res); + res.writeHead(204); + res.end(); + return; + } + + if (method === 'GET' && pathname === '/health') { + writeJson(res, 200, { + status: 'ok', + queueSize: jobs.size, + host: HOST, + port: PORT, + }); + return; + } + + if (method === 'POST' && pathname === '/api/word-baseline') { + try { + const body = await readJsonBody(req, MAX_REQUEST_BYTES); + const requestOrigin = getRequestOrigin(req); + const result = await enqueue(() => + createJobFromBase64Payload({ + fileName: body?.fileName, + docxBase64: body?.docxBase64, + requestOrigin, + }), + ); + + writeJson(res, 200, result); + } catch (error) { + const statusCode = Number(error?.statusCode) || 500; + writeJson(res, statusCode, { error: toErrorMessage(error) }); + } + return; + } + + if (method === 'GET') { + const pageRoute = parsePageRoute(pathname); + if (pageRoute) { + const job = jobs.get(pageRoute.jobId); + if (!job) { + writeJson(res, 404, { error: `Unknown job: ${pageRoute.jobId}` }); + return; + } + + if (!job.pages.includes(pageRoute.pageName)) { + writeJson(res, 404, { error: `Unknown page: ${pageRoute.pageName}` }); + return; + } + + const pagePath = path.join(job.pagesDir, pageRoute.pageName); + try { + const png = await fs.readFile(pagePath); + withCors(res); + res.writeHead(200, { + 'Content-Type': 'image/png', + 'Cache-Control': 'no-store', + }); + res.end(png); + } catch (error) { + writeJson(res, 500, { error: `Failed to read page image: ${toErrorMessage(error)}` }); + } + return; + } + } + + writeJson(res, 404, { error: `Not found: ${method} ${pathname}` }); +}; + +const server = http.createServer(handleRequest); + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + await fs.mkdir(JOB_ROOT, { recursive: true }); + + const benchmarkCheck = spawnSync('superdoc-benchmark', ['--version'], { + stdio: 'ignore', + shell: false, + }); + if (benchmarkCheck.error?.code === 'ENOENT') { + console.warn(`[word-sidecar] ${MISSING_BENCHMARK_MESSAGE.replace(/\n/g, ' ')}`); + } + + const cleanupTimer = setInterval(() => { + cleanupExpiredJobs().catch((error) => { + console.warn(`[word-sidecar] cleanup error: ${toErrorMessage(error)}`); + }); + }, CLEANUP_INTERVAL_MS); + cleanupTimer.unref(); + + try { + await startServer(); + console.log(`[word-sidecar] listening on http://${HOST}:${PORT}`); + console.log(`[word-sidecar] job root: ${JOB_ROOT}`); + } catch (error) { + if (error?.code === 'EADDRINUSE') { + const probe = await probeExistingSidecar(); + if (probe.isHealthySidecar) { + if (STAY_ALIVE_ON_REUSE) { + console.log( + `[word-sidecar] word-sidecar is already running at http://${HOST}:${PORT}; skipping local start.`, + ); + await waitForShutdownSignal(); + process.exit(0); + } else { + console.log(`[word-sidecar] existing instance detected at http://${HOST}:${PORT}; reusing it.`); + process.exit(0); + } + } + + console.error(`[word-sidecar] port ${PORT} on ${HOST} is already in use by another process.`); + process.exit(1); + } + + console.error(`[word-sidecar] failed to start: ${toErrorMessage(error)}`); + process.exit(1); + } +} diff --git a/devtools/word-benchmark-sidecar/server.test.mjs b/devtools/word-benchmark-sidecar/server.test.mjs new file mode 100644 index 0000000000..14c59b3aaf --- /dev/null +++ b/devtools/word-benchmark-sidecar/server.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import test from "node:test"; +import { handleRequest } from "./server.js"; + +test("word baseline sidecar returns 404 for the removed local-path endpoint", async () => { + const response = await invokeHandler({ + method: "POST", + url: "/api/word-baseline/from-path", + body: JSON.stringify({ localPath: "/tmp/example.docx" }), + }); + + assert.equal(response.statusCode, 404); +}); + +test("word baseline sidecar still handles the base64 endpoint", async () => { + const response = await invokeHandler({ + method: "POST", + url: "/api/word-baseline", + body: JSON.stringify({ fileName: "example.docx" }), + }); + const payload = JSON.parse(response.body); + + assert.equal(response.statusCode, 400); + assert.equal(payload.error, "docxBase64 is required"); +}); + +function invokeHandler({ method, url, body = "" }) { + const req = Readable.from(body ? [Buffer.from(body)] : []); + req.method = method; + req.url = url; + req.headers = { + host: "127.0.0.1:9185", + "content-type": "application/json", + }; + + return new Promise((resolve, reject) => { + const headers = {}; + const chunks = []; + const res = { + setHeader(name, value) { + headers[name.toLowerCase()] = value; + }, + writeHead(statusCode, nextHeaders = {}) { + this.statusCode = statusCode; + for (const [name, value] of Object.entries(nextHeaders)) { + this.setHeader(name, value); + } + }, + end(chunk = "") { + if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + resolve({ + statusCode: this.statusCode, + headers, + body: Buffer.concat(chunks).toString("utf8"), + }); + }, + }; + + Promise.resolve(handleRequest(req, res)).catch(reject); + }); +} diff --git a/examples/__tests__/font-family-selection.spec.ts b/examples/__tests__/font-family-selection.spec.ts new file mode 100644 index 0000000000..f4e2a90b13 --- /dev/null +++ b/examples/__tests__/font-family-selection.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from '@playwright/test'; + +const example = process.env.EXAMPLE || 'react'; + +test.describe('font family selection screenshots', () => { + test.skip(example !== 'editor/theming', 'Run with EXAMPLE=editor/theming'); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.route('**/ingest.superdoc.dev/**', (route) => + route.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + }); + + test('toolbar font field reflects a single font and blanks a mixed selection', async ({ page }) => { + await page.goto('/?withToolbar=1'); + await page.waitForFunction(() => (window as any).__SUPERDOC_READY__ === true, null, { timeout: 15_000 }); + + await seedMixedFontDocument(page); + const fontField = page.locator('[data-item="btn-fontFamily"] input[role="combobox"]'); + + await selectText(page, 'Times'); + await expect(fontField).toHaveValue('Times New Roman'); + await expect(page.locator('body')).toHaveScreenshot('font-family-times-new-roman-selection.png', { + animations: 'disabled', + caret: 'hide', + }); + + await selectText(page, 'Times Arial'); + await expect(fontField).toHaveValue(''); + await expect(page.locator('body')).toHaveScreenshot('font-family-mixed-selection.png', { + animations: 'disabled', + caret: 'hide', + }); + }); +}); + +async function seedMixedFontDocument(page: Page) { + await page.evaluate(() => { + const editor = (window as any).__SUPERDOC__?.activeEditor; + if (!editor) throw new Error('SuperDoc editor is not ready'); + const findRange = (text: string) => { + const doc = editor.state.doc; + const max = doc.content.size; + + for (let from = 0; from <= max; from += 1) { + for (let to = from; to <= max; to += 1) { + if (doc.textBetween(from, to, '') === text) return { from, to }; + } + } + + throw new Error(`Could not find text range for "${text}"`); + }; + + editor.commands.setTextSelection(1); + editor.commands.insertContent('Times Arial', { contentType: 'text' }); + + const timesRange = findRange('Times'); + const arialRange = findRange('Arial'); + + editor.commands.setTextSelection(timesRange); + editor.commands.setFontFamily('Times New Roman'); + + editor.commands.setTextSelection(arialRange); + editor.commands.setFontFamily('Arial'); + + editor.commands.setTextSelection(timesRange); + editor.focus(); + }); +} + +async function selectText(page: Page, text: string) { + await page.evaluate((selectionText) => { + const editor = (window as any).__SUPERDOC__?.activeEditor; + if (!editor) throw new Error('SuperDoc editor is not ready'); + const doc = editor.state.doc; + const max = doc.content.size; + let range: { from: number; to: number } | null = null; + + for (let from = 0; from <= max; from += 1) { + for (let to = from; to <= max; to += 1) { + if (doc.textBetween(from, to, '') === selectionText) { + range = { from, to }; + break; + } + } + if (range) break; + } + + if (!range) throw new Error(`Could not find text range for "${selectionText}"`); + + editor.commands.setTextSelection(range); + editor.focus(); + }, text); +} diff --git a/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-mixed-selection-chromium-darwin.png b/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-mixed-selection-chromium-darwin.png new file mode 100644 index 0000000000..911d712905 Binary files /dev/null and b/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-mixed-selection-chromium-darwin.png differ diff --git a/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-times-new-roman-selection-chromium-darwin.png b/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-times-new-roman-selection-chromium-darwin.png new file mode 100644 index 0000000000..92347bea87 Binary files /dev/null and b/examples/__tests__/font-family-selection.spec.ts-snapshots/font-family-times-new-roman-selection-chromium-darwin.png differ diff --git a/examples/editor/theming/src/App.tsx b/examples/editor/theming/src/App.tsx index 33e2b9e2d2..5658afd238 100644 --- a/examples/editor/theming/src/App.tsx +++ b/examples/editor/theming/src/App.tsx @@ -3,6 +3,13 @@ import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; import { themes, presets, themeLabels, type ThemeKey } from './themes'; +declare global { + interface Window { + __SUPERDOC__?: SuperDoc; + __SUPERDOC_READY__?: boolean; + } +} + const allThemeClasses = [ ...Object.values(themes).filter(Boolean), ...Object.values(presets), @@ -22,6 +29,7 @@ export default function App() { const [theme, setTheme] = useState('default'); const containerRef = useRef(null); const superdocRef = useRef(null); + const withToolbar = new URLSearchParams(window.location.search).get('withToolbar') === '1'; useEffect(() => { applyTheme(theme); @@ -30,20 +38,34 @@ export default function App() { useEffect(() => { if (!containerRef.current) return; + if (withToolbar) window.__SUPERDOC_READY__ = false; superdocRef.current?.destroy(); superdocRef.current = new SuperDoc({ selector: containerRef.current, document: file ?? undefined, documentMode: 'editing', + ...(withToolbar + ? { + toolbar: '#toolbar', + onReady: ({ superdoc }: { superdoc: SuperDoc }) => { + window.__SUPERDOC__ = superdoc; + window.__SUPERDOC_READY__ = true; + }, + } + : {}), user: { name: 'Jane Doe', email: 'jane@example.com' }, - modules: { toolbar: true }, + modules: { toolbar: withToolbar ? {} : true }, }); return () => { superdocRef.current?.destroy(); superdocRef.current = null; + if (withToolbar) { + window.__SUPERDOC__ = undefined; + window.__SUPERDOC_READY__ = false; + } }; - }, [file]); + }, [file, withToolbar]); return (
@@ -93,6 +115,7 @@ export default function App() { + {withToolbar ?
: null}
); diff --git a/package.json b/package.json index f9cdd16a63..f5ecd39e26 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dev:superdoc": "pnpm --prefix packages/superdoc run dev", "dev:super-editor": "pnpm --prefix packages/super-editor run dev", "dev:collab": "pnpm --prefix packages/superdoc run dev:collab", + "word-benchmark-sidecar": "pnpm --prefix packages/superdoc run word-benchmark-sidecar", "dev:docs": "concurrently -k -n VITE,CDN,DOCS -c cyan,yellow,green \"pnpm --prefix packages/superdoc run dev\" \"pnpm --prefix packages/superdoc run watch:cdn\" \"pnpm --prefix apps/docs run dev\"", "build:superdoc": "pnpm --prefix packages/superdoc run build", "build:super-editor": "pnpm --prefix packages/super-editor run build", diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts index 2c4701dc32..bd437efa56 100644 --- a/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.test.ts @@ -54,17 +54,6 @@ describe('reference docs artifacts', () => { expect(formatApply!).toContain('- Operation ID: `format.apply`'); }); - it('generates a non-empty markRunProps example for paragraph mark run props docs', () => { - const artifacts = artifactContentByPath(); - - const setMarkRunPropsDoc = artifacts.get( - 'apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx', - ); - expect(setMarkRunPropsDoc).toBeDefined(); - expect(setMarkRunPropsDoc!).not.toContain('"markRunProps": {}'); - expect(setMarkRunPropsDoc!).toContain('"fontSizeCs"'); - }); - it('uses a valid raw TOC instruction in create.tableOfContents example requests', () => { const artifacts = artifactContentByPath(); diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts index d765fca6f9..c12ac757ec 100644 --- a/packages/document-api/scripts/lib/reference-docs-artifacts.ts +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts @@ -15,7 +15,6 @@ import { PUBLIC_STEP_OP_CATALOG, REFERENCE_OPERATION_ALIASES, REFERENCE_OPERATION_GROUPS, - V1_RUNTIME_UNAVAILABLE_OPERATION_IDS, type ReferenceAliasDefinition, type ReferenceOperationGroupDefinition, } from '../../src/index.js'; @@ -26,7 +25,6 @@ const REFERENCE_INDEX_PATH = `${OUTPUT_ROOT}/index.mdx`; const OVERVIEW_PATH = 'apps/docs/document-api/available-operations.mdx'; const OVERVIEW_OPERATIONS_START = '{/* DOC_API_OPERATIONS_START */}'; const OVERVIEW_OPERATIONS_END = '{/* DOC_API_OPERATIONS_END */}'; -const V1_RUNTIME_UNAVAILABLE_OPERATION_SET = new Set(V1_RUNTIME_UNAVAILABLE_OPERATION_IDS); interface OperationGroup { definition: ReferenceOperationGroupDefinition; @@ -1006,22 +1004,11 @@ function buildCapabilitiesOutputExample(snapshot: ReturnType [ entry.operationId, - V1_RUNTIME_UNAVAILABLE_OPERATION_SET.has(entry.operationId) - ? { - available: false, - tracked: false, - dryRun: false, - reasons: [ - 'OPERATION_UNAVAILABLE', - ...(entry.metadata.supportsTrackedMode ? ['TRACKED_MODE_UNAVAILABLE'] : []), - ...(entry.metadata.supportsDryRun ? ['DRY_RUN_UNAVAILABLE'] : []), - ], - } - : { - available: true, - tracked: entry.metadata.supportsTrackedMode, - dryRun: entry.metadata.supportsDryRun, - }, + { + available: true, + tracked: entry.metadata.supportsTrackedMode, + dryRun: entry.metadata.supportsDryRun, + }, ]), ), }; diff --git a/packages/document-api/src/blocks/blocks.test.ts b/packages/document-api/src/blocks/blocks.test.ts index ca14106a30..834b74c813 100644 --- a/packages/document-api/src/blocks/blocks.test.ts +++ b/packages/document-api/src/blocks/blocks.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it, mock } from 'bun:test'; -import { - executeBlocksDelete, - executeBlocksDeleteRange, - executeBlocksList, - executeBlocksMerge, - executeBlocksMove, - executeBlocksSplit, - type BlocksAdapter, -} from './blocks.js'; +import { executeBlocksDelete, executeBlocksDeleteRange, executeBlocksList, type BlocksAdapter } from './blocks.js'; import type { BlocksDeleteInput, BlocksDeleteResult, @@ -206,27 +198,6 @@ describe('executeBlocksDelete', () => { }); }); -describe('structural block capability fallbacks', () => { - it('returns CAPABILITY_UNAVAILABLE when legacy adapters omit split/merge/move hooks', () => { - const adapter = makeAdapter(); - const target = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }; - const destination = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p2' }; - - expect(executeBlocksSplit(adapter, { target, offset: 1 })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeBlocksMerge(adapter, { first: target, second: destination })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeBlocksMove(adapter, { source: target, destination, placement: 'after' })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - }); -}); - describe('executeBlocksList', () => { const footerStory: StoryLocator = { kind: 'story', diff --git a/packages/document-api/src/blocks/blocks.ts b/packages/document-api/src/blocks/blocks.ts index 457ee0024c..dea148a534 100644 --- a/packages/document-api/src/blocks/blocks.ts +++ b/packages/document-api/src/blocks/blocks.ts @@ -7,12 +7,6 @@ import type { BlocksListResult, BlocksDeleteRangeInput, BlocksDeleteRangeResult, - BlocksMergeInput, - BlocksMergeResult, - BlocksMoveInput, - BlocksMoveResult, - BlocksSplitInput, - BlocksSplitResult, } from '../types/blocks.types.js'; import { BLOCK_NODE_TYPES, DELETABLE_BLOCK_NODE_TYPES } from '../types/base.js'; import { storyLocatorToKey, type StoryLocator } from '../types/story.types.js'; @@ -25,22 +19,11 @@ export interface BlocksApi { list(input?: BlocksListInput): BlocksListResult; delete(input: BlocksDeleteInput, options?: MutationOptions): BlocksDeleteResult; deleteRange(input: BlocksDeleteRangeInput, options?: MutationOptions): BlocksDeleteRangeResult; - /** Split a paragraph at a visible-text offset. */ - split(input: BlocksSplitInput, options?: MutationOptions): BlocksSplitResult; - /** Merge two adjacent paragraphs in the same story. */ - merge(input: BlocksMergeInput, options?: MutationOptions): BlocksMergeResult; - /** Move a paragraph within the same story. */ - move(input: BlocksMoveInput, options?: MutationOptions): BlocksMoveResult; } export interface BlocksAdapter { list(input?: BlocksListInput): BlocksListResult; delete(input: BlocksDeleteInput, options?: MutationOptions): BlocksDeleteResult; deleteRange(input: BlocksDeleteRangeInput, options?: MutationOptions): BlocksDeleteRangeResult; - /** Structural block operations. Engines that don't support them - * may reject with CAPABILITY_UNAVAILABLE. */ - split?(input: BlocksSplitInput, options?: MutationOptions): BlocksSplitResult; - merge?(input: BlocksMergeInput, options?: MutationOptions): BlocksMergeResult; - move?(input: BlocksMoveInput, options?: MutationOptions): BlocksMoveResult; } // --------------------------------------------------------------------------- // Shared constants @@ -220,112 +203,3 @@ export function executeBlocksDeleteRange( validateBlocksDeleteRangeInput(input); return adapter.deleteRange(input, normalizeMutationOptions(options)); } -// --------------------------------------------------------------------------- -// blocks.split / blocks.merge / blocks.move validation -// --------------------------------------------------------------------------- -const PARAGRAPH_SHAPE_TYPES = new Set(['paragraph', 'heading', 'listItem']); -function validateBlockNodeAddressParagraph(address: unknown, operationLabel: string, field: string): void { - if (!address || typeof address !== 'object') { - throw new DocumentApiValidationError('INVALID_INPUT', `${operationLabel} requires a ${field}.`, { - fields: [field], - }); - } - const addr = address as Record; - if (addr.kind !== 'block') { - throw new DocumentApiValidationError('INVALID_INPUT', `${operationLabel} ${field}.kind must be "block".`, { - fields: [`${field}.kind`], - }); - } - if (!addr.nodeId || typeof addr.nodeId !== 'string') { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${operationLabel} ${field}.nodeId must be a non-empty string.`, - { fields: [`${field}.nodeId`] }, - ); - } - if (typeof addr.nodeType !== 'string' || !PARAGRAPH_SHAPE_TYPES.has(addr.nodeType)) { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `${operationLabel} ${field}.nodeType must be paragraph, heading, or listItem; got "${String(addr.nodeType)}".`, - { fields: [`${field}.nodeType`] }, - ); - } - validateStoryLocator(addr.story, `${field}.story`); -} -function validateBlocksSplitInput(input: BlocksSplitInput): void { - if (!input || typeof input !== 'object') { - throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.split requires an input object.', { - fields: ['input'], - }); - } - validateBlockNodeAddressParagraph(input.target, 'blocks.split', 'target'); - if (typeof input.offset !== 'number' || !Number.isInteger(input.offset) || input.offset < 0) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `blocks.split offset must be a non-negative integer, got ${JSON.stringify(input.offset)}.`, - { fields: ['offset'] }, - ); - } -} -function validateBlocksMergeInput(input: BlocksMergeInput): void { - if (!input || typeof input !== 'object') { - throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.merge requires an input object.', { - fields: ['input'], - }); - } - validateBlockNodeAddressParagraph(input.first, 'blocks.merge', 'first'); - validateBlockNodeAddressParagraph(input.second, 'blocks.merge', 'second'); -} -function validateBlocksMoveInput(input: BlocksMoveInput): void { - if (!input || typeof input !== 'object') { - throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.move requires an input object.', { - fields: ['input'], - }); - } - validateBlockNodeAddressParagraph(input.source, 'blocks.move', 'source'); - validateBlockNodeAddressParagraph(input.destination, 'blocks.move', 'destination'); - if (input.placement !== 'before' && input.placement !== 'after') { - throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.move placement must be "before" or "after".', { - fields: ['placement'], - }); - } -} -function unavailableBlocksResult( - operationName: string, -): TResult { - return { - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: `${operationName} is not available. The host engine has not provided an adapter for this capability.`, - details: { operation: operationName }, - }, - } as TResult; -} -export function executeBlocksSplit( - adapter: BlocksAdapter, - input: BlocksSplitInput, - options?: MutationOptions, -): BlocksSplitResult { - validateBlocksSplitInput(input); - if (!adapter.split) return unavailableBlocksResult('blocks.split'); - return adapter.split(input, normalizeMutationOptions(options)); -} -export function executeBlocksMerge( - adapter: BlocksAdapter, - input: BlocksMergeInput, - options?: MutationOptions, -): BlocksMergeResult { - validateBlocksMergeInput(input); - if (!adapter.merge) return unavailableBlocksResult('blocks.merge'); - return adapter.merge(input, normalizeMutationOptions(options)); -} -export function executeBlocksMove( - adapter: BlocksAdapter, - input: BlocksMoveInput, - options?: MutationOptions, -): BlocksMoveResult { - validateBlocksMoveInput(input); - if (!adapter.move) return unavailableBlocksResult('blocks.move'); - return adapter.move(input, normalizeMutationOptions(options)); -} diff --git a/packages/document-api/src/comments/comments.types.ts b/packages/document-api/src/comments/comments.types.ts index 69eb002309..2484944c8a 100644 --- a/packages/document-api/src/comments/comments.types.ts +++ b/packages/document-api/src/comments/comments.types.ts @@ -34,9 +34,8 @@ export interface TrackedChangeCommentTarget { story?: StoryLocator; } /** - * Labs/sdk-v2 shorthand for "anchor this comment to the first occurrence of - * this text". Adapters normalize it to a concrete TextAddress/TextTarget - * before mutation. + * Shorthand for "anchor this comment to the first occurrence of this text". + * Adapters normalize it to a concrete TextAddress/TextTarget before mutation. */ export interface TextSearchCommentTarget { text: string; @@ -110,8 +109,8 @@ export interface CommentInfo { * Compatibility aliases for consumers that predate `trackedChangeLink`. * `trackedChangeType` uses the legacy side vocabulary (`insert`, * `delete`, `format`) when there is a direct side equivalent, while the - * nested `trackedChangeLink.trackedChangeType` remains the canonical v2 - * broad type. + * nested `trackedChangeLink.trackedChangeType` remains the canonical broad + * type. */ trackedChange?: boolean; trackedChangeType?: TrackChangeType; @@ -122,12 +121,11 @@ export interface CommentInfo { insertedText?: string; deletedText?: string; /** - * Source `w:id` provenance when v2 had to repair the imported id to - * mint a canonical, Word-compatible `commentId`. Per - * `comments-spec.md` §13.2 / §13.4: present when the source id was - * missing, malformed, duplicated, or non-Word-compatible. Omitted - * when the source id was already a valid unique Word id and v2 kept - * it unchanged. + * Source `w:id` provenance when import repaired the incoming id to mint a + * canonical, Word-compatible `commentId`. Per `comments-spec.md` §13.2 / + * §13.4: present when the source id was missing, malformed, duplicated, or + * non-Word-compatible. Omitted when the source id was already a valid unique + * Word id and was kept unchanged. */ importedId?: string; parentCommentId?: string; @@ -137,13 +135,11 @@ export interface CommentInfo { imported?: boolean; text?: string; /** - * @deprecated Legacy `sdcom:internal` compatibility residue. v2 does - * not treat internal/private comments as a supported product feature - * (`comments-spec.md` §7 / §14.6). The v2 adapter omits this field - * from `comments.list` / `comments.get` projections; the field is - * kept in the type for backward-compatibility with v1 consumers and - * MUST be ignored in new code. The v2 adapter makes - * `comments.patch({ isInternal })` fail with `CAPABILITY_UNAVAILABLE`. + * @deprecated Legacy `sdcom:internal` compatibility residue. Internal/private + * comments are not supported for new patch behavior (`comments-spec.md` §7 / + * §14.6). The field is kept in the type for backward-compatibility with v1 + * consumers and MUST be ignored in new code. `comments.patch({ isInternal })` + * fails with `CAPABILITY_UNAVAILABLE`. */ isInternal?: boolean; status: CommentStatus; @@ -151,7 +147,7 @@ export interface CommentInfo { anchoredText?: string; /** * Creation timestamp in milliseconds. Omitted when the source had no - * `w:date` (`comments-spec.md` §3.1 / §13.2 — v2 MUST NOT fabricate). + * `w:date` (`comments-spec.md` §3.1 / §13.2). */ createdTime?: number; creatorName?: string; @@ -200,7 +196,7 @@ export interface CommentDomain { origin?: 'word' | 'google-docs' | 'superdoc' | 'custom' | 'unknown'; imported?: boolean; text?: string; - /** @deprecated See {@link CommentInfo.isInternal}. Legacy compatibility residue; v2 omits it from projections. */ + /** @deprecated See {@link CommentInfo.isInternal}. Legacy compatibility residue. */ isInternal?: boolean; status: CommentStatus; target?: TextTarget; diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts index 997182f412..6b2a201395 100644 --- a/packages/document-api/src/contract/index.ts +++ b/packages/document-api/src/contract/index.ts @@ -11,7 +11,6 @@ export * from './step-op-catalog.js'; export { INTENT_GROUP_META, OPERATION_DEFINITIONS, - V1_RUNTIME_UNAVAILABLE_OPERATION_IDS, projectFromDefinitions, type IntentGroupMeta, type OperationDefinitionEntry, diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index f1d93808ef..d5f5be9de2 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -98,28 +98,8 @@ export interface IntentGroupMeta { inputExamples?: Record[]; } -export const V1_RUNTIME_UNAVAILABLE_OPERATION_IDS = [ - 'blocks.split', - 'blocks.merge', - 'blocks.move', - 'lists.getState', - 'lists.apply', - 'lists.continue', - 'lists.restart', - 'lists.remove', - 'format.paragraph.setMarkRunProps', - 'tables.moveRow', -] as const; - -const V2_BACKED_ONLY_DESCRIPTION_NOTE = - ' Available on v2-backed sessions only; v1-backed sessions currently return `CAPABILITY_UNAVAILABLE`.'; - -function v2BackedOnlyDescription(description: string): string { - return `${description}${V2_BACKED_ONLY_DESCRIPTION_NOTE}`; -} - const FOOTNOTE_STRUCTURED_BODY_V1_NOTE = - ' Structured `body` content is currently available on v2-backed sessions only; v1-backed sessions return `CAPABILITY_UNAVAILABLE` for those shapes.'; + ' Structured `body` content is not available in this v1-only branch; those shapes return `CAPABILITY_UNAVAILABLE`.'; const FIELD_PRESERVE_CACHED_V1_NOTE = ' The current v1 runtime supports rebuild flows; `cachedResultText` / `updatePolicy: "preserveCached"` currently return `CAPABILITY_UNAVAILABLE`.'; @@ -419,7 +399,7 @@ export const INTENT_GROUP_META: Record = { '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 comment text, re-anchors the thread, or changes status to "resolved". The legacy `isInternal` field remains in schema for v1 compatibility but the v2 surface rejects it with `CAPABILITY_UNAVAILABLE`. Action "delete" removes a comment or reply by ID. ' + + 'Action "get" retrieves a single comment by ID. Action "update" changes comment text, re-anchors the thread, or changes status to "resolved". The legacy `isInternal` field remains in schema for v1 compatibility but is not supported for new comment patch behavior. 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.', inputExamples: [ { @@ -1044,78 +1024,6 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'blocks/delete-range.mdx', referenceGroup: 'blocks', }, - 'blocks.split': { - memberPath: 'blocks.split', - description: v2BackedOnlyDescription( - 'Split a paragraph at a visible-text offset, producing two paragraphs. Preserves unambiguous simple run properties around the cut. Rejects when the paragraph contains fields, content controls, drawings, equations, or unsupported tracked-change wrappers.', - ), - expectedResult: - 'Returns a BlocksSplitResult; on success carries the new tail paragraph address, affectedStories, and (for engines that report them) story-absolute textRangeShifts and a txId for history correlation.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: [ - 'INVALID_TARGET', - 'TARGET_NOT_FOUND', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_CONTEXT', - 'INVALID_INPUT', - ], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], - }), - referenceDocPath: 'blocks/split.mdx', - referenceGroup: 'blocks', - }, - 'blocks.merge': { - memberPath: 'blocks.merge', - description: v2BackedOnlyDescription( - 'Merge two adjacent paragraphs in the same story. The first paragraph keeps its pPr; the second paragraph is removed. Rejects when either paragraph carries a w:sectPr or when their numbering definitions differ.', - ), - expectedResult: - 'Returns a BlocksMergeResult; on success carries the removed paragraph address plus affectedStories, textRangeShifts, and a txId.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: [ - 'INVALID_TARGET', - 'TARGET_NOT_FOUND', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_CONTEXT', - 'INVALID_INPUT', - ], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], - }), - referenceDocPath: 'blocks/merge.mdx', - referenceGroup: 'blocks', - }, - 'blocks.move': { - memberPath: 'blocks.move', - description: v2BackedOnlyDescription( - 'Move a paragraph within the same story to a new anchor paragraph (before or after). Cross-story and relationship-bearing moves reject with named reasons. Tracked-mode authoring emits paired `` / `` review state with explicit move range marker identity; safe content (no comment/bookmark/permission anchors and no pre-existing tracked wrappers) is required for tracked authoring.', - ), - expectedResult: - 'Returns a BlocksMoveResult; on success carries the moved paragraph address plus affectedStories and a txId. Tracked-mode results include `trackedChangeRefs` pointing at the paired move review entity.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: [ - 'INVALID_TARGET', - 'TARGET_NOT_FOUND', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_CONTEXT', - 'INVALID_INPUT', - ], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], - }), - referenceDocPath: 'blocks/move.mdx', - referenceGroup: 'blocks', - }, 'format.apply': { memberPath: 'format.apply', description: 'Apply inline run-property patch changes to the target range with explicit set/clear semantics.', @@ -1823,24 +1731,6 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'format/paragraph/clear-shading.mdx', referenceGroup: 'format.paragraph', }, - 'format.paragraph.setMarkRunProps': { - memberPath: 'format.paragraph.setMarkRunProps', - description: v2BackedOnlyDescription( - "Set the paragraph mark's run properties (w:pPr/w:rPr), e.g. the font size or specVanish carried by the paragraph-end mark.", - ), - expectedResult: - 'Returns a ParagraphMutationResult; reports NO_OP if the encoded mark run properties already match.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'CAPABILITY_UNAVAILABLE'], - throws: T_PARAGRAPH_MUTATION, - }), - referenceDocPath: 'format/paragraph/set-mark-run-props.mdx', - referenceGroup: 'format.paragraph', - }, 'format.paragraph.setDirection': { memberPath: 'format.paragraph.setDirection', description: 'Set paragraph base direction (LTR or RTL via w:bidi). Optionally align text to match.', @@ -2521,103 +2411,6 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'lists/set-level-layout.mdx', referenceGroup: 'lists', }, - // v2 numbering-aware list operation surface. - 'lists.getState': { - memberPath: 'lists.getState', - description: v2BackedOnlyDescription( - 'Read the numbering-aware list state for a paragraph (numId, ilvl, abstract reference, level format). Returns null when the target is not a list item.', - ), - expectedResult: - 'Returns a ListsGetStateResult with `isListItem` plus numId/ilvl/abstract/numFmt metadata when present.', - requiresDocumentContext: true, - metadata: readOperation({ - possibleFailureCodes: ['TARGET_NOT_FOUND', 'CAPABILITY_UNAVAILABLE'], - throws: [...T_NOT_FOUND_CAPABLE], - }), - referenceDocPath: 'lists/get-state.mdx', - referenceGroup: 'lists', - }, - 'lists.apply': { - memberPath: 'lists.apply', - description: v2BackedOnlyDescription( - 'Apply a numbering definition to a paragraph. When `/word/numbering.xml` is absent it is materialized atomically together with the package content-type override and document relationship. Seeds bullet or ordered single-level definitions when no `reuseNumId` is provided.', - ), - expectedResult: - 'Returns a ListsMutateItemResult receipt with txId; partsChanged includes numbering / content-types / rels when first-list materialization fires.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: [ - 'INVALID_TARGET', - 'TARGET_NOT_FOUND', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_CONTEXT', - 'INVALID_INPUT', - ], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], - }), - referenceDocPath: 'lists/apply.mdx', - referenceGroup: 'lists', - }, - 'lists.continue': { - memberPath: 'lists.continue', - description: v2BackedOnlyDescription( - "Continue from the previous compatible list item in the same story. Adopts the previous paragraph's numId+ilvl. Rejects with named reasons when no compatible previous item exists or when an intervening structural boundary blocks the continuation.", - ), - expectedResult: 'Returns a ListsMutateItemResult receipt with txId.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET', 'TARGET_NOT_FOUND', 'CAPABILITY_UNAVAILABLE', 'INVALID_CONTEXT'], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], - }), - referenceDocPath: 'lists/continue.mdx', - referenceGroup: 'lists', - }, - 'lists.restart': { - memberPath: 'lists.restart', - description: v2BackedOnlyDescription( - 'Restart numbering at a list item. Creates a new `` that references the existing `` with a ``. Distant paragraphs sharing the old numId are intentionally untouched.', - ), - expectedResult: 'Returns a ListsMutateItemResult receipt with txId; partsChanged includes /word/numbering.xml.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: [ - 'INVALID_TARGET', - 'TARGET_NOT_FOUND', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_CONTEXT', - 'INVALID_INPUT', - ], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], - }), - referenceDocPath: 'lists/restart.mdx', - referenceGroup: 'lists', - }, - 'lists.remove': { - memberPath: 'lists.remove', - description: v2BackedOnlyDescription( - 'Strip the `` from a list-item paragraph. The numbering definition in `/word/numbering.xml` is intentionally NOT modified; orphan cleanup is handled by the export-side stripper.', - ), - expectedResult: 'Returns a ListsMutateItemResult receipt with txId.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET', 'TARGET_NOT_FOUND', 'CAPABILITY_UNAVAILABLE', 'INVALID_CONTEXT'], - throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], - }), - referenceDocPath: 'lists/remove.mdx', - referenceGroup: 'lists', - }, 'comments.create': { memberPath: 'comments.create', description: @@ -2646,7 +2439,7 @@ export const OPERATION_DEFINITIONS = { 'comments.patch': { memberPath: 'comments.patch', description: - 'Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not a supported v2 product behavior. V2 adapters MUST fail a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`.', + 'Patch exactly one field on an existing comment (`text`, `target`, or `status`). The `target` branch accepts a plain TextAddress or a TrackedChangeCommentTarget, with or without `kind: "trackedChange"`, that names a logical tracked-change id. The legacy `isInternal` input is preserved in the schema for v1 backward compatibility but is not supported for new patch behavior and fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). Multi-field patches and no-op edits are rejected with `INVALID_INPUT`; reply target / status patches are rejected with `INVALID_CONTEXT`.', expectedResult: 'Returns a Receipt with `updated` populated on success. Reports `NO_OP` for byte-identical edits, rejects empty-body / trim-equivalent edits with `INVALID_INPUT`, no-op resolve/reopen with `INVALID_INPUT`, reply target/status with `INVALID_CONTEXT`, cross-story moves with `INVALID_CONTEXT`, materializing inherited slot moves with `CAPABILITY_UNAVAILABLE`, stale tracked-change targets with `TARGET_NOT_FOUND`, formatting / structural / unpaired-move tracked-change targets with `CAPABILITY_UNAVAILABLE`, and `isInternal` patches with `CAPABILITY_UNAVAILABLE`.', requiresDocumentContext: true, @@ -3068,24 +2861,6 @@ export const OPERATION_DEFINITIONS = { intentGroup: 'table', intentAction: 'delete_row', }, - 'tables.moveRow': { - memberPath: 'tables.moveRow', - description: v2BackedOnlyDescription('Move a row to a new position within the same table.'), - expectedResult: - 'Returns a TableMutationResult receipt; reports NO_OP if the row is already at the requested position.', - requiresDocumentContext: true, - metadata: mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET', 'NO_OP', 'CAPABILITY_UNAVAILABLE'], - throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], - }), - referenceDocPath: 'tables/move-row.mdx', - referenceGroup: 'tables', - intentGroup: 'table', - intentAction: 'move_row', - }, 'tables.setRowHeight': { memberPath: 'tables.setRowHeight', description: 'Set the height of a table row.', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 709f4b7281..3f598fb054 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -25,12 +25,6 @@ import type { BlocksListResult, BlocksDeleteRangeInput, BlocksDeleteRangeResult, - BlocksMergeInput, - BlocksMergeResult, - BlocksMoveInput, - BlocksMoveResult, - BlocksSplitInput, - BlocksSplitResult, } from '../types/blocks.types.js'; import type { GetNodeByIdInput } from '../get-node/get-node.js'; import type { GetTextInput } from '../get-text/get-text.js'; @@ -142,12 +136,6 @@ import type { ListsSetLevelTextInput, ListsSetLevelStartInput, ListsSetLevelLayoutInput, - ListsGetStateInput, - ListsGetStateResult, - ListsApplyInput, - ListsContinueV2Input, - ListsRestartV2Input, - ListsRemoveV2Input, } from '../lists/lists.types.js'; import type { ParagraphMutationResult, @@ -170,7 +158,6 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, - ParagraphsSetMarkRunPropsInput, ParagraphsSetDirectionInput, ParagraphsClearDirectionInput, ParagraphsSetNumberingInput, @@ -417,7 +404,6 @@ import type { TablesSetLayoutInput, TablesInsertRowInput, TablesDeleteRowInput, - TablesMoveRowInput, TablesSetRowHeightInput, TablesDistributeRowsInput, TablesSetRowOptionsInput, @@ -585,9 +571,6 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'blocks.list': { input: BlocksListInput | undefined; options: never; output: BlocksListResult }; 'blocks.delete': { input: BlocksDeleteInput; options: MutationOptions; output: BlocksDeleteResult }; 'blocks.deleteRange': { input: BlocksDeleteRangeInput; options: MutationOptions; output: BlocksDeleteRangeResult }; - 'blocks.split': { input: BlocksSplitInput; options: MutationOptions; output: BlocksSplitResult }; - 'blocks.merge': { input: BlocksMergeInput; options: MutationOptions; output: BlocksMergeResult }; - 'blocks.move': { input: BlocksMoveInput; options: MutationOptions; output: BlocksMoveResult }; // --- format.* --- 'format.apply': { input: StyleApplyInput; options: MutationOptions; output: TextMutationReceipt }; // --- styles.paragraph.* --- @@ -687,11 +670,6 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { options: MutationOptions; output: ParagraphMutationResult; }; - 'format.paragraph.setMarkRunProps': { - input: ParagraphsSetMarkRunPropsInput; - options: MutationOptions; - output: ParagraphMutationResult; - }; 'format.paragraph.setDirection': { input: ParagraphsSetDirectionInput; options: MutationOptions; @@ -807,12 +785,6 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'lists.setLevelText': { input: ListsSetLevelTextInput; options: MutationOptions; output: ListsMutateItemResult }; 'lists.setLevelStart': { input: ListsSetLevelStartInput; options: MutationOptions; output: ListsMutateItemResult }; 'lists.setLevelLayout': { input: ListsSetLevelLayoutInput; options: MutationOptions; output: ListsMutateItemResult }; - // --- lists.* (v2 numbering-aware) --- - 'lists.getState': { input: ListsGetStateInput; options: never; output: ListsGetStateResult }; - 'lists.apply': { input: ListsApplyInput; options: MutationOptions; output: ListsMutateItemResult }; - 'lists.continue': { input: ListsContinueV2Input; options: MutationOptions; output: ListsMutateItemResult }; - 'lists.restart': { input: ListsRestartV2Input; options: MutationOptions; output: ListsMutateItemResult }; - 'lists.remove': { input: ListsRemoveV2Input; options: MutationOptions; output: ListsMutateItemResult }; // --- sections.* --- 'sections.list': { input: SectionsListQuery | undefined; options: never; output: SectionsListResult }; 'sections.get': { input: SectionsGetInput; options: never; output: SectionInfo }; @@ -935,7 +907,6 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'tables.setLayout': { input: TablesSetLayoutInput; options: MutationOptions; output: TableMutationResult }; 'tables.insertRow': { input: TablesInsertRowInput; options: MutationOptions; output: TableMutationResult }; 'tables.deleteRow': { input: TablesDeleteRowInput; options: MutationOptions; output: TableMutationResult }; - 'tables.moveRow': { input: TablesMoveRowInput; options: MutationOptions; output: TableMutationResult }; 'tables.setRowHeight': { input: TablesSetRowHeightInput; options: MutationOptions; output: TableMutationResult }; 'tables.distributeRows': { input: TablesDistributeRowsInput; options: MutationOptions; output: TableMutationResult }; 'tables.setRowOptions': { input: TablesSetRowOptionsInput; options: MutationOptions; output: TableMutationResult }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index ee3a0f1435..e9ff31f731 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -708,28 +708,6 @@ const tableOrCellAddressSchema = ref('TableOrCellAddress'); const paragraphAddressSchema = ref('ParagraphAddress'); const headingAddressSchema = ref('HeadingAddress'); const listItemAddressSchema = ref('ListItemAddress'); -// v2 list ops accept paragraph blocks or list-item blocks -// (the substrate identifies the paragraph by w14:paraId regardless). -const listsV2BlockTargetSchema: JsonSchema = { - oneOf: [ - objectSchema( - { - kind: { const: 'block' }, - nodeType: { const: 'paragraph' }, - nodeId: { type: 'string' }, - }, - ['kind', 'nodeType', 'nodeId'], - ), - objectSchema( - { - kind: { const: 'block' }, - nodeType: { const: 'listItem' }, - nodeId: { type: 'string' }, - }, - ['kind', 'nodeType', 'nodeId'], - ), - ], -}; const paragraphTargetSchema: JsonSchema = { oneOf: [paragraphAddressSchema, headingAddressSchema, listItemAddressSchema], }; @@ -761,119 +739,6 @@ void inlineNodeAddressSchema; void textMutationRangeSchema; void entityAddressSchema; void matchRunSchema; -const markRunColorRefSchema: JsonSchema = { - oneOf: [ - objectSchema( - { - model: { const: 'rgb' }, - value: { type: 'string', minLength: 1 }, - }, - ['model', 'value'], - ), - objectSchema( - { - model: { const: 'theme' }, - theme: { type: 'string', minLength: 1 }, - tint: { type: 'integer' }, - shade: { type: 'integer' }, - }, - ['model', 'theme'], - ), - objectSchema( - { - model: { const: 'auto' }, - }, - ['model'], - ), - ], -}; -const markRunFontsSchema: JsonSchema = { - ...objectSchema({ - ascii: { type: 'string', minLength: 1 }, - hAnsi: { type: 'string', minLength: 1 }, - eastAsia: { type: 'string', minLength: 1 }, - cs: { type: 'string', minLength: 1 }, - asciiTheme: { type: 'string', minLength: 1 }, - hAnsiTheme: { type: 'string', minLength: 1 }, - eastAsiaTheme: { type: 'string', minLength: 1 }, - csTheme: { type: 'string', minLength: 1 }, - hint: { type: 'string', minLength: 1 }, - }), - minProperties: 1, -}; -const markRunLanguagesSchema: JsonSchema = { - ...objectSchema({ - val: { type: 'string', minLength: 1 }, - eastAsia: { type: 'string', minLength: 1 }, - bidi: { type: 'string', minLength: 1 }, - }), - minProperties: 1, -}; -const markRunUnderlineSchema: JsonSchema = { - ...objectSchema({ - style: { type: 'string', minLength: 1 }, - color: markRunColorRefSchema, - }), - minProperties: 1, -}; -const markRunShadingSchema: JsonSchema = { - ...objectSchema({ - fill: markRunColorRefSchema, - color: markRunColorRefSchema, - pattern: { type: 'string', minLength: 1 }, - }), - minProperties: 1, -}; -const markRunBorderSchema: JsonSchema = { - ...objectSchema({ - style: { type: 'string', minLength: 1 }, - width: { type: 'number' }, - space: { type: 'number' }, - color: markRunColorRefSchema, - frame: { type: 'boolean' }, - shadow: { type: 'boolean' }, - }), - minProperties: 1, -}; -const markRunPropsSchema: JsonSchema = { - ...objectSchema({ - fontSizeCs: { type: 'number' }, - specVanish: { type: 'boolean' }, - fontSize: { type: 'number' }, - fonts: markRunFontsSchema, - fontFamily: { type: 'string', minLength: 1 }, - lang: markRunLanguagesSchema, - color: markRunColorRefSchema, - highlight: { type: 'string', minLength: 1 }, - shading: markRunShadingSchema, - cs: { type: 'boolean' }, - rtl: { type: 'boolean' }, - bold: { type: 'boolean' }, - boldCs: { type: 'boolean' }, - italic: { type: 'boolean' }, - italicCs: { type: 'boolean' }, - underline: markRunUnderlineSchema, - strikethrough: { type: 'boolean' }, - doubleStrikethrough: { type: 'boolean' }, - caps: { type: 'boolean' }, - smallCaps: { type: 'boolean' }, - outline: { type: 'boolean' }, - shadow: { type: 'boolean' }, - emboss: { type: 'boolean' }, - imprint: { type: 'boolean' }, - verticalAlign: { enum: ['baseline', 'superscript', 'subscript'] }, - characterSpacing: { type: 'number' }, - characterScale: { type: 'number' }, - kern: { type: 'number' }, - baselineShift: { type: 'number' }, - fitTextWidth: { type: 'number' }, - vanish: { type: 'boolean' }, - webHidden: { type: 'boolean' }, - border: markRunBorderSchema, - textEffect: { type: 'string', minLength: 1 }, - }), - minProperties: 1, -}; // --------------------------------------------------------------------------- // Discovery envelope schemas (C0) // --------------------------------------------------------------------------- @@ -3081,8 +2946,8 @@ const captionMutation = refMutationSchemas({ caption: captionAddressSchema }, [' const captionConfig = refConfigSchemas(); // --- Field schemas --- // Issue 7: `storyId` and `fieldId` are optional session-stable identity -// fields used by the v2 runtime. The legacy required fields stay required -// so v1 callers and the shared contract keep their existing shape. +// fields. The legacy required fields stay required so v1 callers and the +// shared contract keep their existing shape. const fieldAddressSchema: JsonSchema = objectSchema( { kind: { const: 'field' }, @@ -3729,101 +3594,6 @@ const operationSchemas: Record = { ), failure: preApplyFailureResultSchemaFor('blocks.deleteRange'), }, - 'blocks.split': { - input: objectSchema( - { - target: blockNodeAddressSchema, - offset: { type: 'integer', minimum: 0 }, - }, - ['target', 'offset'], - ), - output: objectSchema( - { - success: { const: true }, - inserted: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - textRangeShifts: arraySchema(ref('TextRangeShift')), - txId: { type: 'string' }, - }, - ['success', 'inserted'], - ), - success: objectSchema( - { - success: { const: true }, - inserted: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - textRangeShifts: arraySchema(ref('TextRangeShift')), - txId: { type: 'string' }, - }, - ['success', 'inserted'], - ), - failure: preApplyFailureResultSchemaFor('blocks.split'), - }, - 'blocks.merge': { - input: objectSchema( - { - first: blockNodeAddressSchema, - second: blockNodeAddressSchema, - }, - ['first', 'second'], - ), - output: objectSchema( - { - success: { const: true }, - removed: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - textRangeShifts: arraySchema(ref('TextRangeShift')), - txId: { type: 'string' }, - }, - ['success', 'removed'], - ), - success: objectSchema( - { - success: { const: true }, - removed: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - textRangeShifts: arraySchema(ref('TextRangeShift')), - txId: { type: 'string' }, - }, - ['success', 'removed'], - ), - failure: preApplyFailureResultSchemaFor('blocks.merge'), - }, - 'blocks.move': { - input: objectSchema( - { - source: blockNodeAddressSchema, - destination: blockNodeAddressSchema, - placement: { enum: ['before', 'after'] }, - }, - ['source', 'destination', 'placement'], - ), - output: objectSchema( - { - success: { const: true }, - moved: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - txId: { type: 'string' }, - }, - ['success', 'moved'], - ), - success: objectSchema( - { - success: { const: true }, - moved: blockNodeAddressSchema, - remappedRefs: arraySchema(ref('AffectedRefRemapping')), - affectedStories: arraySchema(ref('StoryLocator')), - txId: { type: 'string' }, - }, - ['success', 'moved'], - ), - failure: preApplyFailureResultSchemaFor('blocks.move'), - }, // --- styles.paragraph.* --- 'styles.paragraph.setStyle': { input: objectSchema( @@ -4076,21 +3846,6 @@ const operationSchemas: Record = { success: paragraphMutationSuccessSchema, failure: paragraphMutationFailureSchemaFor('format.paragraph.clearShading'), }, - 'format.paragraph.setMarkRunProps': { - input: objectSchema( - { - target: paragraphTargetSchema, - markRunProps: { - ...markRunPropsSchema, - description: 'Paragraph-mark run properties (SDRunProps shape, e.g. fontSizeCs, specVanish).', - }, - }, - ['target', 'markRunProps'], - ), - output: paragraphMutationResultSchemaFor('format.paragraph.setMarkRunProps'), - success: paragraphMutationSuccessSchema, - failure: paragraphMutationFailureSchemaFor('format.paragraph.setMarkRunProps'), - }, 'format.paragraph.setDirection': { input: objectSchema( { @@ -5486,81 +5241,6 @@ const operationSchemas: Record = { success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.setLevelLayout'), }, - // v2 numbering-aware list operations. - 'lists.getState': { - input: objectSchema( - { - target: listsV2BlockTargetSchema, - }, - ['target'], - ), - output: { - oneOf: [ - objectSchema( - { - success: { const: true }, - isListItem: { type: 'boolean' }, - numId: { type: ['string', 'null'] }, - ilvl: { type: 'integer', minimum: 0 }, - abstractNumId: { type: ['string', 'null'] }, - numFmt: { type: ['string', 'null'] }, - lvlText: { type: ['string', 'null'] }, - seed: { type: ['string', 'null'], enum: ['bullet', 'ordered', null] }, - }, - ['success', 'isListItem', 'ilvl'], - ), - listsFailureSchemaFor('lists.getState'), - ], - }, - }, - 'lists.apply': { - input: objectSchema( - { - target: listsV2BlockTargetSchema, - seed: { enum: ['bullet', 'ordered'] }, - reuseNumId: { type: 'string' }, - ilvl: { type: 'integer', minimum: 0, maximum: 8 }, - }, - ['target', 'seed'], - ), - output: listsMutateItemResultSchemaFor('lists.apply'), - success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.apply'), - }, - 'lists.continue': { - input: objectSchema( - { - target: listsV2BlockTargetSchema, - }, - ['target'], - ), - output: listsMutateItemResultSchemaFor('lists.continue'), - success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.continue'), - }, - 'lists.restart': { - input: objectSchema( - { - target: listsV2BlockTargetSchema, - startAt: { type: 'integer', minimum: 1 }, - }, - ['target'], - ), - output: listsMutateItemResultSchemaFor('lists.restart'), - success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.restart'), - }, - 'lists.remove': { - input: objectSchema( - { - target: listsV2BlockTargetSchema, - }, - ['target'], - ), - output: listsMutateItemResultSchemaFor('lists.remove'), - success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.remove'), - }, 'comments.create': { input: objectSchema( { @@ -5622,7 +5302,7 @@ const operationSchemas: Record = { isInternal: { type: 'boolean', description: - 'Legacy v1/document-api compatibility field. Not a supported v2 behavior. V2 adapters MUST reject a `comments.patch` request containing `isInternal` with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6).', + 'Legacy v1/document-api compatibility field. Not supported for new comment patch behavior. A `comments.patch` request containing `isInternal` fails with `CAPABILITY_UNAVAILABLE` (kernel reason `internal-comments-unsupported`). The field is preserved in the schema only so v1 callers keep their input shape (`comments-spec.md` §7, §14.6).', }, }, ['commentId'], @@ -6472,31 +6152,6 @@ const operationSchemas: Record = { success: tableMutationSuccessSchema, failure: tableMutationFailureSchema, }, - 'tables.moveRow': { - input: rowOperationInputSchema( - { - destination: { - oneOf: [ - objectSchema({ kind: { const: 'first' } }, ['kind']), - objectSchema({ kind: { const: 'last' } }, ['kind']), - objectSchema({ kind: { const: 'before' }, rowIndex: { type: 'integer', minimum: 0 } }, [ - 'kind', - 'rowIndex', - ]), - objectSchema({ kind: { const: 'after' }, rowIndex: { type: 'integer', minimum: 0 } }, ['kind', 'rowIndex']), - objectSchema({ kind: { const: 'before' }, target: tableRowAddressSchema }, ['kind', 'target']), - objectSchema({ kind: { const: 'after' }, target: tableRowAddressSchema }, ['kind', 'target']), - objectSchema({ kind: { const: 'before' }, nodeId: { type: 'string' } }, ['kind', 'nodeId']), - objectSchema({ kind: { const: 'after' }, nodeId: { type: 'string' } }, ['kind', 'nodeId']), - ], - }, - }, - ['destination'], - ), - output: tableMutationResultSchema, - success: tableMutationSuccessSchema, - failure: tableMutationFailureSchema, - }, 'tables.setRowHeight': { input: rowOperationInputSchema( { diff --git a/packages/document-api/src/contract/step-op-catalog.ts b/packages/document-api/src/contract/step-op-catalog.ts index 15cb2e40c3..ca01f26453 100644 --- a/packages/document-api/src/contract/step-op-catalog.ts +++ b/packages/document-api/src/contract/step-op-catalog.ts @@ -88,9 +88,6 @@ const STEP_OP_CATALOG_UNFROZEN = [ step('tables.deleteRow', 'tables', 'Delete a row from the target table.', { referenceOperationId: 'tables.deleteRow', }), - step('tables.moveRow', 'tables', 'Move a row to a new position within the same table.', { - referenceOperationId: 'tables.moveRow', - }), step('tables.setRowHeight', 'tables', 'Set row height in the target table.', { referenceOperationId: 'tables.setRowHeight', }), diff --git a/packages/document-api/src/fields/fields.types.ts b/packages/document-api/src/fields/fields.types.ts index 07e3f694ed..2ef19effb5 100644 --- a/packages/document-api/src/fields/fields.types.ts +++ b/packages/document-api/src/fields/fields.types.ts @@ -12,10 +12,10 @@ export interface FieldAddress { occurrenceIndex: number; nestingDepth: number; /** - * Optional session-stable story id for v2 callers. When present together - * with `fieldId`, the v2 runtime resolves the field by its stable identity - * first and only falls back to the legacy `blockId + occurrenceIndex + - * nestingDepth` lookup when no stable handle is bound. + * Optional session-stable story id. When present together with `fieldId`, the + * adapter resolves the field by its stable identity first and only falls back + * to the legacy `blockId + occurrenceIndex + nestingDepth` lookup when no + * stable handle is bound. * * Stability is session-scoped: the handle survives common structural edits * that insert, rebuild, or remove other fields in the same story. True @@ -24,9 +24,9 @@ export interface FieldAddress { */ storyId?: string; /** - * Optional session-stable field id for v2 callers. See `storyId` for the - * stability contract. The legacy fields remain required so v1 callers and - * the shared schema keep their existing shape. + * Optional session-stable field id. See `storyId` for the stability contract. + * The legacy fields remain required so v1 callers and the shared schema keep + * their existing shape. */ fieldId?: string; } diff --git a/packages/document-api/src/history/history.types.test.ts b/packages/document-api/src/history/history.types.test.ts index b5088c29b7..321034d15e 100644 --- a/packages/document-api/src/history/history.types.test.ts +++ b/packages/document-api/src/history/history.types.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; import type { HistoryActionResult, HistoryAdapter } from './history.js'; -describe('HistoryActionResult v2 widening', () => { +describe('HistoryActionResult additive fields', () => { it('accepts v1-shaped results unchanged', () => { const v1: HistoryActionResult = { noop: false, @@ -12,8 +12,8 @@ describe('HistoryActionResult v2 widening', () => { expect(v1.remappedRefs).toBeUndefined(); expect(v1.affectedStories).toBeUndefined(); }); - it('accepts v2-shaped results with populated optional fields', () => { - const v2: HistoryActionResult = { + it('accepts extended results with populated optional fields', () => { + const extended: HistoryActionResult = { noop: false, revision: { before: '5', after: '6' }, removed: [{ kind: 'entity', entityType: 'comment', entityId: '42' }], @@ -23,11 +23,11 @@ describe('HistoryActionResult v2 widening', () => { inserted: [], updated: [], }; - expect(v2.removed?.[0]).toEqual({ kind: 'entity', entityType: 'comment', entityId: '42' }); - expect(v2.invalidatedRefs?.[0]).toEqual({ kind: 'entity', entityType: 'comment', entityId: '42' }); - expect(v2.affectedStories?.[0]).toEqual({ kind: 'story', storyType: 'body' }); + expect(extended.removed?.[0]).toEqual({ kind: 'entity', entityType: 'comment', entityId: '42' }); + expect(extended.invalidatedRefs?.[0]).toEqual({ kind: 'entity', entityType: 'comment', entityId: '42' }); + expect(extended.affectedStories?.[0]).toEqual({ kind: 'story', storyType: 'body' }); }); - it('accepts v2 noop reasons additively', () => { + it('accepts additive noop reasons', () => { const r: HistoryActionResult = { noop: true, reason: 'no-undo-available', diff --git a/packages/document-api/src/history/history.types.ts b/packages/document-api/src/history/history.types.ts index d65a6e56bf..664b126bee 100644 --- a/packages/document-api/src/history/history.types.ts +++ b/packages/document-api/src/history/history.types.ts @@ -24,9 +24,9 @@ export type HistoryNoopReason = | 'EMPTY_UNDO_STACK' | 'EMPTY_REDO_STACK' | 'NO_EFFECT' - // AIDEV-NOTE: v2 adapter reasons. v1 still emits the legacy - // EMPTY_* reasons; v2 returns the dashed forms to make it clear which - // adapter produced the noop. + // AIDEV-NOTE: additive adapter reasons. v1 still emits the legacy + // EMPTY_* reasons; newer adapters return the dashed forms to make it + // clear which adapter produced the noop. | 'no-undo-available' | 'no-redo-available' | 'history-entry-missing' @@ -53,16 +53,15 @@ export interface SDHistoryCollaborationMeta { * Result of a history.undo or history.redo action. * Mirrors PlanReceipt's revision shape for consistency. * - * AIDEV-NOTE: this shape carries optional ref-effect - * fields. v1 callers leave them undefined; v2 (`V2HistoryAdapter`) populates - * them from the stored semantic delta of the undone/redone transaction so - * callers' held refs stay accurate across history actions. Never make any - * of the new fields required — v1 adapters MUST keep compiling unchanged. + * AIDEV-NOTE: this shape carries optional ref-effect fields. v1 callers leave + * them undefined; adapters with stored semantic deltas can populate them so + * callers' held refs stay accurate across history actions. Never make any of + * the new fields required — v1 adapters MUST keep compiling unchanged. * * The shape also carries three optional collaboration fields: - * `status`, `diagnosticCode`, and `collaboration`. v1 leaves them - * undefined; collaborative v2 sessions populate them so callers can react - * to fail-closed inverse safety checks. + * `status`, `diagnosticCode`, and `collaboration`. v1 leaves them undefined; + * collaborative adapters can populate them so callers can react to fail-closed + * inverse safety checks. */ export interface HistoryActionResult { /** True if the action had no effect (empty stack). */ @@ -74,11 +73,11 @@ export interface HistoryActionResult { before: string; after: string; }; - /** Entities created by this history action. v2: redo of insert; v1: undefined. */ + /** Entities created by this history action. */ inserted?: ReceiptEntity[]; /** Entities updated by this history action. */ updated?: ReceiptEntity[]; - /** Entities removed by this history action. v2: undo of insert; v1: undefined. */ + /** Entities removed by this history action. */ removed?: ReceiptEntity[]; /** Refs the action made dead handles (e.g. comment ids that no longer exist). */ invalidatedRefs?: AffectedRef[]; @@ -88,8 +87,8 @@ export interface HistoryActionResult { affectedStories?: StoryLocator[]; /** * Text-range shifts produced by the undone/redone action. - * Optional. v1 adapters leave it undefined; v2 surfaces shifts when an - * undone or redone transaction changed visible text. + * Optional. v1 adapters leave it undefined; compatible adapters surface + * shifts when an undone or redone transaction changed visible text. */ textRangeShifts?: TextRangeShift[]; /** @@ -97,14 +96,12 @@ export interface HistoryActionResult { */ status?: SDHistoryStatus; /** - * Machine-readable diagnostic when status is `rejected`, - * `partial`, or `repaired`. Carries the `COLLAB_V2_UNDO_*` codes from the - * collaboration runtime. + * Machine-readable diagnostic when status is `rejected`, `partial`, or + * `repaired`. */ diagnosticCode?: string; /** * Collaboration metadata for the undone/redone op. - * Present only for collaborative v2 sessions. */ collaboration?: SDHistoryCollaborationMeta; } diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 73ae265eae..25f923f011 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -284,7 +284,6 @@ function makeTablesAdapter(): TablesAdapter { setLayout: mutation, insertRow: mutation, deleteRow: mutation, - moveRow: mutation, setRowHeight: mutation, distributeRows: mutation, setRowOptions: mutation, @@ -3260,7 +3259,6 @@ describe('createDocumentApi', () => { const target = { kind: 'block' as const, nodeType: 'tableRow' as const, nodeId: 'r1' }; expect(() => api.tables.insertRow({ target, position: 'after' })).not.toThrow(); expect(() => api.tables.deleteRow({ target })).not.toThrow(); - expect(() => api.tables.moveRow({ target, destination: { kind: 'last' } })).not.toThrow(); }); it('accepts table-scoped locator for row-locator operations', () => { @@ -3268,19 +3266,6 @@ describe('createDocumentApi', () => { const target = { kind: 'block' as const, nodeType: 'table' as const, nodeId: 't1' }; expect(() => api.tables.insertRow({ target, rowIndex: 0, position: 'after' })).not.toThrow(); expect(() => api.tables.deleteRow({ nodeId: 't1', rowIndex: 0 })).not.toThrow(); - expect(() => api.tables.moveRow({ target, rowIndex: 0, destination: { kind: 'last' } })).not.toThrow(); - }); - - it('returns CAPABILITY_UNAVAILABLE when legacy table adapters omit moveRow', () => { - const tables = makeTablesAdapter(); - delete tables.moveRow; - const api = makeApi(tables); - const target = { kind: 'block' as const, nodeType: 'tableRow' as const, nodeId: 'r1' }; - - expect(api.tables.moveRow({ target, destination: { kind: 'last' } })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); }); it('rejects table-target row ops without rowIndex at the public API boundary', () => { @@ -3289,9 +3274,6 @@ describe('createDocumentApi', () => { expect(() => api.tables.insertRow({ target, position: 'after' } as any)).toThrow(/rowIndex is required/); expect(() => api.tables.deleteRow({ target } as any)).toThrow(/rowIndex is required/); - expect(() => api.tables.moveRow({ target, destination: { kind: 'last' } } as any)).toThrow( - /rowIndex is required/, - ); expect(() => api.tables.setRowHeight({ target, heightPt: 12, rule: 'atLeast' } as any)).toThrow( /rowIndex is required/, ); @@ -3303,9 +3285,6 @@ describe('createDocumentApi', () => { expect(() => api.tables.insertRow({ nodeId: 't1', position: 'after' } as any)).toThrow(/rowIndex is required/); expect(() => api.tables.deleteRow({ nodeId: 't1' } as any)).toThrow(/rowIndex is required/); - expect(() => api.tables.moveRow({ nodeId: 't1', destination: { kind: 'last' } } as any)).toThrow( - /rowIndex is required/, - ); expect(() => api.tables.setRowHeight({ nodeId: 't1', heightPt: 12, rule: 'atLeast' } as any)).toThrow( /rowIndex is required/, ); @@ -3322,9 +3301,6 @@ describe('createDocumentApi', () => { /rowIndex must not be provided/, ); expect(() => api.tables.deleteRow({ target, rowIndex: 0 } as any)).toThrow(/rowIndex must not be provided/); - expect(() => api.tables.moveRow({ target, rowIndex: 0, destination: { kind: 'last' } } as any)).toThrow( - /rowIndex must not be provided/, - ); expect(() => api.tables.setRowHeight({ target, rowIndex: 0, heightPt: 12, rule: 'atLeast' } as any)).toThrow( /rowIndex must not be provided/, ); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 6769b3fdf9..3b819a582b 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -210,12 +210,6 @@ import type { ListsSetLevelTextInput, ListsSetLevelStartInput, ListsSetLevelLayoutInput, - ListsGetStateInput, - ListsGetStateResult, - ListsApplyInput, - ListsContinueV2Input, - ListsRestartV2Input, - ListsRemoveV2Input, } from './lists/lists.types.js'; import { executeListsGet, @@ -257,11 +251,6 @@ import { executeListsSetLevelText, executeListsSetLevelStart, executeListsSetLevelLayout, - executeListsGetState, - executeListsApply, - executeListsContinueV2, - executeListsRestartV2, - executeListsRemoveV2, } from './lists/lists.js'; import { executeReplace, type ReplaceInput } from './replace/replace.js'; import type { CreateAdapter, CreateApi } from './create/create.js'; @@ -273,14 +262,7 @@ import { executeCreateTableOfContents, } from './create/create.js'; import type { BlocksAdapter, BlocksApi } from './blocks/blocks.js'; -import { - executeBlocksList, - executeBlocksDelete, - executeBlocksDeleteRange, - executeBlocksSplit, - executeBlocksMerge, - executeBlocksMove, -} from './blocks/blocks.js'; +import { executeBlocksList, executeBlocksDelete, executeBlocksDeleteRange } from './blocks/blocks.js'; import type { BlocksDeleteInput, BlocksDeleteResult, @@ -302,7 +284,6 @@ import type { TablesSetLayoutInput, TablesInsertRowInput, TablesDeleteRowInput, - TablesMoveRowInput, TablesSetRowHeightInput, TablesDistributeRowsInput, TablesSetRowOptionsInput, @@ -433,7 +414,6 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, - ParagraphsSetMarkRunPropsInput, ParagraphsSetDirectionInput, ParagraphsClearDirectionInput, ParagraphsSetNumberingInput, @@ -459,7 +439,6 @@ import { executeParagraphsClearBorder, executeParagraphsSetShading, executeParagraphsClearShading, - executeParagraphsSetMarkRunProps, executeParagraphsSetDirection, executeParagraphsClearDirection, executeParagraphsSetNumbering, @@ -1391,7 +1370,6 @@ export type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, - ParagraphsSetMarkRunPropsInput, ParagraphsSetDirectionInput, ParagraphsClearDirectionInput, ParagraphsSetNumberingInput, @@ -1482,12 +1460,6 @@ export type { ListsSetLevelTextInput, ListsSetLevelStartInput, ListsSetLevelLayoutInput, - ListsGetStateInput, - ListsGetStateResult, - ListsApplyInput, - ListsContinueV2Input, - ListsRestartV2Input, - ListsRemoveV2Input, } from './lists/lists.types.js'; export { LIST_KINDS, @@ -1589,24 +1561,8 @@ export type { CommentTarget, } from './comments/comments.types.js'; export type { BlocksApi } from './blocks/blocks.js'; -export { - executeBlocksList, - executeBlocksDelete, - executeBlocksDeleteRange, - executeBlocksSplit, - executeBlocksMerge, - executeBlocksMove, -} from './blocks/blocks.js'; -// v2 list operation execute wrappers + v1 indent / outdent. -export { - executeListsIndent, - executeListsOutdent, - executeListsGetState, - executeListsApply, - executeListsContinueV2, - executeListsRestartV2, - executeListsRemoveV2, -} from './lists/lists.js'; +export { executeBlocksList, executeBlocksDelete, executeBlocksDeleteRange } from './blocks/blocks.js'; +export { executeListsIndent, executeListsOutdent } from './lists/lists.js'; export { DocumentApiValidationError } from './errors.js'; export { textReceiptToSDReceipt, buildStructuralReceipt } from './receipt-bridge.js'; export type { StructuralReceiptParams } from './receipt-bridge.js'; @@ -1628,7 +1584,6 @@ export interface TablesApi { setLayout(input: TablesSetLayoutInput, options?: MutationOptions): TableMutationResult; insertRow(input: TablesInsertRowInput, options?: MutationOptions): TableMutationResult; deleteRow(input: TablesDeleteRowInput, options?: MutationOptions): TableMutationResult; - moveRow(input: TablesMoveRowInput, options?: MutationOptions): TableMutationResult; setRowHeight(input: TablesSetRowHeightInput, options?: MutationOptions): TableMutationResult; distributeRows(input: TablesDistributeRowsInput, options?: MutationOptions): TableMutationResult; setRowOptions(input: TablesSetRowOptionsInput, options?: MutationOptions): TableMutationResult; @@ -1668,9 +1623,7 @@ export interface TablesApi { setDefaultStyle(input: TablesSetDefaultStyleInput, options?: MutationOptions): DocumentMutationResult; clearDefaultStyle(input?: TablesClearDefaultStyleInput, options?: MutationOptions): DocumentMutationResult; } -export type TablesAdapter = Omit & { - moveRow?: TablesApi['moveRow']; -}; +export type TablesAdapter = TablesApi; /** * Callable capability accessor returned by `createDocumentApi`. * @@ -2091,16 +2044,6 @@ function requireAdapter(adapter: T | undefined, namespace: string): T { } return adapter; } -function unavailableTableMutationResult(operationName: string): TableMutationResult { - return { - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: `${operationName} is not available. The host engine has not provided an adapter for this capability.`, - details: { operation: operationName }, - }, - }; -} function buildFormatInlineAliasApi(adapter: SelectionMutationAdapter): FormatInlineAliasApi { return Object.fromEntries( INLINE_PROPERTY_REGISTRY.map((entry) => { @@ -2280,9 +2223,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult { return executeParagraphsClearShading(adapters.paragraphs, input, options); }, - setMarkRunProps(input: ParagraphsSetMarkRunPropsInput, options?: MutationOptions): ParagraphMutationResult { - return executeParagraphsSetMarkRunProps(adapters.paragraphs, input, options); - }, setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult { return executeParagraphsSetDirection(adapters.paragraphs, input, options); }, @@ -2333,15 +2273,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { deleteRange(input: BlocksDeleteRangeInput, options?: MutationOptions): BlocksDeleteRangeResult { return executeBlocksDeleteRange(adapters.blocks, input, options); }, - split(input, options) { - return executeBlocksSplit(adapters.blocks, input, options); - }, - merge(input, options) { - return executeBlocksMerge(adapters.blocks, input, options); - }, - move(input, options) { - return executeBlocksMove(adapters.blocks, input, options); - }, }, create: { paragraph(input: CreateParagraphInput, options?: MutationOptions): CreateParagraphResult { @@ -2577,22 +2508,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { setLevelLayout(input: ListsSetLevelLayoutInput, options?: MutationOptions): ListsMutateItemResult { return executeListsSetLevelLayout(adapters.lists, input, options); }, - // v2 numbering-aware list operations. - getState(input: ListsGetStateInput): ListsGetStateResult { - return executeListsGetState(adapters.lists, input); - }, - apply(input: ListsApplyInput, options?: MutationOptions): ListsMutateItemResult { - return executeListsApply(adapters.lists, input, options); - }, - continue(input: ListsContinueV2Input, options?: MutationOptions): ListsMutateItemResult { - return executeListsContinueV2(adapters.lists, input, options); - }, - restart(input: ListsRestartV2Input, options?: MutationOptions): ListsMutateItemResult { - return executeListsRestartV2(adapters.lists, input, options); - }, - remove(input: ListsRemoveV2Input, options?: MutationOptions): ListsMutateItemResult { - return executeListsRemoveV2(adapters.lists, input, options); - }, }, sections: { list(query?: SectionsListQuery): SectionsListResult { @@ -2706,11 +2621,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { deleteRow(input, options?) { return executeRowLocatorOp('tables.deleteRow', adapters.tables.deleteRow.bind(adapters.tables), input, options); }, - moveRow(input, options?) { - const moveRowAdapter = - adapters.tables.moveRow?.bind(adapters.tables) ?? (() => unavailableTableMutationResult('tables.moveRow')); - return executeRowLocatorOp('tables.moveRow', moveRowAdapter, input, options); - }, setRowHeight(input, options?) { return executeRowLocatorOp( 'tables.setRowHeight', diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index afd4b6b1d9..0e4441a158 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -70,9 +70,6 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'blocks.list': (input) => api.blocks.list(input), 'blocks.delete': (input, options) => api.blocks.delete(input, options), 'blocks.deleteRange': (input, options) => api.blocks.deleteRange(input, options), - 'blocks.split': (input, options) => api.blocks.split(input, options), - 'blocks.merge': (input, options) => api.blocks.merge(input, options), - 'blocks.move': (input, options) => api.blocks.move(input, options), // --- format.* --- 'format.apply': (input, options) => api.format.apply(input, options), ...formatInlineAliasDispatch, @@ -98,7 +95,6 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'format.paragraph.clearBorder': (input, options) => api.format.paragraph.clearBorder(input, options), 'format.paragraph.setShading': (input, options) => api.format.paragraph.setShading(input, options), 'format.paragraph.clearShading': (input, options) => api.format.paragraph.clearShading(input, options), - 'format.paragraph.setMarkRunProps': (input, options) => api.format.paragraph.setMarkRunProps(input, options), 'format.paragraph.setDirection': (input, options) => api.format.paragraph.setDirection(input, options), 'format.paragraph.clearDirection': (input, options) => api.format.paragraph.clearDirection(input, options), 'format.paragraph.setNumbering': (input, options) => api.format.paragraph.setNumbering(input, options), @@ -152,12 +148,6 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.setLevelText': (input, options) => api.lists.setLevelText(input, options), 'lists.setLevelStart': (input, options) => api.lists.setLevelStart(input, options), 'lists.setLevelLayout': (input, options) => api.lists.setLevelLayout(input, options), - // --- lists.* (v2 numbering-aware) --- - 'lists.getState': (input) => api.lists.getState(input), - 'lists.apply': (input, options) => api.lists.apply(input, options), - 'lists.continue': (input, options) => api.lists.continue(input, options), - 'lists.restart': (input, options) => api.lists.restart(input, options), - 'lists.remove': (input, options) => api.lists.remove(input, options), // --- sections.* --- 'sections.list': (input) => api.sections.list(input), 'sections.get': (input) => api.sections.get(input), @@ -215,7 +205,6 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'tables.setLayout': (input, options) => api.tables.setLayout(input, options), 'tables.insertRow': (input, options) => api.tables.insertRow(input, options), 'tables.deleteRow': (input, options) => api.tables.deleteRow(input, options), - 'tables.moveRow': (input, options) => api.tables.moveRow(input, options), 'tables.setRowHeight': (input, options) => api.tables.setRowHeight(input, options), 'tables.distributeRows': (input, options) => api.tables.distributeRows(input, options), 'tables.setRowOptions': (input, options) => api.tables.setRowOptions(input, options), diff --git a/packages/document-api/src/lists/lists.test.ts b/packages/document-api/src/lists/lists.test.ts index 437a2065ae..44becbf927 100644 --- a/packages/document-api/src/lists/lists.test.ts +++ b/packages/document-api/src/lists/lists.test.ts @@ -25,11 +25,6 @@ import { executeListsSetLevelLayout, executeListsSetLevelAlignment, executeListsSetLevelRestart, - executeListsApply, - executeListsContinueV2, - executeListsGetState, - executeListsRemoveV2, - executeListsRestartV2, } from './lists.js'; const validTarget = { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }; @@ -120,34 +115,6 @@ describe('validateListItemTarget (strict listItem)', () => { }); }); -describe('v2 list capability fallbacks', () => { - it('returns CAPABILITY_UNAVAILABLE when legacy adapters omit v2 numbering-aware hooks', () => { - const adapter = stubAdapter(); - const target = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }; - - expect(executeListsGetState(adapter, { target })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeListsApply(adapter, { target, seed: 'ordered' })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeListsContinueV2(adapter, { target })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeListsRestartV2(adapter, { target, startAt: 1 })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - expect(executeListsRemoveV2(adapter, { target })).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - }); -}); - // --------------------------------------------------------------------------- // lists.get — validates address as ListItemAddress // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts index 789c0c0263..6da3d13ee0 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -67,13 +67,6 @@ import type { ListsSetLevelTextInput, ListsSetLevelStartInput, ListsSetLevelLayoutInput, - ListsBlockTarget, - ListsGetStateInput, - ListsGetStateResult, - ListsApplyInput, - ListsContinueV2Input, - ListsRestartV2Input, - ListsRemoveV2Input, } from './lists.types.js'; export type { ListInsertInput, @@ -130,13 +123,6 @@ export type { ListsSetLevelTextInput, ListsSetLevelStartInput, ListsSetLevelLayoutInput, - ListsBlockTarget, - ListsGetStateInput, - ListsGetStateResult, - ListsApplyInput, - ListsContinueV2Input, - ListsRestartV2Input, - ListsRemoveV2Input, } from './lists.types.js'; // --------------------------------------------------------------------------- // Validation enum sets @@ -513,20 +499,8 @@ export interface ListsAdapter { setLevelText(input: ListsSetLevelTextInput, options?: MutationOptions): ListsMutateItemResult; setLevelStart(input: ListsSetLevelStartInput, options?: MutationOptions): ListsMutateItemResult; setLevelLayout(input: ListsSetLevelLayoutInput, options?: MutationOptions): ListsMutateItemResult; - // v2 numbering-aware list operations. - getState?(input: ListsGetStateInput): ListsGetStateResult; - apply?(input: ListsApplyInput, options?: MutationOptions): ListsMutateItemResult; - continue?(input: ListsContinueV2Input, options?: MutationOptions): ListsMutateItemResult; - restart?(input: ListsRestartV2Input, options?: MutationOptions): ListsMutateItemResult; - remove?(input: ListsRemoveV2Input, options?: MutationOptions): ListsMutateItemResult; -} -export interface ListsApi extends ListsAdapter { - getState(input: ListsGetStateInput): ListsGetStateResult; - apply(input: ListsApplyInput, options?: MutationOptions): ListsMutateItemResult; - continue(input: ListsContinueV2Input, options?: MutationOptions): ListsMutateItemResult; - restart(input: ListsRestartV2Input, options?: MutationOptions): ListsMutateItemResult; - remove(input: ListsRemoveV2Input, options?: MutationOptions): ListsMutateItemResult; } +export type ListsApi = ListsAdapter; // --------------------------------------------------------------------------- // Execute wrappers: discovery // --------------------------------------------------------------------------- @@ -1051,104 +1025,3 @@ export function executeListsSetLevelLayout( } return adapter.setLevelLayout(input, normalizeMutationOptions(options)); } -// --------------------------------------------------------------------------- -// Execute wrappers: v2 operations -// --------------------------------------------------------------------------- -const VALID_V2_LIST_BLOCK_NODE_TYPES: ReadonlySet = new Set(['paragraph', 'listItem']); -function validateV2BlockTarget(value: unknown, field: string, operationName: string): void { - if (!isRecord(value)) { - throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} ${field} must be an object.`, { - field, - value, - }); - } - const v = value as Record; - if (v.kind !== 'block') { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `${operationName} ${field}.kind must be 'block', got "${String(v.kind)}".`, - { field: `${field}.kind`, value: v.kind }, - ); - } - if (typeof v.nodeType !== 'string' || !VALID_V2_LIST_BLOCK_NODE_TYPES.has(v.nodeType)) { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `${operationName} ${field}.nodeType must be 'paragraph' or 'listItem', got "${String(v.nodeType)}".`, - { field: `${field}.nodeType`, value: v.nodeType }, - ); - } - if (typeof v.nodeId !== 'string' || v.nodeId === '') { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `${operationName} ${field}.nodeId must be a non-empty string.`, - { field: `${field}.nodeId`, value: v.nodeId }, - ); - } -} -function unavailableListsResult( - operationName: string, -): TResult { - return { - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: `${operationName} is not available. The host engine has not provided an adapter for this capability.`, - details: { operation: operationName }, - }, - } as TResult; -} -export function executeListsGetState(adapter: ListsAdapter, input: ListsGetStateInput): ListsGetStateResult { - validateListInput(input, 'lists.getState'); - validateV2BlockTarget(input.target, 'target', 'lists.getState'); - if (!adapter.getState) return unavailableListsResult('lists.getState'); - return adapter.getState(input); -} -export function executeListsApply( - adapter: ListsAdapter, - input: ListsApplyInput, - options?: MutationOptions, -): ListsMutateItemResult { - validateListInput(input, 'lists.apply'); - validateV2BlockTarget(input.target, 'target', 'lists.apply'); - requireEnum(input.seed, 'seed', VALID_LIST_KINDS, 'lists.apply'); - if (input.reuseNumId !== undefined && typeof input.reuseNumId !== 'string') { - throw new DocumentApiValidationError('INVALID_INPUT', 'lists.apply reuseNumId must be a string when provided.', { - field: 'reuseNumId', - value: input.reuseNumId, - }); - } - optionalInteger(input.ilvl, 'ilvl', 'lists.apply'); - if (!adapter.apply) return unavailableListsResult('lists.apply'); - return adapter.apply(input, normalizeMutationOptions(options)); -} -export function executeListsContinueV2( - adapter: ListsAdapter, - input: ListsContinueV2Input, - options?: MutationOptions, -): ListsMutateItemResult { - validateListInput(input, 'lists.continue'); - validateV2BlockTarget(input.target, 'target', 'lists.continue'); - if (!adapter.continue) return unavailableListsResult('lists.continue'); - return adapter.continue(input, normalizeMutationOptions(options)); -} -export function executeListsRestartV2( - adapter: ListsAdapter, - input: ListsRestartV2Input, - options?: MutationOptions, -): ListsMutateItemResult { - validateListInput(input, 'lists.restart'); - validateV2BlockTarget(input.target, 'target', 'lists.restart'); - optionalInteger(input.startAt, 'startAt', 'lists.restart'); - if (!adapter.restart) return unavailableListsResult('lists.restart'); - return adapter.restart(input, normalizeMutationOptions(options)); -} -export function executeListsRemoveV2( - adapter: ListsAdapter, - input: ListsRemoveV2Input, - options?: MutationOptions, -): ListsMutateItemResult { - validateListInput(input, 'lists.remove'); - validateV2BlockTarget(input.target, 'target', 'lists.remove'); - if (!adapter.remove) return unavailableListsResult('lists.remove'); - return adapter.remove(input, normalizeMutationOptions(options)); -} diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index 77aa7fdd25..d5a0985ddf 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -485,55 +485,3 @@ export interface ListsDeleteSuccessResult { deletedCount: number; } export type ListsDeleteResult = ListsDeleteSuccessResult | ListsFailureResult; -// --------------------------------------------------------------------------- -// v2 numbering-aware list operation surface. -// --------------------------------------------------------------------------- -/** - * Target shape accepted by the v2 list operations. We accept paragraph - * blocks (the substrate identifies them by w14:paraId; the public type - * surfaces them under both `paragraph` and `listItem` node types so callers - * can pass refs obtained from `blocks.list` or from `lists.get*`). - */ -export type ListsBlockTarget = - | { kind: 'block'; nodeType: 'paragraph'; nodeId: string } - | { kind: 'block'; nodeType: 'listItem'; nodeId: string }; -export interface ListsGetStateInput { - target: ListsBlockTarget; -} -export interface ListsGetStateSuccessResult { - success: true; - isListItem: boolean; - /** numId; null when the paragraph is not a list item. */ - numId: string | null; - /** Level (`ilvl`); 0 when the paragraph is not a list item or has no ilvl. */ - ilvl: number; - /** abstractNumId from the catalog; null when unknown. */ - abstractNumId: string | null; - /** Level number format at the resolved level; null when unknown. */ - numFmt: string | null; - /** Level text template at the resolved level; null when unknown. */ - lvlText: string | null; - /** Coarse seed classification; null when unknown. */ - seed: 'bullet' | 'ordered' | null; -} -export type ListsGetStateResult = ListsGetStateSuccessResult | ListsFailureResult; -export interface ListsApplyInput { - target: ListsBlockTarget; - /** `'bullet'` or `'ordered'`. Used when materializing a new abstract num. */ - seed: ListKind; - /** Optional existing numId to attach the paragraph to instead of seeding a new one. */ - reuseNumId?: string; - /** Optional ilvl; defaults to 0. */ - ilvl?: number; -} -export interface ListsContinueV2Input { - target: ListsBlockTarget; -} -export interface ListsRestartV2Input { - target: ListsBlockTarget; - /** First number to restart at; defaults to 1. */ - startAt?: number; -} -export interface ListsRemoveV2Input { - target: ListsBlockTarget; -} diff --git a/packages/document-api/src/paragraphs/paragraphs.test.ts b/packages/document-api/src/paragraphs/paragraphs.test.ts index 6f308581b5..73e496ee2d 100644 --- a/packages/document-api/src/paragraphs/paragraphs.test.ts +++ b/packages/document-api/src/paragraphs/paragraphs.test.ts @@ -12,7 +12,6 @@ import { executeParagraphsClearTabStop, executeParagraphsSetFlowOptions, executeParagraphsSetIndentation, - executeParagraphsSetMarkRunProps, executeParagraphsSetNumbering, executeParagraphsSetTabStop, } from './paragraphs.js'; @@ -27,7 +26,6 @@ function makeTarget() { function makeAdapter(): ParagraphsAdapter & { setIndentation: ReturnType; - setMarkRunProps: ReturnType; } { const success: ParagraphMutationResult = { success: true, @@ -57,21 +55,14 @@ function makeAdapter(): ParagraphsAdapter & { clearBorder: mock(() => success), setShading: mock(() => success), clearShading: mock(() => success), - setMarkRunProps: mock(() => success), setDirection: mock(() => success), clearDirection: mock(() => success), setNumbering: mock(() => success), } as ParagraphsAdapter & { setIndentation: ReturnType; - setMarkRunProps: ReturnType; }; } -function makeLegacyAdapterWithoutMarkRunProps(): ParagraphsAdapter { - const { setMarkRunProps: _omitted, ...adapter } = makeAdapter(); - return adapter; -} - describe('executeParagraphsSetNumbering', () => { it('delegates to the adapter for valid input', () => { const adapter = makeAdapter(); @@ -281,51 +272,3 @@ describe('executeParagraphsSetFlowOptions', () => { ).toThrow(DocumentApiValidationError); }); }); - -describe('executeParagraphsSetMarkRunProps', () => { - it('accepts supported numeric and boolean mark run props', () => { - const adapter = makeAdapter(); - const input = { - target: makeTarget(), - markRunProps: { - characterSpacing: -1.5, - fitTextWidth: 72, - fontSizeCs: 11, - specVanish: true, - }, - }; - - const result = executeParagraphsSetMarkRunProps(adapter, input); - - expect(result.success).toBe(true); - expect(adapter.setMarkRunProps).toHaveBeenCalledWith(input, expect.objectContaining({ changeMode: 'direct' })); - }); - - it('returns CAPABILITY_UNAVAILABLE when legacy adapters omit setMarkRunProps', () => { - const result = executeParagraphsSetMarkRunProps(makeLegacyAdapterWithoutMarkRunProps(), { - target: makeTarget(), - markRunProps: { - bold: true, - }, - }); - - expect(result).toMatchObject({ - success: false, - failure: { code: 'CAPABILITY_UNAVAILABLE' }, - }); - }); - - it('rejects unknown markRunProps keys', () => { - const adapter = makeAdapter(); - - expect(() => - executeParagraphsSetMarkRunProps(adapter, { - target: makeTarget(), - markRunProps: { - // @ts-expect-error intentional invalid key - bogus: true, - }, - }), - ).toThrow(DocumentApiValidationError); - }); -}); diff --git a/packages/document-api/src/paragraphs/paragraphs.ts b/packages/document-api/src/paragraphs/paragraphs.ts index e62fa2c465..0c4d815819 100644 --- a/packages/document-api/src/paragraphs/paragraphs.ts +++ b/packages/document-api/src/paragraphs/paragraphs.ts @@ -31,7 +31,6 @@ import type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, - ParagraphsSetMarkRunPropsInput, ParagraphsSetDirectionInput, ParagraphsClearDirectionInput, ParagraphsSetNumberingInput, @@ -80,7 +79,6 @@ export type { ParagraphsClearBorderInput, ParagraphsSetShadingInput, ParagraphsClearShadingInput, - ParagraphsSetMarkRunPropsInput, ParagraphsSetDirectionInput, ParagraphsClearDirectionInput, ParagraphsSetNumberingInput, @@ -125,7 +123,6 @@ export interface ParagraphsAdapter { clearBorder(input: ParagraphsClearBorderInput, options?: MutationOptions): ParagraphMutationResult; setShading(input: ParagraphsSetShadingInput, options?: MutationOptions): ParagraphMutationResult; clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult; - setMarkRunProps?(input: ParagraphsSetMarkRunPropsInput, options?: MutationOptions): ParagraphMutationResult; setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult; clearDirection(input: ParagraphsClearDirectionInput, options?: MutationOptions): ParagraphMutationResult; setNumbering(input: ParagraphsSetNumberingInput, options?: MutationOptions): ParagraphMutationResult; @@ -153,7 +150,6 @@ export interface ParagraphFormatApi { clearBorder(input: ParagraphsClearBorderInput, options?: MutationOptions): ParagraphMutationResult; setShading(input: ParagraphsSetShadingInput, options?: MutationOptions): ParagraphMutationResult; clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult; - setMarkRunProps(input: ParagraphsSetMarkRunPropsInput, options?: MutationOptions): ParagraphMutationResult; setDirection(input: ParagraphsSetDirectionInput, options?: MutationOptions): ParagraphMutationResult; clearDirection(input: ParagraphsClearDirectionInput, options?: MutationOptions): ParagraphMutationResult; setNumbering(input: ParagraphsSetNumberingInput, options?: MutationOptions): ParagraphMutationResult; @@ -271,34 +267,6 @@ function assertNonEmptyString(value: unknown, fieldName: string, operation: stri } } -function assertFiniteNumber(value: unknown, fieldName: string, operation: string): asserts value is number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${operation} ${fieldName} must be a finite number, got ${JSON.stringify(value)}.`, - { field: fieldName, value }, - ); - } -} - -function assertNonEmptyObject( - value: unknown, - fieldName: string, - operation: string, -): asserts value is Record { - if (!isRecord(value)) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} ${fieldName} must be an object.`, { - field: fieldName, - value, - }); - } - if (Object.keys(value).length === 0) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} ${fieldName} must not be empty.`, { - field: fieldName, - }); - } -} - /** Rejects if a patch input has zero patchable fields beyond `target`. */ function assertNotEmptyPatch(input: Record, patchKeys: readonly string[], operation: string): void { const hasPatchField = patchKeys.some((key) => input[key] !== undefined); @@ -342,61 +310,6 @@ const SET_BORDER_KEYS = new Set(['target', 'side', 'style', 'color', 'size', 'sp const CLEAR_BORDER_KEYS = new Set(['target', 'side']); const SET_SHADING_KEYS = new Set(['target', 'fill', 'color', 'pattern']); const CLEAR_SHADING_KEYS = new Set(['target']); -const SET_MARK_RUN_PROPS_KEYS = new Set(['target', 'markRunProps']); -const MARK_RUN_PROPS_FIELD_KEYS = new Set([ - 'fontSizeCs', - 'specVanish', - 'fontSize', - 'fonts', - 'fontFamily', - 'lang', - 'color', - 'highlight', - 'shading', - 'cs', - 'rtl', - 'bold', - 'boldCs', - 'italic', - 'italicCs', - 'underline', - 'strikethrough', - 'doubleStrikethrough', - 'caps', - 'smallCaps', - 'outline', - 'shadow', - 'emboss', - 'imprint', - 'verticalAlign', - 'characterSpacing', - 'characterScale', - 'kern', - 'baselineShift', - 'fitTextWidth', - 'vanish', - 'webHidden', - 'border', - 'textEffect', -]); -const MARK_RUN_COLOR_REF_KEYS = new Set(['model', 'value', 'theme', 'tint', 'shade']); -const MARK_RUN_FONTS_KEYS = new Set([ - 'ascii', - 'hAnsi', - 'eastAsia', - 'cs', - 'asciiTheme', - 'hAnsiTheme', - 'eastAsiaTheme', - 'csTheme', - 'hint', -]); -const MARK_RUN_LANG_KEYS = new Set(['val', 'eastAsia', 'bidi']); -const MARK_RUN_UNDERLINE_KEYS = new Set(['style', 'color']); -const MARK_RUN_SHADING_KEYS = new Set(['fill', 'color', 'pattern']); -const MARK_RUN_BORDER_KEYS = new Set(['style', 'width', 'space', 'color', 'frame', 'shadow']); -const MARK_RUN_COLOR_MODE_VALUES = ['rgb', 'theme', 'auto'] as const; -const MARK_RUN_VERTICAL_ALIGN_VALUES = ['baseline', 'superscript', 'subscript'] as const; const SET_DIRECTION_KEYS = new Set(['target', 'direction', 'alignmentPolicy']); const SET_NUMBERING_KEYS = new Set(['target', 'numId', 'level']); const CLEAR_DIRECTION_KEYS = new Set(['target']); @@ -643,186 +556,6 @@ function validateClearShading(input: unknown): asserts input is ParagraphsClearS assertNoUnknownFields(input as Record, CLEAR_SHADING_KEYS, 'format.paragraph.clearShading'); } -function validateMarkRunColorRef(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_COLOR_REF_KEYS, `${operation} ${fieldName}`); - if (value.model === undefined) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} ${fieldName}.model is required.`, { - field: `${fieldName}.model`, - }); - } - assertOneOf(value.model, `${fieldName}.model`, MARK_RUN_COLOR_MODE_VALUES, operation); - if (value.model === 'rgb') { - if (value.theme !== undefined || value.tint !== undefined || value.shade !== undefined) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${operation} ${fieldName} rgb colors may only set model and value.`, - { field: fieldName, value }, - ); - } - assertNonEmptyString(value.value, `${fieldName}.value`, operation); - return; - } - if (value.model === 'theme') { - if (value.value !== undefined) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${operation} ${fieldName} theme colors may not set value.`, - { field: `${fieldName}.value`, value: value.value }, - ); - } - assertNonEmptyString(value.theme, `${fieldName}.theme`, operation); - if (value.tint !== undefined) assertInteger(value.tint, `${fieldName}.tint`, operation); - if (value.shade !== undefined) assertInteger(value.shade, `${fieldName}.shade`, operation); - return; - } - if (value.value !== undefined || value.theme !== undefined || value.tint !== undefined || value.shade !== undefined) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} ${fieldName} auto colors may only set model.`, { - field: fieldName, - value, - }); - } -} - -function validateMarkRunFonts(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_FONTS_KEYS, `${operation} ${fieldName}`); - for (const [key, nestedValue] of Object.entries(value)) { - assertNonEmptyString(nestedValue, `${fieldName}.${key}`, operation); - } -} - -function validateMarkRunLanguages(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_LANG_KEYS, `${operation} ${fieldName}`); - for (const [key, nestedValue] of Object.entries(value)) { - assertNonEmptyString(nestedValue, `${fieldName}.${key}`, operation); - } -} - -function validateMarkRunUnderline(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_UNDERLINE_KEYS, `${operation} ${fieldName}`); - if (value.style !== undefined) assertNonEmptyString(value.style, `${fieldName}.style`, operation); - if (value.color !== undefined) validateMarkRunColorRef(value.color, `${fieldName}.color`, operation); -} - -function validateMarkRunShading(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_SHADING_KEYS, `${operation} ${fieldName}`); - if (value.fill !== undefined) validateMarkRunColorRef(value.fill, `${fieldName}.fill`, operation); - if (value.color !== undefined) validateMarkRunColorRef(value.color, `${fieldName}.color`, operation); - if (value.pattern !== undefined) assertNonEmptyString(value.pattern, `${fieldName}.pattern`, operation); -} - -function validateMarkRunBorder(value: unknown, fieldName: string, operation: string): void { - assertNonEmptyObject(value, fieldName, operation); - assertNoUnknownFields(value, MARK_RUN_BORDER_KEYS, `${operation} ${fieldName}`); - if (value.style !== undefined) assertNonEmptyString(value.style, `${fieldName}.style`, operation); - if (value.width !== undefined) assertFiniteNumber(value.width, `${fieldName}.width`, operation); - if (value.space !== undefined) assertFiniteNumber(value.space, `${fieldName}.space`, operation); - if (value.color !== undefined) validateMarkRunColorRef(value.color, `${fieldName}.color`, operation); - if (value.frame !== undefined) assertStrictBoolean(value.frame, `${fieldName}.frame`, operation); - if (value.shadow !== undefined) assertStrictBoolean(value.shadow, `${fieldName}.shadow`, operation); -} - -function validateMarkRunPropValue(key: string, value: unknown, operation: string): void { - const fieldName = `markRunProps.${key}`; - if (value === undefined) { - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} ${fieldName} must not be undefined.`, { - field: fieldName, - }); - } - - switch (key) { - case 'fonts': - validateMarkRunFonts(value, fieldName, operation); - return; - case 'lang': - validateMarkRunLanguages(value, fieldName, operation); - return; - case 'color': - validateMarkRunColorRef(value, fieldName, operation); - return; - case 'underline': - validateMarkRunUnderline(value, fieldName, operation); - return; - case 'shading': - validateMarkRunShading(value, fieldName, operation); - return; - case 'border': - validateMarkRunBorder(value, fieldName, operation); - return; - case 'fontFamily': - case 'highlight': - case 'textEffect': - assertNonEmptyString(value, fieldName, operation); - return; - case 'fontSize': - case 'fontSizeCs': - case 'characterSpacing': - case 'characterScale': - case 'kern': - case 'baselineShift': - case 'fitTextWidth': - assertFiniteNumber(value, fieldName, operation); - return; - case 'verticalAlign': - assertOneOf(value, fieldName, MARK_RUN_VERTICAL_ALIGN_VALUES, operation); - return; - case 'cs': - case 'rtl': - case 'bold': - case 'boldCs': - case 'italic': - case 'italicCs': - case 'strikethrough': - case 'doubleStrikethrough': - case 'caps': - case 'smallCaps': - case 'outline': - case 'shadow': - case 'emboss': - case 'imprint': - case 'vanish': - case 'webHidden': - case 'specVanish': - assertStrictBoolean(value, fieldName, operation); - return; - default: - throw new DocumentApiValidationError('INVALID_INPUT', `${operation} does not support ${fieldName}.`, { - field: fieldName, - }); - } -} - -function validateSetMarkRunProps(input: unknown): asserts input is ParagraphsSetMarkRunPropsInput { - const op = 'format.paragraph.setMarkRunProps'; - assertParagraphTarget(input, op); - assertNoUnknownFields(input as Record, SET_MARK_RUN_PROPS_KEYS, op); - const rec = input as Record; - if (rec.markRunProps === undefined) { - throw new DocumentApiValidationError('INVALID_INPUT', `${op} requires a markRunProps field.`); - } - if (!isRecord(rec.markRunProps)) { - throw new DocumentApiValidationError('INVALID_INPUT', `${op} markRunProps must be an object.`, { - field: 'markRunProps', - value: rec.markRunProps, - }); - } - if (Object.keys(rec.markRunProps).length === 0) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${op} markRunProps must declare at least one run property.`, - { field: 'markRunProps' }, - ); - } - assertNoUnknownFields(rec.markRunProps, MARK_RUN_PROPS_FIELD_KEYS, `${op} markRunProps`); - for (const [key, value] of Object.entries(rec.markRunProps)) { - validateMarkRunPropValue(key, value, op); - } -} - function validateSetDirection(input: unknown): asserts input is ParagraphsSetDirectionInput { const op = 'format.paragraph.setDirection'; assertParagraphTarget(input, op); @@ -1043,26 +776,6 @@ export function executeParagraphsClearShading( return adapter.clearShading(input, normalizeMutationOptions(options)); } -export function executeParagraphsSetMarkRunProps( - adapter: ParagraphsAdapter, - input: ParagraphsSetMarkRunPropsInput, - options?: MutationOptions, -): ParagraphMutationResult { - validateSetMarkRunProps(input); - if (!adapter.setMarkRunProps) { - return { - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: - 'format.paragraph.setMarkRunProps is not available. The host engine has not provided an adapter for this capability.', - details: { operation: 'format.paragraph.setMarkRunProps' }, - }, - }; - } - return adapter.setMarkRunProps(input, normalizeMutationOptions(options)); -} - export function executeParagraphsSetDirection( adapter: ParagraphsAdapter, input: ParagraphsSetDirectionInput, diff --git a/packages/document-api/src/paragraphs/paragraphs.types.ts b/packages/document-api/src/paragraphs/paragraphs.types.ts index aec65141bb..eb763b8e01 100644 --- a/packages/document-api/src/paragraphs/paragraphs.types.ts +++ b/packages/document-api/src/paragraphs/paragraphs.types.ts @@ -7,7 +7,6 @@ import type { BlockNodeAddress } from '../types/base.js'; import type { ReceiptFailure } from '../types/receipt.js'; -import type { SDRunProps } from '../types/sd-props.js'; // --------------------------------------------------------------------------- // Target @@ -185,17 +184,6 @@ export interface ParagraphsClearBorderInput { side: ClearBorderSide; } -/** paragraphs.setMarkRunProps */ -export interface ParagraphsSetMarkRunPropsInput { - target: ParagraphTarget; - /** - * Paragraph-mark run properties (`w:pPr/w:rPr`). Stored through the same - * internal shape used by structured paragraph materialization, so values - * round-trip through `paragraph.props.markRunProps`. - */ - markRunProps: SDRunProps; -} - /** paragraphs.setShading */ export interface ParagraphsSetShadingInput { target: ParagraphTarget; diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 2d70e175f5..8bd0aab279 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -216,8 +216,7 @@ export function executeTrackChangesDecide( success: false, failure: { code: 'CAPABILITY_UNAVAILABLE', - message: - 'trackChanges.decide range targets require the v2 adapter or a compatible decideRange() adapter method.', + message: 'trackChanges.decide range targets require a compatible decideRange() adapter method.', }, }; } diff --git a/packages/document-api/src/types/blocks.types.ts b/packages/document-api/src/types/blocks.types.ts index 86a0e4199b..b028a90bb6 100644 --- a/packages/document-api/src/types/blocks.types.ts +++ b/packages/document-api/src/types/blocks.types.ts @@ -1,12 +1,5 @@ import type { BlockNodeType, BlockNodeAddress, DeletableBlockNodeAddress } from './base.js'; -import type { - AffectedRef, - AffectedRefRemapping, - Receipt, - ReceiptFailure, - ReceiptInsert, - TextRangeShift, -} from './receipt.js'; +import type { AffectedRef, Receipt, ReceiptInsert, TextRangeShift } from './receipt.js'; import type { StoryLocator } from './story.types.js'; import type { ParagraphNumbering } from './paragraph.types.js'; // --------------------------------------------------------------------------- @@ -91,85 +84,6 @@ export interface DeletedBlockSummary { nodeType: string; textPreview: string | null; } -// --------------------------------------------------------------------------- -// Structural block / paragraph operations. -// -// `blocks.split`, `blocks.merge`, and `blocks.move` cover the structural -// editing surface that `create.paragraph` and `blocks.delete` cannot -// express. Each operation accepts a `BlockNodeAddress` (or a v2 stable ref -// where applicable) and returns a structured receipt that mirrors the -// kernel's semantic delta. -// --------------------------------------------------------------------------- -export interface BlocksSplitInput { - /** Paragraph-shaped block to split. Only `paragraph` / `heading` / `listItem` are supported. */ - target: BlockNodeAddress; - /** Char offset inside the target paragraph's visible text where the split occurs. */ - offset: number; -} -export interface BlocksSplitSuccessResult { - success: true; - /** Address of the new paragraph created by the split (the tail). */ - inserted: BlockNodeAddress; - trackedChangeRefs?: ReceiptInsert[]; - remappedRefs?: AffectedRefRemapping[]; - affectedStories?: StoryLocator[]; - textRangeShifts?: TextRangeShift[]; - txId?: string; -} -export interface BlocksSplitFailureResult { - success: false; - failure: ReceiptFailure; -} -export type BlocksSplitResult = BlocksSplitSuccessResult | BlocksSplitFailureResult; -export interface BlocksMergeInput { - /** First paragraph; receives the merged content. */ - first: BlockNodeAddress; - /** Paragraph immediately after `first` in the same story. */ - second: BlockNodeAddress; -} -export interface BlocksMergeSuccessResult { - success: true; - /** Address of the paragraph that was removed (the second paragraph). */ - removed: BlockNodeAddress; - trackedChangeRefs?: ReceiptInsert[]; - remappedRefs?: AffectedRefRemapping[]; - affectedStories?: StoryLocator[]; - textRangeShifts?: TextRangeShift[]; - txId?: string; -} -export interface BlocksMergeFailureResult { - success: false; - failure: ReceiptFailure; -} -export type BlocksMergeResult = BlocksMergeSuccessResult | BlocksMergeFailureResult; -export interface BlocksMoveInput { - /** Paragraph to move. */ - source: BlockNodeAddress; - /** Destination anchor paragraph in the same story. */ - destination: BlockNodeAddress; - /** Whether to place `source` before or after `destination`. */ - placement: 'before' | 'after'; -} -export interface BlocksMoveSuccessResult { - success: true; - /** Same `source` paragraph, now relocated. */ - moved: BlockNodeAddress; - remappedRefs?: AffectedRefRemapping[]; - affectedStories?: StoryLocator[]; - /** - * Tracked-mode authoring (changeMode: 'tracked') emits paired move review - * entities. Each entry addresses the logical pair so callers can route the - * resulting review through `trackChanges.list/get/decide`. Direct-mode - * moves leave this field undefined. - */ - trackedChangeRefs?: ReceiptInsert[]; - txId?: string; -} -export interface BlocksMoveFailureResult { - success: false; - failure: ReceiptFailure; -} -export type BlocksMoveResult = BlocksMoveSuccessResult | BlocksMoveFailureResult; // Re-export Receipt so consumers can reference the union for failure-only // shapes without depending on `../types/receipt.js` directly from this file. export type { Receipt }; diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 9fd2e33936..608a106d2a 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -78,7 +78,7 @@ export type ReceiptFailure = { // Review warnings // // AIDEV-NOTE: `ReviewWarning` is the single shared warning carrier -// for the v2 review features (comments and tracked changes). The shared +// for review features (comments and tracked changes). The shared // foundation requires one carrier so comments export and // tracked-change export / degradation policy do not invent // parallel surfaces. Spec language `warning` maps to `severity: 'warning'` @@ -90,8 +90,7 @@ export type ReceiptFailure = { // - source had malformed optional sidecar data that was ignored while // preserving core data // - generated fixture uses direct OOXML rather than Word-authored provenance -// - Word cannot visually represent a richer v2 headless structural intent -// exactly +// - Word cannot visually represent a richer headless structural intent exactly // // Forbidden semantic loss (e.g. dropping a persisted comment, losing thread // topology, deleting an anchor that the cross-feature rules say must survive) @@ -178,10 +177,8 @@ export type ReceiptSuccess = { */ textRangeShifts?: TextRangeShift[]; /** - * Transaction id of the successful commit. Optional and - * additive — engines that lack a per-tx identity omit it. v2 populates - * this for every successful mutation so callers can wire the receipt to - * their own history bookkeeping (undo / redo correlation, audit logs). + * Transaction id of the successful commit. Optional and additive — engines + * that lack a per-tx identity omit it. */ txId?: string; /** diff --git a/packages/document-api/src/types/sd-sections.ts b/packages/document-api/src/types/sd-sections.ts index 5b8e19ceaf..a683c3937a 100644 --- a/packages/document-api/src/types/sd-sections.ts +++ b/packages/document-api/src/types/sd-sections.ts @@ -97,7 +97,7 @@ export interface SDCommentThread { export interface SDTrackedChange { id: string; /** - * Tracked-change broad type. Accepts the canonical v2 spec vocabulary + * Tracked-change broad type. Accepts the canonical spec vocabulary * (`insertion` / `deletion` / `replacement` / `formatting` / `move` / * `structural`) and the legacy `insert` / `delete` / `format` aliases * during the vocabulary migration. diff --git a/packages/document-api/src/types/table-operations.types.ts b/packages/document-api/src/types/table-operations.types.ts index 103f6a8eab..3c0faab3da 100644 --- a/packages/document-api/src/types/table-operations.types.ts +++ b/packages/document-api/src/types/table-operations.types.ts @@ -256,20 +256,6 @@ export type TablesInsertRowInput = export type TablesDeleteRowInput = DirectRowTargetLocator | TableScopedRowLocator; -export type TableRowMoveDestination = - | { kind: 'first' } - | { kind: 'last' } - | { kind: 'before'; rowIndex: number } - | { kind: 'after'; rowIndex: number } - | { kind: 'before'; target: TableRowAddress } - | { kind: 'after'; target: TableRowAddress } - | { kind: 'before'; nodeId: string } - | { kind: 'after'; nodeId: string }; - -export type TablesMoveRowInput = (DirectRowTargetLocator | TableScopedRowLocator) & { - destination: TableRowMoveDestination; -}; - export type TablesSetRowHeightInput = | (TableScopedRowLocator & { heightPt: number; rule?: 'atLeast' | 'exact' | 'auto' }) | (DirectRowTargetLocator & { heightPt: number; rule?: 'atLeast' | 'exact' | 'auto' }); diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 83d409235b..37788695ba 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -2,15 +2,15 @@ import type { TextTarget, TrackedChangeAddress } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; import type { StoryLocator } from './story.types.js'; /** - * Canonical v2 tracked-change broad-type vocabulary defined by + * Canonical tracked-change broad-type vocabulary defined by * `../labs/tests/requirements/specs/tracked-changes-comments/tracked-changes-spec.md` * §3 / §5. * * Public adapters MUST emit one of these values. Existing v1 emitters and * tests still produce the legacy `insert` / `delete` / `format` strings; both * sets are accepted by {@link TrackChangeType} during the vocabulary - * legacy strings are documented compatibility aliases only — new code must - * emit the canonical vocabulary. + * transition. Legacy strings are documented compatibility aliases only — new + * code must emit the canonical vocabulary. */ export type TrackChangeBroadType = 'insertion' | 'deletion' | 'replacement' | 'formatting' | 'move' | 'structural'; /** @@ -19,9 +19,9 @@ export type TrackChangeBroadType = 'insertion' | 'deletion' | 'replacement' | 'f */ export type LegacyTrackChangeType = 'insert' | 'delete' | 'format'; /** - * Tracked-change broad type accepted by the public API. The v2 logical - * projection emits {@link TrackChangeBroadType}; legacy v1 emitters may still - * produce {@link LegacyTrackChangeType}. Filters accept either spelling. + * Tracked-change broad type accepted by the public API. Logical projections + * emit {@link TrackChangeBroadType}; legacy v1 emitters may still produce + * {@link LegacyTrackChangeType}. Filters accept either spelling. */ export type TrackChangeType = TrackChangeBroadType | LegacyTrackChangeType; /** @@ -214,8 +214,8 @@ export interface TrackChangeSnapshot { structural?: TrackChangeStructuralSnapshot; } /** - * Source-platform provenance per spec §3. Imported Word DOCX revisions - * surface as `word`; v2 native edits surface `superdoc`. + * Source-platform provenance per spec §3. Imported Word DOCX revisions surface + * as `word`; native edits surface `superdoc`. */ export type TrackChangeProvenanceOrigin = 'word' | 'google-docs' | 'superdoc' | 'custom' | 'unknown'; export type TrackChangeSourcePlatform = TrackChangeProvenanceOrigin; @@ -255,7 +255,7 @@ export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; export interface TrackChangeOverlapLayer { /** SuperDoc logical id of the contributing tracked change. */ id: string; - /** Broad type of the layer (canonical v2 spelling). */ + /** Broad type of the layer (canonical spelling). */ type: TrackChangeType; /** Layer relationship to the parent overlap surface. */ relationship: TrackChangeOverlapRelationship; diff --git a/packages/document-host/.gitignore b/packages/document-host/.gitignore new file mode 100644 index 0000000000..6e551cddf1 --- /dev/null +++ b/packages/document-host/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.bun-build diff --git a/packages/document-host/README.md b/packages/document-host/README.md new file mode 100644 index 0000000000..e86c048dd3 --- /dev/null +++ b/packages/document-host/README.md @@ -0,0 +1,25 @@ +# @superdoc/document-host + +A neutral, headless **structured document session** over the SuperDoc engine. + +It does exactly four things and nothing more: + +- `openDocument(bytes?)` - open a `.docx` (or a blank doc) headlessly +- `session.invoke(operationId, input?, options?)` - dispatch one operation + directly against the engine via `document-api`'s `invoke` +- `session.export()` - serialize current state back to `.docx` bytes +- `session.close()` - release the editor and DOM environment + +There is **no CLI argv layer** and **no agent-specific vocabulary** here. This is +the boring, reusable runtime boundary. Higher-level concerns (model-friendly +handles, language facades, focused tools, verification loops) belong in separate +downstream layers that consume this package as a client. + +It is distilled from the existing headless assembly in +`apps/cli/src/lib/document.ts`, with CLI types and stdin handling stripped out, so +the open -> invoke -> export path is reusable on its own. + +Collaboration is **optional, not removed**: a caller may inject a Y.Doc and a +collaboration provider at `openDocument` time (and drive cursors through the +session's presence handle). Without one, the session is a plain local document and +export preserves the opened bytes for a clean, unmutated session. diff --git a/packages/document-host/package.json b/packages/document-host/package.json new file mode 100644 index 0000000000..c51c3fd1e6 --- /dev/null +++ b/packages/document-host/package.json @@ -0,0 +1,28 @@ +{ + "name": "@superdoc/document-host", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Neutral headless structured document session over the SuperDoc engine: open a .docx, invoke operations by operationId, export .docx. No CLI argv, no agent vocabulary.", + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "pretest": "pnpm --filter superdoc run build:dev", + "test": "bun test", + "pretypecheck": "pnpm --filter superdoc run build:es", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@superdoc/document-api": "workspace:*", + "superdoc": "workspace:*", + "happy-dom": "catalog:" + }, + "devDependencies": { + "yjs": "catalog:" + } +} diff --git a/packages/document-host/src/dirty.test.ts b/packages/document-host/src/dirty.test.ts new file mode 100644 index 0000000000..e14af1667d --- /dev/null +++ b/packages/document-host/src/dirty.test.ts @@ -0,0 +1,70 @@ +/** + * Unit proofs for the dirty-tracking classifier. These pin the exact dryRun / + * mutates semantics that decide whether `export()` re-serializes or returns the + * original bytes verbatim - including the formatRange input-level dryRun quirk + * and the guard that keeps a stray `input.dryRun` from causing a false-clean save. + */ +import { test, expect } from 'bun:test'; +import { COMMAND_CATALOG } from '@superdoc/document-api'; +import { isMutatingInvoke } from './dirty'; + +test('read-only op (catalog mutates:false) is not dirty', () => { + expect(COMMAND_CATALOG['getText'].mutates).toBe(false); + expect(isMutatingInvoke('getText', {}, undefined)).toBe(false); +}); + +test('mutating op (catalog mutates:true) is dirty', () => { + expect(COMMAND_CATALOG['insert'].mutates).toBe(true); + expect(isMutatingInvoke('insert', {}, undefined)).toBe(true); +}); + +test('unknown op defaults to mutating (fail-safe against false-clean save)', () => { + expect(isMutatingInvoke('not.a.real.op', {}, undefined)).toBe(true); +}); + +test('a leading doc. prefix is stripped before catalog lookup', () => { + expect(isMutatingInvoke('doc.getText', {}, undefined)).toBe(false); + expect(isMutatingInvoke('doc.insert', {}, undefined)).toBe(true); +}); + +test('options.dryRun turns a mutating op into a non-dirty preview', () => { + expect(isMutatingInvoke('insert', {}, { dryRun: true })).toBe(false); +}); + +test('only dryRun === true counts as a preview', () => { + expect(isMutatingInvoke('insert', {}, { dryRun: 'yes' })).toBe(true); + expect(isMutatingInvoke('insert', {}, { dryRun: false })).toBe(true); +}); + +test('formatRange honors input-level dryRun (its dryRun lives on input, not options)', () => { + // Mirrors @superdoc/document-api formatRange: dryRun = input.dryRun ?? options.dryRun. + expect(isMutatingInvoke('formatRange', { dryRun: true }, undefined)).toBe(false); + expect(isMutatingInvoke('formatRange', {}, { dryRun: true })).toBe(false); + // Without any dryRun it follows the catalog (a real format mutation). + expect(isMutatingInvoke('formatRange', { properties: { bold: true } }, undefined)).toBe( + COMMAND_CATALOG['formatRange'].mutates === true, + ); +}); + +test('input-level dryRun is ignored for non-formatRange ops (no false-clean save)', () => { + // These ops read dryRun from options only; a stray input.dryRun must not mark a real + // mutation clean, or export() would silently drop the change. + expect(isMutatingInvoke('insert', { dryRun: true }, undefined)).toBe(true); + expect(isMutatingInvoke('format.apply', { dryRun: true }, undefined)).toBe(true); +}); + +test('formatRange input.dryRun precedence: input:false overrides options:true (op mutates)', () => { + // @superdoc/document-api resolves formatRange dryRun as input.dryRun ?? options.dryRun, so an + // explicit input.dryRun:false means a real mutation even when options.dryRun:true. The session + // must stay dirty or the edit is dropped on export. + expect(isMutatingInvoke('formatRange', { dryRun: false }, { dryRun: true })).toBe(true); +}); + +test('options.dryRun is ignored for mutating ops that do not support dryRun', () => { + // comments.create / clearContent take RevisionGuardOptions (no dryRun); the op ignores a stray + // dryRun and mutates, so the session must stay dirty (no false-clean save). + expect(COMMAND_CATALOG['comments.create'].supportsDryRun).toBe(false); + expect(COMMAND_CATALOG['clearContent'].supportsDryRun).toBe(false); + expect(isMutatingInvoke('comments.create', {}, { dryRun: true })).toBe(true); + expect(isMutatingInvoke('clearContent', {}, { dryRun: true })).toBe(true); +}); diff --git a/packages/document-host/src/dirty.ts b/packages/document-host/src/dirty.ts new file mode 100644 index 0000000000..2d98973b4f --- /dev/null +++ b/packages/document-host/src/dirty.ts @@ -0,0 +1,62 @@ +/** + * Dirty-tracking classification for the headless document session. + * + * A successful invoke dirties the session - forcing `export()` to re-serialize + * instead of returning the original input bytes verbatim - iff the operation + * actually changed the document. We approximate "changed" as: the operation + * mutates (per the authoritative COMMAND_CATALOG) AND it did not run as a dryRun + * preview. Getting this wrong in the clean direction silently drops a real edit + * on the next export, so every ambiguous case fails safe to "mutating". + * + * COMMAND_CATALOG is re-exported from @superdoc/document-api's entry (the rpc + * server imports it the same way). A leading `doc.` is stripped defensively. + * + * Two subtleties, both mirroring @superdoc/document-api exactly: + * - dryRun is only honored for ops that SUPPORT it (`supportsDryRun`). Ops that + * take RevisionGuardOptions (e.g. comments.create, clearContent) ignore a + * stray `dryRun` flag and still mutate, so we must keep the session dirty. + * - `formatRange` is a legacy-compat alias whose mutation flags live on its + * INPUT; the api resolves its dryRun as `input.dryRun ?? options.dryRun` + * (input precedence - an explicit `input.dryRun: false` overrides + * `options.dryRun: true` and the op mutates). Every other op reads dryRun + * from `options` only. + */ +import { COMMAND_CATALOG } from '@superdoc/document-api'; + +/** Ops whose dryRun is resolved from the input object (with precedence over options). */ +const INPUT_DRYRUN_OPERATIONS: ReadonlySet = new Set(['formatRange']); + +/** + * The effective dryRun the operation itself would compute, mirroring + * @superdoc/document-api: `formatRange` uses `input.dryRun ?? options.dryRun`; + * all other ops read `options.dryRun`. Only a literal `true` counts as a preview + * (non-boolean dryRun is rejected by the api's own input validation). + */ +function resolveDryRun(opId: string, input: unknown, options: unknown): boolean { + const optionsDryRun = (options as { dryRun?: unknown } | undefined)?.dryRun; + if (INPUT_DRYRUN_OPERATIONS.has(opId)) { + const inputDryRun = (input as { dryRun?: unknown } | undefined)?.dryRun; + return (inputDryRun ?? optionsDryRun) === true; + } + return optionsDryRun === true; +} + +/** + * Decide whether a successful invoke should mark the session dirty. + * @param operationId canonical document-api operation id (a leading `doc.` is tolerated) + * @param input the op input (consulted for input-dryRun ops like formatRange) + * @param options the op options (the standard home of dryRun) + */ +export function isMutatingInvoke(operationId: string, input: unknown, options: unknown): boolean { + const opId = operationId.startsWith('doc.') ? operationId.slice(4) : operationId; + const entry = COMMAND_CATALOG[opId as keyof typeof COMMAND_CATALOG]; + // Unknown op -> assume mutating (fail-safe: a new/unknown op must never produce a + // false-clean save that silently drops a change). + if (!entry) return true; + // Read-only op -> never dirties. + if (entry.mutates !== true) return false; + // Mutating op stays clean only if it actually ran as a dryRun preview, and only ops + // that support dryRun honor the flag at all. + if (entry.supportsDryRun === true && resolveDryRun(opId, input, options)) return false; + return true; +} diff --git a/packages/document-host/src/host.identity.proof.test.ts b/packages/document-host/src/host.identity.proof.test.ts new file mode 100644 index 0000000000..d5fb5fc204 --- /dev/null +++ b/packages/document-host/src/host.identity.proof.test.ts @@ -0,0 +1,143 @@ +/** + * Proofs for two hardening changes on the in-process host: + * + * 1. Per-session author identity: a caller can attribute tracked changes to a + * specific author via `openDocument(..., { user })`, overriding the env/default + * identity. Omitting `user` keeps the env/default behavior. + * 2. Dirty tracking via the authoritative COMMAND_CATALOG: a read-only op leaves + * the export byte-identical to the input, a mutating op changes it, and a + * dryRun of a mutating op leaves it byte-identical. + */ + +import { test, expect, afterEach } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { COMMAND_CATALOG } from '@superdoc/document-api'; +import { openDocument } from './index'; + +const FIXTURE = join(import.meta.dir, '../../../evals/fixtures/docs/employment-offer.docx'); +const sha = (b: Uint8Array) => createHash('sha256').update(b).digest('hex'); + +// Representative ops, asserted against the catalog so the test fails loudly if a +// future contract change flips their mutation classification. +const READONLY_OP = 'getText'; +const MUTATING_OP = 'insert'; + +test('catalog classifies our representative ops as expected', () => { + expect(COMMAND_CATALOG[READONLY_OP].mutates).toBe(false); + expect(COMMAND_CATALOG[MUTATING_OP].mutates).toBe(true); +}); + +interface TrackChange { + author?: string; +} +interface TrackChangesList { + items: TrackChange[]; +} + +async function listTrackedAuthors(session: Awaited>): Promise { + const result = (await session.invoke('trackChanges.list', {})) as TrackChangesList; + return result.items.map((item) => item.author ?? ''); +} + +// Restore any env we touch so tests stay order-independent. +const savedEnv: Record = {}; +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + delete savedEnv[key]; + } +}); +function setEnv(key: string, value: string | undefined): void { + if (!(key in savedEnv)) savedEnv[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +test('per-session user identity attributes tracked changes to the provided author', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + // Make sure the default path could NOT accidentally produce the expected name. + setEnv('SUPERDOC_DOC_AUTHOR', undefined); + setEnv('SUPERDOC_DOC_AUTHOR_ID', undefined); + + const session = await openDocument(source, { user: { id: 'agent-7', name: 'Agent Seven' } }); + try { + await session.invoke('insert', { value: 'tracked identity marker', type: 'text' }, { changeMode: 'tracked' }); + const authors = await listTrackedAuthors(session); + expect(authors.length).toBeGreaterThan(0); + expect(authors).toContain('Agent Seven'); + // The headless default must not appear when an explicit identity was given. + expect(authors).not.toContain('Document Host'); + } finally { + session.close(); + } +}); + +test('omitting user falls back to the SUPERDOC_DOC_AUTHOR env identity', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + setEnv('SUPERDOC_DOC_AUTHOR', 'Env Author'); + setEnv('SUPERDOC_DOC_AUTHOR_ID', 'env-author'); + + const session = await openDocument(source); + try { + await session.invoke('insert', { value: 'env identity marker', type: 'text' }, { changeMode: 'tracked' }); + const authors = await listTrackedAuthors(session); + expect(authors).toContain('Env Author'); + } finally { + session.close(); + } +}); + +test('omitting user and env falls back to the Document Host default', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + setEnv('SUPERDOC_DOC_AUTHOR', undefined); + setEnv('SUPERDOC_DOC_AUTHOR_ID', undefined); + + const session = await openDocument(source); + try { + await session.invoke('insert', { value: 'default identity marker', type: 'text' }, { changeMode: 'tracked' }); + const authors = await listTrackedAuthors(session); + expect(authors).toContain('Document Host'); + } finally { + session.close(); + } +}); + +test('a read-only op (catalog mutates:false) leaves the export byte-identical to the input', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const session = await openDocument(source); + try { + await session.invoke(READONLY_OP, {}); + const exported = await session.export(); + // Byte identity: the no-op save returns the original bytes verbatim. + expect(sha(exported)).toBe(sha(source)); + } finally { + session.close(); + } +}); + +test('a mutating op (catalog mutates:true) changes the export', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const session = await openDocument(source); + try { + await session.invoke(MUTATING_OP, { value: 'real mutation marker', type: 'text' }); + const exported = await session.export(); + expect(sha(exported)).not.toBe(sha(source)); + } finally { + session.close(); + } +}); + +test('a dryRun of a mutating op leaves the export byte-identical to the input', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const session = await openDocument(source); + try { + await session.invoke(MUTATING_OP, { value: 'preview only marker', type: 'text' }, { dryRun: true }); + const exported = await session.export(); + expect(sha(exported)).toBe(sha(source)); + } finally { + session.close(); + } +}); diff --git a/packages/document-host/src/host.presence.proof.test.ts b/packages/document-host/src/host.presence.proof.test.ts new file mode 100644 index 0000000000..5d5a390ce3 --- /dev/null +++ b/packages/document-host/src/host.presence.proof.test.ts @@ -0,0 +1,225 @@ +/** + * Presence proof: document-host exposes a typed, provider-agnostic presence + * surface (`session.presence`) over the injected collaboration provider's + * awareness, and no longer leaks the engine editor. Proves the contract, not + * just the happy path: + * - presence throws clearly when there is no collaboration provider + * - setUser / setStatus write the awareness `user` / `status` fields + * - setSelection writes a valid relative `cursor` payload from a SelectionTarget + * - setSelection works on a freshly opened session BEFORE it mutates (the + * demo's "jump to Section 2" happens before any streamed edit) + * - clearStatus / clearSelection null their fields + * - document-host bundles no collaboration provider packages + */ + +import { test, expect } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; +import { openDocument } from './index'; +import type { CollaborationProvider } from 'superdoc/super-editor'; + +const FIXTURE = join(import.meta.dir, '../../../evals/fixtures/docs/employment-offer.docx'); + +const collapsedTarget = (blockId: string, offset: number) => ({ + kind: 'selection' as const, + start: { kind: 'text' as const, blockId, offset }, + end: { kind: 'text' as const, blockId, offset }, +}); + +const firstNonEmptyBlock = async (session: Awaited>) => { + const list = (await session.invoke('blocks.list', { includeText: true })) as { + blocks: Array<{ nodeId: string; isEmpty: boolean }>; + }; + return list.blocks.find((b) => !b.isEmpty) ?? list.blocks[0]; +}; + +/** Awareness that actually stores local state, so presence writes are observable. */ +class StatefulAwareness { + clientID = 1; + #state: Record = {}; + setLocalStateField(field: string, value: unknown): void { + this.#state = { ...this.#state, [field]: value }; + } + setLocalState(state: Record | null): void { + this.#state = state ?? {}; + } + getLocalState(): Record { + return this.#state; + } + getStates(): Map { + return new Map([[this.clientID, this.#state]]); + } + on(): void {} + off(): void {} +} + +/** In-memory provider with a stateful awareness and peer-to-peer Y.Doc bridging. */ +class MemoryCollaborationProvider implements CollaborationProvider { + synced = true; + isSynced = true; + awareness = new StatefulAwareness(); + #peer: MemoryCollaborationProvider | null = null; + #closed = false; + + constructor(readonly ydoc: YDoc) { + this.ydoc.on('update', this.#forwardUpdate); + } + + connect(peer: MemoryCollaborationProvider): void { + this.#peer = peer; + } + + syncToPeer(): void { + if (!this.#peer) return; + applyUpdate(this.#peer.ydoc, encodeStateAsUpdate(this.ydoc), this.#peer); + } + + receive(update: Uint8Array): void { + applyUpdate(this.ydoc, update, this); + } + + on(): void {} + off(): void {} + + disconnect(): void { + if (this.#closed) return; + this.#closed = true; + this.ydoc.off('update', this.#forwardUpdate); + this.#peer = null; + } + + destroy(): void { + this.disconnect(); + } + + #forwardUpdate = (update: Uint8Array, origin: unknown) => { + if (this.#closed || origin === this) return; + this.#peer?.receive(update); + }; +} + +test('presence throws a clear error when opened without a collaboration provider', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const session = await openDocument(source); + try { + expect(() => session.presence.setUser({ id: 'u1', name: 'Nobody' })).toThrow( + /Presence requires a collaboration provider/, + ); + expect(() => session.presence.setSelection(collapsedTarget('whatever', 0))).toThrow( + /Presence requires a collaboration provider/, + ); + } finally { + session.close(); + } +}); + +test('setUser / setStatus / clearStatus write and clear the awareness fields', async () => { + const ydoc = new YDoc({ gc: false }); + const provider = new MemoryCollaborationProvider(ydoc); + const session = await openDocument(undefined, { + documentId: 'presence-status', + collaboration: { ydoc, collaborationProvider: provider }, + }); + try { + const user = { id: 'agent-1', name: 'SuperDoc Agent', color: '#7c3aed' }; + session.presence.setUser(user); + expect(provider.awareness.getLocalState().user).toEqual(user); + + session.presence.setStatus({ state: 'thinking', label: 'Reading your request' }); + expect(provider.awareness.getLocalState().status).toEqual({ state: 'thinking', label: 'Reading your request' }); + + // label is optional and normalizes to null + session.presence.setStatus({ state: 'editing' }); + expect(provider.awareness.getLocalState().status).toEqual({ state: 'editing', label: null }); + + session.presence.clearStatus(); + expect(provider.awareness.getLocalState().status).toBeNull(); + } finally { + session.close(); + } +}); + +test('setSelection encodes a SelectionTarget into a relative cursor payload; clearSelection nulls it', async () => { + const ydoc = new YDoc({ gc: false }); + const provider = new MemoryCollaborationProvider(ydoc); + const session = await openDocument(undefined, { + documentId: 'presence-selection', + collaboration: { ydoc, collaborationProvider: provider }, + }); + try { + // The collaborative doc hydrates from the (empty) Y.Doc, so create content first. + await session.invoke('insert', { value: 'Section text for presence.', type: 'text' }); + + const block = await firstNonEmptyBlock(session); + expect(block?.nodeId).toBeTruthy(); + + session.presence.setSelection(collapsedTarget(block.nodeId, 0)); + const cursor = provider.awareness.getLocalState().cursor as { anchor: unknown; head: unknown } | null; + expect(cursor).toBeTruthy(); + expect(cursor!.anchor).toBeTruthy(); + expect(cursor!.head).toBeTruthy(); + + session.presence.clearSelection(); + expect(provider.awareness.getLocalState().cursor).toBeNull(); + } finally { + session.close(); + } +}); + +test('setSelection works on a freshly opened session before it mutates (the pre-edit caret jump)', async () => { + // This mirrors the demo: a session joins a room that already has content and + // moves its caret to a section BEFORE performing any edit. The headless + // collaborative binding must therefore be ready right after open, not only + // after the first transaction. + const ydocA = new YDoc({ gc: false }); + const ydocB = new YDoc({ gc: false }); + const providerA = new MemoryCollaborationProvider(ydocA); + const providerB = new MemoryCollaborationProvider(ydocB); + providerA.connect(providerB); + providerB.connect(providerA); + + let sessionA: Awaited> | undefined; + let sessionB: Awaited> | undefined; + try { + // Seed shared content through session A, then push it to B's Y.Doc. + sessionA = await openDocument(undefined, { + documentId: 'presence-preedit', + collaboration: { ydoc: ydocA, collaborationProvider: providerA }, + }); + await sessionA.invoke('insert', { value: 'Obligations of the receiving party.', type: 'text' }); + providerA.syncToPeer(); + + // Session B opens fresh against the populated Y.Doc and never mutates. + sessionB = await openDocument(undefined, { + documentId: 'presence-preedit', + collaboration: { ydoc: ydocB, collaborationProvider: providerB }, + }); + const block = await firstNonEmptyBlock(sessionB); + expect(block?.nodeId).toBeTruthy(); + + // setSelection BEFORE session B performs any insert/mutation. + sessionB.presence.setSelection(collapsedTarget(block.nodeId, 0)); + const cursor = providerB.awareness.getLocalState().cursor as { anchor: unknown; head: unknown } | null; + expect(cursor).toBeTruthy(); + expect(cursor!.anchor).toBeTruthy(); + expect(cursor!.head).toBeTruthy(); + } finally { + sessionB?.close(); + sessionA?.close(); + providerB.disconnect(); + providerA.disconnect(); + } +}); + +test('document-host bundles no collaboration provider packages', async () => { + const pkg = JSON.parse(await readFile(join(import.meta.dir, '../package.json'), 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + const names = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]; + const forbidden = names.filter( + (n) => n.startsWith('@hocuspocus/') || n.startsWith('@liveblocks/') || n === 'y-websocket' || n === 'y-webrtc', + ); + expect(forbidden).toEqual([]); +}); diff --git a/packages/document-host/src/host.proof.test.ts b/packages/document-host/src/host.proof.test.ts new file mode 100644 index 0000000000..f5f10afd61 --- /dev/null +++ b/packages/document-host/src/host.proof.test.ts @@ -0,0 +1,177 @@ +/** + * Milestone 1 proof: the structured host can open a real .docx, run one + * operation directly by operationId (no argv), export, and the exported file + * reflects the change after a clean round-trip re-open. + */ + +import { test, expect } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; +import { openDocument } from './index'; +import type { CollaborationProvider } from 'superdoc/super-editor'; + +const FIXTURE = join(import.meta.dir, '../../../evals/fixtures/docs/employment-offer.docx'); +const MARKER = 'SUPERDOC_HOST_PROOF_MARKER_42'; +const COLLAB_MARKER = 'SUPERDOC_HOST_COLLAB_MARKER_43'; + +const sha = (b: Uint8Array) => createHash('sha256').update(b).digest('hex'); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForText(readText: () => Promise, marker: string): Promise { + const deadline = Date.now() + 1500; + let lastText = ''; + while (Date.now() < deadline) { + lastText = await readText(); + if (lastText.includes(marker)) return lastText; + await wait(20); + } + throw new Error(`Timed out waiting for collaborative text marker ${marker}. Last text: ${lastText}`); +} + +class MemoryCollaborationProvider implements CollaborationProvider { + synced = true; + isSynced = true; + awareness = { + setLocalStateField() {}, + setLocalState() {}, + getLocalState() { + return null; + }, + getStates() { + return new Map(); + }, + on() {}, + off() {}, + }; + + #peer: MemoryCollaborationProvider | null = null; + #closed = false; + + constructor(readonly ydoc: YDoc) { + this.ydoc.on('update', this.#forwardUpdate); + } + + connect(peer: MemoryCollaborationProvider): void { + this.#peer = peer; + } + + syncToPeer(): void { + if (!this.#peer) return; + applyUpdate(this.#peer.ydoc, encodeStateAsUpdate(this.ydoc), this.#peer); + } + + receive(update: Uint8Array): void { + applyUpdate(this.ydoc, update, this); + } + + on() {} + + off() {} + + disconnect(): void { + if (this.#closed) return; + this.#closed = true; + this.ydoc.off('update', this.#forwardUpdate); + this.#peer = null; + } + + destroy(): void { + this.disconnect(); + } + + #forwardUpdate = (update: Uint8Array, origin: unknown) => { + if (this.#closed || origin === this) return; + this.#peer?.receive(update); + }; +} + +test('open real .docx -> invoke(operationId) -> export -> verify the file changed', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + + // 1. Open + read text directly via operationId. + const session = await openDocument(source); + const before = JSON.stringify(await session.invoke('getText', {})); + expect(before).not.toContain(MARKER); + + // 2. One direct structured mutation (append text at end of document). + const receipt = await session.invoke('insert', { value: MARKER, type: 'text' }); + expect(receipt).toBeTruthy(); + + // 3. Export the mutated document. + const exported = await session.export(); + session.close(); + + // The exported bytes are a different document than the input. + expect(sha(exported)).not.toBe(sha(source)); + + // 4. Round-trip: re-open the exported bytes and confirm the change persisted + // through serialization (the real proof the export is correct). + const reopened = await openDocument(exported); + const after = JSON.stringify(await reopened.invoke('getText', {})); + reopened.close(); + + expect(after).toContain(MARKER); + expect(after).not.toBe(before); +}); + +test('a closed session rejects invoke() and export() instead of acting on a torn-down editor', async () => { + const session = await openDocument(); + session.close(); + // Closed is inert: both surfaces fail clearly (mirrors the presence guard), so a no-op export + // never returns bytes from a destroyed session and invoke never dispatches against it. + await expect(session.invoke('getText', {})).rejects.toThrow(/closed/); + await expect(session.export()).rejects.toThrow(/closed/); +}); + +test('openDocument accepts caller-owned collaboration bindings', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const ydocA = new YDoc({ gc: false }); + const ydocB = new YDoc({ gc: false }); + const providerA = new MemoryCollaborationProvider(ydocA); + const providerB = new MemoryCollaborationProvider(ydocB); + providerA.connect(providerB); + providerB.connect(providerA); + + let sessionA: Awaited> | undefined; + let sessionB: Awaited> | undefined; + + try { + sessionA = await openDocument(source, { + documentId: 'collab-proof', + collaboration: { ydoc: ydocA, collaborationProvider: providerA }, + }); + providerA.syncToPeer(); + + sessionB = await openDocument(undefined, { + documentId: 'collab-proof', + collaboration: { ydoc: ydocB, collaborationProvider: providerB }, + }); + + const before = JSON.stringify(await sessionB.invoke('getText', {})); + expect(before).not.toContain(COLLAB_MARKER); + + await sessionA.invoke('insert', { value: COLLAB_MARKER, type: 'text' }); + + const after = await waitForText(async () => JSON.stringify(await sessionB!.invoke('getText', {})), COLLAB_MARKER); + expect(after).toContain(COLLAB_MARKER); + + // sessionB received the edit via Y.Doc sync, not a local invoke, so it is not "dirty". + // Byte-preservation must NOT short-circuit export for a collaborative session: the Y.Doc is + // authoritative, so export must serialize the live synced state (with the marker), not the + // blank bytes sessionB opened from. Re-open the exported bytes to prove the content is there. + const exportedB = await sessionB.export(); + const verify = await openDocument(exportedB); + try { + expect(JSON.stringify(await verify.invoke('getText', {}))).toContain(COLLAB_MARKER); + } finally { + verify.close(); + } + } finally { + sessionB?.close(); + sessionA?.close(); + providerB.disconnect(); + providerA.disconnect(); + } +}); diff --git a/packages/document-host/src/index.ts b/packages/document-host/src/index.ts new file mode 100644 index 0000000000..cde48f4ea0 --- /dev/null +++ b/packages/document-host/src/index.ts @@ -0,0 +1,266 @@ +/** + * @superdoc/document-host + * + * Neutral, headless structured document session over the SuperDoc engine. + * Open a `.docx`, dispatch operations directly by `operationId` via + * `document-api.invoke`, and export `.docx` bytes. No CLI argv, no agent + * vocabulary - that lives in the labs agent runtime, which consumes this. + * + * Distilled from `apps/cli/src/lib/document.ts` (the existing headless + * assembly), with CLI-specific types and stdin removed. Collaboration provider + * construction stays caller-owned; callers can inject an already-created + * Y.Doc and provider binding when they need a live collaborative session. + */ + +import { + Editor, + BLANK_DOCX_BASE64, + getDocumentApiAdapters, + initPartsRuntime, + encodeCollaborationCursorFromSelectionTarget, + type CollaborationProvider, +} from 'superdoc/super-editor'; +import { createDocumentApi, type DocumentApi, type SelectionTarget } from '@superdoc/document-api'; +import { Window } from 'happy-dom'; +import { isMutatingInvoke } from './dirty'; + +/** Identity published to collaboration awareness (provider-agnostic shape). */ +export interface PresenceProfile { + id?: string; + name?: string; + color?: string; + [key: string]: unknown; +} + +/** A transient activity status published to awareness (generic; no agent vocabulary). */ +export interface PresenceStatus { + state: string; + label?: string | null; +} + +/** + * Collaboration presence surface. Publishes user / status / selection to the + * caller-injected provider's awareness. The engine owns the ProseMirror/Yjs + * selection encoding (`setSelection` takes a Document API `SelectionTarget`, + * never raw editor positions). Every method throws a clear error if the session + * was opened without a collaboration provider exposing + * `awareness.setLocalStateField`, or after the session is closed. + */ +export interface PresenceHandle { + /** Publish the local participant identity (awareness `user`). */ + setUser(profile: PresenceProfile): void; + /** Publish a transient activity status (awareness `status`). */ + setStatus(status: PresenceStatus): void; + /** Clear the activity status (awareness `status` -> null). */ + clearStatus(): void; + /** Publish the local selection/caret as a collaboration cursor (awareness `cursor`). */ + setSelection(target: SelectionTarget): void; + /** Clear the collaboration cursor (awareness `cursor` -> null). */ + clearSelection(): void; +} + +/** A live, headless document session bound to one open `.docx`. */ +export interface DocumentSession { + /** + * Dispatch a structured operation directly against the engine. + * Mirrors `editor.doc.invoke({ operationId, input, options })`. + */ + invoke(operationId: string, input?: unknown, options?: unknown): Promise; + /** Serialize current document state to `.docx` bytes. */ + export(): Promise; + /** Release the editor and DOM environment. Idempotent. */ + close(): void; + /** Collaboration presence (user / status / selection) over the injected provider's awareness. */ + presence: PresenceHandle; +} + +export interface OpenOptions { + /** Logical id used by the engine; defaults to `document.docx`. */ + documentId?: string; + /** + * Per-session author identity attributed to tracked changes and comments. + * Takes precedence over the `SUPERDOC_DOC_AUTHOR(_ID)` env vars, which in turn + * fall back to the headless host default. Omit it to keep env/default behavior. + */ + user?: { id?: string; name?: string }; + /** + * Optional collaboration binding, created and owned by the caller. The host + * forwards `ydoc` and `collaborationProvider` to the engine; it never + * constructs, configures, or bundles a provider package (Hocuspocus, + * Liveblocks, y-websocket, and the like). + * + * Contract: + * - The caller builds the Y.Doc and provider and syncs the provider before + * opening; the host does not wait for provider sync. + * - The Y.Doc is authoritative for content: when a binding is passed the + * engine hydrates from the Y.Doc and ignores the opened docx body. Seeding + * a new room from a docx (isNewFile/bootstrap) is out of scope here. + * - The binding is session-owned once passed in: close() runs the engine's + * collaboration teardown, which disconnects the provider and destroys the + * Y.Doc, so callers must not reuse the Y.Doc or provider after close(). + * - Provider sync, remote-change events, and reconnect policy are out of + * scope here. Presence/awareness is published through `session.presence`. + */ + collaboration?: { + ydoc: unknown; + collaborationProvider?: CollaborationProvider | null; + }; +} + +/** + * Open a `.docx` (or a blank document when no source is given) as a headless + * session. The returned session dispatches operations by `operationId` and can + * export the mutated document back to bytes. + */ +export async function openDocument(source?: Uint8Array, options: OpenOptions = {}): Promise { + const bytes = source ?? new Uint8Array(Buffer.from(BLANK_DOCX_BASE64, 'base64')); + + // Each session gets its own happy-dom window; inject via options.document so + // no globals are touched (mirrors the CLI headless strategy). + const window = new Window(); + const document = window.document as unknown as Document; + let domDisposed = false; + const disposeDom = () => { + if (domDisposed) return; + domDisposed = true; + window.happyDOM.abort(); + window.close(); + }; + + let editor: Editor; + try { + editor = await Editor.open(Buffer.from(bytes), { + documentId: options.documentId ?? 'document.docx', + document, + isHeadless: true, + ydoc: options.collaboration?.ydoc, + collaborationProvider: options.collaboration?.collaborationProvider ?? null, + // The change/comment author. Precedence: explicit per-session identity (e.g. an agent + // attributing its own edits over the wire) > SUPERDOC_DOC_AUTHOR / SUPERDOC_DOC_AUTHOR_ID + // env override > the headless host default. + user: { + id: options.user?.id ?? process.env.SUPERDOC_DOC_AUTHOR_ID ?? 'document-host', + name: options.user?.name ?? process.env.SUPERDOC_DOC_AUTHOR ?? 'Document Host', + }, + }); + } catch (error) { + disposeDom(); + throw error; + } + + // Idempotent: ensures adapter afterCommit hooks are wired in headless sessions. + initPartsRuntime(editor as never); + + // Eagerly bind a DocumentApi over the engine adapters - this is the direct, + // structured dispatch surface (no argv). + const doc: DocumentApi = createDocumentApi(getDocumentApiAdapters(editor)); + + // No-op save preservation: a session that applies no mutation (only dryRun previews or + // read-only ops) exports the ORIGINAL input bytes verbatim, not a normalized re-export. + // Re-exporting through the engine rewrites the OOXML package (zip ordering, normalization) so + // byte identity is lost even when nothing changed; callers that preview-then-save need + // the output to equal the input. Dirtiness is tracked per invoke: a non-dryRun op that is not + // read-only marks the session dirty (see isMutatingInvoke). This catches mutations that do not + // touch the ProseMirror doc node - e.g. comment ops - which a doc-node check would miss and + // silently drop. `originalBytes` is a private snapshot of the bytes we opened from: we copy here + // so a caller mutating their input buffer after open() cannot change what a no-op export returns + // (Editor.open also copies via Buffer.from, so the editor never mutates the input either). + const originalBytes = Uint8Array.from(bytes); + let dirty = false; + + // Byte-preservation applies only to non-collaborative sessions. When a Y.Doc / collaboration + // provider is bound, the Y.Doc is the authoritative state and receives edits via sync (which do + // not go through doc.invoke, so they never mark the session dirty). Returning the opened input + // bytes there would drop synced content, so a collaborative session always re-exports live state. + const isCollaborative = Boolean(options.collaboration?.ydoc || options.collaboration?.collaborationProvider); + + let closed = false; + + // Presence resolves the awareness lazily on each call so it reflects the live + // provider state, and fails clearly when the session was opened without a + // collaboration provider (or after close()). The engine owns cursor encoding. + // Narrowed awareness: the runtime guard guarantees setLocalStateField, but the + // engine's Awareness type declares it optional, so the cast keeps callers typed. + type LocalAwareness = { setLocalStateField: (field: string, value: unknown) => void }; + const requirePresence = (): LocalAwareness => { + if (closed) throw new Error('Document session is closed; presence is unavailable.'); + const awareness = options.collaboration?.collaborationProvider?.awareness; + if (!awareness || typeof awareness.setLocalStateField !== 'function') { + throw new Error('Presence requires a collaboration provider with awareness.setLocalStateField().'); + } + return awareness as LocalAwareness; + }; + + const presence: PresenceHandle = { + setUser(profile) { + requirePresence().setLocalStateField('user', profile); + }, + setStatus(status) { + requirePresence().setLocalStateField('status', { state: status.state, label: status.label ?? null }); + }, + clearStatus() { + requirePresence().setLocalStateField('status', null); + }, + setSelection(target) { + const awareness = requirePresence(); + const payload = encodeCollaborationCursorFromSelectionTarget(editor, target); + if (!payload) { + throw new Error('setSelection requires a collaborative session (no Yjs binding for this document).'); + } + awareness.setLocalStateField('cursor', payload); + }, + clearSelection() { + requirePresence().setLocalStateField('cursor', null); + }, + }; + + return { + presence, + async invoke(operationId, input, options) { + // Closed sessions are inert: fail clearly rather than dispatching against a destroyed editor + // (mirrors the presence guard and export below) so use-after-close is an explicit error. + if (closed) throw new Error('Document session is closed; invoke is unavailable.'); + // Awaiting is a no-op for synchronous operations and correctly surfaces + // async ones (e.g. template application). Input is forwarded exactly as + // given - missing vs {} vs null is preserved, because each operation + // validates its own input. The host must not normalize it. + const result = await doc.invoke({ operationId, input, options } as Parameters[0]); + // After a successful (non-throwing) op: a non-dryRun mutation dirties the session. + if (isMutatingInvoke(operationId, input, options)) dirty = true; + return result; + }, + async export() { + // Closed sessions are inert: a no-op export must not return bytes from a torn-down session + // (mirrors invoke + the presence guard). + if (closed) throw new Error('Document session is closed; export is unavailable.'); + // Byte-preservation for clean, non-collaborative sessions only (see isCollaborative). Return a + // fresh copy so the caller owns the result and cannot corrupt the private snapshot (or a later + // no-op export) by mutating the returned buffer. + if (!dirty && !isCollaborative) return originalBytes.slice(); + const exported = await editor.exportDocument(); + return toUint8Array(exported); + }, + close() { + if (closed) return; + closed = true; + editor.destroy(); + disposeDom(); + }, + }; +} + +async function toUint8Array(data: unknown): Promise { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + if (typeof Blob !== 'undefined' && data instanceof Blob) return new Uint8Array(await data.arrayBuffer()); + if ( + typeof data === 'object' && + data !== null && + 'arrayBuffer' in data && + typeof (data as { arrayBuffer?: unknown }).arrayBuffer === 'function' + ) { + return new Uint8Array(await (data as { arrayBuffer(): Promise }).arrayBuffer()); + } + throw new Error(`Exported document data is not binary (type=${typeof data}).`); +} diff --git a/packages/document-host/src/rpc/capabilities.proof.test.ts b/packages/document-host/src/rpc/capabilities.proof.test.ts new file mode 100644 index 0000000000..d8c38de376 --- /dev/null +++ b/packages/document-host/src/rpc/capabilities.proof.test.ts @@ -0,0 +1,238 @@ +/** + * Proofs for two hardening changes on the RPC boundary: + * + * 1. `document.capabilities` handshake: returns the protocol version, the five + * method names, and the docx size cap. Works in-process and over stdio, and + * is callable before any `document.open` (no session required). + * 2. Per-session author identity over the wire: `document.open` accepts an + * optional `user`, validates its shape, and attributes tracked changes to it. + */ + +import { test, expect } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { DocumentHostServer } from './server'; +import { PROTOCOL_VERSION, MAX_DOCX_BYTES, RpcCode } from './protocol'; + +const PKG_ROOT = join(import.meta.dir, '..', '..'); +const STDIO = join(import.meta.dir, 'stdio.ts'); +const FIXTURE = join(import.meta.dir, '../../../../evals/fixtures/docs/employment-offer.docx'); + +const EXPECTED_METHODS = [ + 'document.open', + 'document.invoke', + 'document.export', + 'document.close', + 'document.capabilities', +]; + +interface RpcResponse { + id: number; + result?: any; + error?: { code: number; message: string; data?: any }; +} + +class RpcClient { + private child: ChildProcess; + private buf = ''; + private nextId = 1; + readonly stdoutNoise: string[] = []; + private pending = new Map< + number, + { resolve: (v: RpcResponse) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + + constructor() { + this.child = spawn('bun', [STDIO], { cwd: PKG_ROOT, stdio: ['pipe', 'pipe', 'inherit'] }); + this.child.stdout!.setEncoding('utf8'); + this.child.stdout!.on('data', (chunk: string) => this.onData(chunk)); + this.child.on('error', (err) => this.rejectAll(new Error(`child error: ${err.message}`))); + this.child.on('exit', (code) => this.rejectAll(new Error(`child exited (code ${code})`))); + } + + private onData(chunk: string): void { + this.buf += chunk; + let nl: number; + while ((nl = this.buf.indexOf('\n')) >= 0) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (!line) continue; + let msg: RpcResponse; + try { + msg = JSON.parse(line); + } catch { + this.stdoutNoise.push(line); + continue; + } + const p = this.pending.get(msg.id); + if (p) { + clearTimeout(p.timer); + this.pending.delete(msg.id); + p.resolve(msg); + } + } + } + + private rejectAll(err: Error): void { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(err); + } + this.pending.clear(); + } + + call(method: string, params: unknown): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, 60000); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + } + + kill(): void { + this.child.kill(); + } +} + +test('document.capabilities (in-process) returns version, the five methods, and the size cap', async () => { + const server = new DocumentHostServer(); + // No open first: capabilities must be callable on a fresh server with zero sessions. + expect(server.sessionCount).toBe(0); + const res = await server.handle({ jsonrpc: '2.0', id: 1, method: 'document.capabilities' }); + expect('result' in res).toBe(true); + const result = (res as { result: any }).result; + expect(result.protocolVersion).toBe(PROTOCOL_VERSION); + expect(result.protocolVersion).toBe('1.0'); + expect(result.methods).toEqual(EXPECTED_METHODS); + expect(result.maxDocxBytes).toBe(MAX_DOCX_BYTES); + // Still no session created by the handshake. + expect(server.sessionCount).toBe(0); +}); + +test('document.capabilities (in-process) ignores params', async () => { + const server = new DocumentHostServer(); + const res = await server.handle({ jsonrpc: '2.0', id: 2, method: 'document.capabilities', params: { junk: true } }); + const result = (res as { result: any }).result; + expect(result.protocolVersion).toBe('1.0'); + expect(result.methods).toEqual(EXPECTED_METHODS); +}); + +test('document.capabilities over stdio: callable before any open', async () => { + const client = new RpcClient(); + try { + const caps = await client.call('document.capabilities', {}); + expect(caps.result.protocolVersion).toBe('1.0'); + expect(caps.result.methods).toEqual(EXPECTED_METHODS); + expect(caps.result.maxDocxBytes).toBe(MAX_DOCX_BYTES); + expect(client.stdoutNoise).toEqual([]); + + // It works before any session was opened, and an open still works afterwards. + const opened = await client.call('document.open', { source: { kind: 'blank' } }); + expect(typeof opened.result.sessionId).toBe('string'); + await client.call('document.close', { sessionId: opened.result.sessionId }); + } finally { + client.kill(); + } +}, 60000); + +test('per-session user identity over stdio attributes tracked changes to the provided author', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const client = new RpcClient(); + try { + const opened = await client.call('document.open', { + source: { kind: 'docxBase64', data: Buffer.from(source).toString('base64') }, + user: { id: 'agent-7', name: 'Agent Seven' }, + }); + const sessionId = opened.result.sessionId as string; + expect(typeof sessionId).toBe('string'); + + const inserted = await client.call('document.invoke', { + sessionId, + operationId: 'insert', + input: { value: 'wire tracked marker', type: 'text' }, + options: { changeMode: 'tracked' }, + }); + expect(inserted.result).toBeTruthy(); + + const listed = await client.call('document.invoke', { + sessionId, + operationId: 'trackChanges.list', + input: {}, + }); + // invoke wraps the op output as { result: }, so the list is at result.result.items. + const items = listed.result.result.items as Array<{ author?: string }>; + const authors = items.map((i) => i.author ?? ''); + expect(authors).toContain('Agent Seven'); + expect(authors).not.toContain('Document Host'); + + expect(client.stdoutNoise).toEqual([]); + await client.call('document.close', { sessionId }); + } finally { + client.kill(); + } +}, 120000); + +test('open rejects a non-record user with InvalidParams (in-process)', async () => { + const server = new DocumentHostServer(); + const res = await server.handle({ + jsonrpc: '2.0', + id: 3, + method: 'document.open', + params: { source: { kind: 'blank' }, user: 'nope' }, + }); + expect('error' in res).toBe(true); + expect((res as { error: { code: number } }).error.code).toBe(RpcCode.InvalidParams); + expect(server.sessionCount).toBe(0); +}); + +test('open rejects a non-string user.id / user.name with InvalidParams (in-process)', async () => { + const server = new DocumentHostServer(); + const badId = await server.handle({ + jsonrpc: '2.0', + id: 4, + method: 'document.open', + params: { source: { kind: 'blank' }, user: { id: 7 } }, + }); + expect((badId as { error: { code: number } }).error.code).toBe(RpcCode.InvalidParams); + + const badName = await server.handle({ + jsonrpc: '2.0', + id: 5, + method: 'document.open', + params: { source: { kind: 'blank' }, user: { name: false } }, + }); + expect((badName as { error: { code: number } }).error.code).toBe(RpcCode.InvalidParams); + expect(server.sessionCount).toBe(0); +}); + +test('rejects a request whose id is a non-spec type as InvalidRequest, without running the method (in-process)', async () => { + const server = new DocumentHostServer(); + // An object id is not a valid JSON-RPC id (string/number/null); the side-effecting method must not run. + const res = await server.handle({ + jsonrpc: '2.0', + id: { not: 'valid' }, + method: 'document.open', + params: { source: { kind: 'blank' } }, + }); + expect('error' in res).toBe(true); + expect((res as { error: { code: number } }).error.code).toBe(RpcCode.InvalidRequest); + expect(server.sessionCount).toBe(0); +}); + +test('open accepts an absent user unchanged (in-process)', async () => { + const server = new DocumentHostServer(); + const res = await server.handle({ + jsonrpc: '2.0', + id: 6, + method: 'document.open', + params: { source: { kind: 'blank' } }, + }); + expect('result' in res).toBe(true); + expect(typeof (res as { result: { sessionId: string } }).result.sessionId).toBe('string'); + server.closeAll(); +}); diff --git a/packages/document-host/src/rpc/main.ts b/packages/document-host/src/rpc/main.ts new file mode 100644 index 0000000000..e040a1b882 --- /dev/null +++ b/packages/document-host/src/rpc/main.ts @@ -0,0 +1,16 @@ +/** + * Compile entrypoint for the standalone host binary (`bun build --compile`). + * + * `stdio.ts` guards its auto-start with `import.meta.main` (true for + * `bun src/rpc/stdio.ts`), but that is false inside a compiled binary - so the + * binary needs to start the server unconditionally here. + */ + +import { runStdioServer } from './stdio.js'; + +runStdioServer() + .then(() => process.exit(0)) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); diff --git a/packages/document-host/src/rpc/protocol.ts b/packages/document-host/src/rpc/protocol.ts new file mode 100644 index 0000000000..d12dbdd27e --- /dev/null +++ b/packages/document-host/src/rpc/protocol.ts @@ -0,0 +1,107 @@ +/** + * JSON-RPC 2.0 framing and the document host method contract. + * + * A new, minimal protocol over `@superdoc/document-host`. No CLI argv, no + * `cli.invoke` compatibility, no agent vocabulary, and no imports from CLI code. + * Exactly five methods are exposed. + */ + +export const PROTOCOL_VERSION = '1.0'; + +/** The five document methods. Nothing else is exposed over the wire. */ +export const Method = { + Open: 'document.open', + Invoke: 'document.invoke', + Export: 'document.export', + Close: 'document.close', + Capabilities: 'document.capabilities', +} as const; +export type MethodName = (typeof Method)[keyof typeof Method]; + +/** Max decoded size for an inbound (open) or outbound (export) .docx: 32 MiB. */ +export const MAX_DOCX_BYTES = 32 * 1024 * 1024; + +export type JsonRpcId = string | number | null; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id?: JsonRpcId; + method: string; + params?: unknown; +} +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +} +export interface JsonRpcErrorObject { + code: number; + message: string; + data?: HostErrorData; +} +export interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId; + error: JsonRpcErrorObject; +} +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; + +/** + * Numeric error codes: JSON-RPC reserved (-326xx) plus a small app range + * (-320xx). Domain failures from the document API keep their own code in + * `data.domainCode` - it is never collapsed into the numeric code. + */ +export const RpcCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + SessionNotFound: -32000, + UnknownOperation: -32010, + PayloadTooLarge: -32020, + OperationFailed: -32030, +} as const; + +/** Structured context attached to every error so agents can recover. */ +export interface HostErrorData { + domainCode?: string; + details?: unknown; + operationId?: string; + sessionId?: string; +} + +/** Tagged union for the open source - no bare `bytes | "blank"` ambiguity. */ +export type OpenSource = { kind: 'blank' } | { kind: 'docxBase64'; data: string }; + +export interface OpenParams { + source: OpenSource; + documentId?: string; + /** Per-session author identity attributed to tracked changes and comments. */ + user?: { id?: string; name?: string }; +} +export interface InvokeParams { + sessionId: string; + operationId: string; + input?: unknown; + options?: unknown; +} +export interface SessionParams { + sessionId: string; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function makeSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} + +export function makeError(id: JsonRpcId, code: number, message: string, data?: HostErrorData): JsonRpcError { + return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } }; +} + +export function serializeFrame(frame: JsonRpcResponse): string { + return `${JSON.stringify(frame)}\n`; +} diff --git a/packages/document-host/src/rpc/rpc.proof.test.ts b/packages/document-host/src/rpc/rpc.proof.test.ts new file mode 100644 index 0000000000..a93bcc99ce --- /dev/null +++ b/packages/document-host/src/rpc/rpc.proof.test.ts @@ -0,0 +1,182 @@ +/** + * Milestone 2 proof: the clean process boundary works. + * + * Spawns the stdio JSON-RPC server as a child process and drives the full + * open -> invoke -> export -> close cycle over the wire (no argv, no SDK, no + * agent vocabulary). Also checks: structured errors (#3/#4), strict base64 + * (#2), stdout purity (non-JSON stdout fails the test), and clean shutdown on + * stdin end (#6). + */ + +import { test, expect } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; + +const PKG_ROOT = join(import.meta.dir, '..', '..'); +const STDIO = join(import.meta.dir, 'stdio.ts'); +const FIXTURE = join(import.meta.dir, '../../../../evals/fixtures/docs/employment-offer.docx'); +const MARKER = 'SUPERDOC_RPC_PROOF_MARKER_77'; + +interface RpcResponse { + id: number; + result?: any; + error?: { code: number; message: string; data?: any }; +} + +class RpcClient { + private child: ChildProcess; + private buf = ''; + private nextId = 1; + /** Complete stdout lines that were NOT valid JSON-RPC frames (contract violation). */ + readonly stdoutNoise: string[] = []; + private pending = new Map< + number, + { resolve: (v: RpcResponse) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + + constructor() { + this.child = spawn('bun', [STDIO], { cwd: PKG_ROOT, stdio: ['pipe', 'pipe', 'inherit'] }); + this.child.stdout!.setEncoding('utf8'); + this.child.stdout!.on('data', (chunk: string) => this.onData(chunk)); + this.child.on('error', (err) => this.rejectAll(new Error(`child error: ${err.message}`))); + this.child.on('exit', (code) => this.rejectAll(new Error(`child exited (code ${code})`))); + } + + private onData(chunk: string): void { + this.buf += chunk; + let nl: number; + while ((nl = this.buf.indexOf('\n')) >= 0) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (!line) continue; + let msg: RpcResponse; + try { + msg = JSON.parse(line); + } catch { + this.stdoutNoise.push(line); // stdout purity is part of the transport contract + continue; + } + const p = this.pending.get(msg.id); + if (p) { + clearTimeout(p.timer); + this.pending.delete(msg.id); + p.resolve(msg); + } + } + } + + private rejectAll(err: Error): void { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(err); + } + this.pending.clear(); + } + + call(method: string, params: unknown): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, 60000); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + } + + kill(): void { + this.child.kill(); + } + + /** Close stdin and resolve with the child's exit code (proves stdin-end cleanup). */ + endAndWaitForExit(): Promise { + return new Promise((resolve) => { + this.child.once('exit', (code) => resolve(code)); + this.child.stdin!.end(); + }); + } +} + +test('clean process boundary: open -> invoke -> export -> close over stdio JSON-RPC', async () => { + const source = new Uint8Array(await readFile(FIXTURE)); + const client = new RpcClient(); + try { + const opened = await client.call('document.open', { + source: { kind: 'docxBase64', data: Buffer.from(source).toString('base64') }, + }); + const sessionId = opened.result.sessionId as string; + expect(typeof sessionId).toBe('string'); + + // #4: unknown operation -> structured host error (not a thrown JS error) + const badOp = await client.call('document.invoke', { sessionId, operationId: 'doc.__nope__', input: {} }); + expect(badOp.error?.code).toBe(-32010); + expect(badOp.error?.data?.operationId).toBe('doc.__nope__'); + + // unknown session -> structured error + const badSession = await client.call('document.invoke', { + sessionId: 'missing', + operationId: 'getText', + input: {}, + }); + expect(badSession.error?.code).toBe(-32000); + + // real mutation by operationId, then read it back over the wire + const inserted = await client.call('document.invoke', { + sessionId, + operationId: 'insert', + input: { value: MARKER, type: 'text' }, + }); + expect(inserted.result).toBeTruthy(); + const text = await client.call('document.invoke', { sessionId, operationId: 'getText', input: {} }); + expect(JSON.stringify(text.result)).toContain(MARKER); + + // export -> base64 + byteLength + sha256 + const exported = await client.call('document.export', { sessionId }); + expect(exported.result.byteLength).toBeGreaterThan(0); + expect(exported.result.sha256).toMatch(/^[0-9a-f]{64}$/); + + // round-trip over the wire: re-open exported in a fresh session, marker persists + const reopened = await client.call('document.open', { + source: { kind: 'docxBase64', data: exported.result.docxBase64 }, + }); + const sessionId2 = reopened.result.sessionId as string; + const text2 = await client.call('document.invoke', { sessionId: sessionId2, operationId: 'getText', input: {} }); + expect(JSON.stringify(text2.result)).toContain(MARKER); + + // stdout purity: no non-JSON lines arrived on the protocol channel + expect(client.stdoutNoise).toEqual([]); + + // #6: close + idempotent close + const closed = await client.call('document.close', { sessionId }); + expect(closed.result.ok).toBe(true); + const closedAgain = await client.call('document.close', { sessionId }); + expect(closedAgain.result.alreadyClosed).toBe(true); + await client.call('document.close', { sessionId: sessionId2 }); + } finally { + client.kill(); + } +}, 120000); + +test('rejects malformed base64 source with InvalidParams', async () => { + const client = new RpcClient(); + try { + const r = await client.call('document.open', { + source: { kind: 'docxBase64', data: 'not valid base64 !!!' }, + }); + expect(r.error?.code).toBe(-32602); + expect(client.stdoutNoise).toEqual([]); + } finally { + client.kill(); + } +}, 60000); + +test('closes all sessions and exits cleanly on stdin end', async () => { + const client = new RpcClient(); + const opened = await client.call('document.open', { source: { kind: 'blank' } }); + expect(typeof opened.result.sessionId).toBe('string'); + expect(client.stdoutNoise).toEqual([]); + const code = await client.endAndWaitForExit(); + expect(code).toBe(0); +}, 60000); diff --git a/packages/document-host/src/rpc/server.ts b/packages/document-host/src/rpc/server.ts new file mode 100644 index 0000000000..c9b7988023 --- /dev/null +++ b/packages/document-host/src/rpc/server.ts @@ -0,0 +1,280 @@ +/** + * Document host RPC server: dispatches the four document methods to live + * sessions. Pure logic over `@superdoc/document-host` + `@superdoc/document-api`. + * No transport details here (see stdio.ts), no CLI imports. + */ + +import { randomUUID, createHash } from 'node:crypto'; +import { openDocument, type DocumentSession } from '../index'; +import { COMMAND_CATALOG } from '@superdoc/document-api'; +import { + Method, + RpcCode, + MAX_DOCX_BYTES, + PROTOCOL_VERSION, + isRecord, + makeSuccess, + makeError, + type JsonRpcId, + type JsonRpcResponse, + type OpenSource, +} from './protocol'; + +/** Valid operation ids = the document API's operation catalog keys. */ +const VALID_OPERATION_IDS: ReadonlySet = new Set(Object.keys(COMMAND_CATALOG)); + +function estimateBase64Bytes(base64: string): number { + const pad = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - pad; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; +function isStrictBase64(value: string): boolean { + return value.length % 4 === 0 && BASE64_RE.test(value); +} + +/** + * Preserve a structured domain code/details from ANY error that carries a + * string `code` - not just DocumentApiValidationError. Super-editor adapters + * throw structured errors too, and agents need the code to drive repair. + */ +function extractDomainError(error: unknown): { domainCode?: string; details?: unknown } { + if (error && typeof error === 'object') { + const code = (error as { code?: unknown }).code; + if (typeof code === 'string') { + return { domainCode: code, details: (error as { details?: unknown }).details }; + } + } + return {}; +} + +function readId(request: Record): JsonRpcId { + const id = request.id; + return typeof id === 'string' || typeof id === 'number' || id === null ? id : null; +} + +export class DocumentHostServer { + private readonly sessions = new Map(); + + /** Live session count (diagnostics/tests). */ + get sessionCount(): number { + return this.sessions.size; + } + + async handle(request: unknown): Promise { + if (!isRecord(request) || request.jsonrpc !== '2.0' || typeof request.method !== 'string') { + return makeError(null, RpcCode.InvalidRequest, 'Invalid JSON-RPC request.'); + } + // A present `id` of a non-spec type (not string/number/null) is a malformed frame: reject it as + // InvalidRequest rather than coercing it to null and running a side-effecting method on it. + if ('id' in request && request.id !== null && typeof request.id !== 'string' && typeof request.id !== 'number') { + return makeError( + null, + RpcCode.InvalidRequest, + 'Invalid JSON-RPC request: "id" must be a string, number, or null.', + ); + } + const id = readId(request); + const params = request.params; + + try { + switch (request.method) { + case Method.Open: + return await this.handleOpen(id, params); + case Method.Invoke: + return await this.handleInvoke(id, params); + case Method.Export: + return await this.handleExport(id, params); + case Method.Close: + return this.handleClose(id, params); + case Method.Capabilities: + return this.handleCapabilities(id); + default: + return makeError(id, RpcCode.MethodNotFound, `Method not found: ${request.method}`); + } + } catch (error) { + return makeError(id, RpcCode.InternalError, errorMessage(error)); + } + } + + /** Close every live session. Call on shutdown / stdin end. */ + closeAll(): void { + for (const session of this.sessions.values()) { + try { + session.close(); + } catch { + // best-effort cleanup + } + } + this.sessions.clear(); + } + + private async handleOpen(id: JsonRpcId, params: unknown): Promise { + if (!isRecord(params) || !isRecord(params.source)) { + return makeError( + id, + RpcCode.InvalidParams, + 'open requires params.source: { kind: "blank" } | { kind: "docxBase64", data: string }.', + ); + } + const source = params.source as OpenSource; + + let bytes: Uint8Array | undefined; + if (source.kind === 'blank') { + bytes = undefined; + } else if (source.kind === 'docxBase64') { + if (typeof source.data !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'open: source.data must be a base64 string.'); + } + if (!isStrictBase64(source.data)) { + return makeError(id, RpcCode.InvalidParams, 'open: source.data is not valid base64.'); + } + if (estimateBase64Bytes(source.data) > MAX_DOCX_BYTES) { + return makeError(id, RpcCode.PayloadTooLarge, `open: docx exceeds ${MAX_DOCX_BYTES} bytes.`, { + details: { maxBytes: MAX_DOCX_BYTES }, + }); + } + const buf = Buffer.from(source.data, 'base64'); + if (buf.byteLength > MAX_DOCX_BYTES) { + return makeError(id, RpcCode.PayloadTooLarge, `open: docx exceeds ${MAX_DOCX_BYTES} bytes.`, { + details: { maxBytes: MAX_DOCX_BYTES, byteLength: buf.byteLength }, + }); + } + bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } else { + return makeError(id, RpcCode.InvalidParams, 'open: unknown source.kind.'); + } + + const documentId = typeof params.documentId === 'string' ? params.documentId : undefined; + + // Optional per-session author identity. Validate shape here so a malformed + // user is a clean InvalidParams instead of leaking into the engine. Absent + // user keeps env/default behavior (fully backward compatible). + let user: { id?: string; name?: string } | undefined; + if ('user' in params && params.user !== undefined) { + if (!isRecord(params.user)) { + return makeError( + id, + RpcCode.InvalidParams, + 'open: params.user must be an object with optional string id/name.', + ); + } + const { id: userId, name: userName } = params.user; + if (userId !== undefined && typeof userId !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'open: params.user.id must be a string when provided.'); + } + if (userName !== undefined && typeof userName !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'open: params.user.name must be a string when provided.'); + } + user = { + ...(userId !== undefined ? { id: userId } : {}), + ...(userName !== undefined ? { name: userName } : {}), + }; + } + + const openOptions = { + ...(documentId ? { documentId } : {}), + ...(user ? { user } : {}), + }; + const session = await openDocument(bytes, openOptions); + const sessionId = randomUUID(); + this.sessions.set(sessionId, session); + return makeSuccess(id, { sessionId }); + } + + private async handleInvoke(id: JsonRpcId, params: unknown): Promise { + if (!isRecord(params) || typeof params.sessionId !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'invoke requires params.sessionId (string).'); + } + const sessionId = params.sessionId; + const session = this.sessions.get(sessionId); + if (!session) { + return makeError(id, RpcCode.SessionNotFound, `Unknown session: ${sessionId}`, { sessionId }); + } + if (typeof params.operationId !== 'string' || params.operationId.length === 0) { + return makeError(id, RpcCode.InvalidParams, 'invoke requires params.operationId (string).', { sessionId }); + } + const operationId = params.operationId; + if (!VALID_OPERATION_IDS.has(operationId)) { + return makeError(id, RpcCode.UnknownOperation, `Unknown operation: ${operationId}`, { operationId, sessionId }); + } + + // Forward input/options exactly as received (preserve missing vs {} vs null). + const input = 'input' in params ? params.input : undefined; + const options = 'options' in params ? params.options : undefined; + + try { + const result = await session.invoke(operationId, input, options); + return makeSuccess(id, { result }); + } catch (error) { + const { domainCode, details } = extractDomainError(error); + return makeError(id, RpcCode.OperationFailed, errorMessage(error), { + ...(domainCode !== undefined ? { domainCode } : {}), + ...(details !== undefined ? { details } : {}), + operationId, + sessionId, + }); + } + } + + private async handleExport(id: JsonRpcId, params: unknown): Promise { + if (!isRecord(params) || typeof params.sessionId !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'export requires params.sessionId (string).'); + } + const sessionId = params.sessionId; + const session = this.sessions.get(sessionId); + if (!session) { + return makeError(id, RpcCode.SessionNotFound, `Unknown session: ${sessionId}`, { sessionId }); + } + + const bytes = await session.export(); + if (bytes.byteLength > MAX_DOCX_BYTES) { + return makeError(id, RpcCode.PayloadTooLarge, `export: docx exceeds ${MAX_DOCX_BYTES} bytes.`, { + sessionId, + details: { maxBytes: MAX_DOCX_BYTES, byteLength: bytes.byteLength }, + }); + } + const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return makeSuccess(id, { + docxBase64: buf.toString('base64'), + byteLength: bytes.byteLength, + sha256: createHash('sha256').update(buf).digest('hex'), + }); + } + + private handleClose(id: JsonRpcId, params: unknown): JsonRpcResponse { + if (!isRecord(params) || typeof params.sessionId !== 'string') { + return makeError(id, RpcCode.InvalidParams, 'close requires params.sessionId (string).'); + } + const sessionId = params.sessionId; + const session = this.sessions.get(sessionId); + if (!session) { + return makeSuccess(id, { ok: true, alreadyClosed: true }); + } + // Always drop the session from the map, even if close() throws, so a failed teardown cannot + // leak a stale session in a long-lived host (the throw still surfaces via handle()'s catch). + try { + session.close(); + return makeSuccess(id, { ok: true }); + } finally { + this.sessions.delete(sessionId); + } + } + + /** + * Handshake: report the protocol version, the method names, and the docx size + * cap. No session is required and params are ignored, so a client can call it + * before any `document.open` to verify compatibility and discover limits. + */ + private handleCapabilities(id: JsonRpcId): JsonRpcResponse { + return makeSuccess(id, { + protocolVersion: PROTOCOL_VERSION, + methods: [Method.Open, Method.Invoke, Method.Export, Method.Close, Method.Capabilities], + maxDocxBytes: MAX_DOCX_BYTES, + }); + } +} diff --git a/packages/document-host/src/rpc/stdio.ts b/packages/document-host/src/rpc/stdio.ts new file mode 100644 index 0000000000..bcff2a4c46 --- /dev/null +++ b/packages/document-host/src/rpc/stdio.ts @@ -0,0 +1,60 @@ +/** + * Run the document host as a newline-delimited JSON-RPC 2.0 server over stdio. + * + * stdout carries ONLY protocol frames. Any stray logging from the engine is + * redirected to stderr so it cannot corrupt the stream. On stdin end (parent + * closes the pipe) every live session is closed. + */ + +import { createInterface } from 'node:readline'; +import { DocumentHostServer } from './server'; +import { makeError, serializeFrame, RpcCode, type JsonRpcResponse } from './protocol'; + +/** Route console.* to stderr so stdout stays a clean protocol channel. */ +function redirectConsoleToStderr(): void { + const toStderr = (...args: unknown[]) => { + const line = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); + process.stderr.write(`${line}\n`); + }; + console.log = toStderr as typeof console.log; + console.info = toStderr as typeof console.info; + console.warn = toStderr as typeof console.warn; + console.debug = toStderr as typeof console.debug; +} + +export async function runStdioServer( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, +): Promise { + redirectConsoleToStderr(); + const server = new DocumentHostServer(); + const write = (frame: JsonRpcResponse) => output.write(serializeFrame(frame)); + const rl = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + + try { + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + write(makeError(null, RpcCode.ParseError, 'Parse error: invalid JSON.')); + continue; + } + write(await server.handle(parsed)); + } + } finally { + server.closeAll(); + } +} + +// Run when executed directly (e.g. `bun src/rpc/stdio.ts`). +if (import.meta.main) { + runStdioServer() + .then(() => process.exit(0)) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); +} diff --git a/packages/document-host/tsconfig.json b/packages/document-host/tsconfig.json new file mode 100644 index 0000000000..8635ab2db4 --- /dev/null +++ b/packages/document-host/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/layout-engine/contracts/src/border-band.test.ts b/packages/layout-engine/contracts/src/border-band.test.ts new file mode 100644 index 0000000000..912d77a404 --- /dev/null +++ b/packages/layout-engine/contracts/src/border-band.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { getBorderBandProfile, getBorderBandWidthPx, isNativeCssDoubleStyle } from './border-band.js'; + +/** + * Band compositions below are MEASURED from Word renders (300dpi PDF pixel-run + * profiling of single-cell probe tables, styles x sz {4,12,24}), recorded in the + * SD-3308 compound-borders plan. At CSS scale: w = authored width px, + * 0.75pt = 1px, 1.5pt = 2px. Segments alternate rule,gap,...,rule outer face first. + */ +describe('getBorderBandProfile', () => { + it('returns null for non-compound styles', () => { + expect(getBorderBandProfile({ style: 'single', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'thick', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'dotted', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'dashSmallGap', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'none', width: 2 })).toBeNull(); + expect(getBorderBandProfile(undefined)).toBeNull(); + expect(getBorderBandProfile(null)).toBeNull(); + expect(getBorderBandProfile({ none: true })).toBeNull(); + }); + + it('double: rule + gap + rule, all at the authored width', () => { + expect(getBorderBandProfile({ style: 'double', width: 2 })).toEqual({ + segments: [2, 2, 2], + band: 6, + }); + }); + + it('triple: three rules and two gaps, all at the authored width (Word sz12 = r6+g6+r6+g6+r6 @300dpi)', () => { + expect(getBorderBandProfile({ style: 'triple', width: 2 })).toEqual({ + segments: [2, 2, 2, 2, 2], + band: 10, + }); + }); + + it('thinThickSmallGap: scaled outer rule, fixed 0.75pt gap and inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickSmallGap', width: 4 })).toEqual({ + segments: [4, 1, 1], + band: 6, + }); + }); + + it('thickThinSmallGap mirrors thinThickSmallGap', () => { + expect(getBorderBandProfile({ style: 'thickThinSmallGap', width: 4 })).toEqual({ + segments: [1, 1, 4], + band: 6, + }); + }); + + it('thinThickMediumGap: scaled outer rule, half-width gap and inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickMediumGap', width: 4 })).toEqual({ + segments: [4, 2, 2], + band: 8, + }); + }); + + it('thickThinMediumGap mirrors thinThickMediumGap', () => { + expect(getBorderBandProfile({ style: 'thickThinMediumGap', width: 4 })).toEqual({ + segments: [2, 2, 4], + band: 8, + }); + }); + + it('thinThickLargeGap: fixed 1.5pt outer rule, scaled gap, fixed 0.75pt inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickLargeGap', width: 4 })).toEqual({ + segments: [2, 4, 1], + band: 7, + }); + }); + + it('thickThinLargeGap mirrors thinThickLargeGap', () => { + expect(getBorderBandProfile({ style: 'thickThinLargeGap', width: 4 })).toEqual({ + segments: [1, 4, 2], + band: 7, + }); + }); + + it('thinThickThinSmallGap: fixed thin rules and gaps around a scaled center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinSmallGap', width: 4 })).toEqual({ + segments: [1, 1, 4, 1, 1], + band: 8, + }); + }); + + it('thinThickThinMediumGap: half-width thin rules and gaps around a scaled center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinMediumGap', width: 4 })).toEqual({ + segments: [2, 2, 4, 2, 2], + band: 12, + }); + }); + + it('thinThickThinLargeGap: fixed thin rules, scaled gaps, fixed 1.5pt center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinLargeGap', width: 4 })).toEqual({ + segments: [1, 4, 2, 4, 1], + band: 12, + }); + }); + + it('clamps every rule and gap to at least 1px', () => { + // w/2 = 0.5 would vanish; Word still paints a visible hairline (measured r1 at sz4). + expect(getBorderBandProfile({ style: 'thinThickThinMediumGap', width: 1 })).toEqual({ + segments: [1, 1, 1, 1, 1], + band: 5, + }); + expect(getBorderBandProfile({ style: 'double', width: 0.5 })).toEqual({ + segments: [1, 1, 1], + band: 3, + }); + }); + + it('accepts the size alias used by raw table border values', () => { + const raw = { style: 'triple', size: 2 } as unknown as Parameters[0]; + expect(getBorderBandProfile(raw)?.band).toBe(10); + }); +}); + +describe('getBorderBandWidthPx with compound profiles', () => { + it('keeps the existing double behavior (band = 3x width, min 3)', () => { + expect(getBorderBandWidthPx({ style: 'double', width: 2 })).toBe(6); + expect(getBorderBandWidthPx({ style: 'double', width: 0.5 })).toBe(3); + }); + + it('keeps non-compound behavior unchanged', () => { + expect(getBorderBandWidthPx({ style: 'single', width: 2 })).toBe(2); + // SD-3028: `thick` paints at the authored width (no 2x); Word renders ST_Border + // thick at the w:sz width (150dpi st-thick probe). + expect(getBorderBandWidthPx({ style: 'thick', width: 2 })).toBe(2); + expect(getBorderBandWidthPx({ style: 'thick', width: 0.5 })).toBe(1); // 1px visibility floor + expect(getBorderBandWidthPx({ style: 'none', width: 2 })).toBe(0); + expect(getBorderBandWidthPx(null)).toBe(0); + }); + + it('returns the profile band total for compound styles', () => { + expect(getBorderBandWidthPx({ style: 'triple', width: 2 })).toBe(10); + expect(getBorderBandWidthPx({ style: 'thinThickSmallGap', width: 4 })).toBe(6); + expect(getBorderBandWidthPx({ style: 'thinThickThinLargeGap', width: 4 })).toBe(12); + }); +}); + +describe('isNativeCssDoubleStyle (SD-3028)', () => { + it('is true only for double (the one band style CSS renders natively as two equal rules)', () => { + expect(isNativeCssDoubleStyle('double')).toBe(true); + }); + + it('is false for the multi-rule overlay styles and non-band styles', () => { + for (const s of ['triple', 'thinThickSmallGap', 'thinThickThinLargeGap', 'single', 'dashed', 'none', undefined]) { + expect(isNativeCssDoubleStyle(s)).toBe(false); + } + }); +}); diff --git a/packages/layout-engine/contracts/src/border-band.ts b/packages/layout-engine/contracts/src/border-band.ts new file mode 100644 index 0000000000..92b2c5e22f --- /dev/null +++ b/packages/layout-engine/contracts/src/border-band.ts @@ -0,0 +1,111 @@ +import type { TableBorderValue } from './index.js'; + +/** + * Composition of a compound (multi-rule) border band. + * + * `segments` alternate rule, gap, rule, ... starting at the band's OUTER face + * (table boundary / neighbor-facing side) and ending at the inner face (cell + * content side). 3 segments = 2 rules, 5 segments = 3 rules. `band` is the sum. + */ +export type BorderBandProfile = { + segments: number[]; + band: number; +}; + +// Fixed rule/gap widths at CSS 96dpi: 0.75pt and 1.5pt. +const PT_075 = 1; +const PT_150 = 2; + +/** + * Per-style band composition as a function of the authored width `w` (px). + * Every formula is MEASURED from Word renders (300dpi probe tables at + * sz {4,12,24}); see the SD-3308 compound-borders plan for the raw data. + * "thinThick" carries the sz-scaled rule on the OUTER face, "thickThin" on the + * inner face; thinThickThin* scales the center rule except LargeGap, where the + * gaps scale and the center is fixed at 1.5pt. + */ +const COMPOUND_PROFILES: Record number[]> = { + double: (w) => [w, w, w], + triple: (w) => [w, w, w, w, w], + thinThickSmallGap: (w) => [w, PT_075, PT_075], + thickThinSmallGap: (w) => [PT_075, PT_075, w], + thinThickMediumGap: (w) => [w, w / 2, w / 2], + thickThinMediumGap: (w) => [w / 2, w / 2, w], + thinThickLargeGap: (w) => [PT_150, w, PT_075], + thickThinLargeGap: (w) => [PT_075, w, PT_150], + thinThickThinSmallGap: (w) => [PT_075, PT_075, w, PT_075, PT_075], + thinThickThinMediumGap: (w) => [w / 2, w / 2, w, w / 2, w / 2], + thinThickThinLargeGap: (w) => [PT_075, w, PT_150, w, PT_075], +}; + +/** + * Band composition for a compound border style, or null for single-rule styles + * (callers keep their existing single-rule path). Rules and gaps are clamped to + * >= 1px so hairline components stay visible, matching Word's measured minimums. + */ +export function getBorderBandProfile(value: TableBorderValue | null | undefined): BorderBandProfile | null { + if (value == null || typeof value !== 'object') return null; + if ('none' in value && value.none) return null; + const raw = value as { style?: string; width?: number; size?: number }; + if (!raw.style) return null; + const formula = COMPOUND_PROFILES[raw.style]; + if (!formula) return null; + const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; + if (w <= 0) return null; + const segments = formula(w).map((s) => Math.max(1, s)); + return { segments, band: segments.reduce((sum, s) => sum + s, 0) }; +} + +/** + * Rendered border band width in pixels for a table or cell border value. + * + * This is the SINGLE source of truth for how wide a border paints, shared by the + * DOM painter (CSS border width) and the measuring engine (row-height reservation) + * so geometry and paint never disagree. + * + * Width semantics per ECMA-376 / Word rendering: + * - `none`/nil (or explicit `{none:true}`) paint nothing: band 0. + * - `thick` paints a single rule at the authored width (NOT doubled). Word renders + * ST_Border `thick` at the w:sz width, same weight as `single` for a given sz + * (150dpi Word probe of st-thick sz=12 = 3px@150 ≈ 1.5pt = authored). A 1px floor + * keeps a hairline visible. (SD-3028: prior 2x multiplier painted ~2x Word.) + * - Compound styles (double, triple, thinThick*) paint a multi-rule band whose + * total width is the sum of the measured profile segments; see + * `getBorderBandProfile`. For `double` this preserves the original semantics: + * w:sz is the width of EACH rule, band = 3x the authored width, floored at 3px + * so both rules always render. (SD-3308) + * - Every other style paints at the authored width. + * + * @param value - Border value from table attrs (`TableBorderValue`) or a cell-side + * `BorderSpec` (the `{none:true}` marker form is also accepted). + * @returns Band width in pixels (always >= 0). + */ +export function getBorderBandWidthPx(value: TableBorderValue | null | undefined): number { + if (value == null) return 0; + if (typeof value !== 'object') return 0; + if ('none' in value && value.none) return 0; + const raw = value as { style?: string; width?: number; size?: number }; + if (raw.style === 'none') return 0; + const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; + const width = Math.max(0, w); + if (width === 0) return 0; + if (raw.style === 'thick') return Math.max(width, 1); + const profile = getBorderBandProfile(value); + if (profile) return profile.band; + return width; +} + +/** + * True when a band border renders correctly via the native CSS `border-style: double` + * (two equal rules + gap) and must NOT be routed through the multi-rule nested-rectangle + * overlay. `double` is the only ECMA-376 multi-rule style CSS expresses exactly (triple = 3 + * rules, thinThick* = unequal rules — CSS cannot, so those keep the overlay). Routing `double` + * through the overlay forces its native CSS border transparent and repaints a single inner + * rule, collapsing the double to one line. (SD-3028) + * + * @param style - A border style name (or undefined). + * @returns true only for the `double` style. + */ +export function isNativeCssDoubleStyle(style: string | undefined): boolean { + return style === 'double'; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d5ece58c9d..ae60be84c2 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -53,6 +53,10 @@ export { rescaleColumnWidths } from './table-column-rescale.js'; // Cell spacing resolution (moved from measuring-dom for cross-stage use) export { getCellSpacingPx } from './cell-spacing.js'; +// Border band width (single source of truth for painter CSS width + measuring row reservation) +export { getBorderBandWidthPx, getBorderBandProfile, isNativeCssDoubleStyle } from './border-band.js'; +export type { BorderBandProfile } from './border-band.js'; + // OOXML z-index normalization (moved from pm-adapter for cross-stage use) export { normalizeZIndex, @@ -801,13 +805,28 @@ export type BorderStyle = | 'single' | 'double' | 'dashed' + | 'dashSmallGap' | 'dotted' | 'thick' | 'triple' | 'dotDash' | 'dotDotDash' + | 'thinThickSmallGap' + | 'thickThinSmallGap' + | 'thinThickThinSmallGap' + | 'thinThickMediumGap' + | 'thickThinMediumGap' + | 'thinThickThinMediumGap' + | 'thinThickLargeGap' + | 'thickThinLargeGap' + | 'thinThickThinLargeGap' | 'wave' - | 'doubleWave'; + | 'doubleWave' + | 'dashDotStroked' + | 'threeDEmboss' + | 'threeDEngrave' + | 'outset' + | 'inset'; /** Border specification for table and cell borders. */ export type BorderSpec = { diff --git a/packages/layout-engine/dom-contract/src/class-names.ts b/packages/layout-engine/dom-contract/src/class-names.ts index fee653a5c8..56859884d4 100644 --- a/packages/layout-engine/dom-contract/src/class-names.ts +++ b/packages/layout-engine/dom-contract/src/class-names.ts @@ -48,6 +48,20 @@ export const DOM_CLASS_NAMES = { */ SDT_GROUP_HOVER: 'sdt-group-hover', + /** + * Selected ancestor/wrapper highlight applied to fragments that participate + * in a selected block SDT container but are not necessarily the selected SDT + * identity themselves. + */ + SDT_CONTAINER_SELECTED: 'sdt-container-selected', + + /** + * Applied to an ancestor block SDT when a nested child SDT is the exact + * selection. This keeps ancestor badges visible without marking the ancestor + * as the active ProseMirror node. + */ + SDT_ANCESTOR_SELECTED: 'sdt-ancestor-selected', + /** Paragraph fragment rendered as a Table of Contents entry. */ TOC_ENTRY: 'superdoc-toc-entry', diff --git a/packages/layout-engine/dom-contract/src/index.test.ts b/packages/layout-engine/dom-contract/src/index.test.ts index 288ff2f93f..0295fd678d 100644 --- a/packages/layout-engine/dom-contract/src/index.test.ts +++ b/packages/layout-engine/dom-contract/src/index.test.ts @@ -31,6 +31,10 @@ describe('@superdoc/dom-contract', () => { TABLE_FRAGMENT: 'superdoc-table-fragment', DOCUMENT_SECTION: 'superdoc-document-section', SDT_GROUP_HOVER: 'sdt-group-hover', + SDT_CONTAINER_SELECTED: 'sdt-container-selected', + SDT_ANCESTOR_SELECTED: 'sdt-ancestor-selected', + TOC_ENTRY: 'superdoc-toc-entry', + TOC_GROUP_HOVER: 'toc-group-hover', IMAGE_FRAGMENT: 'superdoc-image-fragment', INLINE_IMAGE: 'superdoc-inline-image', LIST_MARKER: 'superdoc-list-marker', diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index cd4bcdd1c6..299477ffa7 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -18,6 +18,12 @@ import { DEFAULT_TAB_INTERVAL_PX as _DEFAULT_TAB_INTERVAL_PX, } from '@superdoc/common/layout-constants'; import { resolveListTextStartPx } from '@superdoc/common/list-marker-utils'; +import { + isAtomicLayoutRun, + getAtomicRunLayoutSize, + type MeasureAtomicText, + type MinimalAtomicRun, +} from '@superdoc/common/atomic-run-size'; /** * Type definition for paragraph block attributes that include indentation and tab stops. @@ -349,6 +355,49 @@ const getRunWidth = (run: Run): number => { return typeof width === 'number' ? width : 0; }; +/** + * Measures field annotation label text on the shared canvas. Mirrors the run's + * font properties so the pill width tracks the painted glyphs. Falls back to a + * proportional estimate when no canvas context is available (SSR), matching how + * {@link measureRunSliceWidth} approximates ordinary text. + */ +const measureAtomicText: MeasureAtomicText = (text, run, fontSize) => { + const family = (run.fontFamily as string | undefined) || 'Arial'; + const context = getCtx(); + if (!context) { + return Math.max(0, text.length * (fontSize * 0.6)); + } + const italic = run.italic ? 'italic ' : ''; + const bold = run.bold ? 'bold ' : ''; + context.font = `${italic}${bold}${fontSize}px ${family}`.trim(); + return context.measureText(text).width; +}; + +const getAtomicRunLayoutWidth = (run: Run): number => + getAtomicRunLayoutSize(run as MinimalAtomicRun, measureAtomicText).width; + +const getAtomicRunLayoutHeight = (run: Run): number => + getAtomicRunLayoutSize(run as MinimalAtomicRun, measureAtomicText).height; + +/** Max atomic (image/math/field) height for runs actually included on [fromRun, toRun]. */ +const getLineMaxAtomicHeight = ( + runs: Run[], + fromRun: number, + fromChar: number, + toRun: number, + toChar: number, +): number => { + let max = 0; + for (let r = fromRun; r <= toRun; r += 1) { + const run = runs[r]; + if (!isAtomicLayoutRun(run)) continue; + if (r === toRun && toChar === 0) continue; + if (r === fromRun && r === toRun && toChar <= fromChar) continue; + max = Math.max(max, getAtomicRunLayoutHeight(run)); + } + return max; +}; + /** * Checks if a break run is a line break (as opposed to page/column break). * @@ -672,7 +721,10 @@ const scanTabAlignmentGroup = ( const text = runText(run); if (!text) { - const runWidth = getRunWidth(run); + // Atomic runs (image/math/field annotation) carry their box in the shared sizer, + // not in `run.width` — field annotations in particular are 0 via getRunWidth, which + // would zero out the group and skip center/right/decimal alignment. + const runWidth = isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run); if (runWidth > 0) { totalWidth += runWidth; endRun = r; @@ -742,7 +794,7 @@ const measureTabAlignmentGroupInLine = ( const text = runText(run); if (!text) { - totalWidth += getRunWidth(run); + totalWidth += isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run); continue; } @@ -1001,7 +1053,27 @@ const applyTabLayoutToLines = ( const text = runText(run); if (!text) { - cursorX += getRunWidth(run); + const atomicWidth = isAtomicLayoutRun(run) ? getAtomicRunLayoutWidth(run) : getRunWidth(run); + // Position atomic runs (image/math/field annotation) that follow an + // end/center/decimal tab so the pill aligns to the stop, matching the DOM + // measurer. Without this the pending alignment would leak to a later text run. + const pendingTabAlign = consumePendingTabAlignStart(); + if (pendingTabAlign != null) { + const segment: LineSegment = { + runIndex, + fromChar: 0, + toChar: 1, + width: atomicWidth, + x: pendingTabAlign.paintX, + ...(pendingTabAlign.precedingTabEndX !== undefined + ? { precedingTabEndX: pendingTabAlign.precedingTabEndX } + : {}), + }; + cursorX = pendingTabAlign.layoutX + atomicWidth; + segments.push(segment); + } else { + cursorX += atomicWidth; + } lineWidth = Math.max(lineWidth, cursorX); continue; } @@ -1395,6 +1467,17 @@ export function remeasureParagraph( endChar = text.length > 0 ? text.length : start + 1; continue; } + if (text.length === 0 && isAtomicLayoutRun(run)) { + const atomicWidth = getAtomicRunLayoutWidth(run); + if (width > 0 && width + atomicWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) { + didBreakInThisLine = true; + break; + } + width += atomicWidth; + endRun = r; + endChar = 1; + continue; + } for (let c = start; c < text.length; c += 1) { const ch = text[c]; if (ch === '\t') { @@ -1488,6 +1571,8 @@ export function remeasureParagraph( endChar = startChar + 1; } + const lineMaxAtomicHeight = getLineMaxAtomicHeight(runs, startRun, startChar, endRun, endChar); + const line: Line = { fromRun: startRun, fromChar: startChar, @@ -1496,8 +1581,9 @@ export function remeasureParagraph( width, ascent: 0, descent: 0, - lineHeight: lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), + lineHeight: Math.max(lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize), lineMaxAtomicHeight), maxWidth: effectiveMaxWidth, + ...(lineMaxAtomicHeight > 0 ? { maxImageHeight: lineMaxAtomicHeight } : {}), }; lines.push(line); if (lineMaxTextFontSize > 0) { diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 6f6db14a38..211adfaa6e 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -1638,4 +1638,125 @@ describe('remeasureParagraph', () => { expect(measure.lines[0].width).toBeGreaterThan(0); }); }); + + describe('atomic runs', () => { + it('does not retain image line height after rewinding past an inline image break point', () => { + const block = createBlock([ + textRun('A '), + { + kind: 'image', + src: 'data:image/png;base64,abc', + width: 179, + height: 179, + pmStart: 3, + pmEnd: 4, + }, + textRun('B'.repeat(30)), + ]); + + // Fits "A " + image, then overflows on following text and rewinds to the space. + const measure = remeasureParagraph(block, 200); + + expect(measure.lines.length).toBeGreaterThan(1); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[0].toChar).toBe(2); + expect(measure.lines[0].maxImageHeight).toBeUndefined(); + expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2); + expect(measure.lines[1].maxImageHeight).toBe(179); + }); + + it('measures image-only paragraphs with correct width and line height', () => { + const block = createBlock([ + { + kind: 'image', + src: 'data:image/png;base64,abc', + width: 179, + height: 179, + pmStart: 1, + pmEnd: 2, + }, + ]); + block.attrs = { alignment: 'center' }; + + const measure = remeasureParagraph(block, 179.8); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(179); + expect(measure.lines[0].lineHeight).toBe(179); + expect(measure.lines[0].maxImageHeight).toBe(179); + expect(measure.totalHeight).toBe(179); + }); + + it('sizes field annotation pills from displayLabel + padding (not run.width/height)', () => { + // Regression: field annotation runs carry no top-level width/height, so the old + // atomic sizing measured them 0x0 and the pill collapsed. The shared sizer now + // measures the label (4 chars * 10px) + pill padding (8px) = 48px wide. + const block = createBlock([ + { + kind: 'fieldAnnotation', + variant: 'text', + displayLabel: 'Name', + fontSize: 16, + pmStart: 1, + pmEnd: 2, + } as unknown as Run, + ]); + + const measure = remeasureParagraph(block, 200); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(48); + // Pill height = fontSize * 1.2 + vertical padding (6) = 25.2, taller than the + // 16px-text line height, so it drives both maxImageHeight and lineHeight. + expect(measure.lines[0].maxImageHeight).toBeCloseTo(16 * 1.2 + 6); + expect(measure.lines[0].lineHeight).toBeCloseTo(16 * 1.2 + 6); + }); + + it('sizes math runs from precomputed dimensions without adding dist* margins', () => { + const block = createBlock([ + { + kind: 'math', + ommlJson: {}, + textContent: '', + width: 30, + height: 24, + // dist* is not part of the math box; it must be ignored. + distLeft: 9, + distTop: 9, + pmStart: 1, + pmEnd: 2, + } as unknown as Run, + ]); + + const measure = remeasureParagraph(block, 200); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(30); + expect(measure.lines[0].maxImageHeight).toBe(24); + }); + + it('right-aligns a field annotation following an end tab (atomic group sizing)', () => { + // Regression: the tab look-ahead measured the field with getRunWidth (0), so the + // group width was zero and the aligned branch was skipped, leaving the pill at the + // tab stop instead of ending on it. The pill is 'AB' (2*10) + 8 padding = 28px, so + // a right (end) stop at 100px must start it at 72px. + const tabStop: TabStop = { pos: pxToTwips(100), val: 'end' }; + const field = { + kind: 'fieldAnnotation', + variant: 'text', + displayLabel: 'AB', + fontSize: 16, + pmStart: 3, + pmEnd: 4, + } as unknown as Run; + const block = createBlock([textRun('X'), tabRun(), field], { tabs: [tabStop] }); + + const measure = remeasureParagraph(block, 200); + + expect(measure.lines).toHaveLength(1); + const fieldSegment = measure.lines[0].segments?.find((segment) => segment.runIndex === 2); + expect(fieldSegment?.width).toBe(28); + expect(fieldSegment?.x).toBeCloseTo(72, 1); + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index e86f7d784f..701ce2ae8e 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -842,6 +842,70 @@ describe('layoutDrawingBlock', () => { expect(fragment.x).toBe(400); }); + it('should center inline textboxShape drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + // alignBox = 600, extra = 600 - 200 = 400, x = 0 + 200 = 200 + expect(fragment.x).toBe(200); + }); + + it('should right-align inline textboxShape drawings using paragraph alignment metadata', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Inline' }, + inlineParagraphAlignment: 'right', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(400); + }); + + it('should not apply paragraph alignment metadata when textboxShape is not inline', () => { + const context = createMockContext( + { + drawingKind: 'textboxShape', + attrs: { + pmStart: 10, + pmEnd: 11, + wrap: { type: 'Square' }, + inlineParagraphAlignment: 'center', + }, + }, + { width: 200, height: 150 }, + ); + const state = context.ensurePage(); + + layoutDrawingBlock(context); + + const fragment = state.page.fragments[0] as DrawingFragment; + expect(fragment.x).toBe(0); + }); + it('should not apply paragraph alignment metadata when shapeGroup is not inline', () => { const context = createMockContext( { diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 67ffa21cb2..52543db152 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -89,7 +89,10 @@ export function layoutDrawingBlock({ const maxWidthForBlock = attrs?.isFullWidth === true && maxWidth > 0 ? Math.max(1, maxWidth - indentLeft - indentRight) : maxWidth; const rawWrap = attrs?.wrap as { type?: unknown } | undefined; - const isInlineShapeGroup = block.drawingKind === 'shapeGroup' && rawWrap?.type === 'Inline'; + // Inline shape groups and textboxes render at their authored width, so a centered or + // right-aligned host paragraph must offset the whole box within the column (SD: IT-1140). + const isInlineAlignableDrawing = + (block.drawingKind === 'shapeGroup' || block.drawingKind === 'textboxShape') && rawWrap?.type === 'Inline'; const inlineParagraphAlignment = attrs?.inlineParagraphAlignment === 'center' || attrs?.inlineParagraphAlignment === 'right' ? attrs.inlineParagraphAlignment @@ -117,7 +120,7 @@ export function layoutDrawingBlock({ const pmRange = extractBlockPmRange(block); let x = columnX(state) + marginLeft + indentLeft; - if (isInlineShapeGroup && inlineParagraphAlignment) { + if (isInlineAlignableDrawing && inlineParagraphAlignment) { const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0; const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0; const alignBox = Math.max(0, maxWidthForBlock - pIndentLeft - pIndentRight); diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 1164cb90a7..30abd45747 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -1735,6 +1735,79 @@ describe('layoutTableBlock', () => { } }); + it('extends the repeated header to cover a header-row vMerge spanning a non-header row (SD-1962)', () => { + // Row 0 is the ONLY w:tblHeader row, but its first cell is a vertical merge + // (w:vMerge="restart", rowSpan=2) that spans into row 1, which is NOT flagged + // as a header. Word repeats the entire merged header band as a unit, so the + // continuation fragment must repeat BOTH rows. Repeating only row 0 leaves the + // painter drawing the merged cell at its full rowSpan height inside a one-row + // header, overflowing onto the body/footnotes below (the SD-1962 symptom). + const rows = Array.from({ length: 10 }, (_, i) => ({ + id: `row-${i}` as BlockId, + cells: [ + { + id: `cell-${i}-0` as BlockId, + // Row 0 owns a 2-row vertical merge; row 1 is its continuation. + ...(i === 0 ? { rowSpan: 2 } : {}), + paragraph: { kind: 'paragraph' as const, id: `para-${i}-0` as BlockId, runs: [] }, + }, + { + id: `cell-${i}-1` as BlockId, + paragraph: { kind: 'paragraph' as const, id: `para-${i}-1` as BlockId, runs: [] }, + }, + ], + attrs: { tableRowProperties: { repeatHeader: i === 0 } }, // only row 0 flagged + })); + const block: TableBlock = { kind: 'table', id: 'test-table' as BlockId, rows }; + const measure = createMockTableMeasure([100, 100], Array(10).fill(20)); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => ({ page: mockPage, columnIndex: 0, cursorY, contentBottom: 120 }), + advanceColumn: () => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 120 }; + }, + columnX: () => 0, + }); + + expect(fragments.length).toBeGreaterThan(1); + // Continuation must repeat the full merged band (rows 0 and 1), not just row 0. + expect(fragments[1].repeatHeaderCount).toBe(2); + }); + + it('does not extend the repeated header when header-row cells do not vertically merge', () => { + // Regression guard: a single flagged header row with no rowSpan cells must + // still repeat exactly one row (the fix only triggers on vMerge spans). + const block = createMockTableBlock(10, [{ repeatHeader: true }, ...Array(9).fill({ repeatHeader: false })]); + const measure = createMockTableMeasure([100], Array(10).fill(20)); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ page: mockPage, columnIndex: 0, cursorY, contentBottom: 120 }), + advanceColumn: () => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 120 }; + }, + columnX: () => 0, + }); + + expect(fragments.length).toBeGreaterThan(1); + expect(fragments[1].repeatHeaderCount).toBe(1); + }); + it('repeats only the completed header prefix when a later header row continues on a new page', () => { const block = createMockTableBlock(3, [{ repeatHeader: true }, { repeatHeader: true }, { repeatHeader: false }]); const measure = createMockTableMeasure([100, 100], [10, 20, 10], [[10], [10, 10, 10], [10]], 2); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index fd84ba03ff..9625e8e0d5 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -402,6 +402,42 @@ function countHeaderRows(block: TableBlock): number { return count; } +/** + * Count the leading rows that form the repeatable header BAND on continuation + * pages. + * + * Starts from the contiguous `w:tblHeader` rows ({@link countHeaderRows}) and + * extends downward to include any row that a header-row vertically-merged cell + * (`w:vMerge`, `rowSpan > 1`) spans into. Word repeats the whole merged header + * band as a unit even when the spanned rows are not themselves flagged + * `w:tblHeader`, so the merged cell is never cut at the repeat boundary + * (verified against Word's render of the sd-1962 "Inpatient Stay in CRU" + * schedule: only row 0 carries `w:tblHeader`, but its `rowSpan = 2` + * label / "Screening" / "EOS" cells pull row 1 into the repeated header). + * + * Without this extension only row 0 repeats while the painter still draws the + * merged cell at its full `rowSpan` height, so it overflows the fragment and + * paints over the body rows / footnotes below (SD-1962). + * + * @param block - Table block containing rows, cells, and attributes + * @returns Number of leading rows that repeat together as the header band + */ +function countRepeatableHeaderRows(block: TableBlock): number { + const headerCount = countHeaderRows(block); + if (headerCount === 0) return 0; + + // A vMerge that starts inside the flagged header rows cannot be split at the + // repeat boundary; extend the band to the farthest row it reaches. + let bandEnd = headerCount; // exclusive + for (let r = 0; r < headerCount && r < block.rows.length; r++) { + for (const cell of block.rows[r]?.cells ?? []) { + const rowSpan = cell.rowSpan ?? 1; + if (rowSpan > 1) bandEnd = Math.max(bandEnd, r + rowSpan); + } + } + return Math.min(bandEnd, block.rows.length); +} + /** * Sum row heights for a given range. * @@ -1333,8 +1369,9 @@ export function layoutTableBlock({ return; } - // 2. Count header rows - const headerCount = countHeaderRows(block); + // 2. Count header rows (extended to cover vMerge cells that span past the + // flagged header rows, so the merged band repeats intact — see SD-1962). + const headerCount = countRepeatableHeaderRows(block); const headerPrefixHeights = [0]; for (let i = 0; i < headerCount; i += 1) { headerPrefixHeights.push(headerPrefixHeights[i] + (measure.rows[i]?.height ?? 0)); diff --git a/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts b/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts index ef5282795c..8593042d08 100644 --- a/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts +++ b/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts @@ -154,5 +154,20 @@ describe('resolveTableFrame', () => { expect(result.width).toBe(750); expect(result.x).toBe(-125); }); + + // SD-1513 overhang guard: a full-window (100% pct) table shifted left by a + // negative tblInd keeps its computed width, so it overhangs the LEFT margin + // only and ends short of the right margin (verified against Word; the old + // benchmark prediction of a right overhang was wrong). + it('shifts a full-window table left with negative indent, ending short of the right margin', () => { + const result = resolveTableFrame(0, 500, 480, { + tableWidth: { value: 5000, type: 'pct' }, + tableIndent: { width: -24 }, + } as TableAttrs); + expect(result.x).toBe(-24); + expect(result.width).toBe(524); + // right edge = x + width = 500, the column edge; the painted grid itself + // spans 500px starting at -24, so it ends 24px short of the right margin. + }); }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 3a60175274..ebd57b08c1 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -3243,6 +3243,49 @@ describe('resolveLayout', () => { expect(item.sdtContainerKey).toBe('documentSection:sec-2'); }); + it('uses containerSdt as the visual boundary key for nested block SDTs', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'p-parent-marker', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }, + { kind: 'para', blockId: 'p-child', fromLine: 0, toLine: 1, x: 72, y: 120, width: 468 }, + ], + }, + ], + }; + const parentSdt = { type: 'structuredContent' as const, scope: 'block' as const, id: 'parent-sdt' }; + const childSdt = { type: 'structuredContent' as const, scope: 'block' as const, id: 'child-sdt' }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p-parent-marker', + runs: [], + attrs: { sdt: parentSdt }, + }, + { + kind: 'paragraph', + id: 'p-child', + runs: [], + attrs: { sdt: childSdt, containerSdt: parentSdt }, + }, + ]; + const measures: Measure[] = [ + { kind: 'paragraph', lines: [], totalHeight: 0 }, + { kind: 'paragraph', lines: [], totalHeight: 0 }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const parentItem = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + const childItem = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(parentItem.sdtContainerKey).toBe('structuredContent:parent-sdt'); + expect(childItem.sdtContainerKey).toBe('structuredContent:parent-sdt'); + expect(childItem.block?.attrs?.sdt).toBe(childSdt); + expect(childItem.block?.attrs?.containerSdt).toBe(parentSdt); + }); + it('returns null (omits sdtContainerKey) for inline structuredContent scope', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 9167d69422..5bf84a86e4 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -25,6 +25,7 @@ import type { LineSegment, PageRefLocation, Run, + SdtMetadata, TableBlock, TextRun, } from '@superdoc/contracts'; @@ -447,23 +448,27 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map listItem.id === fragment.itemId); - return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + return resolveSdtBoundaryKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); } if (fragment.kind === 'table' && block.kind === 'table') { - return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + return resolveSdtBoundaryKey(block.attrs?.sdt, block.attrs?.containerSdt); } // image, drawing — no SDT container keys return null; } +function resolveSdtBoundaryKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + return getSdtContainerKey(containerSdt) ?? getSdtContainerKey(sdt); +} + function computeBlockVersion( blockId: string, blockMap: Map, diff --git a/packages/layout-engine/measuring/dom/src/autofit-columns.test.ts b/packages/layout-engine/measuring/dom/src/autofit-columns.test.ts index d87aa7a389..76b78e753a 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-columns.test.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-columns.test.ts @@ -46,6 +46,39 @@ describe('computeAutoFitColumnWidths', () => { expect(result.totalWidth).toBe(300); }); + // SD-3309: when preserveExplicitAutoGrid fires for a pct-width table whose authored grid sum + // (640) exceeds the resolved table width (624), the solver must scale the grid PROPORTIONS to + // the table width, not let short content collapse the columns. 30/70 of 624 = ~187/437. + it('scales an explicit pct grid to the resolved table width while keeping its proportions', () => { + const result = computeAutoFitColumnWidths( + buildExplicitInput({ + workingInput: buildWorkingInput({ + preserveExplicitAutoGrid: true, + preferredTableWidth: 624, + maxTableWidth: 624, + preferredColumnWidths: [192, 448], + }), + fixedLayout: { + columnWidths: [192, 448], + totalWidth: 640, + gridColumnCount: 2, + preferredTableWidth: 624, + }, + contentMetrics: buildContentMetrics([ + [ + { min: 40, max: 50 }, + { min: 40, max: 50 }, + ], + ]), + }), + ); + + expect(result.totalWidth).toBeCloseTo(624, 0); + const col1Pct = (result.columnWidths[0] / result.totalWidth) * 100; + expect(col1Pct).toBeGreaterThan(28); + expect(col1Pct).toBeLessThan(32); + }); + it('does not keep authored grid widths as an autofit floor', () => { const result = computeAutoFitColumnWidths( buildExplicitInput({ diff --git a/packages/layout-engine/measuring/dom/src/autofit-columns.ts b/packages/layout-engine/measuring/dom/src/autofit-columns.ts index 682d22258e..da6c2ac5f8 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-columns.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-columns.ts @@ -45,6 +45,8 @@ export type AutoFitCellInput = { maxContentWidth?: number; /** Preferred width hint equivalent to `tcW`, in pixels. */ preferredWidth?: number; + /** Horizontal padding + cell-border insets baked into the content widths, in pixels. */ + horizontalInsets?: number; }; /** @@ -76,6 +78,8 @@ export type AutoFitContentMetricsCell = { minContentWidth: number; /** Maximum outer cell width, in pixels. */ maxContentWidth: number; + /** Horizontal padding + cell-border insets baked into the content widths, in pixels. */ + horizontalInsets?: number; }; /** @@ -166,6 +170,7 @@ type NormalizedCell = { preferredWidth?: number; minContentWidth: number; maxContentWidth: number; + horizontalInsets: number; }; type NormalizedRow = { @@ -214,6 +219,7 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { const currentWidths = fixedLayout.columnWidths.slice(0, gridColumnCount); const minBounds = new Array(gridColumnCount).fill(0); const maxBounds = new Array(gridColumnCount).fill(0); + const textBounds = new Array(gridColumnCount).fill(0); const preferredOverrides = new Array(gridColumnCount).fill(undefined); const multiSpanCells: NormalizedCell[] = []; @@ -221,6 +227,7 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { rows: normalizedRows, minBounds, maxBounds, + textBounds, preferredOverrides, multiSpanCells, }); @@ -254,7 +261,40 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { targetTableWidth = Math.min(targetTableWidth, maxResolvedTableWidth); } else { targetTableWidth = Math.min(targetTableWidth, maxResolvedTableWidth); - if (!shouldPreservePreferredGrid) { + if (workingInput.contentSizeAutoTable === true) { + // Pure-auto tables content-size like Word: each column takes its max-content + // width and the table ends at the content demand, capped by the available + // width (the shrink below redistributes overflow). The authored grid sum is + // deliberately ignored here; it is not a Word layout cache for this shape. + // (SD-3309) + const columnBandAllowances = workingInput.columnBandAllowances; + // Word band rule (SD-3308 probes): the column grows by half a band per edge + // (the allowance); the painted band then consumes the other half from the + // padding. Padding compresses but TEXT never clips, so the column is floored + // at text + the full bands (2x the allowance). + resolvedWidths = maxBounds.map((max, index) => { + const allowance = columnBandAllowances?.[index] ?? 0; + const withAllowance = Math.max(max, minBounds[index]) + allowance; + const textFloor = textBounds[index] + allowance * 2; + return Math.max(withAllowance, textFloor); + }); + // Spanning cells must keep their max-content demand: the proportional spread in + // applyMultiSpanMaximums can leave the covered columns collectively short (span + // padding is per cell, not per column), which wraps the span text where Word + // keeps one line. Top up the covered columns evenly. (SD-3309) + for (const spanCell of multiSpanCells) { + const covered = resolvedWidths.slice(spanCell.startColumn, spanCell.startColumn + spanCell.span); + const currentTotal = sumWidths(covered); + const demand = spanCell.preferredWidth ?? spanCell.maxContentWidth; + if (currentTotal < demand && covered.length > 0) { + const topUp = (demand - currentTotal) / covered.length; + for (let index = 0; index < covered.length; index++) { + resolvedWidths[spanCell.startColumn + index] += topUp; + } + } + } + targetTableWidth = Math.min(sumWidths(resolvedWidths), maxResolvedTableWidth); + } else if (!shouldPreservePreferredGrid) { resolvedWidths = redistributeTowardMaximumsWithinCurrentTable(resolvedWidths, minBounds, maxBounds); resolvedWidths = redistributeTowardContentWeightedShape(resolvedWidths, minBounds, maxBounds); } @@ -334,6 +374,7 @@ function resolveAutoFitContext(input: AutoFitInput): AutoFitContext { preferredWidth: cell.preferredWidth, minContentWidth: cell.minContentWidth, maxContentWidth: cell.maxContentWidth, + horizontalInsets: cell.horizontalInsets, })), })); @@ -382,6 +423,7 @@ function normalizeLegacyRows(rows: AutoFitRowInput[]): NormalizedRow[] { preferredWidth: sanitizeOptionalWidth(cell.preferredWidth), minContentWidth: Math.max(0, cell.minContentWidth ?? 0), maxContentWidth: Math.max(0, cell.maxContentWidth ?? cell.minContentWidth ?? 0), + horizontalInsets: Math.max(0, cell.horizontalInsets ?? 0), }); columnIndex += span; } @@ -429,6 +471,7 @@ function buildNormalizedRows( preferredWidth: sanitizeOptionalWidth(metrics?.preferredWidth ?? placedCell.preferredWidth), minContentWidth: Math.max(0, metrics?.minContentWidth ?? 0), maxContentWidth: Math.max(0, metrics?.maxContentWidth ?? metrics?.minContentWidth ?? 0), + horizontalInsets: Math.max(0, metrics?.horizontalInsets ?? 0), }; }), skippedColumns: (workingRow.skippedColumns ?? []).map((skipped) => ({ @@ -449,10 +492,11 @@ function accumulateBounds(args: { rows: NormalizedRow[]; minBounds: number[]; maxBounds: number[]; + textBounds: number[]; preferredOverrides: Array; multiSpanCells: NormalizedCell[]; }): void { - const { rows, minBounds, maxBounds, preferredOverrides, multiSpanCells } = args; + const { rows, minBounds, maxBounds, textBounds, preferredOverrides, multiSpanCells } = args; for (const row of rows) { for (const skipped of row.skippedColumns) { @@ -467,6 +511,13 @@ function accumulateBounds(args: { if (cell.span === 1) { minBounds[cell.startColumn] = Math.max(minBounds[cell.startColumn], cell.minContentWidth); maxBounds[cell.startColumn] = Math.max(maxBounds[cell.startColumn], cell.maxContentWidth); + // Text-only demand (content width minus padding/cell-border insets), used by + // the content-size band floor: padding may compress under a fat border band + // but the text itself never loses space. (SD-3308) + textBounds[cell.startColumn] = Math.max( + textBounds[cell.startColumn], + Math.max(0, cell.maxContentWidth - cell.horizontalInsets), + ); if (preferredOverrides[cell.startColumn] == null && cell.preferredWidth != null) { preferredOverrides[cell.startColumn] = cell.preferredWidth; } diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts index 821baf9b52..1781332fe7 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { TableBlock } from '@superdoc/contracts'; import { buildAutoFitWorkingGridInput } from './autofit-normalize.js'; +import { computeFixedTableColumnWidths } from './fixed-table-columns.js'; /** * Build a minimal runtime table block for normalization tests. @@ -132,6 +133,52 @@ describe('buildAutoFitWorkingGridInput', () => { expect(result.gridColumnCount).toBe(3); }); + // SD-3309: a pct-width table re-resolves its absolute width against the CURRENT available + // width, so the authored grid sum (from authoring-time twips) legitimately differs from the + // re-resolved table width while the grid PROPORTIONS stay authoritative. Word honors those + // proportions (300dpi probes: a tblW=pct table with grid 30/70 + short content renders 30/70, + // not content-sized). The grid sum must NOT have to equal the table width for a pct table. + it('preserves an explicit non-uniform grid for a pct-width table even when the grid sum differs from the resolved width', () => { + const block = createTableBlock({ + attrs: { + // 100% of available; resolves to 624 at maxWidth 624, but the authored grid sums to 640. + tableWidth: { width: 5000, type: 'pct' }, + }, + columnWidths: [192, 448], // 30 / 70, authored twips->px, sum 640 != 624 + rows: [ + { + id: 'row-1', + cells: [{ id: 'cell-1' }, { id: 'cell-2' }], + }, + ], + }); + + const result = buildAutoFitWorkingGridInput(block, { maxWidth: 624 }); + + expect(result.layoutMode).toBe('autofit'); + expect(result.preferredTableWidth).toBe(624); + expect(result.preserveExplicitAutoGrid).toBe(true); + }); + + it('does not preserve a uniform pct-width grid with no concrete cell widths (lets content size it)', () => { + const block = createTableBlock({ + attrs: { + tableWidth: { width: 5000, type: 'pct' }, + }, + columnWidths: [312, 312], // uniform, no cell width hints + rows: [ + { + id: 'row-1', + cells: [{ id: 'cell-1' }, { id: 'cell-2' }], + }, + ], + }); + + const result = buildAutoFitWorkingGridInput(block, { maxWidth: 624 }); + + expect(result.preserveExplicitAutoGrid).toBeUndefined(); + }); + it('does not mark complete fixed grids far under tblW as authoritative', () => { const block = createTableBlock({ attrs: { @@ -738,3 +785,112 @@ describe('buildAutoFitWorkingGridInput', () => { expect(result.gridColumnCount).toBe(2); }); }); + +/** + * SD-1513 regression locks: overhanging fixed-layout tables with a merged, + * inset row (gridBefore/gridAfter + wBefore/wAfter + gridSpan). + * + * Contract, verified against Word renders and the live production pipeline: + * the authored grid is preserved verbatim (preserveAuthoredGrid), so the + * merged span resolves to exactly grid_sum - wBefore - wAfter. Word wraps the + * cell text at that width; any narrowing here reflows lines one word early. + * + * Block shapes below mirror the v1 layout-adapter output captured from the + * running pipeline: tableWidth pre-converted to px, cell widths as raw dxa + * measurements, row skips on attrs.tableRowProperties. + */ +describe('overhanging fixed tables with a merged inset row (SD-1513)', () => { + const TWIPS_PER_PX = 15; + + /** Table block mirroring the adapter output for a fixed table with one merged inset row. */ + function createOverhangBlock(args: { + gridDxa: number[]; + headerCells: Array<{ span?: number; widthDxa: number }>; + insetRow: { wBeforeDxa: number; wAfterDxa: number; span: number; widthDxa: number }; + }): TableBlock { + const gridSumDxa = args.gridDxa.reduce((sum, w) => sum + w, 0); + return createTableBlock({ + attrs: { + tableLayout: 'fixed', + tableWidth: { width: gridSumDxa / TWIPS_PER_PX, type: 'dxa' }, + }, + columnWidths: args.gridDxa.map((w) => w / TWIPS_PER_PX), + rows: [ + { + id: 'header-row', + cells: args.headerCells.map((cell, index) => ({ + id: `header-${index}`, + colSpan: cell.span ?? 1, + attrs: { tableCellProperties: { cellWidth: { value: cell.widthDxa, type: 'dxa' } } }, + })), + }, + { + id: 'inset-row', + attrs: { + tableRowProperties: { + gridBefore: 1, + gridAfter: 1, + wBefore: { value: args.insetRow.wBeforeDxa, type: 'dxa' }, + wAfter: { value: args.insetRow.wAfterDxa, type: 'dxa' }, + }, + }, + cells: [ + { + id: 'merged-cell', + colSpan: args.insetRow.span, + attrs: { tableCellProperties: { cellWidth: { value: args.insetRow.widthDxa, type: 'dxa' } } }, + }, + ], + }, + ], + } as Partial); + } + + /** Resolve the merged span's final width: the solved columns it covers. */ + function solveMergedSpanWidth(block: TableBlock, maxWidth: number, span: number): number { + const working = buildAutoFitWorkingGridInput(block, { maxWidth }); + // The overhang shape must take the authored-grid early return; the shrink + // path is what could narrow the merged span. + expect(working.preserveAuthoredGrid).toBe(true); + const solved = computeFixedTableColumnWidths(working); + return solved.columnWidths.slice(1, 1 + span).reduce((sum, w) => sum + w, 0); + } + + it('keeps the ticket repro merged span at grid_sum - wBefore - wAfter (9920 grid)', () => { + // overhang__first row overhangs margins: grid 9920 dxa > 9360 text column. + const block = createOverhangBlock({ + gridDxa: [280, 1900, 1900, 1900, 1900, 1840, 200], + headerCells: [ + { span: 2, widthDxa: 2180 }, + { widthDxa: 1900 }, + { widthDxa: 1900 }, + { widthDxa: 1900 }, + { span: 2, widthDxa: 2040 }, + ], + insetRow: { wBeforeDxa: 280, wAfterDxa: 200, span: 5, widthDxa: 9440 }, + }); + + const mergedWidth = solveMergedSpanWidth(block, 624, 5); + expect(mergedWidth).toBeCloseTo((9920 - 280 - 200) / TWIPS_PER_PX, 1); + }); + + it('keeps the canonical repro merged span at grid_sum - wBefore - wAfter (9360 grid)', () => { + // sd1513-paragraph-allignment: grid exactly the text column width. + const block = createOverhangBlock({ + gridDxa: [185, 51, 1451, 1503, 1503, 1503, 1503, 1503, 158], + headerCells: [ + { span: 2, widthDxa: 236 }, + { widthDxa: 1451 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { span: 2, widthDxa: 1661 }, + ], + insetRow: { wBeforeDxa: 185, wAfterDxa: 158, span: 7, widthDxa: 9017 }, + }); + + const mergedWidth = solveMergedSpanWidth(block, 624, 7); + expect(mergedWidth).toBeCloseTo((9360 - 185 - 158) / TWIPS_PER_PX, 1); + }); +}); diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts index 8b2bcb4c12..d49ca1ab52 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts @@ -1,5 +1,5 @@ -import type { TableBlock, TableRowProperties, TableWidthAttr } from '@superdoc/contracts'; -import { OOXML_PCT_DIVISOR, resolveTableWidthAttr } from '@superdoc/contracts'; +import type { TableBlock, TableBorders, TableRowProperties, TableWidthAttr } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR, getBorderBandWidthPx, resolveTableWidthAttr } from '@superdoc/contracts'; import type { AutoFitCellInput, AutoFitLayoutMode, @@ -75,6 +75,20 @@ export type WorkingTableGridInput = { * force growth. */ preserveExplicitAutoGrid?: boolean; + /** + * Pure-auto tables (autofit layout, auto/nil/absent tblW, and no cell anywhere + * carrying a concrete width preference) content-size like Word: the stored grid + * is not a Word layout cache for these, so the solver targets content demand + * instead of the authored grid sum. (SD-3309) + */ + contentSizeAutoTable?: boolean; + /** + * Per-column vertical border band allowances for content-sized pure-auto tables: + * each column owns its LEFT gridline band, the last column also the right edge + * (single-owner model). Border-box cells subtract these from the text box, so + * content-sized columns must reserve them or text wraps earlier than Word. (SD-3309) + */ + columnBandAllowances?: number[]; /** * AutoFit tables with auto-width semantics and a complete authored grid that * fits the available width should use the grid sum as their outer width @@ -178,22 +192,39 @@ export function buildAutoFitWorkingGridInput( preferredColumnWidths, preferredTableWidth, gridColumnCount, + rows, }); const preserveExplicitAutoGrid = shouldPreserveExplicitAutoGrid({ layoutMode, + tableWidth, preferredColumnWidths, preferredTableWidth, gridColumnCount, rows, }); - const autoGridWidthBudget = resolveAutoGridWidthBudget({ + const contentSizeAutoTable = resolveContentSizeAutoTable({ layoutMode, tableWidth, - preferredColumnWidths, preferredTableWidth, - gridColumnCount, + preferredColumnWidths, maxTableWidth, + rows, + preserveAutoGrid, + preserveExplicitAutoGrid, }); + const columnBandAllowances = contentSizeAutoTable + ? resolveColumnBandAllowances(block.attrs?.borders as TableBorders | null | undefined, gridColumnCount) + : undefined; + const autoGridWidthBudget = contentSizeAutoTable + ? undefined + : resolveAutoGridWidthBudget({ + layoutMode, + tableWidth, + preferredColumnWidths, + preferredTableWidth, + gridColumnCount, + maxTableWidth, + }); return { layoutMode, @@ -202,6 +233,8 @@ export function buildAutoFitWorkingGridInput( ...(preserveAutoGrid ? { preserveAutoGrid } : {}), ...(preserveExplicitAutoGrid ? { preserveExplicitAutoGrid } : {}), ...(autoGridWidthBudget != null ? { autoGridWidthBudget } : {}), + ...(contentSizeAutoTable ? { contentSizeAutoTable } : {}), + ...(columnBandAllowances ? { columnBandAllowances } : {}), preferredTableWidth, preferredColumnWidths, gridColumnCount, @@ -239,31 +272,133 @@ function shouldPreserveAutoGrid(args: { preferredColumnWidths: number[]; preferredTableWidth: number | undefined; gridColumnCount: number; + rows: WorkingTableRowInput[]; }): boolean { - const { layoutMode, preferredColumnWidths, preferredTableWidth, gridColumnCount } = args; + const { layoutMode, preferredColumnWidths, preferredTableWidth, gridColumnCount, rows } = args; if (layoutMode !== 'autofit') return false; if (preferredTableWidth != null) return false; if (preferredColumnWidths.length === 0 || preferredColumnWidths.length !== gridColumnCount) return false; + // A single-column grid with no concrete cell width anywhere is not a Word layout + // cache: Word recomputes and content-sizes the table on open (verified against + // Word renders of single-cell auto tables with a stale w:tblGrid, SD-3308). Let + // the content-size path claim it. With a tcW present the grid IS a cache: keep it. + if (preferredColumnWidths.length === 1 && !hasConcreteCellWidthRequest(rows)) return false; if (!hasNonUniformGrid(preferredColumnWidths)) return false; return true; } function shouldPreserveExplicitAutoGrid(args: { layoutMode: AutoFitLayoutMode; + tableWidth: TableWidthAttr | undefined; preferredColumnWidths: number[]; preferredTableWidth: number | undefined; gridColumnCount: number; rows: WorkingTableRowInput[]; }): boolean { - const { layoutMode, preferredColumnWidths, preferredTableWidth, gridColumnCount, rows } = args; + const { layoutMode, tableWidth, preferredColumnWidths, preferredTableWidth, gridColumnCount, rows } = args; if (layoutMode !== 'autofit') return false; if (preferredTableWidth == null || preferredTableWidth <= 0) return false; if (preferredColumnWidths.length === 0 || preferredColumnWidths.length !== gridColumnCount) return false; if (!hasNonUniformGrid(preferredColumnWidths) && !hasConcreteCellWidthRequest(rows)) return false; + // A pct-width table re-resolves its absolute width against the CURRENT available width, so the + // authored grid sum (from authoring-time twips) legitimately differs from the resolved table + // width while the grid PROPORTIONS stay authoritative. Word honors those proportions (300dpi + // probes, SD-3309); the solver scales the preferred widths to preferredTableWidth. For dxa/px + // the grid sum already equals the table width, so the equality check below still gates those. + if (isPercentTableWidth(tableWidth)) return true; + return approximatelyEqual(sumWidths(preferredColumnWidths), preferredTableWidth); } +/** True when the table preferred width is percentage-based (`tblW type="pct"`). */ +function isPercentTableWidth(tableWidth: TableWidthAttr | undefined): boolean { + return ( + typeof tableWidth === 'object' && + tableWidth != null && + typeof tableWidth.type === 'string' && + tableWidth.type.toLowerCase() === 'pct' + ); +} + +/** + * A "pure auto" table: autofit layout, auto/nil/absent tblW, and no cell anywhere + * carrying a concrete width preference. For these the stored w:tblGrid is not a + * Word layout cache (Word recomputes and content-sizes such tables on open), so + * the solver should target content demand instead of the authored grid sum. + * Tables already claimed by a preserve policy keep that behavior. (SD-3309) + */ +function resolveContentSizeAutoTable(args: { + layoutMode: AutoFitLayoutMode; + tableWidth: TableWidthAttr | undefined; + preferredTableWidth: number | undefined; + preferredColumnWidths: number[]; + maxTableWidth: number; + rows: WorkingTableRowInput[]; + preserveAutoGrid: boolean; + preserveExplicitAutoGrid: boolean; +}): boolean { + const { + layoutMode, + tableWidth, + preferredTableWidth, + preferredColumnWidths, + maxTableWidth, + rows, + preserveAutoGrid, + preserveExplicitAutoGrid, + } = args; + if (layoutMode !== 'autofit') return false; + if (preferredTableWidth != null) return false; + if (preserveAutoGrid || preserveExplicitAutoGrid) return false; + if (!isAutoOrNilTableWidth(tableWidth)) return false; + if (hasConcreteCellWidthRequest(rows)) return false; + // An authored grid WIDER than the available width is preserved as an overflow + // (overhang) table; Word keeps those wide (SD-1239, SD-1513). Content sizing + // only applies when the grid fits the page. + if (sumWidths(preferredColumnWidths) > maxTableWidth + 0.5) return false; + return true; +} + +/** Auto-width semantics for content sizing: absent tblW, type=auto with no positive width, or type=nil. */ +function isAutoOrNilTableWidth(tableWidth: TableWidthAttr | undefined): boolean { + if (tableWidth == null) return true; + if (hasAutoTableWidthSemantics(tableWidth)) return true; + if (typeof tableWidth === 'object' && typeof tableWidth.type === 'string') { + return tableWidth.type.toLowerCase() === 'nil'; + } + return false; +} + +/** + * Vertical border band widths owed per column on the content-size path. Table-level + * borders only (left edge, insideV dividers, right edge); cell-level vertical + * variation is rare in pure-auto tables and at most under-reserves slightly. + * + * Word-measured rule (band-scaling probes, SD-3308): each vertical gridline grants + * HALF its band width to each adjacent column. The painted band then sits fully + * inside the cell box, eating the other half back from the content area, so the + * content span shrinks by exactly the band delta while the column grows by half a + * band per edge. A single-column table therefore widens by ONE band (half left + + * half right), matching Word's measured column = text + margins + band. + */ +function resolveColumnBandAllowances( + borders: TableBorders | null | undefined, + gridColumnCount: number, +): number[] | undefined { + if (gridColumnCount <= 0) return undefined; + const left = getBorderBandWidthPx(borders?.left); + const insideV = getBorderBandWidthPx(borders?.insideV); + const right = getBorderBandWidthPx(borders?.right); + const allowances: number[] = []; + for (let i = 0; i < gridColumnCount; i++) { + const edgeLeft = i === 0 ? left : insideV; + const edgeRight = i === gridColumnCount - 1 ? right : insideV; + allowances.push((edgeLeft + edgeRight) / 2); + } + return allowances.some((a) => a > 0) ? allowances : undefined; +} + function resolveAutoGridWidthBudget(args: { layoutMode: AutoFitLayoutMode; tableWidth: TableWidthAttr | undefined; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index caeb1f9f27..3319a3d8a0 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1738,6 +1738,73 @@ describe('measureBlock', () => { const totalChars = measure.lines.reduce((sum, line) => sum + (line.toChar - line.fromChar), 0); expect(totalChars).toBe(3); }); + + it('matches merged-run wrapping when a numeric run is followed by unspaced CJK text', async () => { + const prefix = '2023'; + const suffix = '年度基层工会职工小家文体活动服务商入围项目'; + const splitBlock: FlowBlock = { + kind: 'paragraph', + id: 'cjk-numeric-split-runs', + runs: [ + { + text: prefix, + fontFamily: 'Arial', + fontSize: 26, + }, + { + text: suffix, + fontFamily: 'Arial', + fontSize: 26, + }, + ], + attrs: {}, + }; + const mergedBlock: FlowBlock = { + kind: 'paragraph', + id: 'cjk-numeric-merged-run', + runs: [ + { + text: `${prefix}${suffix}`, + fontFamily: 'Arial', + fontSize: 26, + }, + ], + attrs: {}, + }; + + const fullWidthMeasure = expectParagraphMeasure(await measureBlock(mergedBlock, 2000)); + const fullWidth = Math.ceil(fullWidthMeasure.lines[0].width); + let matchingWidth: number | undefined; + let splitMeasureAtMatch: ParagraphMeasure | undefined; + let mergedMeasureAtMatch: ParagraphMeasure | undefined; + + for (let width = fullWidth - 1; width >= Math.max(80, Math.floor(fullWidth * 0.4)); width -= 1) { + const mergedCandidate = expectParagraphMeasure(await measureBlock(mergedBlock, width)); + if (mergedCandidate.lines.length !== 2) continue; + if (mergedCandidate.lines[0].toRun !== 0 || mergedCandidate.lines[0].toChar <= prefix.length) continue; + + const splitCandidate = expectParagraphMeasure(await measureBlock(splitBlock, width)); + const splitLineTexts = splitCandidate.lines.map((line) => extractLineText(splitBlock, line)); + const mergedLineTexts = mergedCandidate.lines.map((line) => extractLineText(mergedBlock, line)); + + if (splitCandidate.lines[0].toRun !== 1 || splitCandidate.lines[0].toChar <= 0) continue; + if (splitLineTexts.join('|') !== mergedLineTexts.join('|')) continue; + + matchingWidth = width; + splitMeasureAtMatch = splitCandidate; + mergedMeasureAtMatch = mergedCandidate; + break; + } + + expect(matchingWidth).toBeDefined(); + + const splitLineTexts = splitMeasureAtMatch!.lines.map((line) => extractLineText(splitBlock, line)); + const mergedLineTexts = mergedMeasureAtMatch!.lines.map((line) => extractLineText(mergedBlock, line)); + + expect(splitMeasureAtMatch!.lines[0].toRun).toBe(1); + expect(splitMeasureAtMatch!.lines[0].toChar).toBeGreaterThan(0); + expect(splitLineTexts).toEqual(mergedLineTexts); + }); }); describe('deterministic behavior', () => { @@ -4231,7 +4298,7 @@ describe('measureBlock', () => { expect(Math.abs(measure.columnWidths[0] - measure.columnWidths[1])).toBeLessThan(1); }); - it('preserves authored widths and synthesizes runtime widths for missing logical columns', async () => { + it('synthesizes runtime widths for missing logical columns and content-sizes pure-auto tables', async () => { const block: FlowBlock = { kind: 'table', id: 'table-3', @@ -4296,7 +4363,10 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); expect(measure.columnWidths).toHaveLength(3); expect(measure.columnWidths[0]).toBeGreaterThan(0); - expect(measure.columnWidths[1]).toBeGreaterThan(measure.columnWidths[0]); + // SD-3309: pure-auto tables (auto tblW, no tcW anywhere) content-size like Word; + // the partial authored grid is not a layout cache, so equal content gives equal columns. + expect(measure.columnWidths[1]).toBeGreaterThan(0); + expect(measure.totalWidth).toBeLessThan(250); expect(measure.columnWidths[2]).toBeGreaterThan(0); expect(measure.totalWidth).toBeCloseTo( measure.columnWidths.reduce((sum, width) => sum + width, 0), @@ -4577,6 +4647,126 @@ describe('measureBlock', () => { }); }); + // SD-3308: a row whose cells are ALL row-spanning (vMerge start or continuation) + // must not collapse to zero height. In OOXML the continuation cells are real + // elements holding an empty paragraph, and Word sizes the row from them + // (one line high). The import merges those cells away, so the measurer grants + // every spanned row a minimum of the spanning cell's one-line height. + describe('rowspan-only rows (SD-3028 vMerge continuation)', () => { + it('keeps a row alive when all of its cells are row-spanning', async () => { + const para = (id: string, text: string) => ({ + kind: 'paragraph' as const, + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], + }); + const block: FlowBlock = { + kind: 'table', + id: 'vmerge-rows', + rows: [ + { + id: 'r0', + cells: [ + { id: 'c00', blocks: [para('p00', 'head')] }, + { id: 'c01', rowSpan: 2, colSpan: 2, blocks: [para('p01', 'span-down')] }, + { id: 'c02', rowSpan: 2, blocks: [para('p02', 'right')] }, + ], + }, + { + id: 'r1', + cells: [{ id: 'c10', rowSpan: 2, blocks: [para('p10', 'only-real-cell')] }], + }, + { + id: 'r2', + cells: [ + { id: 'c20', colSpan: 2, blocks: [para('p20', 'new')] }, + { id: 'c21', blocks: [para('p21', 'new2')] }, + ], + }, + ], + columnWidths: [120, 60, 60, 60], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + expect(measure.rows).toHaveLength(3); + // Row 1 holds only the start of a 2-row span (its other columns are vMerge + // continuations merged away at import): one line high like Word, not zero. + expect(measure.rows[1].height).toBeGreaterThan(0); + expect(measure.rows[1].height).toBeCloseTo(measure.rows[0].height, 0); + expect(measure.rows[2].height).toBeCloseTo(measure.rows[0].height, 0); + }); + }); + + describe('border band row-height reservation (SD-3308)', () => { + const makeTable = (borderStyle: string, width: number): FlowBlock => + ({ + kind: 'table', + id: 'table-band', + rows: [0, 1].map((r) => ({ + id: `row-${r}`, + cells: [ + { + id: `cell-${r}-0`, + blocks: [ + { + kind: 'paragraph', + id: `para-${r}`, + runs: [{ text: 'X', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + })), + attrs: { + borders: { + top: { style: borderStyle, width }, + bottom: { style: borderStyle, width }, + left: { style: borderStyle, width }, + right: { style: borderStyle, width }, + insideH: { style: borderStyle, width }, + insideV: { style: borderStyle, width }, + }, + }, + }) as unknown as FlowBlock; + + it('reserves the band excess for double borders and nothing for hairline singles', async () => { + const single = await measureBlock(makeTable('single', 1), { maxWidth: 600 }); + const double = await measureBlock(makeTable('double', 2), { maxWidth: 600 }); + if (single.kind !== 'table' || double.kind !== 'table') throw new Error('expected table measures'); + // double sz12: band = 3 * 2 = 6px -> reservation 5px per gridline. + // row 0 owns its top gridline; the last row owns its top gridline plus the bottom edge. + expect(double.rows[0].height).toBeCloseTo(single.rows[0].height + 5, 5); + expect(double.rows[1].height).toBeCloseTo(single.rows[1].height + 10, 5); + }); + + it('does not reserve anything in separate-borders mode', async () => { + const base = makeTable('double', 2) as { attrs: Record }; + base.attrs.cellSpacing = { type: 'dxa', value: 60 }; + const separate = await measureBlock(base as unknown as FlowBlock, { maxWidth: 600 }); + const collapsed = await measureBlock(makeTable('double', 2), { maxWidth: 600 }); + if (separate.kind !== 'table' || collapsed.kind !== 'table') throw new Error('expected table measures'); + expect(separate.rows[0].height).toBeLessThan(collapsed.rows[0].height); + }); + + it('reserves the band excess for compound thinThick* and triple borders', async () => { + // The shared contracts band profile drives the reservation, so compound + // styles reserve exactly like double does. (SD-3308) + const single = await measureBlock(makeTable('single', 1), { maxWidth: 600 }); + const thinThick = await measureBlock(makeTable('thinThickSmallGap', 4), { maxWidth: 600 }); + const triple = await measureBlock(makeTable('triple', 2), { maxWidth: 600 }); + if (single.kind !== 'table' || thinThick.kind !== 'table' || triple.kind !== 'table') { + throw new Error('expected table measures'); + } + // thinThickSmallGap w4: band = 4 + 1 + 1 = 6px -> reservation 5px per gridline. + expect(thinThick.rows[0].height).toBeCloseTo(single.rows[0].height + 5, 5); + expect(thinThick.rows[1].height).toBeCloseTo(single.rows[1].height + 10, 5); + // triple w2: band = 5 * 2 = 10px -> reservation 9px per gridline. + expect(triple.rows[0].height).toBeCloseTo(single.rows[0].height + 9, 5); + expect(triple.rows[1].height).toBeCloseTo(single.rows[1].height + 18, 5); + }); + }); + describe('autofit tables with colspan should not truncate grid columns', () => { const makeCell = (id: string) => ({ id, @@ -4780,7 +4970,7 @@ describe('measureBlock', () => { expect(measure.totalWidth).toBe(300); }); - it('does not scale when widths are within target', async () => { + it('does not upscale a pure-auto table beyond its content', async () => { const block: FlowBlock = { kind: 'table', id: 'scale-test-2', @@ -4820,8 +5010,139 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Auto layout preserves explicit widths (no scale-up) - expect(measure.columnWidths).toEqual([50, 50]); - expect(measure.totalWidth).toBe(100); + // SD-3309: pure-auto tables content-size like Word; equal content gives equal + // columns and the table never upscales toward the authored grid sum. + expect(Math.abs(measure.columnWidths[0] - measure.columnWidths[1])).toBeLessThan(1); + expect(measure.totalWidth).toBeLessThanOrEqual(100); + expect(measure.totalWidth).toBeGreaterThan(0); + }); + + // SD-3308: a SINGLE-column pure-auto grid is not a Word layout cache either. + // Word content-sizes such tables to the text (band-scaling probe: gridCol 3000 + // renders ~70px wide around "XXXX", not 200px). The single-column arm of + // hasNonUniformGrid must not let preserveAutoGrid pin the stale grid. + it('content-sizes a single-column pure-auto table instead of preserving its grid', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'single-col-pure-auto', + attrs: { tableWidth: { width: 0, type: 'auto' } }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + // Content demand for "XXXX" plus margins is far below the stale 200px grid. + expect(measure.totalWidth).toBeLessThan(120); + expect(measure.totalWidth).toBeGreaterThan(0); + }); + + // SD-3308 Word-measured band rule for content-sized columns (band-scaling probes): + // each vertical gridline grants HALF its band to each adjacent column, then the + // painted band sits fully inside the cell box and eats the other half back from + // the PADDING. Padding may compress to zero but text never clips, so: + // column = text + max(padding + band, 2 x band) + // Probe evidence: Word's thinThickSmallGap content span shrinks by exactly the + // band delta as sz grows (padding absorbing), while triple sz24's content span + // bottoms out at the text width (floor active, margins fully consumed). + it('content-sized columns add half a band per edge, padding absorbing until the text floor', async () => { + const makeBordered = (style: string, width: number): FlowBlock => + ({ + kind: 'table', + id: `band-allowance-${style}-${width}`, + attrs: { + tableWidth: { width: 0, type: 'auto' }, + borders: { + top: { style, width }, + bottom: { style, width }, + left: { style, width }, + right: { style, width }, + }, + }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }) as unknown as FlowBlock; + + const bare = await measureBlock(makeBordered('none', 0), { maxWidth: 624 }); + const single = await measureBlock(makeBordered('single', 2), { maxWidth: 624 }); + const triple = await measureBlock(makeBordered('triple', 2), { maxWidth: 624 }); + if (bare.kind !== 'table' || single.kind !== 'table' || triple.kind !== 'table') { + throw new Error('expected table measures'); + } + // bare = text + default padding 8. single band 2: 2x2 < 8+2 -> padding absorbs, + // column = bare + band. + expect(single.totalWidth - bare.totalWidth).toBeCloseTo(2, 5); + // triple band 10: 2x10 > 8+10 -> text floor wins, column = text + 2x10 = + // (bare - 8) + 20 = bare + 12. The content area between the painted bands is + // exactly the text width: no clipping. + expect(triple.totalWidth - bare.totalWidth).toBeCloseTo(12, 5); + }); + + it('still preserves a single-column auto grid when the cell carries a concrete width', async () => { + // With tcW present the authored grid IS a valid Word layout cache: keep it. + const block: FlowBlock = { + kind: 'table', + id: 'single-col-cached-grid', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + attrs: { tableCellProperties: { cellWidth: { value: 3000, type: 'dxa' } } }, + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + expect(measure.totalWidth).toBeCloseTo(200, 0); }); it('produces exact sum after rounding adjustment', async () => { @@ -5814,9 +6135,13 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // No tableWidth - auto layout preserves column widths - expect(measure.totalWidth).toBe(140); - expect(measure.columnWidths[0]).toBe(140); + // SD-1239 invariant: a missing tableWidth must not be misread as a percentage + // or crash; the table still measures sanely. SD-3308: a single-column + // pure-auto table content-sizes to its text like Word (the stale 140px grid + // is not a layout cache), so the width tracks content, not the grid. + expect(measure.totalWidth).toBeGreaterThan(0); + expect(measure.totalWidth).toBeLessThan(140); + expect(measure.columnWidths[0]).toBe(measure.totalWidth); }); it('does NOT scale up column widths for fixed layout tables with explicit width', async () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 4dc6b5123e..86e182f72e 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -61,6 +61,7 @@ import { type CellSpacing, type TableBorders, type TableBorderValue, + type CellBorders, EMPTY_SDT_PLACEHOLDER_TEXT, effectiveTableCellSpacing, isEmptySdtPlaceholderRun, @@ -76,6 +77,7 @@ import { DEFAULT_LIST_HANGING_PX as DEFAULT_LIST_HANGING, } from '@superdoc/common/layout-constants'; import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils'; +import { getAtomicRunLayoutSize, type MeasureAtomicText } from '@superdoc/common/atomic-run-size'; import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils'; import { toCssFontFamily } from '@superdoc/font-utils'; import { DEFAULT_FONT_MEASURE_CONTEXT, type FaceKey, type FontMeasureContext } from '@superdoc/font-system'; @@ -190,21 +192,16 @@ const pxToTwips = (px: number): number => Math.round(px * TWIPS_PER_PX); // Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported. export { getCellSpacingPx } from '@superdoc/contracts'; -import { getCellSpacingPx } from '@superdoc/contracts'; +import { getCellSpacingPx, getBorderBandWidthPx } from '@superdoc/contracts'; /** - * Returns the border width in pixels for a table border value (matches painter border-utils logic). - * Used so total table dimensions include outer border sizes and there is enough space for last row/column spacing. + * Returns the border band width in pixels for a table border value. + * Delegates to the shared contracts helper so this always matches the painter's + * rendered width (thick = authored width min 1px, double = 3x per-rule width min 3px). Used for + * outer table dimensions and per-row band reservation. */ function getTableBorderWidthPx(value: TableBorderValue | null | undefined): number { - if (value == null) return 0; - if (typeof value === 'object' && 'none' in value && value.none) return 0; - const raw = value as { style?: string; width?: number; size?: number }; - const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; - const width = Math.max(0, w); - if (raw.style === 'none') return 0; - if (raw.style === 'thick') return Math.max(width * 2, 3); - return width; + return getBorderBandWidthPx(value); } /** Computes outer table border widths in px from table attrs (for total dimensions and content offset). */ @@ -227,11 +224,10 @@ const DEFAULT_CELL_PADDING = { top: 0, left: 4, right: 4, bottom: 0 }; const DEFAULT_DECIMAL_SEPARATOR = '.'; const ALLOWED_TAB_VALS = new Set(['start', 'center', 'end', 'decimal', 'bar', 'clear']); -// Field annotation pill styling constants -const FIELD_ANNOTATION_PILL_PADDING = 8; // Border (2px each side) + padding (2px each side) -const FIELD_ANNOTATION_LINE_HEIGHT_MULTIPLIER = 1.2; // Line height multiplier for pill height -const FIELD_ANNOTATION_VERTICAL_PADDING = 6; // Vertical padding/border for pill height -const DEFAULT_FIELD_ANNOTATION_FONT_SIZE = 16; // Default font size for field annotations +// Field annotation pill styling constants are shared via @superdoc/common/layout-constants +// (FIELD_ANNOTATION_PILL_PADDING, FIELD_ANNOTATION_VERTICAL_PADDING, +// FIELD_ANNOTATION_LINE_HEIGHT_MULTIPLIER, DEFAULT_FIELD_ANNOTATION_FONT_SIZE) so the fast +// remeasure path and this full measurer stay in lockstep. const DEFAULT_PARAGRAPH_FONT_SIZE = 12; const DEFAULT_PARAGRAPH_FONT_FAMILY = 'Arial'; const isValidFontSize = (value: unknown): value is number => @@ -727,6 +723,21 @@ function measureTabAlignmentGroup( let foundDecimal = false; + // Field annotation label measurement for the shared atomic-run sizer, using this + // group measurer's existing font resolution (buildFontString + measureRunWidth). + const measureAtomicText: MeasureAtomicText = (text, atomicRun, fontSize) => { + const { font } = buildFontString( + { + fontFamily: ((atomicRun as { fontFamily?: string }).fontFamily as string) ?? 'Arial', + fontSize, + bold: (atomicRun as { bold?: boolean }).bold, + italic: (atomicRun as { italic?: boolean }).italic, + }, + fontContext, + ); + return measureRunWidth(text, font, ctx, atomicRun as unknown as TextRun, 0); + }; + for (let i = startRunIndex; i < runs.length; i++) { const run = runs[i]; @@ -778,42 +789,12 @@ function measureTabAlignmentGroup( continue; } - // Measure image runs - if (isImageRun(run)) { - const leftSpace = run.distLeft ?? 0; - const rightSpace = run.distRight ?? 0; - const imageWidth = run.width + leftSpace + rightSpace; - - result.runs.push({ runIndex: i, width: imageWidth }); - result.totalWidth += imageWidth; - continue; - } - - // Measure math runs (atomic, pre-computed dimensions like images) - if (run.kind === 'math') { - const mathWidth = (run as { width: number }).width ?? 20; - result.runs.push({ runIndex: i, width: mathWidth }); - result.totalWidth += mathWidth; - continue; - } - - // Measure field annotation runs - if (isFieldAnnotationRun(run)) { - const fontSize = (run as { fontSize?: number }).fontSize ?? DEFAULT_FIELD_ANNOTATION_FONT_SIZE; - const { font } = buildFontString( - { - fontFamily: (run as { fontFamily?: string }).fontFamily ?? 'Arial', - fontSize, - bold: (run as { bold?: boolean }).bold, - italic: (run as { italic?: boolean }).italic, - }, - fontContext, - ); - const textWidth = run.displayLabel ? measureRunWidth(run.displayLabel, font, ctx, run, 0) : 0; - const pillWidth = textWidth + FIELD_ANNOTATION_PILL_PADDING; - - result.runs.push({ runIndex: i, width: pillWidth }); - result.totalWidth += pillWidth; + // Measure atomic runs (image / math / field annotation) via the shared sizer so + // alignment-group widths match the canonical line-pass sizing. + if (isImageRun(run) || run.kind === 'math' || isFieldAnnotationRun(run)) { + const { width } = getAtomicRunLayoutSize(run, measureAtomicText); + result.runs.push({ runIndex: i, width }); + result.totalWidth += width; continue; } @@ -894,6 +875,20 @@ async function measureParagraphBlock( fontContext: FontMeasureContext, ): Promise { const ctx = getCanvasContext(); + // Field annotation label measurement for the shared atomic-run sizer. Resolves the + // physical render family (the family the pill actually paints) and applies the run's + // text-transform so the measured width matches the glyphs on screen. + const measureAtomicText: MeasureAtomicText = (text, run, fontSize) => { + const family = fontContext.resolvePhysical( + ((run as { fontFamily?: string }).fontFamily as string) || 'Arial, sans-serif', + faceOf(run as { bold?: boolean; italic?: boolean }), + ); + const weight = (run as { bold?: boolean }).bold ? 'bold' : 'normal'; + const style = (run as { italic?: boolean }).italic ? 'italic' : 'normal'; + ctx.font = `${style} ${weight} ${fontSize}px ${family}`; + const displayText = applyTextTransform(text, run as Run); + return displayText ? ctx.measureText(displayText).width : 0; + }; const wordLayout: WordParagraphLayoutOutput | undefined = block.attrs?.wordLayout as | WordParagraphLayoutOutput | undefined; @@ -1795,15 +1790,8 @@ async function measureParagraphBlock( // Handle image runs if (isImageRun(run)) { - // Calculate image width including spacing - const leftSpace = run.distLeft ?? 0; - const rightSpace = run.distRight ?? 0; - const imageWidth = run.width + leftSpace + rightSpace; - - // Calculate image height including spacing (for line height) - const topSpace = run.distTop ?? 0; - const bottomSpace = run.distBottom ?? 0; - const imageHeight = run.height + topSpace + bottomSpace; + // Width/height (including dist* spacing) come from the shared atomic-run sizer. + const { width: imageWidth, height: imageHeight } = getAtomicRunLayoutSize(run, measureAtomicText); // Determine image position - check active tab group first, then pending alignment let imageStartX: number | undefined; @@ -1927,9 +1915,7 @@ async function measureParagraphBlock( // Handle math runs (atomic, pre-computed dimensions like images) if (run.kind === 'math') { - const mathRun = run as { width: number; height: number }; - const mathWidth = mathRun.width ?? 20; - const mathHeight = mathRun.height ?? 24; + const { width: mathWidth, height: mathHeight } = getAtomicRunLayoutSize(run, measureAtomicText); if (!currentLine) { currentLine = { @@ -1958,54 +1944,10 @@ async function measureParagraphBlock( // Handle field annotation runs (pill-styled form fields) if (isFieldAnnotationRun(run)) { - // Use displayLabel for text measurement, with fallback defaults - const rawDisplayText = run.displayLabel || ''; - const displayText = applyTextTransform(rawDisplayText, run); - - // Use annotation's typography or fallback to defaults (16px Arial is standard) - const annotationFontSize = - typeof run.fontSize === 'number' - ? run.fontSize - : typeof run.fontSize === 'string' - ? parseFloat(run.fontSize) || DEFAULT_FIELD_ANNOTATION_FONT_SIZE - : DEFAULT_FIELD_ANNOTATION_FONT_SIZE; - // Resolve to the physical render family (a per-document fonts.map or the bundled substitute), - // the same family the pill paints, so the measured pill width matches the painted glyphs. - const annotationFontFamily = fontContext.resolvePhysical(run.fontFamily || 'Arial, sans-serif', faceOf(run)); - - // Build font string for measurement - const fontWeight = run.bold ? 'bold' : 'normal'; - const fontStyle = run.italic ? 'italic' : 'normal'; - const annotationFont = `${fontStyle} ${fontWeight} ${annotationFontSize}px ${annotationFontFamily}`; - ctx.font = annotationFont; - - // Measure text width - const textWidth = displayText ? ctx.measureText(displayText).width : 0; - - const annotationHorizontalPadding = run.highlighted === false ? 0 : FIELD_ANNOTATION_PILL_PADDING; - const annotationVerticalPadding = run.highlighted === false ? 0 : FIELD_ANNOTATION_VERTICAL_PADDING; - - // Add pill styling overhead: border (2px each side) + padding (2px each side) = 8px total - const annotationWidth = textWidth + annotationHorizontalPadding; - - // Calculate height including pill styling - let annotationHeight = annotationFontSize * FIELD_ANNOTATION_LINE_HEIGHT_MULTIPLIER + annotationVerticalPadding; - - // Signature images are capped to 28px in the renderer; reflect that in measurement. - if (run.variant === 'signature' && run.imageSrc) { - const signatureHeight = 28 + annotationVerticalPadding; - annotationHeight = Math.max(annotationHeight, signatureHeight); - } - - // Image annotations use explicit size when provided. - if (run.variant === 'image' && run.imageSrc && run.size?.height) { - const imageHeight = run.size.height + annotationVerticalPadding; - annotationHeight = Math.max(annotationHeight, imageHeight); - } - - if (run.variant === 'html' && run.size?.height) { - annotationHeight = Math.max(annotationHeight, run.size.height); - } + // Pill width/height come from the shared atomic-run sizer (single source of + // truth with the fast remeasure path); `measureAtomicText` resolves the font + // and applies the text-transform exactly as the pill paints. + const { width: annotationWidth, height: annotationHeight } = getAtomicRunLayoutSize(run, measureAtomicText); // If a tab alignment is pending, apply it let annotationStartX: number | undefined; @@ -2457,11 +2399,38 @@ async function measureParagraphBlock( // - We only want to break mid-word when the word truly exceeds available width // - Breaking words that exactly fit would cause unnecessary fragmentation if (wordOnlyWidth > effectiveMaxWidth + WIDTH_FUDGE_PX && word.length > 1) { + // Track the remaining portion of the oversized word. If the current line + // already has content, we may be able to place an initial chunk into the + // remaining width before finalizing the line. + let wordToBreak = word; + let wordToBreakStart = wordStartChar; + // First, finish any existing currentLine before processing the long word // Only push the line if it has actual text content (segments), not just tab positioning. // If the line only has width from tab advances but no text, we should keep it so the // long word can use the pending tab alignment. if (currentLine && currentLine.width > 0 && currentLine.segments && currentLine.segments.length > 0) { + const remainingWidth = currentLine.maxWidth - currentLine.width; + if (remainingWidth > WIDTH_FUDGE_PX) { + const firstChunks = breakWordIntoChunks(wordToBreak, remainingWidth, font, ctx, run, wordToBreakStart); + const firstChunk = firstChunks[0]; + if (firstChunk && firstChunk.text.length > 0 && firstChunk.text.length < wordToBreak.length) { + const firstChunkEnd = wordToBreakStart + firstChunk.text.length; + currentLine.toRun = runIndex; + currentLine.toChar = firstChunkEnd; + currentLine.width = roundValue(currentLine.width + firstChunk.width); + currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lineHeightFontSize(run)); + currentLine.maxFontInfo = getFontInfoFromRun(run, fontContext); + currentLine.segments.push({ + runIndex, + fromChar: wordToBreakStart, + toChar: firstChunkEnd, + width: firstChunk.width, + }); + wordToBreakStart = firstChunkEnd; + wordToBreak = wordToBreak.slice(firstChunk.text.length); + } + } trimTrailingWrapSpaces(currentLine); const metrics = finalizeLineMetrics(currentLine, spacing); const lineBase = currentLine; @@ -2489,10 +2458,10 @@ async function measureParagraphBlock( // Use remaining width for chunking if we have a tab-only line, otherwise use full line width const chunkWidth = hasTabOnlyLine ? Math.max(remainingWidthAfterTab, lineMaxWidth * 0.25) : lineMaxWidth; - const chunks = breakWordIntoChunks(word, chunkWidth, font, ctx, run, wordStartChar); + const chunks = breakWordIntoChunks(wordToBreak, chunkWidth, font, ctx, run, wordToBreakStart); // Process all chunks except the last one as complete lines - let chunkCharOffset = wordStartChar; + let chunkCharOffset = wordToBreakStart; for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; const chunkStartChar = chunkCharOffset; @@ -2983,7 +2952,8 @@ async function measureTableBlock( // Measure each cell paragraph with appropriate column width based on colspan const rows: TableRowMeasure[] = []; const rowBaseHeights: number[] = new Array(block.rows.length).fill(0); - const spanConstraints: Array<{ startRow: number; rowSpan: number; requiredHeight: number }> = []; + const spanConstraints: Array<{ startRow: number; rowSpan: number; requiredHeight: number; minRowHeight: number }> = + []; for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { const row = block.rows[rowIndex]; const normalizedRow = workingInput.rows[rowIndex]; @@ -3111,7 +3081,23 @@ async function measureTableBlock( if (rowspan === 1) { rowBaseHeights[rowIndex] = Math.max(rowBaseHeights[rowIndex], totalCellHeight); } else { - spanConstraints.push({ startRow: rowIndex, rowSpan: rowspan, requiredHeight: totalCellHeight }); + // A row whose cells are ALL row-spanning would otherwise measure 0: the + // OOXML vMerge continuation cells are real elements holding an empty + // paragraph and Word sizes rows from them, but the import merges those + // cells away. Approximate the lost empty-cell height with the spanning + // cell's first text line; non-text spans (e.g. a logo image) fall back to + // an even share so a spanning picture never doubles. Applied only to rows + // with no height of their own (see pass 1 below). (SD-3028) + const firstBlockMeasure = blockMeasures[0]; + const firstLineHeight = + firstBlockMeasure?.kind === 'paragraph' && firstBlockMeasure.lines.length > 0 + ? firstBlockMeasure.lines[0].lineHeight + : undefined; + const minRowHeight = Math.min( + totalCellHeight, + firstLineHeight != null ? firstLineHeight + paddingTop + paddingBottom : totalCellHeight / rowspan, + ); + spanConstraints.push({ startRow: rowIndex, rowSpan: rowspan, requiredHeight: totalCellHeight, minRowHeight }); } // Advance grid column position by colspan @@ -3129,6 +3115,19 @@ async function measureTableBlock( } const rowHeights = [...rowBaseHeights]; + // Pass 1: a spanned row with NO height of its own (all of its cells are vMerge + // starts/continuations) gets the spanning cell's one-line minimum instead of + // collapsing to zero. Rows that already have height from their own cells are + // left alone; Word sizes those from their own content. + for (const constraint of spanConstraints) { + const spanLength = Math.min(constraint.rowSpan, rowHeights.length - constraint.startRow); + for (let i = 0; i < spanLength; i++) { + if (rowBaseHeights[constraint.startRow + i] === 0) { + rowHeights[constraint.startRow + i] = Math.max(rowHeights[constraint.startRow + i], constraint.minRowHeight); + } + } + } + // Pass 2: the spanned rows together must fit the spanning cell's full content. for (const constraint of spanConstraints) { const { startRow, rowSpan, requiredHeight } = constraint; if (rowSpan <= 0) continue; @@ -3147,6 +3146,49 @@ async function measureTableBlock( } } + // Reserve row height for fat border bands (collapsed mode). Word adds the full border + // band to the table's vertical extent: measured against Word output, the dotted sz12 + // (2px band) and double sz12 (6px band) tables have IDENTICAL content regions and their + // row pitch differs by exactly the band delta. The legacy model absorbed hairline bands + // (<= 2px) in line-height slack, so to keep thin-border geometry byte-stable we only + // reserve bands above that hairline class, minus the same 1px nibble every bordered + // table already absorbs. Painter cells are border-box, so reserved height lets the band + // and the content coexist exactly like Word. Attribution follows the single-owner paint + // model: each row reserves its TOP gridline, the last row also reserves the bottom edge. + // (SD-3308) + const isCollapsedForBands = + (block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse')) !== 'separate'; + if (isCollapsedForBands && block.rows.length > 0) { + const tableBordersForBands = block.attrs?.borders as TableBorders | null | undefined; + const bandReservation = (band: number): number => (band > 2 ? band - 1 : 0); + const gridlineBand = (gridline: number): number => { + let band = 0; + const rowAbove = gridline > 0 ? block.rows[gridline - 1] : undefined; + const rowBelow = gridline < block.rows.length ? block.rows[gridline] : undefined; + for (const row of [rowAbove, rowBelow]) { + if (!row) continue; + // Row-level tblPrEx overrides merge per edge onto the table borders (§17.4.61). + const override = row.attrs?.borders as TableBorders | null | undefined; + const eff = override ? { ...(tableBordersForBands ?? {}), ...override } : tableBordersForBands; + const value = gridline === 0 ? eff?.top : gridline === block.rows.length ? eff?.bottom : eff?.insideH; + band = Math.max(band, getBorderBandWidthPx(value)); + } + // Cell-level tcBorders on either side of the gridline; the §17.4.66 winner is the + // heavier border, so the max band across candidates is the painted band width. + for (const cell of rowAbove?.cells ?? []) { + band = Math.max(band, getBorderBandWidthPx((cell.attrs?.borders as CellBorders | undefined)?.bottom)); + } + for (const cell of rowBelow?.cells ?? []) { + band = Math.max(band, getBorderBandWidthPx((cell.attrs?.borders as CellBorders | undefined)?.top)); + } + return band; + }; + for (let i = 0; i < block.rows.length; i++) { + rowHeights[i] += bandReservation(gridlineBand(i)); + } + rowHeights[block.rows.length - 1] += bandReservation(gridlineBand(block.rows.length)); + } + // Apply explicit row heights (exact / atLeast) from row attributes block.rows.forEach((row, index) => { const spec = row.attrs?.rowHeight as { value?: number; rule?: string } | undefined; diff --git a/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts b/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts index 2b7a19fd12..fcda52b1ec 100644 --- a/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts +++ b/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts @@ -68,6 +68,12 @@ export type TableCellContentMetrics = { * This is the no-wrap authored line width, plus horizontal cell chrome. */ maxWidthPx: number; + /** + * Horizontal cell chrome (padding + cell-border widths) baked into the outer + * widths, in pixels. Lets the AutoFit solver recover the text-only demand for + * the content-size band floor. (SD-3308) + */ + horizontalInsetsPx?: number; }; /** @@ -381,6 +387,7 @@ export async function measureTableCellContentMetrics( const emptyMetrics = { minWidthPx: horizontalInsets, maxWidthPx: horizontalInsets, + horizontalInsetsPx: horizontalInsets, }; tableCellMetricsCache.set(cacheKey, emptyMetrics); return emptyMetrics; @@ -398,6 +405,7 @@ export async function measureTableCellContentMetrics( const result = { minWidthPx: minContentWidthPx + horizontalInsets, maxWidthPx: maxContentWidthPx + horizontalInsets, + horizontalInsetsPx: horizontalInsets, }; tableCellMetricsCache.set(cacheKey, result); @@ -466,6 +474,7 @@ export async function measureTableAutoFitContentMetrics( preferredWidth: normalizedCell?.preferredWidth, minContentWidth: metrics.minWidthPx, maxContentWidth: metrics.maxWidthPx, + horizontalInsets: metrics.horizontalInsetsPx, }; }), ); @@ -488,6 +497,7 @@ export async function measureTableAutoFitContentMetrics( preferredWidth: cellMetrics.preferredWidth, minContentWidth: cellMetrics.minContentWidth, maxContentWidth: cellMetrics.maxContentWidth, + horizontalInsets: cellMetrics.horizontalInsets, })), skippedAfter: normalizedRow.skippedAfter ?? [], }; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 85ca744810..8c55f08d18 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -15188,6 +15188,156 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.sdtContainerEnd).toBe('true'); }); + it('continues parent block SDT chrome across a nested child block SDT fragment', () => { + const parentSdt = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'parent-sdt', + alias: 'Parent', + }; + const childSdt = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'child-sdt', + alias: 'Child', + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'parent-marker', + runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: parentSdt }, + }, + { + kind: 'paragraph', + id: 'child-content', + runs: [{ text: 'Child text', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: childSdt, containerSdt: parentSdt }, + }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }, + ]; + const layout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'parent-marker', fromLine: 0, toLine: 1, x: 20, y: 30, width: 320 }, + { kind: 'para', blockId: 'child-content', fromLine: 0, toLine: 1, x: 20, y: 50, width: 320 }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks, measures }); + painter.paint(layout, mount); + + const fragments = Array.from(mount.querySelectorAll('.superdoc-fragment')); + expect(fragments).toHaveLength(2); + expect(fragments[0].dataset.sdtId).toBe('parent-sdt'); + expect(fragments[0].dataset.sdtContainerStart).toBe('true'); + expect(fragments[0].dataset.sdtContainerEnd).toBe('false'); + expect(fragments[0].dataset.sdtNextOwnContainerStartsNested).toBe('true'); + expect(fragments[1].dataset.sdtId).toBe('child-sdt'); + expect(fragments[1].dataset.sdtContainerId).toBe('parent-sdt'); + expect(fragments[1].dataset.sdtContainerStart).toBe('false'); + expect(fragments[1].dataset.sdtContainerEnd).toBe('true'); + expect(fragments[1].dataset.sdtOwnContainerStart).toBe('true'); + expect(fragments[1].dataset.sdtOwnContainerEnd).toBe('true'); + expect(fragments[1].dataset.sdtOwnContainerNested).toBe('true'); + expect(fragments[0].querySelector('.superdoc-structured-content__label')?.textContent).toBe('Parent'); + expect(fragments[1].querySelector('.superdoc-structured-content__label')?.textContent).toBe('Child'); + }); + + it('keeps the parent label available when a parent block SDT starts with a child block SDT', () => { + const parentSdt = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'parent-sdt', + alias: 'Parent', + }; + const childSdt = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'child-sdt', + alias: 'Child', + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'child-content', + runs: [{ text: 'Child text', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: childSdt, containerSdt: parentSdt }, + }, + { + kind: 'paragraph', + id: 'parent-content', + runs: [{ text: 'Parent text', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: parentSdt }, + }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 90, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }, + ]; + const layout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'child-content', fromLine: 0, toLine: 1, x: 20, y: 30, width: 320 }, + { kind: 'para', blockId: 'parent-content', fromLine: 0, toLine: 1, x: 20, y: 50, width: 320 }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks, measures }); + painter.paint(layout, mount); + + const fragments = Array.from(mount.querySelectorAll('.superdoc-fragment')); + expect(fragments).toHaveLength(2); + expect(fragments[0].dataset.sdtId).toBe('child-sdt'); + expect(fragments[0].dataset.sdtContainerId).toBe('parent-sdt'); + expect(fragments[0].dataset.sdtContainerStart).toBe('true'); + expect(fragments[0].dataset.sdtContainerEnd).toBe('false'); + expect(fragments[0].dataset.sdtOwnContainerStart).toBe('true'); + expect(fragments[0].dataset.sdtOwnContainerEnd).toBe('true'); + expect(fragments[0].dataset.sdtOwnContainerNested).toBe('true'); + expect(fragments[1].dataset.sdtId).toBe('parent-sdt'); + expect(fragments[1].dataset.sdtContainerStart).toBe('false'); + expect(fragments[1].dataset.sdtContainerEnd).toBe('true'); + expect( + fragments.map((fragment) => fragment.querySelector('.superdoc-structured-content__label')?.textContent), + ).toEqual(['Child', 'Parent']); + }); + it('limits block SDT chrome to paragraph content width', () => { const textSdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index bd555afcf3..be808a421e 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -22,6 +22,7 @@ import { applySdtContainerChrome, getSdtContainerMetadata, isStructuredContentMetadata, + resolveRenderedSdtBoundary, shouldRenderSdtContainerChrome, type SdtAncestorOptions, type SdtBoundaryOptions, @@ -164,6 +165,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re } applySdtDataset(frameEl, block.attrs?.sdt); applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); + const effectiveSdtBoundary = resolveRenderedSdtBoundary(block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { ancestorContainerKey, @@ -178,7 +180,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re frameEl, block.attrs?.sdt, block.attrs?.containerSdt, - sdtBoundary, + effectiveSdtBoundary, undefined, contentControlsChrome, ) @@ -228,7 +230,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re lineIndexOffset + localStartLine, continuesFromPrev, continuesOnNext, - sdtBoundary, + effectiveSdtBoundary, resolvedContent, ); } diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts index 213967303b..519398116d 100644 --- a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts @@ -1,5 +1,31 @@ -import type { Fragment, ResolvedPaintItem } from '@superdoc/contracts'; -import type { SdtBoundaryOptions } from './container.js'; +import type { Fragment, ResolvedPaintItem, SdtMetadata } from '@superdoc/contracts'; +import { getSdtContainerKey } from '@superdoc/contracts'; +import { computeOwnContainerFlags, type SdtBoundaryOptions } from './container.js'; + +type SdtAttrsCandidate = { + attrs?: { + sdt?: SdtMetadata | null; + } | null; +}; + +const getOwnContainerKey = (item: ResolvedPaintItem): string | null => { + if (item.kind !== 'fragment') return null; + + const fragment = item.fragment; + const block = item.block; + if (!block) return null; + + if (fragment.kind === 'list-item' && block.kind === 'list') { + const listItem = block.items.find((candidate) => candidate.id === fragment.itemId); + return getSdtContainerKey(listItem?.paragraph.attrs?.sdt); + } + + if ('attrs' in block) { + return getSdtContainerKey((block as SdtAttrsCandidate).attrs?.sdt); + } + + return null; +}; export const computeSdtBoundaries = ( resolvedItems: readonly ResolvedPaintItem[], @@ -13,6 +39,7 @@ export const computeSdtBoundaries = ( } return null; }); + const ownContainerKeys: (string | null)[] = resolvedItems.map((item) => getOwnContainerKey(item)); const fragmentOf = (idx: number): Fragment | null => { const item = resolvedItems[idx]; @@ -60,17 +87,25 @@ export const computeSdtBoundaries = ( } } - const showLabel = isStart && !sdtLabelsRendered.has(currentKey); - if (showLabel) { - sdtLabelsRendered.add(currentKey); - } + const ownKey = ownContainerKeys[k]; + const previousOwnKey = k > 0 ? ownContainerKeys[k - 1] : null; + const nextOwnKey = k + 1 < ownContainerKeys.length ? ownContainerKeys[k + 1] : null; + const nextContainerKey = k + 1 < containerKeys.length ? containerKeys[k + 1] : null; + const ownFlags = computeOwnContainerFlags({ + containerKey: currentKey, + ownContainerKey: ownKey, + previousOwnContainerKey: previousOwnKey, + nextOwnContainerKey: nextOwnKey, + nextContainerKey, + sdtLabelsRendered, + }); boundaries.set(k, { isStart, isEnd, widthOverride: groupRight - fragment.x, paddingBottomOverride, - showLabel, + ...ownFlags, }); } diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index 26fbd71aa7..447e65d239 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -5,6 +5,7 @@ import { getSdtContainerKey, getSdtContainerKeyForBlock, getSdtSiblingBoundaries, + getSdtSiblingBoundariesWithOwnContainers, shouldRenderSdtContainerChrome, } from './container.js'; @@ -200,6 +201,31 @@ describe('SDT container chrome', () => { ]); }); + it('preserves own nested container boundaries within shared parent sibling boundaries', () => { + expect(getSdtSiblingBoundariesWithOwnContainers(['parent', 'parent'], ['parent', 'child'], new Set())).toEqual([ + { + isStart: true, + isEnd: false, + showLabel: true, + ownIsStart: true, + ownIsEnd: true, + ownShowLabel: false, + ownIsNested: false, + nextOwnContainerStartsNested: true, + }, + { + isStart: false, + isEnd: true, + showLabel: false, + ownIsStart: true, + ownIsEnd: true, + ownShowLabel: true, + ownIsNested: true, + nextOwnContainerStartsNested: false, + }, + ]); + }); + it('gets container keys for image and drawing blocks', () => { const sdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 9a29784d45..27a14f7046 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -22,6 +22,11 @@ export type SdtBoundaryOptions = { widthOverride?: number; paddingBottomOverride?: number; showLabel?: boolean; + ownIsStart?: boolean; + ownIsEnd?: boolean; + ownShowLabel?: boolean; + ownIsNested?: boolean; + nextOwnContainerStartsNested?: boolean; }; export type SdtAncestorOptions = { @@ -31,6 +36,55 @@ export type SdtAncestorOptions = { ancestorContainerSdts?: readonly (SdtMetadata | null | undefined)[]; }; +type OwnContainerFlagOptions = Pick< + SdtBoundaryOptions, + 'showLabel' | 'ownIsStart' | 'ownIsEnd' | 'ownShowLabel' | 'ownIsNested' | 'nextOwnContainerStartsNested' +>; + +export function computeOwnContainerFlags({ + containerKey, + ownContainerKey, + previousOwnContainerKey, + nextOwnContainerKey, + nextContainerKey, + sdtLabelsRendered, +}: { + containerKey: string; + ownContainerKey: string | null; + previousOwnContainerKey: string | null; + nextOwnContainerKey: string | null; + nextContainerKey: string | null; + sdtLabelsRendered: Set; +}): OwnContainerFlagOptions { + const ownIsStart = Boolean(ownContainerKey && ownContainerKey !== previousOwnContainerKey); + const ownIsEnd = Boolean(ownContainerKey && ownContainerKey !== nextOwnContainerKey); + const ownIsNested = Boolean(ownContainerKey && ownContainerKey !== containerKey); + const nextOwnContainerStartsNested = Boolean( + nextOwnContainerKey && + nextOwnContainerKey !== ownContainerKey && + nextOwnContainerKey !== containerKey && + nextContainerKey === containerKey, + ); + const showLabel = Boolean(!ownIsNested && !sdtLabelsRendered.has(containerKey)); + if (showLabel) { + sdtLabelsRendered.add(containerKey); + } + + const ownShowLabel = Boolean(ownContainerKey && ownIsNested && ownIsStart && !sdtLabelsRendered.has(ownContainerKey)); + if (ownShowLabel && ownContainerKey) { + sdtLabelsRendered.add(ownContainerKey); + } + + return { + showLabel, + ownIsStart, + ownIsEnd, + ownShowLabel, + ownIsNested, + nextOwnContainerStartsNested, + }; +} + export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is StructuredContentMetadata { return ( sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' @@ -105,6 +159,56 @@ export function getSdtSiblingBoundaries( }); } +export function getSdtSiblingBoundariesWithOwnContainers( + containerKeys: readonly (string | null)[], + ownContainerKeys: readonly (string | null)[], + sdtLabelsRendered: Set = new Set(), +): Array { + return containerKeys.map((key, index): SdtBoundaryOptions | undefined => { + if (!key) return undefined; + + const prev = index > 0 ? containerKeys[index - 1] : null; + const next = index < containerKeys.length - 1 ? containerKeys[index + 1] : null; + const ownKey = ownContainerKeys[index] ?? null; + const previousOwnKey = index > 0 ? ownContainerKeys[index - 1] : null; + const nextOwnKey = index < ownContainerKeys.length - 1 ? ownContainerKeys[index + 1] : null; + const nextContainerKey = index < containerKeys.length - 1 ? containerKeys[index + 1] : null; + const ownFlags = computeOwnContainerFlags({ + containerKey: key, + ownContainerKey: ownKey, + previousOwnContainerKey: previousOwnKey, + nextOwnContainerKey: nextOwnKey, + nextContainerKey, + sdtLabelsRendered, + }); + + return { + isStart: key !== prev, + isEnd: key !== next, + ...ownFlags, + }; + }); +} + +export function resolveRenderedSdtBoundary( + sdt: SdtMetadata | null | undefined, + containerSdt: SdtMetadata | null | undefined, + boundaryOptions: SdtBoundaryOptions | undefined, +): SdtBoundaryOptions | undefined { + if (!boundaryOptions) return undefined; + + const ownKey = getSdtContainerKey(sdt); + const boundaryKey = getSdtContainerKey(containerSdt) ?? getSdtContainerKey(sdt); + if (!ownKey || ownKey === boundaryKey || boundaryOptions.ownIsStart == null) { + return boundaryOptions; + } + + return { + ...boundaryOptions, + showLabel: boundaryOptions.ownShowLabel, + }; +} + export function applySdtContainerChrome( doc: Document, container: HTMLElement, @@ -126,6 +230,18 @@ export function applySdtContainerChrome( container.classList.add(config.className); container.dataset.sdtContainerStart = String(isStart); container.dataset.sdtContainerEnd = String(isEnd); + if (boundaryOptions?.ownIsStart != null) { + container.dataset.sdtOwnContainerStart = String(boundaryOptions.ownIsStart); + } + if (boundaryOptions?.ownIsEnd != null) { + container.dataset.sdtOwnContainerEnd = String(boundaryOptions.ownIsEnd); + } + if (boundaryOptions?.ownIsNested != null) { + container.dataset.sdtOwnContainerNested = String(boundaryOptions.ownIsNested); + } + if (boundaryOptions?.nextOwnContainerStartsNested != null) { + container.dataset.sdtNextOwnContainerStartsNested = String(boundaryOptions.nextOwnContainerStartsNested); + } container.style.overflow = 'visible'; if (isStructuredContentMetadata(metadata)) { @@ -142,6 +258,10 @@ export function applySdtContainerChrome( } const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; + container.dataset.sdtContainerShowLabel = String(shouldShowLabel); + if (boundaryOptions?.ownShowLabel != null) { + container.dataset.sdtOwnContainerShowLabel = String(boundaryOptions.ownShowLabel); + } if (shouldShowLabel) { if (chrome === 'none' && isStructuredContentMetadata(metadata)) { @@ -164,10 +284,32 @@ export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtB } const startAttr = element.dataset.sdtContainerStart; const endAttr = element.dataset.sdtContainerEnd; + const showLabelAttr = element.dataset.sdtContainerShowLabel; + const ownStartAttr = element.dataset.sdtOwnContainerStart; + const ownEndAttr = element.dataset.sdtOwnContainerEnd; + const ownShowLabelAttr = element.dataset.sdtOwnContainerShowLabel; + const ownNestedAttr = element.dataset.sdtOwnContainerNested; + const nextOwnStartsNestedAttr = element.dataset.sdtNextOwnContainerStartsNested; const expectedStart = String(boundary.isStart ?? true); const expectedEnd = String(boundary.isEnd ?? true); + const expectedShowLabel = boundary.showLabel == null ? undefined : String(boundary.showLabel); + const expectedOwnStart = boundary.ownIsStart == null ? undefined : String(boundary.ownIsStart); + const expectedOwnEnd = boundary.ownIsEnd == null ? undefined : String(boundary.ownIsEnd); + const expectedOwnShowLabel = boundary.ownShowLabel == null ? undefined : String(boundary.ownShowLabel); + const expectedOwnNested = boundary.ownIsNested == null ? undefined : String(boundary.ownIsNested); + const expectedNextOwnStartsNested = + boundary.nextOwnContainerStartsNested == null ? undefined : String(boundary.nextOwnContainerStartsNested); if (startAttr === undefined || endAttr === undefined) { return true; } - return startAttr !== expectedStart || endAttr !== expectedEnd; + return ( + startAttr !== expectedStart || + endAttr !== expectedEnd || + (expectedShowLabel !== undefined && showLabelAttr !== expectedShowLabel) || + (expectedOwnStart !== undefined && ownStartAttr !== expectedOwnStart) || + (expectedOwnEnd !== undefined && ownEndAttr !== expectedOwnEnd) || + (expectedOwnShowLabel !== undefined && ownShowLabelAttr !== expectedOwnShowLabel) || + (expectedOwnNested !== undefined && ownNestedAttr !== expectedOwnNested) || + (expectedNextOwnStartsNested !== undefined && nextOwnStartsNestedAttr !== expectedNextOwnStartsNested) + ); } diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 7a7c8c71c0..4ef57e5af6 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -151,6 +151,33 @@ describe('ensureSdtContainerStyles', () => { ); }); + it('shows nested child top chrome only on the active child SDT', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const nestedInactiveRule = + cssText.match( + /\.superdoc-structured-content-block\[data-sdt-own-container-nested="true"\]\[data-sdt-own-container-start="true"\]:not\(\.ProseMirror-selectednode\)::after\s*\{([^}]*)\}/, + )?.[1] ?? ''; + + expect(nestedInactiveRule).toContain('border-top: none;'); + expect(cssText).toContain( + '.superdoc-structured-content-block[data-sdt-next-own-container-starts-nested="true"]::after', + ); + expect(cssText).toContain( + '.superdoc-structured-content-block.sdt-ancestor-selected[data-sdt-next-own-container-starts-nested="true"]::after', + ); + expect(cssText).toContain( + '.superdoc-structured-content-block.sdt-container-selected:not(.ProseMirror-selectednode):not(.sdt-ancestor-selected)::after', + ); + const nestedActiveRule = + cssText.match( + /\.superdoc-structured-content-block\.ProseMirror-selectednode\[data-sdt-container-start="false"\]::after\s*\{([^}]*)\}/, + )?.[1] ?? ''; + expect(nestedActiveRule).toContain('border-top: none;'); + }); + it('gives empty inline SDTs a default visible affordance', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 88adb80858..c4972694c8 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -687,6 +687,14 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-block-border, #629be7); } +.superdoc-structured-content-block.sdt-container-selected::after { + border-color: var(--sd-content-controls-block-border, #629be7); +} + +.superdoc-structured-content-block.sdt-ancestor-selected::after { + border-color: var(--sd-content-controls-block-border, #629be7); +} + /* Structured content labels - shared box model; positioning differs by scope. */ .superdoc-structured-content__label, .superdoc-structured-content-inline__label { @@ -746,6 +754,10 @@ const SDT_CONTAINER_STYLES = ` display: inline-flex; } +.superdoc-structured-content-block.sdt-ancestor-selected .superdoc-structured-content__label { + display: inline-flex; +} + /* Continuation styling for structured content blocks */ /* Single fragment (both start and end): full border radius */ .superdoc-structured-content-block[data-sdt-container-start="true"][data-sdt-container-end="true"] { @@ -780,6 +792,30 @@ const SDT_CONTAINER_STYLES = ` border-bottom: none; } +.superdoc-structured-content-block[data-sdt-own-container-nested="true"][data-sdt-own-container-start="true"]:not(.ProseMirror-selectednode)::after { + border-top: none; +} + +.superdoc-structured-content-block[data-sdt-next-own-container-starts-nested="true"]::after { + border-bottom: none; +} + +.superdoc-structured-content-block.sdt-ancestor-selected[data-sdt-next-own-container-starts-nested="true"]::after { + border-bottom: 1px solid var(--sd-content-controls-block-border, #629be7); +} + +.superdoc-structured-content-block.sdt-container-selected:not(.ProseMirror-selectednode):not(.sdt-ancestor-selected)::after { + border-top: none; +} + +.superdoc-structured-content-block.ProseMirror-selectednode[data-sdt-container-start="false"]::after { + border-top: none; +} + +.superdoc-structured-content-block.ProseMirror-selectednode[data-sdt-own-container-nested="true"][data-sdt-own-container-end="true"]::after { + border-bottom: 1px solid var(--sd-content-controls-block-border, #629be7); +} + /* Structured Content Inline - Inline wrapper with blue border */ .superdoc-structured-content-inline { padding: 1px; @@ -982,7 +1018,9 @@ const SDT_CONTAINER_STYLES = ` .superdoc-cc-chrome-none .superdoc-structured-content-block::after, .superdoc-cc-chrome-none .superdoc-structured-content-block:hover::after, .superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::after, -.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode::after { +.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode::after, +.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-container-selected::after, +.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-ancestor-selected::after { border: none; } diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index ebadd10b2d..fc0af60729 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -10,7 +10,7 @@ * - createTableBorderOverlay: Border overlay element creation */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { BorderSpec, CellBorders, TableBorders, TableBorderValue, TableFragment } from '@superdoc/contracts'; import { applyBorder, @@ -23,6 +23,7 @@ import { swapTableBordersLR, swapCellBordersLR, resolveBorderConflict, + bevelToneSpec, } from './border-utils.js'; describe('applyBorder', () => { @@ -39,10 +40,26 @@ describe('applyBorder', () => { expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); - it('should apply border with double style', () => { + // SD-3308: OOXML w:sz on a double border is the width of EACH rule; Word renders + // rule + gap + rule at ~3x that width (measured: sz12 = 1.5pt rules, 6px band at + // 100dpi). The shared contracts band helper emits 3x the authored single-rule + // width, floored at 3px so CSS renders both rules. + it('renders a double border at three times the authored rule width', () => { const border: BorderSpec = { style: 'double', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/2px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/6px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('floors a hairline double border at 3px so both rules render', () => { + const border: BorderSpec = { style: 'double', width: 1, color: '#FF0000' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/3px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('scales a heavy double border by the same three-times rule', () => { + const border: BorderSpec = { style: 'double', width: 4, color: '#FF0000' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/12px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); it('should apply border with dashed style', () => { @@ -57,23 +74,40 @@ describe('applyBorder', () => { expect(element.style.borderTop).toMatch(/1px dotted (#0000FF|rgb\(0,\s*0,\s*255\))/i); }); - it('should convert triple to solid CSS', () => { + // SD-3308: compound styles carry their full measured band width so layout + // (content inset, row reservation) matches Word; the visible rules are painted + // by the compound nested-rectangle path, which makes this CSS border transparent. + it('renders a triple border at its full band width (5 segments of the authored width)', () => { const border: BorderSpec = { style: 'triple', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/10px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('renders thinThickSmallGap at its full band width (w + 0.75pt gap + 0.75pt rule)', () => { + const border: BorderSpec = { style: 'thinThickSmallGap', width: 4, color: '#FF0000' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/6px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + // SD-3308: dashSmallGap is a dash variant; CSS dashed is the accepted + // approximation (same as dotDash/dotDotDash). + it('renders dashSmallGap as CSS dashed at the authored width', () => { + const border: BorderSpec = { style: 'dashSmallGap', width: 2, color: '#00FF00' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/2px dashed (#00FF00|rgb\(0,\s*255,\s*0\))/i); }); - it('should handle thick border with width multiplier', () => { + it('paints a thick border at the authored width, not doubled', () => { const border: BorderSpec = { style: 'thick', width: 1, color: '#000000' }; applyBorder(element, 'Top', border); - // Thick borders use max(width * 2, 3) - expect(element.style.borderTop).toMatch(/3px solid (#000000|rgb\(0,\s*0,\s*0\))/i); + // SD-3028: thick paints at the authored width (1px floor), matching Word. + expect(element.style.borderTop).toMatch(/1px solid (#000000|rgb\(0,\s*0,\s*0\))/i); }); - it('should handle thick border with larger width', () => { + it('paints a wider thick border at the authored width', () => { const border: BorderSpec = { style: 'thick', width: 3, color: '#000000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/6px solid (#000000|rgb\(0,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/3px solid (#000000|rgb\(0,\s*0,\s*0\))/i); }); it('should set border to none for none style', () => { @@ -561,4 +595,100 @@ describe('resolveBorderConflict (ECMA-376 §17.4.66)', () => { // brightness(R+B+2G): dark=0 < light=1020 → dark wins expect(resolveBorderConflict(light, dark)).toEqual(dark); }); + + // SD-3308: the §17.4.66 weight tables must cover the compound styles so they + // win/lose conflicts the way Word resolves them. + it('compound thinThick styles outweigh single rules', () => { + const single = { style: 'single' as const, width: 1, color: '#000000' }; + const compound = { style: 'thinThickSmallGap' as const, width: 1, color: '#000000' }; + // weight: single = 1×1 = 1, thinThickSmallGap = 2 lines × number 9 = 18 + expect(resolveBorderConflict(single, compound)).toEqual(compound); + expect(resolveBorderConflict(compound, single)).toEqual(compound); + }); + + it('dashSmallGap outweighs plain dashed (style number 20 vs 5)', () => { + const dashed = { style: 'dashed' as const, width: 1, color: '#000000' }; + const dashSmallGap = { style: 'dashSmallGap' as const, width: 1, color: '#000000' }; + expect(resolveBorderConflict(dashed, dashSmallGap)).toEqual(dashSmallGap); + }); +}); + +describe('bevelToneSpec (separate-borders outset/inset, SD-3028)', () => { + const outset = (color: string) => ({ style: 'outset' as const, width: 1, color }); + const inset = (color: string) => ({ style: 'inset' as const, width: 1, color }); + + it('raises the table frame for outset: top/left light, bottom/right dark', () => { + expect(bevelToneSpec(outset('#000000'), 'top', 'table')).toMatchObject({ style: 'single', color: '#F0F0F0' }); + expect(bevelToneSpec(outset('#000000'), 'left', 'table')).toMatchObject({ color: '#F0F0F0' }); + expect(bevelToneSpec(outset('#000000'), 'bottom', 'table')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(outset('#000000'), 'right', 'table')).toMatchObject({ color: '#A0A0A0' }); + }); + + it('sinks the cells for outset: top/left dark, bottom/right light (legacy HTML look)', () => { + expect(bevelToneSpec(outset('#000000'), 'top', 'cell')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(outset('#000000'), 'bottom', 'cell')).toMatchObject({ color: '#F0F0F0' }); + }); + + it('inset mirrors both owners', () => { + expect(bevelToneSpec(inset('#000000'), 'top', 'table')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(inset('#000000'), 'top', 'cell')).toMatchObject({ color: '#F0F0F0' }); + }); + + it('derives tones from an explicit color: light = the color, dark = half intensity', () => { + expect(bevelToneSpec(outset('#FF0000'), 'top', 'table')).toMatchObject({ color: '#FF0000' }); + expect(bevelToneSpec(outset('#FF0000'), 'bottom', 'table')).toMatchObject({ color: '#7f0000' }); + }); + + it('passes other styles through unchanged', () => { + const single = { style: 'single' as const, width: 1, color: '#123456' }; + expect(bevelToneSpec(single, 'top', 'table')).toBe(single); + expect(bevelToneSpec(undefined, 'top', 'cell')).toBeUndefined(); + }); +}); + +describe('extended ST_Border values (dashDotStroked / threeDEmboss / threeDEngrave) (SD-3028)', () => { + let el: HTMLElement; + beforeEach(() => { + el = document.createElement('div'); + }); + + // These are valid ECMA-376 ST_Border values (spec order 21/22/23). They must paint as + // their CSS approximation, NOT hit the invalid-style 'solid' fallback (which warns). + it('paints dashDotStroked as dashed', () => { + applyBorder(el, 'Top', { style: 'dashDotStroked', width: 1, color: '#000000' }); + expect(el.style.borderTopStyle).toBe('dashed'); + }); + + it('paints threeDEmboss as a CSS ridge bevel', () => { + applyBorder(el, 'Top', { style: 'threeDEmboss', width: 1, color: '#000000' }); + expect(el.style.borderTopStyle).toBe('ridge'); + }); + + it('paints threeDEngrave as a CSS groove bevel', () => { + applyBorder(el, 'Top', { style: 'threeDEngrave', width: 1, color: '#000000' }); + expect(el.style.borderTopStyle).toBe('groove'); + }); + + it('does not warn (they are allowed styles, not the invalid fallback)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const style of ['dashDotStroked', 'threeDEmboss', 'threeDEngrave'] as const) { + applyBorder(document.createElement('div'), 'Top', { style, width: 1, color: '#000000' }); + } + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + // §17.4.66 conflict weight = lines x style number. Spec order 21/22/23 ranks these + // above the gap families (<=20) and below outset/inset (24/25) at equal line count. + it('ranks dashDotStroked (21) above dashed (5) and dashSmallGap (20) at equal weight', () => { + const dashed = { style: 'dashed' as const, width: 1, color: '#000000' }; + const dashDotStroked = { style: 'dashDotStroked' as const, width: 1, color: '#000000' }; + expect(resolveBorderConflict(dashed, dashDotStroked)).toEqual(dashDotStroked); + }); + + it('ranks threeDEngrave (23) above threeDEmboss (22) at equal weight', () => { + const emboss = { style: 'threeDEmboss' as const, width: 1, color: '#000000' }; + const engrave = { style: 'threeDEngrave' as const, width: 1, color: '#000000' }; + expect(resolveBorderConflict(emboss, engrave)).toEqual(engrave); + }); }); diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index 1285d0aae0..0f93b53562 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -6,6 +6,7 @@ import type { TableBorders, TableFragment, } from '@superdoc/contracts'; +import { getBorderBandWidthPx } from '@superdoc/contracts'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; const ALLOWED_BORDER_STYLES = new Set([ @@ -13,13 +14,28 @@ const ALLOWED_BORDER_STYLES = new Set([ 'single', 'double', 'dashed', + 'dashSmallGap', 'dotted', 'thick', 'triple', 'dotDash', 'dotDotDash', + 'thinThickSmallGap', + 'thickThinSmallGap', + 'thinThickThinSmallGap', + 'thinThickMediumGap', + 'thickThinMediumGap', + 'thinThickThinMediumGap', + 'thinThickLargeGap', + 'thickThinLargeGap', + 'thinThickThinLargeGap', 'wave', 'doubleWave', + 'dashDotStroked', + 'threeDEmboss', + 'threeDEngrave', + 'outset', + 'inset', ]); const borderStyleToCSS = (style?: BorderStyle): string => { @@ -31,18 +47,42 @@ const borderStyleToCSS = (style?: BorderStyle): string => { return 'solid'; } + // Compound styles (triple, thinThick*) map to 'solid' so the CSS border carries + // the full band width for layout; their visible rules are painted by the + // nested-rectangle compound path, which makes this CSS paint transparent. (SD-3308) const styleMap: Record = { none: 'none', single: 'solid', double: 'double', dashed: 'dashed', + dashSmallGap: 'dashed', dotted: 'dotted', thick: 'solid', triple: 'solid', dotDash: 'dashed', dotDotDash: 'dashed', + thinThickSmallGap: 'solid', + thickThinSmallGap: 'solid', + thinThickThinSmallGap: 'solid', + thinThickMediumGap: 'solid', + thickThinMediumGap: 'solid', + thinThickThinMediumGap: 'solid', + thinThickLargeGap: 'solid', + thickThinLargeGap: 'solid', + thinThickThinLargeGap: 'solid', wave: 'solid', doubleWave: 'solid', + // dashDotStroked: CSS cannot alternate dash and dot, so approximate as dashed + // (consistent with dotDash/dotDotDash). threeDEmboss/threeDEngrave map to CSS's + // native 3D bevels (ridge = raised, groove = engraved) as a close approximation. (SD-3028) + dashDotStroked: 'dashed', + threeDEmboss: 'ridge', + threeDEngrave: 'groove', + // In the collapsed model Word paints outset/inset as plain solid lines at the + // authored width and color (300dpi probes, SD-3028); the bevel only exists in + // separate-borders mode where bevelToneSpec retones the sides. + outset: 'solid', + inset: 'solid', }; return styleMap[style]; @@ -72,6 +112,7 @@ export const applyBorder = ( element: HTMLElement, side: 'Top' | 'Right' | 'Bottom' | 'Left', border?: BorderSpec, + widthOverridePx?: number, ): void => { if (!border) return; if (border.style === 'none' || border.width === 0) { @@ -80,10 +121,15 @@ export const applyBorder = ( } const style = borderStyleToCSS(border.style); - const width = border.width ?? 1; const color = border.color ?? '#000000'; const safeColor = isValidHexColor(color) ? color : '#000000'; - const actualWidth = border.style === 'thick' ? Math.max(width * 2, 3) : width; + // Band width comes from the shared contracts helper so the painted width and the + // measuring engine's row-height reservation can never disagree. Word semantics: + // thick = authored width (1px floor); double = 3x the per-rule w:sz (min 3px so CSS renders both + // rules); everything else = authored width. `widthOverridePx` carries the + // straddled half-band for interior compound edges (Word centers those bands on + // the gridline, half in each adjacent cell). (SD-3308) + const actualWidth = widthOverridePx ?? getBorderBandWidthPx(border); element.style[`border${side}`] = `${actualWidth}px ${style} ${safeColor}`; }; @@ -106,12 +152,57 @@ export const applyBorder = ( * }); * ``` */ -export const applyCellBorders = (element: HTMLElement, borders?: CellBorders): void => { +export const applyCellBorders = ( + element: HTMLElement, + borders?: CellBorders, + widthOverridesPx?: { left?: number; right?: number }, +): void => { if (!borders) return; applyBorder(element, 'Top', borders.top); - applyBorder(element, 'Right', borders.right); + applyBorder(element, 'Right', borders.right, widthOverridesPx?.right); applyBorder(element, 'Bottom', borders.bottom); - applyBorder(element, 'Left', borders.left); + applyBorder(element, 'Left', borders.left, widthOverridesPx?.left); +}; + +/** The two tones Word uses for outset/inset bevel sides (300dpi probes, SD-3028). */ +const BEVEL_LIGHT_AUTO = '#F0F0F0'; +const BEVEL_DARK_AUTO = '#A0A0A0'; + +const bevelDarkColor = (color?: string): string => { + if (!color || !/^#[0-9A-Fa-f]{6}$/.test(color) || color.toLowerCase() === '#000000') return BEVEL_DARK_AUTO; + const half = (i: number) => Math.floor(parseInt(color.slice(i, i + 2), 16) / 2); + return `#${[1, 3, 5].map((i) => half(i).toString(16).padStart(2, '0')).join('')}`; +}; + +const bevelLightColor = (color?: string): string => { + if (!color || !/^#[0-9A-Fa-f]{6}$/.test(color) || color.toLowerCase() === '#000000') return BEVEL_LIGHT_AUTO; + return color; +}; + +/** + * Word's separate-borders bevel model for `outset`/`inset` (measured from 300dpi + * probes, SD-3028): the legacy HTML table look. With `outset` the TABLE frame is + * raised (visual top/left light, bottom/right dark) and each CELL is sunken (the + * inverse); `inset` mirrors both. Tones derive from the authored color: auto/black + * uses #F0F0F0 / #A0A0A0, an explicit color uses the color itself (light) and the + * color at half intensity (dark). All other styles pass through unchanged. Only + * separate-borders mode calls this; in the collapsed model Word paints these + * styles as plain solid lines. + */ +export const bevelToneSpec = ( + spec: BorderSpec | undefined, + visualSide: 'top' | 'right' | 'bottom' | 'left', + owner: 'table' | 'cell', +): BorderSpec | undefined => { + if (!spec || (spec.style !== 'outset' && spec.style !== 'inset')) return spec; + const raisedSide = visualSide === 'top' || visualSide === 'left'; + const raisedOwner = (spec.style === 'outset') === (owner === 'table'); + const light = raisedOwner === raisedSide; + return { + ...spec, + style: 'single', + color: light ? bevelLightColor(spec.color) : bevelDarkColor(spec.color), + }; }; /** @@ -193,8 +284,23 @@ const BORDER_STYLE_NUMBER: Partial> = { dotDash: 6, dotDotDash: 7, triple: 8, + thinThickSmallGap: 9, + thickThinSmallGap: 10, + thinThickThinSmallGap: 11, + thinThickMediumGap: 12, + thickThinMediumGap: 13, + thinThickThinMediumGap: 14, + thinThickLargeGap: 15, + thickThinLargeGap: 16, + thinThickThinLargeGap: 17, wave: 18, doubleWave: 19, + dashSmallGap: 20, + dashDotStroked: 21, + threeDEmboss: 22, + threeDEngrave: 23, + outset: 24, + inset: 25, }; // Number of drawn lines per style (single=1, double=2, triple=3, …). const BORDER_STYLE_LINES: Partial> = { @@ -206,8 +312,21 @@ const BORDER_STYLE_LINES: Partial> = { dotDash: 1, dotDotDash: 1, triple: 3, + thinThickSmallGap: 2, + thickThinSmallGap: 2, + thinThickThinSmallGap: 3, + thinThickMediumGap: 2, + thickThinMediumGap: 2, + thinThickThinMediumGap: 3, + thinThickLargeGap: 2, + thickThinLargeGap: 2, + thinThickThinLargeGap: 3, wave: 1, doubleWave: 2, + dashSmallGap: 1, + dashDotStroked: 1, + threeDEmboss: 1, + threeDEngrave: 1, }; export const isPresentBorder = (b?: BorderSpec): b is BorderSpec => diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index cd22ae83dd..57f14ba8ac 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -101,6 +101,43 @@ describe('renderTableCell', () => { }, }); + // SD-3308 Word-measured padding rule for compound border bands: the painted band + // eats HALF its width back from the cell padding per side (probe evidence: Word's + // thinThickSmallGap sz24 leftover margin = padding - band/2). Padding floors at 0; + // non-compound borders keep the full padding (sub-pixel difference, deliberately + // out of scope to avoid corpus churn). + it('compresses horizontal padding by half the band on compound border sides', () => { + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: baseCellMeasure, + cell: baseCell, + borders: { + // thinThickSmallGap w4: band 6 -> padding 4 - 3 = 1px + left: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + // triple w2: band 10 -> padding 4 - 5 -> floors at 0 + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }); + + expect(cellElement.style.paddingLeft).toBe('1px'); + expect(cellElement.style.paddingRight).toBe('0px'); + }); + + it('keeps full padding for non-compound border sides', () => { + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: baseCellMeasure, + cell: baseCell, + borders: { + left: { style: 'single', width: 2, color: '#000000' }, + right: { style: 'thick', width: 2, color: '#000000' }, + }, + }); + + expect(cellElement.style.paddingLeft).toBe('4px'); + expect(cellElement.style.paddingRight).toBe('4px'); + }); + it('uses an end-of-cell mark for the final paragraph in a table cell', () => { const secondParagraphBlock: ParagraphBlock = { kind: 'paragraph', @@ -3800,6 +3837,99 @@ describe('renderTableCell', () => { expect(cellElement.style.overflow).toBe('visible'); }); + it('should render child SDT label when a table cell parent SDT continues into a nested child paragraph', () => { + const parentSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'cell-parent-sdt', + alias: 'Parent', + }; + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'cell-child-sdt', + alias: 'Child', + }; + const parentPara: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-parent-para', + runs: [{ text: 'Parent', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: parentSdt }, + }; + const childPara: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-child-para', + runs: [{ text: 'Child', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: childSdt, containerSdt: parentSdt }, + }; + const parentMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 50, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const childMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 40, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [parentMeasure, childMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-parent-child-sdt', + blocks: [parentPara, childPara], + attrs: {}, + }, + }); + + const chromeElements = Array.from( + cellElement.querySelectorAll('.superdoc-structured-content-block'), + ); + expect(chromeElements).toHaveLength(2); + expect(chromeElements[0].dataset.sdtContainerStart).toBe('true'); + expect(chromeElements[0].dataset.sdtContainerEnd).toBe('false'); + expect(chromeElements[0].dataset.sdtNextOwnContainerStartsNested).toBe('true'); + expect(chromeElements[1].dataset.sdtContainerStart).toBe('false'); + expect(chromeElements[1].dataset.sdtContainerEnd).toBe('true'); + expect(chromeElements[1].dataset.sdtOwnContainerStart).toBe('true'); + expect(chromeElements[1].dataset.sdtOwnContainerEnd).toBe('true'); + expect(chromeElements[1].dataset.sdtOwnContainerNested).toBe('true'); + expect(chromeElements.map((el) => el.querySelector('.superdoc-structured-content__label')?.textContent)).toEqual([ + 'Parent', + 'Child', + ]); + }); + it('should set overflow:visible when cell contains documentSection SDT', () => { const para: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index ff51462e4f..08142826e0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1,4 +1,5 @@ import type { + BorderSpec, CellBorders, DrawingBlock, ImageDrawing, @@ -18,7 +19,13 @@ import type { WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; +import { + rescaleColumnWidths, + normalizeZIndex, + getCellSpacingPx, + getBorderBandProfile, + getSdtContainerKey, +} from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; @@ -26,8 +33,7 @@ import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { createBlockImageContent } from '../images/image-block.js'; import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; import { - getSdtContainerKeyForBlock, - getSdtSiblingBoundaries, + getSdtSiblingBoundariesWithOwnContainers, type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; @@ -38,6 +44,32 @@ import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableCellMeasure = TableRowMeasure['cells'][number]; +const getCellBlockSdtBoundaryKey = (block: ParagraphBlock | TableBlock): string | null => { + return getSdtContainerKey(block.attrs?.containerSdt) ?? getSdtContainerKey(block.attrs?.sdt); +}; + +const getCellBlockOwnSdtBoundaryKey = (block: ParagraphBlock | TableBlock): string | null => { + return getSdtContainerKey(block.attrs?.sdt); +}; + +const narrowSdtBoundaryFlags = ( + boundary: SdtBoundaryOptions | undefined, + isFirstSlice: boolean, + isLastSlice: boolean, +): SdtBoundaryOptions | undefined => { + if (!boundary) return undefined; + + return { + ...boundary, + isStart: (boundary.isStart ?? true) && isFirstSlice, + isEnd: (boundary.isEnd ?? true) && isLastSlice, + showLabel: boundary.showLabel === undefined ? undefined : boundary.showLabel && isFirstSlice, + ownIsStart: boundary.ownIsStart === undefined ? undefined : boundary.ownIsStart && isFirstSlice, + ownIsEnd: boundary.ownIsEnd === undefined ? undefined : boundary.ownIsEnd && isLastSlice, + ownShowLabel: boundary.ownShowLabel === undefined ? undefined : boundary.ownShowLabel && isFirstSlice, + }; +}; + /** * Compute the total segment count for a cell's blocks, matching the layout engine's * recursive getCellLines() expansion. Paragraph blocks contribute their line count, @@ -486,14 +518,7 @@ function renderPartialEmbeddedTable(params: { } const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); - const effectiveSdtBoundary = sdtBoundary - ? { - ...sdtBoundary, - isStart: (sdtBoundary.isStart ?? true) && localFrom === 0, - isEnd: (sdtBoundary.isEnd ?? true) && localTo >= totalTableSegments, - showLabel: sdtBoundary.showLabel === undefined ? undefined : sdtBoundary.showLabel && localFrom === 0, - } - : undefined; + const effectiveSdtBoundary = narrowSdtBoundaryFlags(sdtBoundary, localFrom === 0, localTo >= totalTableSegments); const tableWrapper = doc.createElement('div'); tableWrapper.style.position = 'relative'; @@ -554,6 +579,12 @@ type TableCellRenderDependencies = { cell?: TableBlock['rows'][number]['cells'][number]; /** Resolved borders for this cell */ borders?: CellBorders; + /** + * Per-side CSS band width overrides in px. Interior compound bands straddle the + * gridline (Word model, SD-3308): each adjacent cell carries HALF the band as its + * transparent border instead of the owner carrying it all. + */ + borderBandOverridesPx?: { left?: number; right?: number }; /** Whether to apply default border if no borders specified */ useDefaultBorder?: boolean; /** Function to render a line of paragraph content */ @@ -703,6 +734,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen fromLine, toLine, resolvePhysical, + borderBandOverridesPx, } = deps; const attrs = cell?.attrs; @@ -714,9 +746,30 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ): HTMLElement => buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display); // RTL: swap left↔right cell margins (ECMA-376 Part 4 §14.3.3–14.3.4, §14.3.7–14.3.8) - const paddingLeft = isRtl ? (padding.right ?? 4) : (padding.left ?? 4); + // Word eats half of a border band back from the cell padding on that side + // (band-scaling probe measurements: leftover margin = padding - band/2, floored + // at 0). Scoped to compound bands (double, triple, thinThick*): for single-rule + // borders the difference is sub-pixel and not worth disturbing existing layouts. + // The matching column growth lives in measuring resolveColumnBandAllowances. (SD-3308) + // The eaten amount is the painted band in THIS cell minus the half-band the + // column was granted: a boundary band (fully inside) eats band/2; a straddled + // interior band (half inside, see borderBandOverridesPx) eats nothing. + const compoundBandEats = (border: BorderSpec | undefined, bandInCellPx?: number): number => { + const profile = border ? getBorderBandProfile(border) : null; + if (!profile) return 0; + const bandInCell = bandInCellPx ?? profile.band; + return Math.max(0, bandInCell - profile.band / 2); + }; + const paddingLeft = Math.max( + 0, + (isRtl ? (padding.right ?? 4) : (padding.left ?? 4)) - compoundBandEats(borders?.left, borderBandOverridesPx?.left), + ); const paddingTop = padding.top ?? 0; - const paddingRight = isRtl ? (padding.left ?? 4) : (padding.right ?? 4); + const paddingRight = Math.max( + 0, + (isRtl ? (padding.left ?? 4) : (padding.right ?? 4)) - + compoundBandEats(borders?.right, borderBandOverridesPx?.right), + ); const paddingBottom = padding.bottom ?? 0; const cellEl = doc.createElement('div'); @@ -735,7 +788,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen cellEl.style.paddingBottom = `${paddingBottom}px`; if (borders) { - applyCellBorders(cellEl, borders); + applyCellBorders(cellEl, borders, borderBandOverridesPx); } else if (useDefaultBorder) { cellEl.style.border = '1px solid rgba(0,0,0,0.6)'; } @@ -748,9 +801,12 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const cellBlocks = cell?.blocks ?? (cell?.paragraph ? [cell.paragraph] : []); const blockMeasures = cellMeasure?.blocks ?? (cellMeasure?.paragraph ? [cellMeasure.paragraph] : []); const sdtContainerKeys = cellBlocks.map((block) => - block.kind === 'paragraph' || block.kind === 'table' ? getSdtContainerKeyForBlock(block) : null, + block.kind === 'paragraph' || block.kind === 'table' ? getCellBlockSdtBoundaryKey(block) : null, + ); + const sdtOwnContainerKeys = cellBlocks.map((block) => + block.kind === 'paragraph' || block.kind === 'table' ? getCellBlockOwnSdtBoundaryKey(block) : null, ); - const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); + const sdtBoundaries = getSdtSiblingBoundariesWithOwnContainers(sdtContainerKeys, sdtOwnContainerKeys); if (cellBlocks.length > 0 && blockMeasures.length > 0) { // Content is a child of the cell, positioned relative to it @@ -999,16 +1055,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paraWrapper.style.position = 'relative'; paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; - const baseSdtBoundary = sdtBoundaries[i]; - const sdtBoundary = baseSdtBoundary - ? { - ...baseSdtBoundary, - isStart: (baseSdtBoundary.isStart ?? true) && localStartLine === 0, - isEnd: (baseSdtBoundary.isEnd ?? true) && localEndLine >= blockLineCount, - showLabel: - baseSdtBoundary.showLabel === undefined ? undefined : baseSdtBoundary.showLabel && localStartLine === 0, - } - : undefined; + const sdtBoundary = narrowSdtBoundaryFlags( + sdtBoundaries[i], + localStartLine === 0, + localEndLine >= blockLineCount, + ); content.appendChild(paraWrapper); const result = renderParagraphContent({ diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index 79d5d1d529..1cd86633e5 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -136,6 +136,59 @@ describe('renderTableFragment', () => { expect(element.dataset.pmEnd).toBe('34'); }); + it('renders a nested table SDT own label when the parent boundary label is suppressed', () => { + const parentSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'parent-sdt', + alias: 'Parent', + }; + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'child-sdt', + alias: 'Child', + }; + const block = createTestTableBlock(); + block.attrs = { + sdt: childSdt, + containerSdt: parentSdt, + }; + const measure = createTestTableMeasure(); + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + sdtBoundary: { + isStart: false, + isEnd: true, + showLabel: false, + ownIsStart: true, + ownIsEnd: true, + ownShowLabel: true, + ownIsNested: true, + }, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => { + // Intentionally empty for test mock + }, + applySdtDataset: () => { + // Intentionally empty for test mock + }, + applyStyles: () => { + // Intentionally empty for test mock + }, + }); + + expect(element.dataset.sdtContainerShowLabel).toBe('true'); + expect(element.querySelector('.superdoc-structured-content-block__label')?.textContent).toBe('Child'); + }); + it('applies outer left/right borders in separate mode even when cellSpacing is unset or zero', () => { const block = createTestTableBlock(); block.attrs = { @@ -170,6 +223,189 @@ describe('renderTableFragment', () => { expect(element.style.borderRightWidth).toBe('3px'); }); + // SD-3308 review: in separate-borders mode a COMPOUND table border (triple/thinThick*) + // is painted by the nested-rectangle outline/middle-grid overlay. The container's CSS + // border keeps its band WIDTH (separate-mode gap geometry) but its color must be + // transparent on compound sides, or applyBorder's solid band renders a filled slab + // under the overlay rules. A plain single border stays painted normally. + it('makes the separate-mode container border transparent on compound sides (no solid slab)', () => { + const block = createTestTableBlock(); + block.attrs = { + borderCollapse: 'separate', + borders: { + top: { style: 'triple', width: 2, color: '#000000' }, // compound -> transparent + left: { style: 'single', width: 2, color: '#ff0000' }, // plain -> stays painted + }, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure: createTestTableMeasure(), + cellSpacingPx: 6, + effectiveColumnWidths: [100], + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + // compound top: width kept (band), color transparent so the overlay rules are the only paint + expect(element.style.borderTopWidth).not.toBe(''); + expect(element.style.borderTopColor).toBe('transparent'); + // plain single left: normal solid paint, not transparent + expect(element.style.borderLeftStyle).toBe('solid'); + expect(element.style.borderLeftColor).not.toBe('transparent'); + // the compound overlay still paints the frame rules + expect(element.querySelector('.superdoc-compound-border-outline')).not.toBeNull(); + }); + + // SD-3028: a table-level `double` is NOT a multi-rule overlay style — it renders via the + // boundary cells' native CSS `border-style: double` (two equal rules). The fragment outline + // and the separate-mode transparent treatment must skip it, or a third rule stacks on top. + it('does not draw the compound outline for a table-level double (native cells render it)', () => { + const block = createTestTableBlock(); + block.attrs = { + borders: { + top: { style: 'double', width: 2, color: '#000000' }, + bottom: { style: 'double', width: 2, color: '#000000' }, + left: { style: 'double', width: 2, color: '#000000' }, + right: { style: 'double', width: 2, color: '#000000' }, + }, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure: createTestTableMeasure(), + cellSpacingPx: 0, + effectiveColumnWidths: [100], + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + // No compound outline / mid-grid / per-cell rects for a plain double. + expect(element.querySelector('.superdoc-compound-border-outline')).toBeNull(); + expect(element.querySelector('.superdoc-compound-border-midring')).toBeNull(); + expect(element.querySelectorAll('.superdoc-compound-border-rect').length).toBe(0); + // The boundary cell carries a real, visible double border (not transparent-ized). + const cell = element.querySelector('div[style*="border"]') as HTMLElement | null; + expect(cell).not.toBeNull(); + expect(cell!.style.borderTopStyle === 'double' || cell!.style.borderBottomStyle === 'double').toBe(true); + }); + + // SD-3308: Word paints the MIDDLE rule of table-level 3-rule bands as a continuous + // grid (measured: the divider's middle rule runs unbroken through the row band and + // meets the boundary band's middle ring). The fragment paints one ring inset by + // outer rule + gap plus full-length center strips per interior gridline. + it('paints a continuous middle grid for table-level triple borders', () => { + const para = (id: string): ParagraphBlock => ({ kind: 'paragraph', id: id as BlockId, runs: [] }); + const block: TableBlock = { + kind: 'table', + id: 'triple-grid' as BlockId, + attrs: { + borders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + insideH: { style: 'triple', width: 2, color: '#000000' }, + insideV: { style: 'triple', width: 2, color: '#000000' }, + }, + }, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { id: 'c00' as BlockId, blocks: [para('p00')] }, + { id: 'c01' as BlockId, blocks: [para('p01')] }, + ], + }, + { + id: 'r1' as BlockId, + cells: [ + { id: 'c10' as BlockId, blocks: [para('p10')] }, + { id: 'c11' as BlockId, blocks: [para('p11')] }, + ], + }, + ], + }; + const cellMeasure = { + blocks: [{ kind: 'paragraph' as const, lines: [], totalHeight: 20 }], + width: 100, + height: 20, + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [cellMeasure, cellMeasure], height: 20 }, + { cells: [cellMeasure, cellMeasure], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const fragment: TableFragment = { + kind: 'table', + blockId: 'triple-grid' as BlockId, + fromRow: 0, + toRow: 2, + x: 0, + y: 0, + width: 200, + height: 40, + }; + + const element = renderTableFragment({ + doc, + fragment, + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + // Per-cell mids are suppressed (table-level provides the grid). + expect(element.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + + // Ring inset by outer rule + gap = 4, borders 2px. + const ring = element.querySelector('.superdoc-compound-border-midring') as HTMLElement; + expect(ring).toBeTruthy(); + expect(ring.style.left).toBe('4px'); + expect(ring.style.top).toBe('4px'); + expect(ring.style.borderTop).toMatch(/2px solid/); + expect(ring.style.borderLeft).toMatch(/2px solid/); + + // Interior vertical center strip: centered on the gridline (x=100), spanning + // between the ring's middle rules (continuous through the row band). + const verticals = [...element.querySelectorAll('.superdoc-compound-border-midv')] as HTMLElement[]; + expect(verticals.length).toBe(1); + expect(verticals[0].style.left).toBe('99px'); + expect(verticals[0].style.width).toBe('2px'); + expect(verticals[0].style.top).toBe('4px'); + expect(verticals[0].style.height).toBe(`${40 - 8}px`); + + // Interior horizontal center strip: at row boundary + midOffset, full width + // between the ring's middle rules. + const horizontals = [...element.querySelectorAll('.superdoc-compound-border-midh')] as HTMLElement[]; + expect(horizontals.length).toBe(1); + expect(horizontals[0].style.top).toBe('24px'); + expect(horizontals[0].style.height).toBe('2px'); + expect(horizontals[0].style.left).toBe('4px'); + expect(horizontals[0].style.width).toBe(`${200 - 8}px`); + }); + it('suppresses child chrome when table containerSdt shares id-less metadata', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', @@ -2106,4 +2342,233 @@ describe('renderTableFragment', () => { expect(positions).toEqual([0, 100]); }); }); + + describe('interior row boundary gap strips (SD-3028)', () => { + // Word paints the boundary between two rows as ONE continuous line across the UNION of + // both rows' extents (300dpi probes: gridBefore/gridAfter slivers render with insideH). + // Cells below own their own span; segments with a cell above but none below get a strip. + const para = (id: string) => ({ kind: 'paragraph' as const, id: id as BlockId, runs: [] }); + const measuredCell = (gridColumnStart: number, colSpan: number, width: number, rowSpan = 1) => ({ + paragraph: { kind: 'paragraph' as const, lines: [], totalHeight: 20 }, + width, + height: 20, + gridColumnStart, + colSpan, + rowSpan, + }); + const fragmentFor = (block: TableBlock, width: number, height: number, toRow: number): TableFragment => ({ + kind: 'table', + blockId: block.id, + fromRow: 0, + toRow, + x: 0, + y: 0, + width, + height, + }); + const render = (block: TableBlock, measure: TableMeasure, fragment: TableFragment) => + renderTableFragment({ + doc, + fragment, + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + const gapStrips = (el: HTMLElement): HTMLElement[] => + Array.from(el.querySelectorAll('.superdoc-row-boundary-gap')) as HTMLElement[]; + + it('paints insideH strips over gridBefore/gridAfter slivers of a narrower row (SD-1513)', () => { + // Grid [20, 100, 15]: row 0 spans all three columns; row 1 covers only col 1. + const block: TableBlock = { + kind: 'table', + id: 'gap-table' as BlockId, + attrs: { + borders: { + top: { style: 'single', width: 1, color: '#000000' }, + bottom: { style: 'single', width: 1, color: '#000000' }, + left: { style: 'single', width: 1, color: '#000000' }, + right: { style: 'single', width: 1, color: '#000000' }, + insideH: { style: 'single', width: 1, color: '#FF0000' }, + insideV: { style: 'single', width: 1, color: '#FF00FF' }, + }, + }, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 3, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 3, 135)], height: 20 }, + { cells: [measuredCell(1, 1, 100)], height: 20 }, + ], + columnWidths: [20, 100, 15], + totalWidth: 135, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 135, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(2); + const lefts = strips.map((s) => parseFloat(s.style.left)).sort((a, b) => a - b); + const widths = strips.map((s) => parseFloat(s.style.width)).sort((a, b) => a - b); + expect(lefts).toEqual([0, 120]); + expect(widths).toEqual([15, 20]); + for (const strip of strips) { + expect(strip.style.top).toBe('20px'); + expect(strip.style.borderTopStyle).toBe('solid'); + expect(strip.style.borderTopColor.toLowerCase()).toBe('#ff0000'); + } + // The wide cell above must NOT paint its own interior bottom (the strip + the cell + // below own the boundary); painting it too would double the line. + const aboveCell = el.children[0] as HTMLElement; + expect(aboveCell.style.borderBottomWidth).toBe(''); + }); + + it('paints the uncovered span with the above cell own bottom border when there are no table borders (SD-3345 callout)', () => { + // The 23_notification shape: a full-width callout cell with its own borders above a + // narrower row (gridAfter). The strip closes the bottom-right corner in the callout color. + const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; + const block: TableBlock = { + kind: 'table', + id: 'callout-table' as BlockId, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { + id: 'callout' as BlockId, + colSpan: 2, + attrs: { borders: { top: blue, left: blue, right: blue, bottom: blue } }, + paragraph: para('p0'), + }, + ], + }, + { id: 'r1' as BlockId, cells: [{ id: 'opt' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 200)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(1); + expect(strips[0].style.left).toBe('100px'); + expect(strips[0].style.width).toBe('100px'); + expect(strips[0].style.top).toBe('20px'); + expect(strips[0].style.borderTopColor.toLowerCase()).toBe('#342d8c'); + // The callout does not also paint its interior bottom (single line, no doubling). + const callout = el.children[0] as HTMLElement; + expect(callout.style.borderBottomWidth).toBe(''); + }); + + it('does not paint a strip inside a rowspan crossing the boundary', () => { + const block: TableBlock = { + kind: 'table', + id: 'span-table' as BlockId, + attrs: { + borders: { + insideH: { style: 'single', width: 1, color: '#FF0000' }, + }, + }, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { id: 'tall' as BlockId, rowSpan: 2, paragraph: para('p0') }, + { id: 'top' as BlockId, paragraph: para('p1') }, + ], + }, + { id: 'r1' as BlockId, cells: [{ id: 'under' as BlockId, paragraph: para('p2') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 1, 100, 2), measuredCell(1, 1, 100)], height: 20 }, + { cells: [measuredCell(1, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + // Col 0 is a vMerge (no edge), col 1 is covered below (the cell paints its own top). + expect(gapStrips(el).length).toBe(0); + }); + + it('mirrors strip positions for RTL tables', () => { + const block: TableBlock = { + kind: 'table', + id: 'rtl-gap-table' as BlockId, + attrs: { + tableProperties: { rightToLeft: true }, + borders: { + insideH: { style: 'single', width: 1, color: '#FF0000' }, + }, + }, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 2, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 150)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 50], + totalWidth: 150, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 150, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(1); + // Logical gap is cols [1,2) = x 100..150; mirrored: left = 150 - 100 - 50 = 0. + expect(strips[0].style.left).toBe('0px'); + expect(strips[0].style.width).toBe('50px'); + }); + + it('paints no strip when neither the above cell nor the table defines a border for the edge', () => { + const block: TableBlock = { + kind: 'table', + id: 'borderless-gap-table' as BlockId, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 2, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 200)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + expect(gapStrips(el).length).toBe(0); + }); + }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 38989cf2b5..834ea7a727 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -5,10 +5,11 @@ import type { ParagraphBlock, SdtMetadata, TableBlock, + TableBorders, TableFragment, TableMeasure, } from '@superdoc/contracts'; -import { getTableVisualDirection } from '@superdoc/contracts'; +import { getTableVisualDirection, getBorderBandProfile, isNativeCssDoubleStyle } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; @@ -19,11 +20,21 @@ import { getSdtContainerKey, getSdtContainerMetadata, hasExplicitSdtContainerKey, + resolveRenderedSdtBoundary, type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; -import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; +import { + bevelToneSpec, + applyBorder, + borderValueToSpec, + hasExplicitCellBorders, + isExplicitNoneBorder, + isPresentBorder, + resolveTableBorderValue, +} from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; +import { buildColumnOccupancy, computeBoundaryGapSegments } from './row-boundary-gaps.js'; type ApplyStylesFn = (el: HTMLElement, styles: Partial) => void; /** @@ -233,6 +244,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } const contentLeft = tableBorderWidths?.left ?? 0; const contentTop = tableBorderWidths?.top ?? 0; + const effectiveSdtBoundary = resolveRenderedSdtBoundary(block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); // Apply SDT container styling (document sections, structured content blocks) if ( @@ -241,7 +253,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container, block.attrs?.sdt, block.attrs?.containerSdt, - sdtBoundary, + effectiveSdtBoundary, { ancestorContainerKey, ancestorContainerSdt, @@ -424,11 +436,41 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + // Word's separate-borders model also applies at spacing 0: edges stack, cells paint all four + // sides, and outset/inset render as the legacy HTML bevel (SD-3028, 300dpi probes). + const separateBorders = borderCollapse === 'separate'; if (borderCollapse === 'separate' && tableBorders) { - applyBorder(container, 'Top', borderValueToSpec(tableBorders.top)); - applyBorder(container, 'Right', borderValueToSpec(isRtl ? tableBorders.left : tableBorders.right)); - applyBorder(container, 'Bottom', borderValueToSpec(tableBorders.bottom)); - applyBorder(container, 'Left', borderValueToSpec(isRtl ? tableBorders.right : tableBorders.left)); + // The table frame renders raised for outset (visual top/left light, bottom/right dark), + // the inverse of its cells; inset mirrors. Other styles pass through unchanged. (SD-3028) + applyBorder(container, 'Top', bevelToneSpec(borderValueToSpec(tableBorders.top), 'top', 'table')); + applyBorder( + container, + 'Right', + bevelToneSpec(borderValueToSpec(isRtl ? tableBorders.left : tableBorders.right), 'right', 'table'), + ); + applyBorder(container, 'Bottom', bevelToneSpec(borderValueToSpec(tableBorders.bottom), 'bottom', 'table')); + applyBorder( + container, + 'Left', + bevelToneSpec(borderValueToSpec(isRtl ? tableBorders.right : tableBorders.left), 'left', 'table'), + ); + // A compound table border (double/triple/thinThick*) is painted by the nested-rectangle + // outline + middle-grid overlay below, exactly as cell compound borders are. Keep the + // container CSS border WIDTH (so separate-mode gap geometry is unchanged) but make its + // color transparent on compound sides, or applyBorder's solid band would render a filled + // slab under the overlay rules. (SD-3028 review) + for (const [cssSide, value] of [ + ['Top', tableBorders.top], + ['Right', isRtl ? tableBorders.left : tableBorders.right], + ['Bottom', tableBorders.bottom], + ['Left', isRtl ? tableBorders.right : tableBorders.left], + ] as const) { + const spec = borderValueToSpec(value); + // `double` paints via native CSS (two rules); only true overlay styles go transparent. + if (spec && getBorderBandProfile(spec) && !isNativeCssDoubleStyle(spec.style)) { + container.style[`border${cssSide}Color` as 'borderTopColor'] = 'transparent'; + } + } } // Pre-calculate all row heights for rowspan calculations @@ -482,9 +524,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRow: r > 0 ? block.rows[r - 1] : undefined, prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, - nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], - nextRowOccupiedRightCol: rowOccupiedRightCols[r + 1], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -634,10 +675,15 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } // Render body rows (fromRow to toRow) + // Interior row boundary Ys, collected for the fragment-level compound middle grid and + // the row-boundary gap strips. + const interiorRowBoundaries: Array<{ y: number; belowRowIndex: number }> = []; for (let r = fragment.fromRow; r < fragment.toRow; r += 1) { const rowMeasure = measure.rows[r]; if (!rowMeasure) break; + if (r > fragment.fromRow) interiorRowBoundaries.push({ y, belowRowIndex: r }); + const isFirstRenderedBodyRow = r === fragment.fromRow; const isLastRenderedBodyRow = r === fragment.toRow - 1; @@ -656,9 +702,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRow: r > 0 ? block.rows[r - 1] : undefined, prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, - nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], - nextRowOccupiedRightCol: rowOccupiedRightCols[r + 1], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -689,5 +734,195 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement y += actualRowHeight + cellSpacingPx; } + // Word paints a compound table border (double, triple, thinThick*) as an outer + // OUTLINE rule at the table boundary plus each cell's inner rectangle (see + // appendCompoundBorderRects). The outline rule is the band's OUTER-face rule + // (profile segments[0]). Continuation fragments skip the broken edge. (SD-3308) + { + const sides = [ + ['top', tableBorders?.top, fragment.continuesFromPrev !== true], + ['right', isRtl ? tableBorders?.left : tableBorders?.right, true], + ['bottom', tableBorders?.bottom, fragment.continuesOnNext !== true], + ['left', isRtl ? tableBorders?.right : tableBorders?.left, true], + ] as const; + let outlineEl: HTMLElement | null = null; + for (const [side, value, enabled] of sides) { + if (!enabled || value == null || typeof value !== 'object') continue; + const spec = value as { style?: string; color?: string }; + const profile = getBorderBandProfile(value); + if (!profile) continue; + // `double` renders via the boundary cells' native CSS border (two equal rules); + // drawing it here too would stack a third rule. Only true multi-rule overlay styles + // (triple/thinThick*) need the outline. (SD-3028) + if (isNativeCssDoubleStyle(spec.style)) continue; + const rule = Math.max(1, Math.round(profile.segments[0])); + const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; + if (!outlineEl) { + outlineEl = doc.createElement('div'); + outlineEl.className = 'superdoc-compound-border-outline'; + const st = outlineEl.style; + st.position = 'absolute'; + st.inset = '0'; + st.boxSizing = 'border-box'; + st.pointerEvents = 'none'; + container.appendChild(outlineEl); + } + const cssSide = side[0].toUpperCase() + side.slice(1); + outlineEl.style[`border${cssSide}` as 'borderTop'] = `${rule}px solid ${color}`; + } + } + + // Middle layer of table-level 3-rule bands (triple, thinThickThin*): Word paints + // it as a CONTINUOUS grid, measured from 300dpi probes: a ring inset by + // outer rule + gap from the table boundary, plus full-length center strips per + // interior gridline that run unbroken through perpendicular band crossings and + // meet the ring squarely. Per-cell middle rectangles are suppressed for these + // sides (see renderTableRow). Interior vertical strips sit centered on the + // gridline (the band straddles it); interior horizontal strips sit at the + // band's middle inside the lower row. (SD-3308) + { + const midProfileOf = (value: unknown) => { + if (value == null || typeof value !== 'object') return null; + const profile = getBorderBandProfile(value as never); + return profile && profile.segments.length === 5 ? profile : null; + }; + const colorOf = (value: unknown): string => { + const c = (value as { color?: string } | null)?.color; + return c && /^#[0-9A-Fa-f]{6}$/.test(c) ? c : '#000000'; + }; + const midOffsetOf = (profile: { segments: number[] }): number => + Math.round(profile.segments[0] + profile.segments[1]); + const midRuleOf = (profile: { segments: number[] }): number => Math.max(1, Math.round(profile.segments[2])); + + const topBorder = tableBorders?.top; + const bottomBorder = tableBorders?.bottom; + const leftBorder = isRtl ? tableBorders?.right : tableBorders?.left; + const rightBorder = isRtl ? tableBorders?.left : tableBorders?.right; + const topMid = fragment.continuesFromPrev !== true ? midProfileOf(topBorder) : null; + const bottomMid = fragment.continuesOnNext !== true ? midProfileOf(bottomBorder) : null; + const leftMid = midProfileOf(leftBorder); + const rightMid = midProfileOf(rightBorder); + const insideHMid = midProfileOf(tableBorders?.insideH); + const insideVMid = midProfileOf(tableBorders?.insideV); + + const fragmentWidth = fragment.width; + const fragmentHeight = fragment.height; + const ringTopInset = topMid ? midOffsetOf(topMid) : 0; + const ringBottomInset = bottomMid ? midOffsetOf(bottomMid) : 0; + const ringLeftInset = leftMid ? midOffsetOf(leftMid) : 0; + const ringRightInset = rightMid ? midOffsetOf(rightMid) : 0; + + if (topMid || bottomMid || leftMid || rightMid) { + const ring = doc.createElement('div'); + ring.className = 'superdoc-compound-border-midring'; + const rs = ring.style; + rs.position = 'absolute'; + rs.boxSizing = 'border-box'; + rs.pointerEvents = 'none'; + rs.left = `${ringLeftInset}px`; + rs.top = `${ringTopInset}px`; + rs.width = `${fragmentWidth - ringLeftInset - ringRightInset}px`; + rs.height = `${fragmentHeight - ringTopInset - ringBottomInset}px`; + if (topMid) rs.borderTop = `${midRuleOf(topMid)}px solid ${colorOf(topBorder)}`; + if (bottomMid) rs.borderBottom = `${midRuleOf(bottomMid)}px solid ${colorOf(bottomBorder)}`; + if (leftMid) rs.borderLeft = `${midRuleOf(leftMid)}px solid ${colorOf(leftBorder)}`; + if (rightMid) rs.borderRight = `${midRuleOf(rightMid)}px solid ${colorOf(rightBorder)}`; + container.appendChild(ring); + } + + const appendStrip = (className: string, l: number, t: number, w: number, h: number, color: string): void => { + const strip = doc.createElement('div'); + strip.className = className; + const ss = strip.style; + ss.position = 'absolute'; + ss.pointerEvents = 'none'; + ss.left = `${l}px`; + ss.top = `${t}px`; + ss.width = `${w}px`; + ss.height = `${h}px`; + ss.background = color; + container.appendChild(strip); + }; + + if (insideVMid && effectiveColumnWidths.length > 1) { + const rule = midRuleOf(insideVMid); + const color = colorOf(tableBorders?.insideV); + let cum = 0; + for (let i = 0; i < effectiveColumnWidths.length - 1; i += 1) { + cum += effectiveColumnWidths[i]; + const gx = isRtl ? fragmentWidth - cum : cum; + appendStrip( + 'superdoc-compound-border-midv', + Math.round(gx - rule / 2), + ringTopInset, + rule, + fragmentHeight - ringTopInset - ringBottomInset, + color, + ); + } + } + + if (insideHMid && interiorRowBoundaries.length > 0) { + const rule = midRuleOf(insideHMid); + const color = colorOf(tableBorders?.insideH); + for (const { y: gy } of interiorRowBoundaries) { + appendStrip( + 'superdoc-compound-border-midh', + ringLeftInset, + Math.round(gy + midOffsetOf(insideHMid)), + fragmentWidth - ringLeftInset - ringRightInset, + rule, + color, + ); + } + } + } + + // Word paints an interior row boundary as ONE continuous line across the UNION of the two + // adjacent rows' extents (300dpi probes: gridBefore/gridAfter slivers render with insideH). + // Cells in the row below own and paint their top across their own span; segments with a + // cell above but none below are closed here as positioned strips, so the line never doubles + // and never stops short of a wider row's edge. (SD-3028 / SD-1513) + if (cellSpacingPx === 0 && !separateBorders && interiorRowBoundaries.length > 0 && block.rows?.length) { + const occupancy = buildColumnOccupancy(measure.rows, effectiveColumnWidths.length); + const columnX: number[] = [0]; + for (const width of effectiveColumnWidths) columnX.push(columnX[columnX.length - 1] + width); + + for (const { y: boundaryY, belowRowIndex } of interiorRowBoundaries) { + for (const segment of computeBoundaryGapSegments(occupancy, belowRowIndex)) { + // A rowspan cell that started before this fragment is rendered as a ghost cell, + // which already paints its own bottom edge. + if (segment.aboveCell.rowIndex < fragment.fromRow) continue; + + const aboveCell = block.rows[segment.aboveCell.rowIndex]?.cells?.[segment.aboveCell.cellIndex]; + const boundaryRowBorders = block.rows[belowRowIndex - 1]?.attrs?.borders; + const effectiveInsideH = boundaryRowBorders + ? ({ ...(tableBorders ?? {}), ...boundaryRowBorders } as TableBorders).insideH + : tableBorders?.insideH; + const cellBottom = aboveCell?.attrs?.borders?.bottom; + const spec = isExplicitNoneBorder(cellBottom) + ? undefined + : resolveTableBorderValue(cellBottom, effectiveInsideH); + if (!isPresentBorder(spec)) continue; + + const x = columnX[segment.startCol]; + const width = columnX[segment.endColExclusive] - x; + if (width <= 0) continue; + + const strip = doc.createElement('div'); + strip.className = 'superdoc-row-boundary-gap'; + const ss = strip.style; + ss.position = 'absolute'; + ss.pointerEvents = 'none'; + ss.left = `${isRtl ? fragment.width - x - width : x}px`; + ss.top = `${boundaryY}px`; + ss.width = `${width}px`; + ss.height = '0'; + applyBorder(strip, 'Top', spec); + container.appendChild(strip); + } + } + } + return container; }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index a7b79102ca..9a33035ea8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -292,11 +292,264 @@ describe('renderTableRow', () => { expect(secondCall.borders?.left).toBeDefined(); }); + // SD-3028: `double` renders via the native CSS `border-style: double` (two equal rules + + // gap), which matches Word exactly (300dpi probes). It is NOT routed through the multi-rule + // nested-rectangle overlay (that path forced the CSS border transparent and repainted a + // single inner rule, collapsing the double to one line). So the cell keeps a real `double` + // border and no compound rect is emitted. triple/thinThick* still use the overlay. + it('renders double borders via native CSS, not the single-rule compound overlay', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'double', width: 2, color: '#000000' }, + bottom: { style: 'double', width: 2, color: '#000000' }, + left: { style: 'double', width: 2, color: '#000000' }, + right: { style: 'double', width: 2, color: '#000000' }, + }, + }) as never, + ); + + // No overlay rects/strips for a plain double — the browser draws the two rules. + expect(container.querySelectorAll('.superdoc-compound-border-rect').length).toBe(0); + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + // The cell receives a real `double` border (renderTableCell applies native CSS double). + const cellArgs = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: { style?: string } } }; + expect(cellArgs.borders?.top?.style).toBe('double'); + }); + + // SD-3308: asymmetric 2-rule bands. thinThickSmallGap = [w, 0.75pt, 0.75pt] outer + // to inner (measured from Word 300dpi probes): the inner rectangle paints the + // INNER-face rule (1px), the outline paints the outer-face rule. + it('paints thinThickSmallGap with the inner-face rule width on the inner rectangle', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + bottom: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + left: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + right: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + }, + }) as never, + ); + + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(1); + const rect = rects[0] as HTMLElement; + // band 6 (4+1+1), inner rule 1: rule sits band - rule = 5px inside the owned edges. + expect(rect.style.left).toBe('5px'); + expect(rect.style.top).toBe('5px'); + expect(rect.style.borderTop).toMatch(/1px solid/); + expect(rect.style.borderLeft).toMatch(/1px solid/); + // 2-rule band: no middle strips + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + }); + + // SD-3028 (Gabriel review, style_plus_direct_border_overrides + matrixB): an INTERIOR + // bottom compound border (cell is not the last row, so it does not own the bottom band) + // must keep its overlay rect INSIDE the cell box. The prior code used bottomInset = + // -outerRule, which pushed the rect ~outerRule px BELOW the cell, bleeding the double + // rule into the row below. The fix mirrors the interior VERTICAL divider (band/2 - + // outerRule), straddling the gridline instead of overshooting past it. + it('keeps an interior-bottom compound overlay rect inside the cell box (no downward bleed)', () => { + const cellHeight = 20; // rowMeasure.height + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 2, // row 0 of 2 -> bottom edge is INTERIOR (does not own bottom band) + cellSpacingPx: 0, + tableBorders: undefined, + row: { + id: 'row-1', + cells: [ + { + id: 'cell-1', + attrs: { + borders: { + top: { style: 'double', width: 2, color: '#FF0000' }, + bottom: { style: 'double', width: 2, color: '#FF0000' }, + left: { style: 'double', width: 2, color: '#FF0000' }, + right: { style: 'double', width: 2, color: '#FF0000' }, + }, + }, + blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }], + }, + ], + }, + }) as never, + ); + + const rects = Array.from(container.querySelectorAll('.superdoc-compound-border-rect')) as HTMLElement[]; + expect(rects.length).toBeGreaterThan(0); + for (const rect of rects) { + const top = parseFloat(rect.style.top) || 0; + const height = parseFloat(rect.style.height) || 0; + // The overlay must not extend below the cell's bottom edge (was top+height = 22 > 20). + expect(top + height).toBeLessThanOrEqual(cellHeight); + } + }); + + // SD-3308: 3-rule bands (triple = [w, w, w, w, w]) add a middle RECTANGLE between + // the outline and the inner rectangle (Word's 300dpi corner crops show three clean + // nested boxes; full-edge strips would protrude across the outer and inner rings). + // Cell-level borders here: table-level 3-rule borders paint their middle layer as + // a continuous fragment-level grid instead (see renderTableFragment). + it('paints triple borders as inner rectangle plus a middle rectangle on owned edges', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: undefined, + row: { + id: 'row-1', + cells: [ + { + id: 'cell-1', + attrs: { + borders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }, + blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }], + }, + ], + }, + }) as never, + ); + + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(1); + const rect = rects[0] as HTMLElement; + // band 10 (2+2+2+2+2), inner rule 2: rule sits band - rule = 8px inside. + expect(rect.style.left).toBe('8px'); + expect(rect.style.top).toBe('8px'); + expect(rect.style.borderTop).toMatch(/2px solid/); + + // The middle rule is ONE bordered rectangle inset by outer rule + gap = 4px, + // so its corners join cleanly instead of crossing the other rings. + const mids = container.querySelectorAll('.superdoc-compound-border-mid'); + expect(mids.length).toBe(1); + const mid = mids[0] as HTMLElement; + expect(mid.style.left).toBe('4px'); + expect(mid.style.top).toBe('4px'); + // 100x20 cell inset 4px on each side + expect(mid.style.width).toBe('92px'); + expect(mid.style.height).toBe('12px'); + expect(mid.style.borderTop).toMatch(/2px solid/); + expect(mid.style.borderBottom).toMatch(/2px solid/); + expect(mid.style.borderLeft).toMatch(/2px solid/); + expect(mid.style.borderRight).toMatch(/2px solid/); + }); + + // SD-3308: table-level 3-rule borders paint their middle layer at the FRAGMENT + // level (continuous grid through band intersections, measured from Word), so the + // per-cell middle rectangle must not double-paint it. + it('suppresses the per-cell middle rectangle when table-level borders provide the grid', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }) as never, + ); + + expect(container.querySelectorAll('.superdoc-compound-border-rect').length).toBe(1); + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + }); + + // SD-3308: Word centers an interior compound band ON the gridline (measured from + // the triple probe: the divider spans gridline -band/2 .. +band/2 and both cells + // keep equal content widths). Each adjacent cell carries HALF the band as its + // transparent CSS border, and the inner rectangles place their divider-facing + // rules at the straddled band's faces. + it('straddles an interior vertical compound band across the gridline', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + columnWidths: [100, 100], + rowMeasure: { + height: 20, + cells: [ + { width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }, + { width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 }, + ], + }, + row: { + id: 'row-1', + cells: [ + { id: 'cell-1', blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }] }, + { id: 'cell-2', blocks: [{ kind: 'paragraph', id: 'p2', runs: [] }] }, + ], + }, + tableBorders: { + top: { style: 'double', width: 2, color: '#000000' }, + bottom: { style: 'double', width: 2, color: '#000000' }, + left: { style: 'double', width: 2, color: '#000000' }, + right: { style: 'double', width: 2, color: '#000000' }, + insideV: { style: 'double', width: 2, color: '#000000' }, + }, + }) as never, + ); + + // The interior vertical divider keeps the straddle: each cell carries half the divider + // band (6/2 = 3px) on its divider-facing side, and each draws one rule via the overlay + // rect — together the two rules form the double centered on the gridline. (SD-3028: only + // the straddled interior-vertical double stays in the overlay; full-band sides are native.) + const callA = renderTableCellMock.mock.calls[0][0] as { + borders?: { left?: { style?: string }; right?: unknown }; + borderBandOverridesPx?: { left?: number; right?: number }; + }; + const callB = renderTableCellMock.mock.calls[1][0] as { + borders?: { left?: unknown; right?: { style?: string } }; + borderBandOverridesPx?: { left?: number; right?: number }; + }; + expect(callA.borders?.right).toBeDefined(); + expect(callA.borderBandOverridesPx?.right).toBe(3); + expect(callB.borders?.left).toBeDefined(); + expect(callB.borderBandOverridesPx?.left).toBe(3); + // Boundary sides render via native CSS double (not the overlay): they reach renderTableCell + // as a real `double` border, with no half-band override. + expect(callA.borders?.left?.style).toBe('double'); + expect(callA.borderBandOverridesPx?.left).toBeUndefined(); + expect(callB.borders?.right?.style).toBe('double'); + expect(callB.borderBandOverridesPx?.right).toBeUndefined(); + + // Two overlay rects: one per cell, drawing only the straddled interior divider rule. + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(2); + const rectA = rects[0] as HTMLElement; + const rectB = rects[1] as HTMLElement; + // cellA: only the right (divider) rule; left/top/bottom boundaries are native CSS. + expect(rectA.style.borderRightWidth).not.toBe(''); + expect(rectA.style.borderLeftWidth).toBe(''); + // cellB: only the left (divider) rule; right boundary is native CSS. Its rule sits just + // past the gridline (100 + band/2 - innerRule = 101). + expect(rectB.style.left).toBe('101px'); + expect(rectB.style.borderLeftWidth).not.toBe(''); + expect(rectB.style.borderRightWidth).toBe(''); + }); + // SD-1797: a single row's measure only lists cells that START in it, so on a w:vMerge // (rowspan) continuation row the columns held by a cell spanning from above look empty. - // `rowOccupiedRightCol` / `nextRowOccupiedRightCol` count that occupancy so the single-owner - // edge ownership doesn't misfire (a leftmost cell drawing a right border, or a covered column - // mistaken for a gridAfter gap) and double the shared edge. + // `rowOccupiedRightCol` counts that occupancy so the single-owner edge ownership doesn't + // misfire (a leftmost cell drawing a right border) and double the shared edge. const sparseRow = (overrides: Record = {}) => createDeps({ rowIndex: 2, @@ -318,13 +571,14 @@ describe('renderTableRow', () => { expect(call.borders?.left).toBeDefined(); }); - it('does not treat a rowspan-covered column below a spanning cell as a gridAfter gap', () => { - // A cell spanning all 4 columns; the row below is fully covered (occupancy 4) -> no gap, - // so the spanning cell does NOT draw its own bottom (the cell below owns the shared edge). + it('never paints an interior bottom on a spanning cell, even over a gridAfter gap below', () => { + // Interior bottoms are always owned by the row below; boundary segments the row below + // leaves uncovered (gridBefore/gridAfter slivers) are closed by fragment-level gap strips + // (row-boundary-gaps.ts), never by this cell painting its full-width bottom — that would + // double the covered part of the edge (this painter has no border-collapse). (SD-3028) renderTableRow( sparseRow({ rowMeasure: { height: 20, cells: [{ width: 400, height: 20, gridColumnStart: 0, colSpan: 4, rowSpan: 1 }] }, - nextRowOccupiedRightCol: 4, }) as never, ); @@ -332,20 +586,6 @@ describe('renderTableRow', () => { expect(call.borders?.bottom).toBeUndefined(); }); - it('still treats a genuine gridAfter gap as a bottom boundary (SD-3345 preserved)', () => { - // The cell spans all 4 columns but the row below only reaches column 2 (real gridAfter gap), - // so the spanning cell must draw its own bottom across the uncovered span. - renderTableRow( - sparseRow({ - rowMeasure: { height: 20, cells: [{ width: 400, height: 20, gridColumnStart: 0, colSpan: 4, rowSpan: 1 }] }, - nextRowOccupiedRightCol: 2, - }) as never, - ); - - const call = getRenderedCellCall(); - expect(call.borders?.bottom).toBeDefined(); - }); - it('does not paint interior bottom border for explicit cell borders in collapsed mode on non-final row', () => { const explicit = { top: { style: 'single' as const, width: 2, color: '#123456' }, @@ -686,12 +926,12 @@ describe('renderTableRow', () => { expect(cell.borders?.top).toMatchObject({ style: 'single', color: '#000000' }); }); - it('draws its own bottom border when the next row leaves a gridAfter gap under it (SD-3345 callout corner)', () => { - // SD-3345 23_notification: the callout cell spans the full grid (gridSpan), but the - // row below has a gridAfter so its real cells do not reach the callout's rightmost - // column. Single-owner would defer the callout's bottom to the row below, which then - // stops short of the right edge → a gap at the bottom-right corner. The callout must - // draw its own bottom across the uncovered span instead. + it('keeps the interior bottom on the spanning callout suppressed even over a gridAfter gap below (SD-3345)', () => { + // SD-3345 23_notification: the callout cell spans the full grid, the row below has a + // gridAfter. The covered span of the shared edge is painted by the row below (its top + // resolves to the §17.4.66 winner, the callout blue), and the uncovered sliver is + // closed by a fragment-level gap strip (row-boundary-gaps.ts) — never by the callout + // painting its full-width bottom, which would double the covered part. (SD-3028) const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; renderTableRow( createDeps({ @@ -712,21 +952,17 @@ describe('renderTableRow', () => { }, ], }, - // next row covers only col0 (col1 is a gridAfter spacer → not a real cell) - nextRowMeasure: { - height: 20, - cells: [{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }], - }, }) as never, ); const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { bottom?: unknown } }; - expect(cell.borders?.bottom).toMatchObject({ style: 'single', color: '#342D8C' }); + expect(cell.borders?.bottom).toBeUndefined(); }); - it('suppresses this row top border when the cell above spans past it (gridAfter) so the edge is drawn once', () => { - // The companion to the case above: when the spanning cell owns the shared bottom edge, - // the narrower row below must NOT also draw its top, or the two adjacent cell divs stack - // into a doubled line (this painter has no border-collapse). (SD-3345 callout) + it('paints this row top as the conflict winner when the cell above spans past it (single line, below owns)', () => { + // The narrower row below a spanning bordered cell owns the covered span of the shared + // edge: its top resolves to the §17.4.66 winner of (own top, callout bottom) — the + // callout blue. The uncovered gridAfter sliver is closed by a fragment-level gap strip. + // Exactly one paint per segment: no doubling, no dropped corner. (SD-3028) const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; renderTableRow( createDeps({ @@ -741,7 +977,7 @@ describe('renderTableRow', () => { id: 'r1', cells: [{ id: 'opt', attrs: {}, blocks: [{ kind: 'paragraph', id: 'po', runs: [] }] }], }, - // the cell above spans BOTH columns and has a bottom border (it owns the shared edge) + // the cell above spans BOTH columns and has a bottom border prevRow: { id: 'r0', cells: [ @@ -758,8 +994,8 @@ describe('renderTableRow', () => { }, }) as never, ); - const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: unknown } } | undefined; - expect(cell?.borders?.top).toBeUndefined(); + const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: unknown } }; + expect(cell.borders?.top).toMatchObject({ style: 'single', color: '#342D8C' }); }); }); @@ -881,6 +1117,100 @@ describe('renderTableRow', () => { }); }); + describe('separate-borders mode (authored tblCellSpacing, even 0) (SD-3028)', () => { + // Word probes (300dpi): with w:tblCellSpacing present every cell paints all four edges + // (own border, else the table border for its position) and adjacent edges STACK; outset + // cells render sunken: visual top/left dark #A0A0A0, bottom/right light #F0F0F0. + it('paints all four edges on an interior cell so adjacent edges stack like Word', () => { + renderTableRow( + createDeps({ + rowIndex: 3, + totalRows: 10, + cellSpacingPx: 0, + separateBorders: true, + }) as never, + ); + + const call = getRenderedCellCall(); + expect(call.borders?.top).toBeDefined(); + expect(call.borders?.bottom).toBeDefined(); + expect(call.borders?.left).toBeDefined(); + expect(call.borders?.right).toBeDefined(); + }); + + it('tones outset cell edges sunken: top dark, bottom light', () => { + renderTableRow( + createDeps({ + rowIndex: 3, + totalRows: 10, + cellSpacingPx: 0, + separateBorders: true, + tableBorders: { + top: { style: 'outset', width: 1, color: '#000000' }, + bottom: { style: 'outset', width: 1, color: '#000000' }, + left: { style: 'outset', width: 1, color: '#000000' }, + right: { style: 'outset', width: 1, color: '#000000' }, + insideH: { style: 'outset', width: 1, color: '#000000' }, + insideV: { style: 'outset', width: 1, color: '#000000' }, + }, + }) as never, + ); + + const call = getRenderedCellCall(); + expect(call.borders?.top).toMatchObject({ style: 'single', color: '#A0A0A0' }); + expect(call.borders?.bottom).toMatchObject({ style: 'single', color: '#F0F0F0' }); + expect(call.borders?.left).toMatchObject({ color: '#A0A0A0' }); + expect(call.borders?.right).toMatchObject({ color: '#F0F0F0' }); + }); + + it('keeps collapsed single-owner behavior when no cell spacing is authored', () => { + renderTableRow(createDeps({ rowIndex: 3, totalRows: 10, cellSpacingPx: 0 }) as never); + + const call = getRenderedCellCall(); + // Interior bottom owned by the row below in the collapsed model. + expect(call.borders?.bottom).toBeUndefined(); + }); + }); + + describe('explicitly borderless cells in a compound-bordered table (SD-3308 review)', () => { + // A cell whose `borders` attribute is present but clears every side is intentionally + // borderless. Even in a table with compound (triple) table borders, the + // nested-rectangle compound path must NOT draw the rules onto it. (triple, not double: + // a plain double renders via native CSS and never uses the overlay rects.) + const compoundTableBorders = { + top: { style: 'triple' as const, width: 2, color: '#000000' }, + bottom: { style: 'triple' as const, width: 2, color: '#000000' }, + left: { style: 'triple' as const, width: 2, color: '#000000' }, + right: { style: 'triple' as const, width: 2, color: '#000000' }, + }; + + it('draws compound rects for a normal cell (control)', () => { + renderTableRow( + createDeps({ rowIndex: 0, totalRows: 1, cellSpacingPx: 0, tableBorders: compoundTableBorders }) as never, + ); + expect(container.querySelectorAll('.superdoc-compound-border-rect').length).toBe(1); + }); + + it('draws NO compound rects for a cell with an empty borders attribute', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: compoundTableBorders, + row: { + id: 'row-1', + cells: [{ id: 'c1', attrs: { borders: {} }, blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }] }], + }, + }) as never, + ); + expect(container.querySelectorAll('.superdoc-compound-border-rect').length).toBe(0); + // and the cell itself stays borderless (no CSS border resolved) + const call = renderTableCellMock.mock.calls[0][0] as { borders?: unknown }; + expect(call.borders).toBeUndefined(); + }); + }); + describe('structural row tracked changes', () => { const trackedRowDeps = ( kind: 'insert' | 'delete', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 8288888f0a..7ac9672a9c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -9,6 +9,7 @@ import type { TableBorders, TableMeasure, } from '@superdoc/contracts'; +import { getBorderBandProfile, isNativeCssDoubleStyle } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { renderTableCell } from './renderTableCell.js'; import { @@ -20,6 +21,7 @@ import { isPresentBorder, isExplicitNoneBorder, swapCellBordersLR, + bevelToneSpec, } from './border-utils.js'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; import { resolveTrackedChangesConfig, applyRowTrackedChangeToCell } from '../runs/tracked-changes.js'; @@ -45,18 +47,12 @@ type CellBorderResolutionArgs = { /** Borders of the cell directly to the right (same row, next grid column), for asymmetric-edge ownership. */ rightCellBorders?: CellBorders; /** - * True when the next row's real cells do not reach this cell's right edge (e.g. the next - * row has a `w:gridAfter` spacer while this cell spans into it). The cell below then can't - * own the shared bottom edge across the uncovered span, so this cell must draw its own - * bottom border or the line stops short at the bottom-right corner. (SD-3345) + * True when the table authored `w:tblCellSpacing` (even 0), which switches Word to the + * separate-borders model: every cell paints all four edges from its own/table borders and + * adjacent edges STACK (300dpi probes render single sz=6 boundaries 2x wide, SD-3028). + * Single-owner suppression does not apply. */ - nextRowLeavesRightGap?: boolean; - /** - * True when the cell ABOVE spans past this cell's row right edge (this row has a gridAfter - * relative to it). The spanning cell owns the shared bottom edge and draws it, so this cell - * must suppress its top border to avoid a doubled line. (SD-3345) - */ - deferTopToAboveCell?: boolean; + separateBorders?: boolean; /** * True when the row BELOW has a tblPrEx border override that suppresses its shared horizontal * edge (insideH none/nil). The lower cell owns that edge but won't draw it, so a present @@ -87,22 +83,19 @@ const resolveRenderedCellBorders = ({ aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, + separateBorders, nextRowSuppressesSharedTop, }: CellBorderResolutionArgs): CellBorders | undefined => { const hasExplicitBorders = hasExplicitCellBorders(cellBorders); const cellBounds = getTableCellGridBounds(cellPosition); const touchesTopBoundary = cellBounds.touchesTopEdge || continuesFromPrev; - // The bottom is a real boundary either when this is the last row / a fragment break, OR - // when the next row's real cells don't reach this cell's right edge (a gridAfter spacer - // under a spanning cell): the row below can't own the shared edge across the uncovered - // span, so this (spanning) cell owns and draws its full-width bottom. The row below then - // suppresses its top there (see `deferTopToAboveCell`) so the edge is drawn exactly once — - // this painter has no border-collapse, so two cells drawing it would stack into a doubled - // line, not overlap. (SD-3345) - const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext || nextRowLeavesRightGap === true; + // Interior bottoms are always owned by the row below: each cell there paints its own top, + // and boundary segments the row below leaves uncovered (gridBefore/gridAfter slivers) are + // closed by fragment-level gap strips (see row-boundary-gaps.ts), never by this cell + // painting its full-width bottom — this painter has no border-collapse, so two cells + // drawing one edge stack into a doubled line. (SD-3345, SD-3028) + const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext; // A shared interior edge in the collapsed model is owned by the lower/right cell, so a // border defined ONLY by the neighbor above/left must still be painted here — even when @@ -110,7 +103,7 @@ const resolveRenderedCellBorders = ({ // suppressed its own edge under single-owner). (SD-2969: a bordered clause-header row // above a fully borderless spacer row.) const hasInteriorNeighborBorder = - (!touchesTopBoundary && !deferTopToAboveCell && isPresentBorder(aboveCellBorders?.bottom)) || + (!touchesTopBoundary && isPresentBorder(aboveCellBorders?.bottom)) || (!cellBounds.touchesLeftEdge && isPresentBorder(leftCellBorders?.right)); // Collapsed model (zero cell spacing): single-owner positioning, where the value at a @@ -121,20 +114,35 @@ const resolveRenderedCellBorders = ({ // (undefined, x) === x). Interior right/bottom are owned by the neighbor to the right/below; // outer edges use the cell border (which beats the table border), falling back to the table // border. Works whether or not table-level borders exist. (SD-3345, SD-2969) + // Authored `w:tblCellSpacing` (even 0) = Word's separate-borders model: each cell paints + // all four edges (own border, else the table outer/inside border for its position) and + // adjacent cell edges stack into a double-width line exactly like Word renders them. + // Spacing > 0 keeps the legacy branches below (visible gaps, probe-verified earlier). + if (separateBorders && cellSpacingPx === 0) { + const cb = (cellBorders ?? {}) as CellBorders; + return { + top: resolveTableBorderValue(cb.top, touchesTopBoundary ? tableBorders?.top : tableBorders?.insideH), + right: resolveTableBorderValue( + cb.right, + cellBounds.touchesRightEdge ? tableBorders?.right : tableBorders?.insideV, + ), + bottom: resolveTableBorderValue(cb.bottom, touchesBottomBoundary ? tableBorders?.bottom : tableBorders?.insideH), + left: resolveTableBorderValue(cb.left, cellBounds.touchesLeftEdge ? tableBorders?.left : tableBorders?.insideV), + }; + } + if (cellSpacingPx === 0 && (hasExplicitBorders || hasInteriorNeighborBorder)) { const cb = (cellBorders ?? {}) as CellBorders; return { top: touchesTopBoundary ? resolveTableBorderValue(cb.top, tableBorders?.top) - : deferTopToAboveCell - ? undefined - : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? - // Both sides not present: an explicit nil on BOTH adjacent cells suppresses the - // shared horizontal edge (§17.4.66); only inherit the table insideH when at least - // one side is merely unset. (SD-3028) - (isExplicitNoneBorder(cb.top) && isExplicitNoneBorder(aboveCellBorders?.bottom) - ? undefined - : borderValueToSpec(tableBorders?.insideH))), + : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? + // Both sides not present: an explicit nil on BOTH adjacent cells suppresses the + // shared horizontal edge (§17.4.66); only inherit the table insideH when at least + // one side is merely unset. (SD-3028) + (isExplicitNoneBorder(cb.top) && isExplicitNoneBorder(aboveCellBorders?.bottom) + ? undefined + : borderValueToSpec(tableBorders?.insideH))), // Vertical interior edges: when BOTH adjacent cells declare a border, the right cell // owns it (draws its left as the §17.4.66 winner) so the edge is painted once (no // doubling). When only ONE side declares a border (asymmetric, no doubling risk) that @@ -243,8 +251,6 @@ type TableRowRenderDependencies = { /** Next (below) row data, to detect a row-level border override that suppresses the shared * horizontal edge so the current row closes the grid itself (§17.4.61/§17.4.66). */ nextRow?: TableRow; - /** Next (below) row measure, to detect a gridAfter gap under a spanning cell (SD-3345). */ - nextRowMeasure?: TableRowMeasure; /** * Rightmost occupied grid column (exclusive) for THIS row, counting cells that span into it * via w:vMerge (rowspan) from an earlier row. Falls back to this row's own cells when absent. @@ -252,9 +258,8 @@ type TableRowRenderDependencies = { * column. (SD-1797) */ rowOccupiedRightCol?: number; - /** Same as {@link rowOccupiedRightCol} for the NEXT row, so a rowspan continuation below is - * not mistaken for a gridAfter gap (which would double the shared bottom edge). (SD-1797) */ - nextRowOccupiedRightCol?: number; + /** Authored `w:tblCellSpacing` present (even 0): Word separate-borders model (SD-3028). */ + separateBorders?: boolean; /** Total number of rows in the table (for border resolution) */ totalRows: number; /** Table-level borders (for resolving cell borders) */ @@ -365,6 +370,159 @@ type TableRowRenderDependencies = { * // Appends all cell elements to container * ``` */ +/** + * Paints a cell's compound borders (double, triple, thinThick*) the way Word does: + * as a single-rule INNER RECTANGLE per cell, connected with square L-joins at the + * corners (verified against 300dpi Word renders). A band's rules sit at fixed + * positions measured from Word (see contracts getBorderBandProfile): the inner-face + * rule belongs to this cell's rectangle, the outer-face rule belongs to the table + * outline (outer edges) or to the neighboring cell's rectangle (interior edges), + * and a 3-rule band's middle rule is a centered strip per owned edge (strips span + * the full edge so they join squarely at corners, forming Word's middle rectangle). + * CSS compound borders cannot do this: they miter diagonally and their band hugs + * the owning cell, so junctions render as crossings instead of closed boxes. + * + * The cell keeps its CSS border with a TRANSPARENT color so border-box layout + * (content inset, band reservation) is unchanged. For each compound side, the + * rectangle's rule sits at the inner face of that side's band: inset (band - rule) + * on sides whose band lives in this cell (top/left always, bottom/right at table + * boundaries), and the OUTER-face rule extended past the box on interior + * bottom/right sides whose band lives in the neighboring cell. The table outline + * rules are painted by renderTableFragment. (SD-3308) + */ +const appendCompoundBorderRects = ( + doc: Document, + container: HTMLElement, + cellElement: HTMLElement, + borders: CellBorders | undefined, + rect: { x: number; y: number; width: number; height: number }, + edges: { + ownsBottomBand: boolean; + /** Visual right side is the table boundary (band fully inside this cell). */ + rightIsBoundary: boolean; + /** Visual left side is the table boundary (band fully inside this cell). */ + leftIsBoundary: boolean; + /** Sides whose 3-rule middle layer is painted by the fragment grid instead. */ + suppressMid?: { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean }; + }, +): void => { + if (!borders) return; + const { ownsBottomBand, rightIsBoundary, leftIsBoundary, suppressMid } = edges; + const sideInfo = (['top', 'right', 'bottom', 'left'] as const).map((side) => { + const spec = borders[side]; + const profile = spec ? getBorderBandProfile(spec) : null; + if (!spec || !profile) return null; + // A symmetric `double` renders via the native CSS `border-style: double` (two equal rules) + // on any FULL-BAND side: the table boundary, and the horizontal interior edge owned by this + // cell. Routing those through the overlay paints only a single inner rule (the double + // collapses to one line). The straddled interior-VERTICAL divider stays in the overlay: + // each half-cell draws one rule and together they form the double centered on the gridline. + // (SD-3028) + if (isNativeCssDoubleStyle(spec.style)) { + const fullBand = + side === 'top' || + (side === 'bottom' && ownsBottomBand) || + (side === 'left' && leftIsBoundary) || + (side === 'right' && rightIsBoundary); + if (fullBand) return null; + } + const { segments } = profile; + const band = Math.max(1, Math.round(profile.band)); + const outerRule = Math.max(1, Math.round(segments[0])); + const innerRule = Math.max(1, Math.round(segments[segments.length - 1])); + // 5 segments = 3 rules: the middle rule sits outer rule + gap inside the band. + const midRule = segments.length === 5 ? Math.max(1, Math.round(segments[2])) : 0; + const midOffset = segments.length === 5 ? Math.round(segments[0] + segments[1]) : 0; + const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; + return { side, band, outerRule, innerRule, midRule, midOffset, color }; + }); + if (!sideInfo.some(Boolean)) return; + + const x0 = Math.round(rect.x); + const y0 = Math.round(rect.y); + const x1 = Math.round(rect.x + rect.width); + const y1 = Math.round(rect.y + rect.height); + + // Hide the CSS paint for compound sides, keep the layout band. + for (const info of sideInfo) { + if (!info) continue; + const cssSide = (info.side[0].toUpperCase() + info.side.slice(1)) as 'Top' | 'Right' | 'Bottom' | 'Left'; + cellElement.style[`border${cssSide}Color`] = 'transparent'; + } + + const [top, right, bottom, left] = sideInfo; + const rectEl = doc.createElement('div'); + rectEl.className = 'superdoc-compound-border-rect'; + const st = rectEl.style; + st.position = 'absolute'; + st.boxSizing = 'border-box'; + st.pointerEvents = 'none'; + // Inner-face placement per side. Boundary band (fully inside the cell): the inner + // rule sits band - rule inside the box. Interior VERTICAL bands straddle the + // gridline (half in each cell, Word model): this cell's divider-facing rule sits + // at the straddled band's near face, band/2 - rule from the gridline (negative + // when the rule is wider than the half-band, extending past the gridline). + // Interior horizontal bands keep the owner-cell placement: the band lives in the + // lower cell's top (the row reservation already centers it visually), and the + // upper cell contributes the band's outer-face rule just past its box. + const topInset = top ? top.band - top.innerRule : 0; + const leftInset = left + ? leftIsBoundary + ? left.band - left.innerRule + : Math.round(left.band / 2) - left.innerRule + : 0; + const bottomInset = bottom + ? ownsBottomBand + ? bottom.band - bottom.innerRule + : Math.round(bottom.band / 2) - bottom.outerRule + : 0; + const rightInset = right + ? rightIsBoundary + ? right.band - right.innerRule + : Math.round(right.band / 2) - right.outerRule + : 0; + st.left = `${x0 + leftInset}px`; + st.top = `${y0 + topInset}px`; + st.width = `${x1 - x0 - leftInset - rightInset}px`; + st.height = `${y1 - y0 - topInset - bottomInset}px`; + if (top) st.borderTop = `${top.innerRule}px solid ${top.color}`; + if (bottom) st.borderBottom = `${ownsBottomBand ? bottom.innerRule : bottom.outerRule}px solid ${bottom.color}`; + if (left) st.borderLeft = `${left.innerRule}px solid ${left.color}`; + if (right) st.borderRight = `${rightIsBoundary ? right.innerRule : right.outerRule}px solid ${right.color}`; + container.appendChild(rectEl); + + // Middle rule of 3-rule bands: ONE bordered rectangle inset to the middle rule's + // position (outer rule + gap) on each OWNED 3-rule side. A box with borders joins + // cleanly at corners, matching Word's middle rectangle; full-edge strips would + // protrude across the outer and inner rings. Neighbor-owned interior sides are + // painted by the owning cell's own middle rectangle. + const midTop = top && top.midRule > 0 && !suppressMid?.top ? top : null; + const midLeft = left && left.midRule > 0 && !suppressMid?.left ? left : null; + const midBottom = bottom && bottom.midRule > 0 && ownsBottomBand && !suppressMid?.bottom ? bottom : null; + const midRight = right && right.midRule > 0 && rightIsBoundary && !suppressMid?.right ? right : null; + if (midTop || midLeft || midBottom || midRight) { + const mid = doc.createElement('div'); + mid.className = 'superdoc-compound-border-mid'; + const ms = mid.style; + ms.position = 'absolute'; + ms.boxSizing = 'border-box'; + ms.pointerEvents = 'none'; + const tIn = midTop ? midTop.midOffset : 0; + const lIn = midLeft ? midLeft.midOffset : 0; + const bIn = midBottom ? midBottom.midOffset : 0; + const rIn = midRight ? midRight.midOffset : 0; + ms.left = `${x0 + lIn}px`; + ms.top = `${y0 + tIn}px`; + ms.width = `${x1 - x0 - lIn - rIn}px`; + ms.height = `${y1 - y0 - tIn - bIn}px`; + if (midTop) ms.borderTop = `${midTop.midRule}px solid ${midTop.color}`; + if (midBottom) ms.borderBottom = `${midBottom.midRule}px solid ${midBottom.color}`; + if (midLeft) ms.borderLeft = `${midLeft.midRule}px solid ${midLeft.color}`; + if (midRight) ms.borderRight = `${midRight.midRule}px solid ${midRight.color}`; + container.appendChild(mid); + } +}; + export const renderTableRow = (deps: TableRowRenderDependencies): void => { const { doc, @@ -376,9 +534,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { prevRow, prevRowMeasure, nextRow, - nextRowMeasure, rowOccupiedRightCol, - nextRowOccupiedRightCol, + separateBorders, totalRows, tableBorders, columnWidths, @@ -564,32 +721,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { return undefined; }; - // Right edge (exclusive grid column) of the cell occupying `gridCol` in `measureCells`. - const findCellRightEdgeAtColumn = ( - measureCells: TableRowMeasure['cells'] | undefined, - gridCol: number, - ): number | undefined => { - if (!measureCells) return undefined; - for (let i = 0; i < measureCells.length; i++) { - const start = measureCells[i].gridColumnStart ?? i; - const span = measureCells[i].colSpan ?? 1; - if (gridCol >= start && gridCol < start + span) return start + span; - } - return undefined; - }; - - // Rightmost grid column (exclusive) covered by the next row's REAL cells. When a spanning - // cell's right edge exceeds this, the next row has a gridAfter spacer beneath it and can't - // own the shared bottom edge across the uncovered span. (SD-3345) - // Rowspan-aware occupied width of the next row (counts cells spanning into it); fall back to - // the next row's own cells. A covered column must not look like a gridAfter gap. (SD-1797) - const nextRowMaxCol = - nextRowOccupiedRightCol != null && nextRowOccupiedRightCol > 0 - ? nextRowOccupiedRightCol - : nextRowMeasure?.cells?.length - ? Math.max(...nextRowMeasure.cells.map((c) => (c.gridColumnStart ?? 0) + (c.colSpan ?? 1))) - : Infinity; - for (let cellIndex = 0; cellIndex < rowMeasure.cells.length; cellIndex += 1) { const cellMeasure = rowMeasure.cells[cellIndex]; const cell = row?.cells?.[cellIndex]; @@ -625,13 +756,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { // The cell to the right (same row, the column just past this cell's span) — used to keep // an asymmetric vertical edge on the owning cell instead of moving it to the neighbor. const rightCellBorders = findCellBordersAtColumn(row?.cells, rowMeasure.cells, gridColumnStart + colSpan); - // This cell spans past the next row's real cells (gridAfter spacer beneath its right edge). - const nextRowLeavesRightGap = gridColumnStart + colSpan > nextRowMaxCol; - // Conversely, the cell ABOVE spans past THIS row's right edge (this row has a gridAfter - // relative to it). The spanning cell then owns the full shared edge and draws its own - // bottom, so this cell must NOT also draw its top, or the edge doubles. (SD-3345) - const aboveCellRightEdge = findCellRightEdgeAtColumn(prevRowMeasure?.cells, gridColumnStart); - const deferTopToAboveCell = aboveCellRightEdge !== undefined && aboveCellRightEdge > rowRightEdgeCol; // Resolve borders using logical positions, then swap output for RTL. // The resolver uses touchesLeftEdge/touchesRightEdge which are LOGICAL edges. @@ -648,12 +772,23 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, + separateBorders, nextRowSuppressesSharedTop, }); // RTL: swap resolved left↔right so CSS properties match visual edges const finalBorders = isRtl && resolvedBorders ? swapCellBordersLR(resolvedBorders) : resolvedBorders; + // Separate-borders mode: outset/inset cells render sunken (the legacy HTML table look) — + // visual top/left dark, bottom/right light; inset mirrors. Toned after the RTL swap so the + // lighting follows VISUAL sides. Other styles pass through unchanged. (SD-3028, 300dpi probes) + const tonedBorders = + separateBorders && finalBorders + ? { + top: bevelToneSpec(finalBorders.top, 'top', 'cell'), + right: bevelToneSpec(finalBorders.right, 'right', 'cell'), + bottom: bevelToneSpec(finalBorders.bottom, 'bottom', 'cell'), + left: bevelToneSpec(finalBorders.left, 'left', 'cell'), + } + : finalBorders; // Calculate cell height - use rowspan height if cell spans multiple rows // For partial rows, use the partial height instead @@ -681,6 +816,68 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { x = tableContentWidth - x - computedCellWidth; } + const cellGridBounds = getTableCellGridBounds(cellPosition); + // A cell whose `borders` attribute is present but clears every side is intentionally + // borderless: `resolveRenderedCellBorders` returns undefined for it (no CSS border). + // The compound-rectangle path must honor that too, or `appendCompoundBorderRects` would + // draw the table's double/triple rules onto a cell that explicitly cleared its borders. + // Yield no effective sides so it paints nothing. (SD-3308 review) + const cellIsIntentionallyBorderless = hasBordersAttribute && !hasExplicitCellBorders(cellBordersAttr); + // Word's double model needs the EFFECTIVE border of every side of this cell, + // not the single-owner-suppressed set: ownership picks which band face the rule + // sits on, but every surrounding double edge contributes a side to this cell's + // rectangle. (SD-3308) + const cb = (cellBordersAttr ?? {}) as CellBorders; + const effectiveSideSpecs: CellBorders = cellIsIntentionallyBorderless + ? {} + : { + top: + cellGridBounds.touchesTopEdge || continuesFromPrev === true + ? resolveTableBorderValue(cb.top, effectiveTableBorders?.top) + : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? + borderValueToSpec(effectiveTableBorders?.insideH)), + bottom: + cellGridBounds.touchesBottomEdge || continuesOnNext === true + ? resolveTableBorderValue(cb.bottom, effectiveTableBorders?.bottom) + : (resolveBorderConflict(cb.bottom, undefined) ?? borderValueToSpec(effectiveTableBorders?.insideH)), + left: cellGridBounds.touchesLeftEdge + ? resolveTableBorderValue(cb.left, effectiveTableBorders?.left) + : (resolveBorderConflict(cb.left, leftCellBorders?.right) ?? + borderValueToSpec(effectiveTableBorders?.insideV)), + right: cellGridBounds.touchesRightEdge + ? resolveTableBorderValue(cb.right, effectiveTableBorders?.right) + : (resolveBorderConflict(cb.right, rightCellBorders?.left) ?? + borderValueToSpec(effectiveTableBorders?.insideV)), + }; + const rectBorders = (isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs) ?? effectiveSideSpecs; + + // Visual (post-RTL-swap) boundary flags matching rectBorders sides. + const visualTouchesLeft = isRtl ? cellGridBounds.touchesRightEdge : cellGridBounds.touchesLeftEdge; + const visualTouchesRight = isRtl ? cellGridBounds.touchesLeftEdge : cellGridBounds.touchesRightEdge; + + // Interior vertical compound bands straddle the gridline (Word model, measured + // from the triple probes: the divider spans gridline -band/2 .. +band/2 and both + // cells keep equal content widths). Each adjacent cell carries HALF the band as + // its transparent CSS border, so the painted geometry and the column's half-band + // allowance agree. Boundary bands stay fully inside the cell. (SD-3308) + const leftStraddleProfile = !visualTouchesLeft && rectBorders.left ? getBorderBandProfile(rectBorders.left) : null; + const rightStraddleProfile = + !visualTouchesRight && rectBorders.right ? getBorderBandProfile(rectBorders.right) : null; + let paintBorders = tonedBorders; + let borderBandOverridesPx: { left?: number; right?: number } | undefined; + if (leftStraddleProfile || rightStraddleProfile) { + paintBorders = { ...(tonedBorders ?? {}) }; + borderBandOverridesPx = {}; + if (leftStraddleProfile) { + paintBorders.left = rectBorders.left; + borderBandOverridesPx.left = leftStraddleProfile.band / 2; + } + if (rightStraddleProfile) { + paintBorders.right = rectBorders.right; + borderBandOverridesPx.right = rightStraddleProfile.band / 2; + } + } + // Never use default borders - cells are either explicitly styled or borderless // This prevents gray borders on cells with borders={} (intentionally borderless) const { cellElement } = renderTableCell({ @@ -690,7 +887,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { rowHeight: cellHeight, cellMeasure, cell, - borders: finalBorders, + borders: paintBorders, + borderBandOverridesPx, useDefaultBorder: false, renderLine, captureLineSnapshot, @@ -718,5 +916,57 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { } container.appendChild(cellElement); + + // Table-level 3-rule bands paint their middle layer as a continuous fragment + // grid (see renderTableFragment); suppress the per-cell middle rectangle there. + const tableProvidesMid = (value: unknown): boolean => { + const profile = value != null && typeof value === 'object' ? getBorderBandProfile(value as never) : null; + return profile != null && profile.segments.length === 5; + }; + const suppressMid = { + top: tableProvidesMid( + cellGridBounds.touchesTopEdge || continuesFromPrev === true + ? effectiveTableBorders?.top + : effectiveTableBorders?.insideH, + ), + bottom: tableProvidesMid( + cellGridBounds.touchesBottomEdge || continuesOnNext === true + ? effectiveTableBorders?.bottom + : effectiveTableBorders?.insideH, + ), + left: tableProvidesMid( + visualTouchesLeft + ? isRtl + ? effectiveTableBorders?.right + : effectiveTableBorders?.left + : effectiveTableBorders?.insideV, + ), + right: tableProvidesMid( + visualTouchesRight + ? isRtl + ? effectiveTableBorders?.left + : effectiveTableBorders?.right + : effectiveTableBorders?.insideV, + ), + }; + + appendCompoundBorderRects( + doc, + container, + cellElement, + rectBorders, + { + x, + y, + width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), + height: cellHeight, + }, + { + ownsBottomBand: cellGridBounds.touchesBottomEdge || continuesOnNext === true, + rightIsBoundary: visualTouchesRight, + leftIsBoundary: visualTouchesLeft, + suppressMid, + }, + ); } }; diff --git a/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts new file mode 100644 index 0000000000..d074cc9b32 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { buildColumnOccupancy, computeBoundaryGapSegments } from './row-boundary-gaps.js'; + +describe('buildColumnOccupancy', () => { + it('maps plain cells to their grid columns', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [{ gridColumnStart: 0, colSpan: 2 }] }, + ], + 2, + ); + expect(occupancy[0][0]).toEqual({ rowIndex: 0, cellIndex: 0 }); + expect(occupancy[0][1]).toEqual({ rowIndex: 0, cellIndex: 1 }); + expect(occupancy[1][0]).toEqual({ rowIndex: 1, cellIndex: 0 }); + expect(occupancy[1][1]).toBe(occupancy[1][0]); + }); + + it('leaves gridBefore/gridAfter columns unoccupied', () => { + // Row 1 skips col 0 (gridBefore) and col 3 (gridAfter): one merged cell over cols 1-2. + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 0, colSpan: 4 }] }, { cells: [{ gridColumnStart: 1, colSpan: 2 }] }], + 4, + ); + expect(occupancy[1][0]).toBeNull(); + expect(occupancy[1][1]).toEqual({ rowIndex: 1, cellIndex: 0 }); + expect(occupancy[1][2]).toBe(occupancy[1][1]); + expect(occupancy[1][3]).toBeNull(); + }); + + it('marks rowspan coverage on continuation rows with the same ref', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1, rowSpan: 2 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 1 }] }, + ], + 2, + ); + expect(occupancy[1][0]).toBe(occupancy[0][0]); + expect(occupancy[1][1]).toEqual({ rowIndex: 1, cellIndex: 0 }); + }); +}); + +describe('computeBoundaryGapSegments', () => { + it('returns no segments when the row below fully covers the row above', () => { + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 0, colSpan: 2 }] }, { cells: [{ gridColumnStart: 0, colSpan: 2 }] }], + 2, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([]); + }); + + it('finds the gridBefore and gridAfter slivers under a wider row (SD-1513 shape)', () => { + // Above: five cells over the full 7-col grid. Below: gridBefore=1, merged span 5, gridAfter=1. + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 2 }, + { gridColumnStart: 2, colSpan: 1 }, + { gridColumnStart: 3, colSpan: 1 }, + { gridColumnStart: 4, colSpan: 1 }, + { gridColumnStart: 5, colSpan: 2 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 5 }] }, + ], + 7, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 0, endColExclusive: 1, aboveCell: { rowIndex: 0, cellIndex: 0 } }, + { startCol: 6, endColExclusive: 7, aboveCell: { rowIndex: 0, cellIndex: 4 } }, + ]); + }); + + it('returns no segment where neither row has a cell (gridBefore in both rows)', () => { + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 1, colSpan: 2 }] }, { cells: [{ gridColumnStart: 1, colSpan: 2 }] }], + 3, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([]); + }); + + it('does not produce a segment inside a rowspan crossing the boundary', () => { + // Col 0 is a vMerge crossing the boundary: same cell above and below -> no edge, no strip. + // Col 2 of the above row has nothing below (gridAfter) -> strip. + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1, rowSpan: 2 }, + { gridColumnStart: 1, colSpan: 2 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 1 }] }, + ], + 3, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 2, endColExclusive: 3, aboveCell: { rowIndex: 0, cellIndex: 1 } }, + ]); + }); + + it('splits adjacent gap columns owned by different above cells into separate segments', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [] }, + ], + 2, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 0, endColExclusive: 1, aboveCell: { rowIndex: 0, cellIndex: 0 } }, + { startCol: 1, endColExclusive: 2, aboveCell: { rowIndex: 0, cellIndex: 1 } }, + ]); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts new file mode 100644 index 0000000000..69ceae254d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts @@ -0,0 +1,93 @@ +/** + * Interior row boundary coverage for the single-owner border model. + * + * Word paints the horizontal boundary between two rows as ONE continuous line across the + * UNION of both rows' cell extents (verified with 300dpi probes: when one row is narrower, + * e.g. via `w:gridBefore`/`w:gridAfter`, the uncovered slivers still render, and with the + * table's insideH border). In this painter each cell in the row BELOW owns and paints its + * top across its own span, so boundary segments that have a cell ABOVE but none BELOW are + * painted by nobody. These helpers identify exactly those segments so the fragment renderer + * can close them with positioned strips, without ever doubling a line that a cell below + * already paints. (SD-3028 / SD-1513) + */ + +/** Identifies a measured cell by the row it STARTS in and its index within that row. */ +export interface BoundaryCellRef { + rowIndex: number; + cellIndex: number; +} + +interface MeasuredCellLike { + gridColumnStart?: number; + colSpan?: number; + rowSpan?: number; +} + +interface MeasuredRowLike { + cells?: readonly MeasuredCellLike[] | null; +} + +/** + * Builds the per-row grid column occupancy map, including columns covered by cells that + * span into a row via rowspan (`w:vMerge`). `occupancy[r][c]` is the cell covering grid + * column `c` on row `r`, or null when no cell covers it (a gridBefore/gridAfter region). + */ +export const buildColumnOccupancy = ( + rows: ReadonlyArray, + numCols: number, +): (BoundaryCellRef | null)[][] => { + const occupancy: (BoundaryCellRef | null)[][] = rows.map(() => new Array(numCols).fill(null)); + rows.forEach((row, rowIndex) => { + row?.cells?.forEach((cell, cellIndex) => { + const startCol = cell.gridColumnStart ?? 0; + const endCol = Math.min(numCols, startCol + (cell.colSpan ?? 1)); + const endRow = Math.min(rows.length, rowIndex + (cell.rowSpan ?? 1)); + const ref: BoundaryCellRef = { rowIndex, cellIndex }; + for (let r = rowIndex; r < endRow; r += 1) { + for (let c = startCol; c < endCol; c += 1) { + occupancy[r][c] = ref; + } + } + }); + }); + return occupancy; +}; + +/** A run of grid columns on a row boundary covered above but not below. */ +export interface BoundaryGapSegment { + startCol: number; + endColExclusive: number; + /** The cell whose bottom edge forms this segment (its borders resolve the strip). */ + aboveCell: BoundaryCellRef; +} + +/** + * Segments of the boundary ABOVE `belowRowIndex` where a cell ends from above but no cell + * exists below. A rowspan cell crossing the boundary occupies both sides with the same ref, + * so it never produces a segment (there is no edge inside a vertical merge). Contiguous + * columns sharing the same above cell merge into one segment. + */ +export const computeBoundaryGapSegments = ( + occupancy: ReadonlyArray>, + belowRowIndex: number, +): BoundaryGapSegment[] => { + const above = occupancy[belowRowIndex - 1]; + const below = occupancy[belowRowIndex]; + if (!above || !below) return []; + + const segments: BoundaryGapSegment[] = []; + let current: BoundaryGapSegment | null = null; + for (let c = 0; c < above.length; c += 1) { + const aboveCell = above[c]; + const isGap = aboveCell !== null && below[c] === null; + if (isGap && current && current.aboveCell === aboveCell) { + current.endColExclusive = c + 1; + } else if (isGap) { + current = { startCol: c, endColExclusive: c + 1, aboveCell: aboveCell as BoundaryCellRef }; + segments.push(current); + } else { + current = null; + } + } + return segments; +}; diff --git a/packages/layout-engine/style-engine/src/ooxml/index.test.ts b/packages/layout-engine/style-engine/src/ooxml/index.test.ts index 7cd3505210..1963cedd37 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -60,6 +60,79 @@ describe('ooxml - resolveStyleChain', () => { const result = resolveStyleChain('runProperties', params, 'MissingStyle'); expect(result).toEqual({}); }); + + it('uses canonical built-in heading properties for localized heading style conflicts', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + Kop1: { + type: 'paragraph', + styleId: 'Kop1', + name: 'heading 1', + runProperties: { fontSize: 20, bold: true, color: { val: '000000' } }, + paragraphProperties: { spacing: { after: 120 } }, + }, + Heading1: { + type: 'paragraph', + styleId: 'Heading1', + name: 'heading 1', + runProperties: { fontSize: 32, bold: true, color: { val: '1F4E79' } }, + paragraphProperties: { spacing: { after: 240 }, keepNext: true }, + }, + }, + }, + }); + + expect(resolveStyleChain('runProperties', params, 'Kop1')).toEqual({ + fontSize: 32, + bold: true, + color: { val: '1F4E79' }, + }); + expect(resolveParagraphProperties(params, { styleId: 'Kop1' }, null)).toEqual({ + styleId: 'Kop1', + spacing: { after: 240 }, + keepNext: true, + indent: undefined, + }); + }); + + it('resolves basedOn references through the canonical heading mapping for derived styles', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + MyHeading: { + type: 'paragraph', + styleId: 'MyHeading', + name: 'My Heading', + basedOn: 'Kop1', + runProperties: { italic: true }, + }, + Kop1: { + type: 'paragraph', + styleId: 'Kop1', + name: 'heading 1', + runProperties: { fontSize: 20, bold: true, color: { val: '000000' } }, + }, + Heading1: { + type: 'paragraph', + styleId: 'Heading1', + name: 'heading 1', + runProperties: { fontSize: 32, color: { val: '1F4E79' } }, + }, + }, + }, + }); + + // A custom style based on the localized `Kop1` must inherit the canonical + // `Heading1` formatting, not the literal small/black `Kop1` definition. + expect(resolveStyleChain('runProperties', params, 'MyHeading')).toEqual({ + fontSize: 32, + color: { val: '1F4E79' }, + italic: true, + }); + }); }); describe('ooxml - getNumberingProperties', () => { @@ -246,9 +319,11 @@ describe('ooxml - resolveRunProperties', () => { numCells: 2, }; const result = resolveRunProperties(params, {}, null, tableInfo); - expect(result.fontSize).toBe(15); - expect(result.bold).toBe(true); - expect(result.italic).toBe(true); + expect(result.fontSize).toBe(15); // nwCell (corner) is highest priority + expect(result.bold).toBe(true); // wholeTable + expect(result.italic).toBe(true); // band1Horz + // Word applies COLUMN banding over row banding (verified vs Word renders, SD-3028), + // so band1Vert's color wins over band1Horz's. expect(result.color).toEqual({ val: 'CCCCCC' }); }); it('does not treat paragraph mark run properties as inherited text styling', () => { @@ -421,6 +496,40 @@ describe('ooxml - resolveCellStyles', () => { const result = resolveCellStyles('runProperties', tableInfo, params.translatedLinkedStyles!); expect(result).toEqual([{ fontSize: 10 }, { fontSize: 50 }]); }); + + // SD-3028 (Gabriel review): Word applies COLUMN banding over row banding (verified by 150dpi + // Word renders of first_row_styling / conditional_style_regions: interior cells paint + // band1Vert/band2Vert #EEEEEE/#FAFAFA, not band1Horz/band2Horz). When both band axes are + // active, the vertical band must be the highest-priority (last) entry. + it('applies column banding over row banding when both axes are active (matches Word)', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + TableBandBoth: { + type: 'table', + tableProperties: { tableStyleRowBandSize: 1, tableStyleColBandSize: 1 }, + tableStyleProperties: { + wholeTable: { runProperties: { fontSize: 10 } }, + band1Horz: { runProperties: { fontSize: 40 } }, + band1Vert: { runProperties: { fontSize: 20 } }, + }, + }, + }, + }, + }); + const tableInfo = { + // both band axes active (noHBand/noVBand unset), no edge flags + tableProperties: { tableStyleId: 'TableBandBoth', tblLook: {} }, + rowIndex: 0, + cellIndex: 0, + numRows: 4, + numCells: 4, + }; + const result = resolveCellStyles('runProperties', tableInfo, params.translatedLinkedStyles!); + // Array is low -> high; the highest-priority (last) entry must be the column band. + expect(result[result.length - 1]).toEqual({ fontSize: 20 }); // band1Vert wins over band1Horz + }); }); describe('ooxml - resolveTableCellProperties', () => { @@ -498,6 +607,47 @@ describe('ooxml - resolveTableCellProperties', () => { expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'EEEEEE' }); }); + // SD-3028 (Gabriel review, conditional_style_regions): the fixture declares 3 gridCol but + // each row has 4 cells. Word auto-extends the grid to 4; SuperDoc must treat the actual cell + // count as the column boundary so lastCol/ne/se apply ONLY to the true last column, not to + // every column whose end reaches the (under-declared) grid width. + it('uses actual cell count as the last-column boundary when the grid under-declares columns', () => { + const styles = { + ...emptyStyles, + styles: { + UnderGrid: { + type: 'table', + tableProperties: { tableStyleRowBandSize: 1, tableStyleColBandSize: 1 }, + tableStyleProperties: { + wholeTable: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'FFFFFF' } } }, + lastCol: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'AAAAAA' } } }, + }, + }, + }, + }; + const base = { + tableProperties: { tableStyleId: 'UnderGrid', tblLook: { lastColumn: true, noHBand: true, noVBand: true } }, + rowIndex: 1, + numRows: 4, + numCells: 4, + numGridCols: 3, // grid under-declares: 3 cols for 4 cells + }; + // 3rd cell (grid col 2, end 3): NOT the last column once the grid is reconciled to 4. + const col3 = resolveTableCellProperties( + null, + { ...base, cellIndex: 2, gridColumnStart: 2, gridColumnSpan: 1 }, + styles, + ); + expect(col3.shading).toEqual({ val: 'clear', color: 'auto', fill: 'FFFFFF' }); + // 4th cell (grid col 3, end 4): the true last column. + const col4 = resolveTableCellProperties( + null, + { ...base, cellIndex: 3, gridColumnStart: 3, gridColumnSpan: 1 }, + styles, + ); + expect(col4.shading).toEqual({ val: 'clear', color: 'auto', fill: 'AAAAAA' }); + }); + it('inline cell shading overrides style shading', () => { const tableInfo = { tableProperties: { @@ -886,6 +1036,92 @@ describe('ooxml - resolveTableCellProperties basedOn tblStylePr inheritance', () }); }); +// ────────────────────────────────────────────────────────────────────────────── +// Style base-level tcPr as the wholeTable layer (ECMA-376 17.7.6, SD-3035) +// A table style's base-level is stored on the style +// def's own tableCellProperties (sibling of tableStyleProperties) and IS the +// wholeTable conditional layer. Word paints it on every cell. +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - style base-level tcPr surfaces as wholeTable (SD-3035)', () => { + const interiorCell = (styleId: string) => ({ + tableProperties: { tableStyleId: styleId, tblLook: { noHBand: true, noVBand: true } }, + rowIndex: 1, + cellIndex: 1, + numRows: 3, + numCells: 3, + }); + + it('resolves a base-level shading with no explicit wholeTable region', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'F2F2F2' }); + }); + + it('leaf base-level shading beats an ancestor base-level shading via basedOn', () => { + const styles = { + ...emptyStyles, + styles: { + BaseStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'AAAAAA' } }, + }, + LeafStyle: { + type: 'table', + basedOn: 'BaseStyle', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('LeafStyle'), styles); + expect(result.shading).toEqual({ fill: 'F2F2F2' }); + }); + + it('an explicit tableStyleProperties.wholeTable entry beats the base-level tcPr', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'BASE99' } }, + tableStyleProperties: { + wholeTable: { tableCellProperties: { shading: { fill: 'EXPL77' } } }, + }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ fill: 'EXPL77' }); + }); + + it('inline cell shading still wins over the base-level wholeTable fill', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties({ shading: { fill: '4472C4' } }, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ fill: '4472C4' }); + }); +}); + // ────────────────────────────────────────────────────────────────────────────── // cnfStyle supplementing index-based conditional type detection // ────────────────────────────────────────────────────────────────────────────── @@ -1330,3 +1566,143 @@ describe('ooxml - corner cell gating matches Word behavior', () => { expect(fills).toContain('NE'); }); }); + +/** + * SD-3028 G7: conditional firstCol/lastCol regions are GRID positions in Word, + * not display-cell indices. gridSpan, vMerge continuations (merged away at + * import), and gridBefore placeholders all shift display indices off the grid, + * landing edge styling one column early. TableInfo carries optional grid + * positions; display indices remain the fallback for legacy callers. + * + * Fixture evidence: merged_cells_with_styles.docx, Word render 2026-06-06 + * (B3 stays unshaded; the lastCol green follows grid column 3 through the + * vMerge; the gridSpan firstRow cell keeps firstCol at grid start). + */ +describe('grid-position conditional regions (SD-3028 G7)', () => { + const condGridStyles = { + ...emptyStyles, + styles: { + CondGrid: { + type: 'table', + tableStyleProperties: { + firstCol: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'FFFF00' } } }, + lastCol: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: '92D050' } } }, + }, + }, + }, + }; + + const tableInfoBase = { + tableProperties: { + tableStyleId: 'CondGrid', + tblLook: { firstRow: false, lastRow: false, firstColumn: true, lastColumn: true, noHBand: true, noVBand: true }, + }, + numRows: 3, + }; + + it('does not mark a middle cell lastCol when a vMerge hides the trailing display cell', () => { + // Row 3 of the fixture: display cells [A3, B3] because C3 is a vMerge + // continuation. B3 is display-last but sits at grid column 1 of 3. + const tableInfo = { + ...tableInfoBase, + rowIndex: 2, + cellIndex: 1, + numCells: 2, + gridColumnStart: 1, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toBeUndefined(); + }); + + it('marks lastCol when the cell grid span reaches the last grid column', () => { + // The vMerge restart cell in column C: display index 2, grid columns 2..3. + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 2, + numCells: 3, + gridColumnStart: 2, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: '92D050' }); + }); + + it('keeps firstCol by grid start when a placeholder shifts the display index', () => { + // A gridBefore placeholder makes the first REAL cell display index 1, but + // it still starts at grid column 0. + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 1, + numCells: 3, + gridColumnStart: 0, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'FFFF00' }); + }); + + it('falls back to display indices when grid positions are absent', () => { + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 2, + numCells: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: '92D050' }); + }); +}); + +/** + * SD-3028 G5 remainder, DISPROVEN and locked: a table STYLE's table-level + * shading (w:tblPr > w:shd) does NOT fill cells in Word. Measured from the + * nested_tables_with_styles.docx Word render (NestedSage style carries + * and no tcPr shading): the inner cells + * render pure white (zero C6E0B4 pixels); only the style's borders and run + * formatting apply. Cell fills come from the style's base tcPr (the + * wholeTable layer), conditional regions, or inline cell shading. + */ +describe('table style tblPr shading stays off cells (SD-3028 G5, Word-verified)', () => { + const tableInfo = { + tableProperties: { tableStyleId: 'NestedSage', tblLook: { noHBand: true, noVBand: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 2, + numCells: 2, + }; + + it('does not paint the style table-level shading onto cells', () => { + const styles = { + ...emptyStyles, + styles: { + NestedSage: { + type: 'table', + tableProperties: { shading: { val: 'clear', color: 'auto', fill: 'C6E0B4' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toBeUndefined(); + }); + + it('still fills cells from the style base tcPr when both shadings exist', () => { + const styles = { + ...emptyStyles, + styles: { + NestedSage: { + type: 'table', + tableProperties: { shading: { val: 'clear', color: 'auto', fill: 'C6E0B4' } }, + tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'F2F2F2' }); + }); +}); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0791ee9472..92eab612ca 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -49,6 +49,17 @@ export interface TableInfo { numRows: number; rowCnfStyle?: ParagraphConditionalFormatting | null; cellCnfStyle?: ParagraphConditionalFormatting | null; + /** + * Grid position of the cell (SD-3028 G7). Word's firstCol/lastCol/banding + * regions are GRID columns, not display-cell indices: gridSpan, vMerge + * continuations (merged away at import), and gridBefore placeholders all + * shift display indices off the grid. When absent, display indices are used. + */ + gridColumnStart?: number | null; + /** Grid columns covered by the cell. Defaults to 1. */ + gridColumnSpan?: number | null; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number | null; } /** @@ -64,6 +75,47 @@ export const DEFAULT_TBL_LOOK: TableLookProperties = { noVBand: true, }; +const BUILT_IN_HEADING_NAME_RE = /^heading\s+([1-9])$/i; + +function getBuiltInHeadingLevel(styleDef: StyleDefinition | undefined): number | undefined { + if (styleDef?.type !== 'paragraph' || typeof styleDef.name !== 'string') { + return undefined; + } + + const match = BUILT_IN_HEADING_NAME_RE.exec(styleDef.name.trim()); + if (!match?.[1]) { + return undefined; + } + + return Number(match[1]); +} + +function resolveStyleDefinition( + params: OoxmlResolverParams, + styleId: string, +): { styleId: string; styleDef: StyleDefinition } | undefined { + const styles = params.translatedLinkedStyles?.styles; + const styleDef = styles?.[styleId]; + if (!styles || !styleDef) { + return undefined; + } + + const headingLevel = getBuiltInHeadingLevel(styleDef); + const canonicalHeadingStyleId = headingLevel ? `Heading${headingLevel}` : null; + const canonicalHeadingStyleDef = canonicalHeadingStyleId ? styles[canonicalHeadingStyleId] : undefined; + + if ( + canonicalHeadingStyleId && + canonicalHeadingStyleId !== styleId && + canonicalHeadingStyleDef && + getBuiltInHeadingLevel(canonicalHeadingStyleDef) === headingLevel + ) { + return { styleId: canonicalHeadingStyleId, styleDef: canonicalHeadingStyleDef }; + } + + return { styleId, styleDef }; +} + export function resolveRunProperties( params: OoxmlResolverParams, inlineRpr: RunProperties | null | undefined, @@ -291,21 +343,29 @@ export function resolveStyleChain( ): T { if (!styleId) return {} as T; - const styleDef = params.translatedLinkedStyles?.styles?.[styleId]; - if (!styleDef) return {} as T; + const resolvedStyle = resolveStyleDefinition(params, styleId); + if (!resolvedStyle) return {} as T; + const { styleDef } = resolvedStyle; const styleProps = (styleDef[propertyType as keyof typeof styleDef] ?? {}) as T; const basedOn = styleDef.basedOn; let styleChain: T[] = [styleProps]; - const seenStyles = new Set([styleId]); + const seenStyles = new Set([styleId, resolvedStyle.styleId]); let nextBasedOn = basedOn; while (followBasedOnChain && nextBasedOn) { if (seenStyles.has(nextBasedOn as string)) { break; } seenStyles.add(nextBasedOn as string); - const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn]; + // Resolve each parent through the canonical heading mapping so derived styles + // based on a localized heading (e.g. `MyHeading` basedOn `Kop1`) inherit the + // canonical `Heading1` formatting rather than the literal localized definition. + const resolvedBasedOn = resolveStyleDefinition(params, nextBasedOn as string); + const basedOnStyleDef = resolvedBasedOn?.styleDef; + if (resolvedBasedOn) { + seenStyles.add(resolvedBasedOn.styleId); + } const basedOnProps = basedOnStyleDef?.[propertyType as keyof typeof basedOnStyleDef] as T; if (basedOnProps && Object.keys(basedOnProps).length) { @@ -483,6 +543,15 @@ function resolveConditionalProps( const def: StyleDefinition | undefined = translatedLinkedStyles.styles?.[currentId]; const props = def?.tableStyleProperties?.[styleType]?.[propertyType] as T | undefined; if (props) chain.push(props); + // ECMA-376 17.7.6: a table style's BASE-LEVEL (stored on the def's own + // tableCellProperties, a sibling of tableStyleProperties) IS the wholeTable + // conditional layer; Word paints e.g. its w:shd on every cell. Pushed after the + // explicit wholeTable entry so, post-reverse, the explicit entry still wins within + // one def while a leaf's base props beat any ancestor's. (SD-3035) + if (styleType === 'wholeTable' && propertyType === 'tableCellProperties') { + const baseProps = def?.tableCellProperties as T | undefined; + if (baseProps) chain.push(baseProps); + } currentId = def?.basedOn; } if (chain.length === 0) return undefined; @@ -511,6 +580,9 @@ export function resolveCellStyles( colBandSize, tableInfo.rowCnfStyle, tableInfo.cellCnfStyle, + tableInfo.gridColumnStart, + tableInfo.gridColumnSpan, + tableInfo.numGridCols, ); cellStyleTypes.forEach((styleType) => { const typeProps = resolveConditionalProps(propertyType, styleType, tableStyleId, translatedLinkedStyles); @@ -572,9 +644,14 @@ const CNF_STYLE_MAP: ReadonlyArray<[keyof ParagraphConditionalFormatting, TableS ['lastRowLastColumn', 'seCell'], ]; -// Word / Office precedence order (low → high), per MS-OI29500 §2.1.1310. -// combineProperties treats later entries as higher priority, so this array -// must list types from lowest to highest override strength. +// Conditional-format apply order (low → high). combineProperties treats later +// entries as higher priority. NOTE: this intentionally diverges from the literal +// ECMA-376 §17.7.6.6 list (which puts banded rows after banded columns). Word's +// ACTUAL render applies COLUMN banding over row banding and column edges over row +// edges — verified by 150dpi Word renders of first_row_styling and +// conditional_style_regions, whose interior cells paint band1Vert/band2Vert +// (#EEEEEE/#FAFAFA), not band1Horz/band2Horz (SD-3028, Gabriel review). Match Word, +// not the spec text. const TABLE_STYLE_PRECEDENCE: TableStyleType[] = [ 'wholeTable', 'band1Horz', @@ -601,16 +678,31 @@ function determineCellStyleTypes( colBandSize = 1, rowCnfStyle?: ParagraphConditionalFormatting | null, cellCnfStyle?: ParagraphConditionalFormatting | null, + gridColumnStart?: number | null, + gridColumnSpan?: number | null, + numGridCols?: number | null, ): TableStyleType[] { const applicable = new Set(['wholeTable']); const normalizedRowBandSize = rowBandSize > 0 ? rowBandSize : 1; const normalizedColBandSize = colBandSize > 0 ? colBandSize : 1; + // Column position on the GRID when the caller provides it (SD-3028 G7); + // display indices otherwise. firstCol/lastCol and vertical banding follow + // grid columns in Word, so spans and merges must not shift them. + const columnStart = gridColumnStart ?? cellIndex; + const columnEnd = columnStart + (gridColumnSpan ?? 1); + // When a row declares more cells than the table grid has columns, Word + // auto-extends the grid; trust the larger count so lastCol / corners apply + // only to the true last column, not to every cell whose end reaches the + // under-declared grid width (SD-3028). + const columnCount = + numGridCols != null && numCells != null ? Math.max(numGridCols, numCells) : (numGridCols ?? numCells); + // Per ECMA-376, banding excludes header/footer rows and first/last columns. // Offset the index so the first data row/column starts at band1. const bandRowIndex = Math.max(0, rowIndex - (tblLook?.firstRow ? 1 : 0)); - const bandColIndex = Math.max(0, cellIndex - (tblLook?.firstColumn ? 1 : 0)); + const bandColIndex = Math.max(0, columnStart - (tblLook?.firstColumn ? 1 : 0)); const rowGroup = Math.floor(bandRowIndex / normalizedRowBandSize); const colGroup = Math.floor(bandColIndex / normalizedColBandSize); @@ -625,8 +717,8 @@ function determineCellStyleTypes( // Row/column edge flags — reused for both row/col styles and corner gating. const isFirstRow = !!tblLook?.firstRow && rowIndex === 0; const isLastRow = !!tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1; - const isFirstCol = !!tblLook?.firstColumn && cellIndex === 0; - const isLastCol = !!tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1; + const isFirstCol = !!tblLook?.firstColumn && columnStart === 0; + const isLastCol = !!tblLook?.lastColumn && columnCount != null && columnCount > 0 && columnEnd >= columnCount; if (isFirstRow) applicable.add('firstRow'); if (isFirstCol) applicable.add('firstCol'); diff --git a/packages/layout-engine/tests/src/architecture-boundaries.test.ts b/packages/layout-engine/tests/src/architecture-boundaries.test.ts index e7952ae054..16b1bd67f8 100644 --- a/packages/layout-engine/tests/src/architecture-boundaries.test.ts +++ b/packages/layout-engine/tests/src/architecture-boundaries.test.ts @@ -288,18 +288,10 @@ describe('architecture boundaries', () => { }); }); - describe('Guard G: prep-001 layout boundary does not depend on v2 runtime packages', () => { + describe('Guard G: prep-001 layout boundary does not depend on editor runtime packages', () => { // Editor-neutral substrate added by `prep-001-layout-boundary-and-identity.md`. - // It must not silently pick up a dependency on any v2 runtime package — if a - // future Phase 3 plan needs to ship a v2 type through this boundary, that - // belongs in the v2 layer, not in the shared layout-engine packages. - const FORBIDDEN_V2_PACKAGES = [ - '@superdoc/editor-core', - '@superdoc/headless', - '@superdoc/v2-host', - '@superdoc/v2-layout-adapter', - '@superdoc/document-api-v2-adapter', - ]; + // It must not silently pick up a dependency on editor runtime packages. + const FORBIDDEN_RUNTIME_PACKAGES = ['@superdoc/editor-core', '@superdoc/headless']; const PREP_001_RUNTIME_DIRS = [ 'contracts/src', 'dom-contract/src', @@ -309,7 +301,7 @@ describe('architecture boundaries', () => { ]; for (const dir of PREP_001_RUNTIME_DIRS) { - for (const pkg of FORBIDDEN_V2_PACKAGES) { + for (const pkg of FORBIDDEN_RUNTIME_PACKAGES) { it(`${dir} does not import ${pkg}`, () => { const srcDir = path.join(LAYOUT_ENGINE_ROOT, dir); expectNoViolations(findImportViolations(srcDir, pkg)); diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index a6c0c10bc9..5ebe4c9f1d 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -59,8 +59,6 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { if (!destroyed) { @@ -274,19 +270,7 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef, + extends Omit, Partial>, - EditorImplementationProps, CallbackProps, ReactProps {} diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 32458000cf..990e1a01d1 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -107,7 +107,6 @@ export function dispatchIntentTool( 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 'move_row': return execute('doc.tables.moveRow', 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); diff --git a/packages/sdk/tools/intent_dispatch_generated.py b/packages/sdk/tools/intent_dispatch_generated.py index 0ea3a9e81b..5fc0785197 100644 --- a/packages/sdk/tools/intent_dispatch_generated.py +++ b/packages/sdk/tools/intent_dispatch_generated.py @@ -149,8 +149,6 @@ def dispatch_intent_tool( return execute('doc.tables.insertRow', rest) elif action == 'delete_row': return execute('doc.tables.deleteRow', rest) - elif action == 'move_row': - return execute('doc.tables.moveRow', rest) elif action == 'set_row': return execute('doc.tables.setRowHeight', rest) elif action == 'set_row_options': diff --git a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.test.js b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.test.js index 22bf3636e7..eac8739888 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.test.js @@ -58,18 +58,38 @@ afterEach(() => { }); describe('FontFamilyCombobox', () => { - it('focuses and selects the input without opening the list', async () => { + it('opens the list when the main font field is clicked', async () => { const { input } = mountCombobox(); await input.trigger('mousedown'); await nextTick(); - expect(document.body.querySelector('[role="listbox"]')).toBeNull(); - expect(input.attributes('aria-expanded')).toBe('false'); + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + expect(input.attributes('aria-expanded')).toBe('true'); expect(input.element.selectionStart).toBe(0); expect(input.element.selectionEnd).toBe('Arial'.length); }); + it('opens from a focused edit without replacing the typed font value', async () => { + const { input } = mountCombobox(); + + input.element.focus(); + await input.trigger('focus'); + await input.setValue('Brand Sans'); + await input.trigger('mousedown'); + await nextTick(); + + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + expect(input.element.value).toBe('Brand Sans'); + + await input.trigger('keydown', { key: 'Enter' }); + + expect(wrapper.emitted('command')?.[0]?.[0]).toMatchObject({ + argument: 'Brand Sans', + option: null, + }); + }); + it('opens the list from the caret control', async () => { const { input } = mountCombobox(); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue index b255a2169c..81049236d2 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/FontFamilyCombobox.vue @@ -176,13 +176,31 @@ const onFocus = () => { }; const onInputMousedown = (event) => { - if (disabled.value || document.activeElement === inputRef.value) return; - event.preventDefault(); - inputRef.value?.focus(); + if (disabled.value) return; + + const hadInputFocus = document.activeElement === inputRef.value; + emit('item-clicked'); isEditing.value = true; - query.value = ''; - inputDisplay.value = appliedLabel.value; - setSelectionRange(0, appliedLabel.value.length); + + if (!hadInputFocus) { + query.value = ''; + inputDisplay.value = appliedLabel.value; + event.preventDefault(); + inputRef.value?.focus(); + setSelectionRange(0, appliedLabel.value.length); + } + + if (!isOpen.value) { + const typedMatch = findPrefixMatchIndex(query.value, optionLabels.value); + if (typedMatch >= 0) { + openList(typedMatch); + } else if (hadInputFocus) { + openList(-1); + } else { + const index = appliedIndex(); + openList(index >= 0 ? index : 0); + } + } }; const onBlur = (event) => { diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index bae87dc72f..f7dd2f1398 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -65,7 +65,13 @@ export const makeDefaultItems = ({ ariaLabel: 'Font family', }, options: fontOptions, - onActivate: ({ fontFamily }) => { + onActivate: ({ fontFamily } = {}, isMultiple = false) => { + if (isMultiple) { + fontButton.label.value = ''; + fontButton.selectedValue.value = ''; + return; + } + if (!fontFamily) return; fontFamily = fontFamily.split(',')[0]; // in case of fonts with fallbacks fontButton.label.value = fontFamily; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js index 756f9bbcb0..3cacc55c99 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js @@ -87,6 +87,20 @@ describe('makeDefaultItems formatting marks button opt-in', () => { }); }); +describe('makeDefaultItems font family mixed selection state', () => { + it('blanks the font family label and selection for mixed selections', () => { + const { defaultItems, overflowItems } = buildItems(2000); + const fontFamily = getItem(defaultItems, overflowItems, 'fontFamily'); + fontFamily.label.value = 'Arial'; + fontFamily.selectedValue.value = 'Arial'; + + fontFamily.activate({}, true); + + expect(fontFamily.label.value).toBe(''); + expect(fontFamily.selectedValue.value).toBe(''); + }); +}); + describe('makeDefaultItems XL overflow boundary (SD-2328)', () => { const XL_OVERFLOW_SAFETY_BUFFER = 20; const XL_CUTOFF = RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 626513f65d..4d6880f73a 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -701,9 +701,9 @@ export class SuperToolbar extends EventEmitter { return fontFamily || null; } - // Headless currently represents mixed font-size selections as active + null value. + // Headless represents mixed selections (e.g. font-size, font-family) as active + null value. // The built-in toolbar still needs to translate that shape into the legacy isMultiple UI state. - #isFontSizeMixedState(commandState) { + #isMixedState(commandState) { return Boolean(commandState?.active) && commandState?.value == null; } @@ -751,6 +751,11 @@ export class SuperToolbar extends EventEmitter { return; } + if (this.#isMixedState(commandState)) { + item.activate({}, true); + return; + } + const fallbackFontFamily = this.#getFontFamilyFallbackValue(); if (fallbackFontFamily) { item.activate({ fontFamily: fallbackFontFamily }); @@ -764,7 +769,7 @@ export class SuperToolbar extends EventEmitter { item.activate({ fontSize: commandState.value }); return; } - if (this.#isFontSizeMixedState(commandState)) { + if (this.#isMixedState(commandState)) { item.activate({}, true); return; } diff --git a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.d.ts b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.d.ts index 34911a1cae..b4c83710e2 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.d.ts +++ b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.d.ts @@ -11,6 +11,8 @@ export function getSelectionFormattingState( resolvedRunProperties: Record | null; inlineRunProperties: Record | null; styleRunProperties: Record | null; + directMarkRunProperties: Record | null; + mixedRunProperties: Record | null; }; export function getFormattingStateAtPos( state: EditorState, @@ -27,6 +29,8 @@ export function getFormattingStateAtPos( resolvedRunProperties: Record | null; inlineRunProperties: Record | null; styleRunProperties: Record | null; + directMarkRunProperties: Record | null; + mixedRunProperties: Record | null; }; export function getFormattingStateForRange( state: EditorState, @@ -39,6 +43,8 @@ export function getFormattingStateForRange( resolvedRunProperties: Record | null; inlineRunProperties: Record | null; styleRunProperties: Record | null; + directMarkRunProperties: Record | null; + mixedRunProperties: Record | null; }; export function getInheritedRunProperties( $pos: any, diff --git a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.js index feaba912ba..77d402853f 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.js @@ -32,6 +32,7 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) { const context = getParagraphRunContext($pos, editor); const currentRunProperties = context?.runProperties || null; const cursorMarks = $pos.marks(); + const directMarkRunProperties = decodeRPrFromMarks(cursorMarks); const hasStoredMarks = storedMarks !== null; const hasExplicitEmptyStoredMarks = hasStoredMarks && storedMarks.length === 0; const resolvedMarks = []; @@ -62,6 +63,8 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) { resolvedRunProperties: {}, inlineRunProperties: {}, styleRunProperties: {}, + directMarkRunProperties: {}, + mixedRunProperties: null, }; } @@ -86,6 +89,8 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) { resolvedRunProperties, inlineRunProperties, styleRunProperties, + directMarkRunProperties, + mixedRunProperties: null, }; } @@ -109,9 +114,14 @@ export function getFormattingStateForRange(state, from, to, editor) { } function aggregateFormattingSegments(state, editor, segments) { - const resolvedRunProperties = intersectRunProperties(segments.map((segment) => segment.resolvedRunProperties)); - const inlineRunProperties = intersectRunProperties(segments.map((segment) => segment.inlineRunProperties)); - const styleRunProperties = intersectRunProperties(segments.map((segment) => segment.styleRunProperties)); + const resolvedRunPropertiesList = segments.map((segment) => segment.resolvedRunProperties); + const inlineRunPropertiesList = segments.map((segment) => segment.inlineRunProperties); + const styleRunPropertiesList = segments.map((segment) => segment.styleRunProperties); + const directMarkRunPropertiesList = segments.map((segment) => segment.directMarkRunProperties); + const resolvedRunProperties = intersectRunProperties(resolvedRunPropertiesList); + const inlineRunProperties = intersectRunProperties(inlineRunPropertiesList); + const styleRunProperties = intersectRunProperties(styleRunPropertiesList); + const directMarkRunProperties = intersectRunProperties(directMarkRunPropertiesList); const resolvedMarks = createMarksFromRunProperties(state, resolvedRunProperties, editor); const inlineMarks = createMarksFromRunProperties(state, inlineRunProperties, editor); @@ -121,6 +131,8 @@ function aggregateFormattingSegments(state, editor, segments) { resolvedRunProperties, inlineRunProperties, styleRunProperties, + directMarkRunProperties, + mixedRunProperties: getMixedRunProperties(resolvedRunPropertiesList, directMarkRunPropertiesList), }; } @@ -150,6 +162,53 @@ function intersectRunProperties(runPropertiesList) { return Object.keys(intersection).length ? intersection : null; } +function getMixedRunProperties(runPropertiesList, directRunPropertiesList = []) { + const filtered = runPropertiesList.filter((props) => props && typeof props === 'object'); + if (filtered.length <= 1) return null; + + const keys = new Set(filtered.flatMap((props) => Object.keys(props))); + const mixed = {}; + keys.forEach((key) => { + if (key === 'fontFamily' && hasUniformDirectFontFamily(directRunPropertiesList)) { + return; + } + + const values = filtered.map((props) => (Object.prototype.hasOwnProperty.call(props, key) ? props[key] : undefined)); + const first = JSON.stringify(values[0]); + if (values.some((value) => JSON.stringify(value) !== first)) { + mixed[key] = true; + } + }); + + return Object.keys(mixed).length ? mixed : null; +} + +function hasUniformDirectFontFamily(runPropertiesList) { + if (runPropertiesList.length <= 1) return false; + + let firstValue; + let hasFirstValue = false; + + for (const props of runPropertiesList) { + if (!props || typeof props !== 'object' || !Object.prototype.hasOwnProperty.call(props, 'fontFamily')) { + return false; + } + + const value = props.fontFamily; + if (!hasFirstValue) { + firstValue = value; + hasFirstValue = true; + continue; + } + + if (JSON.stringify(value) !== JSON.stringify(firstValue)) { + return false; + } + } + + return hasFirstValue; +} + /** * Resolve inherited run properties for the current position, returning: * - resolvedRunProperties: the full cascade used for toolbar state / first-char visuals diff --git a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.test.js index 42e3860118..3cde35df72 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/editors/v1/core/helpers/getMarksFromSelection.test.js @@ -211,6 +211,18 @@ describe('getMarksFromSelection', () => { return ['em', 0]; }, }, + link: { + attrs: { href: { default: null } }, + toDOM(mark) { + return ['a', { href: mark.attrs.href }, 0]; + }, + }, + textStyle: { + attrs: { fontFamily: { default: null }, csFontFamily: { default: null } }, + toDOM(mark) { + return ['span', { style: mark.attrs.fontFamily ? `font-family: ${mark.attrs.fontFamily}` : '' }, 0]; + }, + }, }, }); @@ -304,6 +316,78 @@ describe('getMarksFromSelection', () => { expect(result.inlineRunProperties).toEqual({ bold: true }); }); + it('marks fontFamily as mixed when a range crosses runs with different fonts', () => { + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { fontFamily: { ascii: 'Aptos' } } }, [runSchema.text('AB')]), + runSchema.node('run', { runProperties: { fontFamily: { ascii: 'Times New Roman' } } }, [ + runSchema.text('CD'), + ]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 2, 8))); + + const result = getSelectionFormattingState(rangeState, { converter: { convertedXml: {} } }); + + expect(result.resolvedRunProperties?.fontFamily).toBeUndefined(); + expect(result.mixedRunProperties).toEqual({ fontFamily: true }); + }); + + it('marks fontFamily as mixed when selected runs differ only by cs font metadata', () => { + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node( + 'run', + { runProperties: { fontFamily: { ascii: 'Times New Roman', hAnsi: 'Times New Roman' } } }, + [runSchema.text('AB')], + ), + runSchema.node( + 'run', + { + runProperties: { + fontFamily: { ascii: 'Times New Roman', hAnsi: 'Times New Roman', cs: 'Times New Roman' }, + }, + }, + [runSchema.text('CD')], + ), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 2, 8))); + + const result = getSelectionFormattingState(rangeState, { converter: { convertedXml: {} } }); + + expect(result.resolvedRunProperties?.fontFamily).toBeUndefined(); + expect(result.mixedRunProperties).toEqual({ fontFamily: true }); + }); + + it('does not mark fontFamily as mixed when uniform direct font marks override link run metadata', () => { + const desiredFont = runSchema.marks.textStyle.create({ fontFamily: 'Courier New, monospace' }); + const link = runSchema.marks.link.create({ href: 'https://example.com/one' }); + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { fontFamily: { ascii: 'Arial' } } }, [ + runSchema.text('Before 6.5%', [desiredFont]), + ]), + runSchema.node( + 'run', + { runProperties: { styleId: 'Hyperlink', fontFamily: { ascii: 'Arial', cs: 'Arial' } } }, + [runSchema.text('[1]', [link, desiredFont])], + ), + runSchema.node('run', { runProperties: { fontFamily: { ascii: 'Arial' } } }, [ + runSchema.text(' after', [desiredFont]), + ]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 2, testDoc.content.size - 1))); + + const result = getSelectionFormattingState(rangeState, { converter: { convertedXml: {} } }); + + expect(result.mixedRunProperties).toEqual({ styleId: true }); + }); + it('normalizes empty nodeAfter runProperties to null and falls back to cursor marks', () => { const testDoc = runSchema.node('doc', null, [ runSchema.node('paragraph', null, [runSchema.node('run', { runProperties: {} }, [runSchema.text('Hello')])]), @@ -320,6 +404,83 @@ describe('getMarksFromSelection', () => { }); }); + describe('mixed fontFamily from resolved textStyle marks', () => { + const textStyleSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + textStyle: { + attrs: { + fontFamily: { default: null }, + }, + toDOM(mark) { + return ['span', { style: mark.attrs.fontFamily ? `font-family: ${mark.attrs.fontFamily}` : '' }, 0]; + }, + }, + }, + }); + + const fontFamily = (name) => ({ ascii: name, eastAsia: name, hAnsi: name, cs: name }); + const mockEditorWithDefaultFont = (name) => ({ + converter: { + convertedXml: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: { fontFamily: fontFamily(name) }, paragraphProperties: {} }, + latentStyles: {}, + styles: { + Normal: { default: true, runProperties: { fontFamily: fontFamily(name) } }, + }, + }, + }, + }); + + it('marks fontFamily as mixed when selected textStyle font families differ', () => { + const times = textStyleSchema.marks.textStyle.create({ fontFamily: 'Times New Roman' }); + const arial = textStyleSchema.marks.textStyle.create({ fontFamily: 'Arial' }); + const testDoc = textStyleSchema.node('doc', null, [ + textStyleSchema.node('paragraph', null, [ + textStyleSchema.text('Times', [times]), + textStyleSchema.text(' Arial', [arial]), + ]), + ]); + const state = EditorState.create({ schema: textStyleSchema, doc: testDoc }); + const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1, testDoc.content.size - 1))); + + const result = getSelectionFormattingState(rangeState, mockEditorWithDefaultFont('Arial')); + + expect(result.resolvedRunProperties?.fontFamily).toBeUndefined(); + expect(result.mixedRunProperties).toEqual({ fontFamily: true }); + }); + + it('does not mark fontFamily as mixed when an unmarked segment resolves to the same default font', () => { + const arial = textStyleSchema.marks.textStyle.create({ fontFamily: 'Arial' }); + const testDoc = textStyleSchema.node('doc', null, [ + textStyleSchema.node('paragraph', null, [ + textStyleSchema.text('Direct', [arial]), + textStyleSchema.text(' Plain'), + ]), + ]); + const state = EditorState.create({ schema: textStyleSchema, doc: testDoc }); + const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1, testDoc.content.size - 1))); + + const result = getSelectionFormattingState(rangeState, mockEditorWithDefaultFont('Arial')); + + expect(result.resolvedRunProperties?.fontFamily).toEqual(fontFamily('Arial')); + expect(result.mixedRunProperties).toBeNull(); + }); + }); + it('reads inline run properties from the surrounding run node instead of decoding visible marks', () => { const runSchema = new Schema({ nodes: { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts index 8f80e5bbe9..cb789f3541 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts @@ -198,13 +198,28 @@ const BORDER_STYLES = new Set([ 'single', 'double', 'dashed', + 'dashSmallGap', 'dotted', 'thick', 'triple', 'dotDash', 'dotDotDash', + 'thinThickSmallGap', + 'thickThinSmallGap', + 'thinThickThinSmallGap', + 'thinThickMediumGap', + 'thickThinMediumGap', + 'thinThickThinMediumGap', + 'thinThickLargeGap', + 'thickThinLargeGap', + 'thinThickThinLargeGap', 'wave', 'doubleWave', + 'dashDotStroked', + 'threeDEmboss', + 'threeDEngrave', + 'outset', + 'inset', ]); function isBorderStyle(value: unknown): value is BorderStyle { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts index 4f4bde372d..33de02d242 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts @@ -778,6 +778,133 @@ describe('shapes converter', () => { }, }); }); + + it('derives inlineParagraphAlignment from a centered wrapper paragraph for inline textboxes', () => { + // A real import stores alignment only as the raw `w:jc` value on + // `paragraphProperties.justification`; the wrapper paragraph has no top-level `textAlign`. + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + paragraphProperties: { justification: 'center' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('derives inlineParagraphAlignment "right" from a right-aligned wrapper paragraph', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + paragraphProperties: { justification: 'right' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('right'); + }); + + it('derives wrapper paragraph indents with inlineParagraphAlignment for centered inline textboxes', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + paragraphProperties: { + justification: 'center', + indent: { left: 720, right: 360 }, + }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + expect(result.attrs?.paragraphIndentLeft).toBe(48); + expect(result.attrs?.paragraphIndentRight).toBe(24); + }); + + it('derives inlineParagraphAlignment "center" from a distribute wrapper paragraph', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + paragraphProperties: { justification: 'distribute' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBe('center'); + }); + + it('does not derive inlineParagraphAlignment for non-inline (anchored) textboxes', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Square' }, + wrapperParagraph: { + paragraphProperties: { justification: 'center' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); + + it('does not derive inlineParagraphAlignment for left-aligned wrapper paragraphs', () => { + const node: PMNode = { + type: 'shapeContainer', + attrs: { + width: 199, + height: 191, + wrap: { type: 'Inline' }, + wrapperParagraph: { + paragraphProperties: { justification: 'left' }, + }, + }, + }; + + const result = shapeContainerNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock & { + attrs?: Record; + }; + + expect(result.attrs?.inlineParagraphAlignment).toBeUndefined(); + }); }); describe('shapeTextboxNodeToDrawingBlock', () => { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts index 348778b9a8..24f3125d1b 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts @@ -47,6 +47,7 @@ import { mergeWrapDistancesFromPadding, ptToPx, } from '../utilities.js'; +import { computeParagraphAttrs } from '../attributes/index.js'; import { getLastParagraphFont } from './paragraph.js'; // ============================================================================ @@ -837,6 +838,59 @@ export function shapeGroupNodeToDrawingBlock( }); } +/** + * Inline textbox/shape paragraphs whose only content is the drawing are lifted to the + * document top level by the importer, which stashes the original host paragraph props on + * `wrapperParagraph`. Those drawings keep their authored width, so a centered/right-aligned + * host paragraph must still offset the whole box on the page. Derive the alignment metadata + * the layout engine consumes (`inlineParagraphAlignment` plus wrapper indents) from that + * wrapper (SD: IT-1140). + * + * Only applies to inline-wrapped drawings; anchored drawings are positioned by their anchor. + */ +const resolveWrapperAlignment = (justification: unknown): 'center' | 'right' | undefined => { + switch (justification) { + // `distribute` visually centers a sole inline drawing in Word. + case 'center': + case 'distribute': + return 'center'; + case 'right': + case 'end': + return 'right'; + default: + return undefined; + } +}; + +const resolveInlineAlignmentFromWrapper = (rawAttrs: Record): Record => { + const wrapType = (rawAttrs.wrap as { type?: unknown } | undefined)?.type; + if (wrapType !== 'Inline' || !isPlainObject(rawAttrs.wrapperParagraph)) { + return {}; + } + const wrapper = rawAttrs.wrapperParagraph as Record; + const paragraphProperties = isPlainObject(wrapper.paragraphProperties) + ? (wrapper.paragraphProperties as Record) + : undefined; + // The paragraph node has no top-level `textAlign`; the text-align extension and the + // importer both store alignment as the raw OOXML `w:jc` value on + // `paragraphProperties.justification` (e.g. "center", "right", "distribute"). Map that + // to a physical alignment, treating "distribute" as visual centering for a sole inline + // drawing. `wrapper.textAlign` is only an additional fallback for callers that surface a + // pre-resolved alignment. + const justification = paragraphProperties?.justification; + const textAlign = typeof wrapper.textAlign === 'string' ? wrapper.textAlign : undefined; + const effectiveAlignment = resolveWrapperAlignment(justification) ?? textAlign; + if (effectiveAlignment !== 'center' && effectiveAlignment !== 'right') { + return {}; + } + const metadata: Record = { inlineParagraphAlignment: effectiveAlignment }; + const { paragraphAttrs } = computeParagraphAttrs({ type: 'paragraph', attrs: wrapper } as PMNode); + const indent = paragraphAttrs.indent; + if (typeof indent?.left === 'number') metadata.paragraphIndentLeft = indent.left; + if (typeof indent?.right === 'number') metadata.paragraphIndentRight = indent.right; + return metadata; +}; + /** * Convert a ProseMirror shapeContainer node to a DrawingBlock * @@ -869,6 +923,7 @@ export function shapeContainerNodeToDrawingBlock( return buildDrawingBlock( { ...rawAttrs, + ...resolveInlineAlignmentFromWrapper(rawAttrs), ...(textContent ? { textContent } : {}), ...(rawAttrs.textAlign == null && textContent?.horizontalAlign ? { textAlign: textContent.horizontalAlign } : {}), ...(rawAttrs.textInsets == null ? { textInsets: resolveTextboxInsetsFromAttrs(textboxAttrs) } : {}), diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index 6820ef2e18..759cd9c65d 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -112,6 +112,10 @@ type ParseTableCellArgs = { defaultCellPadding?: BoxSpacing; tableProperties?: TableProperties; rowCnfStyle?: Record | null; + /** Grid placement for conditional style regions (SD-3028 G7). */ + gridPlacement?: GridCellPlacement | null; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number; }; type ParseTableRowArgs = { @@ -122,6 +126,68 @@ type ParseTableRowArgs = { defaultCellPadding?: BoxSpacing; /** Table style to pass to paragraph converter for style cascade */ tableProperties?: TableProperties; + /** Per-content-index grid placements for this row (SD-3028 G7). */ + cellGridPlacements?: Array; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number; +}; + +/** Grid column placement of one display cell (SD-3028 G7). */ +type GridCellPlacement = { + gridColumnStart: number; + gridColumnSpan: number; +}; + +/** + * Place a row's cells on the table grid (SD-3028 G7). + * + * Word's firstCol/lastCol/banding conditional regions follow GRID columns, but + * the PM document only exposes display cells: vMerge continuations are merged + * into rowspans on earlier rows and gridBefore/gridAfter become placeholder + * cells. This walks the row's content with a column cursor, skipping columns + * occupied by rowspans from above, so every display cell knows its grid start. + * + * Mirrors the measuring normalizer's activeRowSpans idiom: `activeRowSpans[col]` + * counts how many upcoming rows column `col` is still covered by. + * + * @param rowNode - The PM table row node. + * @param activeRowSpans - Occupied-column counters carried from previous rows. + * @returns Placements aligned to `rowNode.content` indices plus the counters + * for the next row. + */ +const placeRowCellsOnGrid = ( + rowNode: PMNode, + activeRowSpans: number[], +): { placements: Array; nextActiveRowSpans: number[] } => { + const placements: Array = []; + const nextActiveRowSpans = activeRowSpans.map((count) => Math.max(0, count - 1)); + let column = 0; + + const cellSpan = (cellNode: PMNode): number => { + const colspan = cellNode.attrs?.colspan; + if (typeof colspan === 'number' && colspan > 0) return colspan; + const colwidth = cellNode.attrs?.colwidth; + return Array.isArray(colwidth) && colwidth.length > 0 ? colwidth.length : 1; + }; + + for (const cellNode of Array.isArray(rowNode.content) ? rowNode.content : []) { + if (!isTableCellNode(cellNode)) { + placements.push(null); + continue; + } + while ((activeRowSpans[column] ?? 0) > 0) column += 1; + const span = cellSpan(cellNode); + placements.push({ gridColumnStart: column, gridColumnSpan: span }); + const rowspan = typeof cellNode.attrs?.rowspan === 'number' ? cellNode.attrs.rowspan : 1; + if (rowspan > 1) { + for (let covered = column; covered < column + span; covered += 1) { + nextActiveRowSpans[covered] = Math.max(nextActiveRowSpans[covered] ?? 0, rowspan - 1); + } + } + column += span; + } + + return { placements, nextActiveRowSpans }; }; const isTableRowNode = (node: PMNode): boolean => node.type === 'tableRow' || node.type === 'table_row'; @@ -161,10 +227,40 @@ function normalizeLegacyBorderStyle(value: string | undefined): string { return 'dotDash'; case 'dotdotdash': return 'dotDotDash'; + case 'dashsmallgap': + return 'dashSmallGap'; + case 'thinthicksmallgap': + return 'thinThickSmallGap'; + case 'thickthinsmallgap': + return 'thickThinSmallGap'; + case 'thinthickthinsmallgap': + return 'thinThickThinSmallGap'; + case 'thinthickmediumgap': + return 'thinThickMediumGap'; + case 'thickthinmediumgap': + return 'thickThinMediumGap'; + case 'thinthickthinmediumgap': + return 'thinThickThinMediumGap'; + case 'thinthicklargegap': + return 'thinThickLargeGap'; + case 'thickthinlargegap': + return 'thickThinLargeGap'; + case 'thinthickthinlargegap': + return 'thinThickThinLargeGap'; case 'wave': return 'wave'; case 'doublewave': return 'doubleWave'; + case 'dashdotstroked': + return 'dashDotStroked'; + case 'threedemboss': + return 'threeDEmboss'; + case 'threedengrave': + return 'threeDEngrave'; + case 'outset': + return 'outset'; + case 'inset': + return 'inset'; case 'single': default: return 'single'; @@ -286,7 +382,22 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { const rowCnfStyle = args.rowCnfStyle ?? null; const cellCnfStyle = (cellNode.attrs?.tableCellProperties as Record | undefined)?.cnfStyle ?? null; const tableInfo: TableInfo | undefined = tableProperties - ? { tableProperties, rowIndex, cellIndex, numCells, numRows, rowCnfStyle, cellCnfStyle } + ? { + tableProperties, + rowIndex, + cellIndex, + numCells, + numRows, + rowCnfStyle, + cellCnfStyle, + ...(args.gridPlacement != null && args.numGridCols != null + ? { + gridColumnStart: args.gridPlacement.gridColumnStart, + gridColumnSpan: args.gridPlacement.gridColumnSpan, + numGridCols: args.numGridCols, + } + : {}), + } : undefined; // Resolve table cell properties from the style cascade (wholeTable → bands → conditional → inline) @@ -754,6 +865,8 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { numCells: rowNode?.content?.length || 1, numRows, rowCnfStyle, + gridPlacement: args.cellGridPlacements?.[cellIndex] ?? null, + numGridCols: args.numGridCols, }); if (parsedCell) { cells.push(parsedCell); @@ -1017,7 +1130,15 @@ export function tableNodeToBlock( : undefined; const rows: TableRow[] = []; + // Grid placements for conditional style regions (SD-3028 G7): Word's + // firstCol/lastCol/banding follow grid columns, so each display cell needs + // its grid start across rowspans, spans, and placeholder columns. + const grid = node.attrs?.grid; + const numGridCols = Array.isArray(grid) && grid.length > 0 ? grid.length : undefined; + let activeRowSpans: number[] = []; node.content.forEach((rowNode, rowIndex) => { + const { placements, nextActiveRowSpans } = placeRowCellsOnGrid(rowNode, activeRowSpans); + activeRowSpans = nextActiveRowSpans; const parsedRow = parseTableRow({ rowNode, rowIndex, @@ -1025,6 +1146,8 @@ export function tableNodeToBlock( context: parserDeps, defaultCellPadding, tableProperties: tablePropertiesForCascade, + cellGridPlacements: placements, + numGridCols, }); if (parsedRow) { // Drop a tracked row from the layout entirely (not just CSS-hide it in the diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts index d56965acc1..c86990f2a5 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { handleStructuredContentBlockNode } from './structured-content-block.js'; import type { PMNode, NodeHandlerContext } from '../types.js'; -import type { FlowBlock, ParagraphBlock, SdtMetadata } from '@superdoc/contracts'; +import type { FlowBlock, ListBlock, ParagraphBlock, SdtMetadata } from '@superdoc/contracts'; import * as metadataModule from './metadata.js'; // Mock the metadata module @@ -704,6 +704,98 @@ describe('structured-content-block', () => { expect(recordBlockKind).toHaveBeenCalledTimes(3); }); + it('should preserve child SDT metadata on a nested list item after a bookmark-only paragraph', () => { + const markerParagraph: PMNode = { + type: 'paragraph', + content: [{ type: 'bookmarkStart', attrs: { id: '42', name: 'beforeChildSdt' } }], + }; + const childListParagraph: PMNode = nonEmptyParagraph('Nested heading'); + const childBodyParagraph: PMNode = nonEmptyParagraph('Nested body'); + const childSdt: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'child-sdt' }, + content: [childListParagraph, childBodyParagraph], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'parent-sdt' }, + content: [markerParagraph, childSdt], + }; + const childMetadata: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'child-sdt', + }; + const childListBlock: ListBlock = { + kind: 'list', + id: 'child-list', + listType: 'number', + items: [ + { + id: 'child-list-item', + marker: { text: 'Item 8.2', style: {} }, + paragraph: { + kind: 'paragraph', + id: 'child-list-paragraph', + runs: [{ text: 'Nested heading', fontFamily: 'Arial', fontSize: 12 }], + }, + }, + ], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockImplementation((target) => + target === childSdt ? childMetadata : scbMetadata, + ); + vi.mocked(metadataModule.applySdtMetadataToParagraphBlocks).mockImplementation((paragraphBlocks, metadata) => { + paragraphBlocks.forEach((block) => { + block.attrs = { ...(block.attrs ?? {}), sdt: metadata }; + }); + }); + + const mockParagraphConverter = vi.fn(({ para }) => { + if (para === childListParagraph) return [childListBlock]; + return [ + { + kind: 'paragraph', + id: `p-${para === markerParagraph ? 'marker' : 'child-body'}`, + runs: [{ text: para === markerParagraph ? '' : 'Nested body', fontFamily: 'Arial', fontSize: 12 }], + } as ParagraphBlock, + ]; + }); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks: mockParagraphConverter, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(3); + expect(blocks[1]).toBe(childListBlock); + expect(childListBlock.items[0].paragraph.attrs).toEqual({ + sdt: childMetadata, + containerSdt: scbMetadata, + }); + expect((blocks[2] as ParagraphBlock).attrs).toEqual({ + sdt: childMetadata, + containerSdt: scbMetadata, + }); + }); + it('should resolve node metadata with correct override type', () => { const node: PMNode = { type: 'structuredContentBlock', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts index 4fb2c5f7f7..baea5d020f 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts @@ -5,9 +5,14 @@ * paragraphs and tables while preserving their content structure. */ -import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ListBlock, ParagraphBlock, SdtMetadata, TableBlock, TextRun } from '@superdoc/contracts'; import type { PMNode, NodeHandlerContext } from '../types.js'; -import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +import { + resolveNodeSdtMetadata, + applySdtMetadataToParagraphBlocks, + applySdtMetadataToTableBlock, + applySdtMetadataToListBlock, +} from './metadata.js'; const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set([ 'bookmarkEnd', @@ -77,6 +82,24 @@ function applyPlaceholderToEmptyParagraphBlocks( return applied; } +function applyContainerSdtMetadata(blocks: FlowBlock[], metadata?: SdtMetadata): void { + if (!metadata) return; + + blocks.forEach((block) => { + if (block.kind === 'paragraph' || block.kind === 'table') { + if (!block.attrs) block.attrs = {}; + block.attrs.containerSdt ??= metadata; + return; + } + if (block.kind === 'list') { + block.items.forEach((item) => { + if (!item.paragraph.attrs) item.paragraph.attrs = {}; + item.paragraph.attrs.containerSdt ??= metadata; + }); + } + }); +} + /** * Handle structured content block nodes. * Processes child paragraphs and tables, applying SDT metadata. @@ -154,6 +177,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], structuredContentMetadata, ); + paragraphBlocks.forEach((block) => { + if (block.kind === 'list') applySdtMetadataToListBlock(block as ListBlock, structuredContentMetadata); + }); if (applyPlaceholderToEmptyParagraphBlocks(paragraphBlocks, structuredContentMetadata, contentPos)) { paragraphBlocks.forEach((block) => { blocks.push(block); @@ -199,6 +225,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], structuredContentMetadata, ); + paragraphBlocks.forEach((block) => { + if (block.kind === 'list') applySdtMetadataToListBlock(block as ListBlock, structuredContentMetadata); + }); paragraphBlocks.forEach((block) => { blocks.push(block); recordBlockKind?.(block.kind); @@ -227,6 +256,12 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand } return; } + if (child.type === 'structuredContentBlock') { + const blockStart = blocks.length; + handleStructuredContentBlockNode(child, context); + applyContainerSdtMetadata(blocks.slice(blockStart), structuredContentMetadata); + return; + } // SD-1333: documentPartObject is a transparent wrapper - recurse its content. // SD-3005: a block field (bibliography / index / table of authorities) generated // inside this content control is likewise transparent here; render its entry diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts index 2d19dade97..a9f1dbfff0 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts @@ -904,6 +904,39 @@ describe('Media Utilities', () => { }); }); + describe('hydrateImageBlocks - textboxShape contentBlocks hydration', () => { + it('hydrates ImageRuns inside textboxShape contentBlocks', () => { + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + id: 'textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 80, rotation: 0, flipH: false, flipV: false }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'textbox-para-1', + runs: [{ kind: 'image', src: 'word/media/image1.png', width: 100, height: 100 }], + }, + ], + } as unknown as FlowBlock, + ]; + const mediaFiles = { 'word/media/image1.png': 'base64ImageData' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + const drawingBlock = result[0] as { + contentBlocks: Array<{ runs: Array<{ kind?: string; src?: string }> }>; + }; + + expect(drawingBlock.contentBlocks[0].runs[0]).toEqual({ + kind: 'image', + src: 'data:image/png;base64,base64ImageData', + width: 100, + height: 100, + }); + }); + }); + describe('hydrateImageBlocks - ShapeGroup image hydration', () => { it('hydrates image children inside shapeGroup drawing blocks', () => { const blocks: FlowBlock[] = [ diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts index 4d8e61daca..524789cd0c 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts @@ -17,6 +17,7 @@ import type { ShapeGroupTransform, ShapeEffects, TextPart, + TextboxDrawing, VectorShapeDrawing, FlowBlock, ImageRun, @@ -1276,28 +1277,55 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { - if (part?.kind !== 'image' || !part.src || part.src.startsWith('data:')) { - return part; + let blockChanged = false; + let nextBlock: VectorShapeDrawing | TextboxDrawing = drawingBlock; + + if (drawingBlock.drawingKind === 'textboxShape') { + const contentBlocks = drawingBlock.contentBlocks; + if (Array.isArray(contentBlocks) && contentBlocks.length > 0) { + let contentBlocksChanged = false; + const hydratedContentBlocks = contentBlocks.map((paragraphBlock) => { + if (paragraphBlock.kind !== 'paragraph' || !paragraphBlock.runs?.length) { + return paragraphBlock; + } + const hydratedRuns = hydrateRuns(paragraphBlock.runs); + if (hydratedRuns !== paragraphBlock.runs) { + contentBlocksChanged = true; + return { ...paragraphBlock, runs: hydratedRuns }; + } + return paragraphBlock; + }); + if (contentBlocksChanged) { + blockChanged = true; + nextBlock = { ...drawingBlock, contentBlocks: hydratedContentBlocks }; + } } - const resolvedSrc = resolveImageSrc(part.src, part.rId, undefined, part.extension); - if (resolvedSrc) { - partsChanged = true; - return { ...part, src: resolvedSrc }; + } + + const parts = nextBlock.textContent?.parts; + if (parts && parts.length > 0) { + let partsChanged = false; + const hydratedParts = parts.map((part: TextPart) => { + if (part?.kind !== 'image' || !part.src || part.src.startsWith('data:')) { + return part; + } + const resolvedSrc = resolveImageSrc(part.src, part.rId, undefined, part.extension); + if (resolvedSrc) { + partsChanged = true; + return { ...part, src: resolvedSrc }; + } + return part; + }); + if (partsChanged) { + blockChanged = true; + nextBlock = { + ...nextBlock, + textContent: { ...nextBlock.textContent, parts: hydratedParts }, + }; } - return part; - }); - if (partsChanged) { - const vectorShapeBlock = drawingBlock as VectorShapeDrawing; - return { - ...vectorShapeBlock, - textContent: { ...vectorShapeBlock.textContent, parts: hydratedParts }, - }; } - return blk; + + return blockChanged ? nextBlock : blk; } if (drawingBlock.drawingKind !== 'shapeGroup') { 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 c1fd12b2a1..dfd6dbc94d 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 @@ -730,6 +730,8 @@ export class PresentationEditor extends EventEmitter { #lastSelectedStructuredContentBlock: { id: string | null; elements: HTMLElement[]; + wrapperElements: HTMLElement[]; + ancestorElements: HTMLElement[]; } | null = null; #lastSelectedStructuredContentInline: { id: string | null; @@ -6105,6 +6107,7 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, + getDocumentMode: () => this.#getEffectiveDocumentMode(), getStorySessionManager: () => this.#ensureStorySessionManager(), }); @@ -6362,21 +6365,63 @@ export class PresentationEditor extends EventEmitter { return; } - if (session.kind !== 'note') { + const editor = session.editor; + const mode = this.#getStoryEditorParentDocumentMode(editor) ?? this.#getEffectiveDocumentMode(); + const isEditorHandledMode = + !editor.options?.isHeaderOrFooter && + !editor.options?.isChildEditor && + typeof editor.setDocumentMode === 'function'; + + if (isEditorHandledMode) { + session.editor.setDocumentMode(mode); return; } - // Story editors default to viewing mode at construction time. When a note - // session becomes the active presentation surface, it must inherit the - // current document mode so double-clicking produces an actually editable - // footnote/endnote surface. - if (typeof session.editor.setDocumentMode === 'function') { - session.editor.setDocumentMode(this.#documentMode); - return; + this.#applyStorySessionDocumentMode(editor, mode); + } + + #getEffectiveDocumentMode(): 'editing' | 'viewing' | 'suggesting' { + const mode = this.#editor?.options?.documentMode; + if (mode === 'editing' || mode === 'viewing' || mode === 'suggesting') { + return mode; } + return this.#documentMode; + } + + #getActiveEditorDocumentMode(): 'editing' | 'viewing' | 'suggesting' | null { + const mode = this.getActiveEditor()?.options?.documentMode; + return mode === 'editing' || mode === 'viewing' || mode === 'suggesting' ? mode : null; + } - session.editor.setEditable?.(this.#documentMode !== 'viewing'); - session.editor.setOptions?.({ documentMode: this.#documentMode }); + #getStoryEditorParentDocumentMode(editor: Editor): 'editing' | 'viewing' | 'suggesting' | null { + const parent = (editor.options as { parentEditor?: Editor } | undefined)?.parentEditor; + const mode = parent?.options?.documentMode; + return mode === 'editing' || mode === 'viewing' || mode === 'suggesting' ? mode : null; + } + + #applyStorySessionDocumentMode(editor: Editor, mode: 'editing' | 'viewing' | 'suggesting'): void { + if (mode === 'viewing') { + editor.commands?.enableTrackChangesShowOriginal?.(); + editor.setOptions?.({ documentMode: 'viewing' }); + editor.setEditable?.(false); + } else if (mode === 'suggesting') { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.enableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'suggesting' }); + editor.setEditable?.(true); + } else { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.disableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'editing' }); + editor.setEditable?.(true); + } + + const pm = editor.view?.dom ?? null; + if (pm instanceof HTMLElement) { + pm.setAttribute('aria-readonly', mode === 'viewing' ? 'true' : 'false'); + pm.setAttribute('documentmode', mode); + pm.classList.toggle('view-mode', mode === 'viewing'); + } } /** @@ -7923,22 +7968,112 @@ export class PresentationEditor extends EventEmitter { this.#lastSelectedStructuredContentBlock.elements.forEach((element) => { element.classList.remove('ProseMirror-selectednode'); }); + this.#lastSelectedStructuredContentBlock.wrapperElements.forEach((element) => { + element.classList.remove(DOM_CLASS_NAMES.SDT_CONTAINER_SELECTED); + }); + this.#lastSelectedStructuredContentBlock.ancestorElements.forEach((element) => { + element.classList.remove(DOM_CLASS_NAMES.SDT_ANCESTOR_SELECTED); + }); this.#lastSelectedStructuredContentBlock = null; } - #setSelectedStructuredContentBlockClass(elements: HTMLElement[], id: string | null) { + #setSelectedStructuredContentBlockClass( + elements: HTMLElement[], + wrapperElements: HTMLElement[], + ancestorElements: HTMLElement[], + id: string | null, + ) { if ( this.#lastSelectedStructuredContentBlock && this.#lastSelectedStructuredContentBlock.id === id && this.#lastSelectedStructuredContentBlock.elements.length === elements.length && - this.#lastSelectedStructuredContentBlock.elements.every((el) => elements.includes(el)) + this.#lastSelectedStructuredContentBlock.elements.every((el) => elements.includes(el)) && + this.#lastSelectedStructuredContentBlock.wrapperElements.length === wrapperElements.length && + this.#lastSelectedStructuredContentBlock.wrapperElements.every((el) => wrapperElements.includes(el)) && + this.#lastSelectedStructuredContentBlock.ancestorElements.length === ancestorElements.length && + this.#lastSelectedStructuredContentBlock.ancestorElements.every((el) => ancestorElements.includes(el)) ) { return; } this.#clearSelectedStructuredContentBlockClass(); elements.forEach((element) => element.classList.add('ProseMirror-selectednode')); - this.#lastSelectedStructuredContentBlock = { id, elements }; + wrapperElements.forEach((element) => element.classList.add(DOM_CLASS_NAMES.SDT_CONTAINER_SELECTED)); + ancestorElements.forEach((element) => element.classList.add(DOM_CLASS_NAMES.SDT_ANCESTOR_SELECTED)); + this.#lastSelectedStructuredContentBlock = { id, elements, wrapperElements, ancestorElements }; + } + + #getStructuredContentBlockExactElementsById(id: string): HTMLElement[] { + const indexed = this.#painterAdapter.getStructuredContentBlockElementsById(id); + if (indexed.length > 0) return indexed; + + if (!this.#painterHost) return []; + return Array.from(this.#painterHost.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}`)).filter( + (element) => element.dataset.sdtId === id, + ); + } + + #getStructuredContentBlockWrapperElementsById(id: string): HTMLElement[] { + if (!this.#painterHost) return []; + return Array.from(this.#painterHost.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}`)).filter( + (element) => element.dataset.sdtId === id || element.dataset.sdtContainerId === id, + ); + } + + #resolveSelectedStructuredContentBlockWrapperElements(id: string | null, elements: HTMLElement[]): HTMLElement[] { + const wrapperElements = new Set(); + const visitedIds = new Set(); + const pendingIds: string[] = []; + + const enqueueId = (candidate: string | null | undefined) => { + if (!candidate || visitedIds.has(candidate)) return; + visitedIds.add(candidate); + pendingIds.push(candidate); + }; + + enqueueId(id); + elements.forEach((element) => { + wrapperElements.add(element); + enqueueId(element.dataset.sdtId); + }); + + while (pendingIds.length > 0) { + const currentId = pendingIds.shift(); + if (!currentId) continue; + const currentElements = this.#getStructuredContentBlockWrapperElementsById(currentId); + currentElements.forEach((element) => { + wrapperElements.add(element); + enqueueId(element.dataset.sdtId); + }); + } + + return [...wrapperElements]; + } + + #resolveSelectedStructuredContentBlockAncestorElements(id: string | null, elements: HTMLElement[]): HTMLElement[] { + const ancestorElements = new Set(); + const visitedIds = new Set(); + const pendingIds: string[] = []; + + const enqueueId = (candidate: string | null | undefined) => { + if (!candidate || candidate === id || visitedIds.has(candidate)) return; + visitedIds.add(candidate); + pendingIds.push(candidate); + }; + + elements.forEach((element) => enqueueId(element.dataset.sdtContainerId)); + + while (pendingIds.length > 0) { + const currentId = pendingIds.shift(); + if (!currentId) continue; + const currentElements = this.#getStructuredContentBlockExactElementsById(currentId); + currentElements.forEach((element) => { + ancestorElements.add(element); + enqueueId(element.dataset.sdtContainerId); + }); + } + + return [...ancestorElements]; } #syncSelectedStructuredContentBlockClass(selection: Selection | null | undefined) { @@ -8023,7 +8158,9 @@ export class PresentationEditor extends EventEmitter { return; } - this.#setSelectedStructuredContentBlockClass(elements, id); + const wrapperElements = this.#resolveSelectedStructuredContentBlockWrapperElements(id, elements); + const ancestorElements = this.#resolveSelectedStructuredContentBlockAncestorElements(id, elements); + this.#setSelectedStructuredContentBlockClass(elements, wrapperElements, ancestorElements, id); } /** @@ -8043,7 +8180,10 @@ export class PresentationEditor extends EventEmitter { hoverClass: DOM_CLASS_NAMES.SDT_GROUP_HOVER, // PM-selected SDTs render with their selection style — leave it alone // so the hover greying doesn't mask the selection feedback. - shouldApplyTo: (element) => !element.classList.contains('ProseMirror-selectednode'), + shouldApplyTo: (element) => + !element.classList.contains('ProseMirror-selectednode') && + !element.classList.contains(DOM_CLASS_NAMES.SDT_CONTAINER_SELECTED) && + !element.classList.contains(DOM_CLASS_NAMES.SDT_ANCESTOR_SELECTED), }); this.#tocHoverCoordinator = new HoverGroupCoordinator({ @@ -8987,6 +9127,7 @@ export class PresentationEditor extends EventEmitter { const editor = (await this.#headerFooterSession?.activateRegion(region, { initialSelection: options ? 'defer' : 'end', + documentMode: this.#getActiveEditorDocumentMode() ?? this.#getEffectiveDocumentMode(), })) ?? null; if (!editor || !options) { @@ -10338,6 +10479,7 @@ export class PresentationEditor extends EventEmitter { const activeEditor = await this.#headerFooterSession?.activateRegion(region, { initialSelection: 'defer', + documentMode: this.#getActiveEditorDocumentMode() ?? this.#getEffectiveDocumentMode(), }); if (!activeEditor) { return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 5533e03195..43ff2361a5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -327,6 +327,8 @@ export type SessionManagerDependencies = { setPendingDocChange: () => void; /** Get total page count from body layout */ getBodyPageCount: () => number; + /** Get current document mode from the owning presentation editor */ + getDocumentMode?: () => 'editing' | 'viewing' | 'suggesting'; /** Get the generic story-session manager when enabled */ getStorySessionManager?: () => { activate: (locator: HeaderFooterPartStoryLocator, options?: Record) => { editor: Editor }; @@ -375,6 +377,7 @@ export type SessionManagerCallbacks = { type HeaderFooterActivationOptions = { initialSelection?: 'end' | 'defer'; + documentMode?: 'editing' | 'viewing' | 'suggesting'; }; // ============================================================================= @@ -781,6 +784,22 @@ export class HeaderFooterSessionManager { this.#applyChildEditorDocumentMode(editor, this.#documentMode); } + #getCurrentDocumentMode(): 'editing' | 'viewing' | 'suggesting' { + const editorMode = this.#options.editor?.options?.documentMode; + const mode = + (editorMode === 'editing' || editorMode === 'viewing' || editorMode === 'suggesting' ? editorMode : undefined) ?? + this.#deps?.getDocumentMode?.() ?? + this.#documentMode; + this.#documentMode = mode; + return mode; + } + + #getParentDocumentMode(editor: Editor): 'editing' | 'viewing' | 'suggesting' | null { + const parent = (editor.options as { parentEditor?: Editor } | undefined)?.parentEditor; + const mode = parent?.options?.documentMode; + return mode === 'editing' || mode === 'viewing' || mode === 'suggesting' ? mode : null; + } + setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void { const nextConfig: HeaderFooterTrackedChangesRenderConfig = { mode: config.mode, @@ -1319,9 +1338,11 @@ export class HeaderFooterSessionManager { } const shouldRestoreInitialSelection = options?.initialSelection !== 'defer'; + const documentMode = + options?.documentMode ?? this.#getParentDocumentMode(editor) ?? this.#getCurrentDocumentMode(); try { - this.#applyChildEditorDocumentMode(editor, this.#documentMode); + this.#applyChildEditorDocumentMode(editor, documentMode); if (shouldRestoreInitialSelection) { this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not set cursor to end'); 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 6f8c0694d4..99b22a8763 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 @@ -818,6 +818,102 @@ describe('PresentationEditor', () => { expect(sdtWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); }); + + it('marks child fragments as outer wrapper participants without selecting the child SDT', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'outer-sdt-selection-wraps-child-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const parentNode = { + type: { name: 'structuredContentBlock' }, + attrs: { id: 'parent-sdt' }, + nodeSize: 30, + }; + editorInstance.state.selection = makeNodeSelection(90, 120, parentNode); + + const parentWrapper = document.createElement('div'); + parentWrapper.className = 'superdoc-structured-content-block'; + parentWrapper.dataset.sdtId = 'parent-sdt'; + parentWrapper.dataset.pmStart = '90'; + parentWrapper.dataset.pmEnd = '99'; + + const childWrapper = document.createElement('div'); + childWrapper.className = 'superdoc-structured-content-block'; + childWrapper.dataset.sdtId = 'child-sdt'; + childWrapper.dataset.sdtContainerId = 'parent-sdt'; + childWrapper.dataset.pmStart = '100'; + childWrapper.dataset.pmEnd = '110'; + + container.querySelector('.presentation-editor__pages')?.append(parentWrapper, childWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(parentWrapper.classList.contains('ProseMirror-selectednode')).toBe(true); + expect(parentWrapper.classList.contains('sdt-container-selected')).toBe(true); + expect(parentWrapper.classList.contains('sdt-ancestor-selected')).toBe(false); + expect(childWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); + expect(childWrapper.classList.contains('sdt-container-selected')).toBe(true); + expect(childWrapper.classList.contains('sdt-ancestor-selected')).toBe(false); + }); + + it('marks the outer wrapper as an ancestor when a nested child SDT is selected', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'inner-sdt-selection-selects-outer-wrapper-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const childNode = { + type: { name: 'structuredContentBlock' }, + attrs: { id: 'child-sdt' }, + nodeSize: 10, + }; + editorInstance.state.selection = makeNodeSelection(100, 110, childNode); + + const parentWrapper = document.createElement('div'); + parentWrapper.className = 'superdoc-structured-content-block'; + parentWrapper.dataset.sdtId = 'parent-sdt'; + parentWrapper.dataset.pmStart = '90'; + parentWrapper.dataset.pmEnd = '99'; + + const childWrapper = document.createElement('div'); + childWrapper.className = 'superdoc-structured-content-block'; + childWrapper.dataset.sdtId = 'child-sdt'; + childWrapper.dataset.sdtContainerId = 'parent-sdt'; + childWrapper.dataset.pmStart = '100'; + childWrapper.dataset.pmEnd = '110'; + + const siblingWrapper = document.createElement('div'); + siblingWrapper.className = 'superdoc-structured-content-block'; + siblingWrapper.dataset.sdtId = 'sibling-sdt'; + siblingWrapper.dataset.sdtContainerId = 'parent-sdt'; + siblingWrapper.dataset.pmStart = '111'; + siblingWrapper.dataset.pmEnd = '120'; + + container.querySelector('.presentation-editor__pages')?.append(parentWrapper, childWrapper, siblingWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(childWrapper.classList.contains('ProseMirror-selectednode')).toBe(true); + expect(childWrapper.classList.contains('sdt-container-selected')).toBe(true); + expect(childWrapper.classList.contains('sdt-ancestor-selected')).toBe(false); + expect(parentWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); + expect(parentWrapper.classList.contains('sdt-container-selected')).toBe(false); + expect(parentWrapper.classList.contains('sdt-ancestor-selected')).toBe(true); + expect(siblingWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); + expect(siblingWrapper.classList.contains('sdt-container-selected')).toBe(false); + expect(siblingWrapper.classList.contains('sdt-ancestor-selected')).toBe(false); + }); }); describe('scrollToPosition', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/styles.test.js b/packages/super-editor/src/editors/v1/core/super-converter/styles.test.js index 187c660d1d..b5c67609c5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/styles.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/styles.test.js @@ -479,6 +479,30 @@ describe('decodeRPrFromMarks', () => { }); }); + it('ignores cleared per-script font attrs when decoding a user-applied fontFamily', () => { + const marks = [ + { + type: 'textStyle', + attrs: { + fontFamily: 'Courier New, monospace', + eastAsiaFontFamily: null, + csFontFamily: null, + }, + }, + ]; + + const rPr = decodeRPrFromMarks(marks); + + expect(rPr).toEqual({ + fontFamily: { + ascii: 'Courier New', + cs: 'Courier New', + eastAsia: 'Courier New', + hAnsi: 'Courier New', + }, + }); + }); + it('should decode textStyle with textTransform', () => { const marks = [{ type: 'textStyle', attrs: { textTransform: 'uppercase' } }]; const rPr = decodeRPrFromMarks(marks); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js index 68ea753d9b..a6490387f9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js @@ -399,6 +399,11 @@ describe('handleStructuredContentNode nested SDT import regression', () => { elements: [sdtPr(props), { name: 'w:sdtContent', elements: contentElements }], }); + const bookmarkStart = (id, name) => ({ + name: 'w:bookmarkStart', + attributes: { 'w:id': id, 'w:name': name }, + }); + const table = (text) => ({ name: 'w:tbl', elements: [ @@ -507,6 +512,43 @@ describe('handleStructuredContentNode nested SDT import regression', () => { expectSchemaValid(result); }); + it('keeps a nested block SDT when a bookmark starts before it in the parent SDT', () => { + const inner = sdt({ id: 'inner-after-bookmark', tag: 'inner-tag', alias: 'Inner Alias' }, [ + paragraph('Nested paragraph'), + ]); + const outer = sdt({ id: 'outer-bookmark', tag: 'outer-tag', alias: 'Outer Alias' }, [ + bookmarkStart('42', 'beforeInnerSdt'), + inner, + ]); + + const result = importNodes([outer]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('structuredContentBlock'); + expect(result[0].content?.map((node) => node.type)).toEqual(['paragraph', 'structuredContentBlock']); + expect(result[0].content?.[0]?.content).toContainEqual({ + type: 'bookmarkStart', + attrs: { + id: '42', + name: 'beforeInnerSdt', + }, + }); + + const nested = findFirstJson( + result[0], + (node) => node.type === 'structuredContentBlock' && node.attrs?.id === 'inner-after-bookmark', + ); + expect(nested).toBeTruthy(); + expect(nested.attrs).toMatchObject({ + id: 'inner-after-bookmark', + tag: 'inner-tag', + alias: 'Inner Alias', + controlType: 'richText', + }); + + expectSchemaValid(result); + }); + it('wraps nested inline SDT safely when an outer block SDT also contains paragraph and table content', () => { const inlineNested = sdt( { id: 'inner-inline', tag: 'inline-tag', alias: 'Inline Alias', lockMode: 'sdtContentLocked' }, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index aa856d55bb..c7f22ff75f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -690,197 +690,6 @@ function makeDocumentApiForEditor(editor: Editor) { return createDocumentApi(assembleDocumentApiAdapters(editor)); } -const V1_STUB_PARAGRAPH_TARGET = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }; -const V1_STUB_SECOND_PARAGRAPH_TARGET = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p2' }; -const V1_STUB_TABLE_ROW_TARGET = { kind: 'block' as const, nodeType: 'tableRow' as const, nodeId: 'row-1' }; - -function runV1CompatibilityStubMutation(operationId: OperationId, options?: any) { - switch (operationId) { - case 'blocks.split': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).blocks.split( - { target: V1_STUB_PARAGRAPH_TARGET, offset: 1 }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'blocks.merge': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).blocks.merge( - { first: V1_STUB_PARAGRAPH_TARGET, second: V1_STUB_SECOND_PARAGRAPH_TARGET }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'blocks.move': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).blocks.move( - { source: V1_STUB_PARAGRAPH_TARGET, destination: V1_STUB_SECOND_PARAGRAPH_TARGET, placement: 'before' }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'format.paragraph.setMarkRunProps': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).format.paragraph.setMarkRunProps( - { target: V1_STUB_PARAGRAPH_TARGET, markRunProps: { bold: true } }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'lists.apply': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).lists.apply( - { target: V1_STUB_PARAGRAPH_TARGET, seed: 'ordered' }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'lists.continue': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).lists.continue({ target: V1_STUB_PARAGRAPH_TARGET }, options); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'lists.restart': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).lists.restart( - { target: V1_STUB_PARAGRAPH_TARGET, startAt: 1 }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'lists.remove': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).lists.remove({ target: V1_STUB_PARAGRAPH_TARGET }, options); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - case 'tables.moveRow': { - const { editor, dispatch } = makeTextEditor(); - const result = makeDocumentApiForEditor(editor).tables.moveRow( - { target: V1_STUB_TABLE_ROW_TARGET, destination: { kind: 'last' } }, - options, - ); - if (options?.dryRun) expect(dispatch).not.toHaveBeenCalled(); - return result; - } - default: - throw new Error(`Unhandled v1 compatibility stub op ${operationId}`); - } -} - -function runV1CompatibilityStubThrowCase(operationId: OperationId) { - switch (operationId) { - case 'blocks.split': - return makeDocumentApiForEditor(makeTextEditor().editor).blocks.split({ - target: V1_STUB_PARAGRAPH_TARGET, - offset: -1, - } as any); - case 'blocks.merge': - return makeDocumentApiForEditor(makeTextEditor().editor).blocks.merge({ - first: V1_STUB_PARAGRAPH_TARGET, - second: { kind: 'block', nodeType: 'table', nodeId: 'table-1' }, - } as any); - case 'blocks.move': - return makeDocumentApiForEditor(makeTextEditor().editor).blocks.move({ - source: V1_STUB_PARAGRAPH_TARGET, - destination: V1_STUB_SECOND_PARAGRAPH_TARGET, - placement: 'sideways', - } as any); - case 'format.paragraph.setMarkRunProps': - return makeDocumentApiForEditor(makeTextEditor().editor).format.paragraph.setMarkRunProps({ - target: { kind: 'block', nodeType: 'table', nodeId: 'table-1' }, - markRunProps: { bold: true }, - } as any); - case 'lists.apply': - return makeDocumentApiForEditor(makeTextEditor().editor).lists.apply({ - target: V1_STUB_PARAGRAPH_TARGET, - seed: 'romanette', - } as any); - case 'lists.continue': - return makeDocumentApiForEditor(makeTextEditor().editor).lists.continue({ - target: { kind: 'block', nodeType: 'table', nodeId: 'table-1' }, - } as any); - case 'lists.restart': - return makeDocumentApiForEditor(makeTextEditor().editor).lists.restart({ - target: V1_STUB_PARAGRAPH_TARGET, - startAt: 1.5, - } as any); - case 'lists.remove': - return makeDocumentApiForEditor(makeTextEditor().editor).lists.remove({ - target: { kind: 'block', nodeType: 'table', nodeId: 'table-1' }, - } as any); - case 'tables.moveRow': - return makeDocumentApiForEditor(makeTextEditor().editor).tables.moveRow({ - nodeId: 'table-1', - destination: { kind: 'last' }, - } as any); - default: - throw new Error(`Unhandled v1 compatibility stub op ${operationId}`); - } -} - -const V1_COMPATIBILITY_STUB_MUTATION_VECTORS: Partial> = { - 'blocks.split': { - throwCase: () => runV1CompatibilityStubThrowCase('blocks.split'), - failureCase: () => runV1CompatibilityStubMutation('blocks.split', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('blocks.split', { changeMode: 'direct' }), - }, - 'blocks.merge': { - throwCase: () => runV1CompatibilityStubThrowCase('blocks.merge'), - failureCase: () => runV1CompatibilityStubMutation('blocks.merge', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('blocks.merge', { changeMode: 'direct' }), - }, - 'blocks.move': { - throwCase: () => runV1CompatibilityStubThrowCase('blocks.move'), - failureCase: () => runV1CompatibilityStubMutation('blocks.move', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('blocks.move', { changeMode: 'direct' }), - }, - 'format.paragraph.setMarkRunProps': { - throwCase: () => runV1CompatibilityStubThrowCase('format.paragraph.setMarkRunProps'), - failureCase: () => runV1CompatibilityStubMutation('format.paragraph.setMarkRunProps', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('format.paragraph.setMarkRunProps', { changeMode: 'direct' }), - }, - 'lists.apply': { - throwCase: () => runV1CompatibilityStubThrowCase('lists.apply'), - failureCase: () => runV1CompatibilityStubMutation('lists.apply', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('lists.apply', { changeMode: 'direct' }), - }, - 'lists.continue': { - throwCase: () => runV1CompatibilityStubThrowCase('lists.continue'), - failureCase: () => runV1CompatibilityStubMutation('lists.continue', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('lists.continue', { changeMode: 'direct' }), - }, - 'lists.restart': { - throwCase: () => runV1CompatibilityStubThrowCase('lists.restart'), - failureCase: () => runV1CompatibilityStubMutation('lists.restart', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('lists.restart', { changeMode: 'direct' }), - }, - 'lists.remove': { - throwCase: () => runV1CompatibilityStubThrowCase('lists.remove'), - failureCase: () => runV1CompatibilityStubMutation('lists.remove', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('lists.remove', { changeMode: 'direct' }), - }, - 'tables.moveRow': { - throwCase: () => runV1CompatibilityStubThrowCase('tables.moveRow'), - failureCase: () => runV1CompatibilityStubMutation('tables.moveRow', { changeMode: 'direct' }), - applyCase: () => runV1CompatibilityStubMutation('tables.moveRow', { changeMode: 'direct' }), - }, -}; - -const V1_COMPATIBILITY_STUB_DRY_RUN_VECTORS: Partial unknown>> = { - 'format.paragraph.setMarkRunProps': () => - runV1CompatibilityStubMutation('format.paragraph.setMarkRunProps', { changeMode: 'direct', dryRun: true }), - 'tables.moveRow': () => runV1CompatibilityStubMutation('tables.moveRow', { changeMode: 'direct', dryRun: true }), -}; - function makeTextEditor( text = 'Hello', overrides: Partial & { @@ -2049,17 +1858,6 @@ const IMPLEMENTED_TABLE_OPS: ReadonlySet = new Set([ /** Table stub ops that always throw CAPABILITY_UNAVAILABLE. */ const STUB_TABLE_OPS: ReadonlySet = new Set([] as OperationId[]); -const V1_COMPATIBILITY_STUB_OPS: ReadonlySet = new Set( - Object.keys(V1_COMPATIBILITY_STUB_MUTATION_VECTORS) as OperationId[], -); -const V1_COMPATIBILITY_STUB_BLOCK_OPS: ReadonlySet = new Set([ - 'blocks.split', - 'blocks.merge', - 'blocks.move', -] as OperationId[]); -const V1_COMPATIBILITY_STUB_DRY_RUN_OPS: ReadonlySet = new Set( - Object.keys(V1_COMPATIBILITY_STUB_DRY_RUN_VECTORS) as OperationId[], -); /** * Plan-engine meta-operations that don't follow the standard throw/failure/apply @@ -9460,7 +9258,6 @@ const mutationVectors: Partial> = { // Reference namespace mutation vectors // ------------------------------------------------------------------------- - ...V1_COMPATIBILITY_STUB_MUTATION_VECTORS, ...refNamespaceMutationVectors, }; @@ -11586,7 +11383,6 @@ const dryRunVectors: Partial unknown>> = { { changeMode: 'direct', dryRun: true }, ); }, - ...V1_COMPATIBILITY_STUB_DRY_RUN_VECTORS, 'footnotes.remove': () => { refResolverMocks.resolveFootnoteTarget.mockReturnValueOnce({ ...mockResolvedNode(1, 'fn-1', 'footnoteReference'), @@ -12219,9 +12015,7 @@ describe('document-api adapter conformance', () => { expect(result.success, `${operationId} failureCase should return success=false`).toBe(false); if (result.success !== false || !result.failure) return; expect(COMMAND_CATALOG[operationId].possibleFailureCodes).toContain(result.failure.code); - if (!V1_COMPATIBILITY_STUB_BLOCK_OPS.has(operationId)) { - assertSchema(operationId, 'output', result); - } + assertSchema(operationId, 'output', result); assertSchema(operationId, 'failure', result); }); @@ -12235,15 +12029,6 @@ describe('document-api adapter conformance', () => { const err = error as Error; throw new Error(`${operationId} threw post-apply: ${err.message}\n${err.stack ?? ''}`); } - if (V1_COMPATIBILITY_STUB_OPS.has(operationId)) { - expect(result.success, `${operationId} should report CAPABILITY_UNAVAILABLE on applyCase`).toBe(false); - expect(result.failure?.code).toBe('CAPABILITY_UNAVAILABLE'); - if (!V1_COMPATIBILITY_STUB_BLOCK_OPS.has(operationId)) { - assertSchema(operationId, 'output', result); - } - assertSchema(operationId, 'failure', result); - return; - } expect(result.success, `${operationId} should report success on applyCase`).toBe(true); assertSchema(operationId, 'output', result); assertSchema(operationId, 'success', result); @@ -12253,13 +12038,6 @@ describe('document-api adapter conformance', () => { const run = dryRunVectors[operationId]!; // Promise-aware: async operations (e.g. templates.apply) return a Promise. const result = (await Promise.resolve(run())) as { success?: boolean; failure?: { code?: string } }; - if (V1_COMPATIBILITY_STUB_DRY_RUN_OPS.has(operationId)) { - expect(result.success, `${operationId} dryRun should report CAPABILITY_UNAVAILABLE`).toBe(false); - expect(result.failure?.code).toBe('CAPABILITY_UNAVAILABLE'); - assertSchema(operationId, 'output', result); - assertSchema(operationId, 'failure', result); - return; - } expect(result.success).toBe(true); assertSchema(operationId, 'output', result); assertSchema(operationId, 'success', result); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts index b3055a0318..9aff5d2d76 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts @@ -43,7 +43,6 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('paragraphs.clearBorder'); expect(adapters).toHaveProperty('paragraphs.setShading'); expect(adapters).toHaveProperty('paragraphs.clearShading'); - expect(adapters).toHaveProperty('paragraphs.setMarkRunProps'); expect(adapters).toHaveProperty('paragraphs.setDirection'); expect(adapters).toHaveProperty('paragraphs.clearDirection'); expect(adapters).toHaveProperty('trackChanges.list'); @@ -55,16 +54,8 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('create.paragraph'); expect(adapters).toHaveProperty('create.heading'); expect(adapters).toHaveProperty('create.sectionBreak'); - expect(adapters).toHaveProperty('blocks.split'); - expect(adapters).toHaveProperty('blocks.merge'); - expect(adapters).toHaveProperty('blocks.move'); expect(adapters).toHaveProperty('lists.list'); expect(adapters).toHaveProperty('lists.get'); - expect(adapters).toHaveProperty('lists.getState'); - expect(adapters).toHaveProperty('lists.apply'); - expect(adapters).toHaveProperty('lists.continue'); - expect(adapters).toHaveProperty('lists.restart'); - expect(adapters).toHaveProperty('lists.remove'); expect(adapters).toHaveProperty('lists.insert'); expect(adapters).toHaveProperty('lists.indent'); expect(adapters).toHaveProperty('lists.outdent'); @@ -101,7 +92,6 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('tables.get'); expect(adapters).toHaveProperty('tables.getCells'); expect(adapters).toHaveProperty('tables.getProperties'); - expect(adapters).toHaveProperty('tables.moveRow'); expect(adapters).toHaveProperty('create.tableOfContents'); expect(adapters).toHaveProperty('toc.list'); expect(adapters).toHaveProperty('toc.get'); @@ -121,29 +111,19 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.paragraphs.setStyle).toBe('function'); expect(typeof adapters.paragraphs.setAlignment).toBe('function'); expect(typeof adapters.paragraphs.setBorder).toBe('function'); - expect(typeof adapters.paragraphs.setMarkRunProps).toBe('function'); expect(typeof adapters.paragraphs.setDirection).toBe('function'); expect(typeof adapters.paragraphs.clearDirection).toBe('function'); expect(typeof adapters.create.paragraph).toBe('function'); expect(typeof adapters.create.heading).toBe('function'); expect(typeof adapters.create.sectionBreak).toBe('function'); - expect(typeof adapters.blocks.split).toBe('function'); - expect(typeof adapters.blocks.merge).toBe('function'); - expect(typeof adapters.blocks.move).toBe('function'); expect(typeof adapters.create.tableOfContents).toBe('function'); expect(typeof adapters.lists.insert).toBe('function'); - expect(typeof adapters.lists.getState).toBe('function'); - expect(typeof adapters.lists.apply).toBe('function'); - expect(typeof adapters.lists.continue).toBe('function'); - expect(typeof adapters.lists.restart).toBe('function'); - expect(typeof adapters.lists.remove).toBe('function'); expect(typeof adapters.sections.list).toBe('function'); expect(typeof adapters.sections.setBreakType).toBe('function'); expect(typeof adapters.sections.setOddEvenHeadersFooters).toBe('function'); expect(typeof adapters.tables.get).toBe('function'); expect(typeof adapters.tables.getCells).toBe('function'); expect(typeof adapters.tables.getProperties).toBe('function'); - expect(typeof adapters.tables.moveRow).toBe('function'); expect(typeof adapters.toc.list).toBe('function'); expect(typeof adapters.toc.get).toBe('function'); expect(typeof adapters.toc.configure).toBe('function'); @@ -152,49 +132,4 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.ranges.resolve).toBe('function'); expect(typeof adapters.selection!.current).toBe('function'); }); - - it('returns CAPABILITY_UNAVAILABLE receipts for v2-only compatibility stubs', () => { - const adapters = assembleDocumentApiAdapters(makeEditor()); - - expect( - adapters.blocks.split({ target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, offset: 1 }), - ).toEqual({ - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: 'blocks.split is only available on v2-backed sessions.', - }, - }); - expect( - adapters.paragraphs.setMarkRunProps({ - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, - markRunProps: {}, - }), - ).toEqual({ - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: 'format.paragraph.setMarkRunProps is only available on v2-backed sessions.', - }, - }); - expect(adapters.lists.getState({ target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } })).toEqual({ - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: 'lists.getState is only available on v2-backed sessions.', - }, - }); - expect( - adapters.tables.moveRow({ - target: { kind: 'block', nodeType: 'tableRow', nodeId: 'row1' }, - destination: { kind: 'last' }, - }), - ).toEqual({ - success: false, - failure: { - code: 'CAPABILITY_UNAVAILABLE', - message: 'tables.moveRow is only available on v2-backed sessions.', - }, - }); - }); }); 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 bc20659231..ad58537608 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 @@ -365,13 +365,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters registerPartDescriptor(numberingPartDescriptor); const ccAdapter = createContentControlsAdapter(editor); - const capabilityUnavailable = (message: string) => ({ - success: false as const, - failure: { - code: 'CAPABILITY_UNAVAILABLE' as const, - message, - }, - }); // Register the setValue delegate for the restartAt wrapper registerSetValueDelegate((ed, input, options) => listsSetValueWrapper(ed, input, options)); @@ -446,8 +439,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters clearBorder: (input, options) => paragraphsClearBorderWrapper(editor, input, options), setShading: (input, options) => paragraphsSetShadingWrapper(editor, input, options), clearShading: (input, options) => paragraphsClearShadingWrapper(editor, input, options), - setMarkRunProps: () => - capabilityUnavailable('format.paragraph.setMarkRunProps is only available on v2-backed sessions.'), setDirection: (input, options) => paragraphsSetDirectionWrapper(editor, input, options), clearDirection: (input, options) => paragraphsClearDirectionWrapper(editor, input, options), setNumbering: (input, options) => paragraphsSetNumberingWrapper(editor, input, options), @@ -465,9 +456,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters list: (input) => blocksListWrapper(editor, input), delete: (input, options) => blocksDeleteWrapper(editor, input, options), deleteRange: (input, options) => blocksDeleteRangeWrapper(editor, input, options), - split: () => capabilityUnavailable('blocks.split is only available on v2-backed sessions.'), - merge: () => capabilityUnavailable('blocks.merge is only available on v2-backed sessions.'), - move: () => capabilityUnavailable('blocks.move is only available on v2-backed sessions.'), }, create: { paragraph: (input, options) => createParagraphWrapper(editor, input, options), @@ -520,11 +508,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters setLevelText: (input, options) => listsSetLevelTextWrapper(editor, input, options), setLevelStart: (input, options) => listsSetLevelStartWrapper(editor, input, options), setLevelLayout: (input, options) => listsSetLevelLayoutWrapper(editor, input, options), - getState: () => capabilityUnavailable('lists.getState is only available on v2-backed sessions.'), - apply: () => capabilityUnavailable('lists.apply is only available on v2-backed sessions.'), - continue: () => capabilityUnavailable('lists.continue is only available on v2-backed sessions.'), - restart: () => capabilityUnavailable('lists.restart is only available on v2-backed sessions.'), - remove: () => capabilityUnavailable('lists.remove is only available on v2-backed sessions.'), }, sections: { list: (query) => sectionsListAdapter(editor, query), @@ -556,7 +539,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters setLayout: (input, options) => tablesSetLayoutWrapper(editor, input, options), insertRow: (input, options) => tablesInsertRowWrapper(editor, input, options), deleteRow: (input, options) => tablesDeleteRowWrapper(editor, input, options), - moveRow: () => capabilityUnavailable('tables.moveRow is only available on v2-backed sessions.'), setRowHeight: (input, options) => tablesSetRowHeightWrapper(editor, input, options), distributeRows: (input, options) => tablesDistributeRowsWrapper(editor, input, options), setRowOptions: (input, options) => tablesSetRowOptionsWrapper(editor, input, options), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts index 02467fe5d6..26d7c4dc27 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts @@ -322,27 +322,6 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.operations['blocks.delete'].tracked).toBe(true); }); - it('marks v2-only compatibility stub operations as unavailable in v1', () => { - const capabilities = getDocumentApiCapabilities(makeEditor()); - const stubbedOperations = [ - 'blocks.split', - 'blocks.merge', - 'blocks.move', - 'lists.getState', - 'lists.apply', - 'lists.continue', - 'lists.restart', - 'lists.remove', - 'format.paragraph.setMarkRunProps', - 'tables.moveRow', - ] as const; - - for (const operationId of stubbedOperations) { - expect(capabilities.operations[operationId].available).toBe(false); - expect(capabilities.operations[operationId].reasons).toContain('OPERATION_UNAVAILABLE'); - } - }); - it('uses OPERATION_UNAVAILABLE without COMMAND_UNAVAILABLE for non-command-backed availability failures', () => { const capabilities = getDocumentApiCapabilities( makeEditor({ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts index 9e807e9f03..13c96d9d57 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts @@ -260,19 +260,6 @@ const REQUIRED_HELPERS: Partial boolean> }, }; -const V1_STUBBED_OPERATIONS = new Set([ - 'blocks.split', - 'blocks.merge', - 'blocks.move', - 'lists.getState', - 'lists.apply', - 'lists.continue', - 'lists.restart', - 'lists.remove', - 'format.paragraph.setMarkRunProps', - 'tables.moveRow', -]); - // --------------------------------------------------------------------------- // Schema-node gating for specialized namespaces // --------------------------------------------------------------------------- @@ -476,10 +463,6 @@ function isTemplatesApplyAvailable(editor: Editor): boolean { } function isOperationAvailable(editor: Editor, operationId: OperationId): boolean { - if (V1_STUBBED_OPERATIONS.has(operationId)) { - return false; - } - // format.apply is available when at least one inline property can be executed. if (operationId === 'format.apply') { return INLINE_PROPERTY_REGISTRY.some((property) => isInlinePropertyAvailable(editor, property)); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/collaboration-cursor-encoder.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/collaboration-cursor-encoder.ts new file mode 100644 index 0000000000..1aa3339562 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/collaboration-cursor-encoder.ts @@ -0,0 +1,57 @@ +/** + * Collaboration-cursor encoder: converts a Document API SelectionTarget into + * the Yjs awareness `cursor` payload (relative anchor/head positions). + * + * This is the engine's single source of truth for "selection target to awareness + * cursor", so hosts and apps never import y-prosemirror or reach into editor + * internals to publish presence. It mirrors the encode-and-guard logic in + * `RemoteCursorManager.updateLocalCursor` (the read/render side lives in + * `RemoteCursorAwareness.normalizeAwarenessStates`), but resolves an arbitrary + * SelectionTarget rather than the local editor selection. + */ + +import { ySyncPluginKey, absolutePositionToRelativePosition } from 'y-prosemirror'; +import type { SelectionTarget } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { resolveSelectionTarget } from './selection-target-resolver.js'; + +/** The Yjs awareness `cursor` field shape consumed by the cursor render side. */ +export interface CollaborationCursorPayload { + anchor: unknown; + head: unknown; +} + +/** + * Encode a SelectionTarget into a collaboration-cursor awareness payload. + * + * Returns `null` when the editor has no collaborative Yjs binding (no ySync + * plugin / binding mapping), or when position conversion fails. Callers should + * treat `null` as "not a collaborative session". Target-shape and + * target-not-found errors propagate from {@link resolveSelectionTarget} so the + * caller surfaces them; this function does not invent a looser shape. + * + * @param editor - The engine editor (collaboratively bound). + * @param target - A Document API SelectionTarget (a collapsed start==end target is a caret). + */ +export function encodeCollaborationCursorFromSelectionTarget( + editor: Editor, + target: SelectionTarget, +): CollaborationCursorPayload | null { + const { absFrom, absTo } = resolveSelectionTarget(editor, target); + + const ystate = ySyncPluginKey.getState(editor.state); + if (!ystate?.binding?.mapping) return null; + + // Position conversion can throw during document restructuring; mirror + // RemoteCursorManager.updateLocalCursor and treat any failure as "no cursor" + // (returns null), honoring this function's documented contract. + try { + const anchor = absolutePositionToRelativePosition(absFrom, ystate.type, ystate.binding.mapping); + const head = absolutePositionToRelativePosition(absTo, ystate.type, ystate.binding.mapping); + if (!anchor || !head) return null; + + return { anchor, head }; + } catch { + return null; + } +} 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 9e667fd8bc..5cf46e1d85 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 @@ -2825,6 +2825,147 @@ describe('span target contiguity checks', () => { expect(tr.delete).toHaveBeenCalledWith(6, 14); }); + it('marks tracked span rewrites as changed for later batch fallback', () => { + const meta = new Map([ + ['forceTrackChanges', true], + ['documentApiTrackedRewriteBatch', true], + ]); + const schema = { + nodes: {}, + text: vi.fn((text: string, marks?: unknown[]) => ({ type: { name: 'text' }, text, marks: marks ?? [] })), + }; + const tr = { + replace: vi.fn(), + replaceWith: vi.fn(), + getMeta: vi.fn((key: string) => meta.get(key)), + setMeta: vi.fn((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }), + doc: {}, + }; + tr.replaceWith.mockReturnValue(tr); + + const target = { + kind: 'span', + stepId: 'step-span-rewrite', + op: 'text.rewrite', + matchId: 'm:tracked-span', + segments: [{ blockId: 'p1', from: 0, to: 3, absFrom: 1, absTo: 4 }], + text: 'abc', + marks: [], + capturedStyleBySegment: [], + } as any; + const step: TextRewriteStep = { + id: 'step-span-rewrite', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'abc' }, require: 'exactlyOne' }, + args: { replacement: { text: 'xyz' } }, + }; + + executeSpanTextRewrite({ state: { schema } } as unknown as Editor, tr as any, target, step, { + map: (pos: number) => pos, + } as any); + + expect(tr.getMeta('documentApiTrackedRewriteChanged')).toBe(true); + }); + + it('does not mark tracked span rewrite no-ops as changed', () => { + const meta = new Map([ + ['forceTrackChanges', true], + ['documentApiTrackedRewriteBatch', true], + ]); + const schema = { + nodes: {}, + text: vi.fn((text: string, marks?: unknown[]) => ({ type: { name: 'text' }, text, marks: marks ?? [] })), + }; + const tr = { + replace: vi.fn(), + replaceWith: vi.fn(), + getMeta: vi.fn((key: string) => meta.get(key)), + setMeta: vi.fn((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }), + doc: {}, + }; + + const target = { + kind: 'span', + stepId: 'step-span-rewrite', + op: 'text.rewrite', + matchId: 'm:tracked-span-noop', + segments: [{ blockId: 'p1', from: 0, to: 3, absFrom: 1, absTo: 4 }], + text: 'abc', + marks: [], + capturedStyleBySegment: [], + } as any; + const step: TextRewriteStep = { + id: 'step-span-rewrite', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'abc' }, require: 'exactlyOne' }, + args: { replacement: { text: 'abc' } }, + }; + + const outcome = executeSpanTextRewrite({ state: { schema } } as unknown as Editor, tr as any, target, step, { + map: (pos: number) => pos, + } as any); + + expect(outcome).toEqual({ changed: false }); + expect(tr.replaceWith).not.toHaveBeenCalled(); + expect(tr.getMeta('documentApiTrackedRewriteChanged')).toBeUndefined(); + }); + + it('does not no-op same-text cross-block span rewrites', () => { + const meta = new Map([ + ['forceTrackChanges', true], + ['documentApiTrackedRewriteBatch', true], + ]); + const schema = { + nodes: {}, + text: vi.fn((text: string, marks?: unknown[]) => ({ type: { name: 'text' }, text, marks: marks ?? [] })), + }; + const tr = { + replace: vi.fn(), + replaceWith: vi.fn(), + getMeta: vi.fn((key: string) => meta.get(key)), + setMeta: vi.fn((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }), + doc: {}, + }; + tr.replaceWith.mockReturnValue(tr); + + const target = { + kind: 'span', + stepId: 'step-span-rewrite', + op: 'text.rewrite', + matchId: 'm:tracked-span-cross-block-same-text', + segments: [ + { blockId: 'p1', from: 0, to: 3, absFrom: 1, absTo: 4 }, + { blockId: 'p2', from: 0, to: 3, absFrom: 6, absTo: 9 }, + ], + text: 'abcdef', + marks: [], + capturedStyleBySegment: [], + } as any; + const step: TextRewriteStep = { + id: 'step-span-rewrite', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'abcdef' }, require: 'exactlyOne' }, + args: { replacement: { text: 'abcdef' } }, + }; + + const outcome = executeSpanTextRewrite({ state: { schema } } as unknown as Editor, tr as any, target, step, { + map: (pos: number) => pos, + } as any); + + expect(outcome).toEqual({ changed: true }); + expect(tr.replaceWith).toHaveBeenCalledWith(1, 9, { type: { name: 'text' }, text: 'abcdef', marks: [] }); + expect(tr.getMeta('documentApiTrackedRewriteChanged')).toBe(true); + }); + it('inherits paragraph-level attrs for multi-block span rewrites without copying ids', () => { const schema = new Schema({ nodes: { @@ -2917,6 +3058,96 @@ describe('span target contiguity checks', () => { expect(second.attrs.paraId).toBeNull(); expect(second.attrs.sdBlockId).toBeNull(); }); + + it('marks multi-block tracked span rewrites as changed for later batch fallback', () => { + // Sibling of "marks tracked span rewrites as changed for later batch fallback" + // but exercises the MULTI-block span branch (replacement.blocks.length > 1), + // which routes through tr.replace(slice). Without that branch marking the + // transaction, a later rewrite in the same tracked batch keeps using the + // granular word-diff against stale positions — the SD-3478 corruption. + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'text*', + attrs: { + paragraphProperties: { default: null }, + listRendering: { default: null }, + paraId: { default: null }, + sdBlockId: { default: null }, + }, + }, + text: { group: 'inline' }, + }, + }); + + const sourceP1 = schema.nodes.paragraph.create({ + paragraphProperties: { styleId: 'Heading2' }, + paraId: 'p1', + sdBlockId: 'sd-p1', + }); + const sourceP2 = schema.nodes.paragraph.create({ + paragraphProperties: { styleId: 'Normal' }, + paraId: 'p2', + sdBlockId: 'sd-p2', + }); + + mockedDeps.getBlockIndex.mockReturnValue({ + candidates: [ + { nodeId: 'p1', node: sourceP1, pos: 0, end: 12 }, + { nodeId: 'p2', node: sourceP2, pos: 20, end: 32 }, + ], + }); + + const meta = new Map([ + ['forceTrackChanges', true], + ['documentApiTrackedRewriteBatch', true], + ]); + const tr = { + replace: vi.fn(), + replaceWith: vi.fn(), + getMeta: vi.fn((key: string) => meta.get(key)), + setMeta: vi.fn((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }), + doc: {}, + }; + + const target = { + kind: 'span', + stepId: 'step-span-rewrite', + op: 'text.rewrite', + matchId: 'm:tracked-multi-block-span', + segments: [ + { blockId: 'p1', from: 0, to: 3, absFrom: 1, absTo: 4 }, + { blockId: 'p2', from: 0, to: 3, absFrom: 6, absTo: 9 }, + ], + text: 'abcdef', + marks: [], + capturedStyleBySegment: [], + } as any; + + const step: TextRewriteStep = { + id: 'step-span-rewrite', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'abcdef' }, require: 'exactlyOne' }, + args: { + replacement: { blocks: [{ text: 'alpha' }, { text: 'beta' }] }, + style: { inline: { mode: 'clear' }, paragraph: { mode: 'preserve' } }, + }, + }; + + const outcome = executeSpanTextRewrite({ state: { schema } } as unknown as Editor, tr as any, target, step, { + map: (pos: number) => pos, + } as any); + + // Multi-block replacement goes through tr.replace(slice), not tr.replaceWith. + expect(outcome).toEqual({ changed: true }); + expect(tr.replace).toHaveBeenCalledTimes(1); + expect(tr.getMeta('documentApiTrackedRewriteChanged')).toBe(true); + }); }); // --------------------------------------------------------------------------- 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 09bc17e5d9..8356f0cb69 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 @@ -163,12 +163,59 @@ const DEBUG_TEXT_REWRITE = typeof process !== 'undefined' && typeof process.env?.SUPERDOC_DEBUG_TEXT_REWRITE === 'string' ? process.env.SUPERDOC_DEBUG_TEXT_REWRITE === '1' : false; +const TRACKED_REWRITE_BATCH_META = 'documentApiTrackedRewriteBatch'; +const TRACKED_REWRITE_CHANGED_META = 'documentApiTrackedRewriteChanged'; +// Keep <=9-change single rewrites granular so unchanged anchors stay live. The +// SD-3478 large-rewrite regressions cross this boundary and need coarse replace +// to avoid remapped tracked diff steps leaving corrupted anchors in accepted text. +const MAX_GRANULAR_TRACKED_REWRITE_CHANGES = 9; function debugTextRewrite(message: string, details?: Record): void { if (!DEBUG_TEXT_REWRITE) return; console.error('[text-rewrite]', message, details ?? {}); } +function isTrackedMutationTransaction(tr: Transaction): boolean { + return tr.getMeta?.('forceTrackChanges') === true; +} + +function isTrackedRewriteBatchTransaction(tr: Transaction): boolean { + // The first changed rewrite can still use granular word-diff safely; later + // changed rewrites in the same tracked batch use a coarse replace so previous + // tracked insert/delete maps do not corrupt their positions. + return ( + isTrackedMutationTransaction(tr) && tr.getMeta(TRACKED_REWRITE_BATCH_META) === true && hasTrackedRewriteChanged(tr) + ); +} + +function shouldUseCoarseSingleTrackedRewrite(tr: Transaction, wordChangeCount: number): boolean { + return isTrackedMutationTransaction(tr) && wordChangeCount > MAX_GRANULAR_TRACKED_REWRITE_CHANGES; +} + +function hasTrackedRewriteChanged(tr: Transaction): boolean { + return tr.getMeta?.(TRACKED_REWRITE_CHANGED_META) === true; +} + +function markTrackedRewriteChanged(tr: Transaction): void { + if (!isTrackedMutationTransaction(tr)) return; + tr.setMeta?.(TRACKED_REWRITE_CHANGED_META, true); +} + +function countTextRewriteTargets(compiled: CompiledPlan): number { + return compiled.mutationSteps.reduce((count, compiledStep) => { + return compiledStep.step.op === 'text.rewrite' ? count + compiledStep.targets.length : count; + }, 0); +} + +export function applyMutationPlanMeta(tr: Transaction, compiled: CompiledPlan, changeMode: 'direct' | 'tracked'): void { + if (changeMode === 'tracked') { + applyTrackedMutationMeta(tr); + if (countTextRewriteTargets(compiled) > 1) tr.setMeta(TRACKED_REWRITE_BATCH_META, true); + } else { + applyDirectMutationMeta(tr); + } +} + type StructuredTextPayload = { blocks: string[]; splitBefore: boolean; @@ -882,6 +929,10 @@ export function executeTextRewrite( step: TextRewriteStep, mapping: Mapping, ): { changed: boolean } { + const finish = (changed: boolean): { changed: boolean } => { + if (changed) markTrackedRewriteChanged(tr); + return { changed }; + }; const absFrom = mapping.map(target.absFrom); const absTo = mapping.map(target.absTo); @@ -897,6 +948,17 @@ export function executeTextRewrite( const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; const parentAllowsLineBreakAt = (pos: number): boolean => lineBreakNodeType ? parentAllowsNodeAt(tr, pos, lineBreakNodeType) : false; + const replaceTextRange = (from: number, to: number, text: string, parentPos: number): void => { + if (text.length === 0) { + tr.delete(from, to); + return; + } + + const content = buildTextWithTabs(editor.state.schema, text, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(parentPos), + }); + tr.replaceWith(from, to, content); + }; const structuralRewrite = resolveStructuralRangeRewrite(tr.doc, absFrom, absTo, step); if (structuralRewrite) { @@ -917,7 +979,7 @@ export function executeTextRewrite( // containers that cannot host sibling paragraph nodes. tr.doc.replace(structuralRewrite.replaceFrom, structuralRewrite.replaceTo, slice); tr.replace(structuralRewrite.replaceFrom, structuralRewrite.replaceTo, slice); - return { changed: replacementText !== target.text }; + return finish(replacementText !== target.text); } catch (error) { debugTextRewrite('structural rewrite fell back to inline replacement', { replaceFrom: structuralRewrite.replaceFrom, @@ -942,15 +1004,8 @@ export function executeTextRewrite( 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), { - parentAllowsLineBreak: parentAllowsLineBreakAt(absFrom), - }); - tr.replaceWith(absFrom, absTo, content); - return { changed: replacementText !== target.text }; + replaceTextRange(absFrom, absTo, replacementText, absFrom); + return finish(replacementText.length === 0 ? target.text.length > 0 : replacementText !== target.text); } let prefix = 0; @@ -958,7 +1013,7 @@ export function executeTextRewrite( prefix++; } if (prefix === origLen && prefix === replLen) { - return { changed: false }; // texts are identical + return finish(false); // texts are identical } let suffix = 0; while ( @@ -974,9 +1029,19 @@ export function executeTextRewrite( const trimmedOld = originalText.slice(prefix, origLen - suffix); const trimmedNew = replacementText.slice(prefix, replLen - suffix); + if (isTrackedRewriteBatchTransaction(tr)) { + replaceTextRange(absFrom, absTo, replacementText, absFrom); + return finish(replacementText !== target.text); + } + // 2. Word-level diff on the trimmed range for multi-word granularity. const wordChanges = getWordChanges(trimmedOld, trimmedNew); + if (shouldUseCoarseSingleTrackedRewrite(tr, wordChanges.length)) { + replaceTextRange(absFrom, absTo, replacementText, absFrom); + return finish(replacementText !== target.text); + } + if (wordChanges.length > 1) { // Multiple word-level changes: apply each granularly. const doc = tr.doc; @@ -1031,7 +1096,7 @@ export function executeTextRewrite( tr.replaceWith(trimmedFrom, trimmedTo, content); } - return { changed: replacementText !== target.text }; + return finish(replacementText !== target.text); } /** @@ -1347,6 +1412,8 @@ export function executeSpanTextRewrite( // Build replacement content: one text node per block, separated by paragraph nodes // For single replacement block, use flat replacement into the span if (replacementBlocks.length === 1) { + if (target.segments.length === 1 && replacementBlocks[0] === target.text) return { changed: false }; + const marks = resolveSpanMarks(editor, target, policy, step.id); // Probe parent admission so a newline replacement into a `text*`-only span // parent falls back to literal text (the export safety net handles the rest). @@ -1356,6 +1423,7 @@ export function executeSpanTextRewrite( parentAllowsLineBreak, }); tr.replaceWith(absFrom, absTo, content); + markTrackedRewriteChanged(tr); return { changed: true }; } @@ -1383,6 +1451,7 @@ export function executeSpanTextRewrite( const slice = new Slice(Fragment.from(nodes), 1, 1); tr.replace(absFrom, absTo, slice); + markTrackedRewriteChanged(tr); return { changed: true }; } @@ -2378,11 +2447,7 @@ export function executeCompiledPlan( const tr = editor.state.tr; const changeMode = options.changeMode ?? 'direct'; - if (changeMode === 'tracked') { - applyTrackedMutationMeta(tr); - } else { - applyDirectMutationMeta(tr); - } + applyMutationPlanMeta(tr, compiled, changeMode); const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: true, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts index 07d4e048dd..94a3dace43 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/footnote-wrappers.ts @@ -233,7 +233,7 @@ export function footnotesInsertWrapper( if (input.body !== undefined) { return footnoteFailure( 'CAPABILITY_UNAVAILABLE', - 'footnotes.insert structured body content is only available on v2-backed sessions.', + 'footnotes.insert structured body content is not supported by the v1 editor runtime.', ); } @@ -317,7 +317,7 @@ export function footnotesUpdateWrapper( if (input.patch.body !== undefined) { return footnoteFailure( 'CAPABILITY_UNAVAILABLE', - 'footnotes.update structured body content is only available on v2-backed sessions.', + 'footnotes.update structured body content is not supported by the v1 editor runtime.', ); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview-parity.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview-parity.test.ts index 42cb1cdbe8..68a3120a1c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview-parity.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview-parity.test.ts @@ -83,6 +83,16 @@ beforeEach(() => { mockedDeps.getRevision.mockReturnValue('0'); mockedDeps.incrementRevision.mockReturnValue('1'); mockedDeps.mapBlockNodeType.mockReturnValue(undefined); + mockedDeps.applyDirectMutationMeta.mockImplementation((tr) => { + tr.setMeta('inputType', 'programmatic'); + tr.setMeta('skipTrackChanges', true); + return tr; + }); + mockedDeps.applyTrackedMutationMeta.mockImplementation((tr) => { + tr.setMeta('inputType', 'programmatic'); + tr.setMeta('forceTrackChanges', true); + return tr; + }); }); // --------------------------------------------------------------------------- @@ -102,6 +112,7 @@ function makeEditor(text = 'Hello'): { dispatch: ReturnType; } { const boldMark = mockMark('bold'); + const meta = new Map(); const tr = { replaceWith: vi.fn(), delete: vi.fn(), @@ -109,6 +120,7 @@ function makeEditor(text = 'Hello'): { addMark: vi.fn(), removeMark: vi.fn(), setMeta: vi.fn(), + getMeta: vi.fn(), mapping: { map: (pos: number) => pos }, docChanged: true, doc: { @@ -124,7 +136,11 @@ function makeEditor(text = 'Hello'): { tr.insert.mockReturnValue(tr); tr.addMark.mockReturnValue(tr); tr.removeMark.mockReturnValue(tr); - tr.setMeta.mockReturnValue(tr); + tr.setMeta.mockImplementation((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }); + tr.getMeta.mockImplementation((key: string) => meta.get(key)); const dispatch = vi.fn(); @@ -211,6 +227,7 @@ function makeTextStyleEditor(textStyleAttrNames: string[]): { }; const textNode = { isText: true, nodeSize: 5, marks: [] as unknown[] }; + const meta = new Map(); const doc = { textContent: 'Hello', textBetween: vi.fn(() => 'Hello'), @@ -227,6 +244,7 @@ function makeTextStyleEditor(textStyleAttrNames: string[]): { addMark: vi.fn(), removeMark: vi.fn(), setMeta: vi.fn(), + getMeta: vi.fn(), mapping: { map: (pos: number) => pos }, docChanged: true, doc: { @@ -243,7 +261,11 @@ function makeTextStyleEditor(textStyleAttrNames: string[]): { tr.insert.mockReturnValue(tr); tr.addMark.mockReturnValue(tr); tr.removeMark.mockReturnValue(tr); - tr.setMeta.mockReturnValue(tr); + tr.setMeta.mockImplementation((key: string, value: unknown) => { + meta.set(key, value); + return tr; + }); + tr.getMeta.mockImplementation((key: string) => meta.get(key)); const dispatch = vi.fn(); @@ -310,6 +332,38 @@ describe('previewPlan: non-mutating guarantee', () => { expect(dispatch).not.toHaveBeenCalled(); }); + it('applies tracked rewrite batch metadata for multi-target previews', () => { + const { editor } = makeEditor('foo bar baz'); + const step: TextRewriteStep = { + id: 'rewrite-all', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'foo bar baz' }, require: 'all' }, + args: { replacement: { text: 'foo qux baz zap' } }, + }; + + mockedDeps.compilePlan.mockReturnValue( + makeCompiledPlan({ + mutationSteps: [ + { + step, + targets: [ + makeTarget({ stepId: step.id, text: 'foo bar baz', absFrom: 1, absTo: 12 }), + makeTarget({ stepId: step.id, blockId: 'p2', text: 'foo bar baz', absFrom: 14, absTo: 25 }), + ], + }, + ], + }), + ); + + previewPlan(editor, { steps: [step], changeMode: 'tracked' }); + + expect(mockedDeps.applyTrackedMutationMeta).toHaveBeenCalledWith(editor.state.tr); + expect(editor.state.tr.setMeta).toHaveBeenCalledWith('documentApiTrackedRewriteBatch', true); + expect(editor.state.tr.getMeta('documentApiTrackedRewriteBatch')).toBe(true); + expect(editor.state.tr.replaceWith).toHaveBeenCalledTimes(2); + expect(editor.state.tr.insert).not.toHaveBeenCalled(); + }); + it('does not dispatch even when the plan would fail at compile', () => { const { editor, dispatch } = makeEditor(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview.ts index 4fdfe0c2b5..d388379d4d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/preview.ts @@ -16,7 +16,7 @@ import type { import type { Editor } from '../../core/Editor.js'; import { checkRevision, getRevision } from './revision-tracker.js'; import { compilePlan } from './compiler.js'; -import { runMutationsOnTransaction } from './executor.js'; +import { applyMutationPlanMeta, runMutationsOnTransaction } from './executor.js'; import { planError, PlanError } from './errors.js'; export function previewPlan(editor: Editor, input: MutationsPreviewInput): MutationsPreviewOutput { @@ -43,17 +43,22 @@ export function previewPlan(editor: Editor, input: MutationsPreviewInput): Mutat // PreviewFailure). The mutation path (`executeCompiledPlan`) does run // the repair, so calling `mutations.apply` on the same state recovers // automatically. - const compiled = compilePlan(editor, input.steps, { skipIdentityRepair: true }); + const compiled = compilePlan(editor, input.steps, { + skipIdentityRepair: true, + selectTextModel: input.changeMode === 'tracked' ? 'raw' : 'visible', + }); evaluatedRevision = compiled.compiledRevision; currentPhase = 'execute'; // Phase 2: Execute on ephemeral transaction (never dispatched) const tr = editor.state.tr; + const changeMode = input.changeMode ?? 'direct'; + applyMutationPlanMeta(tr, compiled, changeMode); // Run mutations without throwing on assert failure — collect failures instead const { stepOutcomes, assertFailures } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false, - changeMode: input.changeMode ?? 'direct', + changeMode, isPreview: true, }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite-batch-performance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite-batch-performance.test.ts new file mode 100644 index 0000000000..3f09c8d030 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite-batch-performance.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { TrackDeleteMarkName, TrackInsertMarkName } from '../../extensions/track-changes/constants.js'; +import { getWordChanges } from './word-diff.js'; + +vi.mock('./word-diff.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getWordChanges: vi.fn(actual.getWordChanges), + }; +}); + +function makeEditor(paragraphs: string[]) { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: paragraphs.map((text) => ({ + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'text', text }], + }, + ], + })), + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; +} + +function markedTextByAuthor(editor: any, markName: string, authorEmail: string): string { + const parts: string[] = []; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + if (node.marks.some((mark: any) => mark.type.name === markName && mark.attrs.authorEmail === authorEmail)) { + parts.push(node.text); + } + }); + return parts.join(''); +} + +describe('tracked rewrite batch performance', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + vi.clearAllMocks(); + }); + + it('skips word diff for later tracked batch rewrite targets', () => { + editor = makeEditor(['foo bar baz', 'foo bar baz']); + const mockedGetWordChanges = vi.mocked(getWordChanges); + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-all', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'foo bar baz' }, require: 'all' }, + args: { replacement: { text: 'foo qux baz zap' } }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(mockedGetWordChanges).toHaveBeenCalledTimes(1); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toBe('barfoo bar baz'); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toBe('qux zapfoo qux baz zap'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts index 7c92d330f3..aa33ee74c3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync } from 'node:fs'; +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 { TrackDeleteMarkName, TrackInsertMarkName } from '../../extensions/track-changes/constants.js'; import { compilePlan } from './compiler.ts'; import { executeTextRewrite } from './executor.ts'; +import { previewPlan } from './preview.ts'; +import { getWordChanges } from './word-diff.ts'; function makeEditor(paragraphs: string[] = ['hello world']) { return initTestEditor({ @@ -25,6 +32,113 @@ function makeEditor(paragraphs: string[] = ['hello world']) { }).editor; } +function run(text: string) { + return { + type: 'run', + attrs: {}, + content: [{ type: 'text', text }], + }; +} + +function runsFromText(text: string) { + const parts = text.match(/\S+\s*/g) ?? [text]; + return parts.map(run); +} + +function paragraph(nodeId: string, text: string, listIndex?: number) { + const listAttrs = + listIndex == null + ? {} + : { + listRendering: { + markerText: `${listIndex}.`, + justification: 'left', + path: [listIndex], + numberingType: 'decimal', + }, + }; + + return { + type: 'paragraph', + attrs: { + paraId: nodeId, + ...listAttrs, + }, + content: runsFromText(text), + }; +} + +const SD3478_EXECUTOR_SOURCE = + 'I appoint Pat Example of 20 Oak Avenue, Sampletown and Example Trust Company of [address] as my executors and trustees. If either of my executors is unable or unwilling to act, then I appoint Example Trust Company as executor and trustee in their place.'; +const SD3478_GIFT_SOURCE = + 'I give my freehold property known as 42 Example Cottage, Sample Bay to my partner and my children in equal shares absolutely. If any of my children dies before me, their share shall pass to their own children in equal shares.'; + +function makeSd3478Editor() { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + paragraph( + 'OPENING1', + 'I, Jordan Example of 10 Market Road, Sampletown revoke all earlier wills and declare this to be my will.', + ), + paragraph('EXECUTOR', SD3478_EXECUTOR_SOURCE, 1), + paragraph('GIFTITEM', SD3478_GIFT_SOURCE, 2), + paragraph('RESIDUE', 'I give the residue of my estate to the same beneficiaries in the same shares.', 3), + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + useImmediateSetTimeout: false, + }).editor; +} + +const SD3478_EXECUTOR_REPLACEMENT = + 'I appoint my wife Rachel Louise Whitfield of 14 Beech Grove, Godalming, Surrey and [name of professional executor — e.g. Executor Services Limited] of [address] as my executors and trustees. I further appoint my son Thomas Andrew Whitfield to act as an additional executor and trustee upon his attaining the age of 25 years. If either of my executors or trustees is unable or unwilling to act or dies before proving my will, then I appoint [name of professional executor / substitute] of [city], [occupation] as executor and trustee in their place.'; +const SD3478_GIFT_REPLACEMENT = + 'I give my freehold property known as 7 Sea View Cottage, Whitstable, Kent (which I own solely and which is free of mortgage) to my wife Rachel Louise Whitfield and my children Emily Grace Whitfield, Thomas Andrew Whitfield and Jessica Ann Whitfield in equal shares absolutely. If any of my children dies before me, their share shall pass to their own children in equal shares, or if none, to the survivors of the named beneficiaries in equal shares.'; +const SD3478_CORPUS_RELATIVE_PATH = 'behavior/document-api/sd-3478-will-life-interest.docx'; +const PRIVATE_SUPERDOC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../../../..'); + +function resolveCorpusFixturePath(relativePath: string): string | null { + const corpusRoots = [ + process.env.SUPERDOC_LAYOUT_CORPUS_ROOT, + process.env.SUPERDOC_CORPUS_ROOT, + resolve(PRIVATE_SUPERDOC_ROOT, 'tests/corpus'), + ].filter(Boolean) as string[]; + + for (const corpusRoot of corpusRoots) { + const fixturePath = resolve(corpusRoot, relativePath); + if (existsSync(fixturePath)) return fixturePath; + } + + return null; +} + +const sd3478CorpusFixturePath = resolveCorpusFixturePath(SD3478_CORPUS_RELATIVE_PATH); +const itWithSd3478CorpusFixture = sd3478CorpusFixturePath ? it : it.skip; + +async function makeSd3478FixtureEditor() { + if (!sd3478CorpusFixturePath) { + throw new Error( + `Missing SD-3478 corpus fixture. Pull ${SD3478_CORPUS_RELATIVE_PATH} with pnpm --dir superdoc run corpus:pull.`, + ); + } + + const fileSource = await readFile(sd3478CorpusFixturePath); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(fileSource, true); + + return initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + user: { name: 'Integration User', email: 'integration@example.com' }, + useImmediateSetTimeout: false, + }).editor; +} + function getFirstMatchRef(editor: any, pattern: string): string { const match = editor.doc.query.match({ select: { type: 'text', pattern }, @@ -83,6 +197,43 @@ function markedTextByAuthor(editor: any, markName: string, authorEmail: string): return parts.join(''); } +function markedTextForBlockByAuthor(editor: any, nodeId: string, markName: string, authorEmail: string): string { + const parts: string[] = []; + + editor.state.doc.descendants((node: any) => { + const attrs = node.attrs ?? {}; + if (attrs.paraId !== nodeId && attrs.sdBlockId !== nodeId) return true; + + node.descendants((child: any) => { + if (!child.isText || !child.text) return; + if (child.marks.some((mark: any) => mark.type.name === markName && mark.attrs.authorEmail === authorEmail)) { + parts.push(child.text); + } + }); + return false; + }); + + return parts.join(''); +} + +function acceptedTextForBlock(editor: any, nodeId: string): string { + let text = ''; + + editor.state.doc.descendants((node: any) => { + const attrs = node.attrs ?? {}; + if (attrs.paraId !== nodeId && attrs.sdBlockId !== nodeId) return true; + + node.descendants((child: any) => { + if (!child.isText || !child.text) return; + const isDeleted = child.marks.some((mark: any) => mark.type.name === TrackDeleteMarkName); + if (!isDeleted) text += child.text; + }); + return false; + }); + + return text; +} + function compileSingleRewrite(editor: any, pattern: string, text: string) { const step = { id: 'rewrite-step', @@ -286,6 +437,34 @@ describe('doc.replace multi-paragraph integration', () => { expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'alice@example.com')).toBe(' '); }); + it('previews tracked rewrite selectors against unresolved deletion text', () => { + editor = makeEditor(['The quick brown fox jumps over the lazy dog.']); + markTextAsOtherUserDeletion(editor, 'lazy '); + + const result = previewPlan(editor, { + changeMode: 'tracked', + steps: [ + { + id: 'preview-inside-delete', + op: 'text.rewrite', + where: { + by: 'select', + select: { type: 'text', pattern: 'lazy' }, + require: 'first', + }, + args: { + replacement: { text: 'OO' }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(result.valid).toBe(true); + expect(result.failures).toBeUndefined(); + expect(result.steps[0]).toMatchObject({ stepId: 'preview-inside-delete', op: 'text.rewrite' }); + }); + it('resolves tracked delete selectors against unresolved deletion text as a child deletion', () => { editor = makeEditor(['The quick brown fox jumps over the lazy dog.']); markTextAsOtherUserDeletion(editor, 'lazy '); @@ -515,4 +694,370 @@ describe('doc.replace multi-paragraph integration', () => { expect(accepted).not.toContain('AustraliaNew'); expect(accepted).not.toContain('(ACNPark'); }); + + it('keeps unchanged anchors live for single tracked rewrites', () => { + editor = makeEditor(['foo bar baz']); + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef(editor, 'foo bar baz'), + text: 'foo qux baz zap', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toBe('bar'); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toBe('qux zap'); + }); + + it('keeps unchanged anchors live for large single tracked rewrites', () => { + const source = 'A x1 B x2 C x3 D x4 E x5 F x6 G x7 H x8 I x9 J'; + const target = 'A y1 B y2 C y3 D y4 E y5 F y6 G y7 H y8 I y9 J'; + expect(getWordChanges(source, target)).toHaveLength(9); + editor = makeEditor([source]); + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef(editor, source), + text: target, + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + + const acceptedParts: string[] = []; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + const isDeleted = node.marks.some((mark: any) => mark.type.name === TrackDeleteMarkName); + if (!isDeleted) acceptedParts.push(node.text); + }); + + const deleted = markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com'); + const inserted = markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com'); + expect(acceptedParts.join('')).toBe(target); + expect(deleted).toContain('x1'); + expect(deleted).not.toContain('A'); + expect(deleted).not.toContain('B'); + expect(deleted).not.toContain('J'); + expect(inserted).toContain('y1'); + expect(inserted).not.toContain('A'); + expect(inserted).not.toContain('B'); + expect(inserted).not.toContain('J'); + }); + + it('uses coarse fallback for single tracked rewrites above the granular threshold', () => { + expect(getWordChanges(SD3478_EXECUTOR_SOURCE, SD3478_EXECUTOR_REPLACEMENT).length).toBeGreaterThan(9); + + editor = makeSd3478Editor(); + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-executor', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: 'EXECUTOR' }, + args: { + replacement: { text: SD3478_EXECUTOR_REPLACEMENT }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(acceptedTextForBlock(editor, 'EXECUTOR')).toBe(SD3478_EXECUTOR_REPLACEMENT); + expect(markedTextForBlockByAuthor(editor, 'EXECUTOR', TrackDeleteMarkName, 'integration@example.com')).toBe( + SD3478_EXECUTOR_SOURCE, + ); + expect(markedTextForBlockByAuthor(editor, 'EXECUTOR', TrackInsertMarkName, 'integration@example.com')).toBe( + SD3478_EXECUTOR_REPLACEMENT, + ); + }); + + it('keeps single tracked rewrite granularity inside mixed mutation plans', () => { + editor = makeEditor(['foo bar baz', 'tail']); + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-one', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'foo bar baz' }, require: 'first' }, + args: { replacement: { text: 'foo qux baz zap' } }, + }, + { + id: 'insert-unrelated', + op: 'text.insert', + where: { by: 'select', select: { type: 'text', pattern: 'tail' }, require: 'first' }, + args: { position: 'after', content: { text: ' end' } }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toBe('bar'); + }); + + it('ignores no-op rewrites when preserving single tracked rewrite granularity', () => { + editor = makeEditor(['Hello', 'foo bar baz']); + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-noop', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'first' }, + args: { replacement: { text: 'Hello' } }, + }, + { + id: 'rewrite-one', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'foo bar baz' }, require: 'first' }, + args: { replacement: { text: 'foo qux baz zap' } }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toBe('bar'); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toBe('qux zap'); + }); + + it('uses tracked rewrite batch fallback for later targets in a single multi-target step', () => { + editor = makeEditor(['foo bar baz', 'foo bar baz']); + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-all', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'foo bar baz' }, require: 'all' }, + args: { replacement: { text: 'foo qux baz zap' } }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toBe('barfoo bar baz'); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toBe('qux zapfoo qux baz zap'); + }); + + it('SD-3478: synthetic tracked batch keeps later list rewrite clean after earlier rewrites', () => { + editor = makeSd3478Editor(); + + const openingReplacement = + 'I, Jordan Example of 30 Cedar Street, Fakeshire revoke all earlier wills and declare this to be my Last Will and Testament.'; + const executorReplacement = + 'I appoint my partner Riley Example of 30 Cedar Street, Fakeshire and [name of professional executor - e.g. Sample Executor Services Limited] of [address] as my executors and trustees. I further appoint my adult child Morgan Example to act as an additional executor and trustee upon attaining the age of 25 years. If either of my executors or trustees is unable or unwilling to act or dies before proving my will, then I appoint [name of professional executor / substitute] of [city], [occupation] as executor and trustee in their place.'; + const giftReplacement = + 'I give my freehold property known as 99 Fictional View Cottage, Exampleton, Test County (which I own solely and which is free of mortgage) to my partner Riley Example and my children Morgan Example, Casey Example and Taylor Example in equal shares absolutely. If any of my children dies before me, their share shall pass to their own children in equal shares, or if none, to the survivors of the named beneficiaries in equal shares.'; + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-testator', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'paragraph', nodeId: 'OPENING1' }, + args: { + replacement: { text: openingReplacement }, + style: { inline: { mode: 'preserve' } }, + }, + }, + { + id: 'rewrite-executor', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: 'EXECUTOR' }, + args: { + replacement: { text: executorReplacement }, + style: { inline: { mode: 'preserve' } }, + }, + }, + { + id: 'rewrite-gift', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: 'GIFTITEM' }, + args: { + replacement: { text: giftReplacement }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(acceptedTextForBlock(editor, 'OPENING1')).toBe(openingReplacement); + expect(acceptedTextForBlock(editor, 'EXECUTOR')).toBe(executorReplacement); + expect(acceptedTextForBlock(editor, 'GIFTITEM')).toBe(giftReplacement); + }); + + // Same corruption as the by:block case above, but reached through `by: select` + // text matching — the path a consumer (and the SuperdocDev "Reproduce SD-3478" + // button) actually uses to apply a batch. Multi-run paragraphs are load-bearing: + // the bug is in cross-run offset math, so single-run blocks never reproduce it + // (makeSd3478Editor seeds one run per word via `paragraph` → `runsFromText`). + it('SD-3478: tracked batch via text-pattern selectors keeps later multi-run rewrites clean', () => { + editor = makeSd3478Editor(); + + const openingReplacement = + 'I, Jordan Example of 30 Cedar Street, Fakeshire revoke all earlier wills and declare this to be my Last Will and Testament.'; + const executorReplacement = + 'I appoint my partner Riley Example of 30 Cedar Street, Fakeshire and [name of professional executor - e.g. Sample Executor Services Limited] of [address] as my executors and trustees. I further appoint my adult child Morgan Example to act as an additional executor and trustee upon attaining the age of 25 years. If either of my executors or trustees is unable or unwilling to act or dies before proving my will, then I appoint [name of professional executor / substitute] of [city], [occupation] as executor and trustee in their place.'; + const giftReplacement = + 'I give my freehold property known as 99 Fictional View Cottage, Exampleton, Test County (which I own solely and which is free of mortgage) to my partner Riley Example and my children Morgan Example, Casey Example and Taylor Example in equal shares absolutely. If any of my children dies before me, their share shall pass to their own children in equal shares, or if none, to the survivors of the named beneficiaries in equal shares.'; + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-testator', + op: 'text.rewrite', + where: { + by: 'select', + select: { + type: 'text', + pattern: + 'I, Jordan Example of 10 Market Road, Sampletown revoke all earlier wills and declare this to be my will.', + }, + require: 'first', + }, + args: { replacement: { text: openingReplacement }, style: { inline: { mode: 'preserve' } } }, + }, + { + id: 'rewrite-executor', + op: 'text.rewrite', + where: { + by: 'select', + select: { + type: 'text', + pattern: + 'I appoint Pat Example of 20 Oak Avenue, Sampletown and Example Trust Company of [address] as my executors and trustees. If either of my executors is unable or unwilling to act, then I appoint Example Trust Company as executor and trustee in their place.', + }, + require: 'first', + }, + args: { replacement: { text: executorReplacement }, style: { inline: { mode: 'preserve' } } }, + }, + { + id: 'rewrite-gift', + op: 'text.rewrite', + where: { + by: 'select', + select: { + type: 'text', + pattern: + 'I give my freehold property known as 42 Example Cottage, Sample Bay to my partner and my children in equal shares absolutely. If any of my children dies before me, their share shall pass to their own children in equal shares.', + }, + require: 'first', + }, + args: { replacement: { text: giftReplacement }, style: { inline: { mode: 'preserve' } } }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(acceptedTextForBlock(editor, 'OPENING1')).toBe(openingReplacement); + expect(acceptedTextForBlock(editor, 'EXECUTOR')).toBe(executorReplacement); + expect(acceptedTextForBlock(editor, 'GIFTITEM')).toBe(giftReplacement); + }); + + itWithSd3478CorpusFixture('SD-3478: fixture single executor rewrite projection stays clean', async () => { + editor = await makeSd3478FixtureEditor(); + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-executor', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: '6489CE71' }, + args: { + replacement: { text: SD3478_EXECUTOR_REPLACEMENT }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(receipt.success).toBe(true); + + const executorAccepted = acceptedTextForBlock(editor, '6489CE71'); + const executorInserted = markedTextForBlockByAuthor( + editor, + '6489CE71', + TrackInsertMarkName, + 'integration@example.com', + ); + + expect(executorAccepted).toBe(SD3478_EXECUTOR_REPLACEMENT); + expect(executorInserted).not.toContain('oprofessional executor / substitute'); + expect(executorInserted).not.toContain('of f [city]'); + expect(executorInserted).not.toContain('executo and trusteer'); + }); + + itWithSd3478CorpusFixture( + 'SD-3478: fixture tracked batch keeps executor and gift rewrite projections clean', + async () => { + editor = await makeSd3478FixtureEditor(); + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'rewrite-executor', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: '6489CE71' }, + args: { + replacement: { text: SD3478_EXECUTOR_REPLACEMENT }, + style: { inline: { mode: 'preserve' } }, + }, + }, + { + id: 'rewrite-gift', + op: 'text.rewrite', + where: { by: 'block', nodeType: 'listItem', nodeId: '35102C49' }, + args: { + replacement: { text: SD3478_GIFT_REPLACEMENT }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(receipt.success).toBe(true); + + const executorAccepted = acceptedTextForBlock(editor, '6489CE71'); + const giftAccepted = acceptedTextForBlock(editor, '35102C49'); + const executorInserted = markedTextForBlockByAuthor( + editor, + '6489CE71', + TrackInsertMarkName, + 'integration@example.com', + ); + const giftInserted = markedTextForBlockByAuthor( + editor, + '35102C49', + TrackInsertMarkName, + 'integration@example.com', + ); + + expect.soft(executorAccepted).toBe(SD3478_EXECUTOR_REPLACEMENT); + expect.soft(giftAccepted).toBe(SD3478_GIFT_REPLACEMENT); + expect.soft(giftAccepted.startsWith('I give my')).toBe(true); + expect.soft(giftAccepted).not.toContain('give To'); + expect.soft(giftAccepted.endsWith('s]')).toBe(false); + expect.soft(executorInserted).not.toContain('oprofessional executor / substitute'); + expect.soft(executorInserted).not.toContain('of f [city]'); + expect.soft(executorInserted).not.toContain('executo and trusteer'); + expect.soft(giftInserted.startsWith('I give my')).toBe(true); + expect.soft(giftInserted).not.toContain('give To'); + }, + ); }); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts index bc7b4d55ef..023bfe63fa 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts @@ -46,7 +46,7 @@ export interface MarksDiff { export function getAttributesDiff( objectA: Record | null | undefined = {}, objectB: Record | null | undefined = {}, - ignoreKeys: string[] = [], + ignoreKeys: readonly string[] = [], ): AttributesDiff | null { const diff: AttributesDiff = { added: {}, diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.test.js index 203a829bcd..c24b64037c 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.test.js @@ -72,6 +72,17 @@ const createParagraph = (text, attrs = {}, options = {}) => { return { node: paragraphNode, pos, depth }; }; +const createParagraphSequence = (texts, options = {}) => { + const { attrs = {}, textAttrs = {}, depth = 1, startPos = 0 } = options; + let pos = startPos; + + return texts.map((text) => { + const paragraph = createParagraph(text, attrs, { pos, textAttrs, depth }); + pos += paragraph.node.nodeSize; + return paragraph; + }); +}; + describe('diffParagraphs', () => { it('treats similar paragraphs without IDs as modifications', () => { const oldParagraphs = [createParagraph('Hello world from ProseMirror.')]; @@ -120,6 +131,172 @@ describe('diffParagraphs', () => { expect(diffs[2].action).toBe('added'); }); + it('keeps a middle insertion in repeated paragraphs as a single added diff at the correct position', () => { + const repeatedText = 'Repeated boilerplate paragraph.'; + const insertedText = 'Inserted middle paragraph.'; + const oldParagraphs = createParagraphSequence(Array(10).fill(repeatedText)); + const newParagraphs = createParagraphSequence([ + ...Array(5).fill(repeatedText), + insertedText, + ...Array(5).fill(repeatedText), + ]); + + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(oldParagraphs)), + normalizeNodes(createDocFromNodes(newParagraphs)), + ); + const additions = diffs.filter((diff) => diff.action === 'added'); + const nonAdditions = diffs.filter((diff) => diff.action !== 'added'); + + expect(additions).toHaveLength(1); + expect(nonAdditions).toEqual([]); + expect(additions[0]).toMatchObject({ + action: 'added', + nodeType: 'paragraph', + text: insertedText, + pos: oldParagraphs[5].pos, + }); + }); + + it('keeps a middle edit in repeated paragraphs as a single modified diff without surrounding noise', () => { + const repeatedText = 'Repeated boilerplate paragraph.'; + const modifiedText = 'Repeated boilerplate paragraph with one edit.'; + const oldParagraphs = createParagraphSequence(Array(10).fill(repeatedText)); + const newParagraphs = createParagraphSequence([ + ...Array(5).fill(repeatedText), + modifiedText, + ...Array(4).fill(repeatedText), + ]); + + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(oldParagraphs)), + normalizeNodes(createDocFromNodes(newParagraphs)), + ); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ + action: 'modified', + nodeType: 'paragraph', + oldText: repeatedText, + newText: modifiedText, + pos: oldParagraphs[5].pos, + }); + }); + + it('does not pair moderately similar repeated paragraphs as a modification in a larger document', () => { + const repeatedText = 'Repeated boilerplate paragraph.'; + const oldCandidate = 'electronic warranties section'; + const newCandidate = 'electric what section'; + const oldParagraphs = createParagraphSequence([ + ...Array(5).fill(repeatedText), + oldCandidate, + ...Array(4).fill(repeatedText), + ]); + const newParagraphs = createParagraphSequence([ + ...Array(5).fill(repeatedText), + newCandidate, + ...Array(4).fill(repeatedText), + ]); + + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(oldParagraphs)), + normalizeNodes(createDocFromNodes(newParagraphs)), + ); + + expect(diffs).toHaveLength(2); + expect(diffs[0]).toMatchObject({ + action: 'deleted', + nodeType: 'paragraph', + oldText: oldCandidate, + pos: oldParagraphs[5].pos, + }); + expect(diffs[1]).toMatchObject({ + action: 'added', + nodeType: 'paragraph', + text: newCandidate, + // Replay processes diffs in reverse order. The added diff must land after the deleted + // paragraph in old-doc coordinates so that when replay reverses (insert first, then delete), + // the inserted node ends up at the deleted paragraph's original position. + pos: oldParagraphs[5].pos + oldParagraphs[5].node.nodeSize, + }); + }); + + it('keeps an unrelated replacement in equal-length repeated sequences as deleted + added', () => { + const repeatedText = 'Repeated boilerplate paragraph.'; + const unrelatedText = 'Zephyr quickly jinxed an entirely unrelated passage.'; + // 5 identical old paragraphs; the 3rd is replaced with something with low similarity to P. + const oldParagraphs = createParagraphSequence(Array(5).fill(repeatedText)); + const newParagraphs = createParagraphSequence([ + ...Array(2).fill(repeatedText), + unrelatedText, + ...Array(2).fill(repeatedText), + ]); + + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(oldParagraphs)), + normalizeNodes(createDocFromNodes(newParagraphs)), + ); + + expect(diffs).toHaveLength(2); + expect(diffs[0]).toMatchObject({ + action: 'deleted', + nodeType: 'paragraph', + oldText: repeatedText, + pos: oldParagraphs[2].pos, + }); + expect(diffs[1]).toMatchObject({ + action: 'added', + nodeType: 'paragraph', + text: unrelatedText, + pos: oldParagraphs[2].pos + oldParagraphs[2].node.nodeSize, + }); + }); + + it('emits deleted+added for a paragraph with similarity 0.65–0.70 when surrounded by repeated paragraphs', () => { + // Myers path: old is NOT all-identical (contains oldPara ≠ R), so positionalAlignDiffs + // is skipped and Myers + detectLocalRepeatedContent runs instead. + // sim("Repeated standard text here.", "Repeated standard paragraph.") ≈ 0.679 — + // above the base threshold (0.65) but below the repeated-content threshold (0.70). + // detectLocalRepeatedContent sees the surrounding R paragraphs and raises the + // threshold to 0.70, so the pair must be emitted as deleted+added, not modified. + const repeatedText = 'Repeated boilerplate paragraph.'; + const oldPara = 'Repeated standard text here.'; // ≈ 0.679 similarity with newPara + const newPara = 'Repeated standard paragraph.'; + const oldParagraphs = createParagraphSequence([ + ...Array(4).fill(repeatedText), + oldPara, + ...Array(5).fill(repeatedText), + ]); + const newParagraphs = createParagraphSequence([ + ...Array(4).fill(repeatedText), + newPara, + ...Array(5).fill(repeatedText), + ]); + + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(oldParagraphs)), + normalizeNodes(createDocFromNodes(newParagraphs)), + ); + + expect(diffs).toHaveLength(2); + expect(diffs[0]).toMatchObject({ action: 'deleted', oldText: oldPara }); + expect(diffs[1]).toMatchObject({ action: 'added', text: newPara }); + }); + + it('emits modified for the same borderline pair when there are no repeated neighbours', () => { + // Without repeated neighbours detectLocalRepeatedContent does not raise the + // threshold, so similarity 0.679 clears the base 0.65 threshold → modified. + const oldText = 'Repeated standard text here.'; + const newText = 'Repeated standard paragraph.'; + const diffs = diffNodes( + normalizeNodes(createDocFromNodes(createParagraphSequence([oldText]))), + normalizeNodes(createDocFromNodes(createParagraphSequence([newText]))), + ); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ action: 'modified', oldText, newText }); + }); + it('treats paragraph attribute-only changes as modifications', () => { const oldParagraph = createParagraph('Consistent text', { align: 'left' }); const newParagraph = createParagraph('Consistent text', { align: 'right' }); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts index c8849eea5f..e8789fc6c0 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts @@ -2,7 +2,7 @@ import type { Node as PMNode } from 'prosemirror-model'; import { createParagraphSnapshot, paragraphComparator, - canTreatAsModification as canTreatParagraphDeletionInsertionAsModification, + canTreatAsModification as canTreatParagraphAsModification, shouldProcessEqualAsModification as shouldProcessEqualParagraphsAsModification, buildAddedParagraphDiff, buildDeletedParagraphDiff, @@ -85,6 +85,16 @@ export type NodeDiff = ParagraphDiff | NodeAddedDiff | NodeDeletedDiff | NodeMod /** * Produces a sequence diff between two normalized node lists. * + * For sequences where all old nodes are paragraphs with the same content signature and both + * sequences have equal length, positional alignment is used instead of Myers. Myers freely + * chooses which identical paragraph to delete, often producing non-adjacent insert+delete + * operations that cannot be paired as a modification. Positional alignment directly pairs + * old[i] with new[i] and treats diverging positions as modifications. + * + * When either sequence contains repeated paragraph signatures, a stricter similarity threshold + * is applied to avoid false pairings between structurally unrelated paragraphs that share + * incidental character overlap (common in legal boilerplate documents). + * * @param oldNodes Normalized nodes from the old document. * @param newNodes Normalized nodes from the new document. * @returns List of node diffs describing the changes. @@ -92,11 +102,21 @@ export type NodeDiff = ParagraphDiff | NodeAddedDiff | NodeDeletedDiff | NodeMod export function diffNodes(oldNodes: NodeInfo[], newNodes: NodeInfo[]): NodeDiff[] { const addedNodesSet = new Set(); const deletedNodesSet = new Set(); + + // Positional alignment: when every old node is a paragraph sharing one content signature and + // both sequences are the same length, Myers cannot reliably place the edit at the right + // position (it may delete the last P and insert M in the middle, producing non-adjacent ops). + // Zip by index instead and treat any mismatch as a direct modification. + if (isAllIdenticalParagraphSequence(oldNodes) && oldNodes.length === newNodes.length) { + return positionalAlignDiffs(oldNodes as ParagraphNodeInfo[], newNodes, addedNodesSet, deletedNodesSet); + } + return diffSequences(oldNodes, newNodes, { comparator: nodeComparator, reorderOperations: reorderDiffOperations, shouldProcessEqualAsModification, - canTreatAsModification, + canTreatAsModification: (deleted, inserted, oldIdx, newIdx) => + canTreatAsModification(deleted, inserted, detectLocalRepeatedContent(oldNodes, newNodes, oldIdx, newIdx)), buildAdded: (nodeInfo, _oldIdx) => buildAddedDiff(nodeInfo, oldNodes, _oldIdx, addedNodesSet), buildDeleted: (nodeInfo) => buildDeletedDiff(nodeInfo, deletedNodesSet), buildModified: buildModifiedDiff, @@ -160,10 +180,17 @@ function shouldProcessEqualAsModification(oldNodeInfo: NodeInfo, newNodeInfo: No /** * Determines whether a delete/insert pair should instead be surfaced as a modification. * Only paragraphs qualify because we can measure textual similarity; other nodes remain as-is. + * + * `hasRepeatedContent` tightens the similarity threshold when the surrounding sequences contain + * repeated paragraph signatures, preventing false pairings in boilerplate-heavy documents. */ -function canTreatAsModification(deletedNodeInfo: NodeInfo, insertedNodeInfo: NodeInfo): boolean { +function canTreatAsModification( + deletedNodeInfo: NodeInfo, + insertedNodeInfo: NodeInfo, + hasRepeatedContent = false, +): boolean { if (isParagraphNodeInfo(deletedNodeInfo) && isParagraphNodeInfo(insertedNodeInfo)) { - return canTreatParagraphDeletionInsertionAsModification(deletedNodeInfo, insertedNodeInfo); + return canTreatParagraphAsModification(deletedNodeInfo, insertedNodeInfo, hasRepeatedContent); } return false; } @@ -248,3 +275,124 @@ function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDi function isParagraphNodeInfo(nodeInfo: NodeInfo): nodeInfo is ParagraphNodeInfo { return nodeInfo.node.type.name === 'paragraph'; } + +/** + * Returns true when every node in the sequence is a paragraph sharing the same non-empty + * content signature. Requires at least two nodes — a single-paragraph sequence does not + * exhibit the repeated-content alignment problem that motivates positional alignment. + * Empty paragraphs are excluded because they appear in virtually every document for structural + * spacing reasons and should not trigger positional alignment logic. + */ +function isAllIdenticalParagraphSequence(nodes: NodeInfo[]): nodes is ParagraphNodeInfo[] { + if (nodes.length < 2) return false; + if (!nodes.every(isParagraphNodeInfo)) return false; + const firstSig = (nodes[0] as ParagraphNodeInfo).contentSignature; + if (!firstSig) return false; + return nodes.every((n) => (n as ParagraphNodeInfo).contentSignature === firstSig); +} + +/** + * Returns true when the local window around the candidate pair contains a non-empty paragraph + * content signature that appears more than once. "Local" means within `windowRadius` positions + * of the delete index in old-sequence coordinates and the insert index in new-sequence coordinates. + * + * Window-scoped detection keeps the stricter similarity threshold in `canTreatAsModification` + * applied only to the region where repeated boilerplate actually appears, rather than raising the + * bar for the entire document whenever any two paragraphs elsewhere happen to share a signature. + * + * Empty paragraphs are deliberately excluded — they are present in virtually every document for + * structural spacing and would otherwise fire the repeated-content heuristic globally. + */ +function detectLocalRepeatedContent( + oldNodes: NodeInfo[], + newNodes: NodeInfo[], + oldIdx: number, + newIdx: number, + windowRadius = 10, +): boolean { + // Count old-window and new-window separately so that a paragraph appearing once in old + // and once in new (stable unchanged neighbors) does not inflate the count to 2 and + // falsely trigger the stricter threshold for unrelated pairs nearby. + const hasRepeatIn = (nodes: NodeInfo[], center: number): boolean => { + const counts = new Map(); + const start = Math.max(0, center - windowRadius); + const end = Math.min(nodes.length, center + windowRadius); + for (let i = start; i < end; i++) { + const n = nodes[i]; + if (isParagraphNodeInfo(n) && n.contentSignature.length > 0) { + const next = (counts.get(n.contentSignature) ?? 0) + 1; + if (next > 1) return true; + counts.set(n.contentSignature, next); + } + } + return false; + }; + return hasRepeatIn(oldNodes, oldIdx) || hasRepeatIn(newNodes, newIdx); +} + +/** + * Positional alignment for sequences where Myers produces unreliable edit placement. + * + * Pairs old[i] with new[i] directly. Equal pairs (by `paragraphComparator`) are skipped or + * emitted as modifications when `shouldProcessEqualAsModification` fires. Unequal pairs at + * the same index are tested against `canTreatParagraphAsModification` (base threshold) before + * being emitted as modifications — positionally paired items that are completely unrelated + * (similarity below threshold) are kept as a separate deleted + added pair instead. + * Remaining items in the longer sequence are emitted as additions or deletions. + */ +function positionalAlignDiffs( + oldParas: ParagraphNodeInfo[], + newNodes: NodeInfo[], + addedNodesSet: Set, + deletedNodesSet: Set, +): NodeDiff[] { + const diffs: NodeDiff[] = []; + const minLen = Math.min(oldParas.length, newNodes.length); + + for (let i = 0; i < minLen; i++) { + const oldNode = oldParas[i]; + const newNode = newNodes[i]; + + if (!isParagraphNodeInfo(newNode)) { + // Non-paragraph appeared in new at a position occupied by a paragraph in old. + // Fall back to treating old as deleted and new as added. + // Use i + 1 so the add pos resolves to after old[i] — reverse replay inserts there first, + // then deletes old[i] at its original pos, landing the new node at old[i].pos. + const del = buildDeletedDiff(oldNode, deletedNodesSet); + if (del) diffs.push(del); + const add = buildAddedDiff(newNode, oldParas, i + 1, addedNodesSet); + if (add) diffs.push(add); + continue; + } + + if (paragraphComparator(oldNode, newNode)) { + if (shouldProcessEqualAsModification(oldNode, newNode)) { + const diff = buildModifiedDiff(oldNode, newNode); + if (diff) diffs.push(diff); + } + } else if (canTreatParagraphAsModification(oldNode, newNode)) { + const diff = buildModifiedDiff(oldNode, newNode); + if (diff) diffs.push(diff); + } else { + // Positionally aligned but content is too different — emit as separate delete + add. + // Replay processes diffs in reverse: insert at old[i+1] first, then delete at old[i].pos, + // so the inserted node ends up at old[i].pos. + const del = buildDeletedDiff(oldNode, deletedNodesSet); + if (del) diffs.push(del); + const add = buildAddedDiff(newNode, oldParas, i + 1, addedNodesSet); + if (add) diffs.push(add); + } + } + + for (let i = minLen; i < oldParas.length; i++) { + const diff = buildDeletedDiff(oldParas[i], deletedNodesSet); + if (diff) diffs.push(diff); + } + + for (let i = minLen; i < newNodes.length; i++) { + const diff = buildAddedDiff(newNodes[i], oldParas, oldParas.length, addedNodesSet); + if (diff) diffs.push(diff); + } + + return diffs; +} diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts index d901e9c881..bc4d14f459 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts @@ -12,6 +12,13 @@ * `diff.apply` across two editor instances rejects with `PRECONDITION_FAILED` * even when both editors hold the same document content. See SD-3279. */ +/** + * Volatile OOXML revision metadata on run nodes — regenerated by Word on every save + * and irrelevant to visible content semantics. Single source of truth shared by + * inline-diffing.ts (granularity checks) and semantic-normalization.ts (node normalization). + */ +export const VOLATILE_RUN_ATTR_KEYS: readonly string[] = ['rsidR', 'rsidRPr', 'rsidDel']; + export const NON_SEMANTIC_BLOCK_ATTRS = new Set([ 'sdBlockId', 'sdBlockRev', diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.test.js index 0e73018ea2..91e785d51f 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.test.js @@ -5,7 +5,13 @@ vi.mock('./myers-diff.ts', async () => { myersDiff: vi.fn(actual.myersDiff), }; }); -import { getInlineDiff, tokenizeInlineContent } from './inline-diffing.ts'; +import { + buildInlineDiffPlan, + getInlineDiff, + isSafeSpan, + segmentInlineTokens, + tokenizeInlineContent, +} from './inline-diffing.ts'; /** * Builds text tokens with offsets for inline diff tests. @@ -17,10 +23,30 @@ import { getInlineDiff, tokenizeInlineContent } from './inline-diffing.ts'; */ const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ - char, + text: char, + length: 1, runAttrs: { ...runAttrs }, kind: 'text', offset: offsetStart + index, + endOffset: offsetStart + index, + })); + +/** + * Builds text tokens with explicit offsets for multi-run position tests. + * + * @param {string} text Text content to tokenize. + * @param {number[]} offsets Absolute offsets for each character token. + * @param {Record} runAttrs Run attributes to attach. + * @returns {import('./inline-diffing.ts').InlineTextToken[]} + */ +const buildTextRunsWithOffsets = (text, offsets, runAttrs = {}) => + text.split('').map((char, index) => ({ + text: char, + length: 1, + runAttrs: { ...runAttrs }, + kind: 'text', + offset: offsets[index], + endOffset: offsets[index], })); /** @@ -34,10 +60,12 @@ const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => */ const buildMarkedTextRuns = (text, marks, runAttrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ - char, + text: char, + length: 1, runAttrs: { ...runAttrs }, kind: 'text', offset: offsetStart + index, + endOffset: offsetStart + index, marks, })); @@ -97,7 +125,8 @@ const buildImageNodeToken = (attrs = {}, pos = 0) => { */ const buildTextTokens = (text, runAttrs = {}, marks = []) => text.split('').map((char) => ({ - char, + text: char, + length: 1, runAttrs, kind: 'text', marks, @@ -191,7 +220,8 @@ const stripTokenOffsets = (tokens) => if (token.kind === 'text') { return { kind: token.kind, - char: token.char, + text: token.text, + length: token.length, runAttrs: token.runAttrs, marks: token.marks, }; @@ -203,6 +233,46 @@ const stripTokenOffsets = (tokens) => }; }); +/** + * Collects text diff fragments for readability assertions. + * + * @param {import('./inline-diffing.ts').InlineDiffResult[]} diffs Inline diff results. + * @returns {{ added: string[]; deleted: string[]; modifiedOld: string[]; modifiedNew: string[] }} + */ +const collectTextDiffFragments = (diffs) => { + const added = []; + const deleted = []; + const modifiedOld = []; + const modifiedNew = []; + + for (const diff of diffs) { + if (diff.kind !== 'text') { + continue; + } + + if (diff.action === 'added' && typeof diff.text === 'string') { + added.push(diff.text); + continue; + } + + if (diff.action === 'deleted' && typeof diff.text === 'string') { + deleted.push(diff.text); + continue; + } + + if (diff.action === 'modified') { + if (typeof diff.oldText === 'string') { + modifiedOld.push(diff.oldText); + } + if (typeof diff.newText === 'string') { + modifiedNew.push(diff.newText); + } + } + } + + return { added, deleted, modifiedOld, modifiedNew }; +}; + describe('getInlineDiff', () => { it('returns an empty diff list when both strings are identical', () => { const oldRuns = buildTextRuns('unchanged'); @@ -218,12 +288,14 @@ describe('getInlineDiff', () => { expect(diffs).toEqual([ { - action: 'added', + action: 'modified', kind: 'text', - startPos: 12, + startPos: 10, endPos: 12, - text: 'X', - runAttrs: {}, + oldText: 'abc', + newText: 'abXc', + runAttrsDiff: null, + marksDiff: null, }, ]); }); @@ -235,20 +307,14 @@ describe('getInlineDiff', () => { expect(diffs).toEqual([ { - action: 'deleted', - kind: 'text', - startPos: 7, - endPos: 7, - text: 'c', - runAttrs: {}, - }, - { - action: 'added', + action: 'modified', kind: 'text', - startPos: 8, + startPos: 5, endPos: 8, - text: 'XY', - runAttrs: {}, + oldText: 'abcd', + newText: 'abXYd', + runAttrsDiff: null, + marksDiff: null, }, ]); }); @@ -366,6 +432,248 @@ describe('getInlineDiff', () => { }, ]); }); + + it('marks plain text spans with matching formatting as safe', () => { + const oldRuns = buildMarkedTextRuns('word', [{ type: 'bold', attrs: {} }], { styleId: 'BodyText' }); + const newRuns = buildMarkedTextRuns('text', [{ type: 'bold', attrs: {} }], { styleId: 'BodyText' }); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(true); + }); + + it('rejects spans with tracked-change marks', () => { + const oldRuns = buildMarkedTextRuns('word', [{ type: 'trackInsert', attrs: { id: 'tracked-1' } }]); + const newRuns = buildTextRuns('text'); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(false); + }); + + it('rejects spans with comment anchors or inline nodes', () => { + const commentAnchor = { + kind: 'inlineNode', + nodeType: 'commentRangeStart', + node: { + type: { name: 'commentRangeStart' }, + attrs: { id: 'c1' }, + toJSON: () => ({ type: 'commentRangeStart', attrs: { id: 'c1' } }), + }, + nodeJSON: { type: 'commentRangeStart', attrs: { id: 'c1' } }, + pos: 0, + }; + + expect(isSafeSpan([commentAnchor], buildTextRuns('text'))).toBe(false); + }); + + it('rejects spans with PAGEREF-like run attrs', () => { + const oldRuns = buildTextRuns('9', { instruction: 'PAGEREF _Toc123456789 h' }); + const newRuns = buildTextRuns('10', { instruction: 'PAGEREF _Toc123456789 h' }); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(false); + }); + + it('rejects spans with formatting drift between old and new tokens', () => { + const oldRuns = buildMarkedTextRuns('word', [{ type: 'bold', attrs: {} }], { styleId: 'BodyText' }); + const newRuns = buildMarkedTextRuns('text', [{ type: 'italic', attrs: {} }], { styleId: 'BodyText' }); + const newRunAttrs = buildMarkedTextRuns('text', [{ type: 'bold', attrs: {} }], { styleId: 'Quote' }); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(false); + expect(isSafeSpan(oldRuns, newRunAttrs)).toBe(false); + }); + + it('ignores volatile run attrs when checking safe spans', () => { + const oldRuns = buildTextRuns('LEASE', { rsidR: '001', rsidRPr: 'aaa', rsidDel: null }); + const newRuns = buildTextRuns('RENTAL', { rsidR: '002', rsidRPr: 'bbb', rsidDel: 'deleted' }); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(true); + }); + + it('ignores importer font-family churn when checking safe spans', () => { + const oldRuns = buildTextRuns('LEASE', { + runProperties: { + kern: 0, + ligatures: 'none', + }, + runPropertiesInlineKeys: ['kern', 'ligatures', 'fontFamily', 'fontSize', 'fontSizeCs'], + rsidRPr: null, + }); + const newRuns = buildTextRuns('RENTAL', { + runProperties: { + fontFamily: { + eastAsia: 'Aptos', + cs: 'Aptos', + asciiTheme: 'minorHAnsi', + hAnsiTheme: 'minorHAnsi', + }, + kern: 0, + ligatures: 'none', + }, + runPropertiesInlineKeys: ['fontFamily', 'fontSize', 'fontSizeCs'], + rsidRPr: '00952A78', + }); + + expect(isSafeSpan(oldRuns, newRuns)).toBe(true); + }); + + it('does not emit text modifications for volatile run-attr churn alone', () => { + const oldRuns = buildTextRuns('same', { rsidR: '001', rsidRPr: null, rsidDel: null }); + const newRuns = buildTextRuns('same', { rsidR: '002', rsidRPr: 'abc', rsidDel: 'def' }); + + expect(getInlineDiff(oldRuns, newRuns, oldRuns.length)).toEqual([]); + }); + + it('still emits text modifications for equal text when font attrs really differ', () => { + const oldRuns = buildTextRuns('same', { + runProperties: { + kern: 0, + ligatures: 'none', + }, + runPropertiesInlineKeys: ['kern', 'ligatures', 'fontFamily', 'fontSize', 'fontSizeCs'], + rsidRPr: null, + }); + const newRuns = buildTextRuns('same', { + runProperties: { + fontFamily: { + eastAsia: 'Aptos', + cs: 'Aptos', + asciiTheme: 'minorHAnsi', + hAnsiTheme: 'minorHAnsi', + }, + kern: 0, + ligatures: 'none', + }, + runPropertiesInlineKeys: ['fontFamily', 'fontSize', 'fontSizeCs'], + rsidRPr: '00952A78', + }); + + expect(getInlineDiff(oldRuns, newRuns, oldRuns.length)).toEqual([ + expect.objectContaining({ + action: 'modified', + kind: 'text', + oldText: 'same', + newText: 'same', + runAttrsDiff: expect.objectContaining({ + added: expect.objectContaining({ + 'runProperties.fontFamily.eastAsia': 'Aptos', + }), + }), + }), + ]); + }); + + it('keeps LEASE to RENTAL AGREEMENT as a whole-word replacement', () => { + const oldRuns = buildTextRuns('LEASE'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('RENTAL AGREEMENT'), oldRuns.length); + const fragments = collectTextDiffFragments(diffs); + const removedText = [...fragments.deleted, ...fragments.modifiedOld]; + const addedText = [...fragments.added, ...fragments.modifiedNew]; + + expect(removedText).toEqual(expect.arrayContaining(['LEASE'])); + expect(addedText).toEqual(expect.arrayContaining(['RENTAL', ' AGREEMENT'])); + expect(removedText).not.toEqual(expect.arrayContaining(['R', 'NT', 'L'])); + expect(addedText).not.toEqual(expect.arrayContaining(['L', 'SE'])); + }); + + it('keeps electronic to electric as a whole-word replacement', () => { + const oldRuns = buildTextRuns('electronic'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('electric'), oldRuns.length); + const fragments = collectTextDiffFragments(diffs); + const removedText = [...fragments.deleted, ...fragments.modifiedOld]; + const addedText = [...fragments.added, ...fragments.modifiedNew]; + + expect(removedText).toEqual(expect.arrayContaining(['electronic'])); + expect(addedText).toEqual(expect.arrayContaining(['electric'])); + expect(removedText).not.toEqual(expect.arrayContaining(['on'])); + expect(addedText).not.toEqual(expect.arrayContaining(['lectr'])); + }); + + it('keeps endPos aligned to the last source token for multi-run word replacements', () => { + const oldRuns = buildTextRunsWithOffsets('electronic', [2, 5, 6, 7, 8, 9, 12, 13, 14, 15]); + const diffs = getInlineDiff(oldRuns, buildTextRuns('electric', {}, 2), 16); + + expect(diffs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'modified', + kind: 'text', + oldText: 'electronic', + newText: 'electric', + startPos: 2, + endPos: 15, + }), + ]), + ); + }); + + it('keeps warranties to what as a whole-word replacement', () => { + const oldRuns = buildTextRuns('warranties'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('what'), oldRuns.length); + const fragments = collectTextDiffFragments(diffs); + const removedText = [...fragments.deleted, ...fragments.modifiedOld]; + const addedText = [...fragments.added, ...fragments.modifiedNew]; + + expect(removedText).toEqual(expect.arrayContaining(['warranties'])); + expect(addedText).toEqual(expect.arrayContaining(['what'])); + expect(removedText).not.toEqual(expect.arrayContaining(['what'])); + expect(addedText).not.toEqual(expect.arrayContaining(['w', 'arranties'])); + }); + + it('keeps sentence to phrase as a whole-word replacement', () => { + const oldRuns = buildTextRuns('sentence'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('phrase'), oldRuns.length); + const fragments = collectTextDiffFragments(diffs); + const removedText = [...fragments.deleted, ...fragments.modifiedOld]; + const addedText = [...fragments.added, ...fragments.modifiedNew]; + + expect(removedText).toEqual(expect.arrayContaining(['sentence'])); + expect(addedText).toEqual(expect.arrayContaining(['phrase'])); + expect(removedText).not.toEqual(expect.arrayContaining(['phra'])); + expect(addedText).not.toEqual(expect.arrayContaining(['ntence'])); + }); + + it('keeps goods to products as a whole-word replacement', () => { + const oldRuns = buildTextRuns('goods'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('products'), oldRuns.length); + const fragments = collectTextDiffFragments(diffs); + const removedText = [...fragments.deleted, ...fragments.modifiedOld]; + const addedText = [...fragments.added, ...fragments.modifiedNew]; + + expect(removedText).toEqual(expect.arrayContaining(['goods'])); + expect(addedText).toEqual(expect.arrayContaining(['products'])); + expect(removedText).not.toEqual(expect.arrayContaining(['od'])); + expect(addedText).not.toEqual(expect.arrayContaining(['uct'])); + }); +}); + +describe('segmentInlineTokens', () => { + it('starts a new segment at unsafe tokens instead of appending them to the previous span', () => { + const tokens = [ + ...buildTextRuns('ab', {}, 0), + ...buildMarkedTextRuns('c', [{ type: 'trackInsert', attrs: { id: 'tracked-1' } }], {}, 2), + ...buildTextRuns('de', {}, 3), + ]; + + expect( + segmentInlineTokens(tokens).map((segment) => ({ + text: segment.tokens.map((token) => (token.kind === 'text' ? token.text : `[${token.nodeType}]`)).join(''), + isTextOnly: segment.isTextOnly, + isIndividuallySafe: segment.isIndividuallySafe, + sourceStartTokenIdx: segment.sourceStartTokenIdx, + })), + ).toEqual([ + { text: 'ab', isTextOnly: true, isIndividuallySafe: true, sourceStartTokenIdx: 0 }, + { text: 'c', isTextOnly: true, isIndividuallySafe: false, sourceStartTokenIdx: 2 }, + { text: 'de', isTextOnly: true, isIndividuallySafe: true, sourceStartTokenIdx: 3 }, + ]); + }); + + it('falls back when segment counts are asymmetric', () => { + const oldSegments = segmentInlineTokens(buildTextRuns('LEASE')); + const newSegments = segmentInlineTokens([ + ...buildTextRuns('RENTAL', {}, 0), + ...buildTextRuns(' body', { styleId: 'Quote' }, 6), + ]); + + expect(buildInlineDiffPlan(oldSegments, newSegments)).toBeNull(); + }); }); describe('tokenizeInlineContent', () => { @@ -448,6 +756,54 @@ describe('tokenizeInlineContent', () => { expect(tokens[0]?.offset).toBe(11); expect(tokens[5]?.offset).toBe(16); }); + + it('treats structuredContent as atomic and does not emit descendant text tokens', () => { + // Simulates: paragraph → "before" text + structuredContent (non-leaf, with text + // children) + "after" text. The structuredContent should appear as a single + // inlineNode token; its internal text should not produce additional text tokens. + const sdtAttrs = { sdtId: '446128060', sdtTag: 'my field 2' }; + const sdtNode = { + isInline: true, + isLeaf: false, + type: { name: 'structuredContent', spec: {} }, + attrs: sdtAttrs, + toJSON: () => ({ type: 'structuredContent', attrs: sdtAttrs }), + }; + + const mockParagraph = { + content: { size: 30 }, + nodesBetween: (_from, _to, callback) => { + // text before SDT + 'hi'.split('').forEach((ch, i) => { + callback({ isText: true, text: ch, marks: [] }, i); + }); + // structuredContent (non-leaf inline container) + const shouldDescend = callback(sdtNode, 2); + // simulate PM: only call children if callback did NOT return false + if (shouldDescend !== false) { + 'field content'.split('').forEach((ch, i) => { + callback({ isText: true, text: ch, marks: [] }, 3 + i); + }); + } + // text after SDT + 'ok'.split('').forEach((ch, i) => { + callback({ isText: true, text: ch, marks: [] }, 20 + i); + }); + }, + nodeAt: () => ({ attrs: {} }), + }; + + const tokens = tokenizeInlineContent(mockParagraph); + + const inlineNodeTokens = tokens.filter((t) => t.kind === 'inlineNode'); + const textTokens = tokens.filter((t) => t.kind === 'text'); + + expect(inlineNodeTokens).toHaveLength(1); + expect(inlineNodeTokens[0].nodeType).toBe('structuredContent'); + // Only "hi" (2) and "ok" (2) — no tokens from SDT's internal content + expect(textTokens).toHaveLength(4); + expect(textTokens.map((t) => t.text).join('')).toBe('hiok'); + }); }); describe('image semantic normalization in inline diff', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.ts index 41d57ae175..3e359fac10 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/inline-diffing.ts @@ -2,9 +2,12 @@ import type { Node as PMNode } from 'prosemirror-model'; import { getAttributesDiff, getMarksDiff, type AttributesDiff, type MarksDiff } from './attributes-diffing'; import { normalizeInlineNodeJSON, normalizeInlineNodeAttrs, semanticInlineNodeKey } from './semantic-normalization'; import { diffSequences } from './sequence-diffing'; +import { VOLATILE_RUN_ATTR_KEYS } from './identity-attrs'; type NodeJSON = ReturnType; type MarkJSON = { type: string; attrs?: Record }; +const TRACK_CHANGE_MARK_NAMES = new Set(['trackInsert', 'trackDelete', 'trackFormat']); +const SAFE_SPAN_IGNORED_INLINE_RUN_ATTR_KEYS = [...VOLATILE_RUN_ATTR_KEYS, 'runPropertiesInlineKeys', 'fontFamily']; /** * Supported diff operations for inline changes. @@ -12,14 +15,17 @@ type MarkJSON = { type: string; attrs?: Record }; type InlineAction = 'added' | 'deleted' | 'modified'; /** - * Serialized representation of a single text character plus its run attributes. + * Serialized representation of a text segment plus its run attributes. */ export type InlineTextToken = { kind: 'text'; - char: string; + text: string; + length: number; runAttrs: Record; marks: MarkJSON[]; offset?: number | null; + // Inclusive PM position of the last source char in this token. + endOffset?: number | null; }; /** @@ -39,6 +45,19 @@ export type InlineNodeToken = { */ export type InlineDiffToken = InlineTextToken | InlineNodeToken; +export interface InlineTokenSegment { + tokens: InlineDiffToken[]; + isTextOnly: boolean; + isIndividuallySafe: boolean; + sourceStartTokenIdx: number; +} + +export interface InlineDiffPlanEntry { + oldSegment: InlineTokenSegment; + newSegment: InlineTokenSegment; + useWordLevel: boolean; +} + /** * Narrow an inline token to an inline-node token. * @@ -49,6 +68,27 @@ function isInlineNodeToken(token: InlineDiffToken): token is InlineNodeToken { return token.kind === 'inlineNode'; } +/** + * Determines whether a text-only span is eligible for word-level tokenization. + * + * The span must be semantically plain text on both sides: no inline anchors, + * no tracked/comment markers, no field-like run metadata, and no formatting + * drift between the old/new token sequences. + */ +export function isSafeSpan(oldTokens: InlineDiffToken[], newTokens: InlineDiffToken[]): boolean { + const spanTokens = [...oldTokens, ...newTokens]; + if (spanTokens.some((token) => token.kind !== 'text')) { + return false; + } + + const textTokens = spanTokens as InlineTextToken[]; + if (textTokens.some((token) => hasUnsafeMarks(token.marks ?? []) || hasFieldLikeRunAttrs(token.runAttrs))) { + return false; + } + + return hasUniformTextFormatting(textTokens); +} + /** * Intermediate text diff emitted by `diffSequences`. */ @@ -162,15 +202,19 @@ export function tokenizeInlineContent(pmNode: PMNode, baseOffset = 0): InlineDif } if (nodeText) { + // pos is the text node's first-character offset; pos-1 is the run's opening token, + // so nodeAt(pos-1) returns the parent run node and its OOXML attrs. const runNode = pos > 0 ? pmNode.nodeAt(pos - 1) : null; const runAttrs = runNode?.attrs ?? {}; const tokenOffset = baseOffset + pos; for (let i = 0; i < nodeText.length; i += 1) { content.push({ kind: 'text', - char: nodeText[i] ?? '', + text: nodeText[i] ?? '', + length: 1, runAttrs, offset: tokenOffset + i, + endOffset: tokenOffset + i, marks: node.marks?.map((mark) => mark.toJSON()) ?? [], }); } @@ -185,6 +229,13 @@ export function tokenizeInlineContent(pmNode: PMNode, baseOffset = 0): InlineDif nodeJSON: node.toJSON(), pos: baseOffset + pos, }); + if (node.type.name === 'structuredContent') { + // Treat structuredContent as an atomic unit: emitting the parent as + // inlineNode and also descending into descendants produces both an + // inlineNode token and text tokens for the same content region, causing + // double-application during replay (boundary leak + duplicate edits). + return false; + } } }, 0, @@ -192,6 +243,57 @@ export function tokenizeInlineContent(pmNode: PMNode, baseOffset = 0): InlineDif return content; } +/** + * Re-groups character-level text tokens into word-boundary tokens. + * + * Safe spans are guaranteed to be text-only and formatting-uniform, so we can + * preserve the first token's attrs/marks while coalescing adjacent characters + * that belong to the same lexical class. + */ +function reTokenizeAsWords(tokens: InlineDiffToken[]): InlineDiffToken[] { + const wordTokens: InlineDiffToken[] = []; + let currentGroup: InlineTextToken | null = null; + let currentCategory: 'word' | 'whitespace' | 'punctuation' | null = null; + + const pushCurrentGroup = () => { + if (currentGroup) { + wordTokens.push(currentGroup); + currentGroup = null; + currentCategory = null; + } + }; + + for (const token of tokens) { + if (token.kind !== 'text') { + pushCurrentGroup(); + wordTokens.push(token); + continue; + } + + const tokenCategory = classifyTextToken(token.text); + if ( + !currentGroup || + currentCategory !== tokenCategory || + !areInlineAttrsEqual(currentGroup.runAttrs, token.runAttrs) || + !areInlineMarksEqual(currentGroup.marks, token.marks) + ) { + pushCurrentGroup(); + currentGroup = { ...token }; + currentCategory = tokenCategory; + continue; + } + + currentGroup.text += token.text; + currentGroup.length += token.length; + // Preserve the right edge of the last source token so replay can span + // multi-run words without reconstructing positions from string length. + currentGroup.endOffset = token.endOffset ?? token.offset ?? currentGroup.endOffset ?? null; + } + + pushCurrentGroup(); + return wordTokens; +} + /** * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. * @@ -205,70 +307,178 @@ export function getInlineDiff( newContent: InlineDiffToken[], oldParagraphEndPos: number, ): InlineDiffResult[] { - const buildInlineDiff = ( - action: Exclude, - token: InlineDiffToken, - oldIdx: number, - ): RawDiff => { - if (token.kind !== 'text') { - return { - action, - idx: oldIdx, - kind: 'inlineNode', - nodeJSON: token.nodeJSON ?? token.node.toJSON(), - nodeType: token.nodeType, + const oldSegments = segmentInlineTokens(oldContent); + const newSegments = segmentInlineTokens(newContent); + const segmentPlan = buildInlineDiffPlan(oldSegments, newSegments); + + if (!segmentPlan) { + const shouldUseWordTokens = isSafeSpan(oldContent, newContent); + const diffOldContent = shouldUseWordTokens ? reTokenizeAsWords(oldContent) : oldContent; + const diffNewContent = shouldUseWordTokens ? reTokenizeAsWords(newContent) : newContent; + return groupDiffs(buildRawDiffs(diffOldContent, diffNewContent), diffOldContent, oldParagraphEndPos); + } + + const mergedOldTokens: InlineDiffToken[] = []; + const allDiffs: RawDiff[] = []; + + for (const planEntry of segmentPlan) { + const diffOldTokens = planEntry.useWordLevel + ? reTokenizeAsWords(planEntry.oldSegment.tokens) + : planEntry.oldSegment.tokens; + const diffNewTokens = planEntry.useWordLevel + ? reTokenizeAsWords(planEntry.newSegment.tokens) + : planEntry.newSegment.tokens; + const idxOffset = mergedOldTokens.length; + + mergedOldTokens.push(...diffOldTokens); + allDiffs.push(...buildRawDiffs(diffOldTokens, diffNewTokens, idxOffset)); + } + + return groupDiffs(allDiffs, mergedOldTokens, oldParagraphEndPos); +} + +export function segmentInlineTokens(tokens: InlineDiffToken[]): InlineTokenSegment[] { + const segments: InlineTokenSegment[] = []; + let currentSegment: InlineTokenSegment | null = null; + + const pushCurrentSegment = () => { + if (currentSegment) { + segments.push(currentSegment); + currentSegment = null; + } + }; + + tokens.forEach((token, index) => { + const tokenIsText = token.kind === 'text'; + const tokenIsIndividuallySafe = tokenIsText ? isIndividuallySafeTextToken(token) : false; + const previousToken = currentSegment?.tokens[currentSegment.tokens.length - 1]; + const shouldStartNewSegment = + !currentSegment || + !previousToken || + !tokenIsText || + previousToken.kind !== 'text' || + !currentSegment.isTextOnly || + currentSegment.isIndividuallySafe !== tokenIsIndividuallySafe || + !areInlineAttrsEqual(previousToken.runAttrs, token.runAttrs) || + !areInlineMarksEqual(previousToken.marks, token.marks); + + if (shouldStartNewSegment) { + pushCurrentSegment(); + currentSegment = { + tokens: [token], + isTextOnly: tokenIsText, + isIndividuallySafe: tokenIsIndividuallySafe, + sourceStartTokenIdx: index, }; + return; } + + currentSegment.tokens.push(token); + }); + + pushCurrentSegment(); + return segments; +} + +export function buildInlineDiffPlan( + oldSegments: InlineTokenSegment[], + newSegments: InlineTokenSegment[], +): InlineDiffPlanEntry[] | null { + if (oldSegments.length !== newSegments.length) { + return null; + } + + return oldSegments.map((oldSegment, index) => { + const newSegment = newSegments[index]!; return { - action, - idx: oldIdx, - kind: 'text', - text: token.char, - runAttrs: token.runAttrs, - marks: token.marks, + oldSegment, + newSegment, + useWordLevel: + oldSegment.isTextOnly && + newSegment.isTextOnly && + oldSegment.isIndividuallySafe && + newSegment.isIndividuallySafe && + isSafeSpan(oldSegment.tokens, newSegment.tokens), }; - }; + }); +} - const diffs = diffSequences(oldContent, newContent, { +function buildRawDiffs(oldContent: InlineDiffToken[], newContent: InlineDiffToken[], idxOffset = 0): RawDiff[] { + return diffSequences(oldContent, newContent, { comparator: inlineComparator, shouldProcessEqualAsModification, - canTreatAsModification: (oldToken, newToken) => - isInlineNodeToken(oldToken) && isInlineNodeToken(newToken) && oldToken.node.type.name === newToken.node.type.name, - buildAdded: (token, oldIdx) => buildInlineDiff('added', token, oldIdx), - buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx), - buildModified: (oldToken, newToken, oldIdx) => { - if (oldToken.kind !== 'text' && newToken.kind !== 'text') { - const oldNormalized = normalizeInlineNodeAttrs(oldToken.node.type.name, oldToken.node.attrs); - const newNormalized = normalizeInlineNodeAttrs(newToken.node.type.name, newToken.node.attrs); - const attrsDiff = getAttributesDiff(oldNormalized, newNormalized); - return { - action: 'modified', - idx: oldIdx, - kind: 'inlineNode', - oldNodeJSON: oldToken.node.toJSON(), - newNodeJSON: newToken.node.toJSON(), - nodeType: oldToken.nodeType, - attrsDiff, - }; + canTreatAsModification: (oldToken, newToken) => { + if (isInlineNodeToken(oldToken) && isInlineNodeToken(newToken)) { + return oldToken.node.type.name === newToken.node.type.name; } + if (oldToken.kind === 'text' && newToken.kind === 'text') { - return { - action: 'modified', - idx: oldIdx, - kind: 'text', - newText: newToken.char, - oldText: oldToken.char, - oldAttrs: oldToken.runAttrs, - newAttrs: newToken.runAttrs, - oldMarks: oldToken.marks, - newMarks: newToken.marks, - }; + return ( + areInlineAttrsEqual(oldToken.runAttrs, newToken.runAttrs) && + areInlineMarksEqual(oldToken.marks, newToken.marks) + ); } - return null; + + return false; }, + buildAdded: (token, oldIdx) => buildInlineDiff('added', token, oldIdx + idxOffset), + buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx + idxOffset), + buildModified: (oldToken, newToken, oldIdx) => buildInlineModified(oldToken, newToken, oldIdx + idxOffset), }); +} + +function buildInlineDiff(action: Exclude, token: InlineDiffToken, oldIdx: number): RawDiff { + if (token.kind !== 'text') { + return { + action, + idx: oldIdx, + kind: 'inlineNode', + nodeJSON: token.nodeJSON ?? token.node.toJSON(), + nodeType: token.nodeType, + }; + } - return groupDiffs(diffs, oldContent, oldParagraphEndPos); + return { + action, + idx: oldIdx, + kind: 'text', + text: token.text, + runAttrs: token.runAttrs, + marks: token.marks, + }; +} + +function buildInlineModified(oldToken: InlineDiffToken, newToken: InlineDiffToken, oldIdx: number): RawDiff | null { + if (oldToken.kind !== 'text' && newToken.kind !== 'text') { + const oldNormalized = normalizeInlineNodeAttrs(oldToken.node.type.name, oldToken.node.attrs); + const newNormalized = normalizeInlineNodeAttrs(newToken.node.type.name, newToken.node.attrs); + const attrsDiff = getAttributesDiff(oldNormalized, newNormalized); + return { + action: 'modified', + idx: oldIdx, + kind: 'inlineNode', + oldNodeJSON: oldToken.node.toJSON(), + newNodeJSON: newToken.node.toJSON(), + nodeType: oldToken.nodeType, + attrsDiff, + }; + } + + if (oldToken.kind === 'text' && newToken.kind === 'text') { + return { + action: 'modified', + idx: oldIdx, + kind: 'text', + newText: newToken.text, + oldText: oldToken.text, + oldAttrs: oldToken.runAttrs, + newAttrs: newToken.runAttrs, + oldMarks: oldToken.marks, + newMarks: newToken.marks, + }; + } + + return null; } /** @@ -282,7 +492,7 @@ function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean { } if (a.kind === 'text' && b.kind === 'text') { - return a.char === b.char; + return a.text === b.text; } if (a.kind === 'inlineNode' && b.kind === 'inlineNode') { return semanticInlineNodeKey(a.node) === semanticInlineNodeKey(b.node); @@ -290,13 +500,83 @@ function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean { return false; } +/** + * Returns true when any mark in the token belongs to tracked changes or + * comment-anchored review metadata. + */ +function hasUnsafeMarks(marks: MarkJSON[] = []): boolean { + return marks.some( + (mark) => TRACK_CHANGE_MARK_NAMES.has(mark.type) || mark.type === 'commentMark' || mark.type === 'comment', + ); +} + +function isIndividuallySafeTextToken(token: InlineTextToken): boolean { + return !hasUnsafeMarks(token.marks ?? []) && !hasFieldLikeRunAttrs(token.runAttrs); +} + +/** + * Conservatively detects field-like run metadata that should stay on the + * character-level diff path. + */ +function hasFieldLikeRunAttrs(runAttrs: Record): boolean { + return objectContainsString(runAttrs, 'PAGEREF'); +} + +/** + * Ensures every text token in the span shares the same marks and run attrs, + * which implies there is no formatting drift between the old/new sides. + */ +function hasUniformTextFormatting(tokens: InlineTextToken[]): boolean { + if (tokens.length === 0) { + return true; + } + + const [{ marks: referenceMarks, runAttrs: referenceRunAttrs }] = tokens; + return tokens.every((token) => { + return !getMarksDiff(referenceMarks, token.marks) && !getSafeSpanInlineAttrsDiff(referenceRunAttrs, token.runAttrs); + }); +} + +/** + * Recursively searches for a substring inside an arbitrary attribute payload. + */ +function objectContainsString(value: unknown, needle: string): boolean { + if (typeof value === 'string') { + return value.toUpperCase().includes(needle); + } + + if (Array.isArray(value)) { + return value.some((item) => objectContainsString(item, needle)); + } + + if (value && typeof value === 'object') { + return Object.values(value as Record).some((item) => objectContainsString(item, needle)); + } + + return false; +} + +/** + * Buckets text into word / whitespace / punctuation classes for word-level + * diff tokenization. + */ +function classifyTextToken(text: string): 'word' | 'whitespace' | 'punctuation' { + if (/^\s+$/u.test(text)) { + return 'whitespace'; + } + if (/^\w+$/u.test(text)) { + return 'word'; + } + return 'punctuation'; +} + /** * Determines whether equal tokens should still be treated as modifications, either because run attributes changed or the node payload differs. */ function shouldProcessEqualAsModification(oldToken: InlineDiffToken, newToken: InlineDiffToken): boolean { if (oldToken.kind === 'text' && newToken.kind === 'text') { return ( - Boolean(getAttributesDiff(oldToken.runAttrs, newToken.runAttrs)) || + Boolean(getInlineAttrsDiff(oldToken.runAttrs, newToken.runAttrs)) || oldToken.marks?.length !== newToken.marks?.length || Boolean(getMarksDiff(oldToken.marks, newToken.marks)) ); @@ -407,6 +687,40 @@ function groupDiffs(diffs: RawDiff[], oldTokens: InlineDiffToken[], oldParagraph return grouped; } +function canBridgeSingleSpaceGap( + currentGroup: TextDiffGroup, + diff: RawTextDiff, + oldTokens: InlineDiffToken[], + oldParagraphEndPos: number, +): boolean { + if (currentGroup.action !== 'modified' || diff.action !== 'modified') { + return false; + } + + if ( + !areInlineAttrsEqual(currentGroup.oldAttrs, diff.oldAttrs) || + !areInlineAttrsEqual(currentGroup.newAttrs, diff.newAttrs) + ) { + return false; + } + if ( + !areInlineMarksEqual(currentGroup.oldMarks, diff.oldMarks) || + !areInlineMarksEqual(currentGroup.newMarks, diff.newMarks) + ) { + return false; + } + + const diffPos = resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos); + if (diffPos == null || currentGroup.endPos == null) { + return false; + } + if (diffPos !== currentGroup.endPos + 2) { + return false; + } + + return resolveDocumentTextSlice(oldTokens, currentGroup.endPos + 1, diffPos - 1) === ' '; +} + /** * Builds a fresh text diff group seeded with the current diff token. */ @@ -417,7 +731,7 @@ function createTextGroup(diff: RawTextDiff, oldTokens: InlineDiffToken[], oldPar action: diff.action, kind: 'text' as const, startPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), - endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), + endPos: resolveTextGroupEndPosition(diff, oldTokens, oldParagraphEndPos), newText: diff.newText, oldText: diff.oldText, oldAttrs: diff.oldAttrs, @@ -429,7 +743,10 @@ function createTextGroup(diff: RawTextDiff, oldTokens: InlineDiffToken[], oldPar action: diff.action, kind: 'text' as const, startPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), - endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), + endPos: + diff.action === 'added' + ? resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos) + : resolveTextGroupEndPosition(diff, oldTokens, oldParagraphEndPos), text: diff.text, runAttrs: diff.runAttrs, marks: diff.marks, @@ -448,8 +765,16 @@ function extendTextGroup( oldTokens: InlineDiffToken[], oldParagraphEndPos: number, ): void { - group.endPos = resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos); + const shouldBridgeSingleSpace = canBridgeSingleSpaceGap(group, diff, oldTokens, oldParagraphEndPos); + group.endPos = + group.action === 'added' + ? resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos) + : resolveTextGroupEndPosition(diff, oldTokens, oldParagraphEndPos); if (group.action === 'modified' && diff.action === 'modified') { + if (shouldBridgeSingleSpace) { + group.newText += ' '; + group.oldText += ' '; + } group.newText += diff.newText; group.oldText += diff.oldText; } else if (group.action !== 'modified' && diff.action !== 'modified') { @@ -496,7 +821,18 @@ function canExtendGroup( if (diffPos == null || group.endPos == null) { return false; } - return group.endPos + 1 === diffPos; + return group.endPos + 1 === diffPos || canBridgeSingleSpaceGap(group, diff, oldTokens, oldParagraphEndPos); +} + +/** + * Returns the inclusive end position in the old document for a text diff. + */ +function resolveTextGroupEndPosition( + diff: RawTextDiff, + oldTokens: InlineDiffToken[], + oldParagraphEndPos: number, +): number | null { + return resolveTokenEndPosition(oldTokens, diff.idx, oldParagraphEndPos); } /** @@ -524,6 +860,67 @@ function resolveTokenPosition(tokens: InlineDiffToken[], idx: number, paragraphE return null; } +/** + * Maps a raw diff index back to the inclusive end position for the + * corresponding token span in the old document. + * + * Text tokens can span multiple runs after word re-tokenization, so the end + * position must come from the last source token offset rather than + * `offset + text.length - 1`. + * + * @param tokens Flattened tokens from the old paragraph. + * @param idx Index provided by the Myers diff output. + * @param paragraphEndPos Absolute document position marking the paragraph boundary. + * @returns Inclusive end position or null when the index is outside the known ranges. + */ +function resolveTokenEndPosition(tokens: InlineDiffToken[], idx: number, paragraphEndPos: number): number | null { + if (idx < 0) { + return null; + } + const token = tokens[idx]; + if (token) { + if (token.kind === 'text') { + if (token.endOffset != null) { + return token.endOffset; + } + if (token.offset == null) { + return null; + } + return token.offset + token.length - 1; + } + return token.pos ?? null; + } + if (idx === tokens.length) { + return paragraphEndPos; + } + return null; +} + +function resolveDocumentTextSlice(tokens: InlineDiffToken[], startPos: number, endPos: number): string { + if (endPos < startPos) { + return ''; + } + + let text = ''; + for (const token of tokens) { + if (token.kind !== 'text' || token.offset == null) { + continue; + } + + const tokenStart = token.offset; + const tokenEnd = token.endOffset ?? token.offset + token.length - 1; + if (tokenEnd < startPos || tokenStart > endPos) { + continue; + } + + const sliceStart = Math.max(startPos - tokenStart, 0); + const sliceEnd = Math.min(endPos - tokenStart + 1, token.text.length); + text += token.text.slice(sliceStart, sliceEnd); + } + + return text; +} + /** * Compares two sets of inline attributes and determines if they are equal. * @@ -532,7 +929,31 @@ function resolveTokenPosition(tokens: InlineDiffToken[], idx: number, paragraphE * @returns `true` if the attributes are equal, `false` otherwise. */ function areInlineAttrsEqual(a: Record | undefined, b: Record | undefined): boolean { - return !getAttributesDiff(a ?? {}, b ?? {}); + return !getInlineAttrsDiff(a ?? {}, b ?? {}); +} + +/** + * Computes attribute diffs for inline text semantics while ignoring volatile + * OOXML revision metadata that should not affect granularity decisions. + */ +function getInlineAttrsDiff( + a: Record | undefined, + b: Record | undefined, +): AttributesDiff | null { + return getAttributesDiff(a ?? {}, b ?? {}, VOLATILE_RUN_ATTR_KEYS); +} + +/** + * Computes the attr diff used only for granularity eligibility checks. + * This intentionally ignores importer/bookkeeping churn such as + * `runPropertiesInlineKeys` and OOXML font-family payload differences that do + * not necessarily correspond to a visible text-style change. + */ +function getSafeSpanInlineAttrsDiff( + a: Record | undefined, + b: Record | undefined, +): AttributesDiff | null { + return getAttributesDiff(a ?? {}, b ?? {}, SAFE_SPAN_IGNORED_INLINE_RUN_ATTR_KEYS); } /** diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.test.js index f5f645490a..8804d7d62f 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -16,7 +16,8 @@ import { semanticInlineNodeKey } from './semantic-normalization.ts'; * @param {Record} attrs Run attributes to attach. * @returns {Array>} */ -const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: attrs, kind: 'text' })); +const buildRuns = (text, attrs = {}) => + text.split('').map((char) => ({ text: char, length: 1, runAttrs: attrs, kind: 'text' })); /** * Builds marked text tokens with offsets for paragraph diff tests. @@ -29,7 +30,8 @@ const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, ru */ const buildMarkedRuns = (text, marks, attrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ - char, + text: char, + length: 1, runAttrs: attrs, kind: 'text', marks, @@ -85,12 +87,12 @@ const createParagraphNode = (overrides = {}) => { */ /** * Derives a content signature from tokens, matching the real buildContentSignature logic. - * Text tokens contribute their char; inline node tokens contribute a normalized JSON key. + * Text tokens contribute their text; inline node tokens contribute a normalized JSON key. */ const deriveContentSignature = (tokens) => tokens .map((token) => { - if (token.kind === 'text') return token.char; + if (token.kind === 'text') return token.text; if (token.kind === 'inlineNode' && token.node) { return `\0${semanticInlineNodeKey(token.node)}\0`; } @@ -143,6 +145,27 @@ describe('shouldProcessEqualAsModification', () => { const node = { toJSON: () => ({ attrs: { bold: true } }) }; expect(shouldProcessEqualAsModification({ node }, { node })).toBe(false); }); + + it('returns false when runs differ only in volatile rsid attrs (SD-3339 — Word save reassigns rsids)', () => { + // When Word saves a document without any visible edit, it may reassign rsidR/rsidRPr + // on unchanged runs. These paragraphs must not produce false modifications. + const makePara = (rsidR) => ({ + node: { + toJSON: () => ({ + type: 'paragraph', + attrs: { paraId: 'P1', align: 'left' }, + content: [ + { + type: 'run', + attrs: { rsidR, rsidRPr: rsidR, styleId: 'Normal' }, + content: [{ type: 'text', text: 'Unchanged paragraph content.' }], + }, + ], + }), + }, + }); + expect(shouldProcessEqualAsModification(makePara('00112233'), makePara('00AABBCC'))).toBe(false); + }); }); describe('paragraphComparator', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.ts index bfb38e0a7d..e550fa3b29 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/paragraph-diffing.ts @@ -7,6 +7,9 @@ import { levenshteinDistance } from './similarity'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. const SIMILARITY_THRESHOLD = 0.65; +// Higher threshold for sequences with repeated-content paragraphs (legal boilerplate, etc.). +// Repeated paragraphs share incidental character overlap without being semantically related edits. +const REPEATED_CONTENT_SIMILARITY_THRESHOLD = 0.7; const MIN_LENGTH_FOR_SIMILARITY = 4; type NodeJSON = ReturnType; @@ -99,7 +102,18 @@ export function createParagraphSnapshot(paragraph: PMNode, paragraphPos: number, depth, text, endPos: paragraphPos + 1 + paragraph.content.size, - fullText: text.map((token) => (token.kind === 'text' ? token.char : '')).join(''), + fullText: text + .map((token) => { + if (token.kind === 'text') return token.text; + // Recover visible text from atomic inline containers (structuredContent) + // so that paragraph similarity scoring and oldText/newText summaries + // include SDT content rather than treating the control as empty. + if (token.kind === 'inlineNode' && token.nodeType === 'structuredContent') { + return token.node.textContent; + } + return ''; + }) + .join(''), contentSignature: buildContentSignature(text), }; } @@ -116,7 +130,7 @@ function buildContentSignature(tokens: InlineDiffToken[]): string { return tokens .map((token) => { if (token.kind === 'text') { - return token.char; + return token.text; } // Null bytes delimit inline node keys so they can't collide with text return `\0${semanticInlineNodeKey(token.node)}\0`; @@ -229,8 +243,16 @@ export function buildModifiedParagraphDiff( /** * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. + * + * @param hasRepeatedContent When true, applies a stricter similarity threshold to avoid false pairings + * in documents with many repeated paragraphs (e.g. legal boilerplate). Repeated paragraphs share + * incidental character overlap that would otherwise pull unrelated paragraphs into a "modified" pairing. */ -export function canTreatAsModification(oldParagraph: ParagraphNodeInfo, newParagraph: ParagraphNodeInfo): boolean { +export function canTreatAsModification( + oldParagraph: ParagraphNodeInfo, + newParagraph: ParagraphNodeInfo, + hasRepeatedContent = false, +): boolean { if (oldParagraph?.depth !== newParagraph?.depth) { return false; } @@ -245,8 +267,9 @@ export function canTreatAsModification(oldParagraph: ParagraphNodeInfo, newParag return false; } + const threshold = hasRepeatedContent ? REPEATED_CONTENT_SIMILARITY_THRESHOLD : SIMILARITY_THRESHOLD; const similarity = getTextSimilarityScore(oldText, newText); - return similarity >= SIMILARITY_THRESHOLD; + return similarity >= threshold; } /** diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts index 1f9bd823ad..aec12ee7d1 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts @@ -183,6 +183,23 @@ describe('normalizeParagraphNodeJSON', () => { const result = normalizeParagraphNodeJSON(paragraphJSON) as any; expect(result.content[0].content[0]).toEqual({ type: 'text', text: 'hello' }); }); + + it('strips volatile rsid attrs from run nodes within a paragraph', () => { + const paragraphJSON = { + type: 'paragraph', + attrs: { paraId: 'P1', align: 'left' }, + content: [ + { + type: 'run', + attrs: { rsidR: '00112233', rsidRPr: 'aabbccdd', rsidDel: 'eeff0011', styleId: 'Normal' }, + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }; + + const result = normalizeParagraphNodeJSON(paragraphJSON) as any; + expect(result.content[0].attrs).toEqual({ styleId: 'Normal' }); + }); }); describe('normalizeDocJSON', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts index d61737d494..9ede867e0b 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts @@ -8,7 +8,9 @@ * never mutates live ProseMirror nodes or touches importer/exporter code. */ -import { NON_SEMANTIC_BLOCK_ATTRS } from './identity-attrs'; +import { NON_SEMANTIC_BLOCK_ATTRS, VOLATILE_RUN_ATTR_KEYS } from './identity-attrs'; + +const VOLATILE_RUN_ATTRS = new Set(VOLATILE_RUN_ATTR_KEYS); /** * Keys inside `originalAttributes` on image nodes that Word regenerates @@ -93,9 +95,73 @@ export function normalizeInlineNodeJSON(nodeJSON: Record): Reco if (nodeJSON.type === 'image') { return normalizeImageNodeJSON(nodeJSON); } + if (nodeJSON.type === 'structuredContent') { + return normalizeStructuredContentNodeJSON(nodeJSON); + } return nodeJSON; } +/** + * Top-level SDT attrs that Word regenerates on every save and must not + * participate in semantic equality comparisons. + * + * - `id` — maps to `` in OOXML; a per-instance numeric identifier + * reassigned whenever Word saves the document. + * - `sdtPr` — the raw `` XML snapshot stored for export round-trip. + * It also contains ``, so two otherwise-identical SDTs saved + * at different times will differ in this field. All semantic + * properties (tag, alias, lockMode, controlType, …) are already + * extracted into their own attrs, so sdtPr is safe to exclude from + * diff equality. + */ +const VOLATILE_SDT_TOP_LEVEL_ATTR_KEYS = new Set(['id', 'sdtPr']); + +/** + * Strips volatile attrs from a structuredContent node JSON tree. + * + * Two passes are applied: + * 1. Top-level SDT attrs: `id` and `sdtPr` are removed from the SDT root's own attrs. + * 2. Descendant run attrs: `rsidR`, `rsidRPr`, `rsidDel` are removed recursively + * from all content children (run nodes inside the SDT). + * + * @param nodeJSON Serialized structuredContent node. + * @returns Normalized copy with volatile attrs removed. + */ +function normalizeStructuredContentNodeJSON(nodeJSON: Record): Record { + // Strip top-level volatile SDT attrs from the SDT root node only. + const result: Record = { ...nodeJSON }; + if (result.attrs !== null && typeof result.attrs === 'object') { + result.attrs = omitKeys(result.attrs as Record, VOLATILE_SDT_TOP_LEVEL_ATTR_KEYS); + } + // Strip volatile run attrs recursively from all content descendants. + if (Array.isArray(result.content)) { + result.content = result.content.map((child) => + child !== null && typeof child === 'object' ? stripVolatileRunAttrsDeep(child as Record) : child, + ); + } + return result; +} + +function stripVolatileRunAttrsDeep(json: Record): Record { + const result: Record = { ...json }; + + if (result.attrs !== null && typeof result.attrs === 'object') { + const attrs = { ...(result.attrs as Record) }; + for (const key of VOLATILE_RUN_ATTR_KEYS) { + delete attrs[key]; + } + result.attrs = attrs; + } + + if (Array.isArray(result.content)) { + result.content = result.content.map((child) => + child !== null && typeof child === 'object' ? stripVolatileRunAttrsDeep(child as Record) : child, + ); + } + + return result; +} + /** * Strips volatile metadata from an inline node's raw attributes. * @@ -164,6 +230,7 @@ export function normalizeParagraphNodeJSON(nodeJSON: Record): R * recurses into container nodes (e.g. runs) that have their own content. */ function normalizeContentNodeJSON(nodeJSON: Record): Record { + const attrs = nodeJSON.attrs as Record | undefined; const content = nodeJSON.content as Record[] | undefined; // Leaf inline nodes (image, etc.) @@ -171,9 +238,13 @@ function normalizeContentNodeJSON(nodeJSON: Record): Record { return ''; }; +/** + * Collects text fragments from inline changes for readable assertions. + * + * @param {Array>} contentDiff Inline diff entries. + * @returns {{ added: string[]; deleted: string[]; modified: Array<{ oldText: string; newText: string }> }} + */ +const collectInlineTextChanges = (contentDiff) => { + const added = []; + const deleted = []; + const modified = []; + + for (const change of contentDiff ?? []) { + if (change.kind !== 'text') { + continue; + } + + if (change.action === 'added' && typeof change.text === 'string') { + added.push(change.text); + continue; + } + + if (change.action === 'deleted' && typeof change.text === 'string') { + deleted.push(change.text); + continue; + } + + if (change.action === 'modified' && typeof change.oldText === 'string' && typeof change.newText === 'string') { + modified.push({ oldText: change.oldText, newText: change.newText }); + } + } + + return { added, deleted, modified }; +}; + +/** + * Collects plain-text paragraph contents from a ProseMirror document. + * + * @param {import('prosemirror-model').Node} doc + * @returns {string[]} + */ +const collectParagraphTexts = (doc) => { + const paragraphs = []; + + doc.descendants((node) => { + if (node.type.name === 'paragraph') { + paragraphs.push(node.textContent); + return false; + } + return undefined; + }); + + return paragraphs; +}; + describe('Diff', () => { it('Compares two documents and identifies added, deleted, and modified paragraphs', async () => { const { doc: docBefore, schema } = await getDocument('diff_before.docx'); @@ -80,8 +134,12 @@ describe('Diff', () => { 'Curabitur facilisis ligula suscipit enim pretium et nunc ligula, porttitor augue consequat maximus.', ); const textPropsChanges = diff?.contentDiff.filter((textDiff) => textDiff.action === 'modified'); - expect(textPropsChanges).toHaveLength(18); - expect(diff?.contentDiff).toHaveLength(24); + const { added, deleted, modified } = collectInlineTextChanges(diff?.contentDiff); + expect(textPropsChanges).toHaveLength(1); + expect(diff?.contentDiff).toHaveLength(4); + expect(deleted).toEqual(expect.arrayContaining([','])); + expect(added).toEqual(expect.arrayContaining(['nunc ligula, ', ' maximus'])); + expect(modified).toEqual(expect.arrayContaining([{ oldText: 'sed', newText: 'et' }])); // Deleted paragraph diff = getDiff( @@ -164,10 +222,12 @@ describe('Diff', () => { let diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'Here’s some text.'); expect(diff.newText).toBe('Here’s some NEW text.'); - expect(diff.contentDiff).toHaveLength(3); - expect(diff.contentDiff[0].newText).toBe(' '); - expect(diff.contentDiff[1].text).toBe('NEW'); - expect(diff.contentDiff[2].text).toBe(' '); + expect(diff.contentDiff).toHaveLength(1); + expect(diff.contentDiff[0]).toMatchObject({ + action: 'added', + kind: 'text', + text: 'NEW ', + }); diff = diffs.find((diff) => diff.action === 'deleted' && diff.oldText === 'I deleted this sentence.'); expect(diff).toBeDefined(); @@ -177,7 +237,7 @@ describe('Diff', () => { diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'We are not done yet.'); expect(diff.newText).toBe('We are done now.'); - expect(diff.contentDiff).toHaveLength(3); + expect(diff.contentDiff.length).toBeGreaterThan(0); }); it('Compare another set of two documents with only formatting changes', async () => { @@ -213,13 +273,14 @@ describe('Diff', () => { expect(diffs).toHaveLength(1); const diff = diffs[0]; expect(diff.action).toBe('modified'); - expect(diff.contentDiff).toHaveLength(3); - expect(diff.contentDiff[0].action).toBe('modified'); - expect(diff.contentDiff[0].kind).toBe('text'); - expect(diff.contentDiff[1].action).toBe('added'); - expect(diff.contentDiff[1].kind).toBe('inlineNode'); - expect(diff.contentDiff[2].action).toBe('added'); - expect(diff.contentDiff[2].kind).toBe('text'); + expect(diff.contentDiff).toHaveLength(2); + expect(diff.contentDiff[0].action).toBe('added'); + expect(diff.contentDiff[0].kind).toBe('inlineNode'); + expect(diff.contentDiff[1]).toMatchObject({ + action: 'added', + kind: 'text', + text: ' ', + }); }); it('Compare a complex document with table edits and tracked formatting', async () => { @@ -387,6 +448,156 @@ describe('Diff', () => { expect(trackedMarkDiffs).toHaveLength(0); }); + it('keeps SD-2787 replacements at whole-word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/sd_2787_source.docx'); + const { doc: docAfter } = await getDocument('word/sd_2787_target.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find( + (diff) => + diff.action === 'modified' && + diff.oldText?.includes('sentence that is a test') && + diff.oldText?.includes('friendly neighborhood Github') && + diff.newText?.includes('phrase that is an examination') && + diff.newText?.includes('friendly barrio Github'), + ); + + expect(paragraphDiff).toBeDefined(); + + const { added, deleted, modified } = collectInlineTextChanges(paragraphDiff?.contentDiff); + const replacedOldTexts = modified.map((change) => change.oldText); + const replacedNewTexts = modified.map((change) => change.newText); + const allDeleted = [...deleted, ...replacedOldTexts]; + const allAdded = [...added, ...replacedNewTexts]; + + expect(allDeleted).toEqual(expect.arrayContaining(['sentence', 'a test', 'some ', 'neighborhood'])); + expect(allAdded).toEqual(expect.arrayContaining(['phrase', 'an examination', 'barrio'])); + + expect(allDeleted).not.toEqual(expect.arrayContaining(['phra', 'xamina', 'a', 'ri'])); + expect(allAdded).not.toEqual(expect.arrayContaining(['ntence', 's', 'neigh', 'o', 'h', 'od'])); + expect(allAdded).not.toEqual(expect.arrayContaining(['some'])); + }); + + it('keeps IT-1029 repeated edit insertions as four whole-word additions', async () => { + const { doc: docBefore, schema } = await getDocument('word/it_1029_source.docx'); + const { doc: docAfter } = await getDocument('word/it_1029_target.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find( + (diff) => + diff.action === 'modified' && + diff.oldText?.startsWith('Lorem Ipsum') && + diff.newText?.startsWith('Edit Lorem Ipsum') && + diff.newText?.includes('dummy edit text') && + diff.newText?.includes('typesetting, edit remaining') && + diff.newText?.endsWith('Lorem Ipsum. edit'), + ); + + expect(paragraphDiff).toBeDefined(); + + const { added } = collectInlineTextChanges(paragraphDiff?.contentDiff); + const normalizedAdded = added.map((text) => text.trim().toLowerCase()); + + expect(added).toHaveLength(4); + expect(normalizedAdded).toEqual(['edit', 'edit', 'edit', 'edit']); + + expect(added).not.toEqual(expect.arrayContaining(['edi', ' t', 'edit ', ' edit'])); + }); + + it('does not delete IT-1029 paragraphs whose text still exists verbatim in the new document', async () => { + const { doc: docBefore, schema } = await getDocument('word/it_1029_source.docx'); + const { doc: docAfter } = await getDocument('word/it_1029_target.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const newParagraphTexts = new Set(collectParagraphTexts(docAfter)); + const falseDeletions = docDiffs.filter( + (diff) => + diff.action === 'deleted' && + typeof diff.oldText === 'string' && + diff.oldText.length > 0 && + newParagraphTexts.has(diff.oldText), + ); + + expect(falseDeletions).toEqual([]); + }); + + it('keeps LEASE to RENTAL replacement at whole-word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/doc_a.docx'); + const { doc: docAfter } = await getDocument('word/doc_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find( + (diff) => + diff.action === 'modified' && + diff.oldText?.startsWith('LEASE AGREEMENT') && + diff.newText?.startsWith('RENTAL AGREEMENT'), + ); + + expect(paragraphDiff).toBeDefined(); + + const { added, deleted, modified } = collectInlineTextChanges(paragraphDiff?.contentDiff); + const replacedOldTexts = modified.map((change) => change.oldText); + const replacedNewTexts = modified.map((change) => change.newText); + const allDeleted = [...deleted, ...replacedOldTexts]; + const allAdded = [...added, ...replacedNewTexts]; + + expect(allDeleted).toEqual(expect.arrayContaining(['LEASE'])); + expect(allAdded).toEqual(expect.arrayContaining(['RENTAL'])); + + expect(allDeleted).not.toEqual(expect.arrayContaining(['R', 'NT', 'L'])); + expect(allAdded).not.toEqual(expect.arrayContaining(['L', 'SE'])); + }); + + it('keeps electronic/electric and warranties/what replacements at whole-word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/doc_a1.docx'); + const { doc: docAfter } = await getDocument('word/doc_b1.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const electronicRelatedDiffs = docDiffs.filter( + (diff) => diff.oldText?.toLowerCase().includes('electronic') || diff.newText?.toLowerCase().includes('electric'), + ); + const warrantiesRelatedDiffs = docDiffs.filter( + (diff) => + diff.oldText?.toLowerCase().includes('warranties') || + diff.newText?.toLowerCase().includes('what') || + diff.oldText?.toLowerCase().includes('what') || + diff.newText?.toLowerCase().includes('warranties'), + ); + + expect(electronicRelatedDiffs.length).toBeGreaterThan(0); + expect(warrantiesRelatedDiffs.length).toBeGreaterThan(0); + + const electronicChanges = electronicRelatedDiffs.map((diff) => collectInlineTextChanges(diff.contentDiff)); + const electronicDeleted = [ + ...electronicChanges.flatMap((changes) => changes.deleted), + ...electronicChanges.flatMap((changes) => changes.modified.map((change) => change.oldText)), + ]; + const electronicAdded = [ + ...electronicChanges.flatMap((changes) => changes.added), + ...electronicChanges.flatMap((changes) => changes.modified.map((change) => change.newText)), + ]; + + expect(electronicDeleted).toEqual(expect.arrayContaining(['Electronic'])); + expect(electronicAdded).toEqual(expect.arrayContaining(['Electric'])); + expect(electronicDeleted).not.toEqual(expect.arrayContaining(['on'])); + expect(electronicAdded).not.toEqual(expect.arrayContaining(['lectr'])); + + const warrantiesChanges = warrantiesRelatedDiffs.map((diff) => collectInlineTextChanges(diff.contentDiff)); + const warrantiesDeleted = [ + ...warrantiesChanges.flatMap((changes) => changes.deleted), + ...warrantiesChanges.flatMap((changes) => changes.modified.map((change) => change.oldText)), + ]; + const warrantiesAdded = [ + ...warrantiesChanges.flatMap((changes) => changes.added), + ...warrantiesChanges.flatMap((changes) => changes.modified.map((change) => change.newText)), + ]; + + expect(warrantiesDeleted).toEqual(expect.arrayContaining(['Warranties'])); + expect(warrantiesAdded).toEqual(expect.arrayContaining(['What'])); + expect(warrantiesDeleted).not.toEqual(expect.arrayContaining(['What', ' test'])); + expect(warrantiesAdded).not.toEqual(expect.arrayContaining(['W', 'arranties'])); + }); + it('returns null style diffs when style snapshots are omitted', async () => { const { doc, schema } = await getDocument('diff_before8.docx'); const diff = computeDiff(doc, doc, schema); @@ -483,4 +694,365 @@ describe('Diff', () => { to: '%1)', }); }); + + it('SD-3339: does not produce false paragraph modifications when only run-level rsid attrs differ (IT-1132 PAGEREF fixture)', async () => { + // IT-1132: base and edited documents have identical visible content; the only differences + // are OOXML rsid attrs reassigned by Word on save. These must not produce any diffs. + const { doc: docBase, schema } = await getDocument('word/it_1132_base.docx'); + const { doc: docEdited } = await getDocument('word/it_1132_edited.docx'); + + const { docDiffs } = computeDiff(docBase, docEdited, schema); + + expect(docDiffs).toHaveLength(0); + }); +}); + +/** + * Collects all changed text fragments from a single paragraph diff. + * Merges deleted, added, and modified entries into flat arrays for easy assertions. + */ +function collectAllChanges(paragraphDiff) { + const { added, deleted, modified } = collectInlineTextChanges(paragraphDiff?.contentDiff); + return { + allDeleted: [...deleted, ...modified.map((c) => c.oldText)], + allAdded: [...added, ...modified.map((c) => c.newText)], + }; +} + +describe('computeDiff — word-level granularity fixtures', () => { + it('lease_basic: LEASE → RENTAL at whole-word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/lease_basic_a.docx'); + const { doc: docAfter } = await getDocument('word/lease_basic_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('LEASE'); + expect(allAdded).toContain('RENTAL'); + expect(allDeleted).not.toContain('L'); + expect(allDeleted).not.toContain('S'); + expect(allDeleted).not.toContain('SE'); + expect(allAdded).not.toContain('R'); + expect(allAdded).not.toContain('NT'); + expect(allAdded).not.toContain('AL'); + }); + + it('electronic_basic: electronic → electric at whole-word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/electronic_basic_a.docx'); + const { doc: docAfter } = await getDocument('word/electronic_basic_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('electronic'); + expect(allAdded).toContain('electric'); + expect(allDeleted).not.toContain('onic'); + expect(allDeleted).not.toContain('on'); + expect(allAdded).not.toContain('lectr'); + expect(allAdded).not.toContain('ic'); + }); + + it('warranties_basic: warranties → what at whole-word granularity (target has split runs with rsidR)', async () => { + const { doc: docBefore, schema } = await getDocument('word/warranties_basic_a.docx'); + const { doc: docAfter } = await getDocument('word/warranties_basic_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + // Target has "w" + "hat" in separate runs (rsidR on second run). + // Volatile rsidR must be ignored so the runs merge into whole-word "what". + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('warranties'); + expect(allAdded).toContain('what'); + expect(allDeleted).not.toContain('warrantie'); + expect(allDeleted).not.toContain('arranties'); + expect(allAdded).not.toContain('w'); + expect(allAdded).not.toContain('hat'); + }); + + it('lease_split_runs: LEASE → RENTAL with split-run fixture', async () => { + const { doc: docBefore, schema } = await getDocument('word/lease_split_runs_a.docx'); + const { doc: docAfter } = await getDocument('word/lease_split_runs_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('LEASE'); + expect(allAdded).toContain('RENTAL'); + expect(allDeleted).not.toContain('L'); + expect(allDeleted).not.toContain('S'); + expect(allDeleted).not.toContain('SE'); + expect(allAdded).not.toContain('R'); + expect(allAdded).not.toContain('NT'); + expect(allAdded).not.toContain('AL'); + }); + + it('electronic_split_runs: electronic → electric with multi-run target (electr + ic)', async () => { + const { doc: docBefore, schema } = await getDocument('word/electronic_split_runs_a.docx'); + const { doc: docAfter } = await getDocument('word/electronic_split_runs_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + // Target has "electr" + "ic" in separate runs (rsidR on second run). + // Both runs must merge into whole-word "electric" for word-level granularity. + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('electronic'); + expect(allAdded).toContain('electric'); + expect(allDeleted).not.toContain('onic'); + expect(allAdded).not.toContain('electr'); + expect(allAdded).not.toContain('ic'); + }); + + it('lease_sentence: LEASE → RENTAL within a full sentence (target has 3 runs)', async () => { + const { doc: docBefore, schema } = await getDocument('word/lease_sentence_a.docx'); + const { doc: docAfter } = await getDocument('word/lease_sentence_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find( + (d) => d.action === 'modified' && d.oldText?.includes('LEASE') && d.newText?.includes('RENTAL'), + ); + expect(paragraphDiff).toBeDefined(); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('LEASE'); + expect(allAdded).toContain('RENTAL'); + expect(allDeleted).not.toContain('L'); + expect(allDeleted).not.toContain('S'); + expect(allAdded).not.toContain('R'); + expect(allAdded).not.toContain('NT'); + }); + + it('lease_prefix: LEASE → LEASING (word expansion, target has split runs)', async () => { + const { doc: docBefore, schema } = await getDocument('word/lease_prefix_a.docx'); + const { doc: docAfter } = await getDocument('word/lease_prefix_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + // "LEAS" + "ING" runs in target must merge into whole-word "LEASING". + // The result must be a whole-word replacement or modification, not a partial insertion of "ING". + expect(allAdded).toContain('LEASING'); + expect(allAdded).not.toContain('LEAS'); + expect(allAdded).not.toContain('ING'); + expect(allAdded).not.toContain('I'); + }); + + it('warranties_prefix: warranties → warranty (word truncation, target has split runs)', async () => { + const { doc: docBefore, schema } = await getDocument('word/warranties_prefix_a.docx'); + const { doc: docAfter } = await getDocument('word/warranties_prefix_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const paragraphDiff = docDiffs.find((d) => d.action === 'modified'); + expect(paragraphDiff).toBeDefined(); + + // "warra" + "nty" runs in target must merge into whole-word "warranty". + // Without word-level: char-level Myers produces "ies" deleted + "y" added. + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + expect(allDeleted).toContain('warranties'); + expect(allAdded).toContain('warranty'); + expect(allDeleted).not.toContain('ies'); + expect(allAdded).not.toContain('warra'); + expect(allAdded).not.toContain('nty'); + expect(allAdded).not.toContain('y'); + }); + + it('lorem_ipsum_a1/b1: multi-paragraph document with LEASE→RENTAL, Electronic→Electric, Warranties→What at word granularity', async () => { + const { doc: docBefore, schema } = await getDocument('word/lorem_ipsum_a1.docx'); + const { doc: docAfter } = await getDocument('word/lorem_ipsum_b1.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + + // Five paragraphs change: LEASE→RENTAL, Electronic→Electric, Warranties→What, + // word appended ("Edited"), words deleted ("popularised" + "Lorem Ipsum"). + expect(docDiffs).toHaveLength(5); + expect(docDiffs.every((d) => d.action === 'modified')).toBe(true); + + // --- LEASE → RENTAL --- + const leaseDiff = docDiffs.find((d) => d.oldText?.startsWith('LEASE')); + expect(leaseDiff).toBeDefined(); + const { allDeleted: leaseDeleted, allAdded: leaseAdded } = collectAllChanges(leaseDiff); + expect(leaseDeleted).toContain('LEASE'); + expect(leaseAdded).toContain('RENTAL'); + expect(leaseDeleted).not.toContain('L'); + expect(leaseDeleted).not.toContain('SE'); + expect(leaseAdded).not.toContain('R'); + expect(leaseAdded).not.toContain('NT'); + + // --- Electronic → Electric --- + const electronicDiff = docDiffs.find((d) => d.oldText?.includes('Electronic')); + expect(electronicDiff).toBeDefined(); + const { allDeleted: elDeleted, allAdded: elAdded } = collectAllChanges(electronicDiff); + expect(elDeleted).toContain('Electronic'); + expect(elAdded).toContain('Electric'); + expect(elDeleted).not.toContain('on'); + expect(elAdded).not.toContain('lectr'); + + // --- Warranties → What --- + const warrantiesDiff = docDiffs.find((d) => d.oldText?.includes('Warranties')); + expect(warrantiesDiff).toBeDefined(); + const { allDeleted: wDeleted, allAdded: wAdded } = collectAllChanges(warrantiesDiff); + expect(wDeleted).toContain('Warranties'); + expect(wAdded).toContain('What'); + expect(wDeleted).not.toContain('arranties'); + expect(wAdded).not.toContain('W'); + }); + + it('doc_a2/doc_b2: complex real-world paragraph produces a single modified diff without char-level fragments', async () => { + const { doc: docBefore, schema } = await getDocument('word/doc_a2.docx'); + const { doc: docAfter } = await getDocument('word/doc_b2.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + + // The two documents each contain one paragraph — expect exactly one modified diff, + // not a deleted+added pair (which would mean the similarity threshold was not met). + expect(docDiffs).toHaveLength(1); + const paragraphDiff = docDiffs[0]; + expect(paragraphDiff.action).toBe('modified'); + expect(paragraphDiff.oldText).toContain('If Vendor were to recognize'); + expect(paragraphDiff.newText).toContain('Vendor recognizes that applying'); + + const { allDeleted, allAdded } = collectAllChanges(paragraphDiff); + + // The algorithm may group changes into spans of varying size (e.g. "were to recognize" → + // "recognizes" instead of individual words). What matters is: no char-level fragments from + // word replacement zones appear in the output. + const combinedDeleted = allDeleted.join(' '); + const combinedAdded = allAdded.join(' '); + + // Key old content must be covered somewhere in the deleted/modified set. + expect(combinedDeleted).toMatch(/were|recognize/); + expect(combinedDeleted).toMatch(/subject|applying/); + expect(combinedDeleted).toMatch(/administrative|operations/); + expect(combinedDeleted).toMatch(/burden|obligation/); + + // Key new content must be covered somewhere in the added/modified set. + expect(combinedAdded).toMatch(/recognizes|recognize/); + expect(combinedAdded).toMatch(/applying|subject/); + expect(combinedAdded).toMatch(/operations|administrative/); + expect(combinedAdded).toMatch(/obligation|burden/); + + // Negative: no char fragments that would indicate word-level re-tokenization failed. + expect(allAdded).not.toContain('gnizes'); + expect(allAdded).not.toContain('ecognizes'); + expect(allAdded).not.toContain('pplying'); + expect(allAdded).not.toContain('perations'); + expect(allAdded).not.toContain('bligation'); + }); +}); + +describe('computeDiff — structuredContent (SDT) diffing', () => { + it('sdt: produces no text-kind inline diffs at positions inside SDT nodes (no double-tokenization)', async () => { + const { doc: docBefore, schema } = await getDocument('word/sdt_a.docx'); + const { doc: docAfter } = await getDocument('word/sdt_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + + // Collect SDT position ranges from the base document so we can check + // whether any text-kind diff lands inside one. + const sdtRanges = []; + docBefore.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + sdtRanges.push({ from: pos, to: pos + node.nodeSize }); + } + }); + + const insideSDT = (pos) => sdtRanges.some((r) => pos > r.from && pos < r.to); + + const textDiffsInsideSDT = docDiffs + .filter((d) => d.action === 'modified' && d.contentDiff) + .flatMap((d) => d.contentDiff) + .filter((d) => d.kind === 'text' && insideSDT(d.startPos)); + + // Before the fix, tokenizeInlineContent emitted both an inlineNode token for the SDT + // and text tokens for its descendants — two conflicting diff paths for the same region. + // This produced text-kind diffs whose startPos falls inside the SDT boundary. + expect(textDiffsInsideSDT).toHaveLength(0); + }); + + it('sdt: changed inline SDT produces exactly one inlineNode diff with no overlapping text diffs at the same position', async () => { + const { doc: docBefore, schema } = await getDocument('word/sdt_a.docx'); + const { doc: docAfter } = await getDocument('word/sdt_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + + const allInlineDiffs = docDiffs + .filter((d) => d.action === 'modified' && d.contentDiff) + .flatMap((d) => d.contentDiff); + + const sdtInlineDiffs = allInlineDiffs.filter((d) => d.kind === 'inlineNode' && d.nodeType === 'structuredContent'); + + // "my field 2" SDT: content changed from "fawehifew fwe" to "fawehifew few edit". + // Must produce exactly one inlineNode diff representing the whole SDT replacement. + expect(sdtInlineDiffs).toHaveLength(1); + expect(sdtInlineDiffs[0].action).toMatch(/added|deleted|modified/); + + // Double-tokenization would produce both an inlineNode diff and a text diff at the + // same startPos. Confirm there is no text diff overlapping the SDT diff position. + const sdtPositions = new Set(sdtInlineDiffs.map((d) => d.startPos)); + const overlappingTextDiffs = allInlineDiffs.filter((d) => d.kind === 'text' && sdtPositions.has(d.startPos)); + expect(overlappingTextDiffs).toHaveLength(0); + }); + + it('sdt: SDT text content appears in oldText/newText paragraph summaries', async () => { + const { doc: docBefore, schema } = await getDocument('word/sdt_a.docx'); + const { doc: docAfter } = await getDocument('word/sdt_b.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + + // The paragraph "Linked Master [my field 2 SDT]": + // A: fullText = "Linked Master fawehifew fwe" + // B: fullText = "Linked Master fawehifew few edit" + // Without fullText recovery for SDTs, the SDT text would be missing and the + // paragraph would appear as "Linked Master " with empty SDT contribution. + const sdtParagraphDiff = docDiffs.find((d) => d.action === 'modified' && d.oldText?.includes('fawehifew')); + expect(sdtParagraphDiff).toBeDefined(); + expect(sdtParagraphDiff.oldText).toContain('fawehifew fwe'); + expect(sdtParagraphDiff.newText).toContain('fawehifew few edit'); + }); + + it('sdt: SDT with same visible content but regenerated id/sdtPr attrs produces no diff', async () => { + // Word regenerates `w:id` (and the raw `sdtPr` snapshot that also contains it) + // on every save. Two copies of the same document that differ only in these + // volatile attrs must not produce a diff for the unchanged SDT. + const { doc: docBefore, schema } = await getDocument('word/sdt_a.docx'); + + // Find an SDT node and build a clone with a different `id` and stripped `sdtPr`. + let sdtNode = null; + docBefore.descendants((node) => { + if (node.type.name === 'structuredContent' && !sdtNode) { + sdtNode = node; + } + }); + expect(sdtNode).not.toBeNull(); + + const { semanticInlineNodeKey } = await import('./algorithm/semantic-normalization.ts'); + + const originalKey = semanticInlineNodeKey(sdtNode); + + // Simulate what Word does: regenerate id and sdtPr on save. + const mutatedJSON = { + ...sdtNode.toJSON(), + attrs: { + ...sdtNode.attrs, + id: 'regenerated-id-99999', + sdtPr: { elements: [{ name: 'w:id', attributes: { 'w:val': '99999' } }] }, + }, + }; + const mutatedNode = { toJSON: () => mutatedJSON, type: sdtNode.type }; + const mutatedKey = semanticInlineNodeKey(mutatedNode); + + // After normalization both must produce the same key. + expect(mutatedKey).toBe(originalKey); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/diffing.js b/packages/super-editor/src/editors/v1/extensions/diffing/diffing.js index b226859e4f..96baef2701 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/diffing.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/diffing.js @@ -2,6 +2,7 @@ import { Extension } from '@core/Extension.js'; import { computeDiff } from './computeDiff.ts'; import { replayDiffs } from './replayDiffs.ts'; +import { STALE_POSITION_WARNING_TAG } from './replay/replay-inline.ts'; import { captureHeaderFooterState } from './algorithm/header-footer-diffing.ts'; import { capturePartsState } from './algorithm/parts-diffing.ts'; @@ -96,6 +97,18 @@ export const Diffing = Extension.create({ tr.setMeta('skipTrackChanges', true); } + const staleWarnings = replayResult.warnings.filter((w) => w.startsWith(STALE_POSITION_WARNING_TAG)); + if (staleWarnings.length > 0) { + console.error( + '[diffing] Stale positions detected during replay — re-capture and re-compare:', + staleWarnings, + ); + this.editor.emit('diffApplyError', { type: 'stalePositions', details: staleWarnings }); + // Do not dispatch: the transaction may be partially mutated. The caller + // must re-capture and re-compare before applying again. + return true; + } + if (dispatch && (tr.docChanged || replayResult.appliedDiffs > 0)) { dispatch(tr); } diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/editors/v1/extensions/diffing/headerFooters.test.ts index fc229a7e06..8892abd1c6 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/headerFooters.test.ts @@ -11,11 +11,15 @@ import { resolveSectionProjections } from '../../document-api-adapters/helpers/s /** * Creates a headless editor from a DOCX fixture. * + * @param fixtureName DOCX fixture filename. * @param user Optional user config for tracked replay tests. * @returns Headless editor ready for diffing tests. */ -async function createEditor(user?: { name: string; email: string }): Promise { - const buffer = await getTestDataAsBuffer('diffing/diff_before2.docx'); +async function createEditor( + fixtureName = 'diffing/diff_before2.docx', + user?: { name: string; email: string }, +): Promise { + const buffer = await getTestDataAsBuffer(fixtureName); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); return new Editor({ @@ -554,6 +558,41 @@ describe('Header/footer diffing', () => { } }); + it('detects real header/footer wording changes in SD-2238 fixtures', async () => { + const beforeEditor = await createEditor('diffing/word/sd_2238_header_footer_a.docx'); + const afterEditor = await createEditor('diffing/word/sd_2238_header_footer_b.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const modifiedParts = diff.headerFootersDiff?.modifiedParts ?? []; + const modifiedHeaderFooterParagraph = modifiedParts + .flatMap((part) => part.docDiffs) + .find( + (partDiff) => + partDiff.action === 'modified' && + partDiff.oldText?.toLowerCase().includes('rental') && + partDiff.newText?.toLowerCase().includes('lease'), + ); + + expect(diff.headerFootersDiff).not.toBeNull(); + expect(modifiedParts.length).toBeGreaterThan(0); + expect(modifiedHeaderFooterParagraph).toBeDefined(); + expect(modifiedHeaderFooterParagraph?.contentDiff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'modified', + kind: 'text', + oldText: expect.stringMatching(/rental/i), + newText: expect.stringMatching(/lease/i), + }), + ]), + ); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('treats header part path changes as a real diff', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); @@ -738,7 +777,7 @@ describe('Header/footer diffing', () => { it('keeps body replay tracked when header/footer diffs are present', async () => { const user = { name: 'Test User', email: 'test@example.com' }; - const beforeEditor = await createEditor(user); + const beforeEditor = await createEditor(undefined, user); const afterEditor = await createEditor(); try { diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.test.js index 340a014bf9..2ec53efe55 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.test.js @@ -144,7 +144,7 @@ const testTextDeleteRange = () => { const paragraphPos = findParagraphPos(doc); const startPos = paragraphPos + 1 + 1; - const endPos = startPos + 3; + const endPos = startPos + 2; const diff = { action: 'deleted', @@ -173,7 +173,7 @@ const testTextModifyRange = () => { const paragraphPos = findParagraphPos(doc); const startPos = paragraphPos + 1; - const endPos = startPos + 1; + const endPos = startPos; const diff = { action: 'modified', @@ -197,6 +197,37 @@ const testTextModifyRange = () => { expect(firstTextNode?.marks?.some((mark) => mark.type.name === 'bold')).toBe(true); }; +/** + * Verifies inline text modification replaces text when content changes. + * @returns {void} + */ +const testTextModifyReplacesText = () => { + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('sentence')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const startPos = findFirstTextPos(doc); + + const diff = { + action: 'modified', + kind: 'text', + startPos, + endPos: startPos + 'sentence'.length - 1, + oldText: 'sentence', + newText: 'phrase', + marksDiff: null, + runAttrsDiff: null, + }; + + const result = replayInlineDiff({ tr, diff, schema, paragraphEndPos: startPos + 'sentence'.length }); + + expect(result.applied).toBe(1); + expect(result.warnings).toEqual([]); + expect(tr.doc.textContent).toBe('phrase'); +}; + /** * Verifies inline node insertion is applied at the paragraph end. * @returns {void} @@ -347,11 +378,9 @@ const testTextModifyRunAttrsAcrossRuns = () => { action: 'modified', kind: 'text', startPos, - endPos: startPos, - // The replay helper computes `to` from oldText length, so we use a synthetic - // span that intersects both runs to validate range-based run-attrs updates. - oldText: 'AB__', - newText: 'AB__', + endPos: startPos + 3, // 'A'(+0) runA-close(+1) runB-open(+2) 'B'(+3) — spans both text nodes + oldText: 'AB', + newText: 'AB', marksDiff: null, runAttrsDiff: { added: {}, @@ -427,6 +456,7 @@ const runInlineReplaySuite = () => { it('inserts text at paragraph end when startPos is null', testTextAddAtParagraphEnd); it('deletes a text range', testTextDeleteRange); it('applies formatting for a modified text range', testTextModifyRange); + it('replaces text for a modified text range when content changes', testTextModifyReplacesText); it('applies run attributes for a modified text range', testTextModifyRunAttrsOnly); it('applies run attributes across multiple runs in a modified range', testTextModifyRunAttrsAcrossRuns); it('applies metadata run attributes when marks are modified', testTextModifyMarksAndRunMetadata); @@ -436,3 +466,163 @@ const runInlineReplaySuite = () => { }; describe('replayInlineDiff', runInlineReplaySuite); + +describe('replayInlineDiff staleness guard', () => { + it('skips and records a STALE_POSITION warning when oldText does not match (deleted)', () => { + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('Hello world')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const startPos = paragraphPos + 1 + 1; // points to 'e' in "Hello" + const endPos = startPos + 3; // covers "ello" (inclusive) + + const diff = { + action: 'deleted', + kind: 'text', + startPos, + endPos, + oldText: 'xyz', // stale — actual text is "ello" + }; + + const result = replayInlineDiff({ tr, diff, schema, paragraphEndPos: paragraphPos + 1 + paragraph.content.size }); + + expect(result.applied).toBe(0); + expect(result.skipped).toBe(1); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/STALE_POSITION/); + expect(result.warnings[0]).toMatch(/xyz/); + expect(result.warnings[0]).toMatch(/ello/); + }); + + it('skips and records a STALE_POSITION warning when oldText does not match (modified)', () => { + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('Hello world')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const startPos = paragraphPos + 1 + 1; + const endPos = startPos + 3; + + const diff = { + action: 'modified', + kind: 'text', + startPos, + endPos, + oldText: 'xyz', // stale — actual text is "ello" + newText: 'abc', + marks: [], + }; + + const result = replayInlineDiff({ tr, diff, schema, paragraphEndPos: paragraphPos + 1 + paragraph.content.size }); + + expect(result.applied).toBe(0); + expect(result.skipped).toBe(1); + expect(result.warnings[0]).toMatch(/STALE_POSITION/); + }); + + it('applies normally when oldText matches the actual text at the range', () => { + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('Hello world')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const startPos = paragraphPos + 1 + 1; + const endPos = startPos + 3; + + const diff = { + action: 'deleted', + kind: 'text', + startPos, + endPos, + oldText: 'ello', // matches actual text + }; + + const result = replayInlineDiff({ tr, diff, schema, paragraphEndPos: paragraphPos + 1 + paragraph.content.size }); + + expect(result.applied).toBe(1); + expect(result.skipped).toBe(0); + expect(result.warnings).toHaveLength(0); + }); + + it('skips stale deletion when the guard uses diff.text (as produced by groupDiffs — no oldText)', () => { + // groupDiffs sets result.text on deleted diffs, not result.oldText. + // The guard must fall back to diff.text for deletions. + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('Hello world')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const startPos = paragraphPos + 1 + 1; + const endPos = startPos + 3; + + const diff = { + action: 'deleted', + kind: 'text', + startPos, + endPos, + text: 'xyz', // stale — actual text is "ello". Note: text, not oldText. + }; + + const result = replayInlineDiff({ tr, diff, schema, paragraphEndPos: paragraphPos + 1 + paragraph.content.size }); + + expect(result.applied).toBe(0); + expect(result.skipped).toBe(1); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/STALE_POSITION/); + expect(result.warnings[0]).toMatch(/xyz/); + expect(result.warnings[0]).toMatch(/ello/); + }); + + it('applies first diff and skips stale second diff in the same transaction (partial-apply)', () => { + // Guards that a batch replay commits earlier steps even when a later step is stale. + // The transaction is NOT rolled back — partial apply is the intended behavior. + const schema = createSchema(); + const paragraph = createParagraph(schema, [schema.text('abcdef')]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const paragraphEndPos = paragraphPos + 1 + paragraph.content.size; + // 'a'=pos1, 'b'=2, 'c'=3, 'd'=4, 'e'=5, 'f'=6 + const aPos = paragraphPos + 1; + + // diff1: delete "def" at the end — valid, applied first to avoid position shift + const diff1 = { + action: 'deleted', + kind: 'text', + startPos: aPos + 3, + endPos: aPos + 5, + text: 'def', + }; + + // diff2: delete "xyz" at the start — stale, actual text is "abc" + const diff2 = { + action: 'deleted', + kind: 'text', + startPos: aPos, + endPos: aPos + 2, + text: 'xyz', + }; + + const result1 = replayInlineDiff({ tr, diff: diff1, schema, paragraphEndPos }); + const result2 = replayInlineDiff({ tr, diff: diff2, schema, paragraphEndPos: paragraphEndPos - 3 }); + + expect(result1.applied).toBe(1); + expect(result1.skipped).toBe(0); + expect(result2.applied).toBe(0); + expect(result2.skipped).toBe(1); + expect(result2.warnings[0]).toMatch(/STALE_POSITION/); + // Transaction contains only the first deletion — "def" removed, "abc" intact. + expect(tr.doc.textContent).toBe('abc'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.ts b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.ts index e5e8087d81..ab5fb89709 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-inline.ts @@ -1,4 +1,7 @@ import { Fragment, Slice } from 'prosemirror-model'; + +/** Tag prefix for stale-position warnings in ReplayResult.warnings. */ +export const STALE_POSITION_WARNING_TAG = '[STALE_POSITION]'; import { ReplaceStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; import { deepEquals } from '../algorithm/attributes-diffing'; @@ -70,14 +73,33 @@ export function replayInlineDiff({ } if (diff.kind === 'text') { - const textForRange = diff.action === 'modified' ? diff.oldText : diff.text; - const rangeLength = typeof textForRange === 'string' ? textForRange.length : null; - if (!isAddition && rangeLength !== null && from !== null) { - to = from + rangeLength; - } else if (!isAddition && to !== null && from !== null) { + if (!isAddition && to !== null && from !== null) { + // Diff end positions are inclusive; PM ReplaceStep expects an exclusive + // upper bound. We trust diff.endPos instead of recomputing from text length. to = to + 1; } + // Staleness guard: verify the document still has the expected text at the + // captured range. A mismatch means the document changed between compare() + // and apply() — skip the step and surface a tagged warning so the caller + // can emit an error event and prompt a re-compare. + // For modified diffs oldText carries the expected value; for deleted diffs + // the expected value is stored in diff.text (groupDiffs only sets oldText + // on modified results). + if (!isAddition && from !== null && to !== null) { + const expectedText = diff.oldText ?? diff.text ?? null; + if (expectedText != null) { + const actualText = tr.doc.textBetween(from, to); + if (actualText !== expectedText) { + result.skipped += 1; + result.warnings.push( + `${STALE_POSITION_WARNING_TAG} expected "${expectedText}" at ${from}–${to - 1}, found "${actualText}". Re-capture and re-compare before applying.`, + ); + return result; + } + } + } + if (isAddition) { if (!diff.text) { skipWithWarning('Missing text content for inline addition.'); @@ -116,47 +138,61 @@ export function replayInlineDiff({ skipWithWarning('Missing newText for inline modification.'); return result; } + const oldMarks = getMarksAtPosition(tr.doc, from); const marks = marksFromDiff({ schema, action: diff.action, marks: diff.marks, marksDiff: diff.marksDiff, - oldMarks: getMarksAtPosition(tr.doc, from), - }); - marks.forEach((mark) => { - const step = new AddMarkStep(from, to!, mark); - const stepResult = tr.maybeStep(step); - if (stepResult.failed) { - skipWithWarning(`Failed to add mark ${mark.type.name} at ${from}-${to}.`); - } + oldMarks, }); - (diff.marksDiff?.deleted ?? []).forEach((markEntry) => { - const markType = schema.marks[markEntry.name]; - if (!markType) { - skipWithWarning(`Unknown mark type ${markEntry.name} for deletion.`); - return; - } - const mark = markType.create(markEntry.attrs || {}); - const step = new RemoveMarkStep(from, to!, mark); - const stepResult = tr.maybeStep(step); - if (stepResult.failed) { - skipWithWarning(`Failed to remove mark ${mark.type.name} at ${from}-${to}.`); - } - }); - (diff.marksDiff?.modified ?? []).forEach((markEntry) => { - const markType = schema.marks[markEntry.name]; - if (!markType) { - skipWithWarning(`Unknown mark type ${markEntry.name} for modification.`); - return; - } - const oldMark = markType.create(markEntry.oldAttrs || {}); - const step = new RemoveMarkStep(from, to!, oldMark); + if (diff.oldText !== diff.newText) { + const slice = + diff.newText.length > 0 ? new Slice(Fragment.from(schema.text(diff.newText, marks)), 0, 0) : Slice.empty; + const step = new ReplaceStep(from, to, slice); const stepResult = tr.maybeStep(step); if (stepResult.failed) { - skipWithWarning(`Failed to remove old mark ${oldMark.type.name} at ${from}-${to}.`); + skipWithWarning(`Failed to replace text at ${from}-${to}.`); + return result; } - }); + to = from + diff.newText.length; + } else { + marks.forEach((mark) => { + const step = new AddMarkStep(from, to!, mark); + const stepResult = tr.maybeStep(step); + if (stepResult.failed) { + skipWithWarning(`Failed to add mark ${mark.type.name} at ${from}-${to}.`); + } + }); + + (diff.marksDiff?.deleted ?? []).forEach((markEntry) => { + const markType = schema.marks[markEntry.name]; + if (!markType) { + skipWithWarning(`Unknown mark type ${markEntry.name} for deletion.`); + return; + } + const mark = markType.create(markEntry.attrs || {}); + const step = new RemoveMarkStep(from, to!, mark); + const stepResult = tr.maybeStep(step); + if (stepResult.failed) { + skipWithWarning(`Failed to remove mark ${mark.type.name} at ${from}-${to}.`); + } + }); + (diff.marksDiff?.modified ?? []).forEach((markEntry) => { + const markType = schema.marks[markEntry.name]; + if (!markType) { + skipWithWarning(`Unknown mark type ${markEntry.name} for modification.`); + return; + } + const oldMark = markType.create(markEntry.oldAttrs || {}); + const step = new RemoveMarkStep(from, to!, oldMark); + const stepResult = tr.maybeStep(step); + if (stepResult.failed) { + skipWithWarning(`Failed to remove old mark ${oldMark.type.name} at ${from}-${to}.`); + } + }); + } if (diff.runAttrsDiff) { // Metadata attributes are independent of mark replay and can always be applied. diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.test.js index ef794a2bc7..42cc1e7211 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.test.js @@ -5,6 +5,8 @@ import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { computeDiff } from './computeDiff'; /** @@ -31,6 +33,35 @@ const getEditorFromFixture = async (name, user = undefined) => { }); }; +/** + * Collects tracked text grouped by mark id so multi-run changes can be asserted + * as whole replacement fragments. + * + * @param {import('prosemirror-state').EditorState} state + * @returns {Array<{ type: string; text: string; id: string }>} + */ +const collectTrackedTextChanges = (state) => { + const grouped = new Map(); + + getTrackChanges(state).forEach(({ mark, node }) => { + const key = `${mark.type.name}:${mark.attrs.id}`; + const existing = grouped.get(key); + + if (existing) { + existing.text += node.textContent; + return; + } + + grouped.set(key, { + type: mark.type.name, + id: mark.attrs.id, + text: node.textContent, + }); + }); + + return [...grouped.values()]; +}; + /** * Determines whether a remaining diff is an acceptable formatting-only delta. * @param {import('./algorithm/generic-diffing.ts').NodeDiff} diff @@ -513,6 +544,61 @@ describe('replayDiffs tracked append regression', () => { } }); }); +describe('replayDiffs SD-2787', () => { + it('replays word replacements as tracked delete/insert text changes', async () => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture('word/sd_2787_source.docx', testUser); + const afterEditor = await getEditorFromFixture('word/sd_2787_target.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + + const trackedTextChanges = collectTrackedTextChanges(beforeEditor.state); + const deletedTexts = trackedTextChanges.filter(({ type }) => type === 'trackDelete').map(({ text }) => text); + const insertedTexts = trackedTextChanges.filter(({ type }) => type === 'trackInsert').map(({ text }) => text); + + expect(deletedTexts).toEqual(expect.arrayContaining(['sentence', 'a test', 'some ', 'neighborhood'])); + expect(insertedTexts).toEqual(expect.arrayContaining(['phrase', 'an examination', 'barrio'])); + + expect(beforeEditor.commands.acceptAllTrackedChanges()).toBe(true); + expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); +describe('replayDiffs multi-run word replacements', () => { + it('tracks the full electronic to electric replacement across split runs', async () => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture('word/doc_a1.docx', testUser); + const afterEditor = await getEditorFromFixture('word/doc_b1.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + + const trackedTextChanges = collectTrackedTextChanges(beforeEditor.state); + const deletedTexts = trackedTextChanges.filter(({ type }) => type === 'trackDelete').map(({ text }) => text); + const insertedTexts = trackedTextChanges.filter(({ type }) => type === 'trackInsert').map(({ text }) => text); + + expect(deletedTexts).toEqual(expect.arrayContaining(['Electronic'])); + expect(insertedTexts).toEqual(expect.arrayContaining(['Electric'])); + expect(deletedTexts).not.toEqual(expect.arrayContaining(['Electron'])); + + expect(beforeEditor.commands.acceptAllTrackedChanges()).toBe(true); + expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); describe('replayDiffs table style', () => { it('replays table style changes when tracked replay is enabled', async () => { await expectReplayPreservesTableStyle('diff_before16.docx', 'diff_after16.docx', true); @@ -577,3 +663,120 @@ describe('parts-aware replay', () => { await expectDirectReplayPopulatesBodyMedia('diff_before19.docx', 'diff_after19.docx', true); }); }); + +const TARGET_TRACK_NAMES = new Set(['w:ins', 'w:del']); + +const collectTrackNodes = (node, tracker) => { + if (!node || typeof node !== 'object') return; + if (TARGET_TRACK_NAMES.has(node.name)) tracker[node.name].push(node); + if (Array.isArray(node.elements)) node.elements.forEach((child) => collectTrackNodes(child, tracker)); +}; + +const getExportedDocBody = async (exportedBuffer) => { + const zipper = new DocxZipper(); + const files = await zipper.getDocxData(exportedBuffer, true); + const entry = files.find((f) => f.name === 'word/document.xml'); + expect(entry).toBeDefined(); + const json = parseXmlToJson(entry.content); + const doc = json.elements?.find((el) => el.name === 'w:document'); + const body = doc?.elements?.find((el) => el.name === 'w:body'); + expect(body).toBeDefined(); + return body; +}; + +describe('replayDiffs DOCX export roundtrip — SD-3339 (IT-1132 rsid churn)', () => { + it('produces no tracked changes in DOCX when only run-level rsid attrs differ', async () => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture('word/it_1132_base.docx', testUser); + const afterEditor = await getEditorFromFixture('word/it_1132_edited.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + expect(getTrackChanges(beforeEditor.state)).toHaveLength(0); + + const exportedBuffer = await beforeEditor.exportDocx({ isFinalDoc: false }); + const body = await getExportedDocBody(exportedBuffer); + const tracker = { 'w:ins': [], 'w:del': [] }; + collectTrackNodes(body, tracker); + + expect(tracker['w:ins']).toHaveLength(0); + expect(tracker['w:del']).toHaveLength(0); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); + +describe('replayDiffs DOCX export roundtrip — IT-1029 real edits', () => { + it('produces tracked insert and delete marks in DOCX for real text edits', async () => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture('word/it_1029_source.docx', testUser); + const afterEditor = await getEditorFromFixture('word/it_1029_target.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + expect(getTrackChanges(beforeEditor.state).length).toBeGreaterThan(0); + + const exportedBuffer = await beforeEditor.exportDocx({ isFinalDoc: false }); + const body = await getExportedDocBody(exportedBuffer); + const tracker = { 'w:ins': [], 'w:del': [] }; + collectTrackNodes(body, tracker); + + // The IT-1029 edits are text insertions — expect tracked inserts in the DOCX. + expect(tracker['w:ins'].length).toBeGreaterThan(0); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); + +describe('replayDiffs — structuredContent (SDT) boundary leak regression', () => { + it('sdt_a/sdt_b: replay without tracked changes produces the correct final text content', async () => { + await expectReplayMatchesFixture('word/sdt_a.docx', 'word/sdt_b.docx'); + }); + + it('sdt_a/sdt_b: tracked replay marks the changed SDT paragraph and leaves unchanged text outside the SDT untracked', async () => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture('word/sdt_a.docx', testUser); + const afterEditor = await getEditorFromFixture('word/sdt_b.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + + const state = beforeEditor.state; + + // Find the paragraph containing "my field 2" SDT: "Linked Master [SDT]". + // Only the SDT content changed; "Linked Master" text must not carry a track mark. + let linkedMasterHasTrackMark = false; + state.doc.descendants((node) => { + if (!node.isText) return; + if (node.text !== 'Linked' && node.text !== 'Master') return; + const hasTrackMark = node.marks.some((m) => m.type.name === 'trackInsert' || m.type.name === 'trackDelete'); + if (hasTrackMark) linkedMasterHasTrackMark = true; + }); + + // Before the SDT boundary leak fix, the pre-fix double-tokenization path + // caused tracked text marks to appear outside the SDT (e.g. " edit" inserted + // at a position past the SDT closing boundary). Plain surrounding text that + // was not part of the edit must remain untracked. + expect(linkedMasterHasTrackMark).toBe(false); + + // The diff must have produced at least one tracked change (the SDT content changed). + expect(getTrackChanges(state).length).toBeGreaterThan(0); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.ts b/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.ts index 0d546c83cb..d21d837add 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replayDiffs.ts @@ -19,6 +19,7 @@ import { replayStyles } from './replay/replay-styles'; import { replayNumbering } from './replay/replay-numbering'; import { replayHeaderFooters } from './replay/replay-header-footers'; import { replayPartsDiff } from './replay/replay-parts'; +import { STALE_POSITION_WARNING_TAG } from './replay/replay-inline'; type ReplayDiffsParams = { tr: import('prosemirror-state').Transaction; @@ -92,6 +93,22 @@ export function replayDiffs({ trackedChangesRequested = false, }: ReplayDiffsParams): ReplayDiffsResult { const docReplay = replayDocDiffs({ tr, docDiffs: diff.docDiffs, schema }); + + // If the document has stale positions the transaction is already partially + // mutated. Stop here so comment/style/numbering/header-footer side-effects + // (which mutate converter-backed state directly) are never applied. The + // caller is responsible for discarding the transaction and prompting + // re-capture before re-applying. + const staleDocWarnings = docReplay.warnings.filter((w) => w.startsWith(STALE_POSITION_WARNING_TAG)); + if (staleDocWarnings.length > 0) { + return { + tr, + appliedDiffs: docReplay.applied, + skippedDiffs: docReplay.skipped, + warnings: docReplay.warnings, + }; + } + const commentsReplay = replayComments({ comments, commentDiffs: diff.commentDiffs, editor }); const stylesReplay = replayStyles({ stylesDiff: diff.stylesDiff, editor }); const numberingReplay = replayNumbering({ numberingDiff: diff.numberingDiff, editor }); diff --git a/packages/super-editor/src/editors/v1/extensions/font-family/font-family.js b/packages/super-editor/src/editors/v1/extensions/font-family/font-family.js index a7408c593e..1a93e658c9 100644 --- a/packages/super-editor/src/editors/v1/extensions/font-family/font-family.js +++ b/packages/super-editor/src/editors/v1/extensions/font-family/font-family.js @@ -92,7 +92,16 @@ export const FontFamily = Extension.create({ setFontFamily: (fontFamily) => ({ chain }) => { - return chain().setMark('textStyle', { fontFamily }).run(); + return ( + chain() + // Intentional: collapse script-specific slots to Latin; UI does not edit them separately. + .setMark('textStyle', { + fontFamily, + eastAsiaFontFamily: null, + csFontFamily: null, + }) + .run() + ); }, /** @@ -105,7 +114,17 @@ export const FontFamily = Extension.create({ unsetFontFamily: () => ({ chain }) => { - return chain().setMark('textStyle', { fontFamily: null }).removeEmptyTextStyle().run(); + return ( + chain() + // Intentional: collapse script-specific slots to Latin; UI does not edit them separately. + .setMark('textStyle', { + fontFamily: null, + eastAsiaFontFamily: null, + csFontFamily: null, + }) + .removeEmptyTextStyle() + .run() + ); }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/font-family/font-family.test.js b/packages/super-editor/src/editors/v1/extensions/font-family/font-family.test.js new file mode 100644 index 0000000000..f9c0503989 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/font-family/font-family.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FontFamily } from './font-family.js'; + +describe('FontFamily extension', () => { + const commands = FontFamily.config.addCommands(); + + it('clears per-script font overrides when setting a font family', () => { + const chainApi = { + setMark: vi.fn(() => chainApi), + run: vi.fn(() => true), + }; + const chain = vi.fn(() => chainApi); + + const result = commands.setFontFamily('Courier New, monospace')({ chain }); + + expect(result).toBe(true); + expect(chainApi.setMark).toHaveBeenCalledWith('textStyle', { + fontFamily: 'Courier New, monospace', + eastAsiaFontFamily: null, + csFontFamily: null, + }); + }); + + it('clears per-script font overrides when unsetting a font family', () => { + const chainApi = { + setMark: vi.fn(() => chainApi), + removeEmptyTextStyle: vi.fn(() => chainApi), + run: vi.fn(() => true), + }; + const chain = vi.fn(() => chainApi); + + const result = commands.unsetFontFamily()({ chain }); + + expect(result).toBe(true); + expect(chainApi.setMark).toHaveBeenCalledWith('textStyle', { + fontFamily: null, + eastAsiaFontFamily: null, + csFontFamily: null, + }); + expect(chainApi.removeEmptyTextStyle).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js b/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js index 7ef3d1ed4a..aee93ab835 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-container/shape-container.js @@ -73,6 +73,15 @@ export const ShapeContainer = Node.create({ rendered: false, }, + // Host-paragraph properties preserved when an inline drawing is the sole + // block content of a and gets hoisted to the top level. Without this + // the paragraph's alignment (e.g. centered) is lost and the layout adapter + // cannot center the drawing. See legacy-handle-paragraph-node.js. + wrapperParagraph: { + default: null, + rendered: false, + }, + anchorData: { rendered: false, }, diff --git a/packages/super-editor/src/editors/v1/extensions/table/table.js b/packages/super-editor/src/editors/v1/extensions/table/table.js index 5116767968..620a932fb9 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/table.js +++ b/packages/super-editor/src/editors/v1/extensions/table/table.js @@ -182,6 +182,7 @@ import { createColGroup } from './tableHelpers/createColGroup.js'; import { deleteTableWhenSelected } from './tableHelpers/deleteTableWhenSelected.js'; import { normalizeNewTableAttrs } from './tableHelpers/normalizeNewTableAttrs.js'; import { computeColumnWidths } from './tableHelpers/computeColumnWidths.js'; +import { cellWidthDxa } from './tableHelpers/cellWidth.js'; import { createTableBorders } from './tableHelpers/createTableBorders.js'; import { isLegacySchemaDefaultBorders, @@ -921,7 +922,12 @@ export const Table = Node.create({ for (let r = 0; r < rows; r++) { const cellNodes = []; for (let c = 0; c < columns; c++) { - const cellAttrs = widths ? { colwidth: [widths[c]] } : {}; + // Emit w:tcW alongside colwidth (matching createTable) so the inserted + // grid is a real layout cache; without it the measuring pass classifies + // the table as pure-auto and content-sizes it. (SD-3308/SD-3309) + const cellAttrs = widths + ? { colwidth: [widths[c]], tableCellProperties: { cellWidth: cellWidthDxa(widths[c]) } } + : {}; const cell = tableCellType.createAndFill(cellAttrs); if (!cell) return false; cellNodes.push(cell); diff --git a/packages/super-editor/src/editors/v1/extensions/table/table.test.js b/packages/super-editor/src/editors/v1/extensions/table/table.test.js index 4f01cf3f06..2059648cb8 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/table.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table/table.test.js @@ -1071,9 +1071,12 @@ describe('Table commands', async () => { const table = editor.state.doc.nodeAt(tablePos); // Each cell should have colwidth [208] (= Math.floor((8.5 - 1 - 1) * 96 / 3)) + // AND the matching w:tcW (208 * 15 twips/px = 3120 dxa) so the inserted grid is + // a real layout cache and the measuring pass does not content-size it. (SD-3308/SD-3309) table.forEach((row) => { row.forEach((cell) => { expect(cell.attrs.colwidth).toEqual([208]); + expect(cell.attrs.tableCellProperties).toEqual({ cellWidth: { value: 3120, type: 'dxa' } }); }); }); @@ -1142,6 +1145,29 @@ describe('Table commands', async () => { editor.converter = originalConverter; }); + + // SD-3308: Word writes w:tcW on every cell it inserts, which marks the grid + // as a real layout cache. Without it the measuring pass classifies the table + // as pure-auto and content-sizes it (shrinking a freshly inserted table to + // its empty-cell width instead of keeping the requested column widths). + it('insertTable cells carry a concrete cellWidth like Word tcW', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + const didInsert = editor.commands.insertTable({ rows: 2, cols: 3, columnWidths: [100, 200, 300] }); + expect(didInsert).toBe(true); + + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + table.forEach((row) => { + // twips = px * 15 at 96dpi + expect(row.child(0).attrs.tableCellProperties?.cellWidth).toEqual({ value: 1500, type: 'dxa' }); + expect(row.child(1).attrs.tableCellProperties?.cellWidth).toEqual({ value: 3000, type: 'dxa' }); + expect(row.child(2).attrs.tableCellProperties?.cellWidth).toEqual({ value: 4500, type: 'dxa' }); + }); + }); }); describe('insertTableAt trailing separator paragraph', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/cellWidth.js b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/cellWidth.js new file mode 100644 index 0000000000..c9a9a1bdd6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/cellWidth.js @@ -0,0 +1,19 @@ +// @ts-check + +/** Twips per CSS pixel at 96 DPI (1440 twips/inch / 96 px/inch). */ +export const TWIPS_PER_PX = 15; + +/** + * Build the OOXML `w:tcW` cell-width value (in twips) for a pixel column width. + * + * Word writes `w:tcW` on every cell it inserts. The concrete cell width marks the + * grid as a real layout cache, so the measuring pass preserves the requested column + * widths instead of content-sizing the table as pure-auto. Both `createTable` and the + * `insertTableAt` command emit this so inserted tables behave identically. (SD-3308/SD-3309) + * + * @param {number} widthPx - Column width in CSS pixels. + * @returns {{ value: number, type: 'dxa' }} The `tcW` dxa measurement. + */ +export function cellWidthDxa(widthPx) { + return { value: widthPx * TWIPS_PER_PX, type: 'dxa' }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js index 082ec7a0eb..dbb7506ec5 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js +++ b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js @@ -2,6 +2,7 @@ import { getNodeType } from '@core/helpers/getNodeType.js'; import { createCell } from './createCell.js'; import { generateDocxHexId } from '../../../utils/generateDocxHexId.js'; +import { cellWidthDxa } from './cellWidth.js'; /** * Create a new table with specified dimensions @@ -42,7 +43,18 @@ export const createTable = ( const cells = []; for (let index = 0; index < colsCount; index++) { - const cellAttrs = columnWidths ? { colwidth: [columnWidths[index]] } : null; + // Word writes w:tcW on every cell it inserts; the concrete cell width marks + // the grid as a real layout cache so the measuring pass preserves the + // requested column widths instead of content-sizing the table as pure-auto. + // (SD-3308) + const cellAttrs = columnWidths + ? { + colwidth: [columnWidths[index]], + tableCellProperties: { + cellWidth: cellWidthDxa(columnWidths[index]), + }, + } + : null; const cell = createCell(types.tableCell, cellContent, cellAttrs); if (cell) cells.push(cell); if (withHeaderRow) { diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index fcf98a5d4b..63e5343ba4 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -49,6 +49,7 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { seedEditorStateToYDoc } from './extensions/collaboration/seed-editor-to-ydoc.js'; import { onCollaborationProviderSynced } from './core/helpers/collaboration-provider-sync.js'; import { resolveSelectionTarget } from './document-api-adapters/helpers/selection-target-resolver.js'; +import { encodeCollaborationCursorFromSelectionTarget } from './document-api-adapters/helpers/collaboration-cursor-encoder.js'; import { resolveDefaultInsertTarget } from './document-api-adapters/helpers/adapter-utils.js'; import { resolveTrackedChangeInStory } from './document-api-adapters/helpers/tracked-change-resolver.js'; import { syncCommentEntitiesFromCollaboration } from './document-api-adapters/helpers/comment-entity-store.js'; @@ -156,6 +157,8 @@ export { /** @internal */ resolveSelectionTarget, /** @internal */ + encodeCollaborationCursorFromSelectionTarget, + /** @internal */ resolveDefaultInsertTarget, /** @internal */ resolveTrackedChangeInStory, diff --git a/packages/super-editor/src/editors/v1/tests/data/.gitignore b/packages/super-editor/src/editors/v1/tests/data/.gitignore index bfd09bf5cf..f05aafbcdf 100644 --- a/packages/super-editor/src/editors/v1/tests/data/.gitignore +++ b/packages/super-editor/src/editors/v1/tests/data/.gitignore @@ -4,3 +4,6 @@ !tab_stops_basic_test/** !table_in_list/ !table_in_list/** +!diffing/ +!diffing/word/ +!diffing/word/** diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a.docx new file mode 100644 index 0000000000..56aa27fdd7 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a1.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a1.docx new file mode 100644 index 0000000000..a36f2ff495 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a1.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a2.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a2.docx new file mode 100644 index 0000000000..217a090f23 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a2.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b.docx new file mode 100644 index 0000000000..d1eecca1c6 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b1.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b1.docx new file mode 100644 index 0000000000..b7c5e8665d Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b1.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b2.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b2.docx new file mode 100644 index 0000000000..2b7eac3881 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b2.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_a.docx new file mode 100644 index 0000000000..6e4aed89ac Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_b.docx new file mode 100644 index 0000000000..41e8ad52e0 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_a.docx new file mode 100644 index 0000000000..b87eb3acc2 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_b.docx new file mode 100644 index 0000000000..3e88829a83 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_source.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_source.docx new file mode 100644 index 0000000000..3bf357b8e6 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_source.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_target.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_target.docx new file mode 100644 index 0000000000..78ebfe54af Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_target.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_base.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_base.docx new file mode 100644 index 0000000000..7d365ecbfd Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_base.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_edited.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_edited.docx new file mode 100644 index 0000000000..f58a38a018 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_edited.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_a.docx new file mode 100644 index 0000000000..f7600452f0 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_b.docx new file mode 100644 index 0000000000..4384b4c5d7 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_a.docx new file mode 100644 index 0000000000..1f7859cdb3 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_b.docx new file mode 100644 index 0000000000..ce776ea708 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_a.docx new file mode 100644 index 0000000000..b15b5b2cc9 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_b.docx new file mode 100644 index 0000000000..94d151afca Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_a.docx new file mode 100644 index 0000000000..45c880bb32 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_b.docx new file mode 100644 index 0000000000..3883fcfb33 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_a1.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_a1.docx new file mode 100644 index 0000000000..fca6c2bcc0 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_a1.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_b1.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_b1.docx new file mode 100644 index 0000000000..694ae96428 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_b1.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_a.docx new file mode 100644 index 0000000000..930081deb6 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_b.docx new file mode 100644 index 0000000000..4d6050ccae Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_source.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_source.docx new file mode 100644 index 0000000000..731cd36f76 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_source.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_target.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_target.docx new file mode 100644 index 0000000000..34c0759c25 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_target.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_a.docx new file mode 100644 index 0000000000..c8b4b7c110 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_b.docx new file mode 100644 index 0000000000..a50687b261 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_a.docx new file mode 100644 index 0000000000..5cb10c688a Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_b.docx new file mode 100644 index 0000000000..dec65ebc83 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_a.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_a.docx new file mode 100644 index 0000000000..6bb746ceec Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_a.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_b.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_b.docx new file mode 100644 index 0000000000..68c7fbdc31 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_b.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/import-export/collab-mergefield-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/collab-mergefield-roundtrip.test.js new file mode 100644 index 0000000000..074a257c6d --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/collab-mergefield-roundtrip.test.js @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Doc as YDoc } from 'yjs'; +import { Awareness } from 'y-protocols/awareness.js'; +import JSZip from 'jszip'; + +import { Editor } from '@core/Editor.js'; +import { seedEditorStateToYDoc } from '@extensions/collaboration/seed-editor-to-ydoc.js'; +import { getTestDataAsFileBuffer, initTestEditor } from '@tests/helpers/helpers.js'; + +const FIELD_INSTRUCTION = ' MERGEFIELD CustomerName \\* MERGEFORMAT '; +const FIELD_RESULT = 'Acme Corp'; + +const createProviderStub = (ydoc) => ({ + synced: true, + isSynced: true, + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + awareness: new Awareness(ydoc), +}); + +const readDocxPart = async (buffer, path) => { + const zip = await JSZip.loadAsync(buffer); + return zip.files[path]?.async('string'); +}; + +const buildOneMergeFieldDocx = async () => { + const baseBuffer = await getTestDataAsFileBuffer('blank-doc.docx'); + const zip = await JSZip.loadAsync(baseBuffer); + const documentXml = await zip.file('word/document.xml')?.async('string'); + + if (!documentXml) { + throw new Error('blank-doc.docx is missing word/document.xml'); + } + + const fieldParagraph = + '' + + '' + + `${FIELD_INSTRUCTION}` + + '' + + `${FIELD_RESULT}` + + '' + + ''; + + const patchedDocumentXml = documentXml.replace( + /[\s\S]*?()<\/w:body>/, + `${fieldParagraph}$1`, + ); + + if (patchedDocumentXml === documentXml) { + throw new Error('Could not inject MERGEFIELD paragraph into blank-doc.docx'); + } + + zip.file('word/document.xml', patchedDocumentXml); + return zip.generateAsync({ type: 'nodebuffer' }); +}; + +const countFieldChars = (documentXml, type) => { + const matches = documentXml.match(new RegExp(`w:fldCharType="${type}"`, 'g')); + return matches?.length ?? 0; +}; + +const expectValidMergeFieldEnvelope = (documentXml) => { + expect(documentXml).toContain('w:instrText'); + expect(documentXml).toContain('MERGEFIELD CustomerName'); + expect(countFieldChars(documentXml, 'begin')).toBe(1); + expect(countFieldChars(documentXml, 'separate')).toBe(1); + expect(countFieldChars(documentXml, 'end')).toBe(1); + expect(documentXml).toMatch( + /w:fldCharType="begin"[\s\S]*]*>[\s\S]*MERGEFIELD CustomerName[\s\S]*<\/w:instrText>[\s\S]*w:fldCharType="separate"[\s\S]*w:fldCharType="end"/, + ); +}; + +const insertTextBeforeField = (editor) => { + editor.dispatch(editor.state.tr.insertText('Edited: ', 1)); + expect(editor.state.doc.textContent).toContain('Edited: '); +}; + +describe('collaboration MERGEFIELD DOCX export', () => { + it('preserves a passthrough MERGEFIELD instruction after Yjs seed and join', async () => { + const source = await buildOneMergeFieldDocx(); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(source, true); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor: seedEditor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + }); + + seedEditorStateToYDoc(seedEditor, ydoc); + + const { editor: joiningEditor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + fragment: ydoc.getXmlFragment('supereditor'), + isHeadless: true, + isNewFile: false, + }); + + try { + insertTextBeforeField(joiningEditor); + + const exported = await joiningEditor.exportDocx({ isFinalDoc: true }); + const documentXml = await readDocxPart(exported, 'word/document.xml'); + + expect(documentXml).toBeTruthy(); + expect(documentXml).toContain('Edited: '); + expectValidMergeFieldEnvelope(documentXml); + } finally { + seedEditor.destroy(); + joiningEditor.options.ydoc = null; + joiningEditor.options.collaborationProvider = null; + joiningEditor.destroy(); + ydoc.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index 0cf1d35d21..d5f730d539 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -1,16 +1,21 @@ import { describe, it, expect, afterEach } from 'vitest'; +import JSZip from 'jszip'; import { toFlowBlocks } from '@core/layout-adapter'; import { createDomPainter } from '@superdoc/painter-dom'; import { resolveLayout } from '@superdoc/layout-resolved'; import { Editor } from '@core/Editor.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; -import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { getTestDataAsFileBuffer, initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; const SIGNATURE_SRC = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; const ENCODED_SIGNATURE_SVG = ''; const ENCODED_SIGNATURE_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(ENCODED_SIGNATURE_SVG)}`; const PNG_SRC = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/Ur/9wAAAABJRU5ErkJggg=='; +const OUTER_BOOKMARK_SDT_ID = '71001'; +const INNER_BOOKMARK_SDT_ID = '71002'; +const BOOKMARK_ID = '42'; +const BOOKMARK_NAME = 'beforeInnerSdt'; const findFirstNodeByType = (node, typeName) => { let found = null; @@ -56,6 +61,68 @@ const getChildElement = (node, name) => node?.elements?.find((child) => child.na const hasDescendantNamed = (node, name) => collectElementsByName(node, name).length > 0; +const findSdtById = (documentXml, id) => + collectElementsByName(documentXml, 'w:sdt').find((candidate) => { + const sdtPr = getChildElement(candidate, 'w:sdtPr'); + return sdtPr?.elements?.some((el) => el.name === 'w:id' && el.attributes?.['w:val'] === id); + }); + +const collectElementOrder = (node, result = []) => { + if (!node || typeof node !== 'object') return result; + if (node.name) result.push(node.name); + (node.elements || []).forEach((child) => collectElementOrder(child, result)); + return result; +}; + +const readDocumentXmlFromDocx = async (buffer) => { + const zip = await JSZip.loadAsync(buffer); + const documentEntry = zip.file('word/document.xml'); + if (!documentEntry) throw new Error('word/document.xml not found in DOCX.'); + return documentEntry.async('string'); +}; + +const buildDocxWithNestedSdtAfterBookmark = async () => { + const baseBuffer = await getTestDataAsFileBuffer('blank-doc.docx'); + const zip = await JSZip.loadAsync(baseBuffer); + const documentEntry = zip.file('word/document.xml'); + if (!documentEntry) throw new Error('word/document.xml not found in blank-doc.docx.'); + + const nestedSdtXml = ` + + + + + + + + + + + + + + + + + + + Nested paragraph + + + + + + `; + const documentXml = await documentEntry.async('string'); + const patchedDocumentXml = documentXml.replace(//, `${nestedSdtXml}`); + if (patchedDocumentXml === documentXml) { + throw new Error('Could not inject nested SDT fixture into blank document.'); + } + + zip.file('word/document.xml', patchedDocumentXml); + return zip.generateAsync({ type: 'nodebuffer' }); +}; + const DEFAULT_CONVERTER_CONTEXT = { docx: {}, translatedLinkedStyles: { @@ -148,6 +215,68 @@ describe('SD-3116 structured content image round-trip', () => { paintMount = null; }); + it('round-trips a nested block SDT when a bookmark starts before it in the parent SDT', async () => { + const inputBuffer = await buildDocxWithNestedSdtAfterBookmark(); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(inputBuffer, true); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true, isNewFile: false })); + + const importedOuter = findNodeByTypeAndId(editor.state.doc, 'structuredContentBlock', OUTER_BOOKMARK_SDT_ID); + const importedInner = findNodeByTypeAndId(editor.state.doc, 'structuredContentBlock', INNER_BOOKMARK_SDT_ID); + const importedBookmark = findNodeByTypeAndId(editor.state.doc, 'bookmarkStart', BOOKMARK_ID); + expect(importedOuter?.attrs).toMatchObject({ + alias: 'Outer Bookmark Roundtrip', + controlType: 'richText', + }); + expect(importedInner?.attrs).toMatchObject({ + alias: 'Inner Bookmark Roundtrip', + controlType: 'richText', + }); + expect(importedBookmark?.attrs).toMatchObject({ + id: BOOKMARK_ID, + name: BOOKMARK_NAME, + }); + + const exported = await editor.exportDocx({ isFinalDoc: false }); + const exportedDocumentXml = await readDocumentXmlFromDocx(exported); + const exportedDocumentJson = parseXmlToJson(exportedDocumentXml); + const exportedOuter = findSdtById(exportedDocumentJson, OUTER_BOOKMARK_SDT_ID); + const outerContent = getChildElement(exportedOuter, 'w:sdtContent'); + const exportedInner = findSdtById(outerContent, INNER_BOOKMARK_SDT_ID); + const exportedBookmark = collectElementsByName(exportedOuter, 'w:bookmarkStart').find( + (node) => node.attributes?.['w:id'] === BOOKMARK_ID && node.attributes?.['w:name'] === BOOKMARK_NAME, + ); + const outerElementOrder = collectElementOrder(outerContent); + + expect(exportedOuter).toBeDefined(); + expect(exportedInner).toBeDefined(); + expect(exportedBookmark).toBeDefined(); + expect(outerElementOrder.indexOf('w:bookmarkStart')).toBeGreaterThanOrEqual(0); + expect(outerElementOrder.indexOf('w:sdt')).toBeGreaterThan(outerElementOrder.indexOf('w:bookmarkStart')); + + const [roundTripDocx, roundTripMedia, roundTripMediaFiles, roundTripFonts] = await Editor.loadXmlData( + exported, + true, + ); + ({ editor: reopened } = initTestEditor({ + content: roundTripDocx, + media: roundTripMedia, + mediaFiles: roundTripMediaFiles, + fonts: roundTripFonts, + isNewFile: false, + })); + + expect( + findNodeByTypeAndId(reopened.state.doc, 'structuredContentBlock', INNER_BOOKMARK_SDT_ID)?.attrs, + ).toMatchObject({ + alias: 'Inner Bookmark Roundtrip', + controlType: 'richText', + }); + expect(findNodeByTypeAndId(reopened.state.doc, 'bookmarkStart', BOOKMARK_ID)?.attrs).toMatchObject({ + id: BOOKMARK_ID, + name: BOOKMARK_NAME, + }); + }); + it('exports and reopens a block SDT containing preset image content', async () => { const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); diff --git a/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js b/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js index 08c3e40ea3..01cf9b86f2 100644 --- a/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js +++ b/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js @@ -877,6 +877,21 @@ describe('updateToolbarState', () => { expect(item.activate).toHaveBeenCalledWith({}, true); }); + it('activates fontFamily with isMultiple flag when snapshot reports active without a value (mixed selection)', () => { + const item = buildItem('fontFamily', { defaultLabel: { value: 'Arial' } }); + toolbar.toolbarItems = [item]; + toolbar.snapshot = { + commands: { + 'document-mode': { value: 'editing' }, + 'font-family': { active: true, value: null, disabled: false }, + }, + }; + + toolbar.updateToolbarState(); + expect(item.activate).toHaveBeenCalledWith({}, true); + expect(item.deactivate).not.toHaveBeenCalled(); + }); + it('disables tableActions when every table command is disabled', () => { const item = buildItem('tableActions', { disabled: { value: false } }); toolbar.toolbarItems = [item]; diff --git a/packages/super-editor/src/editors/v2/.gitkeep b/packages/super-editor/src/editors/v2/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts index d4104951b1..b5ecdde5b5 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts @@ -1,6 +1,7 @@ import { parseSizeUnit } from '../../editors/v1/core/utilities/parseSizeUnit.js'; import { isNegatedMark } from '../../editors/v1/components/toolbar/format-negation.js'; import { getActiveFormatting } from '../../editors/v1/core/helpers/getActiveFormatting.js'; +import { getSelectionFormattingState } from '../../editors/v1/core/helpers/getMarksFromSelection.js'; import { getFileOpener, processAndInsertImageFile } from '../../editors/v1/extensions/image/imageHelpers/index.js'; import { TextSelection, Selection } from 'prosemirror-state'; import { getCurrentResolvedParagraphProperties, isFieldAnnotationSelection, resolveStateEditor } from './context.js'; @@ -86,6 +87,15 @@ export const hasNegatedFormattingMark = (formatting: FormattingEntry[], markName return isNegatedMark(rawActiveMark.name, rawActiveMark.attrs); }; +const getPrimaryFontFamily = (value: unknown) => { + if (typeof value === 'string') return value.split(',')[0].trim(); + if (!value || typeof value !== 'object') return null; + + const fontFamily = value as Record; + const primary = fontFamily.ascii ?? fontFamily.hAnsi ?? fontFamily.eastAsia ?? fontFamily.cs; + return typeof primary === 'string' ? primary.split(',')[0].trim() : null; +}; + type FormatCommandsStorage = { storedStyle?: unknown; }; @@ -253,16 +263,25 @@ export const createFontFamilyStateDeriver = } const values = getFormattingAttr(formatting, 'fontFamily', 'fontFamily'); + const selectionFormattingState = + stateEditor?.state?.selection?.empty === false + ? getSelectionFormattingState(stateEditor.state, stateEditor) + : null; const normalizedValues = values.map((value) => normalizeFontFamilyValue(value)); const uniqueValues = [...new Set(normalizedValues)]; const hasDirectValue = uniqueValues.length > 0; + const hasMixedFontFamily = Boolean(selectionFormattingState?.mixedRunProperties?.fontFamily); + const directSelectionValue = normalizeFontFamilyValue( + getPrimaryFontFamily(selectionFormattingState?.directMarkRunProperties?.fontFamily), + ); // Note (parity gap): legacy also has an empty-paragraph special-case: // const paragraphFontFamily = getParagraphFontFamilyFromProperties // item.activate({ fontFamily: paragraphFontFamily }); - const canUseLinkedStyle = !hasDirectValue && isFormattingActivatedFromLinkedStyle(context, 'font-family'); + const canUseLinkedStyle = + !hasDirectValue && !hasMixedFontFamily && isFormattingActivatedFromLinkedStyle(context, 'font-family'); const paragraphProps = canUseLinkedStyle ? getCurrentResolvedParagraphProperties(context) : null; const documentEditor = context?.presentationEditor?.editor ?? context?.editor ?? null; @@ -270,10 +289,12 @@ export const createFontFamilyStateDeriver = ? documentEditor?.converter?.linkedStyles?.find((style: any) => style.id === paragraphProps?.styleId) : null; const linkedStyleValue = normalizeFontFamilyValue(linkedStyle?.definition?.styles?.['font-family']) ?? null; - const value = uniqueValues.length === 1 ? uniqueValues[0] : linkedStyleValue; + const value = hasMixedFontFamily + ? null + : directSelectionValue || (uniqueValues.length === 1 ? uniqueValues[0] : linkedStyleValue); return { - active: uniqueValues.length > 0 || linkedStyleValue != null, + active: hasMixedFontFamily || uniqueValues.length > 0 || directSelectionValue != null || linkedStyleValue != null, disabled: false, value, }; diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts index e24a568b60..634c4921f8 100644 --- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts +++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts @@ -182,12 +182,11 @@ describe('resolveToolbarSources', () => { expect(result.presentationEditor).toBe(narrowPresentationEditor); }); - it('accepts an explicit v2 active-editor facade without creating a v1 toolbar context', () => { + it('ignores an active editor that exposes no v1 command surface', () => { const result = resolveToolbarSources({ activeEditor: { - editorVersion: 2, options: { - documentId: 'doc-v2', + documentId: 'doc-without-commands', documentMode: 'editing', }, commands: null, diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts index a0c51511eb..bfbd477c92 100644 --- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts +++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts @@ -48,7 +48,7 @@ type EditorWithPresentationOwner = Editor & { }; const isV1ToolbarEditor = (editor: HeadlessToolbarActiveEditor | null | undefined): editor is Editor => { - return Boolean(editor && editor.editorVersion !== 2); + return Boolean(editor && (editor.editorVersion == null || editor.editorVersion === 1) && editor.commands !== null); }; // SD-3213f: accept both the narrow SuperDoc method diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index dedc6fe3b2..11f38ffaf6 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -158,6 +158,86 @@ const makeBlockSdtState = (lockMode: string) => { return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, findTextPos(doc, 'Block field') + 1))); }; +const textStyleFontSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, + }, + marks: { + textStyle: { + attrs: { fontFamily: { default: null } }, + toDOM: () => ['span', 0], + }, + }, +}); + +const runFontSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'inline*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM: () => ['p', 0], + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM: () => ['span', 0], + }, + text: { group: 'inline' }, + }, + marks: { + link: { + attrs: { href: { default: null } }, + toDOM: () => ['a', 0], + }, + textStyle: { + attrs: { fontFamily: { default: null }, csFontFamily: { default: null } }, + toDOM: () => ['span', 0], + }, + }, +}); + +const fontFamilyObject = (name: string) => ({ ascii: name, eastAsia: name, hAnsi: name, cs: name }); + +const selectTextRange = (schema: Schema, doc: any, from: number, to: number) => { + const baseState = EditorState.create({ schema, doc }); + return baseState.apply(baseState.tr.setSelection(TextSelection.create(doc, from, to))); +}; + +const getFontFamilyToolbarState = (state: EditorState, translatedLinkedStyles: Record = {}) => { + const registry = createToolbarRegistry(); + return registry['font-family']?.state({ + context: { + ...createContext(), + editor: { + state, + converter: { + convertedXml: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {}, paragraphProperties: {} }, + latentStyles: {}, + styles: {}, + ...translatedLinkedStyles, + }, + }, + storage: {}, + } as any, + }, + superdoc: {}, + }); +}; + describe('createToolbarRegistry', () => { afterEach(() => { vi.clearAllMocks(); @@ -340,6 +420,189 @@ describe('createToolbarRegistry', () => { }); }); + it('reports a mixed font-family selection as active with no value', () => { + getActiveFormattingMock.mockReturnValueOnce([]); + + const registry = createToolbarRegistry(); + const state = registry['font-family']?.state({ + context: { + ...createContext(), + editor: { + state: { + selection: { + empty: false, + from: 2, + to: 8, + }, + doc: { + nodesBetween: vi.fn((from, to, callback) => { + callback( + { + isText: true, + text: 'AB', + }, + 1, + ); + callback( + { + isText: true, + text: 'CD', + }, + 5, + ); + }), + resolve: vi.fn((pos) => ({ + depth: 2, + marks: vi.fn(() => []), + node: vi.fn((depth) => { + if (depth === 2) { + return { + type: { name: 'run' }, + attrs: { + runProperties: + pos === 2 ? { fontFamily: { ascii: 'Aptos' } } : { fontFamily: { ascii: 'Times New Roman' } }, + }, + }; + } + if (depth === 1) { + return { + type: { name: 'paragraph' }, + attrs: {}, + content: { size: 4 }, + }; + } + return { type: { name: 'doc' }, attrs: {} }; + }), + })), + }, + schema: { + marks: {}, + }, + }, + converter: { + convertedXml: {}, + }, + storage: {}, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: null, + }); + }); + + it('reports mixed textStyle font-family marks as active with no value', () => { + getActiveFormattingMock.mockReturnValueOnce([{ name: 'fontFamily', attrs: { fontFamily: 'Arial' } }]); + + const times = textStyleFontSchema.marks.textStyle.create({ fontFamily: 'Times New Roman' }); + const arial = textStyleFontSchema.marks.textStyle.create({ fontFamily: 'Arial' }); + const doc = textStyleFontSchema.node('doc', null, [ + textStyleFontSchema.node('paragraph', null, [ + textStyleFontSchema.text('Times', [times]), + textStyleFontSchema.text(' Arial', [arial]), + ]), + ]); + const editorState = selectTextRange(textStyleFontSchema, doc, 1, doc.content.size - 1); + const state = getFontFamilyToolbarState(editorState); + + expect(state).toEqual({ + active: true, + disabled: false, + value: null, + }); + }); + + it('keeps a direct font-family value when unmarked text resolves to the same default font', () => { + getActiveFormattingMock.mockReturnValueOnce([{ name: 'fontFamily', attrs: { fontFamily: 'Arial' } }]); + + const fontFamily = fontFamilyObject('Arial'); + const arial = textStyleFontSchema.marks.textStyle.create({ fontFamily: 'Arial' }); + const doc = textStyleFontSchema.node('doc', null, [ + textStyleFontSchema.node('paragraph', null, [ + textStyleFontSchema.text('Direct', [arial]), + textStyleFontSchema.text(' Plain'), + ]), + ]); + const editorState = selectTextRange(textStyleFontSchema, doc, 1, doc.content.size - 1); + const state = getFontFamilyToolbarState(editorState, { + docDefaults: { runProperties: { fontFamily }, paragraphProperties: {} }, + styles: { + Normal: { default: true, runProperties: { fontFamily } }, + }, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'Arial', + }); + }); + + it('keeps a direct font-family value when uniform direct marks override link run metadata', () => { + getActiveFormattingMock.mockReturnValueOnce([{ name: 'fontFamily', attrs: { fontFamily: 'Arial' } }]); + + const desiredFont = runFontSchema.marks.textStyle.create({ fontFamily: 'Courier New, monospace' }); + const link = runFontSchema.marks.link.create({ href: 'https://example.com/one' }); + const doc = runFontSchema.node('doc', null, [ + runFontSchema.node('paragraph', null, [ + runFontSchema.node('run', { runProperties: { fontFamily: { ascii: 'Arial' } } }, [ + runFontSchema.text('Before 6.5%', [desiredFont]), + ]), + runFontSchema.node( + 'run', + { runProperties: { styleId: 'Hyperlink', fontFamily: { ascii: 'Arial', cs: 'Arial' } } }, + [runFontSchema.text('[1]', [link, desiredFont])], + ), + runFontSchema.node('run', { runProperties: { fontFamily: { ascii: 'Arial' } } }, [ + runFontSchema.text(' after', [desiredFont]), + ]), + ]), + ]); + const editorState = selectTextRange(runFontSchema, doc, 2, doc.content.size - 1); + const state = getFontFamilyToolbarState(editorState); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'Courier New', + }); + }); + + it('reports no font-family value when hyperlink and surrounding text differ by cs font metadata', () => { + getActiveFormattingMock.mockReturnValueOnce([{ name: 'fontFamily', attrs: { fontFamily: 'Times New Roman' } }]); + + const times = runFontSchema.marks.textStyle.create({ fontFamily: 'Times New Roman, serif' }); + const timesWithBodyCs = runFontSchema.marks.textStyle.create({ + fontFamily: 'Times New Roman, serif', + csFontFamily: 'Times New Roman (Body CS), Times New Roman, serif', + }); + const link = runFontSchema.marks.link.create({ href: 'https://example.com/one' }); + const doc = runFontSchema.node('doc', null, [ + runFontSchema.node('paragraph', null, [ + runFontSchema.node('run', { runProperties: { fontFamily: { ascii: 'Times New Roman' } } }, [ + runFontSchema.text('Before 6.5%', [timesWithBodyCs]), + ]), + runFontSchema.node( + 'run', + { runProperties: { styleId: 'Hyperlink', fontFamily: { ascii: 'Times New Roman', cs: 'Times New Roman' } } }, + [runFontSchema.text('[1]', [link, times])], + ), + ]), + ]); + const editorState = selectTextRange(runFontSchema, doc, 2, doc.content.size - 1); + const state = getFontFamilyToolbarState(editorState); + + expect(state).toEqual({ + active: true, + disabled: false, + value: null, + }); + }); + it('derives font-family value from linked style when no direct fontFamily mark is active', () => { getActiveFormattingMock.mockReturnValueOnce([]); diff --git a/packages/super-editor/src/headless-toolbar/types.ts b/packages/super-editor/src/headless-toolbar/types.ts index 48a654400e..9c6ebbbe6c 100644 --- a/packages/super-editor/src/headless-toolbar/types.ts +++ b/packages/super-editor/src/headless-toolbar/types.ts @@ -241,14 +241,12 @@ export type ToolbarContext = { /** * Active-editor shape accepted at the SuperDoc host boundary. * - * A real SuperDoc can expose either the v1 Editor projection or a narrow v2 - * facade through `activeEditor`. The toolbar resolver only builds a command - * context for v1-command-capable editors; v2 facades are accepted here so - * `createHeadlessToolbar({ superdoc })` remains assignable during the v2 - * transition without pretending the facade is a full v1 Editor. + * A real SuperDoc exposes the v1 Editor projection through `activeEditor`. + * The toolbar resolver only builds a command context for command-capable + * editors. */ export type HeadlessToolbarActiveEditor = { - editorVersion?: 1 | 2; + editorVersion?: 1; options?: { isHeaderOrFooter?: boolean; headerFooterType?: string; diff --git a/packages/super-editor/src/ui/track-changes.test.ts b/packages/super-editor/src/ui/track-changes.test.ts index 699aeecc9a..2eb76bc200 100644 --- a/packages/super-editor/src/ui/track-changes.test.ts +++ b/packages/super-editor/src/ui/track-changes.test.ts @@ -217,19 +217,18 @@ describe('ui.trackChanges — snapshot', () => { ui.destroy(); }); - it('reads v2 active-editor Document API when toolbar routing has no routed editor', () => { + it('reads the active-editor Document API when toolbar routing has no routed editor', () => { const { superdoc, editor } = makeStubs({ trackedChanges: [ - { id: 'tc-v2-1', type: 'insert' }, - { id: 'tc-v2-2', type: 'delete' }, + { id: 'tc-active-1', type: 'insert' }, + { id: 'tc-active-2', type: 'delete' }, ], }); - (editor as unknown as { editorVersion: number }).editorVersion = 2; editor.presentationEditor = null as never; const ui = createSuperDocUI({ superdoc }); - expect(ui.trackChanges.getSnapshot().items.map((item) => item.id)).toEqual(['tc-v2-1', 'tc-v2-2']); + expect(ui.trackChanges.getSnapshot().items.map((item) => item.id)).toEqual(['tc-active-1', 'tc-active-2']); ui.destroy(); }); diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index ed4124467d..0d76a75dfd 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -115,9 +115,10 @@ "main": "./dist/superdoc.cjs", "module": "./dist/superdoc.es.js", "scripts": { - "dev": "vite", - "dev:collab": "concurrently -k -n VITE,COLLAB -c cyan,green \"vite\" \"pnpm run collab-server\"", + "dev": "concurrently -k -n VITE,WORD -c cyan,magenta \"vite\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"", + "dev:collab": "concurrently -k -n VITE,COLLAB,WORD -c cyan,green,magenta \"vite\" \"pnpm run collab-server\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"", "collab-server": "pnpm --filter @superdoc-dev/superdoc-yjs-collaboration build && tsx src/dev/collab-server.ts", + "word-benchmark-sidecar": "node ../../devtools/word-benchmark-sidecar/server.js", "build": "vite build && pnpm run build:cdn && pnpm run postbuild", "build:dev": "SUPERDOC_SKIP_DTS=1 vite build", "postbuild": "node ./scripts/check-tsconfig-type-surface.cjs && node ./scripts/ensure-types.cjs && node ./scripts/audit-bundle.cjs && node ./scripts/audit-declarations.cjs && node ./scripts/check-export-coverage.cjs && node ./scripts/verify-public-facade-emit.cjs && node ./scripts/report-declaration-reachability.cjs", diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 5a9824c9a0..d8141141fd 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { flushPromises, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { h, defineComponent, ref, shallowRef, reactive, nextTick } from 'vue'; import { DOCX, PDF } from '@superdoc/common'; import { Schema } from 'prosemirror-model'; @@ -194,157 +194,6 @@ vi.mock('@superdoc/components/CommentsLayer/CommentsLayer.vue', () => ({ default: CommentsLayerStub, })); -// ui-phase2-001: stub the v2 DOCX editor component. The injected integration -// renders a deterministic `.v2-super-editor` host element so the branch tests -// can pin which path the DOCX document took without running V2. -const V2SuperEditorStub = defineComponent({ - name: 'V2SuperEditor', - props: ['fileSource', 'documentId', 'options'], - emits: ['v2-editor-ready', 'v2-editor-failed', 'v2-render', 'v2-render-cleared', 'v2-selection-changed'], - setup() { - return () => h('div', { class: 'v2-super-editor' }, 'v2-editor'); - }, -}); - -// Vue test-utils probes the resolved async-component module proxy for -// optional Vue properties (`__isTeleport`, `__isKeepAlive`, `name`, -// `displayName`, `__name`, …) and vitest's mock proxy throws on any -// unknown export. Make the mock a plain object instead of a strict -// proxy by toggling `spy: false` and exporting the common Vue probe -// surface so the resolution path is the same as a real `.vue` module. -const V2RulerStub = defineComponent({ - name: 'V2Ruler', - setup() { - return () => h('div', { class: 'v2-ruler' }); - }, -}); - -const buildRelativeBounds = (element, layersContainer) => { - const elementRect = element.getBoundingClientRect(); - const layersRect = layersContainer.getBoundingClientRect(); - return { - top: elementRect.top - layersRect.top, - left: elementRect.left - layersRect.left, - right: elementRect.right - layersRect.left, - bottom: elementRect.bottom - layersRect.top, - width: elementRect.width, - height: elementRect.height, - }; -}; - -const collectTestV2GeometryEntries = async ({ host, layersContainer, mountContainer }) => { - const entries = {}; - const commentsResult = await host - ?.getHandles?.() - ?.comments?.list?.() - .catch(() => null); - const aliases = new Map(); - for (const item of commentsResult?.items ?? []) { - const id = item?.id ?? item?.commentId; - if (id != null && item?.importedId != null) { - aliases.set(String(id), String(item.importedId)); - } - } - - for (const element of mountContainer.querySelectorAll('[data-comment-ids]')) { - const ids = String(element.dataset.commentIds ?? '') - .split(/[,\s]+/) - .filter(Boolean); - for (const id of ids) { - const entry = { - threadId: id, - key: id, - kind: id === 'pending' ? 'pending' : 'comment', - storyKey: element.dataset.storyKey || 'body', - bounds: buildRelativeBounds(element, layersContainer), - }; - entries[id] = entry; - const importedId = aliases.get(id); - if (importedId) entries[importedId] = entry; - } - } - - return entries; -}; - -const createTestV2GeometryPublisher = ({ - getLayersContainer, - isCommentsEnabled, - publishPositions, - clearPositions, - setGeometryAvailable = () => undefined, -}) => { - let lastPayload = null; - let lastEpoch = null; - - const publish = vi.fn(async (payload) => { - lastPayload = payload ?? null; - if (!payload?.mountContainer) { - clearPositions(); - setGeometryAvailable(false); - return; - } - const layersContainer = getLayersContainer(); - if (!layersContainer || !isCommentsEnabled()) { - clearPositions(); - setGeometryAvailable(false); - return; - } - const entries = await collectTestV2GeometryEntries({ - host: payload.host, - layersContainer, - mountContainer: payload.mountContainer, - }); - publishPositions(entries); - lastEpoch = payload.epoch ?? null; - setGeometryAvailable(true); - }); - - return { - publish, - recollect: vi.fn(async () => { - if (lastPayload) await publish(lastPayload); - }), - reset: vi.fn(() => { - lastPayload = null; - lastEpoch = null; - setGeometryAvailable(false); - }), - getLastEpoch: vi.fn(() => lastEpoch), - getLastPayload: vi.fn(() => lastPayload), - }; -}; - -const createTestV2ReviewHydrationController = () => ({ - setContext: vi.fn(), - onRenderReadiness: vi.fn(), - reset: vi.fn(), - getDiagnostics: vi.fn(() => null), -}); - -const isTestSyntheticTrackedChangeCommentLaneItem = (item) => { - const id = item?.id ?? item?.commentId; - return typeof id === 'string' && id.startsWith('tc-comment:'); -}; - -const isTestV2SyntheticTrackedChangeRow = (row) => - row?.trackedChange === true && - typeof row?.trackedChangeAnchorKey === 'string' && - row.trackedChangeAnchorKey.startsWith('tc::body::'); - -// SuperDoc.vue now receives the v2 editor / ruler components through the -// injected `config.editorIntegration` seam. Build a test integration with -// lightweight local stubs so the public package does not import V2 implementation code. -const buildTestV2Integration = () => ({ - version: 2, - EditorComponent: V2SuperEditorStub, - RulerComponent: V2RulerStub, - createGeometryPublisher: createTestV2GeometryPublisher, - createReviewHydrationController: createTestV2ReviewHydrationController, - isSyntheticTrackedChangeCommentLaneItem: isTestSyntheticTrackedChangeCommentLaneItem, - isV2SyntheticTrackedChangeRow: isTestV2SyntheticTrackedChangeRow, -}); - const buildSuperdocStore = () => { const documents = ref([ { @@ -530,10 +379,6 @@ const createSuperdocStub = () => { suppressDefaultDocxStyles: false, disableContextMenu: false, layoutEngineOptions: {}, - // Inject the v2 integration seam so the editorVersion: 2 branch resolves - // the stub editor / ruler components. v1 tests keep editorVersion: 1, so - // the integration factory is present but never rendered. - editorIntegration: buildTestV2Integration, }, activeEditor: null, toolbar, @@ -579,7 +424,6 @@ const createSuperdocStub = () => { const createRuntimeEditorMock = (documentId = 'doc-1') => ({ options: { documentId }, - editorVersion: 1, state: { doc: { textBetween: vi.fn(() => '') }, selection: { from: 0, to: 0, empty: true }, @@ -3578,643 +3422,12 @@ describe('SuperDoc.vue', () => { }); }); - // ui-phase2-001: branch selection + v1 non-regression for the - // public `editorVersion` switch. These tests look at the rendered - // DOCX branch by class name (`SuperEditorStub` adds `super-editor-stub`, - // `V2SuperEditor` adds `v2-super-editor`) rather than reaching into Vue - // internals so the public branch contract is what we pin. - describe('editorVersion branch selection', () => { - it('renders the v1 SuperEditor for DOCX when editorVersion is omitted', async () => { - const superdocStub = createSuperdocStub(); - // editorVersion intentionally not set - const wrapper = await mountComponent(superdocStub); - await nextTick(); - expect(wrapper.find('.super-editor-stub').exists()).toBe(true); - expect(wrapper.find('.v2-super-editor').exists()).toBe(false); - }); - - it('renders the v1 SuperEditor for DOCX when editorVersion is 1', async () => { + describe('DOCX rendering path', () => { + it('renders DOCX documents through the v1 SuperEditor', async () => { const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 1; const wrapper = await mountComponent(superdocStub); await nextTick(); expect(wrapper.find('.super-editor-stub').exists()).toBe(true); - expect(wrapper.find('.v2-super-editor').exists()).toBe(false); - }); - - it('renders V2SuperEditor for DOCX when editorVersion is 2', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const wrapper = await mountComponent(superdocStub); - // The v2 editor component now comes from the injected integration; flush - // pending microtasks so the rendered branch settles. - await flushPromises(); - await nextTick(); - expect(wrapper.find('.super-editor-stub').exists()).toBe(false); - expect(wrapper.find('.v2-super-editor').exists()).toBe(true); - }); - - it('forwards the injected v2 editor component as the rendered v2 editor', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const wrapper = await mountComponent(superdocStub); - await flushPromises(); - await nextTick(); - // The component rendered for the v2 branch is exactly the one supplied via - // config.editorIntegration.EditorComponent (the test stub), proving the seam - // forwards the integration's component provider. - expect(wrapper.findComponent(V2SuperEditorStub).exists()).toBe(true); - }); - - it('surfaces a clear exception when editorVersion is 2 without an integration', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - // No integration injected: the local stub must fail closed. - delete superdocStub.config.editorIntegration; - const wrapper = await mountComponent(superdocStub); - await flushPromises(); - await nextTick(); - - expect(wrapper.findComponent(V2SuperEditorStub).exists()).toBe(false); - const exceptionCalls = superdocStub.emit.mock.calls.filter((call) => call[0] === 'exception'); - expect(exceptionCalls.length).toBeGreaterThan(0); - const payload = exceptionCalls.at(-1)?.[1]; - expect(String(payload?.code ?? payload?.error?.message ?? '')).toContain('v2-integration-missing'); - }); - - it('forwards document V2 collaboration options to V2SuperEditor', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customSuperdocStore = buildSuperdocStore(); - const v2Collaboration = { - documentId: 'sd-v2-proof-v2', - params: { clientSessionId: 'labs-preview-v2' }, - serverUrl: 'ws://127.0.0.1:8081/v1/collaboration', - }; - customSuperdocStore.documents.value[0].v2Collaboration = v2Collaboration; - - const wrapper = await mountComponent(superdocStub, { superdocStore: customSuperdocStore }); - await flushPromises(); - await nextTick(); - - const v2Editor = wrapper.findComponent(V2SuperEditorStub); - expect(v2Editor.exists()).toBe(true); - expect(v2Editor.props('options').v2Collaboration).toEqual(v2Collaboration); - }); - - it('emits collaboration-ready when a V2 collaborative editor is ready', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const onCollaborationReady = vi.fn(); - superdocStub.on('collaboration-ready', onCollaborationReady); - const customSuperdocStore = buildSuperdocStore(); - customSuperdocStore.documents.value[0].v2Collaboration = { - documentId: 'sd-v2-proof-v2', - params: { clientSessionId: 'labs-preview-v2' }, - serverUrl: 'ws://127.0.0.1:8081/v1/collaboration', - }; - - const wrapper = await mountComponent(superdocStub, { superdocStore: customSuperdocStore }); - await flushPromises(); - await nextTick(); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host: { save: vi.fn(async () => new Uint8Array().buffer), getCapabilities: vi.fn(() => ({})) }, - mount: { focus: { focus: vi.fn(() => true) } }, - documentId: 'doc-1', - }); - await nextTick(); - - const facade = customSuperdocStore.documents.value[0].setEditor.mock.calls.at(-1)?.[0]; - expect(superdocStub.emit).toHaveBeenCalledWith('collaboration-ready', { editor: facade }); - expect(onCollaborationReady).toHaveBeenCalledWith({ editor: facade }); - await nextTick(); - expect(customSuperdocStore.isReady.value).toBe(true); - }); - - it('does not pass a v1 editorCtor to SuperEditor when editorVersion is 2', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const wrapper = await mountComponent(superdocStub); - await nextTick(); - // No v1 SuperEditor stub means no v1 editorCtor was passed by definition. - expect(wrapper.find('.super-editor-stub').exists()).toBe(false); - }); - - it('keeps PDF documents on the existing PdfViewer when editorVersion is 2', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customSuperdocStore = buildSuperdocStore(); - customSuperdocStore.documents.value = [ - { - id: 'pdf-doc', - type: 'application/pdf', - data: new Blob([new Uint8Array([0x25, 0x50, 0x44, 0x46])], { type: 'application/pdf' }), - state: {}, - isReady: false, - rulers: false, - editorMountNonce: ref(0), - setEditor: vi.fn(), - getEditor: vi.fn(() => null), - }, - ]; - const wrapper = await mountComponent(superdocStub, { superdocStore: customSuperdocStore }); - await nextTick(); - // Neither v1 SuperEditor nor V2SuperEditor render for PDF. - expect(wrapper.find('.super-editor-stub').exists()).toBe(false); - expect(wrapper.find('.v2-super-editor').exists()).toBe(false); - }); - - it('keeps HTML documents on the existing HtmlViewer when editorVersion is 2', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customSuperdocStore = buildSuperdocStore(); - customSuperdocStore.documents.value = [ - { - id: 'html-doc', - type: 'text/html', - data: '

HTML

', - state: {}, - html: '

HTML

', - isReady: false, - rulers: false, - editorMountNonce: ref(0), - setEditor: vi.fn(), - getEditor: vi.fn(() => null), - }, - ]; - const wrapper = await mountComponent(superdocStub, { superdocStore: customSuperdocStore }); - await nextTick(); - expect(wrapper.find('.super-editor-stub').exists()).toBe(false); - expect(wrapper.find('.v2-super-editor').exists()).toBe(false); - expect(wrapper.find('.HtmlViewer-stub').exists()).toBe(true); - }); - - it('hides v1-only chrome (comments layer, AI layer, hrbr fields, whiteboard, AI writer) in v2 mode', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - superdocStub.config.modules = { - ...superdocStub.config.modules, - 'hrbr-fields': [{ id: 'field' }], - whiteboard: { enabled: true }, - }; - const customStore = buildSuperdocStore(); - customStore.modules.whiteboard = { enabled: true }; - customStore.modules['hrbr-fields'] = [{ id: 'field' }]; - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await nextTick(); - expect(wrapper.find('.CommentsLayer-stub').exists()).toBe(false); - expect(wrapper.find('.HrbrFieldsLayer-stub').exists()).toBe(false); - expect(wrapper.find('.AIWriter-stub').exists()).toBe(false); - expect(wrapper.find('.superdoc__selection-layer').exists()).toBe(false); - // No selection exists, so the shared floating tools menu should stay hidden. - expect(wrapper.find('.tools-item').exists()).toBe(false); - }); - - it('publishes editor positions and lifts the sidebar gate when v2-render fires (ui-phase3-001)', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - superdocStub.config.modules = { - ...superdocStub.config.modules, - comments: {}, - }; - // Stable RAF mock so we can observe the publish path without timers. - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); - return 1; - }); - vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}); - - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - const mountContainer = document.createElement('div'); - const carrier = document.createElement('span'); - carrier.dataset.commentIds = 'c-1'; - carrier.dataset.storyKey = 'body'; - carrier.getBoundingClientRect = () => ({ - top: 200, - left: 100, - right: 160, - bottom: 220, - width: 60, - height: 20, - x: 100, - y: 200, - toJSON() { - return this; - }, - }); - mountContainer.appendChild(carrier); - - // Provide a deterministic layers rect via the wrapper's root layers div. - const layersEl = wrapper.find('.superdoc__layers').element; - layersEl.getBoundingClientRect = () => ({ - top: 100, - left: 50, - right: 850, - bottom: 1100, - width: 800, - height: 1000, - x: 50, - y: 100, - toJSON() { - return this; - }, - }); - - // Mark the host as ready so the sidebar's other gates are open. - const listComments = vi.fn(async () => ({ items: [{ id: 'c-1', importedId: '0' }] })); - const host = { - save: vi.fn(async () => new Uint8Array().buffer), - getCapabilities: vi.fn(() => ({})), - getHandles: vi.fn(() => ({ - comments: { list: listComments }, - })), - }; - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host, - mount: { focus: { focus: vi.fn() } }, - documentId: 'doc-1', - capabilities: {}, - }); - await nextTick(); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-render', { - epoch: 1, - mountContainer, - host, - mount: { getRenderEpoch: () => 1 }, - }); - await flushPromises(); - await nextTick(); - - expect(commentsStoreStub.handleEditorLocationsUpdate).toHaveBeenCalled(); - const lastEntries = commentsStoreStub.handleEditorLocationsUpdate.mock.calls.at(-1)?.[0]; - expect(listComments).toHaveBeenCalled(); - expect(lastEntries['c-1']).toMatchObject({ - threadId: 'c-1', - key: 'c-1', - kind: 'comment', - storyKey: 'body', - }); - expect(lastEntries['0']).toBe(lastEntries['c-1']); - expect(lastEntries['c-1'].bounds.top).toBe(100); - - rafSpy.mockRestore(); - }); - - it('clears v2 positions when viewing-mode hides comments (ui-phase3-001)', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - superdocStub.config.documentMode = 'viewing'; - superdocStub.config.allowSelectionInViewMode = false; - superdocStub.config.comments = { visible: false }; - superdocStub.config.trackChanges = { visible: false }; - superdocStub.config.modules = { - ...superdocStub.config.modules, - comments: {}, - }; - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); - return 1; - }); - vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}); - - const wrapper = await mountComponent(superdocStub); - await flushPromises(); - await nextTick(); - - const mountContainer = document.createElement('div'); - const carrier = document.createElement('span'); - carrier.dataset.commentIds = 'c-1'; - carrier.dataset.storyKey = 'body'; - carrier.getBoundingClientRect = () => ({ - top: 0, - left: 0, - right: 10, - bottom: 10, - width: 10, - height: 10, - x: 0, - y: 0, - toJSON() { - return this; - }, - }); - mountContainer.appendChild(carrier); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-render', { - epoch: 1, - mountContainer, - host: { getHandles: vi.fn(() => ({ comments: { list: vi.fn(async () => ({ items: [] })) } })) }, - }); - await flushPromises(); - await nextTick(); - - // Viewing mode + hidden comments + hidden tracked changes means the - // collector should be skipped and any prior positions cleared. The - // store's handleEditorLocationsUpdate must NOT be called for this - // payload; clearEditorCommentPositions must run instead. - expect(commentsStoreStub.handleEditorLocationsUpdate).not.toHaveBeenCalled(); - expect(commentsStoreStub.clearEditorCommentPositions).toHaveBeenCalled(); - - rafSpy.mockRestore(); - }); - - it('reuses the floating comment tool in v2 mode and publishes pending geometry (ui-phase3-002)', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - superdocStub.config.modules = { - ...superdocStub.config.modules, - comments: {}, - }; - superdocStub.setActiveEditor = vi.fn((editor) => { - superdocStub.activeEditor = editor; - }); - commentsStoreStub.showAddComment.mockReturnValue({ ok: true }); - commentsStoreStub.editorCommentPositions.value = {}; - - const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); - return 1; - }); - vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}); - - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - const mountContainer = document.createElement('div'); - const selectedBlock = document.createElement('p'); - selectedBlock.dataset.sourceNodeId = 'p-1'; - selectedBlock.getBoundingClientRect = () => ({ - top: 220, - left: 110, - right: 410, - bottom: 250, - width: 300, - height: 30, - x: 110, - y: 220, - toJSON() { - return this; - }, - }); - mountContainer.appendChild(selectedBlock); - - const layersEl = wrapper.find('.superdoc__layers').element; - layersEl.getBoundingClientRect = () => ({ - top: 100, - left: 50, - right: 850, - bottom: 1100, - width: 800, - height: 1000, - x: 50, - y: 100, - toJSON() { - return this; - }, - }); - - const commentsAdapter = { - getCapabilityState: vi.fn(() => ({ canWrite: true, reason: null })), - }; - const host = { - save: vi.fn(async () => new Uint8Array().buffer), - getCapabilities: vi.fn(() => ({})), - getHandles: vi.fn(() => ({ - comments: { list: vi.fn(async () => ({ items: [] })) }, - })), - }; - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host, - mount: { focus: { focus: vi.fn() } }, - documentId: 'doc-1', - capabilities: {}, - commentsAdapter, - }); - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-render', { - epoch: 1, - mountContainer, - host, - }); - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-selection-changed', { - hasRangeSelection: true, - snapshot: { - anchor: { blockId: 'p-1', blockOffset: 0, fragmentId: 'p-1' }, - focus: { blockId: 'p-1', blockOffset: 5, fragmentId: 'p-1' }, - }, - }); - await flushPromises(); - await nextTick(); - - expect(wrapper.find('.superdoc__right-sidebar').exists()).toBe(false); - expect(wrapper.vm.showToolsFloatingMenu).toBe(true); - - const tools = wrapper.findAll('.tools-item'); - expect(tools).toHaveLength(1); - await tools[0].trigger('mousedown'); - - expect(commentsStoreStub.showAddComment).toHaveBeenCalledWith(superdocStub, 220); - expect(commentsStoreStub.handleEditorLocationsUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - pending: expect.objectContaining({ - threadId: 'pending', - key: 'pending', - kind: 'pending', - storyKey: 'body', - bounds: { - top: 120, - left: 60, - right: 360, - bottom: 150, - width: 300, - height: 30, - }, - }), - }), - ); - - rafSpy.mockRestore(); - }); - - it('publishes a v2 facade with shell export and focus hooks when V2SuperEditor is ready', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - const saveBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]).buffer; - const selectionSnapshot = { - anchor: { blockId: 'block-1', blockOffset: 2 }, - focus: { blockId: 'block-1', blockOffset: 5 }, - story: { storyType: 'body', storyId: null }, - }; - const host = { - save: vi.fn(async () => saveBytes), - getCapabilities: vi.fn(() => ({ editing: { canType: true } })), - getHandles: vi.fn(() => ({ - editing: { - selection: { - getSnapshot: vi.fn(() => selectionSnapshot), - }, - }, - })), - }; - const focusController = { focus: vi.fn(() => true) }; - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host, - mount: { focus: focusController }, - documentId: 'doc-1', - capabilities: { editing: { canType: true } }, - }); - await nextTick(); - - const facade = customStore.documents.value[0].setEditor.mock.calls.at(-1)?.[0]; - expect(facade).toMatchObject({ - editorVersion: 2, - documentId: 'doc-1', - commands: null, - state: null, - view: null, - }); - - await expect(facade.save()).resolves.toBe(saveBytes); - const exported = await facade.exportDocx(); - expect(exported).toBeInstanceOf(Blob); - expect(exported.type).toBe(DOCX); - expect(host.save).toHaveBeenCalledTimes(2); - expect(facade.doc.selection.current()).toEqual({ - empty: false, - target: { - kind: 'text', - segments: [{ blockId: 'block-1', range: { start: 2, end: 5 } }], - }, - activeMarks: [], - activeCommentIds: [], - activeChangeIds: [], - }); - expect(facade.focus()).toBe(true); - expect(focusController.focus).toHaveBeenCalledTimes(1); - expect(superdocStub.broadcastEditorCreate).toHaveBeenCalledWith(facade); - }); - - it('guards mutating v2 Document API facade methods when document mode is viewing', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - const documentApi = { - comments: { - list: vi.fn(() => ({ items: [] })), - get: vi.fn(() => null), - create: vi.fn(() => ({ success: true })), - patch: vi.fn(() => ({ success: true })), - delete: vi.fn(() => ({ success: true })), - }, - trackChanges: { - list: vi.fn(() => ({ items: [] })), - get: vi.fn(() => null), - decide: vi.fn(() => ({ success: true })), - }, - history: { - get: vi.fn(() => ({ canUndo: true, canRedo: true })), - undo: vi.fn(() => ({ success: true })), - redo: vi.fn(() => ({ success: true })), - }, - }; - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host: { save: vi.fn(async () => new Uint8Array().buffer), getCapabilities: vi.fn(() => ({})) }, - mount: { focus: { focus: vi.fn(() => true) } }, - documentId: 'doc-1', - documentApi, - }); - await nextTick(); - - const facade = customStore.documents.value[0].setEditor.mock.calls.at(-1)?.[0]; - expect(facade.doc.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } })).toEqual({ - success: true, - }); - expect(documentApi.trackChanges.decide).toHaveBeenCalledTimes(1); - - superdocStub.config.documentMode = 'viewing'; - expect(facade.doc.trackChanges.list()).toEqual({ items: [] }); - expect(facade.doc.history.get()).toEqual({ canUndo: true, canRedo: true }); - expect(() => facade.doc.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } })).toThrow( - /document is read-only/, - ); - expect(() => facade.doc.comments.create({ text: 'blocked' })).toThrow(/document is read-only/); - expect(() => facade.doc.comments.patch({ commentId: 'c-1', text: 'blocked' })).toThrow(/document is read-only/); - expect(() => facade.doc.comments.delete({ commentId: 'c-1' })).toThrow(/document is read-only/); - expect(() => facade.doc.history.undo()).toThrow(/document is read-only/); - expect(() => facade.doc.history.redo()).toThrow(/document is read-only/); - expect(documentApi.trackChanges.decide).toHaveBeenCalledTimes(1); - expect(documentApi.comments.create).not.toHaveBeenCalled(); - expect(documentApi.comments.patch).not.toHaveBeenCalled(); - expect(documentApi.comments.delete).not.toHaveBeenCalled(); - expect(documentApi.history.undo).not.toHaveBeenCalled(); - expect(documentApi.history.redo).not.toHaveBeenCalled(); - }); - - it('clears a ready v2 facade when the render surface is torn down', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host: { save: vi.fn(async () => new Uint8Array().buffer), getCapabilities: vi.fn(() => ({})) }, - mount: { focus: { focus: vi.fn(() => true) } }, - documentId: 'doc-1', - }); - await nextTick(); - expect(superdocStub.activeEditor?.editorVersion).toBe(2); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-render-cleared', { documentId: 'doc-1' }); - await nextTick(); - - expect(superdocStub.activeEditor).toBeNull(); - expect(customStore.documents.value[0].setEditor.mock.calls.at(-1)?.[0]).toBeNull(); - }); - - it('clears a ready v2 facade when the v2 editor reports a terminal failure', async () => { - const superdocStub = createSuperdocStub(); - superdocStub.config.editorVersion = 2; - const customStore = buildSuperdocStore(); - const wrapper = await mountComponent(superdocStub, { superdocStore: customStore }); - await flushPromises(); - await nextTick(); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-ready', { - host: { save: vi.fn(async () => new Uint8Array().buffer), getCapabilities: vi.fn(() => ({})) }, - mount: { focus: { focus: vi.fn(() => true) } }, - documentId: 'doc-1', - }); - await nextTick(); - expect(superdocStub.activeEditor?.editorVersion).toBe(2); - - wrapper.findComponent(V2SuperEditorStub).vm.$emit('v2-editor-failed', { - documentId: 'doc-1', - error: new Error('open failed'), - }); - await nextTick(); - - expect(superdocStub.activeEditor).toBeNull(); - expect(customStore.documents.value[0].setEditor.mock.calls.at(-1)?.[0]).toBeNull(); }); }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 9fdb475834..fe359163ec 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -10,7 +10,6 @@ import { getCurrentInstance, inject, ref, - shallowRef, unref, onMounted, onBeforeUnmount, @@ -19,7 +18,6 @@ import { reactive, watch, defineAsyncComponent, - markRaw, } from 'vue'; import { storeToRefs } from 'pinia'; @@ -60,7 +58,6 @@ import { useViewportFit } from './composables/use-viewport-fit.js'; import { createV1EditorRuntimeAdapter } from './core/editor-runtime/v1/v1-editor-runtime-adapter.js'; import { markRuntimeRoot, unmarkRuntimeRoot } from './core/editor-runtime/root-marker.js'; import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracked-change-ids.js'; -import { resolveV2Integration } from './core/v2-integration/v2-integration.js'; import { transactionTouchesStructuralChange } from './helpers/transaction-touches-structural-change.js'; import SurfaceHost from './components/surfaces/SurfaceHost.vue'; import { @@ -155,19 +152,6 @@ const { const { proxy } = getCurrentInstance(); commentsStore.proxy = proxy; -// Resolve the V2 integration seam from config. Public SuperDoc does not bundle -// a V2 runtime: the host injects a single integration object -// (`config.editorIntegration`), and a local stub preserves V1 -// behavior when none is provided. The V2 editor / ruler components and the -// geometry + review hydration factories all arrive through this object. -const resolvedEditorIntegration = resolveV2Integration(proxy.$superdoc.config); -// ui-phase2-001: the v2 DOCX editor wrapper now comes from the injected -// integration, so the v1 default bundle never references the V2 host runtime. -const V2SuperEditor = markRaw(resolvedEditorIntegration.EditorComponent); -// ui-phase4-002: v2 ruler (optional). Falls back to the stub editor component's -// sibling null when the integration does not provide one. -const V2Ruler = resolvedEditorIntegration.RulerComponent ? markRaw(resolvedEditorIntegration.RulerComponent) : null; - const floatingComments = computed(() => { const currentFloatingComments = unref(commentsStore.getFloatingComments); return Array.isArray(currentFloatingComments) ? currentFloatingComments : []; @@ -255,25 +239,6 @@ const { layers, }); -// ui-phase2-001: opt-in v2 DOCX editor mode. Normalized by `SuperDoc.#init` -// before this component mounts, so `editorVersion` is always `1` or `2` here. -// `1` (default) keeps the existing v1 SuperEditor DOCX path; `2` swaps DOCX -// documents to the v2 host shell wrapper (`V2SuperEditor`). PDF / HTML are -// always routed through their existing viewers regardless of the flag. -const isV2Mode = computed(() => proxy.$superdoc.config.editorVersion === 2); - -// ui-phase3-001: v2 geometry bridge state. `v2GeometryRender` holds the -// latest payload reported by `V2SuperEditor` after each render-epoch change; -// `v2GeometryEpoch` is the last epoch we successfully published into the -// comments store so the RAF batcher can drop duplicate ticks. `v2Geometry- -// Available` flips true once we've published at least one geometry -// snapshot — it gates `showCommentsSidebar` in v2 mode so the sidebar does -// not appear before painted carriers exist. -const v2GeometryRender = shallowRef(null); -const v2GeometryAvailable = ref(false); -const v2GeometryEpoch = ref(null); -let v2GeometryRafHandle = 0; - // Create a ref to pass to the composable const activeEditorRef = computed(() => proxy.$superdoc.activeEditor); @@ -402,10 +367,6 @@ const getPendingCommentTargetClientY = () => { const handleCommentToolClick = () => { const result = showAddComment(proxy.$superdoc, getPendingCommentTargetClientY()); if (result?.ok === false) return; - if (!isV2Mode.value) return; - - const pendingPosition = buildV2PendingPositionEntry(); - if (pendingPosition) publishV2PendingPositionEntry(pendingPosition); }; const handleToolClick = (tool) => { @@ -605,637 +566,6 @@ const onEditorDestroy = () => { proxy.$superdoc.broadcastEditorDestroy(); }; -// ui-phase2-001: V2 ready / failure handlers. These DO NOT impersonate the v1 -// `Editor` / `PresentationEditor` surface. Instead, the shell publishes a -// small v2 facade on `proxy.$superdoc.activeEditor` so existing read-only -// access patterns (`activeEditor.options.documentId`) keep working while -// v1-only methods (`commands`, `state`, `view`, `chain`, `can`) are absent by -// design. Visible shell chrome that previously called those v1-only methods -// is gated by `isV2Mode` and the editorVersion=2 capability surface. -// Readiness-driven v2 review-row hydration. Replaces the former fixed 2s -// `setTimeout`/`requestIdleCallback` startup gate: startup comment and -// tracked-change rows hydrate as soon as the first source-backed document -// window has painted (v2 render-readiness `first-window-painted`), with -// generation-scoped cancellation and in-flight coalescing. Rows and geometry -// stay separate — this controller never reads or waits on the geometry -// publisher. The concrete controller arrives through the injected V2 -// integration. -const v2ReviewHydrationController = resolvedEditorIntegration.createReviewHydrationController({ - hydrateComments: (ctx) => commentsStore.hydrateCommentsFromV2?.(ctx), - hydrateTrackedChanges: (ctx) => commentsStore.hydrateTrackedChangesFromV2?.(ctx), -}); - -const buildEmptyV2SelectionInfo = () => ({ - empty: true, - target: null, - activeMarks: [], - activeCommentIds: [], - activeChangeIds: [], -}); - -const normalizeV2SelectionStory = (story) => { - if (!story || story.storyType === 'body') { - return undefined; - } - return story; -}; - -const readV2SelectionInfo = (host) => { - const empty = buildEmptyV2SelectionInfo(); - try { - const selection = host?.getHandles?.()?.editing?.selection?.getSnapshot?.(); - if (!selection?.anchor || !selection?.focus) { - return empty; - } - - const anchorBlockId = selection.anchor.blockId ?? null; - const focusBlockId = selection.focus.blockId ?? null; - const start = Math.min(selection.anchor.blockOffset ?? 0, selection.focus.blockOffset ?? 0); - const end = Math.max(selection.anchor.blockOffset ?? 0, selection.focus.blockOffset ?? 0); - const story = normalizeV2SelectionStory(selection.story); - - return { - empty: start === end, - target: - anchorBlockId && anchorBlockId === focusBlockId - ? { - kind: 'text', - segments: [{ blockId: anchorBlockId, range: { start, end } }], - ...(story ? { story } : {}), - } - : null, - activeMarks: [], - activeCommentIds: [], - activeChangeIds: [], - }; - } catch { - return empty; - } -}; - -const clearActiveV2EditorFacade = (documentId = null) => { - const activeEditor = proxy.$superdoc?.activeEditor; - if (!activeEditor || activeEditor.editorVersion !== 2) return; - - const activeDocumentId = activeEditor.documentId ?? activeEditor.options?.documentId ?? null; - if (documentId && activeDocumentId && activeDocumentId !== documentId) return; - - const doc = activeDocumentId ? getDocument(activeDocumentId) : null; - if (doc) { - doc.isReady = false; - if (typeof doc.setEditor === 'function') doc.setEditor(null); - } - proxy.$superdoc.setActiveEditor(null); -}; - -const assertV2DocumentApiCanMutate = (operation) => { - if (isViewingMode()) { - throw new Error(`activeEditor.doc.${operation}: document is read-only.`); - } -}; - -const guardV2DocumentApiMutation = (owner, method, operation) => { - if (!owner || typeof owner[method] !== 'function') return owner?.[method]; - return (...args) => { - assertV2DocumentApiCanMutate(operation); - return owner[method](...args); - }; -}; - -const createGuardedV2DocumentApi = (documentApi) => { - if (!documentApi) return null; - const comments = documentApi.comments - ? { - ...documentApi.comments, - create: guardV2DocumentApiMutation(documentApi.comments, 'create', 'comments.create'), - patch: guardV2DocumentApiMutation(documentApi.comments, 'patch', 'comments.patch'), - delete: guardV2DocumentApiMutation(documentApi.comments, 'delete', 'comments.delete'), - } - : undefined; - const trackChanges = documentApi.trackChanges - ? { - ...documentApi.trackChanges, - decide: guardV2DocumentApiMutation(documentApi.trackChanges, 'decide', 'trackChanges.decide'), - } - : undefined; - const history = documentApi.history - ? { - ...documentApi.history, - undo: guardV2DocumentApiMutation(documentApi.history, 'undo', 'history.undo'), - redo: guardV2DocumentApiMutation(documentApi.history, 'redo', 'history.redo'), - } - : undefined; - return { - ...documentApi, - ...(comments ? { comments } : {}), - ...(trackChanges ? { trackChanges } : {}), - ...(history ? { history } : {}), - }; -}; - -const onV2EditorReady = (payload) => { - if (!payload) return; - const { - host, - mount, - documentId, - capabilities, - commentsAdapter, - trackedChangesAdapter, - documentApi, - documentMutationReadiness, - documentApiUnavailableReason, - pageMetrics, - pageLayout, - pageFurniture, - } = payload; - const saveV2Bytes = async () => { - if (!host || typeof host.save !== 'function') { - throw new Error('v2-editor: save unavailable'); - } - return host.save(); - }; - const exportV2Docx = async () => { - const bytes = await saveV2Bytes(); - return new Blob([bytes], { type: DOCX }); - }; - const guardedDocumentApi = createGuardedV2DocumentApi(documentApi); - const facade = { - editorVersion: 2, - documentId, - host, - mount, - options: { - documentId, - documentMode: proxy.$superdoc.config.documentMode, - }, - // Stable disabled / not-shipped status mirror — the host capability - // snapshot is the source of truth; this is a convenience surface for the - // shell so it does not have to re-read `host.getCapabilities()` on every - // toolbar tick. - capabilities: capabilities ?? host?.getCapabilities?.() ?? null, - save: saveV2Bytes, - exportDocx: exportV2Docx, - // Mutation-plane consolidation: `activeEditor.doc` is the SuperDoc-facing - // synchronous Document API mutation surface for normal inline v2. It is the - // structural facade emitted by the v2 browser shell (backed by the private - // raw inline `V2DocumentApiHost.doc`), so v2 comment / tracked-change / - // history mutations route through `activeEditor.doc.*`. The live browser - // `selection.current` read is preserved here rather than using the raw - // headless selection adapter. In worker mode `documentApi` is null (no sync - // Document API across the worker boundary) and only selection is exposed so - // mutation UI fails closed. - doc: guardedDocumentApi - ? { - ...guardedDocumentApi, - selection: { - current: () => readV2SelectionInfo(host), - }, - } - : { - selection: { - current: () => readV2SelectionInfo(host), - }, - }, - // Visual readiness helper emitted beside the document facade. Callers that - // need painted overlay/sidebar/geometry evidence await - // `documentMutationReadiness.whenPainted(...)` after a committed receipt. - documentMutationReadiness: documentMutationReadiness ?? null, - // Stable reason when the synchronous Document API facade is unavailable - // (worker-backed v2). Null when `doc.comments` / `doc.trackChanges` are live. - documentApiUnavailableReason: documentApiUnavailableReason ?? null, - focus: () => { - if (mount?.focus && typeof mount.focus.focus === 'function') { - return mount.focus.focus(); - } - return false; - }, - // ui-phase3-002: v2 comments adapter — used by comments-store and - // CommentDialog to route create / reply / edit / resolve / delete through - // v2 host APIs. Always present in v2 mode; null when the v2 editor host - // boot failed. - v2Comments: commentsAdapter ?? null, - // ui-phase3-003: v2 tracked-change adapter — used by comments-store and - // CommentDialog to list / focus / accept / reject tracked changes through - // v2 host APIs. Always present in v2 mode; null when the v2 editor host - // boot failed. - v2TrackedChanges: trackedChangesAdapter ?? null, - // ui-phase4-001: v2 page metrics + zoom runtime. Always present in v2 - // mode (null only if the v2 editor host boot failed). Consumers: - // - SuperDoc.vue's `activeZoom` watcher calls `pageMetrics.setZoom` - // - rulers, floating layers, whiteboard overlays consume - // `pageMetrics.getSnapshot()` / `subscribe(...)`. - pageMetrics: pageMetrics ?? null, - // ui-phase4-002: narrow v2 page-layout bridge for ruler / margin chrome. - // Always present in v2 mode (null only if the v2 editor host boot failed). - // Routes margin edits through `doc.sections.setPageMargins(...)` under - // the hood; never exposes raw host/session/adapter handles to Vue. - pageLayout: pageLayout ?? null, - // Host-visible page-furniture geometry - // readback. Always present in v2 mode (null only if the v2 editor host - // boot failed). Host-visible proofs read - // `superdoc.activeEditor.pageFurniture.getSnapshot()` to associate painted - // header/footer regions with their story ref ids. - pageFurniture: pageFurniture ?? null, - // Readiness-driven review hydration diagnostics. Lets the example shell / - // proofing layers observe startup row-hydration timing (boot → first-window - // → first row) without reaching into Vue internals. Read-only surface. - reviewHydration: { - getDiagnostics: () => v2ReviewHydrationController.getDiagnostics(), - }, - /** - * The v2 active-editor facade explicitly does NOT carry v1 commands / - * state / view / chain / can. Document mutations (comments, tracked - * changes, history) go through `activeEditor.doc.*` — the synchronous - * Document API facade above. Narrow read / focus / reveal / active-target - * controls use their explicit bridge surfaces (`v2Comments` / - * `v2TrackedChanges`); those are not review mutation routes. Chrome that - * still uses the v1 surface must be gated behind - * `superdoc.config.editorVersion === 1`. - */ - commands: null, - state: null, - view: null, - }; - - const doc = getDocument(documentId); - if (doc) { - doc.isReady = true; - if (typeof doc.setEditor === 'function') doc.setEditor(facade); - } - proxy.$superdoc.setActiveEditor(facade); - proxy.$superdoc.broadcastEditorCreate(facade); - if (getDocument(documentId)?.v2Collaboration) { - onEditorCollaborationReady({ editor: facade }); - } - // ui-phase4-002: flip the reactive readiness signal so the ruler template - // re-evaluates `shouldShowV2Ruler(doc)` now that pageMetrics + pageLayout - // are attached to the active editor facade. - if (pageMetrics && pageLayout) v2RulerReady.value = true; - - // ui-phase3-002 / ui-phase3-003: register the v2 comment + tracked-change - // adapters on the store so its adapter-identity guard - // (`isCurrentV2TrackedChangesAdapter`) can drop stale async results, then - // hand the readiness-driven hydration controller its context. Startup row - // hydration is NOT fired on a fixed timer here: the controller triggers both - // comment and tracked-change hydration as soon as the v2 render-readiness - // lifecycle reports the first source-backed window painted. The geometry - // bridge (Phase 3 / 001) keeps owning floating positions independently. - const commentsModuleEnabled = proxy.$superdoc.config.modules?.comments !== false; - if (commentsAdapter && commentsModuleEnabled) { - commentsStore.setV2CommentsAdapter?.(commentsAdapter); - } - if (trackedChangesAdapter && commentsModuleEnabled) { - commentsStore.setV2TrackedChangesAdapter?.(trackedChangesAdapter); - } - if (commentsModuleEnabled && (commentsAdapter || trackedChangesAdapter)) { - v2ReviewHydrationController.setContext({ - superdoc: proxy.$superdoc, - documentId, - commentsAdapter: commentsAdapter ?? null, - trackedChangesAdapter: trackedChangesAdapter ?? null, - }); - // Feed the current readiness snapshot so a `first-window-painted` transition - // that already happened during mount is not missed by a late subscriber. - // Subsequent transitions arrive via `@v2-render-readiness` → onV2RenderReadiness. - try { - const snapshot = host?.getRenderReadinessSnapshot?.(); - if (snapshot) v2ReviewHydrationController.onRenderReadiness(snapshot); - } catch (err) { - console.warn('[SuperDoc][v2] initial render-readiness snapshot failed', err); - } - } - - if (areDocumentsReady.value && !proxy.$superdoc.config.collaboration) { - isReady.value = true; - } - // Mark floating-comments fallback so the v2-mode shell does not idle on - // the v1-only locations-update event. - isFloatingCommentsReady.value = true; - hasInitializedLocations.value = true; -}; - -// ui-phase3-002: v2 selection mirror used to gate the create-comment -// affordance in v2 mode. v1's `selectionPosition` is fed by PM coordsAtPos -// which v2 never emits, so we maintain a separate flag and feed the floating -// "+" tool from the v2 selection snapshot instead. -const v2HasRangeSelection = ref(false); -const v2SelectionSnapshot = shallowRef(null); -const onV2SelectionChanged = ({ hasRangeSelection, snapshot } = {}) => { - v2HasRangeSelection.value = hasRangeSelection === true; - v2SelectionSnapshot.value = hasRangeSelection === true ? (snapshot ?? null) : null; - syncV2SelectionToolbarState(); -}; - -const getActiveV2MountContainer = () => { - return v2GeometryRender.value?.mountContainer ?? proxy.$superdoc?.activeEditor?.mount?.container ?? null; -}; - -const escapeCssIdent = (value) => { - const raw = String(value); - if (globalThis.CSS?.escape) return globalThis.CSS.escape(raw); - return raw.replace(/["\\]/g, '\\$&'); -}; - -const findV2SelectionAnchorElement = () => { - const snapshot = v2SelectionSnapshot.value; - const root = getActiveV2MountContainer(); - if (!snapshot || !root?.querySelector) return null; - const anchor = snapshot.anchor ?? null; - const ids = [anchor?.fragmentId, anchor?.blockId, anchor?.position?.anchor?.nativeId].filter( - (id) => id != null && id !== '', - ); - - for (const id of ids) { - const escaped = escapeCssIdent(id); - const match = - root.querySelector(`[data-source-node-id="${escaped}"]`) ?? - root.querySelector(`[data-layout-block-ref="${escaped}"]`) ?? - root.querySelector(`[data-layout-fragment-id="${escaped}"]`); - if (match instanceof HTMLElement) return match; - } - return null; -}; - -function getSelectionBoundingBox(root = null) { - const selection = window.getSelection?.(); - if (!selection || selection.rangeCount < 1 || selection.isCollapsed) return null; - - const range = selection.getRangeAt(0); - if (!range) return null; - - if (root) { - const { startContainer, endContainer } = range; - if (!root.contains(startContainer) || !root.contains(endContainer)) return null; - } - - try { - const rect = range.getBoundingClientRect(); - if (!rect || (rect.width <= 0 && rect.height <= 0)) return null; - return rect; - } catch { - return null; - } -} - -const rectToLayerBounds = (rect) => { - if (!rect || !layers.value) return null; - const layerRect = layers.value.getBoundingClientRect(); - return { - top: rect.top - layerRect.top, - left: rect.left - layerRect.left, - right: rect.right - layerRect.left, - bottom: rect.bottom - layerRect.top, - width: rect.width, - height: rect.height, - }; -}; - -const readV2PageIndex = (element) => { - let current = element; - while (current && current.nodeType === 1) { - const raw = current.dataset?.pageIndex; - if (raw != null && raw !== '') { - const parsed = Number(raw); - if (Number.isFinite(parsed)) return parsed; - } - current = current.parentElement; - } - return null; -}; - -const buildV2FloatingSelection = () => { - if (!v2HasRangeSelection.value || !isCommentsEnabled.value) return null; - - const selectionRect = getSelectionBoundingBox(getActiveV2MountContainer()); - const bounds = rectToLayerBounds(selectionRect) ?? buildV2PendingPositionEntry()?.bounds ?? null; - if (!bounds) return null; - - const documentId = proxy.$superdoc?.activeEditor?.options?.documentId ?? proxy.$superdoc?.activeEditor?.documentId; - if (!documentId) return null; - - const pageIndex = readV2PageIndex(findV2SelectionAnchorElement()); - return useSelection({ - selectionBounds: bounds, - page: pageIndex != null ? pageIndex + 1 : 1, - documentId, - // Reuse the existing SuperEditor selection path so the comments shell can - // keep using the same floating comment tool in v2 mode. - source: 'super-editor', - }); -}; - -const buildV2PendingPositionEntry = () => { - if (!layers.value) return null; - const target = findV2SelectionAnchorElement(); - if (!target) return null; - const rect = target.getBoundingClientRect(); - if (!rect || (rect.width <= 0 && rect.height <= 0)) return null; - const layerRect = layers.value.getBoundingClientRect(); - const bounds = { - top: rect.top - layerRect.top, - left: rect.left - layerRect.left, - right: rect.right - layerRect.left, - bottom: rect.bottom - layerRect.top, - width: rect.width, - height: rect.height, - }; - const pageIndex = readV2PageIndex(target); - return { - threadId: 'pending', - key: 'pending', - kind: 'pending', - storyKey: 'body', - bounds, - ...(pageIndex != null ? { pageIndex } : {}), - ...(v2GeometryEpoch.value != null ? { generation: v2GeometryEpoch.value } : {}), - }; -}; - -const syncV2SelectionToolbarState = () => { - if (!isV2Mode.value) return; - if (!v2HasRangeSelection.value) { - activeSelection.value = null; - resetSelection(); - return; - } - - const selection = buildV2FloatingSelection(); - if (!selection) { - activeSelection.value = null; - resetSelection(); - return; - } - - handleSelectionChange(selection); -}; - -const publishV2PendingPositionEntry = (entry) => { - if (!entry) return; - handleEditorLocationsUpdate({ - ...(editorCommentPositions.value ?? {}), - pending: entry, - }); -}; - -// TCS Phase 0 / 002: framework-agnostic geometry publisher. Owns alias -// caching, pending-row preservation, missing-mount/layers clearing, and -// scroll/resize/zoom recollection (see `v2-geometry-publisher.js`). The -// SuperDoc.vue side only feeds payloads and observes the published state. -const v2GeometryPublisher = resolvedEditorIntegration.createGeometryPublisher({ - getLayersContainer: () => layers.value ?? null, - isCommentsEnabled: () => shouldRenderCommentsInViewing.value, - publishPositions: (positions) => handleEditorLocationsUpdate(positions), - clearPositions: () => { - commentsStore.clearEditorCommentPositions?.(); - }, - readCurrentPositions: () => editorCommentPositions.value ?? {}, - setGeometryAvailable: (value) => { - v2GeometryAvailable.value = Boolean(value); - if (value) v2GeometryEpoch.value = v2GeometryPublisher.getLastEpoch(); - }, -}); - -const scheduleV2GeometryPublish = (payload) => { - v2GeometryRender.value = payload; - if (v2GeometryRafHandle && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(v2GeometryRafHandle); - } - if (typeof requestAnimationFrame !== 'function') { - void v2GeometryPublisher.publish(v2GeometryRender.value); - return; - } - v2GeometryRafHandle = requestAnimationFrame(() => { - v2GeometryRafHandle = 0; - void v2GeometryPublisher.publish(v2GeometryRender.value); - }); -}; - -const onV2Render = (payload) => { - if (!payload) return; - scheduleV2GeometryPublish(payload); - if (v2HasRangeSelection.value) syncV2SelectionToolbarState(); -}; - -// phase13: consume the v2 render-readiness lifecycle as the startup clock for -// review-row hydration. Each transition snapshot is fed to the controller, -// which triggers startup comment + tracked-change hydration once the first -// source-backed window has painted. This carries no rendering semantics — it is -// pure shell orchestration over the existing v2 host readiness API. -const onV2RenderReadiness = (payload) => { - const snapshot = payload?.snapshot ?? payload ?? null; - if (!snapshot) return; - v2ReviewHydrationController.onRenderReadiness(snapshot); -}; - -// ui-phase4-001: receive v2 page metrics snapshots from V2SuperEditor. -// Mirrors the v1 `presentationEditor.on('paginationUpdate', ...)` path so -// SuperDoc consumers receive a stable `pagination-update` event regardless -// of editor version. Snapshot shape: -// `{ snapshot: V2PageMetricsSnapshot, host, mount }`. -const v2PageMetricsSnapshot = shallowRef(null); -const onV2PageMetrics = (payload) => { - if (!payload?.snapshot) return; - const snapshot = payload.snapshot; - v2PageMetricsSnapshot.value = snapshot; - const totalPages = Array.isArray(snapshot.pages) ? snapshot.pages.length : 0; - // The pagination-update event payload mirrors the v1 shape - // (`{ totalPages, superdoc }`) so existing consumers don't need to - // discriminate on editor version. The richer snapshot is reachable - // through `superdoc.activeEditor.pageMetrics.getSnapshot()`. - proxy.$superdoc.emit('pagination-update', { totalPages, superdoc: proxy.$superdoc }); - // ui-phase4-002: keep the ruler container offset aligned with the v2 paint - // wrapper. Repaint may shift the wrapper bounds (zoom changes, page count - // changes, scroll); sync once per snapshot so the ruler stays glued to the - // page stack. - nextTick(() => { - syncV2RulerOffset(); - setupV2RulerObservers(); - }); -}; - -const onV2RenderCleared = () => { - if (v2GeometryRafHandle && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(v2GeometryRafHandle); - } - v2GeometryRafHandle = 0; - v2GeometryRender.value = null; - v2GeometryEpoch.value = null; - v2GeometryAvailable.value = false; - v2GeometryPublisher.reset(); - v2HasRangeSelection.value = false; - v2SelectionSnapshot.value = null; - activeSelection.value = null; - resetSelection(); - v2PageMetricsSnapshot.value = null; - // Cancel/invalidate any pending startup review hydration BEFORE the adapters - // are nulled. A hydration still in flight from the previous document will be - // dropped at the controller (generation guard); the store's adapter-identity - // check is the final guard once the adapters below are cleared. - v2ReviewHydrationController.reset('render-cleared'); - commentsStore.setV2CommentsAdapter?.(null); - commentsStore.setV2TrackedChangesAdapter?.(null); - commentsStore.clearEditorCommentPositions?.(); - clearActiveV2EditorFacade(); - // ui-phase4-002: tear down ruler observers on document switch / dispose. - cleanupV2RulerObservers(); - v2RulerHostStyle.value = {}; - v2RulerReady.value = false; -}; - -// ui-phase3-003 / mutation-plane consolidation: refresh v2 review rows from -// the v2 host after every committed mutation. Direct sidebar mutations already -// settle their own adapter refresh, but document-level history undo/redo -// commits through `activeEditor.doc.history.*`; this listener keeps comments -// and tracked-change sidebars reconciled with the document model. -const onV2HostEvent = (event) => { - if (!event) return; - if (event.type !== 'mutation:committed') return; - const commentsAdapter = proxy.$superdoc?.activeEditor?.v2Comments ?? null; - if (commentsAdapter) { - void commentsStore.hydrateCommentsFromV2?.({ - superdoc: proxy.$superdoc, - adapter: commentsAdapter, - documentId: proxy.$superdoc?.activeEditor?.documentId ?? null, - }); - } - const trackedChangesAdapter = proxy.$superdoc?.activeEditor?.v2TrackedChanges ?? null; - if (!trackedChangesAdapter) return; - void commentsStore.hydrateTrackedChangesFromV2?.({ - superdoc: proxy.$superdoc, - adapter: trackedChangesAdapter, - documentId: proxy.$superdoc?.activeEditor?.documentId ?? null, - }); -}; - -const recollectV2GeometryIfActive = () => { - if (!isV2Mode.value) return; - if (!v2GeometryPublisher.getLastPayload()) return; - // Scroll / resize / zoom may change layer-relative coords without advancing - // the v2 paint epoch. The publisher reuses the per-epoch alias cache so a - // recollect does not call `comments.list()` again (plan §4). - if (v2GeometryRafHandle && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(v2GeometryRafHandle); - } - if (typeof requestAnimationFrame !== 'function') { - void v2GeometryPublisher.recollect(); - return; - } - v2GeometryRafHandle = requestAnimationFrame(() => { - v2GeometryRafHandle = 0; - void v2GeometryPublisher.recollect(); - }); -}; - -const onV2EditorFailed = (payload) => { - clearActiveV2EditorFacade(); - proxy.$superdoc.emit('exception', { - error: new Error(`v2-editor: ${payload?.reason ?? 'open-failed'}${payload?.detail ? ':' + payload.detail : ''}`), - code: payload?.reason ?? 'open-failed', - editor: null, - }); -}; - const onEditorFocus = ({ editor }) => { const documentId = editor?.options?.documentId; const entry = documentId ? v1Runtimes.get(documentId) : null; @@ -1558,7 +888,6 @@ const editorOptions = (doc) => { }, trackedChanges: proxy.$superdoc.config.modules?.trackChanges, experimental: proxy.$superdoc.config.experimental, - ...(doc.v2Collaboration ? { v2Collaboration: doc.v2Collaboration } : {}), editorCtor: useLayoutEngine ? PresentationEditor : undefined, onBeforeCreate: onEditorBeforeCreate, onCreate: onEditorCreate, @@ -2054,14 +1383,6 @@ const shouldUseSidebarComments = computed(() => { return !isCompactCommentsMode.value; }); const showCommentsSidebar = computed(() => { - // ui-phase3-001: v2 mode is no longer a hard gate. The sidebar may render - // once `V2SuperEditor` has published at least one render-epoch-checked - // geometry snapshot into `editorCommentPositions`. The v1 path is - // unchanged. v1-only on-document layers (`CommentsLayer`, `AiLayer`, - // `WhiteboardLayer`, etc.) remain gated by `isV2Mode` until they have - // their own v2 adapters — only the FloatingComments sidebar is unblocked - // here. - if (isV2Mode.value && !v2GeometryAvailable.value) return false; if (!shouldRenderCommentsInViewing.value) return false; if (!shouldUseSidebarComments.value) return false; return ( @@ -2129,22 +1450,9 @@ const scrollToComment = (commentId) => { proxy.$superdoc.scrollToComment(commentId); }; -// ui-phase3-001: viewport listeners for v2 geometry refresh. Scroll, window -// resize, and the contained-mode shell reposition do not advance the v2 -// paint epoch, so we recompute layer-relative bounds against the existing -// painted carriers when those events fire. The listeners short-circuit when -// v2 mode is inactive so v1 customers pay nothing. -const handleViewportScrollOrResize = () => { - recollectV2GeometryIfActive(); -}; - onMounted(() => { document.addEventListener('contextmenu', handleDocumentContextMenu, true); document.addEventListener('keydown', handleDocumentShortcut, true); - if (typeof window !== 'undefined') { - window.addEventListener('scroll', handleViewportScrollOrResize, true); - window.addEventListener('resize', handleViewportScrollOrResize, true); - } // Capture-phase product hit routing: activate the owning runtime from real // focus/pointer hits. Capture so a marked root nested under shells that stop @@ -2158,29 +1466,6 @@ onMounted(() => { ensureCompactMeasurementObserver(); }); -// ui-phase3-001: when comments / track-changes are hidden in viewing mode -// (or the layers / v2 mount disappears), clear the published v2 geometry so -// the sidebar does not float over stale bounds. The recompute path picks up -// again on the next render epoch after the user re-enables them. -watch(shouldRenderCommentsInViewing, (value) => { - if (!isV2Mode.value) return; - if (value) { - if (v2GeometryRender.value) { - scheduleV2GeometryPublish(v2GeometryRender.value); - } - } else { - commentsStore.clearEditorCommentPositions?.(); - v2GeometryAvailable.value = false; - } -}); - -// ui-phase3-001: contained-mode and layout-shell repositioning use the -// `.superdoc--contained` / `--web-layout` class toggles which can shift the -// layers element. Re-collect geometry whenever the layers ref reattaches. -watch(layers, () => { - recollectV2GeometryIfActive(); -}); - function isFindShortcutEvent(e) { return (e.metaKey || e.ctrlKey) && !e.altKey && e.key?.toLowerCase?.() === 'f'; } @@ -2207,11 +1492,6 @@ function isFocusInsideSuperDoc() { function handleFindShortcut(e) { if (!isFindShortcutEvent(e)) return; if (!isFindReplaceEnabled.value) return; - // ui-phase2-001: find/replace runs on v1 commands and a v1 active editor. - // The v2 active-editor facade carries no commands, so swallowing the - // shortcut would just hide it from the browser without offering a UI. - // Skip in v2 mode so customers can still use the browser-native find. - if (isV2Mode.value) return; if (!isFocusInsideSuperDoc()) return; // Only steal the shortcut if the composable will actually open a surface. @@ -2225,10 +1505,6 @@ function handleFindShortcut(e) { function handleFormattingMarksShortcut(e) { if (!isFormattingMarksShortcutEvent(e)) return; - // ui-phase2-001: formatting-marks toggling is a v1 layout-engine - // preference. The v2 host does not expose a formatting-marks layout - // toggle in this phase; leave the shortcut to the browser. - if (isV2Mode.value) return; if (!isFocusInsideSuperDoc()) return; e.preventDefault(); @@ -2269,14 +1545,6 @@ onBeforeUnmount(() => { subDocumentRoots.clear(); document.removeEventListener('contextmenu', handleDocumentContextMenu, true); document.removeEventListener('keydown', handleDocumentShortcut, true); - if (typeof window !== 'undefined') { - window.removeEventListener('scroll', handleViewportScrollOrResize, true); - window.removeEventListener('resize', handleViewportScrollOrResize, true); - } - if (v2GeometryRafHandle && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(v2GeometryRafHandle); - v2GeometryRafHandle = 0; - } document.removeEventListener('focusin', handleRuntimeFocusIn, true); document.removeEventListener('pointerdown', handleRuntimePointerDown, true); document.removeEventListener('mousedown', handleRuntimeMouseDown, true); @@ -2478,139 +1746,6 @@ const handleSuperEditorPageMarginsChange = (doc, params) => { doc.documentMarginsLastChange = params.pageMargins; }; -// ui-phase4-002: reactive trigger for the ruler template. Flips true once the -// v2 facade publishes a pageLayout runtime and false on render-cleared / -// document switch. `proxy.$superdoc.activeEditor` itself is a plain property -// (no Vue tracking), so we shadow the readiness signal through a ref so the -// template re-evaluates on hydration. -const v2RulerReady = ref(false); - -const unwrapDocField = (value) => { - if (value && typeof value === 'object' && 'value' in value) return value.value; - return value; -}; - -const shouldShowV2Ruler = (doc) => { - if (!isV2Mode.value) return false; - if (!doc || doc.type !== DOCX) return false; - // `doc.rulers` is a Ref produced by `useDocument`; unwrap defensively in - // case the proxy access surface ever changes. - const rulersOn = Boolean(unwrapDocField(doc.rulers)); - if (!rulersOn) return false; - // Re-evaluate when v2RulerReady changes. - if (!v2RulerReady.value) return false; - const editor = proxy.$superdoc?.activeEditor; - if (!editor || editor.editorVersion !== 2) return false; - const docId = unwrapDocField(doc.id); - const activeDocumentId = editor.documentId ?? editor.options?.documentId ?? null; - if (docId && activeDocumentId && docId !== activeDocumentId) return false; - return Boolean(editor.pageMetrics && editor.pageLayout); -}; - -// ui-phase4-002: ruler container alignment. Mirrors v1 SuperEditor's -// `syncRulerOffset` but anchors to the v2 paint wrapper (`data-v2-paint- -// wrapper`) instead of `.presentation-editor__viewport` so the ruler stays -// aligned with the v2 page stack rather than a v1-specific class name. -const v2RulerHostStyle = ref({}); -let v2RulerEditorObserver = null; -let v2RulerContainerObserver = null; - -const resolveV2RulerContainer = () => { - const container = proxy.$superdoc?.config?.rulerContainer; - if (!container) return null; - if (typeof container === 'string') { - const doc = typeof document !== 'undefined' ? document : globalThis.document; - return doc?.querySelector(container) ?? null; - } - return typeof HTMLElement !== 'undefined' && container instanceof HTMLElement ? container : null; -}; - -const getV2PaintWrapperRect = () => { - const stages = Array.from(layers.value?.querySelectorAll('[data-editor-mount="v2"]') ?? []); - const activeDocumentId = - proxy.$superdoc?.activeEditor?.documentId ?? proxy.$superdoc?.activeEditor?.options?.documentId ?? null; - const stage = activeDocumentId - ? (stages.find((el) => el.dataset?.superdocV2DocumentId === String(activeDocumentId)) ?? null) - : (stages[0] ?? null); - if (!stage) return null; - const wrapper = stage.querySelector('[data-v2-paint-wrapper="true"]') ?? stage; - return wrapper.getBoundingClientRect(); -}; - -const syncV2RulerOffset = () => { - if (!isV2Mode.value) { - v2RulerHostStyle.value = {}; - return; - } - const host = resolveV2RulerContainer(); - if (!host) { - v2RulerHostStyle.value = {}; - return; - } - const wrapperRect = getV2PaintWrapperRect(); - if (!wrapperRect) { - v2RulerHostStyle.value = {}; - return; - } - const hostRect = host.getBoundingClientRect(); - const paddingLeft = Math.max(0, wrapperRect.left - hostRect.left); - const paddingRight = Math.max(0, hostRect.right - wrapperRect.right); - v2RulerHostStyle.value = { - paddingLeft: `${paddingLeft}px`, - paddingRight: `${paddingRight}px`, - }; -}; - -const cleanupV2RulerObservers = () => { - try { - v2RulerEditorObserver?.disconnect(); - } catch { - /* ignore */ - } - v2RulerEditorObserver = null; - try { - v2RulerContainerObserver?.disconnect(); - } catch { - /* ignore */ - } - v2RulerContainerObserver = null; -}; - -const setupV2RulerObservers = () => { - cleanupV2RulerObservers(); - if (typeof ResizeObserver === 'undefined') return; - const layersEl = layers.value; - const host = resolveV2RulerContainer(); - if (layersEl) { - v2RulerEditorObserver = new ResizeObserver(() => syncV2RulerOffset()); - v2RulerEditorObserver.observe(layersEl); - } - if (host) { - v2RulerContainerObserver = new ResizeObserver(() => syncV2RulerOffset()); - v2RulerContainerObserver.observe(host); - } -}; - -// ui-phase4-002: handle margin change events from V2Ruler. Mirrors the v1 -// `handleSuperEditorPageMarginsChange` path so existing consumers reading -// `doc.documentMarginsLastChange` keep working without per-version branching. -const handleV2PageMarginsChange = (doc, event) => { - if (!doc || !event) return; - doc.documentMarginsLastChange = event.pageMargins ?? null; - // Emit a `page-margins-change` event so external listeners can react. The - // payload includes the section that was edited for v2 multi-section - // discoverability. - proxy.$superdoc.emit('page-margins-change', { - documentId: doc.id, - editorVersion: 2, - sectionId: event.sectionId, - sectionIndex: event.sectionIndex, - side: event.side, - value: event.value, - pageMargins: event.pageMargins, - }); -}; - const handlePdfClick = (e) => { if (!isCommentsEnabled.value) return; resetSelection(); @@ -2670,23 +1805,8 @@ watch( () => activeZoom.value, (zoom) => { const zoomFactor = (zoom ?? 100) / 100; - const zoomPercent = zoom ?? 100; - - // ui-phase4-001: route DOCX zoom through the v2 page metrics runtime in - // v2 mode. The v1 PresentationEditor path is skipped entirely for v2 - // DOCX documents — PresentationEditor is not constructed and calling - // setGlobalZoom would no-op. PDF and HTML viewers stay on their - // existing paths (still set below). - const v2PageMetrics = proxy.$superdoc?.activeEditor?.pageMetrics ?? null; - const v2FacadeActive = isV2Mode.value && proxy.$superdoc?.activeEditor?.editorVersion === 2; - if (v2FacadeActive && v2PageMetrics?.setZoom) { - try { - v2PageMetrics.setZoom(zoomPercent); - } catch (err) { - console.warn('[SuperDoc][v2] setZoom failed', err); - } - } else if (!isV2Mode.value && proxy.$superdoc.config.useLayoutEngine !== false) { + if (proxy.$superdoc.config.useLayoutEngine !== false) { PresentationEditor.setGlobalZoom(zoomFactor); } else { initialFallbackZoomApplied = true; @@ -2750,15 +1870,13 @@ const getPDFViewer = () => { >
-
{
{ >
- { { { { /> { @pageMarginsChange="handleSuperEditorPageMarginsChange(doc, $event)" /> - - - - - - {
- -
+
({ ok: true, success: true })), - replyCommentV2: vi.fn(async () => ({ ok: true })), - editCommentV2: vi.fn(async () => ({ ok: true })), - resolveCommentV2: vi.fn(async () => ({ ok: true })), - reconcileCommentsFromV2: vi.fn(), getCommentDocumentId: vi.fn( (comment) => comment?.fileId ?? comment?.documentId ?? comment?.selection?.documentId ?? null, ), @@ -193,7 +189,6 @@ const mountDialog = async ({ floatingCommentsOffset: ref(0), pendingComment: ref(null), currentCommentText: ref('

Pending

'), - isDebugging: ref(false), editingCommentId: ref(null), editorCommentPositions: ref({}), hasSyncedCollaborationComments: ref(false), @@ -524,37 +519,6 @@ describe('CommentDialog.vue', () => { }); }); - it('in v2 mode keeps active UI state unchanged when focusComment fails', async () => { - const focusComment = vi.fn(async () => ({ ok: false, reason: 'comment-anchor-not-found' })); - const { wrapper, baseComment } = await mountDialog({ - props: { - autoFocus: false, - floatingInstanceId: 'comment-1::page:0', - }, - commentsStoreOverrides: { - activeComment: ref('existing-comment'), - activeFloatingCommentInstanceId: ref('existing-instance'), - }, - superdocOverrides: { - activeEditor: { - editorVersion: 2, - v2Comments: { focusComment }, - commands: null, - }, - }, - }); - - await wrapper.trigger('click'); - await nextTick(); - - expect(focusComment).toHaveBeenCalledWith(baseComment); - expect(commentsStoreStub.activeComment.value).toBe('existing-comment'); - expect(commentsStoreStub.activeFloatingCommentInstanceId.value).toBe('existing-instance'); - expect(commentsStoreStub.requestInstantSidebarAlignment).not.toHaveBeenCalled(); - expect(commentsStoreStub.setActiveFloatingCommentInstance).not.toHaveBeenCalled(); - expect(commentsStoreStub.clearInstantSidebarAlignment).toHaveBeenCalled(); - }); - it('prefers the actual visible highlight top after the scroll attempt', async () => { const presentation = { getReachableThreadAnchorClientY: vi.fn().mockReturnValue(274), @@ -944,50 +908,6 @@ describe('CommentDialog.vue', () => { expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, null); }); - it('in v2 mode calls custom tracked-change accept handler only after v2 decision succeeds', async () => { - let resolveDecision; - const decision = new Promise((resolve) => { - resolveDecision = resolve; - }); - const customAcceptHandler = vi.fn(); - - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - trackedChange: true, - trackedChangeType: 'insert', - trackedChangeText: 'Added', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar: vi.fn(() => decision), - }, - superdocOverrides: { - activeEditor: { - editorVersion: 2, - v2TrackedChanges: { - getCapabilityState: vi.fn(() => ({ canDecide: true, reason: null })), - }, - commands: null, - }, - }, - }); - superdocStub.config.onTrackedChangeBubbleAccept = customAcceptHandler; - - wrapper.findComponent(CommentHeaderStub).vm.$emit('resolve'); - await Promise.resolve(); - expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( - expect.objectContaining({ comment: baseComment, decision: 'accept' }), - ); - expect(customAcceptHandler).not.toHaveBeenCalled(); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - - resolveDecision({ ok: true, success: true }); - await Promise.resolve(); - await nextTick(); - - expect(customAcceptHandler).toHaveBeenCalledWith(baseComment, superdocStub.activeEditor); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - }); - it('calls custom reject handler instead of default behavior when configured', async () => { const customRejectHandler = vi.fn(); @@ -1739,523 +1659,6 @@ describe('CommentDialog.vue', () => { expect(commentsStoreStub.cancelComment).toHaveBeenCalledWith(superdocStub); }); - // TCS Phase 0 / 004: dialog-level integration tests for v2 reply / edit / - // resolve / delete. The dialog must call the store-owned helpers and must - // not call v1 `activeEditor.commands` for shipped comment mutations. - describe('TCS Phase 0 / 004 v2 comment dialog operations', () => { - const v2Editor = (overrides = {}) => ({ - editorVersion: 2, - v2Comments: { - focusComment: vi.fn(async () => ({ ok: true })), - getCapabilityState: vi.fn(() => ({ canWrite: true, reason: null })), - ...overrides.v2Comments, - }, - commands: null, - ...overrides, - }); - - it('reply submits via replyCommentV2 and clears reply state on success', async () => { - const replyCommentV2 = vi.fn(async () => ({ ok: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - replyCommentV2, - activeComment: ref('comment-1'), - currentCommentText: ref('

Reply text

'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - await wrapper.find('.reply-pill').trigger('click'); - await nextTick(); - - const replyButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Reply'); - await replyButton.trigger('click'); - await nextTick(); - await nextTick(); - - expect(replyCommentV2).toHaveBeenCalledWith({ - superdoc: superdocStub, - parentCommentId: baseComment.commentId, - text: '

Reply text

', - }); - // addComment must NOT be called in v2 reply path. - expect(commentsStoreStub.addComment).not.toHaveBeenCalled(); - // v1 setCursorById etc. must not have run since commands === null. - expect(commentsStoreStub.currentCommentText.value).toBe(''); - }); - - it('rejected v2 reply keeps reply editor open and preserves typed text', async () => { - const replyCommentV2 = vi.fn(async () => ({ ok: false, reason: 'author-required' })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - replyCommentV2, - activeComment: ref('comment-1'), - currentCommentText: ref('

Pending reply

'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - await wrapper.find('.reply-pill').trigger('click'); - await nextTick(); - - const replyButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Reply'); - await replyButton.trigger('click'); - await nextTick(); - await nextTick(); - - expect(replyCommentV2).toHaveBeenCalledWith({ - superdoc: superdocStub, - parentCommentId: baseComment.commentId, - text: '

Pending reply

', - }); - // Reply pill should NOT have replaced the expanded editor — i.e. - // isReplying remains true and the typed text was not wiped. - expect(wrapper.find('.reply-pill').exists()).toBe(false); - expect(commentsStoreStub.currentCommentText.value).toBe('

Pending reply

'); - }); - - it('edit submits via editCommentV2 and clears edit state on success', async () => { - const editCommentV2 = vi.fn(async () => ({ ok: true })); - const { wrapper, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - editCommentV2, - editingCommentId: ref('comment-1'), - activeComment: ref('comment-1'), - currentCommentText: ref('

edited

'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - await nextTick(); - const updateButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Update'); - await updateButton.trigger('click'); - await nextTick(); - - expect(editCommentV2).toHaveBeenCalledWith({ - superdoc: superdocStub, - commentId: 'comment-1', - text: '

edited

', - }); - expect(commentsStoreStub.editingCommentId.value).toBeNull(); - }); - - it('rejected v2 edit keeps editingCommentId active and preserves input text', async () => { - const editCommentV2 = vi.fn(async () => ({ ok: false, reason: 'author-required' })); - const { wrapper } = await mountDialog({ - commentsStoreOverrides: { - editCommentV2, - editingCommentId: ref('comment-1'), - activeComment: ref('comment-1'), - currentCommentText: ref('

edited

'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - await nextTick(); - const updateButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Update'); - await updateButton.trigger('click'); - await nextTick(); - - expect(commentsStoreStub.editingCommentId.value).toBe('comment-1'); - expect(commentsStoreStub.currentCommentText.value).toBe('

edited

'); - }); - - it('resolve dispatches via resolveCommentV2 and does not call v1 resolveComment', async () => { - const resolveCommentV2 = vi.fn(async () => ({ ok: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - resolveCommentV2, - activeComment: ref('comment-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('resolve'); - await nextTick(); - await nextTick(); - - expect(resolveCommentV2).toHaveBeenCalledWith({ - superdoc: superdocStub, - commentId: baseComment.commentId, - }); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - }); - - it('rejected v2 resolve leaves active state untouched', async () => { - const resolveCommentV2 = vi.fn(async () => ({ ok: false, reason: 'author-required' })); - const { wrapper, baseComment } = await mountDialog({ - commentsStoreOverrides: { - resolveCommentV2, - activeComment: ref('comment-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('resolve'); - await nextTick(); - await nextTick(); - - expect(commentsStoreStub.activeComment.value).toBe('comment-1'); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - }); - - it('reject (non-tracked-change) routes through deleteComment in v2 mode', async () => { - const deleteComment = vi.fn(async () => ({ ok: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - deleteComment, - activeComment: ref('comment-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('reject'); - await nextTick(); - await nextTick(); - - expect(deleteComment).toHaveBeenCalledWith({ - superdoc: superdocStub, - commentId: baseComment.commentId, - }); - }); - - it('overflow-menu Delete uses the same store deleteComment path', async () => { - const deleteComment = vi.fn(async () => ({ ok: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - commentsStoreOverrides: { - deleteComment, - activeComment: ref('comment-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('overflow-select', 'delete'); - await nextTick(); - - expect(deleteComment).toHaveBeenCalledWith({ - superdoc: superdocStub, - commentId: baseComment.commentId, - }); - }); - - it('Reply button is disabled when v2 reports canWrite === false', async () => { - const replyCommentV2 = vi.fn(async () => ({ ok: true })); - const { wrapper } = await mountDialog({ - commentsStoreOverrides: { - replyCommentV2, - activeComment: ref('comment-1'), - currentCommentText: ref('

typed

'), - }, - superdocOverrides: { - activeEditor: v2Editor({ - v2Comments: { - focusComment: vi.fn(async () => ({ ok: true })), - getCapabilityState: vi.fn(() => ({ canWrite: false, reason: 'author-required' })), - }, - }), - }, - }); - - await wrapper.find('.reply-pill').trigger('click'); - await nextTick(); - const replyButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Reply'); - expect(replyButton.attributes('disabled')).toBeDefined(); - expect(replyButton.attributes('data-disabled-reason')).toBe('author-required'); - }); - - it('Update button is disabled when v2 reports canWrite === false', async () => { - const editCommentV2 = vi.fn(async () => ({ ok: true })); - const { wrapper } = await mountDialog({ - commentsStoreOverrides: { - editCommentV2, - editingCommentId: ref('comment-1'), - activeComment: ref('comment-1'), - currentCommentText: ref('

edited

'), - }, - superdocOverrides: { - activeEditor: v2Editor({ - v2Comments: { - focusComment: vi.fn(async () => ({ ok: true })), - getCapabilityState: vi.fn(() => ({ canWrite: false, reason: 'author-required' })), - }, - }), - }, - }); - - await nextTick(); - const updateButton = wrapper.findAll('button.reply-btn-primary').find((b) => b.text() === 'Update'); - expect(updateButton.attributes('disabled')).toBeDefined(); - expect(updateButton.attributes('data-disabled-reason')).toBe('author-required'); - }); - - it('internal/external dropdown is hidden in v2 mode', async () => { - const { wrapper } = await mountDialog({ - commentsStoreOverrides: { - activeComment: ref('comment-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - expect(wrapper.findComponent(InternalDropdownStub).exists()).toBe(false); - }); - }); - - // TCS Phase 0 / 005: dialog-level integration tests for v2 tracked-change - // accept / reject. The dialog must route accept (resolve button) and - // reject through `decideTrackedChangeFromSidebar` in v2 mode and must - // not call v1 `acceptTrackedChangeById` / `rejectTrackedChangeById` - // commands. Failed outcomes must preserve active state and must NOT - // call any custom accept/reject callback. - describe('TCS Phase 0 / 005 v2 tracked-change dialog operations', () => { - const v2Editor = (overrides = {}) => ({ - editorVersion: 2, - v2Comments: { - focusComment: vi.fn(async () => ({ ok: true })), - getCapabilityState: vi.fn(() => ({ canWrite: true, reason: null })), - }, - v2TrackedChanges: { - focusTrackedChange: vi.fn(async () => ({ ok: true })), - getCapabilityState: vi.fn(() => ({ canDecide: true, reason: null })), - ...overrides.v2TrackedChanges, - }, - commands: null, - ...overrides, - }); - - it('accept (resolve button) on a v2 tracked-change row dispatches through decideTrackedChangeFromSidebar and never calls v1 commands', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ ok: true, success: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'insert', - trackedChangeText: 'added', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('resolve'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(decideTrackedChangeFromSidebar).toHaveBeenCalledWith({ - superdoc: superdocStub, - comment: baseComment, - decision: 'accept', - }); - // v1 tracked-change commands are intentionally null in v2 mode; they - // must never be reached. (commands === null on the v2 facade.) - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - }); - - it('reject button on a v2 tracked-change row dispatches reject through decideTrackedChangeFromSidebar', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ ok: true, success: true })); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'delete', - deletedText: 'gone', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - - const header = wrapper.findComponent(CommentHeaderStub); - header.vm.$emit('reject'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(decideTrackedChangeFromSidebar).toHaveBeenCalledWith({ - superdoc: superdocStub, - comment: baseComment, - decision: 'reject', - }); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - }); - - it('failed v2 accept preserves active state and does NOT call the custom accept callback', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ - ok: false, - reason: 'review-target-invalidated', - })); - const customAcceptHandler = vi.fn(); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'insert', - trackedChangeText: 'added', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - superdocStub.config.onTrackedChangeBubbleAccept = customAcceptHandler; - - wrapper.findComponent(CommentHeaderStub).vm.$emit('resolve'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(decideTrackedChangeFromSidebar).toHaveBeenCalled(); - // Failed dispatch must NOT invoke custom callbacks or v1 fallbacks. - expect(customAcceptHandler).not.toHaveBeenCalled(); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - // Active state preserved so the user can retry. - expect(commentsStoreStub.activeComment.value).toBe('tc-1'); - }); - - it('failed v2 reject preserves active state and does NOT call the custom reject callback', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ - ok: false, - reason: 'receipt-failure', - })); - const customRejectHandler = vi.fn(); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'delete', - deletedText: 'gone', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - superdocStub.config.onTrackedChangeBubbleReject = customRejectHandler; - - wrapper.findComponent(CommentHeaderStub).vm.$emit('reject'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(decideTrackedChangeFromSidebar).toHaveBeenCalled(); - expect(customRejectHandler).not.toHaveBeenCalled(); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - expect(commentsStoreStub.activeComment.value).toBe('tc-1'); - }); - - it('committed v2 decision + failed post-list (relist-after-commit-failed) preserves the active row and skips callbacks', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ - ok: false, - committed: true, - reason: 'relist-after-commit-failed', - })); - const customAcceptHandler = vi.fn(); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'insert', - trackedChangeText: 'added', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - superdocStub.config.onTrackedChangeBubbleAccept = customAcceptHandler; - - wrapper.findComponent(CommentHeaderStub).vm.$emit('resolve'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(decideTrackedChangeFromSidebar).toHaveBeenCalled(); - // Plan §4.2: committed + failed-list is a failure outcome. The dialog - // must NOT treat it as success — no callback, no v1 resolveComment. - expect(customAcceptHandler).not.toHaveBeenCalled(); - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - expect(commentsStoreStub.activeComment.value).toBe('tc-1'); - }); - - it('successful v2 accept clears active state and invokes the custom accept callback after dispatch resolves', async () => { - const decideTrackedChangeFromSidebar = vi.fn(async () => ({ ok: true, success: true })); - const customAcceptHandler = vi.fn(); - const { wrapper, baseComment, superdocStub } = await mountDialog({ - baseCommentOverrides: { - commentId: 'tc-1', - trackedChange: true, - trackedChangeType: 'insert', - trackedChangeText: 'added', - trackedChangeAnchorKey: 'tc::body::tc-1', - }, - commentsStoreOverrides: { - decideTrackedChangeFromSidebar, - activeComment: ref('tc-1'), - }, - superdocOverrides: { - activeEditor: v2Editor(), - }, - }); - superdocStub.config.onTrackedChangeBubbleAccept = customAcceptHandler; - - wrapper.findComponent(CommentHeaderStub).vm.$emit('resolve'); - await Promise.resolve(); - await nextTick(); - await nextTick(); - - expect(customAcceptHandler).toHaveBeenCalledWith(baseComment, superdocStub.activeEditor); - // v1 resolveComment must not have been called even after success — - // v2 prunes the row through reconcile, not the v1 ghost-bubble path. - expect(baseComment.resolveComment).not.toHaveBeenCalled(); - expect(commentsStoreStub.activeComment.value).toBeNull(); - }); - }); - describe('readOnly mode', () => { it('hides the reply pill when readOnly is true', async () => { const { wrapper, baseComment } = await mountDialog(); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 0d9aced670..8dd18a6aa1 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -454,10 +454,6 @@ const isEditingAnyComment = computed(() => { const shouldShowInternalExternal = computed(() => { if (!proxy.$superdoc.config.isInternal) return false; - // ui-phase3-002: the v2 host does not model `isInternal` today, so the - // dropdown would only mutate Vue state without surviving save/reopen. - // Hide it in v2 mode until a v2 internal/external surface ships. - if (isV2Mode.value) return false; return !suppressInternalExternal.value && !props.comment.trackedChange; }); @@ -467,7 +463,6 @@ const hasTextContent = computed(() => { const setFocus = async () => { const editor = proxy.$superdoc.activeEditor; - const v2Adapter = editor?.editorVersion === 2 ? (editor?.v2Comments ?? null) : null; const isTrackedChange = Boolean(props.comment?.trackedChange); const targetClientY = getPreferredCommentFocusTargetClientY(); const isInstanceScopedDialog = props.floatingInstanceId != null; @@ -477,36 +472,6 @@ const setFocus = async () => { (isInstanceScopedDialog && currentFloatingInstanceId.value !== activeFloatingCommentInstanceId.value)); let instantAlignmentTargetY = targetClientY; - // ui-phase3-002: in v2 mode, all setFocus work routes through the v2 - // comments adapter. Do not touch PresentationEditor, setCursorById, or v1 - // setActiveComment — those surfaces do not exist in v2. - if (v2Adapter) { - // ui-phase3-003: tracked-change rows route through the v2 tracked-change - // adapter so the painted [data-track-change-id] carriers are the focus - // target (not the comment-anchor carriers, which tracked changes lack). - const trackedChangeAdapter = - editor?.editorVersion === 2 && isTrackedChange ? (editor?.v2TrackedChanges ?? null) : null; - const result = await (trackedChangeAdapter - ? trackedChangeAdapter.focusTrackedChange(props.comment) - : v2Adapter.focusComment(props.comment)); - if (!result?.ok) { - clearInstantSidebarAlignment(); - return result; - } - if (!props.comment.resolvedTime) { - activeComment.value = props.comment.commentId; - if (props.floatingInstanceId) { - setActiveFloatingCommentInstance(props.floatingInstanceId); - } - } - if (willChangeActiveDialog) { - requestInstantSidebarAlignment(targetClientY, props.comment.commentId, props.floatingInstanceId ?? null); - } else { - clearInstantSidebarAlignment(); - } - return result; - } - // Move cursor to the comment location and set active comment in a single PM // transaction. This prevents a race where position-based comment detection in the // plugin clears the activeThreadId before the setActiveComment meta is processed. @@ -639,26 +604,6 @@ const handleClickOutside = (e) => { }; const handleAddComment = async () => { - // TCS Phase 0 / 004 §4.1: in v2 mode route an existing-thread reply through - // the store-owned `replyCommentV2` helper. Pending (new-comment) create is - // deferred to a later phase and still uses the v1 `addComment` path. - if (isV2Mode.value && v2CommentsAdapter.value && !pendingComment.value) { - const outcome = await commentsStore.replyCommentV2({ - superdoc: proxy.$superdoc, - parentCommentId: props.comment.commentId, - text: currentCommentText.value, - }); - if (!outcome?.ok) { - // Plan §4.1: keep reply editor open and typed text intact for retry. - nextTick(() => emit('resize')); - return outcome; - } - isReplying.value = false; - currentCommentText.value = ''; - nextTick(() => emit('resize')); - return outcome; - } - const options = { documentId: props.comment.fileId, isInternal: pendingComment.value ? pendingComment.value.isInternal : isInternal.value, @@ -671,10 +616,6 @@ const handleAddComment = async () => { } const comment = commentsStore.getPendingComment(options); - // ui-phase3-002: pre-populate the new comment's text from the current - // input so the v2 path can read the value off the comment model itself - // (the v2 dispatch happens before Vue state mutation, so the store cannot - // rely on commentText being attached later). if (!pendingComment.value && currentCommentText.value) { comment.setText({ text: currentCommentText.value, suppressUpdate: true }); } @@ -684,94 +625,13 @@ const handleAddComment = async () => { }); }; -const isV2Mode = computed(() => proxy.$superdoc?.activeEditor?.editorVersion === 2); -const v2CommentsAdapter = computed(() => (isV2Mode.value ? (proxy.$superdoc?.activeEditor?.v2Comments ?? null) : null)); -// ui-phase3-003: v2 tracked-change adapter accessor. Mutation-plane -// consolidation: accept/reject route through the adapter's compatibility -// wrappers, which delegate to `activeEditor.doc.trackChanges.decide(...)`. -const v2TrackedChangesAdapter = computed(() => - isV2Mode.value ? (proxy.$superdoc?.activeEditor?.v2TrackedChanges ?? null) : null, -); -const v2WriteCapability = computed(() => { - if (!v2CommentsAdapter.value) return null; - return v2CommentsAdapter.value.getCapabilityState?.() ?? null; -}); -const isV2WriteDisabled = computed(() => { - if (!isV2Mode.value) return false; - const cap = v2WriteCapability.value; - if (!cap) return false; - return cap.canWrite === false; -}); -const v2TrackedChangeCapability = computed(() => { - if (!v2TrackedChangesAdapter.value) return null; - return v2TrackedChangesAdapter.value.getCapabilityState?.() ?? null; -}); - -const getResolveDisabledReason = (comment) => { - if (isV2Mode.value && comment?.trackedChange) { - // Tracked-change accept (resolve) routes through the v2 adapter. Disable - // the control only when the v2 host reports a real precondition. - if (!v2TrackedChangesAdapter.value) return 'v2-tracked-change-adapter-missing'; - const cap = v2TrackedChangeCapability.value; - if (cap && cap.canDecide === false) return cap.reason ?? 'v2-tracked-change-unavailable'; - return null; - } - if (isV2WriteDisabled.value) { - return v2WriteCapability.value?.reason ?? 'v2-write-unavailable'; - } - return null; -}; -const getRejectDisabledReason = (comment) => { - if (isV2Mode.value && comment?.trackedChange) { - if (!v2TrackedChangesAdapter.value) return 'v2-tracked-change-adapter-missing'; - const cap = v2TrackedChangeCapability.value; - if (cap && cap.canDecide === false) return cap.reason ?? 'v2-tracked-change-unavailable'; - return null; - } - if (isV2WriteDisabled.value) { - return v2WriteCapability.value?.reason ?? 'v2-write-unavailable'; - } - return null; -}; - -// TCS Phase 0 / 004 §5: when v2 reports canWrite === false (e.g. -// `author-required`, host not ready), overflow-menu Edit / Delete must hide -// or disable. We surface a stable reason through CommentHeader so the menu -// can filter both options consistently with the visible resolve/reject -// disabled treatment. -const v2OverflowWriteDisabledReason = computed(() => { - if (!isV2Mode.value) return null; - if (!isV2WriteDisabled.value) return null; - return v2WriteCapability.value?.reason ?? 'v2-write-unavailable'; -}); +const getResolveDisabledReason = () => null; +const getRejectDisabledReason = () => null; const handleReject = async () => { const customHandler = proxy.$superdoc.config.onTrackedChangeBubbleReject; if (props.comment.trackedChange) { - if (isV2Mode.value) { - // ui-phase3-003: route tracked-change reject through the store-owned - // v2 adapter path. Vue state and customer callbacks run only after the - // v2 receipt commits and the store reconciles from trackChanges.list(). - // Vue state is NOT mutated until the dispatch commits and the store - // bubble handlers receive the v2 facade explicitly after success. - const outcome = await commentsStore.decideTrackedChangeFromSidebar({ - superdoc: proxy.$superdoc, - comment: props.comment, - decision: 'reject', - }); - if (!outcome?.ok || outcome.success === false) return; - if (typeof customHandler === 'function') { - customHandler(props.comment, proxy.$superdoc.activeEditor); - } - nextTick(() => { - commentsStore.lastUpdate = new Date(); - activeComment.value = null; - commentsStore.setActiveComment(proxy.$superdoc, activeComment.value); - }); - return; - } - // Custom handlers always resolve so the bubble disappears from // getFloatingComments (SD-2049). The internal decision path only resolves // when the decision actually applied; otherwise the tracked marks are @@ -797,14 +657,6 @@ const handleReject = async () => { decision: 'reject', }); } - } else if (isV2Mode.value && v2CommentsAdapter.value) { - // ui-phase3-002: route delete through the v2 host. Vue state mutates - // only after the receipt commits and we refresh from the v2 list. - const outcome = await commentsStore.deleteComment({ - superdoc: proxy.$superdoc, - commentId: props.comment.commentId, - }); - if (!outcome?.ok) return; } else { commentsStore.deleteComment({ superdoc: proxy.$superdoc, commentId: props.comment.commentId }); } @@ -821,27 +673,6 @@ const handleReject = async () => { const handleResolve = async () => { const customHandler = proxy.$superdoc.config.onTrackedChangeBubbleAccept; - // ui-phase3-003: route tracked-change accept through the v2 adapter when - // v2 mode is active. Vue state mutates only after the dispatch commits and - // the store reconciles from `host.getHandles().trackChanges.list()`. - if (props.comment.trackedChange && isV2Mode.value) { - const outcome = await commentsStore.decideTrackedChangeFromSidebar({ - superdoc: proxy.$superdoc, - comment: props.comment, - decision: 'accept', - }); - if (!outcome?.ok || outcome.success === false) return; - if (typeof customHandler === 'function') { - customHandler(props.comment, proxy.$superdoc.activeEditor); - } - nextTick(() => { - commentsStore.lastUpdate = new Date(); - activeComment.value = null; - commentsStore.setActiveComment(proxy.$superdoc, activeComment.value); - }); - return; - } - let v1TrackedChangeDecisionApplied = true; if (props.comment.trackedChange && typeof customHandler === 'function') { // Custom handlers always resolve so the bubble disappears from @@ -854,17 +685,6 @@ const handleResolve = async () => { decision: 'accept', }); v1TrackedChangeDecisionApplied = Boolean(outcome?.ok) && outcome.success !== false; - } else if (isV2Mode.value && v2CommentsAdapter.value) { - // TCS Phase 0 / 004 §4.3: route resolve through the store-owned - // `resolveCommentV2` helper. The store owns capability gating, adapter - // identity stamping, reconciliation, the active-row clearing decision, - // and the rejected `comments-update` event. Plan §4.3: leave the row - // active on rejection so the user can retry. - const outcome = await commentsStore.resolveCommentV2({ - superdoc: proxy.$superdoc, - commentId: props.comment.commentId, - }); - if (!outcome?.ok) return; } else { props.comment.resolveComment({ id: superdocStore.user.id, @@ -876,7 +696,7 @@ const handleResolve = async () => { // For v1 tracked changes we still need to resolve the local Vue model so the // bubble disappears from getFloatingComments after the document decision works. - if (props.comment.trackedChange && !isV2Mode.value && v1TrackedChangeDecisionApplied) { + if (props.comment.trackedChange && v1TrackedChangeDecisionApplied) { props.comment.resolveComment({ id: superdocStore.user.id, email: superdocStore.user.email, @@ -916,22 +736,6 @@ const handleOverflowSelect = (value, comment) => { }; const handleCommentUpdate = async (comment) => { - // TCS Phase 0 / 004 §4.2: in v2 mode route edit through the store-owned - // `editCommentV2` helper. The store owns capability gating, adapter - // identity stamping, reconciliation, and emits the rejected event when the - // dispatch / refresh fails. The dialog only owns `editingCommentId` and - // text input; we keep both intact on rejection so the user can retry. - if (isV2Mode.value && v2CommentsAdapter.value) { - const outcome = await commentsStore.editCommentV2({ - superdoc: proxy.$superdoc, - commentId: comment.commentId, - text: currentCommentText.value, - }); - if (!outcome?.ok) return outcome; - editingCommentId.value = null; - removePendingComment(proxy.$superdoc); - return outcome; - } editingCommentId.value = null; comment.setText({ text: currentCommentText.value, superdoc: proxy.$superdoc }); removePendingComment(proxy.$superdoc); @@ -1097,7 +901,6 @@ watch(editingCommentId, (commentId) => { :is-active="isDialogActive" :resolve-disabled-reason="getResolveDisabledReason(comment)" :reject-disabled-reason="getRejectDisabledReason(comment)" - :write-disabled-reason="v2OverflowWriteDisabledReason" @resolve="handleResolve" @reject="handleReject" @overflow-select="handleOverflowSelect($event, comment)" @@ -1190,9 +993,8 @@ watch(editingCommentId, (commentId) => { @@ -1251,9 +1053,8 @@ watch(editingCommentId, (commentId) => { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index 14e08bda75..a99340aea3 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -33,10 +33,8 @@ const props = defineProps({ type: Boolean, default: false, }, - // ui-phase3-002: stable reason for disabling resolve / reject. When set, - // the buttons render in a disabled state and emit nothing. Used by v2 mode - // to keep tracked-change accept/reject controls visible-but-disabled until - // Phase 3 / 003 wires the tracked-change adapter. + // Stable reason for disabling resolve / reject. When set, the buttons render + // in a disabled state and emit nothing. resolveDisabledReason: { type: String, default: null, @@ -45,14 +43,6 @@ const props = defineProps({ type: String, default: null, }, - // TCS Phase 0 / 004 §5: stable reason for disabling overflow Edit / Delete - // when the v2 host reports `canWrite === false` (e.g. author-required, - // host not ready). Both options are filtered out of the overflow menu so - // the user cannot trigger a mutation that the host will reject. - writeDisabledReason: { - type: String, - default: null, - }, }); const { proxy } = getCurrentInstance(); @@ -145,11 +135,6 @@ const allowOverflow = computed(() => { const getOverflowOptions = computed(() => { if (!generallyAllowed.value) return false; - // TCS Phase 0 / 004 §5: when the v2 host blocks write (e.g. author-required, - // host not ready), hide overflow Edit and Delete so the user cannot trigger - // a mutation the host would immediately reject. - if (props.writeDisabledReason) return []; - const allowedOptions = []; const options = new Set(); diff --git a/packages/superdoc/src/composables/use-document.js b/packages/superdoc/src/composables/use-document.js index e35aa11127..01a0a5c45b 100644 --- a/packages/superdoc/src/composables/use-document.js +++ b/packages/superdoc/src/composables/use-document.js @@ -29,7 +29,6 @@ export default function useDocument(params, superdocConfig) { const provider = shallowRef(params.provider); const socket = shallowRef(params.socket); const isNewFile = ref(params.isNewFile); - const v2Collaboration = params.v2Collaboration || null; // For docx const editorRef = shallowRef(null); @@ -114,7 +113,6 @@ export default function useDocument(params, superdocConfig) { provider, socket, isNewFile, - v2Collaboration, // Placement container, diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 3b6f46e69d..f44db7bc66 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -442,7 +442,7 @@ describe('SuperDoc core', () => { expect(instance.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null })); }); - it('keeps legacy default-user behavior for an explicitly empty v1 user name', async () => { + it('keeps legacy default-user behavior for an explicitly empty user name', async () => { createAppHarness(); const instance = new SuperDoc({ @@ -450,7 +450,6 @@ describe('SuperDoc core', () => { document: 'https://example.com/doc.docx', documents: [], modules: { comments: {}, toolbar: {} }, - editorVersion: 1, user: { name: '', email: null }, }); @@ -2849,10 +2848,9 @@ describe('SuperDoc core', () => { expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.25); }); - it('activeZoom watcher routes to PresentationEditor in v1 mode and does not invoke v2 setZoom', async () => { + it('activeZoom watcher routes to PresentationEditor', async () => { const { superdocStore } = createAppHarness(); const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() }; - const v2SetZoom = vi.fn(); superdocStore.documents = [ { @@ -2863,23 +2861,16 @@ describe('SuperDoc core', () => { ]; let activeZoom = 100; - const v2FacadeActive = false; - const v2PageMetrics = { setZoom: v2SetZoom }; Object.defineProperty(superdocStore, 'activeZoom', { configurable: true, get: () => activeZoom, set: (value) => { activeZoom = value; - const zoomPercent = value ?? 100; - if (v2FacadeActive && v2PageMetrics?.setZoom) { - v2PageMetrics.setZoom(zoomPercent); - } else { - const zoomMultiplier = (value ?? 100) / 100; - superdocStore.documents.forEach((doc) => { - const presentationEditor = doc.getPresentationEditor?.(); - presentationEditor?.setZoom?.(zoomMultiplier); - }); - } + const zoomMultiplier = (value ?? 100) / 100; + superdocStore.documents.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setZoom?.(zoomMultiplier); + }); }, }); @@ -2892,7 +2883,6 @@ describe('SuperDoc core', () => { instance.setZoom(125); expect(mockPresentationEditor.setZoom).toHaveBeenCalledTimes(1); expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.25); - expect(v2SetZoom).not.toHaveBeenCalled(); }); it('setZoom warns and returns early for invalid values', async () => { @@ -3435,57 +3425,6 @@ describe('SuperDoc core', () => { }); }); - // ui-phase2-001: public `editorVersion` flag — config normalization + - // surfacing on the SuperDoc instance. - describe('editorVersion', () => { - it('defaults editorVersion to 1 when omitted', async () => { - createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - document: 'https://example.com/doc.docx', - }); - await flushMicrotasks(); - expect(instance.editorVersion).toBe(1); - expect(instance.config.editorVersion).toBe(1); - expect(instance.v2).toBeNull(); - }); - - it('accepts editorVersion: 1 explicitly', async () => { - createAppHarness(); - const instance = new SuperDoc({ - selector: '#host', - document: 'https://example.com/doc.docx', - editorVersion: 1, - }); - await flushMicrotasks(); - expect(instance.editorVersion).toBe(1); - expect(instance.config.editorVersion).toBe(1); - expect(instance.v2).toBeNull(); - }); - - it('coerces invalid editorVersion values back to 1 with a console warning', async () => { - createAppHarness(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - for (const bad of ['2', true, 0, 3, null, {}, []]) { - const instance = new SuperDoc({ - selector: '#host', - document: 'https://example.com/doc.docx', - // @ts-expect-error — testing runtime coercion of invalid input - editorVersion: bad, - }); - await flushMicrotasks(); - expect(instance.editorVersion).toBe(1); - expect(instance.config.editorVersion).toBe(1); - expect(instance.v2).toBeNull(); - } - expect(warnSpy).toHaveBeenCalled(); - } finally { - warnSpy.mockRestore(); - } - }); - }); - // --------------------------------------------------------------------------- // SD-2916 PR-B: lifecycle guards on ready-required methods // --------------------------------------------------------------------------- @@ -3603,7 +3542,6 @@ describe('SuperDoc core', () => { const handlers = new Map(); return { options: { documentId }, - editorVersion: 1, state: { doc: { textBetween: () => selectionText }, selection: { from: 0, to: selectionText.length, empty: selectionText.length === 0 }, @@ -3819,8 +3757,8 @@ describe('SuperDoc core', () => { }); // The invariant SuperDoc owns: `activeEditor` is the active runtime's - // SUPPORTED v1 projection, or null when the active runtime has no supported - // legacy projection (cleared, or v2-shaped / command-incapable). + // command-capable projection, or null when the active runtime has no + // supported legacy projection. const expectActiveEditorInvariant = (instance) => { const runtime = instance.getActiveRuntime(); const projection = runtime?.getLegacyEditorProjection?.() ?? null; @@ -3828,7 +3766,6 @@ describe('SuperDoc core', () => { runtime?.kind === 'v1' && !!projection && typeof projection === 'object' && - projection.editorVersion !== 2 && !!projection.commands && typeof projection.commands === 'object'; expect(instance.activeEditor).toBe(supported ? projection : null); diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 31792cf9f0..36325d673a 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -116,46 +116,12 @@ import type * as Y from 'yjs'; import type { WhiteboardData } from './whiteboard/Whiteboard.js'; type V1ActiveEditor = Editor & { - editorVersion?: 1; toolbar?: SuperToolbar; presentationEditor?: PresentationEditor | null; }; -type V2ActiveEditorFacade = { - editorVersion: 2; - documentId?: string; - host?: unknown; - mount?: unknown; - options?: { - documentId?: string; - documentMode?: DocumentMode; - [key: string]: unknown; - }; - capabilities?: unknown; - save?: (...args: unknown[]) => Promise; - exportDocx?: (...args: unknown[]) => Promise; - focus?: () => unknown; - v2Comments?: unknown; - v2TrackedChanges?: unknown; - pageMetrics?: unknown; - pageLayout?: unknown; - pageFurniture?: unknown; - reviewHydration?: unknown; - commands?: null; - state?: null; - view?: null; - setHighContrastMode?: (isHighContrast: boolean) => void; - [key: string]: unknown; -}; - -type ActiveEditor = Editor | V2ActiveEditorFacade; - -function isV2ActiveEditorFacade(editor: unknown): editor is V2ActiveEditorFacade { - return Boolean(editor && typeof editor === 'object' && (editor as { editorVersion?: unknown }).editorVersion === 2); -} - -function getActivePresentationEditor(editor: ActiveEditor | null | undefined): PresentationEditor | null { - if (!editor || isV2ActiveEditorFacade(editor)) return null; +function getActivePresentationEditor(editor: Editor | null | undefined): PresentationEditor | null { + if (!editor) return null; return (editor as V1ActiveEditor).presentationEditor ?? null; } @@ -234,18 +200,17 @@ interface SuperDocEventMap { // current consumer-visible contract. /** - * Whether a runtime's legacy editor projection is a v1, command-capable surface + * Whether a runtime's legacy editor projection is a command-capable v1 surface * the legacy shell (toolbar, `search`, `goToSearchResult`, `activeEditor`) can - * drive. v2-shaped scaffolding (`editorVersion === 2`), null projections, and - * projections whose `commands` are not object-like are UNSUPPORTED and must - * fail closed so stale v1 surfaces never stay bound to the wrong editor. + * drive. Null projections and projections whose `commands` are not object-like + * are unsupported and must fail closed so stale v1 surfaces never stay bound to + * the wrong editor. * * @param projection The runtime's `getLegacyEditorProjection()` result. */ function isCommandCapableV1LegacyProjection(projection: unknown): projection is V1ActiveEditor { if (!projection || typeof projection !== 'object') return false; - const candidate = projection as { editorVersion?: number; commands?: unknown }; - if (candidate.editorVersion === 2) return false; + const candidate = projection as { commands?: unknown }; return Boolean(candidate.commands) && typeof candidate.commands === 'object'; } @@ -409,7 +374,6 @@ export class SuperDoc extends EventEmitter { // (called synchronously from the constructor), so by the time any // external callsite reads them they exist. declare activeEditor: Editor | null; - declare editorVersion: 1 | 2; declare toolbar: SuperToolbar | null; declare toolbarElement: string | HTMLElement | undefined; declare userColorMap: Map; @@ -548,10 +512,6 @@ export class SuperDoc extends EventEmitter { // Internal: toggle layout-engine-powered PresentationEditor in dev shells useLayoutEngine: true, - - // Existing v1 DOCX path unless the host explicitly opts into the injected - // v2 shell. - editorVersion: 1, }; constructor(config: Config) { super(); @@ -617,9 +577,6 @@ export class SuperDoc extends EventEmitter { this.config.layoutEngineOptions.flowMode = 'paginated'; } - this.editorVersion = this.#normalizeEditorVersion(this.config.editorVersion); - this.config.editorVersion = this.editorVersion; - const incomingUser = this.config.user; if (!incomingUser || typeof incomingUser !== 'object') { this.config.user = { ...DEFAULT_USER }; @@ -628,7 +585,7 @@ export class SuperDoc extends EventEmitter { ...DEFAULT_USER, ...incomingUser, }; - if (!this.config.user.name && this.editorVersion !== 2) { + if (!this.config.user.name) { this.config.user.name = DEFAULT_USER.name; } } @@ -1669,16 +1626,6 @@ export class SuperDoc extends EventEmitter { this.emit('sidebar-toggle', isOpened); } - #normalizeEditorVersion(raw: unknown): 1 | 2 { - if (raw === undefined || raw === null) return 1; - if (raw === 1) return 1; - if (raw === 2) return 2; - console.warn( - `[SuperDoc] Ignoring invalid editorVersion ${String(raw)}; falling back to v1 DOCX path. Supported values: 1 | 2.`, - ); - return 1; - } - /** @param args */ #log(...args: unknown[]) { (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); @@ -1754,7 +1701,7 @@ export class SuperDoc extends EventEmitter { /** * Clear the legacy `activeEditor` projection and detach the v1 toolbar. Used * when active state clears and when an active runtime is unsupported for the - * v1 shell path (v2-shaped, null, or command-incapable projection). + * v1 shell path (null or command-incapable projection). */ #clearActiveEditorProjection() { this.activeEditor = null; @@ -1801,9 +1748,8 @@ export class SuperDoc extends EventEmitter { if (runtime?.kind === 'v1' && isCommandCapableV1LegacyProjection(projection)) { this.setActiveEditor(projection); } else { - // Fail closed: a non-v1 runtime, or a runtime with a v2-shaped / null / - // command-incapable projection, must not leave a stale v1 editor or - // toolbar bound. + // Fail closed: unsupported runtimes and command-incapable projections + // must not leave a stale v1 editor or toolbar bound. this.#clearActiveEditorProjection(); } } finally { @@ -1819,7 +1765,7 @@ export class SuperDoc extends EventEmitter { * @param editor The legacy editor to resolve a runtime for. * @returns The owning runtime id, or `null` when none can be resolved. */ - #resolveRuntimeIdForEditor(editor: ActiveEditor | null): EditorRuntimeId | null { + #resolveRuntimeIdForEditor(editor: Editor | null): EditorRuntimeId | null { if (!editor) return null; for (const runtime of this.#editorRuntimeRegistry.getAll()) { if (runtime.kind !== 'v1') continue; @@ -1844,15 +1790,7 @@ export class SuperDoc extends EventEmitter { * * @param editor The editor to set as active */ - setActiveEditor(editor: Editor | null): void; - setActiveEditor(editor: ActiveEditor | null) { - if (isV2ActiveEditorFacade(editor)) { - if (!this.#applyingRuntimeActiveChange && this.#editorRuntimeRegistry.getActive()) { - this.#editorRuntimeRegistry.setActive(null, 'set-active-v2-facade'); - } - this.activeEditor = editor as unknown as Editor; - return; - } + setActiveEditor(editor: Editor | null): void { if (!isCommandCapableV1LegacyProjection(editor)) { this.#clearActiveEditorProjection(); return; @@ -1885,136 +1823,6 @@ export class SuperDoc extends EventEmitter { this.#applyActiveEditorProjection(editor); } - getV2FeatureMatrix() { - return [ - { - feature: 'docx.open-render', - status: 'supported', - reason: 'editorVersion: 2 opens and renders DOCX documents through the injected private V2 integration seam', - }, - { - feature: 'docx.review-handles', - status: 'supported', - reason: 'Comment/tracked-change list + decide via v2 host handles', - }, - { - feature: 'shell.toolbar', - status: 'disabled', - reason: 'v1 SuperToolbar requires v1 Editor; v2 toolbar bridge is a Phase 2.x follow-up', - }, - { - feature: 'shell.rich-formatting', - status: 'not-shipped', - reason: 'format-target-unsupported: v2 host has no format.* family yet', - }, - { - feature: 'shell.comments-sidebar', - status: 'supported', - reason: - 'ui-phase3-002: v2 comments adapter routes create/reply/edit/resolve/delete through V2EditorHost.dispatch', - }, - { - feature: 'shell.comments-sidebar.reopen', - status: 'not-shipped', - reason: 'comment-reopen-ui-omitted: public v2 shell keeps resolved-state reopen disabled', - }, - { - feature: 'shell.tracked-change-sidebar', - status: 'supported', - reason: 'ui-phase3-003: v2 tracked-change adapter lists/decides via V2EditorHost.dispatch (body-story scope)', - }, - { - feature: 'shell.tracked-change-sidebar.bulk', - status: 'not-shipped', - reason: - 'bulk-tracked-change-decisions-omitted: acceptAll/rejectAll are matrix-disabled by default in the v2 host', - }, - { - feature: 'shell.tracked-change-sidebar.non-body', - status: 'not-shipped', - reason: 'story-target-not-shipped: public v2 shell only hydrates body-story tracked changes', - }, - { - feature: 'shell.comments-sidebar.persistence', - status: 'supported', - reason: - 'ui-phase3-004: comment create/reply/edit/resolve/delete persist through SuperDoc.export() → re-mount via the v2 host save bridge', - }, - { - feature: 'shell.tracked-change-sidebar.persistence', - status: 'supported', - reason: - 'ui-phase3-004: tracked-change accept/reject persist through SuperDoc.export() → re-mount via the v2 host save bridge', - }, - { - feature: 'shell.comments-sidebar.author-required', - status: 'supported', - reason: - 'ui-phase3-004: v2 comments adapter surfaces commentCommandsReason=author-required from the host capability matrix; write controls disable and forced dispatch reports ok:false', - }, - { - feature: 'shell.find-replace', - status: 'disabled', - reason: 'find-replace-omitted: public v2 shell does not expose v2 find/replace yet', - }, - { - feature: 'shell.ai-writer', - status: 'disabled', - reason: 'ai-omitted: public v2 shell hides AI chrome until a v2 command bridge exists', - }, - { - feature: 'shell.collaboration', - status: 'not-shipped', - reason: 'Y.js / Hocuspocus bridge for v2 not yet wired', - }, - { - feature: 'shell.context-menu', - status: 'not-shipped', - reason: 'context-menu-omitted: public v2 shell disables v1 context menu in v2 mode', - }, - { - feature: 'shell.page-metrics', - status: 'supported', - reason: - 'ui-phase4-001: v2 page metrics snapshot ' + - '(editorVersion: 2, documentId, renderEpoch, layoutGeneration, zoom, pages[], capabilities) ' + - 'available via superdoc.activeEditor.pageMetrics.{ getSnapshot, subscribe, setZoom }', - }, - { - feature: 'shell.zoom', - status: 'supported', - reason: - 'ui-phase4-001: SuperDoc.setZoom routes to V2EditorHost.setZoom in v2 mode; ' + - 'single CSS-transform wrapper applies scale to the painted document, page metrics ' + - 'viewport coords scale with the same zoom value', - }, - { - feature: 'shell.ruler-page-margins', - status: 'supported', - reason: - 'ui-phase4-002: v2 ruler renders against the V2PageMetricsSnapshot and dispatches margin drags through ' + - 'a narrow v2 page-layout bridge (`activeEditor.pageLayout.setMargins(...)`) backed by ' + - '`doc.sections.setPageMargins(...)`. The v2 page metrics snapshot now reports ' + - '`capabilities.marginEdit = { supported: true }`', - }, - { - feature: 'shell.custom-extensions', - status: 'not-shipped', - reason: 'Customer ProseMirror extensions cannot register against the v2 host', - }, - { feature: 'pdf.viewer', status: 'supported', reason: 'editorVersion ignored for PDF documents' }, - { feature: 'html.viewer', status: 'supported', reason: 'editorVersion ignored for HTML documents' }, - ]; - } - - get v2() { - if (this.editorVersion !== 2) return null; - return { - version: 2, - featureMatrix: this.getV2FeatureMatrix(), - }; - } - /** * Register a mounted editor runtime with the shell-owned registry. * @@ -2552,8 +2360,7 @@ export class SuperDoc extends EventEmitter { * * @param text The text or regex to search for * @returns The search results, or `undefined` when there is no active editor - * or the active legacy projection exposes no `search` command (e.g. a - * v2-shaped runtime with `commands: null`). + * or the active projection exposes no `search` command. */ search(text: string | RegExp): SearchMatch[] | undefined { const commands = this.activeEditor?.commands; @@ -2571,9 +2378,8 @@ export class SuperDoc extends EventEmitter { * * @param match The match object returned by `superdoc.search()`. * @returns Whether the command dispatched, or `undefined` when there is no - * active editor or the active legacy projection exposes no - * `goToSearchResult` command (e.g. a v2-shaped runtime with `commands: - * null`). + * active editor or the active projection exposes no `goToSearchResult` + * command. */ goToSearchResult(match: SearchMatch) { const commands = this.activeEditor?.commands; diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index f32cd4d8e9..1a8578ed9d 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -360,31 +360,7 @@ describe('collaboration helpers', () => { expect(superdoc.commentsStore.hasSyncedCollaborationComments).toBe(true); }); - it('loadCommentsFromYdoc preserves local v2 tracked-change rows when room comments are empty', () => { - const trackedRow = { - commentId: 'tc-1', - trackedChange: true, - trackedChangeAnchorKey: 'tc::body::tc-1', - }; - superdoc.config.editorVersion = 2; - superdoc.activeEditor = { editorVersion: 2 }; - superdoc.provider.synced = true; - superdoc.commentsStore.commentsList = [ - trackedRow, - { commentId: 'local-comment', commentText: 'room comments own this row' }, - ]; - - const loaded = loadCommentsFromYdoc(superdoc); - - expect(loaded).toBe(true); - expect(useCommentMock).not.toHaveBeenCalled(); - expect(superdoc.commentsStore.commentsList).toEqual([trackedRow]); - expect(superdoc.commentsStore.hasSyncedCollaborationComments).toBe(true); - }); - - it('loadCommentsFromYdoc does not preserve tracked-change rows for v1 rooms', () => { - superdoc.config.editorVersion = 1; - superdoc.activeEditor = { editorVersion: 1 }; + it('loadCommentsFromYdoc replaces local tracked-change rows from the room payload', () => { superdoc.commentsStore.commentsList = [ { commentId: 'tc-1', diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js index 482607ba50..bdf416f8bc 100644 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ b/packages/superdoc/src/core/collaboration/helpers.js @@ -4,26 +4,6 @@ import { actorIdentitiesMatch } from '@superdoc/common'; import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; -const isV2ReviewRuntimeActive = (superdoc) => - superdoc?.activeEditor?.editorVersion === 2 || superdoc?.config?.editorVersion === 2; - -const commentKey = (comment) => { - const key = comment?.commentId ?? comment?.importedId; - return key == null ? null : String(key); -}; - -const preserveLocalV2TrackedChangeRows = (superdoc, incomingComments) => { - if (!isV2ReviewRuntimeActive(superdoc)) return []; - const currentRows = superdoc?.commentsStore?.commentsList; - if (!Array.isArray(currentRows) || !currentRows.length) return []; - const incomingKeys = new Set(incomingComments.map(commentKey).filter(Boolean)); - return currentRows.filter((row) => { - if (row?.trackedChange !== true) return false; - const key = commentKey(row); - return !key || !incomingKeys.has(key); - }); -}; - /** * Load comments from the ydoc into the comments store. * @@ -62,10 +42,7 @@ export const loadCommentsFromYdoc = (superdoc) => { } filtered.push(c); }); - superdoc.commentsStore.commentsList = [ - ...filtered.map((c) => useComment(c)), - ...preserveLocalV2TrackedChangeRows(superdoc, filtered), - ]; + superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); if (superdoc.provider?.synced) { superdoc.commentsStore.hasSyncedCollaborationComments = true; } diff --git a/packages/superdoc/src/core/editor-runtime/editor-runtime-registry.ts b/packages/superdoc/src/core/editor-runtime/editor-runtime-registry.ts index d34d0749bd..dc95a87001 100644 --- a/packages/superdoc/src/core/editor-runtime/editor-runtime-registry.ts +++ b/packages/superdoc/src/core/editor-runtime/editor-runtime-registry.ts @@ -6,7 +6,7 @@ // // Boundary rules (the runtime contract, enforced by `import-boundary.test.ts`): // This module depends on the runtime contract (`./types.js`) and the -// shell-owned root marker only. It imports NO concrete v1/v2 editor. +// shell-owned root marker only. It imports NO concrete editor implementation. // The registry NEVER interprets runtime positions, maps click coordinates, // or dispatches edit commands. Event-target resolution selects a runtime; it // does nothing editor-semantic. @@ -14,9 +14,8 @@ // `setActiveEditor(...)` remains the sole writer of `SuperDoc.activeEditor`. // The registry only OBSERVES active changes and surfaces the next runtime's // legacy editor projection on the active-change event; SuperDoc routes that -// projection through `setActiveEditor(...)` so the v1 toolbar rebind / v2 -// no-rebind side effects are preserved. The registry never assigns -// `activeEditor` itself. +// projection through `setActiveEditor(...)` so legacy toolbar rebind side +// effects are preserved. The registry never assigns `activeEditor` itself. import type { EditorRuntime, EditorRuntimeId } from './types.js'; import { RUNTIME_ROOT_ATTRIBUTE } from './root-marker.js'; diff --git a/packages/superdoc/src/core/editor-runtime/import-boundary.test.ts b/packages/superdoc/src/core/editor-runtime/import-boundary.test.ts index 7cf22bf32a..922f2d3c85 100644 --- a/packages/superdoc/src/core/editor-runtime/import-boundary.test.ts +++ b/packages/superdoc/src/core/editor-runtime/import-boundary.test.ts @@ -1,14 +1,13 @@ // Import-boundary guard for the editor-runtime contract. // // The shared runtime contract is the one surface shell code uses to talk to a -// mounted editor. current implementation hard gate: it must NOT depend on ProseMirror, the +// mounted editor. Current implementation hard gate: it must NOT depend on ProseMirror, the // concrete v1 editor package, `PresentationEditor`/`EditorInputManager`/ -// `PositionHit`, the concrete v2 host implementation files, or -// `SDPosition`/`SDRange`/Document API internals. +// `PositionHit`, or `SDPosition`/`SDRange`/Document API internals. // // This guard scans every non-test source under `core/editor-runtime/` for both // forbidden import specifiers AND forbidden path-string references to concrete -// v1/v2 implementation files. Conformance fixtures are scanned too: they must +// editor implementation files. Conformance fixtures are scanned too: they must // prove the contract is satisfiable without importing forbidden modules. import { readFileSync, readdirSync, statSync } from 'node:fs'; @@ -33,16 +32,13 @@ const FORBIDDEN_IMPORT_FRAGMENTS = [ 'PresentationEditor', 'EditorInputManager', 'edit-command-adapters', - 'create-v2-editor-host', ]; // Forbidden path-string / identifier references anywhere in source (not just // imports). These catch a back-door like a string path to a concrete impl file // or a type reference smuggled past the import scan. const FORBIDDEN_REFERENCE_FRAGMENTS = [ - 'create-v2-editor-host', 'presentation-editor/PresentationEditor', - 'V2EditorHost', 'SDPosition', 'SDRange', 'PositionHit', @@ -124,7 +120,7 @@ describe('editor-runtime contract - import boundary', () => { }); it('self-test: reference scanner flags a smuggled concrete-impl path string', () => { - const line = `const p = require('@superdoc/v2-host/src/create-v2-editor-host.js');`; + const line = `const p = require('@superdoc/super-editor/src/presentation-editor/PresentationEditor.js');`; const flagged = FORBIDDEN_REFERENCE_FRAGMENTS.some((f) => line.includes(f)); expect(flagged).toBe(true); }); diff --git a/packages/superdoc/src/core/editor-runtime/side-by-side-v1-proof.test.ts b/packages/superdoc/src/core/editor-runtime/side-by-side-v1-proof.test.ts index d80bbe0039..de4beb6878 100644 --- a/packages/superdoc/src/core/editor-runtime/side-by-side-v1-proof.test.ts +++ b/packages/superdoc/src/core/editor-runtime/side-by-side-v1-proof.test.ts @@ -49,7 +49,6 @@ function makeFakeV1Editor(documentId: string, selectionText: string) { const emitter = makeEmitter(); return { options: { documentId }, - editorVersion: 1 as const, state: { doc: { textBetween: (_from: number, _to: number, _sep?: string) => selectionText }, selection: { from: 0, to: selectionText.length, empty: selectionText.length === 0 }, diff --git a/packages/superdoc/src/core/editor-runtime/types.ts b/packages/superdoc/src/core/editor-runtime/types.ts index 8388dfaa25..b320e8adcc 100644 --- a/packages/superdoc/src/core/editor-runtime/types.ts +++ b/packages/superdoc/src/core/editor-runtime/types.ts @@ -1,7 +1,7 @@ // Internal SuperDoc editor-runtime contract. // // This module defines the ONE internal surface shared shell code uses to talk -// to any mounted editor (v1 today, v2 conformance later). It is shell-owned and +// to any mounted v1 editor runtime. It is shell-owned and // NOT a public SDK API, NOT a shared document position model, and NOT a place to // re-export editor internals. // @@ -10,17 +10,16 @@ // browser/platform types. It currently imports nothing. // It must NEVER import ProseMirror, `@superdoc/super-editor`, // `PresentationEditor`, `EditorInputManager`, `PositionHit`, -// `TextSelection`/`NodeSelection`, the concrete v2 host implementation -// files, or `SDPosition`/`SDRange`/Document API internals. +// `TextSelection`/`NodeSelection`, or Document API internals. // Runtime positions are opaque handles. The shell may store and round-trip // them, but must never interpret them. Adapters keep non-serializable // internals in adapter-private maps keyed by `tokenId`. // -// Outcome semantics are modeled to preserve the current v2 editor host posture: +// Outcome semantics preserve the existing host posture: // commit, // history-commit, history-noop, receipt-failure, and named rejection - not a // boolean success/failure collapse. The shared contract defines NEUTRAL codes; -// concrete v1/v2 adapters map their own codes onto these. +// concrete adapters map their own codes onto these. // --------------------------------------------------------------------------- // Neutral JSON-safe helper types (package-local, no external imports) @@ -40,7 +39,7 @@ export type RuntimeJsonObject = { [key: string]: RuntimeJsonValue }; // --------------------------------------------------------------------------- /** Which editing architecture backs a mounted runtime. */ -export type EditorRuntimeKind = 'v1' | 'v2'; +export type EditorRuntimeKind = 'v1'; /** Opaque, registry-unique identifier for a mounted runtime. */ export type EditorRuntimeId = string; @@ -49,8 +48,7 @@ export type EditorRuntimeId = string; export type EditorRuntimeDocumentMode = 'editing' | 'suggesting' | 'viewing'; /** - * Shell-level lifecycle state. Mirrors the current v2 editor host state machine - * so the contract can describe v2 outcomes without importing v2 host types. + * Shell-level lifecycle state for the mounted editor runtime. */ export type EditorRuntimeState = | 'opening' @@ -70,8 +68,8 @@ export type EditorRuntimeState = * * The shell may store a token and send it back to the runtime that created it, * but must NEVER read `payload` internals, and must never construct a token to - * mean a document location. v1 may wrap a PM position or `PositionHit`; v2 may - * wrap an `SDPosition`; both keep the non-serializable internals in an + * mean a document location. The adapter may wrap a PM position or `PositionHit` + * and keeps the non-serializable internals in an * adapter-private map keyed by `tokenId`. * * Tokens are self-validating for staleness: a runtime that cannot prove a token @@ -150,8 +148,8 @@ export type EditorRuntimeNoopReason = | 'empty-selection'; /** - * Neutral rejection codes. Adapters map their concrete v1/v2 codes onto this - * set; the shared contract must not import v1/v2 code unions. + * Neutral rejection codes. Adapters map their concrete codes onto this set; the + * shared contract must not import implementation-specific code unions. */ export type EditorRuntimeRejectionCode = | 'runtime-not-ready' @@ -390,7 +388,7 @@ export interface EditorRuntimeCapabilities { /** * Runtime-owned events. The registry (the editor runtime boundary) owns active-runtime changes; * the runtime owns editor-specific state. Events carry shell-level snapshots, - * not raw ProseMirror or v2 session objects. + * not raw ProseMirror session objects. */ export type EditorRuntimeEvent = | { type: 'selection-change'; selection: EditorRuntimeSelectionSnapshot } @@ -408,9 +406,7 @@ export type EditorRuntimeUnsubscribe = () => void; // --------------------------------------------------------------------------- /** - * The shell-owned editor runtime contract. Implementable by the v1 adapter and - * the current internal v2 host/facade without importing v2 host types into this - * shared module. + * The shell-owned editor runtime contract implemented by the v1 adapter. * * Mutating operations return Promises (callers always await). Runtime-owned * snapshot reads (`getSelectedText`, `getSelectionSnapshot`, @@ -432,9 +428,8 @@ export interface EditorRuntime { /** * Temporary compatibility path backing `SuperDoc.activeEditor`, - * `doc.getEditor()`, and existing public-ish callers. v1 may return the legacy - * editor; v2 may return its facade. New shell behavior must not route through - * this projection. + * `doc.getEditor()`, and existing public-ish callers. New shell behavior must + * not route through this projection. */ getLegacyEditorProjection?(): unknown; diff --git a/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.test.ts b/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.test.ts index f0a002e7c7..7b5f1b267a 100644 --- a/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.test.ts +++ b/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.test.ts @@ -40,7 +40,6 @@ function createFakeEditor(opts: FakeEditorOptions = {}) { setDocumentModeSpy: typeof setDocumentModeSpy; } = { options: { documentId: opts.documentId ?? 'doc-1', documentMode: opts.documentMode }, - editorVersion: 1, state: { doc: { textBetween: (f: number, t: number, sep?: string) => { diff --git a/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.ts b/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.ts index 3cd9975fd4..1afbd56be0 100644 --- a/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.ts +++ b/packages/superdoc/src/core/editor-runtime/v1/v1-editor-runtime-adapter.ts @@ -10,8 +10,8 @@ // Boundary rules (the runtime contract, enforced by `../import-boundary.test.ts`): // This module lives under `core/editor-runtime/`, which is import-scanned. // It therefore NEVER imports `@superdoc/super-editor`, the concrete v1 -// `Editor`/`PresentationEditor`, ProseMirror, or any v2/Document-API -// internals. Instead it talks to the v1 surfaces through the minimal +// `Editor`/`PresentationEditor`, ProseMirror, or any Document API internals. +// Instead it talks to the v1 surfaces through the minimal // STRUCTURAL interfaces below. The concrete v1 instances are injected by the // shell (`SuperDoc.vue`), which is free to reference v1 types directly. // Position tokens are opaque. The adapter keeps the non-serializable PM @@ -75,8 +75,6 @@ export interface V1EditorLike extends V1EventTargetLike { focus?(): void; exportDocx?(params?: unknown): Promise; setDocumentMode?(mode: EditorRuntimeDocumentMode): void; - /** Present on the v2 facade as `2`; absent/`1` for the real v1 editor. */ - editorVersion?: 1 | 2; } /** The subset of the v1 `PresentationEditor` the adapter delegates to. */ @@ -284,11 +282,23 @@ export function createV1EditorRuntimeAdapter(options: V1EditorRuntimeAdapterOpti return { id, kind: 'v1', documentId, state, documentMode, capabilities: capabilities() }; } + function getLivePresentationEditor(): V1PresentationEditorLike | null { + const editorWithPresentation = editor as typeof editor & { + presentationEditor?: V1PresentationEditorLike | null; + _presentationEditor?: V1PresentationEditorLike | null; + }; + return editorWithPresentation.presentationEditor ?? editorWithPresentation._presentationEditor ?? null; + } + function setDocumentMode(mode: EditorRuntimeDocumentMode): void { if (disposed) return; const nextMode = normalizeDocumentMode(mode); if (nextMode === documentMode) return; documentMode = nextMode; + const livePresentationEditor = getLivePresentationEditor(); + if (livePresentationEditor && livePresentationEditor !== presentationEditor) { + attachPresentationEditor(livePresentationEditor); + } presentationEditor?.setDocumentMode?.(documentMode); editor.setDocumentMode?.(documentMode); if (state === 'editing-ready' || state === 'review-ready') { diff --git a/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.test.ts b/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.test.ts deleted file mode 100644 index 785794b500..0000000000 --- a/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createV2EditorRuntimeAdapter } from './v2-editor-runtime-adapter.js'; - -function createReadyHost(overrides: Record = {}) { - return { - getSnapshot: () => ({ - state: 'ready', - documentMode: 'editing', - editableSubset: { - editingMounted: true, - commands: [{ command: 'review.trackedChangeDecide', status: 'supported', rejectionCode: null, detail: null }], - }, - commentCommandsReason: null, - }), - subscribe: () => () => {}, - getDocumentMode: () => 'editing', - setDocumentMode: vi.fn(), - dispatch: vi.fn(async () => ({ status: 'committed', receipt: { success: true } })), - save: vi.fn(async () => new ArrayBuffer(0)), - dispose: vi.fn(async () => {}), - getHandles: () => ({ editing: null }), - getPageMetricsSnapshot: () => ({ pages: [], zoom: { percent: 100 } }), - setZoom: vi.fn(() => ({ status: 'ok' })), - ...overrides, - }; -} - -describe('createV2EditorRuntimeAdapter review mutation route', () => { - it('routes tracked-change decisions through the synchronous Document API facade', async () => { - const decide = vi.fn(() => ({ success: true, txId: 'tx-1' })); - const host = createReadyHost({ - getDocumentFacade: () => ({ - available: true, - doc: { - comments: {}, - trackChanges: { decide }, - }, - }), - }); - const { runtime } = createV2EditorRuntimeAdapter({ - id: 'v2-runtime', - documentId: 'doc-1', - root: document.createElement('div'), - host, - }); - - await expect(runtime.dispatch({ kind: 'trackedChanges.accept', id: 'tc-1' })).resolves.toMatchObject({ - status: 'committed', - }); - expect(decide).toHaveBeenCalledWith({ decision: 'accept', target: { kind: 'id', id: 'tc-1' } }); - expect(host.dispatch).not.toHaveBeenCalled(); - }); - - it('advertises and routes bulk tracked-change decisions when the host publishes bulk support rows', async () => { - const decide = vi.fn(() => ({ success: true, txId: 'tx-1' })); - const host = createReadyHost({ - getSnapshot: () => ({ - state: 'ready', - documentMode: 'editing', - editableSubset: { - editingMounted: true, - commands: [ - { command: 'review.trackedChangeDecide', status: 'supported', rejectionCode: null, detail: null }, - { command: 'trackedChanges.acceptAll', status: 'supported', rejectionCode: null, detail: null }, - { command: 'trackedChanges.rejectAll', status: 'supported', rejectionCode: null, detail: null }, - ], - }, - commentCommandsReason: null, - }), - getDocumentFacade: () => ({ - available: true, - doc: { - comments: {}, - trackChanges: { decide }, - }, - }), - }); - const { runtime } = createV2EditorRuntimeAdapter({ - id: 'v2-runtime', - documentId: 'doc-1', - root: document.createElement('div'), - host, - }); - - expect(runtime.getCapabilities().commands.supportedCommands).toContain('trackedChanges.acceptAll'); - await expect(runtime.dispatch({ kind: 'trackedChanges.acceptAll' })).resolves.toMatchObject({ - status: 'committed', - }); - expect(decide).toHaveBeenCalledWith({ decision: 'accept', target: { kind: 'all' } }); - expect(host.dispatch).not.toHaveBeenCalled(); - }); - - it('does not advertise or dispatch review mutations when the host marks them read-only', async () => { - const decide = vi.fn(() => ({ success: true, txId: 'tx-1' })); - const host = createReadyHost({ - getSnapshot: () => ({ - state: 'ready', - documentMode: 'viewing', - editableSubset: { - editingMounted: false, - commands: [ - { - command: 'review.trackedChangeDecide', - status: 'unsupported', - rejectionCode: 'review-surface-read-only', - detail: 'review-mutations-disabled-in-review-mode', - }, - ], - }, - commentCommandsReason: null, - }), - getDocumentMode: () => 'viewing', - getDocumentFacade: () => ({ - available: true, - doc: { - comments: {}, - trackChanges: { decide }, - }, - }), - }); - const { runtime } = createV2EditorRuntimeAdapter({ - id: 'v2-runtime', - documentId: 'doc-1', - root: document.createElement('div'), - host, - }); - - expect(runtime.getSnapshot().state).toBe('review-ready'); - expect(runtime.getCapabilities().trackedChanges.canDecide).toBe(false); - expect(runtime.getCapabilities().commands.supportedCommands).not.toContain('trackedChanges.accept'); - await expect(runtime.dispatch({ kind: 'trackedChanges.accept', id: 'tc-1' })).resolves.toEqual({ - status: 'rejected', - reason: 'document-readonly', - detail: 'review-mutations-disabled-in-review-mode', - }); - expect(decide).not.toHaveBeenCalled(); - expect(host.dispatch).not.toHaveBeenCalled(); - }); - - it('fails review mutations closed when the sync Document API facade is unavailable', async () => { - const host = createReadyHost({ - getDocumentFacade: () => ({ - available: false, - reason: 'sync-document-api-unavailable-in-worker-mode', - }), - }); - const { runtime } = createV2EditorRuntimeAdapter({ - id: 'v2-runtime', - documentId: 'doc-1', - root: document.createElement('div'), - host, - }); - - await expect(runtime.dispatch({ kind: 'trackedChanges.reject', id: 'tc-1' })).resolves.toEqual({ - status: 'rejected', - reason: 'review-command-unavailable', - }); - expect(host.dispatch).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.ts b/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.ts deleted file mode 100644 index 5ddb079a49..0000000000 --- a/packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.ts +++ /dev/null @@ -1,832 +0,0 @@ -import type { - EditorRuntime, - EditorRuntimeCapabilities, - EditorRuntimeCommand, - EditorRuntimeCommandKind, - EditorRuntimeCommandResult, - EditorRuntimeDocumentMode, - EditorRuntimeEvent, - EditorRuntimeExportOptions, - EditorRuntimeFocusOptions, - EditorRuntimeId, - EditorRuntimeLayoutSnapshot, - EditorRuntimeListener, - EditorRuntimeNavigationTarget, - EditorRuntimePositionToken, - EditorRuntimeRejectionCode, - EditorRuntimeSelectionSnapshot, - EditorRuntimeSnapshot, - EditorRuntimeState, - EditorRuntimeToolbarState, - EditorRuntimeUnsubscribe, -} from '../index.js'; - -type HostLifecycleState = 'opening' | 'blocked' | 'ready' | 'saving' | 'disposed' | 'failed'; - -type HostCommandKind = - | 'text.insert' - | 'text.replace' - | 'text.deleteBackward' - | 'text.deleteForward' - | 'text.pastePlain' - | 'history.undo' - | 'history.redo' - | 'structural.enter' - | 'structural.listIndent' - | 'structural.listOutdent' - | string; - -interface HostCommandSupportRecordLike { - readonly command: HostCommandKind | string; - readonly status: 'supported' | 'unsupported'; - readonly rejectionCode?: string | null; - readonly reason?: string | null; - readonly detail?: string | null; - readonly enabled?: boolean; -} - -interface HostSelectionStateLike { - readonly anchor: unknown; - readonly focus: unknown; -} - -interface HostSelectionControllerLike { - getSnapshot(): HostSelectionStateLike | null; - subscribe(listener: (snapshot: HostSelectionStateLike | null) => void): () => void; -} - -interface HostHandlesLike { - readonly editing: { - readonly selection: HostSelectionControllerLike; - } | null; -} - -interface HostEditableSubsetSnapshotLike { - readonly editingMounted: boolean; - readonly commands: readonly HostCommandSupportRecordLike[]; -} - -interface HostSnapshotLike { - readonly state: HostLifecycleState; - readonly documentMode: EditorRuntimeDocumentMode; - readonly reason?: string; - readonly detail?: string; - readonly commentCommandsReason?: 'author-required' | null; - readonly editableSubset: HostEditableSubsetSnapshotLike; -} - -interface HostDispatchRejectionLike { - readonly code: string; - readonly detail?: string; -} - -type HostDispatchResultLike = - | { readonly status: 'committed'; readonly receipt?: unknown } - | { readonly status: 'history-committed'; readonly result?: unknown } - | { readonly status: 'history-noop'; readonly result?: { readonly reason?: string } } - | { readonly status: 'receipt-failure'; readonly failure?: unknown } - | { readonly status: 'rejected'; readonly rejection: HostDispatchRejectionLike }; - -type HostCommandLike = - | { readonly kind: 'text.insert'; readonly text: string } - | { readonly kind: 'text.replace'; readonly text: string } - | { readonly kind: 'text.deleteBackward' } - | { readonly kind: 'text.deleteForward' } - | { readonly kind: 'text.pastePlain'; readonly text: string } - | { readonly kind: 'history.undo' } - | { readonly kind: 'history.redo' } - | { readonly kind: 'structural.enter' } - | { readonly kind: 'structural.listIndent' } - | { readonly kind: 'structural.listOutdent' }; - -interface DocumentFacadeReceiptLike { - readonly success?: boolean; - readonly failure?: unknown; -} - -interface DocumentFacadeHistoryResultLike { - readonly noop?: boolean; - readonly reason?: string; -} - -interface DocumentFacadeSelectionInfoLike { - readonly empty?: boolean; - readonly target?: unknown; -} - -interface DocumentFacadeLike { - readonly comments?: { - create?(input: { text: string; target?: unknown; parentCommentId?: string }): DocumentFacadeReceiptLike; - patch?(input: { commentId: string; text?: string; status?: 'resolved' | 'active' }): DocumentFacadeReceiptLike; - delete?(input: { commentId: string }): DocumentFacadeReceiptLike; - }; - readonly trackChanges?: { - decide?(input: { - decision: 'accept' | 'reject'; - target: { kind: 'id'; id: string } | { kind: 'all' }; - }): DocumentFacadeReceiptLike; - }; - readonly history?: { - undo?(): DocumentFacadeHistoryResultLike; - redo?(): DocumentFacadeHistoryResultLike; - }; - readonly selection?: { - current?(input?: { includeText?: boolean }): DocumentFacadeSelectionInfoLike; - }; -} - -type DocumentFacadeResultLike = - | { readonly available: true; readonly doc: DocumentFacadeLike } - | { readonly available: false; readonly reason?: string }; - -interface HostPageMetricsSnapshotLike { - readonly pages: readonly unknown[]; - readonly zoom: { - readonly percent: number; - }; -} - -interface HostSetZoomResultLike { - readonly status: 'ok' | 'rejected'; - readonly reason?: string; -} - -interface HostFocusHandleLike { - focus?(options?: unknown): boolean | void | Promise; -} - -interface HostMountHandleLike { - readonly focus: HostFocusHandleLike | null; -} - -interface ModeAwareHostLike { - getSnapshot(): HostSnapshotLike; - subscribe(listener: (snapshot: HostSnapshotLike) => void): () => void; - getDocumentMode(): EditorRuntimeDocumentMode; - setDocumentMode(mode: EditorRuntimeDocumentMode): void; - dispatch(command: HostCommandLike): Promise; - save(options?: { format?: 'docx' }): Promise; - dispose(): Promise; - getHandles(): HostHandlesLike; - getDocumentFacade?(): DocumentFacadeResultLike; - getPageMetricsSnapshot(): HostPageMetricsSnapshotLike; - subscribePageMetrics?(listener: (snapshot: HostPageMetricsSnapshotLike) => void): () => void; - setZoom(percent: number): HostSetZoomResultLike; -} - -export interface V2EditorRuntimeAdapterOptions { - readonly id: EditorRuntimeId; - readonly documentId: string; - readonly root: HTMLElement; - readonly host: ModeAwareHostLike; - readonly onUnregister?: (id: EditorRuntimeId) => void; -} - -const TEXT_AND_STRUCTURE_COMMANDS: readonly EditorRuntimeCommandKind[] = [ - 'text.insert', - 'text.replace', - 'text.deleteBackward', - 'text.deleteForward', - 'text.paste', - 'history.undo', - 'history.redo', - 'structural.splitBlock', - 'structural.indent', - 'structural.outdent', -]; - -const REVIEW_COMMANDS: readonly EditorRuntimeCommandKind[] = [ - 'comments.create', - 'comments.resolve', - 'comments.reopen', - 'comments.delete', - 'comments.reply', - 'comments.edit', - 'trackedChanges.accept', - 'trackedChanges.reject', - 'trackedChanges.acceptAll', - 'trackedChanges.rejectAll', -]; - -const ALWAYS_SUPPORTED_COMMANDS: readonly EditorRuntimeCommandKind[] = ['trackedChanges.setAuthoringMode']; - -function mapLifecycleState(snapshot: HostSnapshotLike): EditorRuntimeState { - if (snapshot.state !== 'ready') return snapshot.state; - return snapshot.documentMode === 'viewing' ? 'review-ready' : 'editing-ready'; -} - -function commandKindForSupport(kind: EditorRuntimeCommandKind): HostCommandKind | null { - switch (kind) { - case 'text.insert': - return 'text.insert'; - case 'text.replace': - return 'text.replace'; - case 'text.deleteBackward': - return 'text.deleteBackward'; - case 'text.deleteForward': - return 'text.deleteForward'; - case 'text.paste': - return 'text.pastePlain'; - case 'history.undo': - return 'history.undo'; - case 'history.redo': - return 'history.redo'; - case 'structural.splitBlock': - return 'structural.enter'; - case 'structural.indent': - return 'structural.listIndent'; - case 'structural.outdent': - return 'structural.listOutdent'; - default: - return null; - } -} - -function reviewCommandKindForSupport(kind: EditorRuntimeCommandKind): string | null { - switch (kind) { - case 'comments.create': - return 'comments.createFromSelection'; - case 'comments.resolve': - return 'review.commentResolve'; - case 'comments.reopen': - return 'review.commentReopen'; - case 'comments.delete': - return 'review.commentDelete'; - case 'comments.reply': - return 'review.commentReply'; - case 'comments.edit': - return 'review.commentEdit'; - case 'trackedChanges.accept': - case 'trackedChanges.reject': - return 'review.trackedChangeDecide'; - case 'trackedChanges.acceptAll': - return 'trackedChanges.acceptAll'; - case 'trackedChanges.rejectAll': - return 'trackedChanges.rejectAll'; - default: - return null; - } -} - -function supportRecordFor(current: HostSnapshotLike, command: string): HostCommandSupportRecordLike | null { - return current.editableSubset.commands.find((entry) => entry.command === command) ?? null; -} - -function supportRecordIsSupported(record: HostCommandSupportRecordLike | null): boolean { - return record?.status === 'supported' || record?.enabled === true; -} - -function supportRecordRejectionCode(record: HostCommandSupportRecordLike | null): string | null { - return record?.rejectionCode ?? record?.reason ?? null; -} - -function supportRecordDetail(record: HostCommandSupportRecordLike | null): string | undefined { - return record?.detail ?? undefined; -} - -function isHostCommandSupported(current: HostSnapshotLike, command: string): boolean { - return supportRecordIsSupported(supportRecordFor(current, command)); -} - -function unsupportedRuntimeRejection( - current: HostSnapshotLike, - command: string, -): { reason: EditorRuntimeRejectionCode; detail?: string } { - const record = supportRecordFor(current, command); - const rawCode = supportRecordRejectionCode(record); - if (rawCode) return { reason: rejectionCode(rawCode), detail: supportRecordDetail(record) }; - if (current.documentMode === 'viewing') return { reason: 'document-readonly', detail: 'review-surface-read-only' }; - return { reason: 'command-unsupported', detail: `unsupported:${command}` }; -} - -function historyNoopReason( - kind: EditorRuntimeCommand['kind'], - result: unknown, -): 'nothing-to-undo' | 'nothing-to-redo' | 'no-effect' { - const rawReason = (result as { reason?: string } | null | undefined)?.reason; - if (kind === 'history.undo') { - return rawReason === 'NO_EFFECT' || rawReason === 'apply-rejected' ? 'no-effect' : 'nothing-to-undo'; - } - return rawReason === 'NO_EFFECT' || rawReason === 'apply-rejected' ? 'no-effect' : 'nothing-to-redo'; -} - -function rejectionCode(code: string): EditorRuntimeRejectionCode { - switch (code) { - case 'host-saving': - return 'host-saving'; - case 'document-readonly': - return 'document-readonly'; - case 'selection-invalidated': - case 'review-target-invalidated': - return 'selection-invalidated'; - case 'review-command-unavailable': - return 'review-command-unavailable'; - case 'review-surface-read-only': - return 'document-readonly'; - case 'editing-selection-required': - return 'selection-unsupported'; - case 'format-target-unsupported': - case 'selection-target-unsupported': - case 'input-target-unsupported': - case 'composition-target-unsupported': - case 'enter-context-unsupported': - case 'boundary-merge-unsupported': - case 'tracked-structural-edit-unsupported': - case 'comment-anchor-create-unsupported': - case 'comment-anchor-move-unsupported': - return 'target-unsupported'; - case 'unsupported-command': - return 'command-unsupported'; - case 'editing-mount-required': - case 'host-not-ready': - case 'host-disposed': - return 'runtime-not-ready'; - default: - return 'command-failed'; - } -} - -export function createV2EditorRuntimeAdapter(options: V2EditorRuntimeAdapterOptions): { - runtime: EditorRuntime; - attachMountHandle(handle: HostMountHandleLike | null): void; -} { - const { id, documentId, root, host, onUnregister } = options; - - let snapshot = host.getSnapshot(); - let didDispose = false; - let unregistered = false; - let mountedFocusHandle: HostMountHandleLike | null = null; - let tokenRevision = 0; - let tokenSeq = 0; - let selectionUnsubscribe: (() => void) | null = null; - let hostUnsubscribe: (() => void) | null = null; - let pageMetricsUnsubscribe: (() => void) | null = null; - let activeSelectionController: HostSelectionControllerLike | null = null; - const positionTokens = new Map(); - const listeners = new Set(); - - function emit(event: EditorRuntimeEvent): void { - for (const listener of Array.from(listeners)) { - try { - listener(event); - } catch { - /* listener errors must not break the runtime */ - } - } - } - - function invalidatePositionTokens(): void { - tokenRevision += 1; - positionTokens.clear(); - } - - function mintToken(marker: unknown): EditorRuntimePositionToken { - const tokenId = `v2-runtime-pos-${tokenSeq++}`; - positionTokens.set(tokenId, marker); - return { runtimeId: id, tokenId, revision: tokenRevision }; - } - - function resolveToken( - token: EditorRuntimePositionToken, - ): { ok: true } | { ok: false; reason: 'wrong-runtime-token' | 'stale-position-token' } { - if (token.runtimeId !== id) return { ok: false, reason: 'wrong-runtime-token' }; - if (token.revision !== tokenRevision || !positionTokens.has(token.tokenId)) { - return { ok: false, reason: 'stale-position-token' }; - } - return { ok: true }; - } - - function selectionController(): HostSelectionControllerLike | null { - return host.getHandles().editing?.selection ?? null; - } - - function selectionSnapshot(): EditorRuntimeSelectionSnapshot | null { - const current = selectionController()?.getSnapshot() ?? null; - if (!current) return null; - return { - isRange: current.anchor !== current.focus, - isEmpty: current.anchor === current.focus, - text: '', - anchor: mintToken(current.anchor), - focus: mintToken(current.focus), - }; - } - - function syncSelectionSubscription(): void { - const next = selectionController(); - if (next === activeSelectionController) return; - selectionUnsubscribe?.(); - selectionUnsubscribe = null; - activeSelectionController = next; - if (!next) return; - selectionUnsubscribe = next.subscribe((current) => { - invalidatePositionTokens(); - const mapped = current - ? { - isRange: current.anchor !== current.focus, - isEmpty: current.anchor === current.focus, - text: '', - anchor: mintToken(current.anchor), - focus: mintToken(current.focus), - } - : { - isRange: false, - isEmpty: true, - text: '', - }; - emit({ type: 'selection-change', selection: mapped }); - }); - } - - function getDocumentFacade(): DocumentFacadeLike | null { - const result = host.getDocumentFacade?.() ?? null; - return result?.available === true ? result.doc : null; - } - - function hasDocumentReviewFacade(): boolean { - const doc = getDocumentFacade(); - return Boolean(doc?.comments && doc.trackChanges); - } - - function supportedCommands(current: HostSnapshotLike): readonly EditorRuntimeCommandKind[] { - if (current.state !== 'ready') return []; - const runtimeKinds = TEXT_AND_STRUCTURE_COMMANDS.filter((kind) => { - const mapped = commandKindForSupport(kind); - return mapped !== null && isHostCommandSupported(current, mapped); - }); - const reviewKinds = hasDocumentReviewFacade() - ? REVIEW_COMMANDS.filter((kind) => { - const mapped = reviewCommandKindForSupport(kind); - return mapped !== null && isHostCommandSupported(current, mapped); - }) - : []; - return [...runtimeKinds, ...reviewKinds, ...ALWAYS_SUPPORTED_COMMANDS]; - } - - function capabilities(current: HostSnapshotLike = snapshot): EditorRuntimeCapabilities { - const canFocus = current.state !== 'disposed'; - const availableCommands = supportedCommands(current); - return { - lifecycle: { canFocus, canDispose: true }, - selection: { - canReadSelectedText: true, - canReadSelectionSnapshot: true, - canMintPositionTokens: true, - }, - commands: { - canDispatch: current.state === 'ready' && availableCommands.length > 0, - supportedCommands: availableCommands, - }, - layout: { supported: true, hasSyncSnapshot: true }, - zoom: { supported: true, min: 25, max: 400 }, - navigation: { supported: false, targets: [] }, - persistence: { canSave: true, canExportDocx: true }, - comments: { - supported: true, - canMutate: - current.commentCommandsReason !== 'author-required' && - [ - 'comments.create', - 'comments.resolve', - 'comments.reopen', - 'comments.delete', - 'comments.reply', - 'comments.edit', - ].some((kind) => availableCommands.includes(kind as EditorRuntimeCommandKind)), - }, - trackedChanges: { - supported: true, - canDecide: - availableCommands.includes('trackedChanges.accept') || availableCommands.includes('trackedChanges.reject'), - canToggleAuthoring: current.state === 'ready', - }, - }; - } - - function currentLayoutSnapshot(): EditorRuntimeLayoutSnapshot { - const pageMetrics = host.getPageMetricsSnapshot(); - return { - pageCount: pageMetrics.pages.length, - currentPage: 1, - zoom: pageMetrics.zoom.percent, - }; - } - - function runtimeSnapshot(current: HostSnapshotLike = snapshot): EditorRuntimeSnapshot { - return { - id, - kind: 'v2', - documentId, - state: mapLifecycleState(current), - documentMode: current.documentMode, - reason: current.reason, - capabilities: capabilities(current), - }; - } - - function resultFromReceipt(receipt: DocumentFacadeReceiptLike | undefined): EditorRuntimeCommandResult { - if (receipt?.success === true) { - invalidatePositionTokens(); - return { status: 'committed', receipt }; - } - if (receipt && 'failure' in receipt) { - return { status: 'receipt-failure', failure: receipt.failure }; - } - return { status: 'rejected', reason: 'command-failed' }; - } - - function resultFromHistory( - kind: 'history.undo' | 'history.redo', - result: DocumentFacadeHistoryResultLike | undefined, - ): EditorRuntimeCommandResult { - if (!result) return { status: 'rejected', reason: 'command-failed' }; - if (result.noop === true) { - return { status: 'history-noop', reason: historyNoopReason(kind, result), result }; - } - invalidatePositionTokens(); - return { status: 'history-committed', result }; - } - - function dispatchReviewCommand(command: EditorRuntimeCommand): EditorRuntimeCommandResult | null { - const reviewCommand = reviewCommandKindForSupport(command.kind); - if (reviewCommand) { - if (!isHostCommandSupported(snapshot, reviewCommand)) { - const rejection = unsupportedRuntimeRejection(snapshot, reviewCommand); - return { status: 'rejected', reason: rejection.reason, detail: rejection.detail }; - } - } - const historyCommand = commandKindForSupport(command.kind); - if ((command.kind === 'history.undo' || command.kind === 'history.redo') && historyCommand) { - if (!isHostCommandSupported(snapshot, historyCommand)) { - const rejection = unsupportedRuntimeRejection(snapshot, historyCommand); - return { status: 'rejected', reason: rejection.reason, detail: rejection.detail }; - } - } - const doc = getDocumentFacade(); - if (!doc) { - return REVIEW_COMMANDS.includes(command.kind) - ? { status: 'rejected', reason: 'review-command-unavailable' } - : null; - } - try { - switch (command.kind) { - case 'comments.create': { - const selection = doc.selection?.current?.(); - const target = selection?.empty === false ? selection.target : undefined; - return resultFromReceipt(doc.comments?.create?.({ text: command.text, ...(target ? { target } : {}) })); - } - case 'comments.resolve': - return resultFromReceipt(doc.comments?.patch?.({ commentId: command.commentId, status: 'resolved' })); - case 'comments.reopen': - return resultFromReceipt(doc.comments?.patch?.({ commentId: command.commentId, status: 'active' })); - case 'comments.delete': - return resultFromReceipt(doc.comments?.delete?.({ commentId: command.commentId })); - case 'comments.reply': - return resultFromReceipt( - doc.comments?.create?.({ parentCommentId: command.parentCommentId, text: command.text }), - ); - case 'comments.edit': - return resultFromReceipt(doc.comments?.patch?.({ commentId: command.commentId, text: command.text })); - case 'trackedChanges.accept': - return resultFromReceipt( - doc.trackChanges?.decide?.({ decision: 'accept', target: { kind: 'id', id: command.id } }), - ); - case 'trackedChanges.reject': - return resultFromReceipt( - doc.trackChanges?.decide?.({ decision: 'reject', target: { kind: 'id', id: command.id } }), - ); - case 'trackedChanges.acceptAll': - return resultFromReceipt(doc.trackChanges?.decide?.({ decision: 'accept', target: { kind: 'all' } })); - case 'trackedChanges.rejectAll': - return resultFromReceipt(doc.trackChanges?.decide?.({ decision: 'reject', target: { kind: 'all' } })); - case 'history.undo': - return resultFromHistory('history.undo', doc.history?.undo?.()); - case 'history.redo': - return resultFromHistory('history.redo', doc.history?.redo?.()); - default: - return null; - } - } catch (error) { - return { - status: 'rejected', - reason: 'command-failed', - detail: error instanceof Error ? error.message : String(error), - }; - } - } - - function handleHostSnapshot(next: HostSnapshotLike): void { - snapshot = next; - invalidatePositionTokens(); - syncSelectionSubscription(); - emit({ type: 'state-change', state: mapLifecycleState(next) }); - emit({ type: 'capabilities-change', capabilities: capabilities(next) }); - if (next.state === 'disposed' && !didDispose) { - didDispose = true; - hostUnsubscribe?.(); - pageMetricsUnsubscribe?.(); - selectionUnsubscribe?.(); - selectionUnsubscribe = null; - emit({ type: 'disposed' }); - listeners.clear(); - if (!unregistered) { - unregistered = true; - onUnregister?.(id); - } - } - } - - hostUnsubscribe = host.subscribe(handleHostSnapshot); - pageMetricsUnsubscribe = - host.subscribePageMetrics?.(() => { - emit({ type: 'layout-change', layout: currentLayoutSnapshot() }); - }) ?? null; - syncSelectionSubscription(); - - async function dispatch(command: EditorRuntimeCommand): Promise { - if (snapshot.state === 'disposed') return { status: 'rejected', reason: 'runtime-not-ready' }; - if (snapshot.state === 'saving') return { status: 'rejected', reason: 'host-saving' }; - if (snapshot.state !== 'ready') return { status: 'rejected', reason: 'runtime-not-ready' }; - - const token = 'at' in command ? command.at : 'range' in command ? command.range : undefined; - if (token) { - const resolved = resolveToken(token); - if (!resolved.ok) return { status: 'rejected', reason: resolved.reason }; - return { - status: 'rejected', - reason: 'target-unsupported', - detail: 'positioned dispatch is deferred until the shared runtime can target host selections explicitly', - }; - } - - if (command.kind === 'trackedChanges.setAuthoringMode') { - host.setDocumentMode(command.mode === 'tracked' ? 'suggesting' : 'editing'); - invalidatePositionTokens(); - return { status: 'committed' }; - } - - const documentApiResult = dispatchReviewCommand(command); - if (documentApiResult) return documentApiResult; - - let mapped: HostCommandLike | null = null; - switch (command.kind) { - case 'text.insert': - mapped = { kind: 'text.insert', text: command.text }; - break; - case 'text.replace': - mapped = { kind: 'text.replace', text: command.text }; - break; - case 'text.deleteBackward': - mapped = { kind: 'text.deleteBackward' }; - break; - case 'text.deleteForward': - mapped = { kind: 'text.deleteForward' }; - break; - case 'text.paste': - mapped = { kind: 'text.pastePlain', text: command.text }; - break; - case 'history.undo': - mapped = { kind: 'history.undo' }; - break; - case 'history.redo': - mapped = { kind: 'history.redo' }; - break; - case 'structural.splitBlock': - mapped = { kind: 'structural.enter' }; - break; - case 'structural.indent': - mapped = { kind: 'structural.listIndent' }; - break; - case 'structural.outdent': - mapped = { kind: 'structural.listOutdent' }; - break; - default: - return { status: 'rejected', reason: 'command-unsupported', detail: command.kind }; - } - - const result = await host.dispatch(mapped); - switch (result.status) { - case 'committed': - invalidatePositionTokens(); - return { status: 'committed', receipt: result.receipt }; - case 'history-committed': - invalidatePositionTokens(); - return { status: 'history-committed', result: result.result }; - case 'history-noop': - return { - status: 'history-noop', - reason: historyNoopReason(command.kind, result.result), - result: result.result, - }; - case 'receipt-failure': - return { status: 'receipt-failure', failure: result.failure }; - case 'rejected': - return { - status: 'rejected', - reason: rejectionCode(result.rejection.code), - detail: result.rejection.detail, - }; - } - } - - async function focus(options?: EditorRuntimeFocusOptions): Promise { - if (didDispose || snapshot.state === 'disposed') return false; - const focusController = mountedFocusHandle?.focus; - if (focusController && typeof focusController.focus === 'function') { - const focused = await focusController.focus({ - restoreSelection: options?.restoreSelection, - preventScroll: options?.preventScroll, - }); - return focused !== false; - } - if (typeof root.focus === 'function') { - root.focus({ preventScroll: options?.preventScroll }); - return true; - } - return false; - } - - async function dispose(): Promise { - if (didDispose) return; - await host.dispose(); - if (!didDispose) { - didDispose = true; - hostUnsubscribe?.(); - pageMetricsUnsubscribe?.(); - selectionUnsubscribe?.(); - selectionUnsubscribe = null; - emit({ type: 'disposed' }); - listeners.clear(); - if (!unregistered) { - unregistered = true; - onUnregister?.(id); - } - } - } - - const runtime: EditorRuntime = { - id, - kind: 'v2', - documentId, - root, - - getCapabilities: () => capabilities(), - getSnapshot: () => runtimeSnapshot(), - setDocumentMode(mode) { - host.setDocumentMode(mode); - }, - getDocumentMode: () => host.getDocumentMode(), - getLegacyEditorProjection: () => ({ editorVersion: 2, commands: null, state: null, view: null }), - - focus, - dispose, - - dispatch, - - getSelectedText: () => '', - getSelectionSnapshot: selectionSnapshot, - getToolbarState(): EditorRuntimeToolbarState | null { - return { activeMarks: [], disabled: ['formatting.applyMark', 'formatting.applyParagraph'] }; - }, - getLayoutSnapshot: currentLayoutSnapshot, - - save: () => host.save(), - exportDocx: (_options?: EditorRuntimeExportOptions) => host.save({ format: 'docx' }), - - async setZoom(percent) { - const result = host.setZoom(percent); - if (result.status === 'ok') return { status: 'committed' }; - return { - status: 'rejected', - reason: result.reason === 'host-disposed' ? 'runtime-not-ready' : 'target-unsupported', - detail: result.reason, - }; - }, - async reveal(target: EditorRuntimeNavigationTarget) { - if (target.kind === 'position') { - const resolved = resolveToken(target.position); - if (!resolved.ok) return { status: 'rejected', reason: resolved.reason }; - } - return { - status: 'rejected', - reason: 'capability-unsupported', - detail: `${target.kind} reveal is not exposed through the shared runtime yet`, - }; - }, - - subscribe(listener: EditorRuntimeListener): EditorRuntimeUnsubscribe { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - - return { - runtime, - attachMountHandle(handle) { - mountedFocusHandle = handle; - syncSelectionSubscription(); - }, - }; -} diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js index 219bda41c6..0e2bd760cb 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js @@ -69,7 +69,7 @@ describe('normalizeTrackChangesConfig', () => { expect(config.modules.trackChanges.enabled).toBe(true); }); - it('preserves canonical author color config for v2 tracked-change rendering', () => { + it('preserves canonical author color config for tracked-change rendering', () => { const authorColors = { enabled: true, overrides: { Ada: '#8250df' }, diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 20bf56d391..d0adab6219 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -2078,20 +2078,6 @@ export interface Config { * path in that case. */ useLayoutEngine?: boolean; - /** - * Opt-in switch for the injected v2 DOCX editor path inside the existing - * SuperDoc shell. `1` preserves the current public v1 editor behavior. - * `2` activates the injected v2 shell path for DOCX documents only; PDF and - * HTML continue to use their existing viewers. - */ - editorVersion?: 1 | 2; - /** - * Opaque editor integration object or factory supplied by the host product. - * Public SuperDoc does not bundle the v2 runtime; it resolves the editor, - * ruler, and shell bridge factories from this injected seam when - * `editorVersion: 2` is enabled. - */ - editorIntegration?: unknown; /** * Zoom behavior: the initial zoom level and optional fit-width * policy. See `SuperDocZoomConfig`. diff --git a/packages/superdoc/src/core/v2-integration/browser-peer-runtime.js b/packages/superdoc/src/core/v2-integration/browser-peer-runtime.js deleted file mode 100644 index 4fa7396c76..0000000000 --- a/packages/superdoc/src/core/v2-integration/browser-peer-runtime.js +++ /dev/null @@ -1,25 +0,0 @@ -import * as Vue from 'vue'; - -export const SUPERDOC_V2_BROWSER_RUNTIME_GLOBAL_KEY = '__SUPERDOC_V2_BROWSER_RUNTIME__'; - -function readInstalledRuntime(globalObject = globalThis) { - const installed = globalObject[SUPERDOC_V2_BROWSER_RUNTIME_GLOBAL_KEY]; - return installed && typeof installed === 'object' ? installed : null; -} - -export function readSuperDocV2BrowserRuntime(globalObject = globalThis) { - return readInstalledRuntime(globalObject); -} - -export function installSuperDocV2BrowserRuntime(runtime = {}, globalObject = globalThis) { - const installed = readInstalledRuntime(globalObject) ?? {}; - const nextRuntime = { - ...installed, - ...runtime, - vue: runtime.vue ?? installed.vue ?? Vue, - }; - globalObject[SUPERDOC_V2_BROWSER_RUNTIME_GLOBAL_KEY] = nextRuntime; - return nextRuntime; -} - -installSuperDocV2BrowserRuntime(); diff --git a/packages/superdoc/src/core/v2-integration/browser-peer-runtime.test.js b/packages/superdoc/src/core/v2-integration/browser-peer-runtime.test.js deleted file mode 100644 index e7c6380f70..0000000000 --- a/packages/superdoc/src/core/v2-integration/browser-peer-runtime.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as Vue from 'vue'; - -import { - installSuperDocV2BrowserRuntime, - readSuperDocV2BrowserRuntime, - SUPERDOC_V2_BROWSER_RUNTIME_GLOBAL_KEY, -} from './browser-peer-runtime.js'; - -describe('browser peer runtime', () => { - it('installs the host Vue runtime on a well-known global', () => { - const runtime = installSuperDocV2BrowserRuntime(); - expect(runtime.vue).toBe(Vue); - expect(readSuperDocV2BrowserRuntime()).toBe(runtime); - expect(globalThis[SUPERDOC_V2_BROWSER_RUNTIME_GLOBAL_KEY]).toBe(runtime); - }); - - it('merges explicit fields without dropping the shared Vue runtime', () => { - const runtime = installSuperDocV2BrowserRuntime({ custom: { ok: true } }); - expect(runtime.custom).toEqual({ ok: true }); - expect(runtime.vue).toBe(Vue); - }); -}); diff --git a/packages/superdoc/src/core/v2-integration/v2-import-boundary.test.ts b/packages/superdoc/src/core/v2-integration/v2-import-boundary.test.ts deleted file mode 100644 index 701dfe9dee..0000000000 --- a/packages/superdoc/src/core/v2-integration/v2-import-boundary.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative, sep } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const SRC_ROOT = join(__dirname, '..', '..'); - -const FORBIDDEN_IMPORT_FRAGMENTS = [ - '@superdoc/v2-browser-shell', - '@superdoc/v2-host', - '@superdoc/headless', - '@superdoc/collaboration-v2', - '@superdoc/editor-core', - '@superdoc/document-api-v2-adapter', - '@superdoc/style-model', - '@superdoc/v2-layout-adapter', - '@superdoc/v2/', -]; - -function* walkSourceFiles(dir: string): IterableIterator { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - yield* walkSourceFiles(full); - continue; - } - if (!/\.(js|ts|vue|jsx|tsx)$/.test(full)) continue; - if (/\.(test|spec)\.[jt]sx?$/.test(full)) continue; - yield full; - } -} - -function importSpecifiers(source: string): string[] { - const specs: string[] = []; - const fromRe = /\b(?:import|export)\b[^;]*?\bfrom\s+['"]([^'"]+)['"]/g; - const bareRe = /\bimport\s*\(?\s*['"]([^'"]+)['"]/g; - for (const re of [fromRe, bareRe]) { - let m: RegExpExecArray | null; - while ((m = re.exec(source)) !== null) specs.push(m[1]); - } - return specs; -} - -describe('public v2 import boundary', () => { - it('has no V2 implementation imports outside the approved seam', () => { - const offenders: { file: string; spec: string; fragment: string }[] = []; - for (const file of walkSourceFiles(SRC_ROOT)) { - const rel = relative(SRC_ROOT, file).split(sep).join('/'); - const source = readFileSync(file, 'utf8'); - for (const spec of importSpecifiers(source)) { - for (const fragment of FORBIDDEN_IMPORT_FRAGMENTS) { - if (spec.includes(fragment)) { - offenders.push({ file: rel, spec, fragment }); - } - } - } - } - expect(offenders).toEqual([]); - }); - - it('self-test: scanner detects a forbidden V2 implementation specifier', () => { - const synthetic = `import { x } from '@superdoc/v2-host';\nimport y from './ok.js';`; - const hits = importSpecifiers(synthetic).filter((spec) => FORBIDDEN_IMPORT_FRAGMENTS.some((f) => spec.includes(f))); - expect(hits).toEqual(['@superdoc/v2-host']); - }); - - it('self-test: scanner allows the local seam module specifier', () => { - const synthetic = `import { resolveV2Integration } from './core/v2-integration/v2-integration.js';`; - const hits = importSpecifiers(synthetic).filter((spec) => FORBIDDEN_IMPORT_FRAGMENTS.some((f) => spec.includes(f))); - expect(hits).toEqual([]); - }); -}); diff --git a/packages/superdoc/src/core/v2-integration/v2-integration.js b/packages/superdoc/src/core/v2-integration/v2-integration.js deleted file mode 100644 index a62aaa77b3..0000000000 --- a/packages/superdoc/src/core/v2-integration/v2-integration.js +++ /dev/null @@ -1,141 +0,0 @@ -// Local V2 integration seam. -// -// Public SuperDoc must not depend on V2 implementation packages. -// Instead, the product injects a single V2 integration object through -// `config.editorIntegration`. When no integration is provided, a local stub -// preserves the existing V1 behavior and surfaces a clear runtime error if -// `editorVersion: 2` is requested without an integration. -// -// The integration is the only seam through which a V2 editor runtime reaches -// public SuperDoc. A production V2 package can expose the same shape later. - -import { defineComponent, h, onMounted } from 'vue'; - -/** - * @typedef {Object} SuperDocV2Integration - * @property {number} version Integration contract version. - * @property {unknown} [capabilities] Optional capability snapshot/hints. - * @property {unknown} EditorComponent Vue component that boots the V2 DOCX editor. - * @property {unknown} [RulerComponent] Optional Vue component for the V2 ruler. - * @property {(...args: unknown[]) => unknown} [createGeometryPublisher] Factory for the V2 geometry publisher. - * @property {(...args: unknown[]) => unknown} [createReviewHydrationController] Factory for the V2 review-row hydration controller. - * @property {(value: unknown) => boolean} [isSyntheticTrackedChangeCommentLaneItem] Predicate for synthetic tracked-change comment-lane items. - * @property {(value: unknown) => boolean} [isV2SyntheticTrackedChangeRow] Predicate for synthesized V2 tracked-change rows. - */ - -/** Default version for the local stub integration (V1-compatible). */ -export const SUPERDOC_V2_INTEGRATION_VERSION = 1; - -const V2_SYNTHETIC_TRACKED_CHANGE_COMMENT_ID_PREFIX = 'tc-comment:'; -const V2_BODY_TRACKED_CHANGE_ANCHOR_PREFIX = 'tc::body::'; - -/** - * @param {unknown} item - * @returns {boolean} - */ -export function isSyntheticTrackedChangeCommentLaneItem(item) { - if (!item || typeof item !== 'object') return false; - const id = item.id ?? item.commentId; - if (typeof id !== 'string') return false; - return id.startsWith(V2_SYNTHETIC_TRACKED_CHANGE_COMMENT_ID_PREFIX); -} - -/** - * @param {{ trackedChange?: unknown, trackedChangeAnchorKey?: unknown } | null | undefined} row - * @returns {boolean} - */ -export function isV2SyntheticTrackedChangeRow(row) { - if (!row || row.trackedChange !== true) return false; - const anchorKey = row.trackedChangeAnchorKey; - return typeof anchorKey === 'string' && anchorKey.startsWith(V2_BODY_TRACKED_CHANGE_ANCHOR_PREFIX); -} - -function createStubGeometryPublisher() { - return { - publish() {}, - recollect() {}, - reset() {}, - getLastEpoch() { - return null; - }, - getLastPayload() { - return null; - }, - }; -} - -function createStubReviewHydrationController() { - return { - setContext() {}, - onRenderReadiness() {}, - reset() {}, - getDiagnostics() { - return null; - }, - }; -} - -const StubV2EditorComponent = defineComponent({ - name: 'SuperDocV2IntegrationMissing', - emits: ['v2-editor-failed'], - setup(_props, { emit }) { - onMounted(() => { - emit('v2-editor-failed', { - reason: 'v2-integration-missing', - detail: - 'SuperDoc: editorVersion: 2 requires an editor integration. Pass `editorIntegration` in the SuperDoc config.', - }); - }); - return () => h('div', { class: 'superdoc-v2-integration-missing' }); - }, -}); - -/** - * @returns {SuperDocV2Integration} - */ -export function createStubV2Integration() { - return { - version: SUPERDOC_V2_INTEGRATION_VERSION, - capabilities: null, - EditorComponent: StubV2EditorComponent, - RulerComponent: null, - createGeometryPublisher: createStubGeometryPublisher, - createReviewHydrationController: createStubReviewHydrationController, - isSyntheticTrackedChangeCommentLaneItem, - isV2SyntheticTrackedChangeRow, - }; -} - -/** - * @param {{ editorIntegration?: unknown } | null | undefined} config - * @returns {SuperDocV2Integration} - */ -export function resolveV2Integration(config) { - const stub = createStubV2Integration(); - const candidate = config?.editorIntegration ?? null; - const injected = typeof candidate === 'function' ? candidate() : candidate; - if (!injected || typeof injected !== 'object') return stub; - - /** @type {SuperDocV2Integration} */ - const integration = /** @type {SuperDocV2Integration} */ (injected); - return { - version: typeof integration.version === 'number' ? integration.version : stub.version, - capabilities: integration.capabilities ?? stub.capabilities, - EditorComponent: integration.EditorComponent ?? stub.EditorComponent, - RulerComponent: integration.RulerComponent ?? stub.RulerComponent, - createGeometryPublisher: integration.createGeometryPublisher ?? stub.createGeometryPublisher, - createReviewHydrationController: - integration.createReviewHydrationController ?? stub.createReviewHydrationController, - isSyntheticTrackedChangeCommentLaneItem: - integration.isSyntheticTrackedChangeCommentLaneItem ?? stub.isSyntheticTrackedChangeCommentLaneItem, - isV2SyntheticTrackedChangeRow: integration.isV2SyntheticTrackedChangeRow ?? stub.isV2SyntheticTrackedChangeRow, - }; -} - -/** - * @param {SuperDocV2Integration | null | undefined} integration - * @returns {boolean} - */ -export function hasRealV2Integration(integration) { - return Boolean(integration && integration.EditorComponent && integration.EditorComponent !== StubV2EditorComponent); -} diff --git a/packages/superdoc/src/core/v2-integration/v2-integration.test.js b/packages/superdoc/src/core/v2-integration/v2-integration.test.js deleted file mode 100644 index 2e9cc37dfe..0000000000 --- a/packages/superdoc/src/core/v2-integration/v2-integration.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - resolveV2Integration, - createStubV2Integration, - hasRealV2Integration, - isSyntheticTrackedChangeCommentLaneItem, - isV2SyntheticTrackedChangeRow, - SUPERDOC_V2_INTEGRATION_VERSION, -} from './v2-integration.js'; - -describe('resolveV2Integration', () => { - it('returns the V1-compatible stub when no integration is injected', () => { - const integration = resolveV2Integration({}); - expect(integration.version).toBe(SUPERDOC_V2_INTEGRATION_VERSION); - expect(integration.EditorComponent).toBeTruthy(); - expect(hasRealV2Integration(integration)).toBe(false); - const publisher = integration.createGeometryPublisher({}); - expect(publisher.getLastEpoch()).toBeNull(); - const controller = integration.createReviewHydrationController({}); - expect(controller.getDiagnostics()).toBeNull(); - }); - - it('treats null / non-object integration values as absent', () => { - expect(hasRealV2Integration(resolveV2Integration({ editorIntegration: null }))).toBe(false); - expect(hasRealV2Integration(resolveV2Integration({ editorIntegration: 'nope' }))).toBe(false); - }); - - it('forwards an injected integration through config.editorIntegration', () => { - const EditorComponent = { name: 'RealV2Editor' }; - const RulerComponent = { name: 'RealV2Ruler' }; - const createGeometryPublisher = () => ({ real: true }); - const integration = resolveV2Integration({ - editorIntegration: { version: 2, EditorComponent, RulerComponent, createGeometryPublisher }, - }); - expect(integration.version).toBe(2); - expect(integration.EditorComponent).toBe(EditorComponent); - expect(integration.RulerComponent).toBe(RulerComponent); - expect(integration.createGeometryPublisher).toBe(createGeometryPublisher); - expect(hasRealV2Integration(integration)).toBe(true); - }); - - it('calls config.editorIntegration when a factory is provided', () => { - const EditorComponent = { name: 'RealV2Editor' }; - const createIntegration = () => ({ version: 2, EditorComponent }); - const integration = resolveV2Integration({ editorIntegration: createIntegration }); - expect(integration.EditorComponent).toBe(EditorComponent); - expect(integration.version).toBe(2); - expect(hasRealV2Integration(integration)).toBe(true); - }); - - it('fills missing optional fields from the stub defaults', () => { - const EditorComponent = { name: 'RealV2Editor' }; - const integration = resolveV2Integration({ editorIntegration: { EditorComponent } }); - expect(typeof integration.createReviewHydrationController).toBe('function'); - expect(typeof integration.isSyntheticTrackedChangeCommentLaneItem).toBe('function'); - }); -}); - -describe('synthetic tracked-change predicates', () => { - it('detects synthetic tracked-change comment-lane items', () => { - expect(isSyntheticTrackedChangeCommentLaneItem({ id: 'tc-comment:abc' })).toBe(true); - expect(isSyntheticTrackedChangeCommentLaneItem({ commentId: 'tc-comment:abc' })).toBe(true); - expect(isSyntheticTrackedChangeCommentLaneItem({ id: 'real-comment' })).toBe(false); - expect(isSyntheticTrackedChangeCommentLaneItem(null)).toBe(false); - }); - - it('detects synthesized V2 body tracked-change rows', () => { - expect(isV2SyntheticTrackedChangeRow({ trackedChange: true, trackedChangeAnchorKey: 'tc::body::1' })).toBe(true); - expect(isV2SyntheticTrackedChangeRow({ trackedChange: true, trackedChangeAnchorKey: 'other::1' })).toBe(false); - expect(isV2SyntheticTrackedChangeRow({ trackedChange: false })).toBe(false); - }); -}); - -describe('createStubV2Integration', () => { - it('produces an EditorComponent that fails closed in V2 mode', () => { - const stub = createStubV2Integration(); - expect(stub.EditorComponent).toBeTruthy(); - expect(stub.RulerComponent).toBeNull(); - }); -}); diff --git a/packages/superdoc/src/core/v2-integration/v2-review-mutation-route.test.ts b/packages/superdoc/src/core/v2-integration/v2-review-mutation-route.test.ts deleted file mode 100644 index 19b95bb9c3..0000000000 --- a/packages/superdoc/src/core/v2-integration/v2-review-mutation-route.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Mutation-plane consolidation guard. -// -// SuperDoc v2 review mutations (comments + tracked changes) must route through -// the synchronous Document API surface (`activeEditor.doc.comments.*`, -// `activeEditor.doc.trackChanges.decide`). The public/shell review-mutation -// surfaces (comments store, comments-layer UI, the SuperDoc Vue component, and -// the v2 shell runtime adapter) must not regress back to host-dispatch review -// commands or to direct `activeEditor.v2Comments.` / -// `activeEditor.v2TrackedChanges.accept|reject` calls. Those bridge surfaces may -// remain as read/focus/reveal/active-target helpers, or as explicitly named -// compatibility wrappers (in the private v2 browser shell) that delegate to -// `activeEditor.doc.*`. -// -// This static guard fails if a public review-mutation surface reintroduces a -// dispatch-backed or bridge-mutation review route. - -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const PKG_SRC = join(__dirname, '..', '..'); - -// Public review-mutation surfaces in scope for this guard. If one of these -// files moves, the guard should fail loudly so coverage is updated instead of -// silently disappearing. -const REVIEW_MUTATION_SURFACES = [ - 'stores/comments-store.js', - 'components/CommentsLayer/CommentDialog.vue', - 'components/CommentsLayer/commentsList/commentsList.vue', - 'SuperDoc.vue', - 'core/editor-runtime/v2/v2-editor-runtime-adapter.ts', -]; - -// Strip line and block comments so guidance comments that mention the old -// routes do not trip the scanner. -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); -} - -const FORBIDDEN_PATTERNS: { label: string; re: RegExp }[] = [ - // Review commands must not be mapped into host dispatch. Non-review v2 - // runtime commands (text/structure/history fallback) may still use - // host.dispatch while that private cleanup remains out of scope. - { - label: 'host-dispatch review command', - re: /(?:host\.dispatch\s*\(\s*\{[\s\S]{0,160}?kind\s*:|mapped\s*=\s*\{\s*kind\s*:)\s*['"]review\.(?:comment\w+|trackedChangeDecide)['"]/, - }, - // Direct bridge mutation method on the active-editor facade. - { - label: 'activeEditor.v2Comments.', - re: /\.v2Comments\s*[?.]*\.(commitPendingComment|reply|edit|resolve|reopen|delete)\s*\(/, - }, - { - label: 'activeEditor.v2TrackedChanges.accept|reject', - re: /\.v2TrackedChanges\s*[?.]*\.(accept|reject|acceptAll|rejectAll)\s*\(/, - }, -]; - -describe('public v2 review mutation route guard', () => { - it('public review-mutation surfaces do not dispatch or call bridge mutation methods directly', () => { - const offenders: { file: string; pattern: string }[] = []; - for (const rel of REVIEW_MUTATION_SURFACES) { - let source: string; - try { - source = readFileSync(join(PKG_SRC, rel), 'utf8'); - } catch (error) { - throw new Error(`review mutation route guard could not read ${rel}: ${String(error)}`); - } - const code = stripComments(source); - for (const { label, re } of FORBIDDEN_PATTERNS) { - if (re.test(code)) offenders.push({ file: rel, pattern: label }); - } - } - expect(offenders).toEqual([]); - }); - - it('self-test: scanner flags host-dispatch review command', () => { - const synthetic = stripComments(`const r = await host.dispatch({ kind: 'review.trackedChangeDecide', input });`); - const hit = FORBIDDEN_PATTERNS.some(({ re }) => re.test(synthetic)); - expect(hit).toBe(true); - }); - - it('self-test: scanner flags direct bridge mutation method', () => { - const synthetic = stripComments(`await superdoc.activeEditor.v2TrackedChanges.accept(row);`); - const hit = FORBIDDEN_PATTERNS.some(({ re }) => re.test(synthetic)); - expect(hit).toBe(true); - }); - - it('self-test: scanner ignores forbidden routes that appear only in comments', () => { - const synthetic = stripComments( - `// route through host.dispatch({ kind: 'review.trackedChangeDecide' })\nconst x = 1;`, - ); - const hit = FORBIDDEN_PATTERNS.some(({ re }) => re.test(synthetic)); - expect(hit).toBe(false); - }); - - it('self-test: scanner allows Document API mutation route', () => { - const synthetic = stripComments(`const receipt = activeEditor.doc.trackChanges.decide({ decision, target });`); - const hit = FORBIDDEN_PATTERNS.some(({ re }) => re.test(synthetic)); - expect(hit).toBe(false); - }); -}); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 8ce4d19929..73742d757d 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -35,6 +35,9 @@ const sidebarInstanceKey = ref(0); const compareInput = ref(null); const urlParams = new URLSearchParams(window.location.search); +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 ensureStableDevTabId = () => { const storageKey = 'superdoc-dev-tab-id'; @@ -58,7 +61,19 @@ const trackChangesReplacements = ref(urlParams.get('replacements') === 'independ const useCollaboration = urlParams.get('collab') === '1'; const collabRoom = urlParams.get('room') || 'superdoc-dev-room'; const collabUrl = 'ws://localhost:8081/v1/collaboration'; +const useWordOverlay = ref(urlParams.get('wordOverlay') !== '0'); +const wordOverlayOpacity = ref(Number.isFinite(overlayOpacityFromUrl) ? clampOpacity(overlayOpacityFromUrl) : 0.45); +const wordOverlayBlendMode = ref(urlParams.get('wordOverlayBlend') || 'difference'); const selectedTheme = ref('default'); +const generatedWordScreenshots = ref([]); +const isGeneratingWordBaseline = ref(false); +const wordBaselineStatus = ref(''); +const wordBaselineError = ref(''); +const wordOverlayOpacityLabel = computed(() => `${Math.round(wordOverlayOpacity.value * 100)}%`); +const wordOverlayAvailable = computed( + () => useLayoutEngine.value && !useWebLayout.value && generatedWordScreenshots.value.length > 0, +); +let wordOverlayLayoutUnsubscribe = null; // Collaboration state const ydocRef = shallowRef(null); @@ -111,6 +126,87 @@ const user = { email: testUserEmail, }; +const getSuperdocRoot = () => document.getElementById('superdoc'); + +const removeWordOverlay = () => { + const root = getSuperdocRoot(); + if (!root) return; + root.querySelectorAll('.dev-word-overlay-image').forEach((node) => node.remove()); + root.querySelectorAll('.dev-word-overlay-page-host').forEach((node) => { + node.classList.remove('dev-word-overlay-page-host'); + }); +}; + +const applyWordOverlay = () => { + const root = getSuperdocRoot(); + if (!root) return; + + if (!useWordOverlay.value || !wordOverlayAvailable.value) { + removeWordOverlay(); + return; + } + + const pageNodes = Array.from(root.querySelectorAll('.superdoc-page[data-page-index]')); + pageNodes.forEach((pageNode, index) => { + const pageIndexRaw = Number.parseInt(pageNode.getAttribute('data-page-index') ?? String(index), 10); + const pageNumber = Number.isFinite(pageIndexRaw) ? pageIndexRaw + 1 : index + 1; + const screenshotUrl = generatedWordScreenshots.value[pageNumber - 1]; + let overlayNode = pageNode.querySelector(':scope > .dev-word-overlay-image'); + + if (!screenshotUrl) { + overlayNode?.remove(); + pageNode.classList.remove('dev-word-overlay-page-host'); + return; + } + + if (!overlayNode) { + overlayNode = document.createElement('img'); + overlayNode.className = 'dev-word-overlay-image'; + overlayNode.setAttribute('alt', `Word screenshot page ${pageNumber}`); + overlayNode.setAttribute('draggable', 'false'); + pageNode.appendChild(overlayNode); + } + + pageNode.classList.add('dev-word-overlay-page-host'); + overlayNode.setAttribute('src', screenshotUrl); + overlayNode.style.opacity = String(wordOverlayOpacity.value); + overlayNode.style.mixBlendMode = wordOverlayBlendMode.value; + }); +}; + +const scheduleWordOverlayApply = () => { + nextTick(() => { + requestAnimationFrame(() => { + applyWordOverlay(); + }); + }); +}; + +const detachWordOverlayListener = () => { + if (typeof wordOverlayLayoutUnsubscribe === 'function') { + wordOverlayLayoutUnsubscribe(); + } + wordOverlayLayoutUnsubscribe = null; +}; + +const bindWordOverlayListener = (editor) => { + detachWordOverlayListener(); + const presentationEditor = editor?.presentationEditor; + if (presentationEditor?.onLayoutUpdated) { + wordOverlayLayoutUnsubscribe = presentationEditor.onLayoutUpdated(() => { + scheduleWordOverlayApply(); + }); + } + scheduleWordOverlayApply(); +}; + +const clearGeneratedWordBaseline = () => { + generatedWordScreenshots.value = []; + wordBaselineStatus.value = ''; + wordBaselineError.value = ''; + scheduleWordOverlayApply(); +}; + const commentPermissionResolver = ({ permission, comment, defaultDecision, currentUser }) => { if (!comment) return defaultDecision; @@ -128,6 +224,7 @@ const commentPermissionResolver = ({ permission, comment, defaultDecision, curre }; const handleNewFile = async (file) => { + clearGeneratedWordBaseline(); uploadedFileName.value = file?.name || ''; // Generate a file url const url = URL.createObjectURL(file); @@ -888,6 +985,99 @@ const exportDocxBlob = async () => { console.debug(blob); }; +const blobToBase64 = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') { + reject(new Error('Unable to encode DOCX export')); + return; + } + + const commaIndex = result.indexOf(','); + resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result); + }; + reader.onerror = () => { + reject(reader.error || new Error('Failed to read DOCX export blob')); + }; + reader.readAsDataURL(blob); + }); + +const getWordBaselineFileName = () => { + const source = uploadedFileName.value || currentFile.value?.name || title.value || 'document'; + const trimmedSource = String(source).trim() || 'document'; + const withoutExtension = trimmedSource.replace(/\.[^.]+$/, '') || 'document'; + return `${withoutExtension}.docx`; +}; + +const generateWordBaseline = async () => { + if (!superdoc.value) { + wordBaselineError.value = 'SuperDoc is not ready yet.'; + return; + } + + isGeneratingWordBaseline.value = true; + wordBaselineError.value = ''; + wordBaselineStatus.value = 'Exporting current document...'; + + try { + const exportBlob = await superdoc.value.export({ + commentsType: 'external', + triggerDownload: false, + }); + + if (!(exportBlob instanceof Blob)) { + throw new Error('SuperDoc export did not return a DOCX blob'); + } + + const response = await fetch(`${wordBaselineServiceUrl}/api/word-baseline`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileName: getWordBaselineFileName(), + docxBase64: await blobToBase64(exportBlob), + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || `Word reference request failed (${response.status})`); + } + + if (!Array.isArray(payload?.pages) || payload.pages.length === 0) { + throw new Error('Word reference request completed but returned no page images'); + } + + generatedWordScreenshots.value = payload.pages; + useWordOverlay.value = true; + wordBaselineStatus.value = `Generated ${payload.pages.length} Word reference page(s).`; + scheduleWordOverlayApply(); + } catch (error) { + wordBaselineStatus.value = ''; + wordBaselineError.value = error instanceof Error ? error.message : String(error); + console.error('[SuperDoc Dev] Failed to generate Word reference:', error); + } finally { + isGeneratingWordBaseline.value = false; + } +}; + +const toggleWordOverlay = () => { + useWordOverlay.value = !useWordOverlay.value; +}; + +const setWordOverlayOpacity = (value) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) return; + wordOverlayOpacity.value = clampOpacity(numericValue); +}; + +const setWordOverlayBlendMode = (value) => { + wordOverlayBlendMode.value = String(value || 'difference'); +}; + const downloadBlob = (blob, fileName) => { if (!blob) return; const url = URL.createObjectURL(blob); @@ -917,6 +1107,7 @@ const getActiveDocumentEntry = () => { const onEditorCreate = ({ editor }) => { activeEditor.value = editor; window.editor = editor; + bindWordOverlayListener(editor); editor.on('fieldAnnotationClicked', (params) => { console.log('fieldAnnotationClicked', { params }); @@ -944,6 +1135,13 @@ const onEditorCreate = ({ editor }) => { }); }; +watch( + [useWordOverlay, wordOverlayOpacity, wordOverlayBlendMode, generatedWordScreenshots, useLayoutEngine, useWebLayout], + () => { + scheduleWordOverlayApply(); + }, +); + watch(selectedTheme, (theme) => { applyDevTheme(theme); }); @@ -1009,6 +1207,8 @@ onMounted(async () => { onBeforeUnmount(() => { applyDevTheme('default'); + detachWordOverlayListener(); + removeWordOverlay(); if (typeof removeYjsObservers === 'function') { removeYjsObservers(); @@ -1123,6 +1323,15 @@ const activeSidebarProps = computed(() => { if (activeSidebarId.value === 'layout') { return { useWebLayout: useWebLayout.value, + useWordOverlay: useWordOverlay.value, + isGeneratingWordBaseline: isGeneratingWordBaseline.value, + generatedCount: generatedWordScreenshots.value.length, + wordOverlayOpacity: wordOverlayOpacity.value, + wordOverlayOpacityLabel: wordOverlayOpacityLabel.value, + wordOverlayBlendMode: wordOverlayBlendMode.value, + wordBaselineStatus: wordBaselineStatus.value, + wordBaselineError: wordBaselineError.value, + wordOverlayAvailable: wordOverlayAvailable.value, }; } @@ -1382,7 +1591,12 @@ if (scrollTestMode.value) { :key="`${activeSidebarId}-${sidebarInstanceKey}`" v-bind="activeSidebarProps" @close="setActiveSidebar('off')" + @toggle-overlay="toggleWordOverlay" @toggle-web-layout="toggleViewLayout" + @generate-baseline="generateWordBaseline" + @clear-generated-baseline="clearGeneratedWordBaseline" + @update:word-overlay-opacity="setWordOverlayOpacity" + @update:word-overlay-blend-mode="setWordOverlayBlendMode" @clear-yjs-events="clearYjsChanges" />
@@ -1995,6 +2209,21 @@ if (scrollTestMode.value) { overflow-x: hidden; } +:deep(.dev-word-overlay-page-host) { + position: relative; + overflow: hidden; +} + +:deep(.dev-word-overlay-image) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; + pointer-events: none; + z-index: 120; +} + .dev-app__inputs-panel { display: grid; height: calc(100vh - var(--header-height) - var(--toolbar-height)); diff --git a/packages/superdoc/src/dev/components/sidebar/SidebarLayout.vue b/packages/superdoc/src/dev/components/sidebar/SidebarLayout.vue index e8d5614202..3dec584183 100644 --- a/packages/superdoc/src/dev/components/sidebar/SidebarLayout.vue +++ b/packages/superdoc/src/dev/components/sidebar/SidebarLayout.vue @@ -1,12 +1,70 @@