From 90ed709c7d181da95e658e0844c2d4f05f5c60f9 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:51:03 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=94=84=20Sync=20stable=20=E2=86=92?= =?UTF-8?q?=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Merges latest stable patches into main with a real merge commit so stable release tags remain reachable from main. This keeps `@next` prerelease version calculation ahead of the latest published stable release. ## Merge requirement This PR must be merged with GitHub's merge-commit option. Squash or rebase merging will discard stable ancestry and break `@next` version calculation again. ## Conflict handling Merge status: `clean`. Version-only release artifact conflicts are auto-resolved to keep stable's already-published versions. Non-version conflicts fail this workflow instead of committing conflict markers. --- _Auto-created by sync-patches workflow._ Source-PR: https://github.com/superdoc-dev/superdoc/pull/3762 Closes superdoc-dev/superdoc#3762 Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: d11d4f4fa2d1b53fb4c1515f4e1866aeb22a6bde Ported-Public-Prefix: superdoc/public --- apps/cli/package.json | 2 +- .../platforms/cli-darwin-arm64/package.json | 2 +- .../cli/platforms/cli-darwin-x64/package.json | 2 +- .../platforms/cli-linux-arm64/package.json | 2 +- apps/cli/platforms/cli-linux-x64/package.json | 2 +- .../platforms/cli-windows-x64/package.json | 2 +- .../lib/mutation-orchestrator.test.ts | 126 ++++++++++++++++++ apps/cli/src/lib/mutation-orchestrator.ts | 14 +- apps/cli/src/lib/read-orchestrator.ts | 8 +- apps/mcp/package.json | 2 +- apps/vscode-ext/package.json | 2 +- packages/fonts/package.json | 2 +- packages/react/package.json | 2 +- packages/sdk/langs/node/package.json | 2 +- .../platforms/sdk-darwin-arm64/package.json | 2 +- .../platforms/sdk-darwin-x64/package.json | 2 +- .../platforms/sdk-linux-arm64/package.json | 2 +- .../node/platforms/sdk-linux-x64/package.json | 2 +- .../platforms/sdk-windows-x64/package.json | 2 +- .../pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../superdoc-sdk-cli-linux-x64/pyproject.toml | 2 +- .../pyproject.toml | 2 +- packages/sdk/langs/python/pyproject.toml | 12 +- packages/sdk/package.json | 2 +- packages/sdk/version.json | 2 +- packages/superdoc/package.json | 2 +- packages/template-builder/package.json | 2 +- 29 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/mutation-orchestrator.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 74541e5631..12fff63ff7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli", - "version": "0.18.0", + "version": "0.19.0", "type": "module", "bin": { "superdoc": "./dist/index.js" diff --git a/apps/cli/platforms/cli-darwin-arm64/package.json b/apps/cli/platforms/cli-darwin-arm64/package.json index e968dfe24b..c2b415a4b0 100644 --- a/apps/cli/platforms/cli-darwin-arm64/package.json +++ b/apps/cli/platforms/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-darwin-arm64", - "version": "0.18.0", + "version": "0.19.0", "os": [ "darwin" ], diff --git a/apps/cli/platforms/cli-darwin-x64/package.json b/apps/cli/platforms/cli-darwin-x64/package.json index b30aa2145a..5d662a405a 100644 --- a/apps/cli/platforms/cli-darwin-x64/package.json +++ b/apps/cli/platforms/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-darwin-x64", - "version": "0.18.0", + "version": "0.19.0", "os": [ "darwin" ], diff --git a/apps/cli/platforms/cli-linux-arm64/package.json b/apps/cli/platforms/cli-linux-arm64/package.json index 957f33a36a..ad7a17360f 100644 --- a/apps/cli/platforms/cli-linux-arm64/package.json +++ b/apps/cli/platforms/cli-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-linux-arm64", - "version": "0.18.0", + "version": "0.19.0", "os": [ "linux" ], diff --git a/apps/cli/platforms/cli-linux-x64/package.json b/apps/cli/platforms/cli-linux-x64/package.json index d2f8410a36..5a162a921d 100644 --- a/apps/cli/platforms/cli-linux-x64/package.json +++ b/apps/cli/platforms/cli-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-linux-x64", - "version": "0.18.0", + "version": "0.19.0", "os": [ "linux" ], diff --git a/apps/cli/platforms/cli-windows-x64/package.json b/apps/cli/platforms/cli-windows-x64/package.json index 12e5c5bb91..40687c81bf 100644 --- a/apps/cli/platforms/cli-windows-x64/package.json +++ b/apps/cli/platforms/cli-windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/cli-windows-x64", - "version": "0.18.0", + "version": "0.19.0", "os": [ "win32" ], diff --git a/apps/cli/src/__tests__/lib/mutation-orchestrator.test.ts b/apps/cli/src/__tests__/lib/mutation-orchestrator.test.ts new file mode 100644 index 0000000000..42db2b891b --- /dev/null +++ b/apps/cli/src/__tests__/lib/mutation-orchestrator.test.ts @@ -0,0 +1,126 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, test } from 'bun:test'; +import type { OpenedRuntimeDocument } from '../../lib/document'; +import { CliError } from '../../lib/errors'; +import { invokeOpenedDocumentOperation } from '../../lib/mutation-orchestrator'; + +type DocInvoke = OpenedRuntimeDocument['doc']['invoke']; + +// Mirrors publicTrackChangeIdForRawId in special-handlers.ts for a single id: +// composite raw ids (with `|`) collapse to a SHA-1 stable id, so the decide +// pre-hook has to translate stable -> raw against the live list. +function stableTrackChangeId(rawId: string): string { + if (rawId.length <= 64 && !/[|/\\]/.test(rawId)) return rawId; + return createHash('sha1').update(rawId).digest('hex').slice(0, 24); +} + +// A host session hands every operation a fresh lease whose `.doc` is the SAME +// pooled object (session-pool.ts createLease: `lease.doc = pooled.doc`). The +// orchestrator then builds a fresh per-call `invoke` closure over that stable +// doc. Returning a new lease object per call while sharing one `doc` reproduces +// exactly that shape; `invokeOpenedDocumentOperation` only ever touches +// `opened.doc.invoke`, so the minimal lease is faithful at runtime. +function hostSession(invoke: DocInvoke): () => OpenedRuntimeDocument { + const doc = { invoke }; + return () => ({ runtime: 'v1', doc }) as unknown as OpenedRuntimeDocument; +} + +const acceptDecided = (rawId: string) => ({ + success: true, + removed: [{ entityType: 'trackedChange', entityId: rawId }], + invalidatedRefs: [], +}); + +describe('mutation orchestrator track-change scope', () => { + test('repeat decide of an already-resolved change reports NO_OP across host calls', async () => { + const rawId = 'tc|main:/word/document.xml|ins|ScopeTester|2026-05-20T16:30:00Z|7'; + const stableId = stableTrackChangeId(rawId); + const change = { + id: rawId, + type: 'insertion', + author: 'ScopeTester', + authorEmail: '', + date: '2026-05-20T16:30:00Z', + excerpt: 'resolve me once', + }; + + // After the first accept the change leaves the list, like the real adapter: + // the second decide can no longer translate the stable id off the live list, + // so the only thing that can produce NO_OP is the per-document resolved-id + // memory surviving between calls. + let listItems: { items: unknown[] } = { items: [change] }; + const invoke: DocInvoke = (request) => { + if (request.operationId === 'trackChanges.list') return listItems; + if (request.operationId === 'trackChanges.decide') { + listItems = { items: [] }; + return acceptDecided(rawId); + } + throw new Error(`unexpected operation ${request.operationId}`); + }; + const lease = hostSession(invoke); + + const first = await invokeOpenedDocumentOperation(lease(), 'trackChanges.decide', { + decision: 'accept', + target: { kind: 'id', id: stableId }, + }); + expect((first.result as { success?: boolean }).success).toBe(true); + + // Fresh lease => fresh per-call invoke closure; only the shared opened.doc + // keeps the resolved-id memory alive. Must be NO_OP, not a fall-through that + // hands a stale id to the adapter and surfaces TARGET_NOT_FOUND. + let thrown: unknown; + try { + await invokeOpenedDocumentOperation(lease(), 'trackChanges.decide', { + decision: 'accept', + target: { kind: 'id', id: stableId }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(CliError); + expect((thrown as CliError).code).toBe('NO_OP'); + }); + + test('resolved-id memory stays scoped to its own document', async () => { + const rawId = 'tc|main:/word/document.xml|ins|DocScopeTester|2026-05-20T16:30:00Z|9'; + const stableId = stableTrackChangeId(rawId); + const change = { + id: rawId, + type: 'insertion', + author: 'DocScopeTester', + authorEmail: '', + date: '2026-05-20T16:30:00Z', + excerpt: 'doc a only', + }; + + // Document A: accept the change, after which it is gone from A's list. + let aItems: { items: unknown[] } = { items: [change] }; + const docA = hostSession((request) => { + if (request.operationId === 'trackChanges.list') return aItems; + if (request.operationId === 'trackChanges.decide') { + aItems = { items: [] }; + return acceptDecided(rawId); + } + throw new Error(`unexpected operation ${request.operationId}`); + }); + await invokeOpenedDocumentOperation(docA(), 'trackChanges.decide', { + decision: 'accept', + target: { kind: 'id', id: stableId }, + }); + + // Document B never resolved this id and has an empty list, so the stable id + // cannot be translated. With per-document scope B's own resolved set is + // empty, so this must fall through and decide (not short-circuit to NO_OP). + // A global/module-level scope would wrongly find A's id and throw NO_OP. + const docB = hostSession((request) => { + if (request.operationId === 'trackChanges.list') return { items: [] }; + if (request.operationId === 'trackChanges.decide') return acceptDecided(rawId); + throw new Error(`unexpected operation ${request.operationId}`); + }); + const result = await invokeOpenedDocumentOperation(docB(), 'trackChanges.decide', { + decision: 'accept', + target: { kind: 'id', id: stableId }, + }); + expect((result.result as { success?: boolean }).success).toBe(true); + }); +}); diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index 7a5cfc1bcb..44c0ca9be3 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -72,7 +72,7 @@ function deriveCommandName(operationId: CliExposedOperationId): string { return cliCommandTokens(`doc.${operationId}` as `doc.${CliExposedOperationId}`).join(' '); } -async function invokeOpenedDocumentOperation( +export async function invokeOpenedDocumentOperation( opened: OpenedRuntimeDocument, operationId: CliExposedOperationId, input: Record, @@ -82,8 +82,16 @@ async function invokeOpenedDocumentOperation( ): Promise { const apiInput = extractInvokeInput(operationId, input); const invoke = (request: { operationId: string; input?: unknown; options?: unknown }) => opened.doc.invoke(request); + // AIDEV-NOTE: Pass the stable opened.doc handle so special-handlers.ts keys + // per-document hook state (resolved track-change ids) by the document, never + // by the per-call `invoke` closure below. In host mode the session pool hands + // every operation the SAME opened.doc (session-pool.ts createLease: + // `lease.doc = pooled.doc`), so this is what lets that state persist across + // calls in a session. Drop editor.doc here and already-resolved NO_OP + // detection silently breaks: a repeat decide surfaces TARGET_NOT_FOUND. + const hookContext = { invoke, editor: { doc: opened.doc } }; const preHook = PRE_INVOKE_HOOKS[operationId]; - const transformedInput = preHook ? preHook(apiInput as Record, { invoke }) : apiInput; + const transformedInput = preHook ? preHook(apiInput as Record, hookContext) : apiInput; let result: unknown; try { @@ -109,7 +117,7 @@ async function invokeOpenedDocumentOperation( const postHook = POST_INVOKE_HOOKS[operationId]; return { - result: postHook ? postHook(result, { invoke, apiInput: transformedInput }) : result, + result: postHook ? postHook(result, { ...hookContext, apiInput: transformedInput }) : result, failedReceipt, }; } diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index 3677ae79d5..38b32dc177 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -43,8 +43,12 @@ function invokeOpenedDocumentOperation( ): unknown { const apiInput = extractInvokeInput(operationId, input); const invoke = (request: { operationId: string; input?: unknown; options?: unknown }) => opened.doc.invoke(request); + // AIDEV-NOTE: Key per-document hook state by the stable opened.doc handle, not + // the per-call `invoke` closure, so reads and mutations in the same host + // session share one scope. See mutation-orchestrator.ts for the full rationale. + const hookContext = { invoke, editor: { doc: opened.doc } }; const preHook = PRE_INVOKE_HOOKS[operationId]; - const transformedInput = preHook ? preHook(apiInput as Record, { invoke }) : apiInput; + const transformedInput = preHook ? preHook(apiInput as Record, hookContext) : apiInput; let result: unknown; try { @@ -57,7 +61,7 @@ function invokeOpenedDocumentOperation( } const postHook = POST_INVOKE_HOOKS[operationId]; - return postHook ? postHook(result, { invoke, apiInput: transformedInput }) : result; + return postHook ? postHook(result, { ...hookContext, apiInput: transformedInput }) : result; } /** diff --git a/apps/mcp/package.json b/apps/mcp/package.json index cb1a137362..54a003a735 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/mcp", - "version": "0.13.0", + "version": "0.14.0", "type": "module", "engines": { "node": ">=20" diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json index 70cea33374..73c14a9c1e 100644 --- a/apps/vscode-ext/package.json +++ b/apps/vscode-ext/package.json @@ -2,7 +2,7 @@ "name": "superdoc-vscode-ext", "displayName": "SuperDoc - Word Document Editor", "description": "Open and edit Word documents (.docx) directly in VS Code using SuperDoc", - "version": "2.13.0", + "version": "2.14.0", "publisher": "superdoc-dev", "icon": "logo.png", "homepage": "https://superdoc.dev", diff --git a/packages/fonts/package.json b/packages/fonts/package.json index 4b4b6fde9d..d2e88342b5 100644 --- a/packages/fonts/package.json +++ b/packages/fonts/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/fonts", - "version": "0.1.1", + "version": "0.1.2", "description": "Optional bundled metric-compatible font substitutes for SuperDoc, shipped as bundler-resolvable assets.", "type": "module", "license": "SEE LICENSE IN assets/LICENSES.md", diff --git a/packages/react/package.json b/packages/react/package.json index 3124a9f851..c53f577038 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/react", - "version": "1.12.0", + "version": "1.13.0", "description": "Official React wrapper for the SuperDoc document editor", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sdk/langs/node/package.json b/packages/sdk/langs/node/package.json index 95d581319b..628ed357e5 100644 --- a/packages/sdk/langs/node/package.json +++ b/packages/sdk/langs/node/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk", - "version": "1.17.0", + "version": "1.18.0", "private": false, "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json index 65dbcc8afb..2d488c1a30 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-arm64", - "version": "1.17.0", + "version": "1.18.0", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json index 45b3cf1aca..3cb22347ff 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-x64", - "version": "1.17.0", + "version": "1.18.0", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json index eedce9b981..3473942a6d 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-arm64", - "version": "1.17.0", + "version": "1.18.0", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json index f7cdeb6c82..0bc8acecdc 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-x64", - "version": "1.17.0", + "version": "1.18.0", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json index 0c2bf6d74d..70d5e093f1 100644 --- a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-windows-x64", - "version": "1.17.0", + "version": "1.18.0", "os": [ "win32" ], diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml index 5422fbbce4..144f4fb90a 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-arm64" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc CLI binary for macOS ARM64 (Apple Silicon)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml index 2539f6f502..f32001365a 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-x64" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc CLI binary for macOS x64 (Intel)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml index 200d2efeae..7ecdb6419d 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-arm64" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc CLI binary for Linux ARM64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml index b802ab4c67..cf9f9be2dd 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-x64" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc CLI binary for Linux x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml index 287c24f543..9db3f7d01d 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-windows-x64" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc CLI binary for Windows x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/pyproject.toml b/packages/sdk/langs/python/pyproject.toml index f787ad6459..2c631e4f2e 100644 --- a/packages/sdk/langs/python/pyproject.toml +++ b/packages/sdk/langs/python/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk" -version = "1.17.0" +version = "1.18.0" description = "SuperDoc SDK (CLI-backed)" readme = "README.md" requires-python = ">=3.9" license = "AGPL-3.0" authors = [{ name = "SuperDoc" }] dependencies = [ - "superdoc-sdk-cli-darwin-arm64==1.17.0; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-darwin-x64==1.17.0; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-x64==1.17.0; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-arm64==1.17.0; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-windows-x64==1.17.0; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-darwin-arm64==1.18.0; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-darwin-x64==1.18.0; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-x64==1.18.0; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-arm64==1.18.0; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-windows-x64==1.18.0; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", ] [tool.setuptools] diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3891cf089b..f345ad24bf 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-workspace", - "version": "1.17.0", + "version": "1.18.0", "private": true, "type": "module", "scripts": { diff --git a/packages/sdk/version.json b/packages/sdk/version.json index d56d8e8b70..8244fe58e2 100644 --- a/packages/sdk/version.json +++ b/packages/sdk/version.json @@ -1,3 +1,3 @@ { - "sdkVersion": "1.17.0" + "sdkVersion": "1.18.0" } diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 7706119cd9..af80d21e2c 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -1,7 +1,7 @@ { "name": "superdoc", "type": "module", - "version": "1.41.0", + "version": "1.42.0", "license": "AGPL-3.0", "repository": { "type": "git", diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 6abd1624cb..f6c2411b21 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/template-builder", - "version": "1.12.0", + "version": "1.12.1", "description": "React template builder component for SuperDoc", "type": "module", "main": "./dist/index.js", From 251a1fd66256469a81d16e963bddd1492a859a07 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:01:26 -0300 Subject: [PATCH 02/17] Merge pull request #160 from superdoc/luccas/sd-3478-bug-atomic-tracked-textrewrite-batch-corrupts-later-rewrite fix(plan-engine): prevent tracked rewrite batches from corrupting later rewrites (SD-3478) Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: fe95fa3ce5448926a1c5df49bb0367d8346790b1 Ported-Public-Prefix: superdoc/public --- .../plan-engine/executor.test.ts | 231 +++++++++++++ .../plan-engine/executor.ts | 86 ++++- .../plan-engine/preview-parity.test.ts | 58 +++- .../plan-engine/preview.ts | 11 +- .../tracked-rewrite.integration.test.ts | 317 ++++++++++++++++++ 5 files changed, 681 insertions(+), 22 deletions(-) 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..05789bc74a 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,51 @@ 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'; 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 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 +921,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 +940,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 +971,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 +996,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 +1005,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,6 +1021,11 @@ export function executeTextRewrite( const trimmedOld = originalText.slice(prefix, origLen - suffix); const trimmedNew = replacementText.slice(prefix, replLen - suffix); + if (isTrackedRewriteBatchTransaction(tr)) { + replaceTextRange(trimmedFrom, trimmedTo, trimmedNew, trimmedFrom); + return finish(replacementText !== target.text); + } + // 2. Word-level diff on the trimmed range for multi-word granularity. const wordChanges = getWordChanges(trimmedOld, trimmedNew); @@ -1031,7 +1083,7 @@ export function executeTextRewrite( tr.replaceWith(trimmedFrom, trimmedTo, content); } - return { changed: replacementText !== target.text }; + return finish(replacementText !== target.text); } /** @@ -1347,6 +1399,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 +1410,7 @@ export function executeSpanTextRewrite( parentAllowsLineBreak, }); tr.replaceWith(absFrom, absTo, content); + markTrackedRewriteChanged(tr); return { changed: true }; } @@ -1383,6 +1438,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 +2434,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/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.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts index 7c92d330f3..265d6a6344 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 @@ -3,6 +3,7 @@ import { initTestEditor } from '@tests/helpers/helpers.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'; function makeEditor(paragraphs: string[] = ['hello world']) { return initTestEditor({ @@ -25,6 +26,70 @@ 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), + }; +} + +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', + '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.', + 1, + ), + paragraph( + 'GIFTITEM', + '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.', + 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; +} + function getFirstMatchRef(editor: any, pattern: string): string { const match = editor.doc.query.match({ select: { type: 'text', pattern }, @@ -83,6 +148,24 @@ function markedTextByAuthor(editor: any, markName: string, authorEmail: string): 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 +369,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 +626,210 @@ 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 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('barbar baz'); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toBe('qux zapqux 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); + }); }); From 6184e2589d43d5e3e3184a1cf845e4eda96c3ff4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 14:15:51 -0300 Subject: [PATCH 03/17] Merge pull request #162 from superdoc/artem/doc-diffing-v5 feat: document diffing upgrades Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 51517137444ab5cf3a865566705071ed1fabca20 Ported-Public-Prefix: superdoc/public --- apps/docs/document-engine/diffing.mdx | 3 +- .../diffing/algorithm/attributes-diffing.ts | 2 +- .../diffing/algorithm/generic-diffing.test.js | 177 ++++++ .../diffing/algorithm/generic-diffing.ts | 156 ++++- .../diffing/algorithm/identity-attrs.ts | 7 + .../diffing/algorithm/inline-diffing.test.js | 398 +++++++++++- .../diffing/algorithm/inline-diffing.ts | 543 ++++++++++++++-- .../algorithm/paragraph-diffing.test.js | 31 +- .../diffing/algorithm/paragraph-diffing.ts | 31 +- .../algorithm/semantic-normalization.test.ts | 17 + .../algorithm/semantic-normalization.ts | 75 ++- .../v1/extensions/diffing/computeDiff.test.js | 600 +++++++++++++++++- .../editors/v1/extensions/diffing/diffing.js | 13 + .../extensions/diffing/headerFooters.test.ts | 45 +- .../diffing/replay/replay-inline.test.js | 204 +++++- .../diffing/replay/replay-inline.ts | 108 ++-- .../v1/extensions/diffing/replayDiffs.test.js | 203 ++++++ .../v1/extensions/diffing/replayDiffs.ts | 17 + .../src/editors/v1/tests/data/.gitignore | 3 + .../v1/tests/data/diffing/word/doc_a.docx | Bin 0 -> 13629 bytes .../v1/tests/data/diffing/word/doc_a1.docx | Bin 0 -> 13864 bytes .../v1/tests/data/diffing/word/doc_a2.docx | Bin 0 -> 13500 bytes .../v1/tests/data/diffing/word/doc_b.docx | Bin 0 -> 13799 bytes .../v1/tests/data/diffing/word/doc_b1.docx | Bin 0 -> 13862 bytes .../v1/tests/data/diffing/word/doc_b2.docx | Bin 0 -> 13465 bytes .../data/diffing/word/electronic_basic_a.docx | Bin 0 -> 13186 bytes .../data/diffing/word/electronic_basic_b.docx | Bin 0 -> 13203 bytes .../diffing/word/electronic_split_runs_a.docx | Bin 0 -> 13189 bytes .../diffing/word/electronic_split_runs_b.docx | Bin 0 -> 13222 bytes .../data/diffing/word/it_1029_source.docx | Bin 0 -> 13667 bytes .../data/diffing/word/it_1029_target.docx | Bin 0 -> 13719 bytes .../tests/data/diffing/word/it_1132_base.docx | Bin 0 -> 36916 bytes .../data/diffing/word/it_1132_edited.docx | Bin 0 -> 36902 bytes .../data/diffing/word/lease_basic_a.docx | Bin 0 -> 13197 bytes .../data/diffing/word/lease_basic_b.docx | Bin 0 -> 13224 bytes .../data/diffing/word/lease_prefix_a.docx | Bin 0 -> 13188 bytes .../data/diffing/word/lease_prefix_b.docx | Bin 0 -> 13227 bytes .../data/diffing/word/lease_sentence_a.docx | Bin 0 -> 13238 bytes .../data/diffing/word/lease_sentence_b.docx | Bin 0 -> 13253 bytes .../data/diffing/word/lease_split_runs_a.docx | Bin 0 -> 13189 bytes .../data/diffing/word/lease_split_runs_b.docx | Bin 0 -> 13213 bytes .../data/diffing/word/lorem_ipsum_a1.docx | Bin 0 -> 14065 bytes .../data/diffing/word/lorem_ipsum_b1.docx | Bin 0 -> 14170 bytes .../diffing/word/sd_2238_header_footer_a.docx | Bin 0 -> 17304 bytes .../diffing/word/sd_2238_header_footer_b.docx | Bin 0 -> 17354 bytes .../data/diffing/word/sd_2787_source.docx | Bin 0 -> 13602 bytes .../data/diffing/word/sd_2787_target.docx | Bin 0 -> 13625 bytes .../v1/tests/data/diffing/word/sdt_a.docx | Bin 0 -> 23266 bytes .../v1/tests/data/diffing/word/sdt_b.docx | Bin 0 -> 23294 bytes .../data/diffing/word/warranties_basic_a.docx | Bin 0 -> 13188 bytes .../data/diffing/word/warranties_basic_b.docx | Bin 0 -> 13223 bytes .../diffing/word/warranties_prefix_a.docx | Bin 0 -> 13187 bytes .../diffing/word/warranties_prefix_b.docx | Bin 0 -> 13226 bytes .../diff/header-footer-diff-roundtrip.ts | 44 ++ 54 files changed, 2519 insertions(+), 158 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a1.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_a2.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b1.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/doc_b2.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_basic_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/electronic_split_runs_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_source.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1029_target.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_base.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/it_1132_edited.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_basic_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_prefix_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_sentence_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lease_split_runs_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_a1.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/lorem_ipsum_b1.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2238_header_footer_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_source.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sd_2787_target.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/sdt_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_basic_b.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_a.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/diffing/word/warranties_prefix_b.docx 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/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/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 0000000000000000000000000000000000000000..56aa27fdd79e4715acf5a96deff921042a5f1386 GIT binary patch literal 13629 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 QHh}|l14FDFPLhZQ=t;Y?G?dVQwic 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 C1VF(6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d1eecca1c689f6dde3043d3f5232a73718d13523 GIT binary patch literal 13799 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 JIp$wS{|CK9;pPAU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b7eac3881df015e179551cbd3f0618f64a1c50a GIT binary patch literal 13465 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6e4aed89ac158c36a1625e931487a02256dd422e GIT binary patch literal 13186 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

dGR$i5f>Nr~%4o92$nZZGQM2&(zlDpS z^>u5;YHvV>Lfh** zlu!Tw>K}iQDUe60`q9wZI4NRe^){eA^o7Lmf?SZcp z47jaKb#}6qXM!NndpCW;HMIk4K;SLr2l3Aq1i}qtEXYl4_!QOgn2E_Zp-|pGIwtJj zn(fX*SP_scD;r@;KO0yJc}2a!52_`jUay9C#d8!rPRK-yn&p2$$g(&yGq1Wa*~^Xt za5l^mOxv3a_prxv;y)h56^=P% z4TcnsfqdJ_zyajJoA5;_XZcJT(j#>qD(a)r^2KoCA$Jtd$af;7J8tCNz+DGaKF-nPq>|j@I4zfT}~6kP)T+%zu?4>>=yf*os6$05)L#D3IDnJB|>T zld=776rlytI3^uTf^_!sBB~B2a>?ymlY0r{)6Xu!El6MPg_0sidNgU>kUeAivH@V8EKWDxT zeNs0ei_5Ji@5$Qv$ryuf{iVb@rh0-oC&qDr@*G=&e?lP3xRdl~lv85PG|mhn`mprP zLF5s@;rEZb3k0K~Y4$)%x&hh@3XqfmWMU{f+SxlX8reDiZZ1HY-+!1Gz^ThhnEZ`# z8hnxbh!}tI1?HWW7;%}vu4;vJvE^c>72`H4PfAzZ;n@ip8Tyy<7TxKV#L1Q8Q;CBw zgr5^bauVE;;{j}p!3!tU9@LF_IK32;MOx-3{dHqxwiV!2rH2%A&fuZyS_$AQDhjm!D9FkJYdH}Sys1%-g zX=f}UVX9LCLg5Zs6vapHhN$6D_XN>dv##yY=UGJp%=;E2vl&z8N7)Abe>77Zs;6Qr zpqUN=%@p~sX8O_I{ky0Au zQ4OtAci1;pzP^x*Up(NYj3y-ehFms__iL~t>-+Tv0@qTwqv5(F!9~V(=FP)f z9-k_z0MHYSjAsmphUje1Lv*Jzm=AwvT=0BPUW*fmcM+XJqE!8?G`?M6rTo*IDHCD7 z_fXv*vrMrYD*I>9C$3;98wo5Uq3S^Mpv$kbYr%O5ecPcQzC|;k1o)uUI&@*B?60WC zZ)$Hmu6;$M#+UF-Xu!~S`A)+~_NwJNBqByf<0ycW2HjdZ3~j1ds<~dk{qofr<7n6@T6P{uTb~#@3&3ORPWO|Fg~YEBM#q_D^sj<-fqcl)Ara__g}_ zQ-dh=zcl=<7W)&`(?PaFP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b87eb3acc26f89eaa8a17ce66abf82243cf3fea1 GIT binary patch literal 13189 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%0A!teIwg@Y(9a)qH96p6sKN*pP?$3}+# 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3e88829a83c23828acb9ec06ebafbb7d9e9f401f GIT binary patch literal 13222 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3bf357b8e6bcb7b7fe63ff2ad9dcc805941e75b3 GIT binary patch literal 13667 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 ae+Ewl87Po>{Pvy(0-zHVV8hvdJNtin40i?q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..78ebfe54af4e2e3d9cfa869179a31598cb2e7005 GIT binary patch literal 13719 zcmeHuWpv%hvhFt9amn0*QTRNkqzYx6Y*8A279?W(k8-=R$gn&XeR9uWiOgwY0EH z<70?Pwv|A?E-=LEYsuW&hAG7gLQh=R6E~*tP9`HSBjAJTJs_GgF-DohJ&Fl0@EMJ1 z^R8_jEP0*OmO`cu3L9QK8yPmfW`V`a`V$d`;gZ9Qs!5_$pl&T{*pcVq?AHX(WZ5tF zD*+C;=h0Xgd?~Uj(NHIj_>RnSRD=CQg)Vv}5tW{bnr=;1`P9-YIGfi>1*El)%QV7t zAhF}CX}RU9Icr&Oy?(}<7S%xhQk>jt@KaxCKFIMR@wZxgZnE_ zR`*~4z}p)bK=yAUi5H92a0cWvX`sZx0!32C-pJB{j`p|qzoPiRSpNR<=-=YH%zNnH z^PdI01WdKbEq7t$Nz)lju3#)dL2HOhp{y*LFTB2RFD`;=9qNgVOwYtmx;tcuI&CIs zons{_!G*WM%s*)NXgsyL0uqB-3m9DIZMWa2@824~3=@k}3 zyVVx4K33h|B;P3|QOUof#03qkRVx_crp%a8!_knJ_4~+6uJk$^23*S;>EP7>jQ?zCOa7fYcy{|NqdAzQT7%Df$A1L zk4V(G=x2koIT%wR!BcFN{rhgjcPf?gTV;&lEh0VZKFegqnDU0et zDSZ!vU|o^l0H_)aC`U3iKp8Vy(WeakguwHgizN_!SPEQGEzY=bezhx8i zICEEC!4J=a*n24qIy}{XJZW@_DnHEAvU;`sL@iLaC=1U?X$D&uvm~G?5ip+Xce0MG z`_YxDV%lsX@Amubt!zjR#I4;MB3GBi#0FWRc#Nrg?^JFKEZu=6H&Ru=N*G;SA+hx` z(*v{b7L2Zh=|qf3PYNyugPBajK~>g3>DH{=105e<( zCkvk@;i8&lRhg-b!Eeq&h=zp=i-vb^l#p+9E�Pc%LdpyYXQop0=G_2*Q239(7@K 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 ae=AQ}NeCc&{Pt}JET9XhU^^LqJNrMZtGa>! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7d365ecbfd2ac1cb0da5943a76f5a056a793be97 GIT binary patch literal 36916 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

PU*l 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 t5avv3hMI%kEkHK{y+}nE(cJ|#0RSK+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f58a38a018f002b48988b547d02cef65de24d56e GIT binary patch literal 36902 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f7600452f062bd6af00bbd4fba4ed1ce2540474f GIT binary patch literal 13197 zcmeHuWl$Y!w)RGX1ShyVY}}pT?gV$Y;O_43?hxGFA-D#Y0Kp}M;O^hfxij~ilbKsz z)%|;?tM*&Hd-b#0mi2m{R*;5(L<2wpU;qFBF<_%;)=Co$03dn;0K5ahfNKfc+Blim zIO!?7+nG4((7Rb%5fwm!Q{@4`LHqw_`9FLI8k5Ft`WTT#Z<8O8;#*V3_BPjX;Xi~Gyp*-w+iHtwcEm%()o4aCQ1=Mtwq9J9on zHM5mPu&@poZq zlfCcus6}q}O5iL=fK&5EWDSKm_9{!aX+A7@=}ZfYmJ(W?(B*uh-68nxU$A}Nn)27B zve3oF_N~|J3@HCdY-NtN1YXnPSTQn?XT=*K~-xKS*Ifo^t^Q0HAz)FucNmI>5oQUQghZQ&;Ft zw;lY%Fg!d2M+-DijHehu?XVAy-rIISsenqp1@3hCb~v()gBha)4KBGp*wuKbL&xXE zsUfzMq|LYtAB==;<~=DKLmhi0GBcS0YNqsPxT@ITO#)T8GM;1Kw5~;j5dT@EYB9RO zP_G9~)=BL-H6GVhpX0A@P0mhn83tYvhxm@!VTv9bnbWDSi5tjRP3%9+6~=w(c%qoA6rQ zTLG)2jL#Z@hr&Q>O*OU3Y3O=cOPKw`RX~eC1@QBI&qgRYv9L&L~-RvAU=s5?2>WfXF9VI~SNqAQtTGA=oVb5L$cr#IRA2O8|vg4GT` z5XQX+zYwW}j~;iC&0Mmp;MhVk5><*Lrx*4~tM!3(l$+NJ6T6xzvl(vTuoAWzAJJ{S zYgN4Z{sSJ0LNSERCuBLJs`&Hs50sY}xwrDA&a>w`9!(h&LUCeeTSXffIj0*sZ)jet zQM$}2I}!UHLm$OjO_{y~^I_XdTdFPZrG|Dah2rjJRL_g1w=wmHdCz!b(sV9&zXS=6 zU<+c&d2yihv7~*;j~;{7t0UEX6D!OJEHUqvwOai~^)cD%F<_9g5Vk+*bGw?sOmuQR z7t z+bkM4C|-diOd!|61Hgd&;Wq!3TmI@i|I9OBppp;de*d?Z%7k%HoCTFU!7qVn-gG?P z3PQV9QpBr^&j3*Z{!&`l$*ZlN8VSjxY5n#yhw3!PDL?krsm~FvQS+W9c_R+U-=k_g zm#E#!2x?9Qkw2<^Ji@2Oir3fD)wRl*wv>~ezxEd?;8dDs3T;X<=Eh?+d7mfE5FH!1 z&pM5zQj$}ESiki$4wt%~e=%@$?}e@EDCo3Xwb$YBe?{r&?nzujxp#384Y zI=+_in?y!wk+=gZTJ%=Y&G{# z5I%5WByM_nw6duCF2EHQf-A`)XIW|Q}t z;vnrmIx*)kL0I+XTlZ3F(&7V~UzoLaPyYLl-Z+3^qqf-~X$-CM#kG)@0aJm|LQ&TS zUFyucsF#c87t^X875Gjl^4R2KHpGl)-G$zd3K#0%`dV~q=4f%<5Tpn`YTZfo=-fU) z=>~!a6n7if!gx6J#XKc8b1NP2GmLOWRU!%)(Ww zNc8XYI^X_ze%UbGlg;PuaC3)Q8~ZGvzI&s;YqqERdUw6C;S2Wia2>iN-&42aj`8~A zfI;v1Ww@sd6ROHKx69+@=bo$H^ZnchxX#H;N9tB)CQyKe3<3WFcS! z8sRRxj#z@^MuNAZ`gyi=ME)4s(CIFn!^Z8kU^P>*2z}yUnQ&HM>+nJlZUVqV1yh|l zSvIxBx0fR=@?Z8f*6?BEtsGrBI@fZCnNR9uO*jOgCWT3K5WUfe!RE2DiH%V@V(Sc+ zAb3ra=E?xKOy32p*hZ0Kqs*m(Z|ta^>uXVxUf8W1?U0

dH>@-@OCgLXoj2hhdp^ zA|8N;s17lbY3#m7^N-%035Z4rbR_0GCKVF&4d84UT{DFXJdvfkOj$UFHb%*ipg5Zd zSqn`vjpyS&f?|xS%Cbi@uFE}7hgXC_EUQh(+ecKi2E8rqwLcPwg>+19th$=u)I*2i zq_eE12+Xg=!Z8Pr6n9Pvkq$OvH;ajzo#5to#UlHc*aaZBKBgnc8};u44{`k`s69ta zGjXS*R2E{8Q=1Z~ZD?Z#*I>Sq?#|)8hmMRMXz){n(a71?Vqr0UAf@0aq|+UBGc8h@ zm@jThY@HM~@@~^af-nzCwsiFDq)*%G;rME#w10f)=(eFH0neFtd3I+|y~fV&7CTKQ zy~(?$b4+$V&RE2FUTNCt@%H`t>idl)HuuN-`i! zeQhC*PoE%DY>q3f;hrMSS92kz&A`Aw`1C$C_(ju0lt2zp{_)(B+<>fKHsng=9vH9Z ze%r^FZ6@RRHOItVcJSJe*E((_C{vQ;U5Gy~FM9{W^3vq|Cn!FX2y2|{q0qA?DH7Z^ ze-Tr-tT8^dYAH4&td^{@r>|s${pvq*+A=L@M zBjb&!Pqq7t09z8&)IL5*jIw=;opN}fIWlI=E~pzhl+1LXRW!u+many(ZX@$Xlac!z zHt`jo3PGhRH3^I|EwYJ?GR-7_r)gWPUDHN%2SK4-lsFusl~=KTl-K|0fGvB1n{{qd zCH;8MP^{Zw)-$*L0ItkmW_E&0V7|;^ijeRmZmL%5vimc&b5aw_mY7}LKtkz zLt`km!DS)3W41CKeZjbN8~hA+vjNdus7%EQ#4r_|GA%8Jmc#GrVm|V!6^r|ykKUVK&xx+8%*M|j!()} zP1CkO2TRq=QL%P3$>pmZEqVs(wyc;V5^!yb1kxNkv?v{Mylqly(JGzS7VVj#ou|oJ zs-9ns_WAT|Zt=10V&QhIGh^w5AC%tyH3800lU1TX0ssW?e|g(*G;wmWurYJ|mHZl2 zS8Y}~k-hcwUq4S@ayO&hQ_KS^2iA+iT11l#bAj>o;b$aq%6P7Iuie5@jad}919TUL zvHKHQ*odLRK6iH`8mInLvY*nRx4trdM^TiJ88jvE>d?9_5tPRzV>DEXbFZc3zH>W& zv?X*2rx@}}KLHD#hGp3QyqHFf4ewM(j(5nSfdZxvL>Gs8zhK%M-(FMUL+l<5KM5=} z9BMo06()yLa`8^`!5+d@5>3!NS@47BEv!_7+^hyppXENyXvdhnFmJi1dB-_F~2p z!jK*9)M*~w=?9Cb-K9rMxTcq;Y^Ue>BYbF?xRX8HuJR;~N+Y#zpb2DI478^=Q@Rd^#lx$4AK!%nmO$Zt(6xp9g*aZTu*Y zrH4a61^{46005+a7SN7P?p7v$M30|3s}8GTsNNgp&(LVOE`)gDB%-6$R6yzGrRpa9 z;RKE;VSOqRa4>p+rfRp(0hC?ukl%N~NJVX}>iRJ0Ti2}~9E-ddh_sgGNXlUfpq$$S09xT2y!>dbq-`CaHTZV`W;w@1k!0h%Q zbte|xxWn`I_fnWtufYhydN%pNA<>i?bs)y&Y9adgDf^L`L9=E9bI!1&_Itc+Ji;k4 zD2mg-1Ud}+5j~SF)4@5VuY`NNC}A3!v^&m<`QL5@JEktov(1PZ-r7%Bi0o9UXDu@B z00EkV207?o3!Vh=q7LH&Q1^F?u0b!nU~={VN`OAbg)L{4W1u}5t#uY>-`0d>7H7TP z6SolqJ2QV{fR6(OMxwwOQg@ ziI@2OLVfgZH`h#4u1v}TwLTh%Ep`7NMNfKXq_3s9Pnntwq03L|WEY`+_mUDEV|{8~ zpV=ifMh2fg*kDX1JX}&pkCU|eevV*_u@y-bEv!&)e7!Nz93ZU=ZYc257^rmKQF<}| zhKlj2>47=-mYCu?f}!z|{uM4GbeiKsJo`5;u5cmDjoM9bIg- zblsL=(F!U}Pv>LOum*j>&%y8ajtv(XHQO!rXy@}QOgcj-uhye;3~yqak9@rgMGRDf zzEYs~P@1vqiAth$yj5=BOGmx7rH#niAa}kkEbNK!wbHXov zw{^l#{%e2$4M=xBt&ck$geNa{C!rnu9*>|+i*DL{3vbt9(?9L$C)3qe$}}WEJIwN6 zMSlX9HrZu$yC@oVOb8_Nn~9&y3WF{TmZ3YnMJ88zy>th@8z$0%jJ#7V<_+ZeGFQVp zy2z`qNOlGT=18BwJREj+=kx`W_rPKVh@Xk5Y#Jcgt}zMg^s88MS|Tt7nx455LK)k*WNH0t+q`Rgl>jxXQ`4ZP97ykj%d1M*@PgO zLqtnyvk}C1TJn8mSP?6{o@tjPE&Y4D3!=(xJTvi3x zZg&zT3i8nUL;EJpX-HCv?ylZAxD7X}_nS1~CQEdf5y5%@8P;cmwpx%r=0rCBv|*gh zq?N4qB$y%AO?5B!o=kZPFDOdg)vH;QRvn;LvlO`EUr=+^gCtz`I0N&RQqP(_yLtE; zF(@g(5$NW5x#ew_SY?s{gbsl^W2PCm5)HAS_J6&Lo_dv>`b3?A^F5#~; z2uX<@og^wEVVHqk!ZgaIy!D*PKf#hB&T5|PP(*Ql)Y=&^!0bv%j_Kgj0yo<)YudnR&p+ok(&R5zOoOyJNnt6kW9d#91{jv z@{~rL6J#=RwxGXqq4!uruoo(Y^tJ37WL&*iHnLnZ8y@;-&pDUI z5*da`4-9ny=qW{&HaGKaQH}3+eSDmlPb3QLI0G6G>zPUpI1V$lS&MdiRS}LaA9w!{ ztEoSDymQfjS{iR(vF)FTxz$vK_3XFu4YW3T&k{S*nBl#t*r}m{AB^;2g5o(kS)3!P zV%UZ|s)Z#yWY0ogdU@ZfXU`>Nt(N!50(O=6)S}F3rS|7+KTXO;6L+6R>gzmLPXGOv z_Rdmv8|&urc*}8iOx~l5f)M3^R0FCS3;~x1iy`X`d0rdOsE9QxEUq?1kE1b!8S>hx zr;oH#KhyHcRw_<&0QP#a1)ic`^f=P1S2^f8uYF68veP&}y+P8nr+8-2W-EyD#PbZr zi-&P%#kfZjMPN@ITdu8Jr^z7dBJ z&QAC$2eFlSS|}LVJRdO)*h;J*^k!dID45DTUkIjAff!P=)DAr49d+X{Ec(hL40^#G z=$9A#_8*Xc?vvx_S<;rOK>Sv-6d9aO8O9%@Jc9ud`Tqw)1}9h6KOt8R3&5Zb5`aS& z$_p+hWV^!r-{9ym{ujq{x-V%HIc%TwiK_A7K3zWbJsflT(eO;wg1~l&q;fqLz3{3L zG)~(Ar%A}0Rq9npD8VbgEt6b|pSobJ?JN!Xqwj!0?VJ4F@^=WmcH^92g(VrVyT%&w z(lVJ2?e%6z$@RwZIcsUgqbwIuy;amKs1hAPwZr;$plDW%pS@aIUDPn*Wwp6=dOz_= z#7qh?-d6R96?PxkNyKo|11T2~f=!nZ_Qcteuv093^V?Bsm4zW0%E6-e7m&v?Enz?s_$c*q zV(=8p*Ug_|X9YODcOMY)JIg{8e3bh?fIwk>pKwU@%Lv?rT>AbZ8pZ!_Q3#B#G6a@y zIdKvPNNKEP9C=|`2+V&e4cioD^E)rW5poA!3_&}vl{{f{fz zsV4^&N>*iwmfFqpl5F%^4j-b&Q&vicOr))qAEC`S0x4;6hX?e{wY-Yb)-?*zX|_1(l6v6!aq*qN-%8zvc|7nx2_ZZ z`#R=r3i@~gf-l!rD(iu0ir1c^fM@BcqPV-$#@$o$fNCxY+GDEW#4hf1)o?P&Gn6_+ zsf!opLT*{YP(7vpy+@ix6}J`r(hrQ}t(;;@JI5+F_{|o=7A3lgbk1_UU30&o5BG)x zDc7^a<<}}KYSJl3Ra$CQH%x|W1mn+*N}dJ(9{qCz$7ic>d~RIl|m6nwPo$QX>eK?G<|Gv z^2oma%t?5_WzQW8E6DoAakk&RxqkkUP2_&$#H2g3WvQ!t?*uttnC=6vK*RI zuq%8Dtb2Kty0t0YfWHd+P$Hi8*lf~ z8HrOzV01fAaOtzqs>jE-=@%AA$(x86B zbZ6Q(MR68F_3{nmqW3k8M{qGUj9hN4gLU4#?TVGXS=9|b!xa{y8f!1c^vLpK5x!7G(+?1J;aXZkMl~do)+v-JNmf3 zF5sFKh}s4dZmldqZGp3|QX0u-Kgy zJwje9!Tlx|)6N*$CD^>5w$|;H;FZ7n%FUEN<=EtE;erqi5&=>y2c0l zqr#v+6g*0o26iX-7l~kIYbRx)C>s7SmRH8{<%Vs)*df`<3WIdSc#{6U9e?@!ob=r= zDl*|fBq=T4x1gO*y@An0&p|;V2jl`SRg$*9bHrD)JOx8J4w`3DxlL;|(;d>( z*187d-sfd9qEJ~c+@#hKLh9+_cWqq0+wX?Oiz{`~A2Pu^CcZbJWhhJN1n@E?VoxThADU)NA*kIg9?dFj#oz!2HkKQ!k zbr^jjuw1rTKj^okqA2zWaTrn8*-BU{*`>LOqH&qr^Jtg$p5E_rydFPuLBj2%Lpq?M zBk<=|Jh_d5ST8miD&T8m<1enC;(G|<>}ac&;5Z*8T}+LCsHll7e}20qx!2;o&>a0+1 z3EzX;Jj^(D_&wJ**P)T)3N9Up)^A4UNtN{1`31Y$EtX;x$b#f;h95KPAQDG@yo_g~ zRQaIuKZ6-yfx3@q|Lh#NnizigOY<>nPSLiH2pO<>Oa6$2a4O|& znKEQqPfPEqkz(}-2BRhoSq-M`+~cEAQE@(a@(Hi;b}y#St8Iz;9_olJBP%KdDA{X= zHf!Z@aovW)n=s&R3i-jDh!Wb)xUID(b4df?2AdBuXkorfjkdvl`sMlq!`{>Z~y`yviaV^ZWkSaN&1--5Rl4Yf!-v zg8CR+FNS-cQuu3y9*yf<^ewbar9i#r8&ry`?F0s&e@jI1ASws`qD4|d0|2Q1d_blk z{-m;jp_R$6-tcbXxXtRX`QtYiNRYR6RQHNYVfaNb;7)U*@jd`qMGTbpkcie_eK&~7 zc^sm^6JhA{>{$T|UE~Pl8zxkSr@2 z-}t7zZ*L zUK^QZA81AEY^kDZ(rkkg30YijJ$X;o z)(t}pn$_oGtC;Eurkogu0m>6B@%Iz_S;n2D-$yycXHDbG-bC-0yxI#t0NDS1E70Iz z<;x^K(q@52U|Nw1|wUCKg=U8HMt<$2 z1&sbvJYN};z1WVH^;A_%N^w{C(=Enw+5q33%hKb?PAhxIld8$to5fM71F~>KRKNY? zIFIDSgFTW?B7foHH#9T|To%3YeLVd|uS)ONpDtCJ6v7_WfO}K1T$DT-(!Yu+-nu^&2 z#(THAnt#d~ZY`%As~&GO zSP(&iT1qFCuBvqB;4=au!+)7fTmD4vA(=uoXe}7?}64M%}xq zn6FkepyG1jXRTL+%3EpW2 zTW&-5b((eZ6}=`7vGc4E9EOzaDaGDo+TJ@Uj#h#1d^u^uV zVAtc0{^`Fd(qQ0ppfc#+f4A^o&*RVW-+aZOApLg*e`i_$2?YQWL5KVc=lXZx@AT6@ zp$#CO)W48Xe~15_hW95J0Qdp>5BUE;%KKf@@6@b6brFH)s{buf>vt8uPj3IIg81D( zRQz?S`*-;7b6bDH4L}^kKhEIKnXcc#zn8avf-@=q0sgJn{awTF_1B*oc!B@W@V9F0 zcl_V;@tDD*^*PVTNrHi+03ZNR004jxuwF22sSW}F5WE2ZkO5Gj8iF>~jz-pw zI*M+#Mh;qZu2z-=dElTFIRH@L_y4o~AHD+hi6hqC42Z(FNe>8dO-lOT^Gc~eBY2W& z6%HUVJV2G7;|JPbI*~!;l|bTPEQv^&Z`YWW`vazy(=DNq8eB*Z(YcfT612_Osh1XZ zXnhPZ$u<)i*7%3md@WhpJ20i#Kp2P{dg8}E!pLUkX9l3E-T|Vil46ue+@qNB0-w;B zH*P!Dz*5#oZ7F3NAhF@KbKb!w)Gn}kS&I>27%n-?sG1~61?twLg#GY5nEjOKnIac! zzY^epdlrp_!J8_l5)FCei0{ZEPd(UAROq5t5>e%;sOi>HT|gtVg0pd@^qI8&VVPEl z9wcsjH9fCFHTPTgD+Od>6i7Rl@b(HW0%hC=7U09aOdzThZ|a zqtzW40Py+>29W!kOX9_0HJt+OOa|z2ut1m8u{W}Gpr`w}{>K&n5BuL=PQ5(-t9cK@ zTfWnP=YXjW`Q@(|`7-ndlPefYkWd=pQphWd<_j;+T#JjKS_gWfBhxbplkN_gB2F8L zT4z|vN^s#F(DV1&JsOYgu7ISV_Roy2^R`=WGxlzbpNEOXDTf0h)KJ4Gupr{@LQ}`S zAn#HM-Ru^@nh{Y>%o>o?RB7)oS%B{zzzMiZZ9XropmgpeN0Y(=Ug?I%}W$;hx`B)E5>+1Dy*UM*rw(tocs; zWj}lmlb7w3P_15P(*`?Ji~kasnEvS>G!ABga6r$wgarVQfEUHZ#@>+L(8j>Y3YfQk zCai*FHJfb$MBjCl_6{VGD5O7;d)W|m+11}G_o`r|pIJZFt zx;)0Z7|k6YV0adpKO3Zt;+`%$LC_G!_YYa0Jh^_;9Lw}$6` z$VWN^E;$N741uD6Ol?$Vq{twh9VZMCxyW&CLDL_ZoMXvL?UIO zY7>;eJueeRJYdaIdIpk6Ya1vQn4~ulys7mTGasA9BH#&+;B4}Cxp=d$7`j%ufiGX1 zpt@8T>z4TWYl}E<{5qNRV_dM+bUA(rs!Sg429pu=+h7G~%bYT2G2QFr&nf_!ny@Cu zm@(+olY)N7Toks75$fbauq7c>@PQ!>geJZ+vQWZ~R0d%Ih5nHGJhA>Ze!hE%n*7aW z#n#eO5sH323!}Vzjtb;{!fQ_L&@R#(r%e;EBYmBg_x|XG&mql^sX;o4qtn4?K9M(z z;5~$M_y!$997axZqghg<{LrnX{5J~VZ;H#QqMo~0Zu}RV-K`-TSu5th+lQWdm5eKV zA+CI|~F zOKa$s%42vr9UDh?t4#PC#-r3Ys;%#ls^D)@3R*C~7^b}Etn?#4w_Z1MPBZF(F{Ccj zt~*jW0#!&$Jz9f($93|qql}&|N`dO8XlTWd`O&v3wlkM{1y_Nnq=)VE>GkqWLN4&L&4LMs0jHFYHoJ5d zQwg=**b`ko%~e4bXj_pPo`&1BCVkVxXvXJHXU|Ue*Jw+Pv`X5A&T`E9*@_Eu_Uz8| zyn2Z7!TXQI3o?OsSN+VU`TE`LmSG~@hZ|AhlvHcNg$LH385LN(#=GmLLiL7qzmpq|l{@>op<41tO7FhQLy#%Cs z(SGof6WFnoAY7S$1_b9QQSEf2l__D1`d<}Prob@Qm8MH_I z9$DqFK;>G3S9QXVXrW?ph)abLr>mi@ZJ9l3E+aX6?I)DSp)ko9(vWD#iNj*VlOstV z6%(+>(yt6*rR@}9pS177B!elJ&Cpk=jOcRB8fyOb4!_YReQhIv-3E-4=OZDn?Q7)d z0f&Stu7=^81O`c=*nMGT$?!@12m_`fIhxWh`F$l{^s>vkv}&_w=w2G}`%kM=9h%jq zbI+rxJDQMVK7FSkl;v^12R;V;(`rn7pX$dU0Dv(9000$e48K{8qp6XV5&dr~eUN~bNt5k=ucbt&f7vSrRO{Bjizjw%yx!=BKg zD>?fEaVh5pV<#7f$_qO0{9T~I$Ybkq-cJN(!%u;20zg}0!Dt8Od}Qoy&FyG~grY4) ztaDzI?Irz&MrZ6t@hjeZ>s%;KoPS{T4Yktj%H^@}!U7B!v`hy|qG^=QuLd{u8uJb1 z3%k^5Q)S#mzFaiE7*}j7y={jejY&FYg-?Ihp8H}UccJ>NyGg5Rh6dXeP6E$D<4&SW z>-GUcI{?%_e~GPC*@nej@(PRTv*84c&AhpFM^j)?u|8SryL+*nK6R)VN^CJ-XF3!P zP<^C)N*qbe4`_)2czcX~u#mkBd792`+9f1lRa@?dF^wFn{0zidj#2l@DO+H7shg_x znb-2;o82D(bnAu4P>b-2GA?YihZ-_Lx6Z6Dj9fW_ZJw6lfH^8dh+2wN}ZD{^#9 z5)9I-7UsO;fWc2{z<(>On`1*u;D@FOndaO+VAxs>QZ*3+*DVT?0c#1e_ErGQm5=g3 z&RA`iBycY@~$Sg({p&?RR zOpV?G7?*M4ObOtY@jIU->kwj0q^U&EjV;Ail1KS$HhsxfT9i8RG!^4u|`AyT>+ z+1Y6DYDl7S95?471Vdy+rX7l5&8PFUxAIW%CDrjcd+_pBz(=Ot)<-;%;I@hN6&E9{ zTF6kWH0G6LzS-3nSf-%C!uBx%;=V@AMiEhy6YSiM7(~A!TYtpn$22%ugPuLg18l!h zDvv?q4D88BrMYOtl!kaJYntf3Rj4K6of#Y+$cU)kI$wDxwd_3&W@f_&Vlwu8TJ0fM z;{t`z*}{f|<}pD7uNHL#Fw?*!a|e%hy41}s_9X*_y*pgPNsW=g-k1JHOaXnVScp+X z{c0P@CWT&GAF8~@1hfo|-NL;>>2GqY6z&2zSsy{3*^^>~xAe{NKAd@#X0->_s%`IV zvQcNy86iKNqqA{y#K6aKNzzP?wC>eb-mfpPx;@_4rni(E)0JVi4MG+hPlJl+fK||W z)F(6C?wN!`zkdhof73$e)x7$c;CD0&M_b=%8V+{foH_pG{_PKwFU5B_5gQb~HJOD4 zCTI5w4HNuQEi|7To0OWc2rkcjWs+}QOUI{OG86#e_3IUtk47bA@oVbb9Z*_j3$!{q zjg?RoOHG7vX`>|ajj_d5oD+n(D$ay7>1b$hpLtS(Uew)%@nis{7U$-qdL%v4!B;}} zlyN$4x82-XCQ=Sd*+y>Ceb@S2RRDWz!edON~r8S{R4)#tNeaB4a9t#OAg+O|U zP{a2Y5ocI+qSa4`vMF9g#o|e9i1k~{g#82cp&?6FUd`Zvc!oWVygsg%Y_<7h3yBws zl+4%A(Is38IHih|L=f`Shz3^jR3rZn4O=3u>ej;BaB{7}gkfOKT=KO;Tz-f9tXZR+ zEHh(DX~(qV>EOGnh_|eAnFCRC zA(c-9!=hG4vJ{iwiI!0cB?(?E7e0N{{VxwJMS&pUsDk zo!UjGDTtsX=L}E$o@!*#MqF-NeGZ?!nyDJ9BF$6_TH)!;^`2ikcXqqFAXU}(^v1AR zM#f|+CTW@=gCweEC|KGWWO7vw=RE?no0d)C@!oF;1yCQ`Hz^#l!!#%~X%x?D3U^J> z%u;7BRL-tMd4GO3HM3~Bn7bWrPhU9U1*W%uCcsTyI#LR70Kn+&UmiIej2s=!tW6w# zCck=>73*y_L@)YDABHQwmzx|o{vOE$eY3hB^+ycuWN*)>M{us z=X4kP@(#x1Z$p>`?|TMB$K4Tx%&7W^py-47V?qV=sTw0TR=&0J!n!XL_#q|u)A)l; zeH|Fl@wu;-WnqMvbwr>DlyR+ra_(0g(#?s$-4xRq42J?pmT)kgl{SJ!&w6QH!wyj! zn#y3ZhVZlC$biaTqM@lJ*Apz%7QaX8UjnOJa(j23PZG`mB?;4!pLAoH-Vs#kXKhv) zYu*S>D8DJbLsXn3rtZ&E6cn$X++Rp!CSZoYwzbI`0Xg2j#)H%8=RuFkquy@BJD;Oa zOQ7CE>2TvI`N-fc;&|l+(z$~2fr=$dgZjmpG)O+)jhp5*b`Pe%z#qbrc6IVdS&iBEs{jG~ z2OlOy_IJqGY8BsCkS*adZ*4t)Ty#Z~Xkvux+nX?Fr-2b$K=I_S4=HR+G_avye8YJT zgVKieg*v>4eOu8cC_E3IhBbX9GrlTbj#_}3O8}riz+JaX(o4>ztZP~M5_ZqW(2*Ht@rRnIPsH?*2ePuXUKr5TqvG5X3z z^M~w3`gq53(Y603m~^J4VR3{^b%Ek{qpKY$&;QvO>QCFui?B!*}WNvcuWUG2(4 zu1jUm&LEi_^$mK2;oTC+2_8l{3G2XBB9(ZVl+c%^a$2=QCJk%DfYQ>EWlwB6P?4q& z$jbuEXTh5@(;Yi2#eGB}!f<=qa(XTh%g1>M!rIPx>=(821FDw ztu9hCSWnPqik3O9CAxArqsQ4vBa}Q#45OI&Ym8<@W6yMnZZC0V_FcZ1V~Rc>aN*si zlwFtO)6aZ2=alYoYkep>277(oxX}66uu>XB2MdP?06-N100@87)eeqsmPWq?l_RYc z`xTLQUhAdLkSL#=@o~b4goi3AC?y*gDjRSI;@Kwzbt#BILFfSL%AMZ(5Vl|XeZTWZ z$ZKj;)`m)c!W=!ANwr?}e!5?wg6_O%53Re>XL;FlMLA;`&Ex3k!0>8uyFBtlK_QhS zPBx_M!r(sBzq*w5d0mOQr4PR#1O_a?^j05LI|l8D{qxrMVyG0)zHt0nR@uIO;p8e+ zO0>(BeAJOswnG!WM)f+T>;Z929-J&3{0R{VveSTgS~R;s9ivU-z8Qrj{9P`jP&IX$ zZ72EMZ@2ty6BnjgCWQ1bc9Ug7+ZC#r^9rvV6xJSMF?1^HYvp16i`Eh%-fbg!_# zV5WNLGG#nXdmZEZ6FymsFCj_>tNC!1CJ8WNFL8VMx~QG5E*Zw}GsyE)x~ai7RsDVx zJn5VfzZQRb%21~dS$t9@xd`#S7Z+n6?pE>q$|kNh*!T3&8f`58;gU>pgs9o)YdAx+ zjZlhkewk|h>y43mFL6y!U7n{}Z@JU9!i(v*cW9p*9+;xHrs6EmqxVY|pOT>8$zGLi z#klv?t2>Z>m{U!uzG_q-m@(0*JRVmo+siA7KpOd^)ukA`eGoy?X|zj*ZYM=Y366Vm z@m&Ik_f^?imB@vM2`v9ry{BPKOQA!vjzKv~KO#P&m@ON31dpWVZ63MF{#^Hd)K0wB z@F+_j;A$P~28w~qKMTrhffF>M!t-4Fjy9%JvSw4iVEG+&SNmh)fEr!iQQ!A_hq{aO zs;wqFl=InTMy-CNSF52J`ZqC+hdy5ULVC)9OJt~Bv_PEY8A&Zk7@U*#hI12pR9EraD zb{w2o1DgXtK?afSFpZ;I?(PwCNR!L<39sng<_Rz9&jk#)Al!L3KW?|-pS;+fgtYOx zKLU5nyJ~XJ!K^{2ecshgqOC2Ls*8uTpZ%fItI0;!e4cE8xTUOcmGAJeRIK$r%)g16@4RKaGmaYp-B53$)1AS*T z@dRIY$;3I3=0&m4KdSaPfW2YnVwGd)VgC7sv6>?vizq;ZMFAqp^vheUsb;g+0xWjm z4r}0!)wj!Mc5PuJLP4$`^Nc4tZEX@vZg57nyp`K4ib_V1#A)efXJNZXY$k_B1ZB&| z)i;a=D=njWAsfM4naafSV~0r*gX+$i*1-s-U{Mm9tax$l=3GR6bFMw4H~dlwyWq#P z(mW0wX(<(XN@5*C9%J!Q*^}@p*wi5&i^>3-t#*P0{tq;MkUohsYU1R=J1aN#t^C{~Za!!5jxr~U zvY39duROJ)e2=uns!?%W5WuHFM7I`kC&+-U;QFfPMsp~jnAb`OQa2pjXZ-R3AtaiO# zvzMd+Buq@0$Z+U4BEkWn`CEK`aqk$O;BjFs@2oh{7SBAKSTLqgzJTUmFi+3$PATER zMh;J)U?hE_WT4{xsH^DB=7xIKCm`j2Aj618k~EqwqnzfY-)gURow znC~@Q7v%YlO!8WK6+E_9BnwfdkrfAZsO$W_+5!ohQ5O_d9`G(j7B@C>Z&Hlxbyz%( z&L$89w4G7v5$YI=_c{zPwpa;wdRE|%EFO3M5UHx&f4p;6gIE}8UAE~NjlNY^hW6;O z^a-#s;9-s#tWWpakZ)I0!VN$l_bg@SvFwJ8QH`f7QAaFE4jF5*|qDOyjsn5 zXa>E)b!t}PxLkcS-9w$c-oV+dmhw9LKD+0BytTcU&DyGQB+h(<4V~-oA}?6cKShtC z3XRYC!K~kEU6#w*BQkuI0^@y)y!+uW+!Sf`#FGWh#8GNa$#U6gHo#6tI?qFRT!%fa za)q6aWf6deW$ z*m*9o_JTS2s9eMwd8qXR!qldx$Q140&n)T z1%fC{a|NL4O$O$UNXT3uI-{9!b{ujq{yf11II%pm9 zj;wO$JY78XIT&{QQTI&I1jl-SpmaSPHTS9(I6~6~tB%i=S?rmQFUBRiC6!c+n=)sm z=_CpMqkErT<(ur?;&(8eR>SNdxdkbZyZS29;u5Jg&9z2w@wNJq87oPK!%Sykot1Z) z5Jg)2DhIW#l){U&;< zCEy0s+FGovln60L5lri_N;1mSn-xndsA=2ymb;d8WSLo`7LlutbWo}9J)K6aIPfHl zCZZ-&MA$*YWq}P&Rd$qgJ@!QWxn|3fsNc_IyOtDKC|Q;ym}@r9inG#b*nf-~NnS4Q zH5gWx9|fk zX*0Xf+}5GO_3cIzev<<2Xc|YU&W@>X|Hpg%-sJ0P!qRIcW);cg!wLT@j1iacFr?J)2pMS;nOWF# z#t#+Jr(I-DlEIuaBKx9yi>J8wX$jam8BX>d+aDa@Mvh(fc7BXJd~nEBbo*AtK2w)= z0oPC$4nQX^yz0`TwUI#LOR-_;zNvGZ>ob0=b9B$T{>p*B|K5%>2AZE`++n)Mt+96Y zkyYq^@WiMyqiLa|bnq;zWuZ6Lsx3vEeX@g0=9a0+W3qW+=Yw69`5^8|!$cVTCKb8y z-uxY3p*s7dQlX3rkg6tb0j{**c7 z<8|jF%qZ%4va?<-(DQoPMe#a^$XmE|fQitjTQ}LB`b}Px8DF(@9kGC?s{Rl*x{Bew zD@$LE7gy_}*08y?=qIIyraC||Y`2W);j!5+*OX!e{;9%&>Fu=Sk8F|k)Zlk3Rj%=# zy6&fz$7`&cu$1UEPM+m!11)u4mAtf@^%HZfP~>0a>zLhZR%EXR$PAusBkR_vV2^2g zDzay>IH$S2I95^gO)Gcd+nz0E6b*dVrbv3;CD)yv>D^+sKrnFCt_re8-2O(mRy zW`qx*kvM!f5kj{?s5aHWhE821RGxaVahp$QkLTDi($^Ci3i&bbQL;FoGv2R22t7+PF%wB% z|A(QhB9;#)bnC@7(Pm~SxC7dg}wF6GzD zjfDO43nEfC*= zpByjZ#c}>HnX6tsZ^wH))@)UGFduKaIa066N8U~Ts!WerDl#&+I)2Vlx7_0}U%C09 z@vRMf4x1uT)6Xe<2_;8PUxuCfnOJ7SO2v4aIHkF!4)M$LA_+l=v?q3A^B_LeWFd0P zd!H@j0nx&8t+a;>(6-U<4Jhe~Vp{%Oj0u<%b!A@c8Oz(Z)wPK&)GEiIR?efeMFST) z`18dx8^>}D(~OFwXhqg&HxfHJ#0w|27o|ftbvSJXpYhBWt=IN@Y$?bJy@TxsRkb$b z7m9YMZz8Fk$9COYCA}v1IvlP?&YTgj+i4N@DQNNhIOR`nqruh+jr#Mr>sfgVYbUrL z0y)}RD#h5(hluA>;vUMXB1)fOHpTbqR(%FFj&=|>b(pd#u1oZ8X(AR>j%Jg&NEsqOX^Xx^dJR~@;E$J)ETjr=RNiM`W8j|+NrdGR92?+dmKNBQ zMERq0;9{iz@h?5e%o%x`ZURKW#x3b10^F&DlX-H#c`Xf{hg!1bBM6j=BzPr=rc;-< zTv^$9-^pj3`rF;;ZqJqls(Xk-lJv~TU`p{XwkXq<_7~SJSX}YFZpPprO$o>$Z4FzR zyD}Ek;0_Vacsvi{b1+iOvJ<8ssq$yf-?2CdmVXIMd80n&nYs)&H)`!N=bK-dnqpB> zz&mM|J}x%0sDVNQGqj0$FB5Ps&C`(MAKIT(YfoO$DO@%cK1>9;FTksg%RN@`o;iOA zwSctgd-Yk*q?CrdV9=HPvXJ^S$c*=wJD60v#am`Pjey|MN@8JN6KCgw?;Nkxg z2P9W06;ovnli-vWP%`oKyoL!PcXz7AXskj6h4bs8ZNBL5eop4C7I-wQan?1{G?oCi zpl{xhU2VnFd;cp*#hsv(@+W1I91;L{_s0z~268JE_4F-`ezuHv5=N|7evXyjTp)no z)==EbFNES2K!G~W2*-H?q~+0&c);PUmVDL;Nk7;}g331~0n8jl9T*%~-Cen`q~WIn z?46}Aq4R~*HzkZ*EFK=OcfG}-!L21Lvyv>_%R$LlwXT<_I>ZWifY>ntsJ)OarBwQgYVUTeOPbqS%xlAaT9&j1( zAt|f@u!J$&7$ezndb_dt!*-c{LHWZV`|IhLfSk8R+~EqDUSs;S2<>}vI>^-A(Hz)_ zZJ#G)+TqgdS9305&kQR)>2J3=GqU;5(MBj4^{)*~vi3Eiv^JIBY0|`}N2)yWoF@yo zNx9cIBT(Uj^;$mgrqol9!1-sVZx9T@HNhK3r-6wQPo14c)?h}=yX;3Ap9{-7ATSZN zqN|astWtP!mQ7Om(oGc;{HXKZ(C$NOMJdK5yQwy^2=~D==)0gkPUMQ8VLVRS%B$8+ z)21e}n>Khk{;ab0zGR|2YZdLd)pNQO4|O`(E-B3{jp@ z-b}-G;_pKoqSMB)CU2tlieBvm9{_AV|Jn+K%NG!`16tB0&}NW;#0(%CL(bmD)`8x@ z#{M^R0aE||!^Qw!y2$u-*#HJ0xl8&9Uu<2?Z_Erz_bHCMgwal9Tf=IiB09OSBkbuG zZ85c%`_6gc@npN1t?fzK=ki3*p(kNL<&|}8Owg!mpG;G zK)hdim>M>wkx5tHAGTe*Eiogr97$TDWZOb+RGKT->pZ_UJXiKO9IKz*@(oK^-g_lq zDF(xO)L>(V_$-ApezV5LBXIwZ6#RZ7qTAw92sHjmC#Hfb_rg?RQGWT~J2@uTW#DzS z782Z-bG=_uJJ;5^ow~3jzMX`FQ8In~n5ZfcHzoL;xXJ4kJo@=O_AXUJS9NJ?n5cMz zpK~>N(s8kKmd*=IT1#!ST{qE9%6D5C>q=$mP!uwoF0IGIrwRXEuhZqeq)%AmP({f((Q~N)KmDhckbix$km>63xV)E(o2T)kn?+{?9X*-Si&~PzWFZ!a>o#N zUFVjTtFRBaQJ>7t|CO8?=mXVZ0kqRTpq(QA)lT(nZGT3!|ClMzMuA(V!iWrzG}KOf zjv#;1OBn)_TS2WD$Z=MjSf5Gjk)X=aXoNlc!kqyj;ET<3JnAl5XVCL7nQ??GI6Gc> zAuPX#seX{ycuciE=Yp={hL%t*fnj2ogwMEV;q48vvcVNxz-UaOw@r1baIp|6yrf63 z&u=A})gPoy;-70&X;$B>LB8%@AEuSO8^1}It5i()M3Nd4zHoZbl{Y-}RcCcURMs8G z{HOy7`hJGN3}#mW!BRihSv*z!b`V`i7SNd2$6;##LQhwWj0d~uNCcHe!P^PWCTP3e zF{MB zz8>z8x)4t34YZ|IAAGO{Js;0iF8XRI4l*wuwoDHX+RlDXO#Z7f0s*B3Rzd&%mxkZ& z#$T3y`GbR;(^oi|8PH?&4_hR zkdL%q@pB+L3&+RhPn1^+hdFj6ab#1V9qcE|cm7!Pt-@Vd+qJ1OmriztaPvwfkD~Tr znO>L?ENXl;IlD|fYc2g12$LTM*2*KYvqFSI9kq!M;QlV_k0s4tA$mU2ijkYW8k~sV z$Rqk`4KB(IBFpt=Vt;WtH6(Z3?h4F2XNSLky~46=+J9<`czuNc$p6hHiKFlveuC^w7UXdVAeYp$H@0+OWcY3Tk1PH!_P@V8dTDf* zMGq6=ho64Wep77<%U!rRvW$k4E4WKAuv!w*7%Phw3op++i;Lhohabg8re|U%-5gRy zKX1nBoZ-i-AceHS&EMw9pcEeetaa*#TTVwoUvK~&Tozm~`HUz0U0+*n~^zZ&L*~{z!0C~q&;9c9RmuW|<%#>RbvZfLw%W$c0@M0o%`*7C*+0LydL#$e&jY!Onb zLL|=w3#s~9JmzDrb7~6m+k2g9OI-6+im|Uz?+?kF;TTxsyA8^? zBBmInob{%9AIcI)lSrD+czo`I_jr@i7`RF9L9~f&#tzAxkAq<%EU7Krt2%5qBJC4w)1Ft7obQmu4yNK=K(b)7224p<3%EHfitK~OR^zF~e%XZZ zEnPR)JEy)|-|JCYCxv?E5u22`k$vo?EGv~M6DJu~gY-G>)$aPW5k_@i$<2Gu&E>W? zhpj_|{e@4Pa9YN1vv$7-+y_fKRhbuPnTKMYq}Ci*vYRBb++~bC8Q@EpkLS0nV$N7_ zlQlsXjWd1Rdl!JaK7%qG$iFKwoQ@&XIecELE>iccuM+XSMaJgH2FMxy-CgF5Y80+O z5@wLg5CUMqes`Du&MAL&nm_Uj7^u_(x!wQntu%TB6kkE5PQZ&_q6a;OpA5mjxP^BI1fu z0n|@wpN@!VaHI6KbagG$CoN>9X0LsOvpE$fnZMS>8oeiEGv>>bVhoG$+h^-ng|^cD z{LMb@yEBU{o_sn}U%4u(^EG>r1>zk^y-o7^rr$doi1&O-r2Mw8p`(YKlIlcSMo=+K zQo@nnMO39iCP}^-vJ}YE6?f+J6?J}0FX_;!PM=|TsVC|GS()h2s4<;&9!}fVfDw^# z03?;;bGrvs{{L<@rruAr>I&5%nqCf z{-7pq;)sp}7eD?Q8K)SEqugEK%0=jW^39ejXlbI9xCLMS5fOa^@?HQ0v$cd1o`HZ4TOS zIe+yN>;cp6Gy{hVlONG|bE>`ad=*n-D+UP^{xB@&4pX1{A;_gbeb;aa=^hI|M?74H z8X+rEW*>(NF{2WVYCaNnFI zj%HOGHVdgMe3m?;33!`%3+uK9|AImT%4YO?@!dX6*a&I@ac?IEOipkEv>a+eDQ#}- zSU=)@W*-EYUZ!kqrxx8Ja)^p;x1)%9&Q$@Xw`q=H_o@lo5O;}N>b0o^iX|~Vt?uXB z$LE(d1089+uJ$)~IF%94AJlhm^mk47bYJhTH#WS$ULLN$F35FM?YLsU{`k(Q_xv)@ zQG^3sW|Pt8_Hw-EtoM9BqlC~pwh2Ta*+#XqMM(4IW{X9rmG%}rJ|PbT>(vN$+I7Gc zpfD7;717VMp?~9ptqqgp)H-0)TnSb&5rNb#29| z0JqEsA1v92P$NRkBm-`2fzQ>IXt6IGmJYTkj=-v-pII0f)VI*&9C1OoCauWd!2?zM znaDME`7nLLcBg#9ko+7-c~9O73V8W)HVmzrAo!ii059X`PGF4ClEo>{Mgv#B#+pR& zzCVIy3N1^u!!)YOI8Q=UfJH8Qi9ri`w{@gd}I!vrm0U;4@0|WzZyrU@}x? zE*v$XE}F)gF1&9QcIoZz3?U!Px3JzCZv|M5^nEQ>R-=cvl<#urb%$I`aur8s^Xp<7 z$Ak<$nlw=$%>3gl9KN(NByM%QTQXGKzaui5)EXJ=ed({q6Eu*HgdRmTsI-x4Q0%q! zqRFn0!A{ZI%iqtJS(9G{x(O1dDSxcV65b3BVgU)ydL0&(A%I^KDY_`|fb@Q(1?CeXVoH9yz%>|U{MLO{BS zF2k`wr2+rV<(ao^{H;sz__TA1A|Rx8qpbYVxQH@(U6Z#BR;OfvUQe&S92U6LKpK@a zO0G~JSy=IYf;3CbiIgrG8yhK)FCpMX(@lg}7Et`@+=AjGd5=usmGC`vl%DHtH*cD$ zw8K)mv8znqwE>S+$*!ioA{o#r3MI%vDmq14T8s^c2kN4pa;hZ`WR3YWUUf{1I&JaN zEIy|?#tAUsqj=U_C~Foi)7QmozAJ?{2yym(^lZf^_E|b*@lwwgLnohG#ioH|uu^kI zr#??ra@Zrzx6NKh&mN65brqrJ>bcFx3>F`sU)p!~Iyzv~HTOS`5wMMn$(BvhHNXT& zR?Gm|TIyu8)Q;xA_~|w*n;{c(Z3_F*p4c}i9=(IFQ*6*GoYfZTn4+7dO zJg1*wVkg8%BPTgDo450{TWA_eWYlh#f{7~6q>Q-bZyaBr`(>pMrb~v)AD}2NM|oI@ zBL-g1&Vy^ljpImGpQbKmUf%Q>E%@853l9iz*H zg0ZfF!DnHlNBGq-8&4k{y324*<5ZJ_cM8&kBf1x>M7*sl8n$L|4MrRT=9p{{Ts`J= z&a?)IU_*@!Y42lEb#r>@b9|D^_KX)sgEp7QTBw~(m#Jx(oSTFg!&JPEenUaS6HEvb zhb|#BxyPoaIMThSNdB2-EaP^ueZOjNbmgS`g`m+{am#N&Yu=DZlENXI{g}nHKx#Q) z3T}$@2CCOu^0=^*_n7;T^YV_zw+pTdNG12(PMe*O_l##h>y9UV%I9l+0FoGg!YG3vP}&X3m7!1Tcv zjoZwg%Uw^3TtPg)(o9{gcWksbsuSVW@(#b6bq+xM{a#->)AC!= zGNz3G_I|BhTd2&-ib|*BHhcvq9b5g&R!Qu>{xXUG)N%FDY&Jc*=trI~UBQ=)@96!i zv$YovyB5UA)>Mq7F(bC~&f-5KspXE9w!d9OTiqg8@R|gfhGdUV^KrP%lPRT8V-Zil zN=w;?=Bn&>SvNqliG|WN%d{ zWI)+M>nojt#ynACS+`y1frXXe3LA7gQ!Ct>SUo=5V&_Lcd11-dnsM%+T3hrtG zx3$VS5nY_R=Eb9J)pr-oQFCP<_kp5)(8zu`W$lrnLquK9^q$!lYC^If2WEIDmV><% z3_~qA(fIjgqZm3D;W~R~)#^zwizIJCUzXdQ zJWQBjZy;At#hg02c+9mS=eRnv5Uo4)jKJo9z#QK(yRmUVe(HJI|L6E206CzBhzbC} z761S!e-zLTj;@x*zekT_ofZ2PQFM=u;%6Al3?~x8U^0=Rav-%-{X%&i(LnUO2_bzT z88{dNKvT8d^Ez&8bLt@3JdZx0iu0y)EGyCf2?mwHLx3LeI7x;ES}FHT}8 z9;6zetPP{6p7lBpHwl!;&~y-m#xSEON-OU3)6B-}&Cc_;Y!ezrFOy5Gp+zJd~ zf8*S(sJ5-yHZMAkuuq5WrK68OZ!VlXeau`lb*&w*iNp!uJW$C#SEZBvlz5H_DBEV5 z2(eUnd4t@4B`yGWd0zEUD_IXH;Pl1-Fd^K(BkXVL;zW<IlW$A@aFeR|pml6q`GYi-^dy1J)r=S;VB%sJ&#V-{q z;q_WuWNK2!Fs5j?;(TmNKYUhvVZ4j-niYGIu}ToK+2et`fYTI}B4fZ|g_d9P{nI0VGRQUo*M#r!9#=fsRTFb!aEHZ^;&T}M{U;YcfAa0w#eV~SVRn02wQSulxw9e^;i>aXWY`IMt=~ON@D*D*>1bETW+Ldej0u^D zsY$xX&JEJ_Lb%uL*Qt7E96b{;{U)koy?3QQ?%s>GvMQ@=%yTG-=N@uL2X@W_vp2XF zSej!suzF0`@_<#GHI#*)OkA8bBgD?ett@2v)k0k25fzC#aljHN`__(Fq2ucqVAg{Z znLXQ%&_HO1v(~@gP8=$1M;;u1VC%q)@GD7<9ieqdCw0x_UO;?}9O3$USj#Kn*4LKT z*iF!Y2pae|AGb~;etI@O_B4^4z8su@PO0zMT`SYNP#ML?`>E6qRM8k*W7KuU-{g`Ll095>%w)kpoF21~-Awu9Bo){<6XbG7BOIM6&s{s1 zk)TMx-qdEKb-)WxK-rii0PM_w|iY)Q;8n>1oV5f zBihI>rs2jNnw9pGqcd&b(qe?rn6v&`7lF4jZj8+f`401YEq<_tZiTk+eD#hatNRB6 zGv)2#^PpXo9W=PoegcLn6oX_sDl;4Ni=?BmR!gih!5cL$w zzC$G;E`NmMxywl%mwZo6wo#wZ!(HEp&rq^fD7?%IAu%cCdLB=S*x{jmshw-=>=(6e zBoV!K8H9Y^Jj#0VseV@ulcJ6M)vZpqnoPAx5hDAwsJ;1-?5=a(59L6qdpkC;VPOIk z9Kf6LdRJ0Ve)^?Y($7e9F|m0kXlR(wvPdhzJq_5nzsMycw_{4!K74SjWegq`fF};c>Ti`L)!NW#sC>XV3$)b#k2yz+S?p(K*#CIvO z`w?ZtPy;6$eGUmOQH6t!<8s55lV{dr6g+x(k}*Ybj$}?*O=G4DDT9)2Gf)liRu4 zGgX~|8znhiLHPh|+_;RW^q1iB3w3>E&-~89Cutvr`y}w3i<+SlZ$@kbbZO_1sy9XM zSTG zi{srGXc%E>)SaL6BrtJmU8t5^Brtg-uq3!lauv>>JBEnZ9xMIooJRHBEwzPVS3lH;3sXeM8M|;GTw@)Z)IYoK)USpm74RZq-xy9NKvEQ^EK-;kfhs zo=BbDry8AbWv%+AO^pN`_FVK3LoW0oLhtvo5DW+5s# zz4CKmR>%Jj?sJvx|NH&2NBGAmBp0l14Bh8uTn9W~rPT73GET&rtO_N(3X9dOOJ63) zv`vrrnsc@Z#IGNEbyQg zkTG7~yQrrSzsT1~x7PC2_+Cr+I zEwmnS3)Wf0WCRu2R*k0~Q4Rtx)wxXWOZZG_dk5wl$EKVB+kMdkd#OPctne(RhiK5j zNIF3lW0*B0W`-;-Pn9*0t;ZV3Uz{~ssr-mA7v`OZPv)Y4r2kYDD8H^O1d@Ez?^Q%X z14&^_-}&Oq0s1p6VE1I${V#Ix(}ydSg&^tXP{E|GJ29#;{vwh+{T~s#@L4u~=baK# zRx3!Ur?MX%o(#Ki=kH2|AQ_9nLiy)Vhf^$I=S1p?v)KwonJtrcC_l!Np88wjZTr?3 zA4Jh>%%?9!2W%$M3zXB0+bu=QB!eE}i?x3{TQi!x`}>b)z0FyZh%H%9h5g1-R)0K6 z0zFp|=r~Pzr#erz7z9hP(5{~qWoOW`$XVv+Y(Z=N?ec#V<0sJ9De#{8AN!vdfc8Ht zhKpmdW{xTQyc>b*$mL?J-ldHIUl#I+ct4!;*ngphu0<~b^wbMsjE56> z{$!f8_(X+OO)CDVO-rrJ)3CRZH~taF6-{jJbY~~#{HnM)ubWQSY2f}L$4t*EMh}v| zReq!XD0ys;yRR2k`l{Q+B&o(BXIZ+V?(lgb*Lq0abooHrDExW7YA|*6e%UhSk>F^Q zovz_%bZ9K$`n^Mzvg=yKyBSacg>-F*1;!vMedyJqu^vn2&9GtSe^Tu@*JtuY<9*wh zCe!$Ut8D7Hp&)A5W$Kk}dKumIPyB3mqg$4v{gclXG%W6>r|ir|E2nu4NRAc3Gi{?M z#B8VR_p+-c`1W7FoH%3)H4XRb?7tA>t0nQ-sc|*%=D|NsD#f}V z@KVgb>K(2lU8ndE+x@tLb=bfT6HE0ZA8aQJfwOvOpP~*z_&_ z5`caUd#+26k<`Mywxvw)B1_&-LC<^^f{VDSW7$~WFf|%&KF{5=Sx_pB&QJSurnlQ& zu?H{cKQb$f-cV18P~lLR3dbhCU&4s%&ye*ke`*xxq$2A|g?dGAcG-3J;-_fF-O1kN zLZdL*%_w9*Rxz1j99$&&)K+H|RCUcBP)kvo2Q)I?X-lVyZB(RGc5`l*X<#IqBFjujXCcdai@Weroo_9OZw z35Y5=dluH;U6M6Uwzxhm*tV(OG6-BF!&9i~rx|dRX7;99t3C*&cCM6i0NO?ID`(13**m=SQiVrIYqYRjCxf?)9+Y7wD~s#+@-WBXP1KZlyi0lL z`}xtB+Ci;+8EW}BQA;*ruY<2tp`dFe%OcOPP?An|jqW&ZP(ZAF&3I2G=A@di#W0W9 zV)1nSdyg%UGT$@Meo$R!D|(?|m-Z%<)@f|dty#)ra=*>tdgROrg`kxl1{Z~dcJXgHg7FEj?&V1Vh_R8HEp%rl)GKDqY?4vxdoBApYU4}CK{*wyYx;MerJq$ za&>Qo{h}TUX?~NJ0srfI3FBl zl1B3qKkhni;Jt0A*mp$QyulI5G5TDDf<^JYS%pSC{bATR_`>_?7Hag z(CZ%;9#NKy2yH+eSbEUnBjz6q2hPR@N`F~3)L>*u159L_zu~`Pm}A-=dn1 z;fqGR6=jdz>LZzMl2~e72 z?upAtbEDSIbKW`Si3y*Ia``9ilE=kI7PTYz$&4=LJ5?PYM%eaZ_*WT9{(srfBF3#XQ3>py1inM8=y!zzm295Gy zu+dkpXN*DxL}pGSPd2R}@QW`~DWpjqCMPV-rDoylc?}lA=x*1D&{~BK2occ7-g+_E z%Zul)6nr$Qa?&@~HjxA^HQ%69UTsG+dj2E-#O+Nn^>1uQDi{C&{f|G$1Vo!u{%Bxn z{M*v-Zp;XXfC6eCLtUUi-c|wc6&8Ysa$&(8XGEer0Wu2MXnc^!R!d$Rq!irtq2LM) zaR75iF$X3Gb~hIud>Q0vKYJ&cOSl|i%`Hh|=T8rh*L$84aFEtg2Ew>swT#-n*4vy0up*&Yl+?l(l{E$DzgP-xi{i+A7?lbWHqBB+N;N+*H7mO`-bsrDaMnx_P1qVUCb~O8ut>VV zl*3~X=Klm{Rq~ipvpf(nk-z}00SKfKJGdk1Q6KjrbB66w`vP)?!M<-K;{h@ejd?>9 zQ$5BE=uukt<@GRVdBZsgP+RgQWm}Pw>{l}{5zdUt-5GCp-lwDsoMVqrGaFnRnx=i% z3e(wAMc1Z_P7YOj;yaHQbd`3iZA76Vg6Oq;;7_Qf9YOLQb;cy8+Up*3R`5>eh%8h;A$ zA~x*1pgoD@iJoCTiQCSu)J@W*C9|70d^yQeTjwg8C{20o|Egw89+^>1(UH1!!x)Zf z*_Ce@UOvj49&X=Db&4y-H_D%C)cW>dh*NCZB+?WrY`@^uPUr!^@y`xTMjD!I3$mn3 zkjjs5TD0%G<3hjjsRq}1rK-w3AuKjR*dqkeY5$!m&`7V>W^ z7fa?@%%yx{+(hS&Z$rJf`HT)LV*A5t;qt_Y)#?3G5znIU=jusA^jk7?@BM&CH!jMb zm*y^F;9sQ?;&Jf%bHT+Zm#$TYWx5d%D(ac^6?_p|W1QgL8CDCj!R3Wld|+O<^uGa_ zLP(a)Ni=@{f+buniOAbxK$8BTnH6sGc+@dH)K9vx{{1kYO-y_F%&DnHoSC=Z^y6AM zCxKq#6jdy?XuLs0Z4T8CBh|-oe$dyC16K|=PxDU}z|Uz|FwuFwR6yphba?~QYSx#} z5QHl`<>%}Tot2eKZ6fkr-y9o-f3O9LEXA zpVk=2gTDSamijKtYxI%!Xd$W(X{zp!?}rmNs!s&a9D)}};x|xmA8Yhn$Pb^RfV(li z^U{=yqW;xPKib;<7S#S@ zrJ!j8ZK;YQvLJ3y>)Ue_h0`)6avJ~oa(UWbAF(`WxB6olgRRB*6V;QA8uK@hjZrhF z$ERi{2I|CGF+@zlC&y$NGT(TFXw#BZ?>`z);BWqDRL70vDCA&+kyRVCSptUQ#p@N>N={;m@) zU&LQ2RAikVwzXU@dIz$4=Lw=B`JEF?inT3#kbtoaa7sV0T?Ni2J4I)4?-AAYI>(`~ zTGHCRRo?nW24CzfbczzKy4rxq)AoCEn$S2pan%Vmi={2l9J*jEl_E_j@6+DD*datL z-_ShxDnlv2Y^BJV3&ChnBbE=ouka6sJtpvu`_xt`JP;Uix4u)txYjJSb-7E$S=~5O zwB$y5p0I?5tR<)r^&rp*=!AF<)qn5QT*qEqk=EhI(BAN(aE zG|~UvlLZ5(2bDqp{JFyK|Hog3fB1MoUh3}({!Xs`3lZ1kYW*wlSJvsD&>9d; z>fbo1zrz2{!ut~p0Bpnk3;uuLVM>E{i@>E-u9m=@G${tBX^YNc( u0KkwI0Qk4;{44zLaq_Qlc!s~g{}namrJz9O@f(pJ0ni1CtYu8U9sL)7p{U{j literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce776ea70888dc79947718d832838f4183c53c72 GIT binary patch literal 13227 zcmeHubxRx~gS!O??(XjH?(P!YEw}}TIl1rMx$^GR)XcxX z*Hvd%_gVW}UERH`_xf613Je?_011Ew006{*jl3C4br1l62m$~=13-gn2-#RW8d*E) zD7o4iIcPDsSXmO~fP+$H0YHKC|GWJUz5{h}qt-o4$Rc;~k4Vvt%KC>nB{ZPlJ|@sB z9zkKbgMNOA8Ekv)Km%1!28o8XBq3+HTW9$^;6J^bVhMv%?@WG-$(!ICt8Mmyc4={! z-rEqHVk?emU0|5q$C9u*7w8TWYy_C|pGCEL6DInngBGYjF}R!zG7VRg-w>0NpzDkRKjLbD41-iSkkQ zEB+4n7ZEsEd`a>u5l|;wDn&0x+tBiIRzs3-ns`Jl z)?lJd!LnU{PVRqSP7BSOu)6^<%iZA{Sg*7!pD^15yg!=t)=Cv$Fqwq9waVhhAKG7e zvbqNY0N&of0P=rvNrGsc#&e*Z$pSqN4(O6P_C}Ttj10fV|GMJ;VE_BmrI*EYoA)xo z^Pl^__)oVhEO%q&$}$>Etza!dL2F1zqpf^5UwnPx`TiYL>qt**bY?bo%FQ87^vh

i$pTml0A4>Jk5MYVSfPaUi4bgOkR) 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b15b5b2cc9c34e1daa1d195834bb5c8660aa00ae GIT binary patch literal 13238 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 wCmH~Vrv(81Ekpkb|9h^ZhG8g~=3jhzPBVupo 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 KJ!Ssw=zjsoTCPU` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45c880bb32eec9ff636ae7ae5542531edec2472f GIT binary patch literal 13189 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 z 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 X1t1hiJ$_^O!vns9{Aw@LZ(IKdIr^P2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fca6c2bcc0847941136a5e6d3041975052b84881 GIT binary patch literal 14065 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 F{U2~+MNa?# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..694ae96428d0f4ef4962a34953e3a2cdbf8e8f70 GIT binary patch literal 14170 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 I+5b5EKi3d>0ssI2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..930081deb686dd83d268eda27883ae8b7544195f GIT binary patch literal 17304 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 H76JYrG^!#8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6050ccae878a11c5158671b71ada27b5ab2e90 GIT binary patch literal 17354 zcmeIagLh@i);_$GbZmBP+qP}1W7|&0X2*8N?$~zHv6GH%tAFWpan9{~#`paJ?_Fcm z91Hc-!hYtgSygjxc`1+&C;%`31ONaa0C4FsdHVtZ0Oa5R05SjqSWC#(#>v>m>65a% zow1`1t(&zKe%=RQ@*Dv0d;R}y|A)Uoed4H106l`pec}`R&s?}&#+gpbaesO6$0*ijY2d4XVCe~W9FESd!R2KqTUuyLk^$^j`6Imk?yvNtK z#Lz%dM8kS8#h1{4(<5zdMe8a85aN~t6Lv$-!mjZh$wkD=L4*>=o)q!hpFmv&eIh_F zp1U{wq%09&2GrfFFAqQ&YpCfx)~5E;`h*bGZ?xG|H7JlLP&J_nI6q!#xIJ%ptuj-0OpBI2+I`TJJHM0He}*Dw5Wbwm;7&M zfq;AugGDY^!X12+hab0)7Ta}qxydif50PFY$k)J6_-5r?1hKLP#o5VKeG_}i#Llwq z&VI5kKVxd{#as)c&Go=>Zx8t@WQdO=_5+So<#XVSuGe_j-rlwI=T8uT{D0^zZX9Og z`8ye9-_;WOU2i`*7+X2g(f*SE)7SsQO8S>qFOToG=%t6@KM!~f_};Ft+>M?uOJ_K> zg1!U}p(P=Wys~Jq@b=2RxCpFss4q4;J@aMC!!bkDc{4%h0y9YkHoP5j{!zDA>$%Mh zkQmff$lx|_w*`~FcW3fCLL@;k5)h$*8a{~$7WWXEGTw!}ODTM}`xV-pkYaMykhm^C z%U)&SKE;PACzWAt-a=f<6TFm1q$3!oWdg$o%7m{bnTaMgrf0oYr%(ClOA}+11;s5j zrWHLMNk*JOITYqa+xH`n{LZiaVL{Z8xsYLW&t4|lNSbf^;d>Z-tmgz8^`Cy&VrA$E zT>q_plz|(Lae)8;3NQcw;`>K&wRJF}GqN>wwtny2e)Vbv$-_1)^a%Xtpil7rXOI>B zQh|#CY0TDep#BA~fV2W?!aVWVoB679`7*6SzjAGIn`;y1UJQi=Ht;x4t|Hjv{%Lqx zvW`J0LJB%oshg>fbA#EST-MH%$<$!U3ZNYW!&)A96%{2Ms5E{hK=(b&P?iw8UrT)NQX zU@S-BaEREk7)8{~zJmu5@m~b%Pl<_`=9>&@%z^ah^XsX^Fyn}MZ10+Ei|c1Ib<`!R2*w>Q z@~NcF3_mme0!Qow^A27HW{)jUS);^ceBe;J!=3 zF#NF1j3NP+wfpLpU@4n{1w+jXi+=)S5(xXE&KxN+&R;NUGx!5!ru2uhf<5*+2P?Wd z$WNBN5B!a$1Uk+EO;(M%WXC6o|n{DN`_2FUu3KJUiY-;OaX zxyz>w_k0$L`oGiAODP zsDC&-769`w{-6yFzQi*^%aw3__A;Bv?26NJt1WS(W(TP)<>4d;3T>ZPJrENsWN(M% z+Z^$O@zCT4vc;N}8y0B9k`r7Vq&EBE^>nthO@O)E{kO~(Q;PMy`3mWe--WOfH0v!- zj<{yVwg-^Ks@lEIn#Z|MjA4|Dc$$hmN)nO|Eq92u%SKf>0`>cN57}m0`=Kt!6sHss zE<_DtpuB{szIe%zRq`H)abCwE7o)%;rl6+lC z`T>pDH-|RPmUoMsJBiTE46#8(`aM z?g|&5@$n=~3s_aO&HeQ(@~(p%pa1|i00QU_P5Vc8 z^jCfRSJwpe?n=Jb{=dDICydJW{&J=kpkmA_&I&e8GUz6@woW#-PR5SEEP8#CmTfvc%HRgsDHr~_lB1NM!k1Fbgc94C7H_~p z%I9#pS{d<Q`njIN3tyXlKJDg4A`PGzLrF<76-P=&ySS91!~kN+`#+3u#S?TBOU>dK ze5fWKMgSv0X1Ks!DDlvy4=kKT_6fLwDO}r}GRCAqtDcIbm5~{MvzuPWx=?5V_Ft?fV?W# zM$R0t*@7G)2Yn&>8MX(R4~>S%3LS8}WX-$%p# z+0$PIr^YB_&kV7=hd}>StOsSB%*?`&st%!PK#xBUPHkFTW*Qu}4{E&zRAp2h@^aHG z{7S3d?WuXkvF!M%R?5*ct=j_0M?G+7zpt$z_E=I@{>Cd$x@$q3YWmRf5T?EqhrWV$ zXivfUvamM&dd4Y!)oH7Tk>vY2yp5kKMhVq|a2N-QJcGXFm*KC8TJ1yMOc7FROs#J+ z?6z^TZ=7e;Z`o8ajvF2^)VxWee9x=~o8f_fKFp@VvHOWs4iNE0n&UhT=48pI6e)q+ z!ROQ-CaGDMz3fpU_S-qFqNT+^!@2qlaYtd+jI&2ajm+mWYiXXl?W|<7w8&|rO{2ak$2HQDA`j?4aj^$V*o%E1`0ZhFcCLzg>?vK0A~?n5a#S4 z1r+Xks>q2)hbo$MfDggRH^ai=41TW;EVOUvLdZdpl!@l)&4$tN!$N=+7{$P^>nY?D z2K}O7siy+V|ARVKfd#)}FOHVlh9{;TJHB#1&YyrHO$-y8jFWp)8^Ht&nU-R9uWA(p zYFCQ@4U9TxLbr~4hOGByo(9b1#5$O8dk)d;GZ}Iv&~C~OkPU^`Hd=gt)X%)@#_8?l zPIo6cJE;EFjF$_p=S#>IcdV zbXSK1SPF~kzSG^}54v+K4gEoP^qDvhqC1AhJKfzx%i+~06x1fEzeqzbmx5#+Ea8T~?jzlX>;xT{FYTMjc@NW`4$gc~ z{c^D{BJM3aCIuB#vV+nu=Q%*)H6BIyr6YQn|LZc)N?L#wo=P${RVxpl4B4-Jo?zZi_tt7hSi>-F2{2t73F*3bMqc+FXd&A z`2CYu3)eP1^owh*MPl(?gbTfK+4J?`{yOS% zp;GwG*ZoJN)e}hsh*8Ife^EuAYZ@FC^!?wMByUnAeacO#Kyj%Z=7wn zGZlj-(FSe|a)TgIJ$o}npV1wQ(IV{gkr@Zb-$a z%!_lNSdI#E3KlG>HvRxnTC;-44`@%S>&*gR)nlxM0L7p6vzW}srf6e2gdS6=(}|TQ z$gPU6NndF4n`!fciO2Dyxz!{nj}E&ArZ?$J@LOYms|_`7*LB%>v`&5VPaC9#Ku)&b zC|s$jc;3k_?O`;7ZxO{y+xAVvy(h;Xt2bY82C8z#@j>~sIXFF8{r+H{!2=l>p^Ph* z>~dWNrEDr{uS9wmCy(i7=R`f=y9)hEBP?zsedj3Bwr& z1ZCa=VG?MPz2YQOZ$Ym=Ynh+e{7CMiSEa%^@kV%Xl25eDM&wC*GaIa4CT0gu;Ou^e zyJYoei3xGL&f(@hIt;fDr|QoQ(hj*cp*qk=$fS~ES{&r^>`%G<_QulkLbNR zz_|}^lmP=Kq4)a?DS?{EWkFXlw`Pd`2II@4CBJ#iMsHZ<)^qFj`0?*7KB4Cbd*!iGd| zb=u^!RV=j=?<+5xldJE}*pgBqG{t zxthHSTGV&yt)|!<(fNf?cwLLYM8Xc`Fg$lKzKlkGHs38EpiDk*;(@&(pvsEU#Hp|V zMC$l+Eci9Yh+elRX?_{{LlayI?0D5{@c^f--Vezns27Z1!VN+A=HTqhb<3`xx=2|2 zrMjUUfr}cg7@82Om{9yw#5VOgQ|$tpq$!|hk_Z=U3WdEII*(2&ZklTN;4?vux1)o$ zZ4h}di)1Xz1|BLIsM&p=9T{VVvuo0%AebE0V=PkPh-Lq zjM~^D+YiDgkLRhW&N2@-Rx+@wEuP>u&xC3D-Mn#}BbQnDy2q<(XI_0>AD4!^Ybsfn zh!VC^6VKN`spCDizx8HJKflyZfB^vG_y7RvzwEn{nX$Do-5>G~BY&bX9)ZP%()sa; zFSMBpH>NY$Er731#^npialsDx%2nii+IRfnRt5t3cqO_ZK}`>en|UD$SmvoIsh=r9 zfvm4Yl;&2GL-S78HMzMbnDa!3_gOw3=ZA+Hj_5x{lnE^va$&YH_pJ}hW-h3&u6Z=3e%s(RL1>TUsAL{m1eaCPK_7N2`Up!id z0y;NVX73{zOqOIj0fk4U7F95U7+k0x`P_Pz>?OKiuHxZIWx{>f%SXsc&VfK2in*cK zsm0;)f{q7&S4a@D*m`V^$-peQ?;t+_z|FBBG=p=#viA2D_B6u6(N>~1IX{yer2K}* zW*o-wDnQpd7K#(*pIAPJT5EUa@>+Uh0tOA6rvs(Xv`Xh!gB$xy_=odFTHv+(q47vx-8|d)Yp0%b*g5lvD{!KaV@nTBs+EPpTKkjfc^8A zSX)$WnJuJlFc}MtCZTNSEo|Bw1HTp*VsfZm=_x$KB!X;bV2oE5G{Ao!*Y=uI|r=+l>uhptq;nkOjHUnr(NqpGW(2pI+Yv zJ4-%-RoG^?d%T_Ox_)|noKb>q8{Z^{#@j-$w}Z~~=VDHPu9yBSdU8q}4AiF)=Cb36 zEe4o7)KU#pH5mijBLSv8S<$10iSS=*rf%nmx#PRwHA~#t$|wM5qJjjY0r4i=IhfgxDHWqrU*cZIUok z0=Q>5;J0EKMu>?tlMK4EBY&-}Mof5PwQ{tBcOtJTInPB#rnm zyBY({7&KJWHZDli-+<8|DrS0ymD?VJ;P=(eAED_v6;{r$caP!_%WsU*bI2qeYbsJ@ zE*c^ETRf!=b#(tK#1hfY3^p%#L{wkhX9Wn2tUWCzCZi`JQnq{=-C;MA0>!b}qHkZC z#)S;Mn>FD<%mNcF96j4;Q+{-^Eg35AJ>VEkX^jr`y$v*A2pUMof{h^ zk(Gx%1}=_Uih63aWv{mKabtnS{rRyrt-0KUwhW_n2)x*28d&rbNCmBDeG>iso@qEF z2NJaZT{EqB)9UjVzmr*5n)(j2aFEBQjESyCm?P7!;s@-AP4dq*8AS!A7mteHCIzIM zsk5CLRT?qzuP;8!Cf&Q0PE5O|D+0pnH!3QhjY~-5*EMwV@8QWb zm3CapGIp2gzct{tjvWe2mmowA_T%PeZKYdW7@sW!<1r4m#=IQ}x%ehYh}G;TYyzDz z!lPCx$zq7!m{IbwM2y#~ezZ-gB(Nn$V@jPA?2|l(#6;^63l1p_PjU=j!w-XiJ)%C@ z;wMbe7_X*g`652dvKBMx@I-ZN#GIK|Gju4C?m(?zfa5J!Z86nM?2RHV+Z{T#ghLLi zQjwehM3xfqjfE`5*q`g$mS~HnjmS2ve2WM{7)TShLhUfO-|;?6<`^gQ%(zPG>8^oj zhr_gIcFR6=iJ$cJ7zh7siTNZR-dXHqwd8e2H>Jc{dUIv-h_n0GzGplL42we}FqZyB zL7G#R5*p!loih-*5DbI_*i) zjDBZ2#>wEoV;D9Z@M{*W)3>E-{wu|I(1{NHG|Z)^4!JrNNm8#C!>7(|V$M?b)eBnSXf5<#-#T`7J3GPEHTU$#v6x54Wh7+^};!waGkak#k+`jgj6sLSfbxP{PiA3F>jaI08^+$ptH3T}_vwe;PWF z2+0;>x+WLJu^Ttq5?r#lb~UCN04*|EyoYEbTB`3neR)D%gGfSAFpN8rXE3ZhI#{M6 zSj}EcKbd%2x>}I(OA0FJu-&~O^@HV01F2LJ3rS_c9s6F2AWS(Dmm+kp>Ki52XD*?u zi@Ibt@{;c>trJwbu9I#)^=>k5BRPB2y@Fk!iL)QXOV{l${0|PEfxTR-ic7jyoo^1A zuP(TO&p`zeehgl-S8_%(sApZpzHnz93<*8;wS2Dvbrg(cGgWZw2oriGzec8w8})L< z&1}xEsTDywjb&Fg8daaVMh`$9Xy_szJ(J_ObeDjR2@I+26uM+A_bau@)rxoMTS@(% z=#<*ewi0fipa=mj>G@%fWBPkZr`_<87~W-mQrW6Yh<_W8QwyQ>C^%} zJkB5{6@E~$zBug?pEN1#etF3iJi6aW$VD2%QukCuxj1n=f*iGlbO~-1Mn6Q)#-LWT zBzmN1p0$H^PVfj<0%`nQ&dq0hsbt-c@(n`R#+&#h3Sd^ysuB~$-$zx5q0wuxP53Pp z__9-U&5zM+MAK_yaMADm$*kU7m&;T?k zX7LI1Z{S$84T!VaS=;VNs|}>ZdkE#oKFTBt6?UdFDWaqVfB=8%^m8E1ICaii*>k7r zjOzL%fB_cm3vTVeXR26x)tkF6Q0aS4DnoO>3%w!ROdm2iMIoj_)^@cZ@UcYDvoH0* z=Kz-IhmkW!D0Ti4C8i)Z8E_dvAAG=^5pO7U*Nhbeq~Jyrn$S?p23bbax(7?3H1)V(TAJ{w#J87XD;l(7 zX%2z>sHZ$Es;GpGNeSZ%lwQrAuj11MVD+bH2qbwGb09d_dkWCc3=+)1Z^hlc%A z=7*g3>4`z2E=2si%=4lIFjHS6h@g6w%1Z+#A~5yODY>n$JMVDOk#NuBgtC>_X^cv>St}74b|KR- zCjCJX@)Pt%sO6S(Y$j#F;0eh1$XKA2>xOZ1NcP5y=-N|5&q#m{I*R(lI4({^vLQfW zOkZxJHNl-@bAAe+v&q>Q!G#b1#Vk?pC zgNFOe>y4=v&-eCXocbZ#J!z1TXCw8cx)J$;8ATyuJ@ee_$?bN_h-?TtNt7Cqf^Wlp z=W$|^@AcJ|k2hnt->~PJAGqLgiIICl1Ev4S%9RcNqu1~iUSIgvt1WFKDb=Cg6C2mz z+UMIN5%MG|)xJ9Ru!zC6*Y@^&9Oy5%0*Ke{*jH+V+H~%k2RE%4a>RM!*CK5rQuQIc zX7Gd&s;Lm}S)-D4$N`N;_L|+&aDXXBIHtG=_sf9tNSe6d_+c{7rv=6mAUJ!#GhmZ^ z=aU^e8JeM5k(2!vM2MsEkG{A7rgC~X{(4b?6S+}`;-AV6;8Zc;W_|%w9Lp-^8r^8r zpO_K%A>l5&1eoEHLNC_)ci8iBR6oZ^_5Wbx_H;}Rj~qt|BW`DmjYc)oyRF(0d;RKr zMfcRk81c#=Hw$n6ExW*p2(e?!u|YOm8^?q#m8V|psgo)W&Hch|;EFz)Z{%u3I=N=- z=aX^TAlDU?srw~kjK*<)(9?Ghk}>H08xib&u2#xH&#c0$D^pU~PFvYsUsW==5vJr) z`6x$7sq+y>Ztu&(d72DI3(`8y4gIBvm1NEzJDb#D=jt^RM%YSXnB9S`)GZ0O`=7#D zq-Muk=jr!+2er$NhE~7nYhffA#kR22DC+LMPt8)fIcW_+?{j&UYG3VqWn zbd{Am9oFiItlB2uDsePy2_<@a_WzpgfIGME+dfTeDi>&wIkGXcO*HNrFILqV=v7Eq z(3Zw%{CZC*kawm#=)A}I=qp(r#$u=S1ZrBE3PGBV(DFbY>YYgFYOxaCyk6Ye0iF2$ zri78xtuzuYn=(KD>&U^w`=NVjB=gw97`ZGF#^voRJ=W(I*ui{J=4df1Gwhh8DR$hF z9D8m_{%tpGoT%+fL+0o~8ASFV2yfj3_UGB{)3v6Rl~DbrmGr=Oq4oJ)fV|PKo?d#R zpWWHPn1BzjVgA>edrQf$PBuF6j zq>bueFU{&>54TTo^F%n?c<5#k=n$^;a5qZ2hY$R(Z;bEcm&)W=uG1F?isxI^bH8#fKARv@acil~mIjS%K!4 z=4%t;d>lExFDM+-Ef&sNA((fxVEIv@Zo<9=*LDgFGqH)1o6udwO&GZBVnFI7^Mtev zZkc7=ZO?^=N>ijP&LiwDiNtay7pICo3#pNailtyo$F*&rj7FxPcE)oa-y4?GYS@HX0EdBYPiO(=V(0J@=h z2vUsFT*wbG8;>X1%%ENr;N6Er3f7;HWZ=!KM^kd*7{mz)1>@Jo*rOzt<)iLx-`J6< zBh0337;RjlQ8FMdlW5e6W(0C*Fh1fLy$~G0nzwDJqm{W6P%3y6134DKUf-U;<3__f zpNsG_GTn@}OkKycl$9yXbVr?&GH(vJA~L7fy34;w$uEJeK8&z#7E^krR&&R+OBDxP z7ME&mDJ;0YY))>42^(EI3z~Kz#g6%t;HN^0)an*-7)P<->IbplI(tLvh;AcF51Y67 z8rutt27~1r2nHy8&*?5^pVBM%5V?`N{W)r*=$9xn>0QQQ`BAjV)aD1dEI>SVTOePjyQ3WBi0F4M)P7<(umrV*xxJjf=iTMz~cS&8Pq1r zLuakFNIxq(&O+*&u5>BHy>Mm6p~~nhBt__nCXj?72s>Ij2jnZU-d9>n6=g4>>jb6; z26++0hni+_KF*9>UY&*qxuoGz-`}R)d0}nPgm1bijNi4=i+O4(C4t4yurEHlk^VQrPh;Cg0rAGHT%jLygRhvqt?0>&YaHeaHyK4G%ATgPs z`86s*$HT3^_E}Yo{p>2+B68opW1E()6sgfg3Ag>UpTcTu-RZ<9{# zaj!+p`vO!G^}ZaK0t2=K>)!|9zjK{D5O@sY@d;P)@5n@li~A(3=MKN07JVd`>V{&4 zXwn0n+4e`3<|9@YNFeC85GLz_|K7^B8Nv^ZDwq?*nV0{*3yDgMN30y`k472zkJC`k zpV)JIZhjS^Kq!S^2-LisASi+)TtSF>d4dm`#UQPB>pKDOm;XF{n{f>B|BFKa zCQ**{ij^mT(kw;*rcs9QLo0Qkwri5t_sEdfe&9FGc|U z1N7@*RjN><5h$f&kSV44go_c3L;u?9zu$&HUJ!H`9P-bX(0MagBlm7SA$!n}I}d(+ zqf%>-$6F<9)+sjkEv{AeDSwM0)P}IftLJrPpWBY|!M~&q3(Cs9D=%$r@(}xAv38w2 zdIs*LeG_-CT^l2KK%xZjnPMYJQQ|moaAzYy-@$QmZ%IeG;e>C=t4c>=4gl8?P&=+& z{X&>d6x&2K#vVqNwWITWSprUG6*WIIaH>ZqZb=yGcd#tY9hS^-gEh|cv%+; zp=I^E0Q()0Q!~jlm#YIy_5hr>F7uIQVe~l1%N5T0)i#wy+m3pCg_(0L;T}% z9Bl1J6ZSa-3;OqhK3DA+Qiw^Y_m9gx_@zK&cBKTfdhdn4w3{45hDEqb5f-spStw9I z86@S0vQTmNd!a48rIrk^KP@@B!W^1a0aQAx|ixU|ZAz)2SG6{T2g>SrbXC3e1I zvT0i6{~Jh;luP&@yP{ii~Cu3zlI=m!2QmXU5-Y| z@sm)(r=@mMp0%<_n2l6V8JeX^&s0aEHOnJsy4JOTf%c+}FwaEN`=$j}G7l)VrAD5{ zfn@62=D13SO=r~B&X55WxUA-~jHWEbVDAG8P2QUJvuK!E{3O>qrG5I#k=q> z;lMlu8q|fau0vqPwR=gv{X9MNN$Eju>@h6aapgJZ$IFg}JzVM>+*`r&I-jcV^TkVe z-lXf%?viG9@cxYHgroULRf(R5iIBN6+VXvg=U(TDYh3Ba=YuLjYlpK(xA+h`%YAFF z$5EYPjm2ojV~ypBFfOLJW9Q;^T$M2-p>Hpjq$+T?dK=D0l9=B5V3N9>I}%sXsjdAZ zzLq`vl4w^)Msl5^j@wUTUWh&Eyfc>Ix}Vof^vCoYvr}w^2ljH&&bdp{@NF3yeR?d) z3v0eh>}qsISk%dTPo0y$`?gQH%#QsfLf9?ez&O@Rxc1fkCUO~kes3UXh(8-S?C~ADzR`JaoBI} zUf4Spy~J9+!fd8-S#)Qsg2NXY@;*Lmyv$ZqN*hR!`Id8==PwUN&aEZ;&PF~ev(pqKev7O6GZnwFGaezn~Ew3AG% z=t&;bDbAJZt7U4`klL$Y!NIlZk!_zjC7LU;d#B%%YBO6A=F}G#a?LI=Tdn(A;m|%! zW=h@GIoih=b+jJXQp|KX9GGsNWS|NzqcO78f zMjhJR^h-aMbzZQ=w0g+UzLLx?1j;uj!^OI?9qEDFVOwq{9eiP#Ea%N~=qR>Y9XJz%$HIWyHoo7z`32};*oIliEYaVS><8#>Iy(4DQ9JBEQDdpBzxMyL7W5ff> zBy2~g7mU~`TGz9@4VUq?_op$OO2-NG4Ohba?LA-a4Co<`N;0E(eUE1Yetb1{jA-7< z#JQThfAI~0OjE4G=o35SMJ#lY654?5JkS2Ykj@E;orBn*|E%D_hgoZ5#0I^1(`VAr(WGFs_%)}#YAI#c2-`ZULzE?vRPNq~9G(e|O-9sVSXair%{M#D7I z>dAK6+RFKe+V!&$qcsiMN$h2?xl`VBN2k&<3Jd3e8V5(O;mNma>;_&Q(^EnfIwI>i zb7w!nQWEvAnlx18--?b49}&qe5EG6FSNZhT$4`>a*$zJgs!|qf24|w(v5X#94a%L; z&vx&FP1j9dMiRbR&U?9Af5C7}60E>1kt%&|z+wr$5TITu1(dM- zeVBVu$@ke#oCmiOc0qDNeViTUnPZ7E#n!ZWME366++1rtTLq|YLsXqcguT4=sEZKK z4<+1rw9_F3>F_wCs zC)cP>T`KP~!2;sPtrV_Yms66JWWPVSP*`)YAR4y`QxkHCYL^EDkW1}C2d5X4M_44)Sd&I(jN3W5tKZ)DE zhxyXHPkE#KYnI5>*g)woGu|09Yc@UjD1gmp63>ue&q7WXDMJ(}y^rNZm9=B6#3R$|)C zqFy`DB;J5?%FbYzV|YgRix6;d6K$B&cMN|;Q%3mSq^NpUFxP0tqN$*(QJgFIL~c!3@%GuAPtOZDy)E{3YFNN3hADHNlbR1-p9ToNd1k-L2L5>!G8RcWx}>k2dRoTXe1QFss@eU{AsB` z<`2mBk@xozI5|5$ayR8;ro?a4uZ!vPlPF+(sk3+UW?7T!1;8X>>`6}BHx(e@%B^>B zXhu4azJ_edNurHS8Af=21r79?-bt0%mo|&kL2&HF<*0u#XwgZKH&&KbM zSi!$*vVo>lHI#4V5$Q$zfJuJ1ZSFQ8uBp!XGEIKr5LO6`xe~`^ z712nVNf`Jvw50O-q`yNN4{VtOrywJom|@ucP^+Nd4Jh@C$53Q`!kHgSl!paMU-IKv z&9OI=!xfb9Kv0!D8G1XKldM&gueiyg>T`TC^kT0_WEretDm~h`_z?~X)lH)FAv>Y# ziY=PhEY!nHPX}YMX<-=gv{?tc*)CXH+M6Vi$W)a>9Q-R*H1^wCTzFk8Q3sR1BN)lx z=uaFZpBKGOg!83ujlhnM=5r?Fa-Z;gy?ni%?jHAhlUoXC+L_FB>wK1i)y$u4J@)(B z3C!`e=dPfGT{fr!w1AAa4kom z=>75nusxXApoqM6XinCZa5tIIwR;UJcBnY%$llCe!0APhX$4|yZ-{T|Je_xHx6KD_ zd7nozzK=O*38Zd29uPV{Jb_t+TqQK6Sm3K3IrK-Fw-<8X^{Jn}tP8D(k4L6w8p9dt z74MC-s9`6Vf{Lm2Ehc)-!-C&U>Ed2BE?r{!xZRVD6p$4$^I>pPgN->37zIQb?(?{N zY0@cRgxDX2l;EvZHWh8X{X0nq z-${z_SCZ=6+5IxB|KsR8LEpCw#XnMD+K4XU70xP@_)(Ry)QVBJx}+@3CX?DFsIgaS zVe@<^gZUx0g5|jC*1wM}SNohx-vtv`n2vfe&|Se)I7zM)jb)dmFBxa`6PF_mXi#4h z4S0>q$!$`w#}JrDVn{5i?^#Rw^s6V(xDZa1SeGcZPK626Gmp)EOKNhy`X-dVsG&fO z(Lq)ZH8&RH|CrO{A@vhgMfUD(tAo>HdRtz9KT3QE?@5A5jn=tK6pb=Du*50EOW+1# zQ-T@Y(}sL=(?8q`(u!PfCLG@=@AdMUN)H)gVI7M`B(MLq9rMVSDn! z3033i!UfA!dWI4A!R%REtkI7xgEW`v&p0*3LvIIq#G6T3L@ByiCe{H*&l4M>ot>{2 zbdLrcbO&CvC+Yc%+aW*j-aqy~95f(cns;aH?=%1Z_)Y#I{^oxGQQO;#1$3M*qP7HDlv<`0qjQf5JoFGo1c_|0M+ecl7V!!hfP8-Xkpk z{qX+`9{wHudqC!&XjAlmqJIz1{Jn|aLt6f9!t9-Me>}in16+QG|Gtp?C!B%!U+{k~ zEB_AueSGmJSdI0c;NM0Yzqjza1N>(T5APwY|NMM^afg4$|EDwZCmH}K=L7)$%Qg8O l{-52%U*QToe}VtA(~y?}ebn001O_^{-P_pTGcsw{HLdbN~#vrm(Gz zld+AHuClwGv7%qXI_$q&f!^(qDjxy7{L zksneRln$YBJi%3;6Z%_T+R(ujRlwrmtVk(XZ`W8=`vNDIGOS=xYh5Xhu=!H_6Lriv z=@#dA7<`RzDL0dt*8~SS{H)koT5)AKz?jKuyAwt~!pQ^kfPt9mcYqk$-AiWxl@E=}>GhdRt zQWfGHmIECL&tvd#_|p{BVxW(mh@IFJ=?3~p^Ii3eA}hR2jJP43&ND)uMoQ!ZbAQ=yAqy; z*T5@gxe6O^29e`_GrqU51dRAPW`6}{p0~~4w^m_QHfFv7U^$%j)y|ZdGaZM%w$2tH z9N1faw7!D?0A61q01AH#NuqeX`ZJK9$$=sc9u$(g4#rlFOpL#c{|Uwa#qsx-uU?wa zY0=G$AaEA=95~skxYUW0C&y$sv5d0_4WlV3gTB0AG5_++yRZPReW))!JT;v-;o%4r zbKXeOKF3Q@L5ygHox9iR)_iPn10)Bxd}DE&v)e+*+`BP(9wL*Z848Tlz>FBjgNnZk zOB?M#-=!6~*)4!KC#4ymF(j|f%d%IQzfJRD%}!^TowJb8^n@CwwX z3+>a(e#9OwKgSt~Mvd;KEk00N@Dh}m{uv(^V^D}Cpvdt?1OQM$KZ>iZgAtRFt)a6u zC~y6iuu4;xZI-!E1lF+^eBWMWO(Zbc2Q7$lN(S=MqgXW&3nV6qh_v{9^;#@=j(%=^ zPC#<5Txnu3`sPOxFFcmM8ONG?_Tkt#yl&-v&Ma+21r_R6gaxH?Tg1!D)1d_}$$q%O z*O`clTnN|6(Cf4I)f1(BbBe~FFepRcc!bpyH(MoHTxvEF8Z2oFXXQ*^GAzQKQ(%WK ziqOTdeG$Ff{JFNZ85M~!aNg#oAU}tWil`ocG z8GW-m4?40;Epc*Ps0qHu2FmSnYohV?{do9=QHKpwkD_51yzcTbZ@gWAXLp{EBif3a z-o+tc5LZv|z9Uw&U%d`N=J6PbfQ7m2y<9b&KiNY^AB5#b{TL9S?u<&B?&1@qrogYy zy{BQ1TfR(2;1o{v#=ifndDvoy&;+U}RnMMZat6_nhThNN01kd|QkxOE94RWuFW z*75?s)Ws*~bkbr-8PtSGZ-`Y3yW!)ceG5_liNMWvzfF?f8!4~I5G-GaJazx%lP*(m z`&M?dYw#r?JaN~6X#E3`C^9dZ%3=3Fe`c@2O(niNZ0_UYs5Ng9;_`XnSymBiP}irZ z-I!&D*tsw8n1x6uZ}Y~8WZ?LLj*(Y(!U_10+VOJ3 zJd=Mp@2dy%;iG-R2<0Y@0A}RAwAp;0>>u)zeo0erN6(%_aCVc3{;hZW{CgYTWP{DC>4OJ zvf!7%G;anTZv~+pD=CuYxo3bV5q}{)?C8~IdxeDL(S%;pxkGuHZw9dX~; z_cX@_jj5cA7`oPa^w=*4)FkpBJnlgkDgX2uQ@^L0F(?3F^eq5@337(ty~fGR*xH!s z_mSndoN%l$8i~(|(T@GVAJ)i26x*Ka7RX;M>yk)u^mT`N`DfHz#^l?2KgpW+Y zLZ3WnuI7Yk5ZNXsq+ip5gE*eaXw9w02j-kED|2#=@#e@5Z?k+n&JGVX9IuxG48Zw%OSyM_h|b%SYjn1RDaD-3JAJ=I!%zDg{y6NjW05f~Epg02G! zgt--{?-(v3-ecnBNyNy~z~{ut?qO3QWJ$G>(0G(<(uJUiBZcWv&#q<3UEuiVC>zT$*)?$RR4WJdR@PxK;$2$v!(p->ar> zLENQns@DMVl}Zx*Tf8o|jxR2&``bVBxjWq4VSkT(7Es^0(c3ZI)p@_lBzbe4mT zPIC)I&XF93W72}O4<4f0$4suV^8v#@dS@~q8Zpq3gztn*NYFQctA23B1U~RomijVf z_5|7pHA8~(d?aKgG|42M@BI-Jb5t479>b{e%SAeZA`DW|_k`>{Bt>h`1>J7bBav80 z^Z5F*t1(^`bQoSb>vD>~%t|c$yWoNRmQf+H-a6bmF>%vV{G8TU6#oLd0F;KubVPZ> z?me1AeE$(z&jFK6{D~-)*%*}6+5}n~`k3Aom_@RkX@U>XkZ%JO$zhvo@oRuHyV7vO(Uas!^&f#|M3hWLrt4m1jKyBjk#8afKD{$4PS3TuA6Mu&@xneMk*{`Q#x=BnK$Ays)6q zC-0UGxe~djiPv?%?c)1vD&x4AW$Z57du_mL9XAk^DM^YR;?K*=(af|kKRWXbiqAO0 z8t=L<^t@Jz6u;45!~`BV#HUs+#cqgG4=j3GBq#1x|FKQ`QE*F~!IVBF#3ywGjg`?O z4jNVlnc@h!QUC#kU`Tzu$zOz~K0!^*@=0QleKmI6;eqbRi0yN3<-nn2rUSjA0in12 zcZ-Qea&HV7xz4bWMM7#sm9o?%Fsii3T6U^5;{cx8EwQFgHlo{z3QeLU;SdeHidBQW z{zv=lpGV%aO^>RipX?flwK+_Aerei=FY=d}8sQe0DKZ}?CO(ZD|1Nde)=4Y5n%P+1 zIOObJ(DO(PgKKeU1jXLFAjELOUZkxj7?*BCnBi{PBl-m@Q?UdoOhvm$Qg4ScdgF9Tcgi9?Qd zS&Hur^B?UVE~bXoJ|Gt1FF-=59PEgOH%-VU{7%%Adu(%U~1AaGQ3-3<}|5dZ51k)yGb zlevwl<8R5YMorN%lMBVCJ^c}xC@Xeh!OuEh4w`2#LB`=E$>~(X@4It63RyVU>FD#V zCalp)-$~EOPH)(=OS^WNi`w1lqp29Qyt+hJN$|FI6cDW3K_||l+U^5C`RNF*L~t>< zqRwV;b^nyR@BO3gnM-_+lz#>RK$L3(kkk+BKM!TpY=RPHx6Kd(RwRGsc4B3>z zjTQE%>FJ)tYts^m(NZktuQYydhvpk4LS^Fzy@e?_2T_#`= zz}%Md3*WyhMORrBawSKXsA0{cGfxrc%#46fQiOq)@&wFsGfy7sav41{OFvodysux) z{ptZ$)E*Rn6YR*bAj*!#eZ@Y~Jve}C@iwqfUqa`iy+I0}#O)*IJdXcyvt3u?54{6X zoqRZ&FscOh{F$hEpaJCOS#God0#sfyP9}J2Va^XQ#p!(C-!>sln#Hy z&d(@p3Ua6yoRSR-Q)Eu9fz8q*wkLo8gsn%CMrLFgA~kDW{RuX)!DjNafb+7KYVYN; z(f2UrA2G~ysj?}bO5n;f$YA;&LaFqB&J>tp(@>kR_MFCN6D`&RU(ai@;h|f0gAYv@Rdrty>C^-x9^a!_j^eSh`feYY zj-7PW8t77gU~EXoGnJ{n_%-!jb?>&SV<yt*Iv1ZygaK<`uGl%Axp9vp_TpJor zFR*htVBd_v&WJRbYWOB<*Wgf~<;x+aW^HvD_~MLQ<^}9?J|x^WCu*0Q=IvIEmSLw2 zE2Vcr7Ci?IQzSGTFk9l))19Ze zA)eTM{kPN9-IweqcoYBtrT_py{-aKJbaJ;c{(X`<)?Rj47DMx1FMftj%5otl2qzVl zDyF89teY>dCG1V$92eH3CIts$1bk9$^Vx^88|d>p5R6pR(k!o%@bfldE|52DvQH=W zeW{ZOsNiMVqHpEGd9rgtIcFQmUz>fVIp z3pTl}pNHpn2WDP+dOsPd7p<^DpPaGEv3MDhpY_Mk{nNhn^6Fq=E*WCi^wYq}ID{-w=OMB);dY)kccEXO`Z9 zX0hOhJrvWp%NSpvrGdX^Zyy|0yOe!)&-`YL%9~Y98nw+csXKWMt^RrIo>*7VwNr*-i)HrR;0&fkjl=q_AxVBo2(WY9nKu8l6J zXj@tOT+JaX`VkwN-q+c)2mw0plDy4ymloSz#^)o<`<30vS}vcLGObFHm%9mss25Ab zd56c$AG-;`l|L{tleI)@JMScJ-i8~a@MQs2bw5;)r&_6)c@h5EOO91 zi>iLSTaDZ`@8+Z#L5K}q%usnT{Fuo*=74ISe)-lV6 z#;s=UbD&k;00+$n0EYiEa)a-f2nBWE_+{7wpA}U0X_tK=YVOKtr%4z{5XfOqU2C#KxS68pw?p4CEXFFGMThOjfyx5Y0Po175)iuXu zRT?SD=Bhshy0pSO!cw z>Dy9iz1f2=rrxAp_Z{(Mf`2c+7!fY&J{$MSY{79hsmVoPl$`E2mrU6iL>y zoDrJlPsuwo&V4Yk;!)GM!dOuRPFs){ zySF-^Y7ECrtGt4(3gn@wMT?VH;KfSlvFrGTdt*!+aSM8iCyuX_=NsplVJcBgu2mU* zqpEqH?HhU_s>AA7=3*K!nm~}===`$dq&g*udOz2Cyk5z!>Z@TbPBC?gD-obG1Ygdk zf44fFuusb1c@TZYA)Rm>FYXjpZ=7YWTTS6xN_|Lv&l$N3#H<`j=6Kum~Q$VcM z;-jAp8dd?Q3Fcm_XOA{9`gU#A`w3;mSUTNg$Ug_HWoYP~W}JS8)CRxL_O$Pm)439P z=OOy!qz+ngD8_=bj9tQ{goa2Ed49S*ULsH!bFKY;n$6H%B+w<%(VJgtFdW5p(V&Qm z5ON94{z9>bIAAfZ>k)a_Py;&`ZI%!=O@)JAb$Z=>fq%w(2s~kEf~iDlmUvcKO=G(A z`U|C1$MqLdYL4DR!3UB2QPWgt9sLEv7=<~qD_e1F)FZB z#5cP!w)I^NcPH`tW(_$R`i|0jLUtv^r5EUTX-TJ*UO&0v>#Pt1y(n)Da*L+9yz z&76WHGhb)8v%2pot#y>~IXRa1Bx;Ql;pLWE%!Fw>jd6xb-o$;j$*^B`MUP!3$|L23 z6O*$^8f$V8u7N#feZ?DQ<)JhyzWLEiC9tBFcpw*lIj@p`bc2r#Y~r7}){y~D1wco0 zb)RCFbL2*O67YoLbBgRYY=;~W_^qIPCXJsaz)V=7{%n|tpX(UsAe^0t$xF78i&@hz zN@sS4UiLOZzUBuDyq&O97E%MrgitVwS3%Kr<70!{%c z<0+KG0*{`twe{hUg{~yPzxi4rGIcysB@vHE7f3|Sub2#MmqxzY; z9+CYRN#%MddiGf(sD$1V29KDRpx7%9UxHV5OD4IHFm={i%UK%oN7p{n{E7VC!U3Od zlTlW%!n_RFU114DVNukQ)><8;O59pd?XaqeMidz1XRn!7 z83m1WS!ZUQ-c4c?IhjI2uvs@?h1&&Y6WO1#Dfu?=vhG^4-yU!NHWfUG!?gy5(k6(b zK1fSt69*1-Nn#UJe%y%KG<6^*aB&#aiGCC!#Cja^GY@YFLAthGVHHNbm?Qvu7SQ*_ z0%lj1JqToh5~H6*0h6xvwR0hJQ$*BzDhg3pQx>M?FWxLdkY*R@P(nogu6)Z&!lVBe z(+<*qn}i{miov4zXHkYSL8RjB0|}GL!jQj{vWuMO;&t48M9gUcv05va&<;V(Qim`& zu8@zs>-ikVAgpCqhrm}wF}%d8S%tz|O);R-%gv}+CFw}!WIP@{ku1gu9w84}GF9iq z$k69ZBA#uqDhjvo&1nLi({>dF)o<^UEwt)pq}UlX9X>`6r(_iN8B5D5KSG;wg1+hh zWZtg&Mp^8c75AC>1;q1Bwb*_!A@esL5fG0>E?z1FyCR4T_=i!64n#F5`#0n7N0I{} ze>eVq8dr*GJ&+ZCP1@gE5DwO`Z(}eo6Rv%^H&fa6MN_owV6lUQ$J8})&^*CEnDOty1DUaQ~0M*EaOWsaSd>KH* zsJW3XV`Lx`fB4NT$GzxzH~h7AYx$yb+eewG4h1GFlN4?qc8PQ!%jwVx$<>6Mv6{)I z0fNU?<9P1I%jiYQTia6Bdi_QnQ#qTq;`wy=Z$n2ut%S0DL!2|QDv%9adQz`Pqd)U8 zz?`mC$x2hdl`wKF4^JIh&jr|j7;`*!B0>)^1MW8jUX~gc`PP#RnYu7l+pNjm7L#_n z?&$~%*PZBiXv*K>y7Op=UW6r{b(1AY>F69E971@& zE>IFPYtTm{N9i^oM_CcuHTfm<>I8NTra#r6pg7EatL0hgbzonY?p2ewP2+0qR3-PW%SxBC`Fa)nvpv$DBB1rhs~hR7^X4miFTusgV7q>B$ZK^w z_3JDOf5FxvE^@D4^+ZeBs-ie6JpHUKjM6Zj^<#h`CM9%iK383&xNEz##;EmSb@5Fd z7Qk6>;n3_eUm1`x!{TDoo0jlI(L-`|`T&1g!Qho$J|R}-^}S}3Z^n6gQse>;m(tnx z$`b#qv^3X=qcdU`tX;z*?sM&`{FOmsr4Lu1hHY~2O~|IIyu~%)Vax}DayS;Y`k&Ct z&&!41Y;LEH5uiHXzgWGg5hHBqO1rQ#g>i)b_L?d?ne+p$Aacr(R7&=9`H{xqqiKWu zF52LC#&=CPL})BlKXR`SUNznTGRr>n3X^L#jqMv)iu9z=$hHyG-Dr0RSLIq-Gpfpk zsk7{Mo{01I+G zq$Ado^ubp8<@0LNfk9Mc0xPN1$UwV-_HoHbzHVG4-<*f#G>YMoC7ijey0Btj6lt*w z6r9mRL^^#;c+PaM!~O21)O%dbWDWM7B7!bV=@IzH>w#83Po6pPuQ3lQ&K=0S75zSZ zspWORw97KS=DFnqkw%m6lhO~xlMd|ljx8glF0Vj#%Lbhi&&+HO=->pp@2V`B;22UP zhZZ^fJ+Lh&L&siwXq+xs;0?rSkVfs{XHXQ3KOf+IXzr!tk=<9oTh5rDdlcdw9+aR(e&mGb zp{SaVw{+2PpEYWw@4rzm3dNgicD-jNh{!4Wl0)!LRjpy?n&v)&ue!pTaeh+!MzvgETfd&7vVJnDt?&PgbR@(dBkQ@jh426hY~q1m0r4z571Ra0bT&6VI3-n{z7!fWK`>h;(963VDwf`vIZ_*|&+Dy3aUc z|LaxT5Vdm)E4-3;cX14Hr^^n1B!TytE{$N*6iZeFr= zbLO_e0xV^e$p$6W)33AjX6&C7HFh?%)MHh}P{CMaw&AD{b$m4`>k3HXXR4c1eR&Emr~i<+6XRtY@MJ?{TJ z7S^Y=r!lDIPJ>nah3hb^A3i_Mwwdy3x#kd;_rNVA!?XfFFr`c-uB%L@t5*t-%cuLk zCm&XSJ*wZ@JW#0j?eP+6i*9Ml%ui@lvoG{>Nc7R&GBl;@>pDWAI=nx7|9#uOuxHvh z5F}R1gaZK3{`iATKsr)oeFH1w-@C7g%NEPb1bt`Fp3vKmb5)LJco`{K@1XLq^O_gF zkq%cDB8e$w?A3W^uOiId!X9(qq*&m8zX{5J}u&q8I;5! z&SlO{C`=l>Cx3F{;ll~`uT;-Zqm!iCjL0}YzBl>yk3X2mi>+ndAMrhZ3W z0G=tVq5%6jeF|LjZst-#&%2%)DGcpRomC%KbqTeg8ICGWE3&9DF4>h-d~o*E!+m@{ zL6}qq5HmlY;WmdMSq{Doh8^mL-fa*)Z4e6mwq76rwJd7rn8Yf-cpy*DBg#wq98qrq zG0qAsBaVHB+xBM6?$(tntl_U5qs^N?SR+=n ztn6cCUlP(TY-Fuy1;e!;2xtoUyh{bH)4pwr^ zkPP`3Cb81v=*|{vyfYk%3D8KGf8d)NvCcVuXN5!v&nldVVnZY}ctA~`yb%9fp++N_ zmo0VJsvNd|H-6{#i3UHWf|d5R{RASZ*Y$z1jZBao= z6Og<_!NJzfk;%~3;dhS(DJ}maF9F4JRD!H+ATvV9S>g?`!YNAjM>Ue{DURzJsl+*N zvwAlB<l;BReP&MEy*GOGrK|h+PKA!-1m~R1eI1+l4(Bz&JV^Qui~G zzOf8bOZ0I|;0pqAM{#k0#Z<1%D~vDW7v1HO2Q4-oMFICAGlNEkxAGShPVwBO?fy%r zZW3BD>?m~3FJrL;L4dPljFnR4CY#B&6`c0ari2V!s&_*KeH{v;$g@c zS08P-zK1^isA*T8F5Dc*)J^DTVEKBu_|ryMFRPmNFzSSkII+KA7JSzpNB;SIEY`H- z;6r$UB#sZebj$-&z^=RhE8SRMM61C5^j-7bzwhtWwZk^qgPe5;I} zJcYxW@ZCPSp5~syP_b?yoU5Sjo7{;I6 zz^$KT&uYrti;t1bKMo{gg@ZqHl9KoyQjKag&Vb=-6l$U6PZ9o1I6&pm{rj6q{_9WqbNn~AmMBR7o#5}%_dlTkKq5#I_!lAkufSh5 z!GA)lLCUayD1?87|6O?aCl~;T2GunGHyPruOuvfJ{^aUL_#dQczf%0#&i|952mN0Z ze{Jsn3jeju_9xsD=MVUQH{E^(|5|JR3C^Sb7xrT@VC~EZ_m1pxIUW-EZ&y50;pZ AMF0Q* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..34c0759c252e75e31ff65e511bde3cc9191de20a GIT binary patch literal 13625 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 Ym4L6n?(y5(7l?okaQ|w=_S@Qj0jtD-761SM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c8b4b7c1107fcf8e7cc3e04d4f9878846ea1c11c GIT binary patch literal 23266 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a50687b261ce04ed8c2089a71f67ce34dd9f2839 GIT binary patch literal 23294 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5cb10c688a33f92cfb6459049cdc6f0ef05c7979 GIT binary patch literal 13188 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6bb746ceecd6b3694e183da9cf8f3aa052d9fd49 GIT binary patch literal 13187 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..68c7fbdc31418123885c28f83fe2a82f93e35adb GIT binary patch literal 13226 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 YRFHlLG>_lt{;+^Az_>cg_}jPt2hw|z!vFvP literal 0 HcmV?d00001 diff --git a/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts b/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts index ac4ec7b3c6..62c13c3c87 100644 --- a/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts +++ b/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts @@ -148,4 +148,48 @@ describe('document-api story: header/footer diff roundtrip', () => { }); expect(reopenHeaderText).toContain(headerText); }); + + it('emits a diagnostics warning when header/footer changes are applied in tracked mode', async () => { + const baseSessionId = sid('hf-tracked-base'); + const targetSessionId = sid('hf-tracked-target'); + + const bodyText = 'Contract body text for tracked mode test.'; + const headerText = 'Header added in tracked mode test.'; + + await client.doc.open({ sessionId: baseSessionId, contentOverride: bodyText, overrideType: 'text' }); + await client.doc.open({ sessionId: targetSessionId, contentOverride: bodyText, overrideType: 'text' }); + + const sectionsResult = unwrapNamed(await client.doc.sections.list({ sessionId: targetSessionId }), 'result'); + const firstSection = requireFirstSectionAddress(sectionsResult); + const headerStory = createHeaderStoryLocator(firstSection); + + await client.doc.insert({ sessionId: targetSessionId, in: headerStory, value: headerText }); + + const snapshot = unwrapNamed(await client.doc.diff.capture({ sessionId: targetSessionId }), 'snapshot'); + await client.doc.close({ sessionId: targetSessionId, discard: true }); + + const diff = unwrapNamed( + await client.doc.diff.compare({ sessionId: baseSessionId, targetSnapshot: snapshot }), + 'diff', + ); + expect(diff.summary.headerFooters.hasChanges).toBe(true); + + const applyResult = unwrapNamed( + await client.doc.diff.apply({ sessionId: baseSessionId, diff, changeMode: 'tracked' }), + 'result', + ); + expect(applyResult.appliedOperations).toBeGreaterThan(0); + + // Header/footer changes are applied directly even in tracked mode. + // The diagnostics array must contain the canonical warning string. + expect(applyResult.diagnostics).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'Header/footer replay applied directly because tracked header/footer replay is not supported.', + ), + ]), + ); + + await client.doc.close({ sessionId: baseSessionId, discard: true }); + }); }); From b55220f527f1ee4d05d08173cf2fa0cc024b072f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 14:20:29 -0300 Subject: [PATCH 04/17] Merge pull request #148 from superdoc/colep/sd-3403-bug-localized-word-heading-styles-render-with-incorrect-font fix: resolve heading style conflicts to inherit Word's built in styles Note: this ports only the public subtree changes from a mixed source commit (2 public paths, 1 non-public path ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: d52147092a9f313397b649bd0146edbaf7155e2c Ported-Public-Prefix: superdoc/public --- .../style-engine/src/ooxml/index.test.ts | 73 +++++++++++++++++++ .../style-engine/src/ooxml/index.ts | 57 ++++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) 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..530c0a230a 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', () => { diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0791ee9472..3f55dbf6ad 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -64,6 +64,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 +332,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) { From 73f75142d2cb2cc92bc2775c05b9549a5a971271 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 14:21:49 -0300 Subject: [PATCH 05/17] Merge pull request #89 from superdoc/artem/SD-3318 fix: cjk wrapping after numeric run in long-word line breaking Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 30f3ada5c4f406a4d176488f9aeabae9f10c5823 Ported-Public-Prefix: superdoc/public --- .../measuring/dom/src/index.test.ts | 67 +++++++++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 31 ++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index caeb1f9f27..3fe5da7435 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', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 4dc6b5123e..f4dd17ee72 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2457,11 +2457,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 +2516,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; From d5175a62c2dc5e4230d0dea11832c4d992ca1467 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 14:27:22 -0300 Subject: [PATCH 06/17] Merge pull request #159 from superdoc/colep/sd-3483-collab-mergefield-integration-coverage-for-sd-3363-one-field test: add collaboration MERGEFIELD DOCX export integration coverage Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 92f104c3b32f809ea9f88d7d60a923362e75cfe6 Ported-Public-Prefix: superdoc/public --- .../collab-mergefield-roundtrip.test.js | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/collab-mergefield-roundtrip.test.js 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(); + } + }); +}); From 33172f4ebb1e3666f280dc7efdd3b46541ec5cd5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 15:08:44 -0300 Subject: [PATCH 07/17] Merge pull request #128 from superdoc/sd-3453_font-picker-dropdown fix: open font dropdown on input click Note: this ports only the public subtree changes from a mixed source commit (2 public paths, 2 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: dead128c31124ce4e548ec384c5f8cb90f0f9af6 Ported-Public-Prefix: superdoc/public --- .../toolbar/FontFamilyCombobox.test.js | 26 ++++++++++++++-- .../components/toolbar/FontFamilyCombobox.vue | 30 +++++++++++++++---- 2 files changed, 47 insertions(+), 9 deletions(-) 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) => { From 873dd25455bdeec9fe9de17d86415a248b672765 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:03:42 -0700 Subject: [PATCH 08/17] chore: reversions Note: this ports only the public subtree changes from a mixed source commit (143 public paths, 1697 non-public paths ignored). Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: a98541aadff55fe90583c5bb1baf83cb29ba157f Ported-Public-Prefix: superdoc/public --- apps/cli/src/__tests__/cli.test.ts | 26 +- apps/cli/src/__tests__/lib/context.test.ts | 22 + .../src/__tests__/lib/error-mapping.test.ts | 56 +- .../src/__tests__/lib/invoke-input.test.ts | 18 +- .../src/__tests__/lib/operation-args.test.ts | 6 +- .../lib/operation-runtime-metadata.test.ts | 14 +- ...2-collaboration-runtime-boundaries.test.ts | 20 - .../lib/v2-runtime-import-boundaries.test.ts | 16 - .../cli/src/__tests__/runtime-v2-open.test.ts | 40 - .../__tests__/schema-ref-resolution.test.ts | 2 +- .../src/cli/cli-only-operation-definitions.ts | 12 +- apps/cli/src/cli/command-examples.ts | 4 +- apps/cli/src/cli/operation-hints.ts | 37 - apps/cli/src/cli/operation-params.ts | 23 +- apps/cli/src/commands/legacy-compat.ts | 9 +- apps/cli/src/commands/open.ts | 71 +- apps/cli/src/host/session-pool.test.ts | 130 +-- apps/cli/src/host/session-pool.ts | 69 +- .../__tests__/headless-comment-bridge.test.ts | 28 +- ...rowser-comment-metadata.regression.test.ts | 4 +- .../__tests__/v2-runtime.test.ts | 20 - apps/cli/src/lib/collaboration/index.ts | 1 - apps/cli/src/lib/collaboration/v2-runtime.ts | 21 - apps/cli/src/lib/context.ts | 7 +- apps/cli/src/lib/document-v2.ts | 44 - apps/cli/src/lib/document.ts | 33 +- apps/cli/src/lib/error-mapping.ts | 36 - apps/cli/src/lib/errors.ts | 4 - apps/cli/src/lib/mutation-orchestrator.ts | 8 +- apps/cli/src/lib/read-orchestrator.ts | 5 +- apps/cli/src/lib/session-collab.ts | 11 +- apps/cli/src/lib/special-handlers.ts | 2 +- apps/cli/src/lib/types.ts | 11 +- .../document-api/available-operations.mdx | 18 +- .../reference/_generated-manifest.json | 30 +- .../document-api/reference/blocks/index.mdx | 3 - .../document-api/reference/blocks/merge.mdx | 291 ------ .../document-api/reference/blocks/move.mdx | 287 ------ .../document-api/reference/blocks/split.mdx | 280 ------ .../reference/capabilities/get.mdx | 499 --------- .../document-api/reference/comments/patch.mdx | 6 +- .../reference/footnotes/insert.mdx | 4 +- .../reference/footnotes/update.mdx | 4 +- .../reference/format/paragraph/index.mdx | 1 - .../format/paragraph/set-mark-run-props.mdx | 912 ----------------- apps/docs/document-api/reference/index.mdx | 24 +- .../document-api/reference/lists/apply.mdx | 300 ------ .../document-api/reference/lists/continue.mdx | 275 ----- .../reference/lists/get-state.mdx | 246 ----- .../document-api/reference/lists/index.mdx | 5 - .../document-api/reference/lists/remove.mdx | 275 ----- .../document-api/reference/lists/restart.mdx | 285 ------ .../reference/mutations/apply.mdx | 1 - .../reference/mutations/preview.mdx | 1 - .../document-api/reference/tables/index.mdx | 1 - .../reference/tables/move-row.mdx | 698 ------------- apps/docs/document-engine/sdks.mdx | 36 +- apps/mcp/src/generated/catalog.ts | 186 +--- .../generated/intent-dispatch.generated.ts | 1 - devtools/word-benchmark-sidecar/README.md | 61 ++ devtools/word-benchmark-sidecar/package.json | 7 + devtools/word-benchmark-sidecar/server.js | 464 +++++++++ .../word-benchmark-sidecar/server.test.mjs | 62 ++ package.json | 1 + .../lib/reference-docs-artifacts.test.ts | 11 - .../scripts/lib/reference-docs-artifacts.ts | 23 +- .../document-api/src/blocks/blocks.test.ts | 31 +- packages/document-api/src/blocks/blocks.ts | 126 --- .../src/comments/comments.types.ts | 36 +- packages/document-api/src/contract/index.ts | 1 - .../src/contract/operation-definitions.ts | 231 +---- .../src/contract/operation-registry.ts | 29 - packages/document-api/src/contract/schemas.ts | 351 +------ .../src/contract/step-op-catalog.ts | 3 - .../document-api/src/fields/fields.types.ts | 14 +- .../src/history/history.types.test.ts | 14 +- .../document-api/src/history/history.types.ts | 35 +- packages/document-api/src/index.test.ts | 24 - packages/document-api/src/index.ts | 98 +- packages/document-api/src/invoke/invoke.ts | 11 - packages/document-api/src/lists/lists.test.ts | 33 - packages/document-api/src/lists/lists.ts | 129 +-- .../document-api/src/lists/lists.types.ts | 52 - .../src/paragraphs/paragraphs.test.ts | 57 -- .../document-api/src/paragraphs/paragraphs.ts | 287 ------ .../src/paragraphs/paragraphs.types.ts | 12 - .../src/track-changes/track-changes.ts | 3 +- .../document-api/src/types/blocks.types.ts | 88 +- packages/document-api/src/types/receipt.ts | 11 +- .../document-api/src/types/sd-sections.ts | 2 +- .../src/types/table-operations.types.ts | 14 - .../src/types/track-changes.types.ts | 18 +- .../tests/src/architecture-boundaries.test.ts | 16 +- packages/react/src/SuperDocEditor.tsx | 18 +- packages/react/src/types.ts | 17 +- .../sdk/langs/browser/src/intent-dispatch.ts | 1 - .../sdk/tools/intent_dispatch_generated.py | 2 - .../presentation-editor/PresentationEditor.ts | 65 +- .../HeaderFooterSessionManager.ts | 23 +- .../contract-conformance.test.ts | 224 +---- .../assemble-adapters.test.ts | 65 -- .../assemble-adapters.ts | 18 - .../capabilities-adapter.test.ts | 21 - .../capabilities-adapter.ts | 17 - .../plan-engine/footnote-wrappers.ts | 4 +- packages/super-editor/src/editors/v2/.gitkeep | 0 .../resolve-toolbar-sources.test.ts | 5 +- .../resolve-toolbar-sources.ts | 2 +- .../src/headless-toolbar/types.ts | 10 +- .../super-editor/src/ui/track-changes.test.ts | 9 +- packages/superdoc/package.json | 5 +- packages/superdoc/src/SuperDoc.test.js | 793 +-------------- packages/superdoc/src/SuperDoc.vue | 949 +----------------- .../CommentsLayer/CommentDialog.test.js | 597 ----------- .../CommentsLayer/CommentDialog.vue | 213 +--- .../CommentsLayer/CommentHeader.vue | 19 +- .../superdoc/src/composables/use-document.js | 2 - packages/superdoc/src/core/SuperDoc.test.js | 81 +- packages/superdoc/src/core/SuperDoc.ts | 226 +---- .../core/collaboration/collaboration.test.js | 26 +- .../src/core/collaboration/helpers.js | 25 +- .../editor-runtime/editor-runtime-registry.ts | 7 +- .../editor-runtime/import-boundary.test.ts | 12 +- .../side-by-side-v1-proof.test.ts | 1 - .../superdoc/src/core/editor-runtime/types.ts | 33 +- .../v1/v1-editor-runtime-adapter.test.ts | 1 - .../v1/v1-editor-runtime-adapter.ts | 18 +- .../v2/v2-editor-runtime-adapter.test.ts | 161 --- .../v2/v2-editor-runtime-adapter.ts | 832 --------------- .../normalize-track-changes-config.test.js | 2 +- packages/superdoc/src/core/types/index.ts | 14 - .../v2-integration/browser-peer-runtime.js | 25 - .../browser-peer-runtime.test.js | 23 - .../v2-integration/v2-import-boundary.test.ts | 71 -- .../src/core/v2-integration/v2-integration.js | 141 --- .../v2-integration/v2-integration.test.js | 80 -- .../v2-review-mutation-route.test.ts | 102 -- .../src/dev/components/SuperdocDev.vue | 229 +++++ .../dev/components/sidebar/SidebarLayout.vue | 166 ++- packages/superdoc/src/index.js | 1 - .../superdoc/src/stores/comments-store.js | 870 +--------------- .../public-method-coverage-allowlist.cjs | 7 +- tests/consumer-typecheck/src/v2-shell-apis.ts | 45 - 143 files changed, 1423 insertions(+), 12689 deletions(-) delete mode 100644 apps/cli/src/__tests__/lib/v2-collaboration-runtime-boundaries.test.ts delete mode 100644 apps/cli/src/__tests__/lib/v2-runtime-import-boundaries.test.ts delete mode 100644 apps/cli/src/__tests__/runtime-v2-open.test.ts delete mode 100644 apps/cli/src/lib/collaboration/__tests__/v2-runtime.test.ts delete mode 100644 apps/cli/src/lib/collaboration/v2-runtime.ts delete mode 100644 apps/cli/src/lib/document-v2.ts delete mode 100644 apps/docs/document-api/reference/blocks/merge.mdx delete mode 100644 apps/docs/document-api/reference/blocks/move.mdx delete mode 100644 apps/docs/document-api/reference/blocks/split.mdx delete mode 100644 apps/docs/document-api/reference/format/paragraph/set-mark-run-props.mdx delete mode 100644 apps/docs/document-api/reference/lists/apply.mdx delete mode 100644 apps/docs/document-api/reference/lists/continue.mdx delete mode 100644 apps/docs/document-api/reference/lists/get-state.mdx delete mode 100644 apps/docs/document-api/reference/lists/remove.mdx delete mode 100644 apps/docs/document-api/reference/lists/restart.mdx delete mode 100644 apps/docs/document-api/reference/tables/move-row.mdx create mode 100644 devtools/word-benchmark-sidecar/README.md create mode 100644 devtools/word-benchmark-sidecar/package.json create mode 100644 devtools/word-benchmark-sidecar/server.js create mode 100644 devtools/word-benchmark-sidecar/server.test.mjs delete mode 100644 packages/super-editor/src/editors/v2/.gitkeep delete mode 100644 packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.test.ts delete mode 100644 packages/superdoc/src/core/editor-runtime/v2/v2-editor-runtime-adapter.ts delete mode 100644 packages/superdoc/src/core/v2-integration/browser-peer-runtime.js delete mode 100644 packages/superdoc/src/core/v2-integration/browser-peer-runtime.test.js delete mode 100644 packages/superdoc/src/core/v2-integration/v2-import-boundary.test.ts delete mode 100644 packages/superdoc/src/core/v2-integration/v2-integration.js delete mode 100644 packages/superdoc/src/core/v2-integration/v2-integration.test.js delete mode 100644 packages/superdoc/src/core/v2-integration/v2-review-mutation-route.test.ts delete mode 100644 tests/consumer-typecheck/src/v2-shell-apis.ts 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..a3f8bb1b7c 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -9,7 +9,6 @@ import { CliError } from './errors'; import { asRecord, isRecord, pathExists } from './guards'; import { validateSessionId } from './session'; import { - ACCEPTED_RUNTIME_VALUES, type CliIO, type DocumentRuntimeKind, type ExecutionMode, @@ -158,9 +157,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/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/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/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/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index c1fd12b2a1..3ea78d8522 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 @@ -6105,6 +6105,7 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, + getDocumentMode: () => this.#getEffectiveDocumentMode(), getStorySessionManager: () => this.#ensureStorySessionManager(), }); @@ -6362,21 +6363,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'); + } } /** @@ -8987,6 +9030,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 +10382,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/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/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/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/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/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 af80d21e2c..31fae7b078 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 @@