From 7bad8f058f1d470106a127c97c8d7f46831e2ce2 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 14:14:11 -0400 Subject: [PATCH 01/34] feat(superdoc): add layout-change event for responsive fit-to-container zoom Adds a new `layout-change` event that fires when container dimensions change, enabling customers to implement responsive fit-to-container zoom without manual polling or ResizeObservers. Payload includes containerWidth, documentWidth, and fitZoom (calculated zoom to fit document in container). Base document width is captured once at 100% zoom to avoid feedback loops when setZoom is called. Closes SD-3294 Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.vue | 33 +++++++++++++++++++ packages/superdoc/src/core/SuperDoc.ts | 2 ++ packages/superdoc/src/core/types/index.ts | 13 ++++++++ .../src/dev/components/SuperdocDev.vue | 12 +++++++ 4 files changed, 60 insertions(+) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index f70bcaf6cb..3e1eab610c 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -57,6 +57,7 @@ import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracke import SurfaceHost from './components/surfaces/SurfaceHost.vue'; import { DEFAULT_COMMENTS_DISPLAY_MODE, + DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX, RIGHT_CLICK_COMMENT_SUPPRESS_MS, VALID_COMMENTS_DISPLAY_MODES, } from './helpers/comment-small-screen.js'; @@ -1321,6 +1322,38 @@ watch(showCommentsSidebar, (value) => { proxy.$superdoc.broadcastSidebarToggle(value); }); +// Emit layout-change event when container width changes. +// Capture base document width once at 100% zoom to avoid feedback loops. +let baseDocumentWidth = null; +let lastEmittedFitZoom = null; + +const emitLayoutChange = () => { + const containerWidth = superdocContainerWidth.value; + if (!proxy.$superdoc || containerWidth <= 0) return; + + // Capture base width once on first call (document at 100% zoom) + if (baseDocumentWidth === null) { + const docEl = superdocRoot.value?.querySelector('.superdoc__document'); + const measured = docEl?.clientWidth || docEl?.getBoundingClientRect?.().width || 0; + baseDocumentWidth = measured > 0 ? measured : DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX; + } + + const rawFitZoom = (containerWidth / baseDocumentWidth) * 100; + const fitZoom = Math.round(rawFitZoom); + + // Only emit if fitZoom changed + if (fitZoom === lastEmittedFitZoom) return; + lastEmittedFitZoom = fitZoom; + + proxy.$superdoc.emit('layout-change', { + containerWidth, + documentWidth: baseDocumentWidth, + fitZoom, + }); +}; + +watch(superdocContainerWidth, emitLayoutChange); + /** * Scroll the page to a given commentId * diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index c3e3d4c349..c93a41e691 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -82,6 +82,7 @@ import type { SuperDocEditorPayload, SuperDocExceptionPayload, SuperDocExceptionStorePayload, + SuperDocLayoutChangePayload, SuperDocLockedPayload, SuperDocReadyPayload, SuperDocState, @@ -151,6 +152,7 @@ interface SuperDocEventMap { 'whiteboard:enabled': [boolean]; 'whiteboard:tool': [string]; exception: [SuperDocExceptionPayload]; + 'layout-change': [SuperDocLayoutChangePayload]; } // Notes on the event map above: // diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 0b6c087108..8205321943 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1538,6 +1538,19 @@ export type SuperDocExceptionPayload = | SuperDocExceptionRestorePayload | SuperDocExceptionEditorPayload; +/** + * Payload emitted when container dimensions change. Useful for implementing + * fit-to-container zoom behavior. + */ +export interface SuperDocLayoutChangePayload { + /** Current container width in pixels. */ + containerWidth: number; + /** Measured document/page width in pixels. */ + documentWidth: number; + /** Calculated zoom to fit document in available width (unclamped). User should clamp to their preferred min/max. */ + fitZoom: number; +} + export interface Config { /** The ID of the SuperDoc. */ superdocId?: string; diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index b963af241b..ebc02be8ea 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -924,6 +924,18 @@ const init = async () => { currentZoom.value = zoom; }); + superdoc.value?.on('layout-change', ({ fitZoom }) => { + // Clamp zoom between your min/max bounds + console.log('[layout-change]', fitZoom); + if (fitZoom < 50) { + superdoc.value.setZoom(50); + } else if (fitZoom > 200) { + superdoc.value.setZoom(200); + } else { + superdoc.value.setZoom(fitZoom); + } + }); + window.superdoc = superdoc.value; // const ydoc = superdoc.value.ydoc; From f168de5de174dc424e2e2a8dd2c556fe0da83672 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 15:57:52 -0400 Subject: [PATCH 02/34] fix(superdoc): wait for layout ready before capturing base document width Defer base width capture until isReady is true to avoid latching stale measurements before DOCX layout resolves (e.g., landscape or multi-section documents). Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3e1eab610c..93111929f2 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -1323,7 +1323,7 @@ watch(showCommentsSidebar, (value) => { }); // Emit layout-change event when container width changes. -// Capture base document width once at 100% zoom to avoid feedback loops. +// Capture base document width after layout resolves to avoid stale measurements. let baseDocumentWidth = null; let lastEmittedFitZoom = null; @@ -1331,8 +1331,9 @@ const emitLayoutChange = () => { const containerWidth = superdocContainerWidth.value; if (!proxy.$superdoc || containerWidth <= 0) return; - // Capture base width once on first call (document at 100% zoom) + // Wait for document layout to resolve before capturing base width if (baseDocumentWidth === null) { + if (!isReady.value) return; const docEl = superdocRoot.value?.querySelector('.superdoc__document'); const measured = docEl?.clientWidth || docEl?.getBoundingClientRect?.().width || 0; baseDocumentWidth = measured > 0 ? measured : DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX; @@ -1353,6 +1354,9 @@ const emitLayoutChange = () => { }; watch(superdocContainerWidth, emitLayoutChange); +watch(isReady, (ready) => { + if (ready) emitLayoutChange(); +}); /** * Scroll the page to a given commentId From fd9f34bc6d470a03dcd36aae1834e6d909640475 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 16:07:08 -0400 Subject: [PATCH 03/34] test(superdoc): add tests for layout-change event Verify that: - layout-change is not emitted before isReady - payload includes containerWidth, documentWidth, and fitZoom Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.test.js | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 3a9ab1410c..ce3db6285d 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -2625,4 +2625,53 @@ describe('SuperDoc.vue', () => { const styleVars = wrapper.vm.superdocStyleVars; expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88'); }); + + it('does not emit layout-change before isReady', async () => { + const superdocStub = createSuperdocStub(); + superdocStoreStub.isReady.value = false; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + // Set up container width measurement + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + + // Trigger recalculation while not ready + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + // Should not emit before isReady + const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); + expect(layoutChangeCalls.length).toBe(0); + }); + + it('includes documentWidth and fitZoom in layout-change payload when ready', async () => { + const superdocStub = createSuperdocStub(); + superdocStoreStub.isReady.value = true; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + // Set up container width measurement + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + + // Trigger recalculation + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); + if (layoutChangeCalls.length > 0) { + const payload = layoutChangeCalls[layoutChangeCalls.length - 1][1]; + expect(payload).toHaveProperty('containerWidth'); + expect(payload).toHaveProperty('documentWidth'); + expect(payload).toHaveProperty('fitZoom'); + expect(typeof payload.fitZoom).toBe('number'); + } + }); }); From aefcb1a657ba893260eeec7170f813945e208ad5 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Tue, 2 Jun 2026 22:35:51 -0400 Subject: [PATCH 04/34] chore(deps): bump uuid to ^11.1.1 (CVE-2026-41907) Addresses npm audit warning for SNYK-JS-UUID-16133035. Note: SuperDoc was not actually vulnerable - we only use the 2-param signature which returns a string directly. The vulnerability only affects the 4-param signature that writes to a caller-provided buffer. Ref: SD-3361 Co-Authored-By: Claude Opus 4.5 --- pnpm-lock.yaml | 882 +++++++++++++++++++++++++++++++++++--------- pnpm-workspace.yaml | 2 +- 2 files changed, 704 insertions(+), 180 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d2753e5f2..10c69fe6cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ catalogs: lib0: specifier: ^0.2.114 version: 0.2.117 + marked: + specifier: ^16.2.0 + version: 16.4.2 nodemon: specifier: ^3.1.10 version: 3.1.14 @@ -274,8 +277,8 @@ catalogs: specifier: ^4.1.0 version: 4.1.0 uuid: - specifier: ^9.0.1 - version: 9.0.1 + specifier: ^11.1.1 + version: 11.1.1 verdaccio: specifier: ^6.1.6 version: 6.3.2 @@ -305,7 +308,7 @@ catalogs: version: 3.0.0 yjs: specifier: ^13.6.19 - version: 13.6.30 + version: 13.6.31 overrides: canvas: 3.2.3 @@ -524,6 +527,34 @@ importers: apps/cli/platforms/cli-windows-x64: {} + apps/codebase-agent: + dependencies: + express: + specifier: ^5.1.0 + version: 5.2.1 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + openai: + specifier: ^4.52.0 + version: 4.104.0(ws@8.20.0)(zod@3.25.76) + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/bun': + specifier: ^1.3.8 + version: 1.3.12 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/node': + specifier: ^22.19.2 + version: 22.19.15 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + apps/create: devDependencies: '@semantic-release/changelog': @@ -555,7 +586,7 @@ importers: version: 14.0.3 mintlify: specifier: 4.2.531 - version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -766,6 +797,87 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + demos/contract-templates-backend: + dependencies: + '@fastify/cors': + specifier: ^11.0.1 + version: 11.2.0 + '@fastify/multipart': + specifier: ^9.0.3 + version: 9.4.0 + '@superdoc-dev/sdk': + specifier: workspace:* + version: link:../../packages/sdk/langs/node + fastify: + specifier: ^5.4.0 + version: 5.8.4 + nanoid: + specifier: ^5.1.5 + version: 5.1.7 + devDependencies: + '@types/node': + specifier: ^22.15.21 + version: 22.19.15 + pino-pretty: + specifier: ^13.0.0 + version: 13.1.3 + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + demos/document-api-repl: + dependencies: + marked: + specifier: 'catalog:' + version: 16.4.2 + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + demos/document-api-repl/server: + dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.6 + express: + specifier: ^4.21.2 + version: 4.22.1 + jsdom: + specifier: 27.3.0 + version: 27.3.0(canvas@3.2.3) + superdoc: + specifier: workspace:* + version: link:../../../packages/superdoc + devDependencies: + '@types/cors': + specifier: ^2.8.18 + version: 2.8.19 + '@types/express': + specifier: ^5.0.2 + version: 5.0.6 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + demos/docxtemplater: dependencies: '@fortawesome/fontawesome-svg-core': @@ -1033,6 +1145,22 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + demos/exhibit-insertion: + dependencies: + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + vue: + specifier: 3.5.32 + version: 3.5.32(typescript@5.9.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^4.2.3 + version: 4.6.2(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + demos/fields: dependencies: superdoc: @@ -1252,6 +1380,42 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + demos/interactive-cli: + dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.6 + express: + specifier: ^4.21.0 + version: 4.22.1 + jsdom: + specifier: 27.3.0 + version: 27.3.0(canvas@3.2.3) + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + demos/interactive-cli/server: {} + demos/linked-sections: dependencies: superdoc: @@ -2696,6 +2860,49 @@ importers: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + packages/layout-engine/pm-adapter: + dependencies: + '@superdoc/common': + specifier: workspace:* + version: link:../../../shared/common + '@superdoc/contracts': + specifier: workspace:* + version: link:../contracts + '@superdoc/font-utils': + specifier: workspace:* + version: link:../../../shared/font-utils + '@superdoc/locale-utils': + specifier: workspace:* + version: link:../../../shared/locale-utils + '@superdoc/measuring-dom': + specifier: workspace:* + version: link:../measuring/dom + '@superdoc/style-engine': + specifier: workspace:* + version: link:../style-engine + '@superdoc/super-editor': + specifier: workspace:* + version: link:../../super-editor + '@superdoc/url-validation': + specifier: workspace:* + version: link:../../../shared/url-validation + '@superdoc/word-layout': + specifier: workspace:* + version: link:../../word-layout + devDependencies: + '@superdoc/layout-engine': + specifier: workspace:* + version: link:../layout-engine + '@superdoc/layout-resolved': + specifier: workspace:* + version: link:../layout-resolved + '@superdoc/painter-dom': + specifier: workspace:* + version: link:../painters/dom + vitest: + specifier: 'catalog:' + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + packages/layout-engine/style-engine: dependencies: '@superdoc/contracts': @@ -2868,6 +3075,9 @@ importers: '@superdoc/painter-dom': specifier: workspace:* version: link:../layout-engine/painters/dom + '@superdoc/pm-adapter': + specifier: workspace:* + version: link:../layout-engine/pm-adapter '@superdoc/preset-geometry': specifier: workspace:* version: link:../preset-geometry @@ -2960,7 +3170,7 @@ importers: version: 4.1.0 uuid: specifier: 'catalog:' - version: 9.0.1 + version: 11.1.1 vue: specifier: 3.5.32 version: 3.5.32(typescript@5.9.3) @@ -2977,9 +3187,6 @@ importers: '@floating-ui/dom': specifier: 'catalog:' version: 1.7.6 - '@superdoc/layout-engine': - specifier: workspace:* - version: link:../layout-engine/layout-engine '@testing-library/react': specifier: 'catalog:' version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3004,6 +3211,9 @@ importers: canvas: specifier: 3.2.3 version: 3.2.3 + fast-xml-parser: + specifier: ^5.5.9 + version: 5.5.9 happy-dom: specifier: 20.4.0 version: 20.4.0 @@ -3078,26 +3288,26 @@ importers: version: 3.5.0 uuid: specifier: 'catalog:' - version: 9.0.1 + version: 11.1.1 vue: specifier: 3.5.32 version: 3.5.32(typescript@5.9.3) y-prosemirror: specifier: 'catalog:' - version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) y-websocket: specifier: 'catalog:' - version: 3.0.0(yjs@13.6.30) + version: 3.0.0(yjs@13.6.31) yjs: specifier: 'catalog:' - version: 13.6.30 + version: 13.6.31 devDependencies: '@hocuspocus/provider': specifier: 'catalog:' - version: 2.15.3(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + version: 2.15.3(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@hocuspocus/server': specifier: 'catalog:' - version: 2.15.3(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + version: 2.15.3(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@superdoc-dev/superdoc-yjs-collaboration': specifier: workspace:* version: link:../collaboration-yjs @@ -3110,6 +3320,15 @@ importers: '@superdoc/super-editor': specifier: workspace:* version: link:../super-editor + '@tiptap/core': + specifier: ^3.24.0 + version: 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': + specifier: ^3.24.0 + version: 3.24.0 + '@tiptap/starter-kit': + specifier: ^3.24.0 + version: 3.24.0 '@types/uuid': specifier: 'catalog:' version: 9.0.8 @@ -5531,6 +5750,9 @@ packages: '@fastify/cors@10.1.0': resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -6516,6 +6738,7 @@ packages: '@microsoft/teamsapp-cli@3.0.2': resolution: {integrity: sha512-AowuJwrrUxeF9Bq/frxuy9YZjK/ECk3pi0UBXl3CQLZ4XNWfgWatiFi/UWpyHDLccFs+0Za3nNYATFvgsxEFwQ==} engines: {node: '>=12'} + deprecated: This package is deprecated and supported Node.js version is 18-22. Please use @microsoft/m365agentstoolkit-cli instead. hasBin: true '@microsoft/teamsfx-api@0.23.1': @@ -10535,6 +10758,132 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.24.0': + resolution: {integrity: sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow==} + peerDependencies: + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-blockquote@3.24.0': + resolution: {integrity: sha512-DgwEEJ1GbDQcT054ynxoaZGmB9apGeUklPrinq9o6xdLHpdg+bO9HCQzggdB8n21VLLglb8jfAEWsVNwh3eASQ==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-bold@3.24.0': + resolution: {integrity: sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-bullet-list@3.24.0': + resolution: {integrity: sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA==} + peerDependencies: + '@tiptap/extension-list': 3.24.0 + + '@tiptap/extension-code-block@3.24.0': + resolution: {integrity: sha512-NZglw4oHoH6oJ5+HvxxQCYk+wODJmsxzUpRQdsOmje08sekQH+Zt9i4UKimBhg4urpd5r+dKXTslab9a5eQ86w==} + peerDependencies: + '@tiptap/core': 3.24.0 + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-code@3.24.0': + resolution: {integrity: sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-document@3.24.0': + resolution: {integrity: sha512-yxgM3+yXy2XZzEwH43y2Kp8D1BkblxEWLXqo0YCoAKtxyKCcEaT8kdlf70kS7D0+VSzYU4D0iN7VdQIYHcL2mA==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-dropcursor@3.24.0': + resolution: {integrity: sha512-Dbv1c5LnvG3PT+yEbCNroyOeeUkHq9wcir2pbC7wri7g7d2sCi0+HvKH0MAxLwY3j5NJJSiSyG2ypMaXOAs4sg==} + peerDependencies: + '@tiptap/extensions': 3.24.0 + + '@tiptap/extension-gapcursor@3.24.0': + resolution: {integrity: sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w==} + peerDependencies: + '@tiptap/extensions': 3.24.0 + + '@tiptap/extension-hard-break@3.24.0': + resolution: {integrity: sha512-T/ZEBiHQPMyTqDvXG0tiqBToNeuSemIPmNtdoGSgBN/degVl7VJZqQIrLIvOUHfjf3QkRs7TE/mcqTJsIboO/g==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-heading@3.24.0': + resolution: {integrity: sha512-GCSgapIzQPqEGNcVGE0/Pcjg5wITMLYJlrS3GGVw7BPmECJwgexcoOsEwkxtzJnXT/HpFXbvOFW43sM0KeHSjg==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-horizontal-rule@3.24.0': + resolution: {integrity: sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw==} + peerDependencies: + '@tiptap/core': 3.24.0 + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-italic@3.24.0': + resolution: {integrity: sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-link@3.24.0': + resolution: {integrity: sha512-MwMoNGG2mL5XGFV1tEGunBRglwsIbW+ZOB2QnKiv+Mcbi2JCWMrorndJZBqpVPR5nM+Bef2KnpchEJmYlQLvKQ==} + peerDependencies: + '@tiptap/core': 3.24.0 + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-list-item@3.24.0': + resolution: {integrity: sha512-zl/U3viJiV9OzkKM37AHIUN1af1TSLrcbHUUoNLkfJ33Nq+NlpaXpCVK0rKRqiLFJf7zk/a5KWG5CrOy9TxjKA==} + peerDependencies: + '@tiptap/extension-list': 3.24.0 + + '@tiptap/extension-list-keymap@3.24.0': + resolution: {integrity: sha512-69fKcrngYGEKWNn4R5oLwl0YuV3FY4kufEValVcjnihUmqJTE1vx+fwctYoTsOGnIuNGpUIQ7f9YDD/0w34qBw==} + peerDependencies: + '@tiptap/extension-list': 3.24.0 + + '@tiptap/extension-list@3.24.0': + resolution: {integrity: sha512-GcxDVMMmDGj7OFTBrV7JpVgr5wxlr2vmjwH7U8QxZX7OJI5vrsMYl/U6KRTvUpG8wP+Zmo5jRlLM+BbL+a/W3g==} + peerDependencies: + '@tiptap/core': 3.24.0 + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-ordered-list@3.24.0': + resolution: {integrity: sha512-buRa6bmBDw0TztH+rAcusIye14DiLDS+yGheo6GiNCTD7kKJnksXagBdxvip3jhW5sx7gyAKvoBmvGSg1BbsGA==} + peerDependencies: + '@tiptap/extension-list': 3.24.0 + + '@tiptap/extension-paragraph@3.24.0': + resolution: {integrity: sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-strike@3.24.0': + resolution: {integrity: sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-text@3.24.0': + resolution: {integrity: sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extension-underline@3.24.0': + resolution: {integrity: sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA==} + peerDependencies: + '@tiptap/core': 3.24.0 + + '@tiptap/extensions@3.24.0': + resolution: {integrity: sha512-z6gRYzy2ucJp07OQ0F2W07NxyhMTxPYH1ia2eGiQkWax1i56oExpjMsDHP8THWlg8Tb7NnbfKpkfh881EsmofA==} + peerDependencies: + '@tiptap/core': 3.24.0 + '@tiptap/pm': 3.24.0 + + '@tiptap/pm@3.24.0': + resolution: {integrity: sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg==} + + '@tiptap/starter-kit@3.24.0': + resolution: {integrity: sha512-Ef4PCP96vcY2GonXN9J0M8iC6zvxPTmQlL/QZiCwuYqqnH/hNpYIjNSQdTndiDpxRKofa32Sr2HWktgEnL32Bg==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -10677,6 +11026,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/extend@3.0.4': resolution: {integrity: sha512-ArMouDUTJEz1SQRpFsT2rIw7DeqICFv5aaVzLSIYMYQSLcwcGOfT3VyglQs/p7K3F7fT4zxr0NWxYZIdifD6dA==} @@ -10707,6 +11059,9 @@ packages: '@types/http-proxy@1.17.17': resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -10842,6 +11197,9 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} @@ -11264,6 +11622,13 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: 3.5.32 + '@vitejs/plugin-vue@4.6.2': + resolution: {integrity: sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: 3.5.32 + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -13012,6 +13377,11 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -13415,9 +13785,16 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -14528,6 +14905,9 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -15349,6 +15729,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -16748,6 +17131,9 @@ packages: linkify-it@3.0.3: resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + listhen@1.9.0: resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} hasBin: true @@ -17034,6 +17420,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -17628,6 +18019,9 @@ packages: mocked-exports@0.1.1: resolution: {integrity: sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + mongodb-connection-string-url@7.0.1: resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} engines: {node: '>=20.19.0'} @@ -19010,6 +19404,10 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -19509,6 +19907,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -20297,7 +20698,6 @@ packages: rolldown-vite@7.3.1: resolution: {integrity: sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==} engines: {node: ^20.19.0 || >=22.12.0} - deprecated: Use this package to migrate from Vite 7 to Vite 8. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -20906,6 +21306,9 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + spawn-error-forwarder@1.0.0: resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} @@ -22221,6 +22624,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@13.0.0: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true @@ -26109,6 +26516,11 @@ snapshots: fastify-plugin: 5.1.0 mnemonist: 0.40.0 + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {} @@ -26278,6 +26690,18 @@ snapshots: - bufferutil - utf-8-validate + '@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@hocuspocus/common': 2.15.3 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + ws: 8.20.0 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@hocuspocus/provider@4.1.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': dependencies: '@hocuspocus/common': 4.1.0 @@ -26300,7 +26724,7 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/server@2.15.3(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)': + '@hocuspocus/server@2.15.3(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': dependencies: '@hocuspocus/common': 2.15.3 async-lock: 1.4.1 @@ -26308,8 +26732,8 @@ snapshots: lib0: 0.2.117 uuid: 11.1.0 ws: 8.20.0 - y-protocols: 1.0.7(yjs@13.6.30) - yjs: 13.6.30 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -26563,16 +26987,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26598,13 +27012,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/confirm@5.1.21(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26619,19 +27026,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/core@10.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/core@10.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26698,14 +27092,6 @@ snapshots: chalk: 4.1.2 external-editor: 3.1.0 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26729,14 +27115,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26745,13 +27123,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -26776,13 +27147,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/input@4.3.1(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/input@4.3.1(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26797,13 +27161,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/number@3.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26818,14 +27175,6 @@ snapshots: ansi-escapes: 4.3.2 chalk: 4.1.2 - '@inquirer/password@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26846,21 +27195,6 @@ snapshots: '@inquirer/rawlist': 1.2.16 '@inquirer/select': 1.3.3 - '@inquirer/prompts@7.10.1(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -26876,20 +27210,20 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': + '@inquirer/prompts@7.9.0(@types/node@25.6.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) + '@inquirer/confirm': 5.1.21(@types/node@25.6.0) + '@inquirer/editor': 4.2.23(@types/node@25.6.0) + '@inquirer/expand': 4.0.23(@types/node@25.6.0) + '@inquirer/input': 4.3.1(@types/node@25.6.0) + '@inquirer/number': 3.0.23(@types/node@25.6.0) + '@inquirer/password': 4.0.23(@types/node@25.6.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) + '@inquirer/search': 3.2.2(@types/node@25.6.0) + '@inquirer/select': 4.4.2(@types/node@25.6.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.6.0 '@inquirer/rawlist@1.2.16': dependencies: @@ -26897,14 +27231,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26913,15 +27239,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/search@3.2.2(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26939,16 +27256,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/select@4.4.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/select@4.4.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26972,10 +27279,6 @@ snapshots: dependencies: mute-stream: 1.0.0 - '@inquirer/type@3.0.10(@types/node@22.19.2)': - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/type@3.0.10(@types/node@25.6.0)': optionalDependencies: '@types/node': 25.6.0 @@ -27540,11 +27843,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@25.6.0) '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27555,7 +27858,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@25.6.0) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 open: 8.4.2 @@ -27587,7 +27890,7 @@ snapshots: - utf-8-validate - yaml - '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@asyncapi/parser': 3.4.0 '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27627,7 +27930,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.1 remark-stringify: 11.0.0 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) unified: 11.0.5 unist-builder: 4.0.0 unist-util-map: 4.0.0 @@ -27711,13 +28014,13 @@ snapshots: - typescript - yaml - '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/models': 0.0.296 '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 @@ -27862,9 +28165,9 @@ snapshots: - utf-8-validate - yaml - '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 fs-extra: 11.1.1 hast-util-to-mdast: 10.1.0 @@ -32609,6 +32912,147 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tiptap/core@3.24.0(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-blockquote@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-bold@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-bullet-list@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-code-block@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-code@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-document@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-dropcursor@3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-gapcursor@3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-hard-break@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-heading@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-horizontal-rule@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-italic@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-link@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-list-keymap@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + + '@tiptap/extension-ordered-list@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + + '@tiptap/extension-paragraph@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-strike@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-text@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extension-underline@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + + '@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + + '@tiptap/pm@3.24.0': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.7 + + '@tiptap/starter-kit@3.24.0': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/extension-blockquote': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-bold': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-bullet-list': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-code': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-code-block': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-document': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-dropcursor': 3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-gapcursor': 3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-hard-break': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-heading': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-horizontal-rule': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-italic': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-link': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list-item': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-list-keymap': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-ordered-list': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + '@tiptap/extension-paragraph': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-strike': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-text': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-underline': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3(supports-color@5.5.0) @@ -32785,6 +33229,12 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 1.15.10 + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/extend@3.0.4': {} '@types/fs-extra@11.0.4': @@ -32819,6 +33269,12 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 22.19.15 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -32955,7 +33411,7 @@ snapshots: '@types/serve-index@1.9.4': dependencies: - '@types/express': 4.17.25 + '@types/express': 5.0.6 '@types/serve-static@1.15.10': dependencies: @@ -32963,6 +33419,11 @@ snapshots: '@types/node': 22.19.15 '@types/send': 0.17.6 + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.15 + '@types/sockjs@0.3.36': dependencies: '@types/node': 22.19.15 @@ -33575,6 +34036,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@4.6.2(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + dependencies: + vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + '@vitejs/plugin-vue@5.2.4(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': dependencies: vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -35659,6 +36125,18 @@ snapshots: readable-stream: 3.6.2(patch_hash=e4aadcbd3e7fffdf34e27d9a810232cda21beee31c3b1f1fda75b4877dfe5e61) typedarray: 0.0.6 + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.18.1 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + concurrently@9.2.1: dependencies: chalk: 4.1.2 @@ -36098,8 +36576,14 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.2 + date-fns@3.6.0: {} + dateformat@4.6.3: {} + dayjs@1.11.13: {} db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8)): @@ -37182,8 +37666,8 @@ snapshots: '@babel/parser': 7.29.2 eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -37629,6 +38113,8 @@ snapshots: fast-content-type-parse@3.0.0: {} + fast-copy@4.0.3: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -38811,6 +39297,8 @@ snapshots: he@1.2.0: {} + help-me@5.0.0: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -39292,12 +39780,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@25.6.0): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.10.1(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/prompts': 7.10.1(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + '@types/node': 25.6.0 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -40259,6 +40747,8 @@ snapshots: dependencies: uc.micro: 1.0.6 + linkifyjs@4.3.3: {} + listhen@1.9.0: dependencies: '@parcel/watcher': 2.5.6 @@ -40594,6 +41084,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -41673,9 +42165,9 @@ snapshots: dependencies: minipass: 7.1.3 - mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: - '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -41725,6 +42217,8 @@ snapshots: mocked-exports@0.1.1: {} + monaco-editor@0.52.2: {} + mongodb-connection-string-url@7.0.1: dependencies: '@types/whatwg-url': 13.0.0 @@ -43470,6 +43964,22 @@ snapshots: dependencies: split2: 4.2.0 + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + pino-std-serializers@7.1.0: {} pino@10.3.1: @@ -43636,13 +44146,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.3 optionalDependencies: postcss: 8.5.10 - ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: @@ -44153,6 +44663,10 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.11.0 + prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -46337,6 +46851,8 @@ snapshots: memory-pager: 1.5.0 optional: true + spawn-command@0.0.2: {} + spawn-error-forwarder@1.0.0: {} spdx-correct@3.2.0: @@ -46813,7 +47329,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -46832,7 +47348,7 @@ snapshots: postcss: 8.5.10 postcss-import: 15.1.0(postcss@8.5.10) postcss-js: 4.1.0(postcss@8.5.10) - postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.10) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -47198,14 +47714,14 @@ snapshots: '@swc/core': 1.15.21 optional: true - ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.2 + '@types/node': 25.6.0 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -47920,6 +48436,8 @@ snapshots: uuid@11.1.0: {} + uuid@11.1.1: {} + uuid@13.0.0: optional: true @@ -49281,6 +49799,12 @@ snapshots: y-protocols: 1.0.7(yjs@13.6.30) yjs: 13.6.30 + y-websocket@3.0.0(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + y18n@5.0.8: {} yallist@3.1.1: {} @@ -49405,9 +49929,9 @@ snapshots: dependencies: zod: 4.3.6 - zod-validation-error@4.0.2(zod@4.3.6): + zod-validation-error@4.0.2(zod@3.25.76): dependencies: - zod: 4.3.6 + zod: 3.25.76 zod@3.21.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c690d51fb5..e045b0ed41 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -121,7 +121,7 @@ catalog: typescript-eslint: ^8.49.0 unified: 11.0.5 utif2: ^4.1.0 - uuid: ^9.0.1 + uuid: ^11.1.1 verdaccio: ^6.1.6 vite: ^7.2.7 vite-plugin-dts: ~4.5.4 From f1741103414f56b6288dba8c15114aa66686445a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 12:27:08 -0300 Subject: [PATCH 05/34] feat(superdoc): zoom modes with viewport metrics and fit-width (SD-3294) Reshapes the layout-change contract from the base branch before it ships, and models zoom as mode + value, the shape document viewers use. - Rename layout-change to viewport-change: the public surface already exports LayoutUpdatePayload for document layout passes, and what this event reports is viewport fit. Payload { availableWidth, documentWidth, fitZoom } carries pure measurements (sidebar-aware, policy-free). - Resolve the base document width from page styles, re-resolved per evaluation, instead of a one-time DOM capture: the measured element scales with zoom, so any zoom applied before capture corrupted fitZoom permanently. Never emit before an editor exists. - zoom config: initial (seeded before first paint, no flash), mode (manual | fit-width), fitWidth bounds and padding. Padding and clamping shape the applied fit only, never the metrics. - setZoom() switches the mode to manual, so picking a percentage stops the auto-fit instead of fighting it; setZoomMode('fit-width') re-enters fitting and applies immediately. The fit application writes zoom state directly and emits zoomChange with the mode. - New reads: getZoomState(), getViewportMetrics() (latest metrics readable any time, so late subscribers cannot miss the first measurement). New constructor callbacks onZoomChange / onViewportChange register before the first emit. --- packages/superdoc/src/SuperDoc.test.js | 275 +++++++++++++++--- packages/superdoc/src/SuperDoc.vue | 49 +--- .../src/composables/use-viewport-fit.js | 209 +++++++++++++ .../src/composables/use-viewport-fit.test.js | 91 ++++++ packages/superdoc/src/core/SuperDoc.test.js | 115 +++++++- packages/superdoc/src/core/SuperDoc.ts | 81 +++++- packages/superdoc/src/core/types/index.ts | 131 ++++++++- .../src/dev/components/SuperdocDev.vue | 14 +- packages/superdoc/src/public/index.ts | 7 + .../superdoc/src/stores/superdoc-store.js | 30 ++ .../src/stores/superdoc-store.test.js | 81 ++++++ .../superdoc-root-classification.json | 83 +++++- .../snapshots/superdoc-root-exports.json | 29 +- .../snapshots/superdoc-root-exports.md | 42 ++- .../src/all-public-types.ts | 14 + .../src/config-callback-payloads.ts | 12 + .../src/read-and-navigation-apis.ts | 26 +- .../consumer-typecheck/src/superdoc-events.ts | 14 +- .../src/ui-state-control-apis.ts | 10 + 19 files changed, 1191 insertions(+), 122 deletions(-) create mode 100644 packages/superdoc/src/composables/use-viewport-fit.js create mode 100644 packages/superdoc/src/composables/use-viewport-fit.test.js diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index ce3db6285d..531820e93d 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -183,6 +183,8 @@ const buildSuperdocStore = () => { selectionPosition: ref(null), activeSelection: ref(null), activeZoom: ref(100), + zoomMode: ref('manual'), + viewportMetrics: ref(null), modules: reactive({ comments: { readOnly: false }, ai: {}, 'hrbr-fields': [] }), handlePageReady: vi.fn(), user: { name: 'Ada', email: 'ada@example.com' }, @@ -2626,52 +2628,249 @@ describe('SuperDoc.vue', () => { expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88'); }); - it('does not emit layout-change before isReady', async () => { - const superdocStub = createSuperdocStub(); - superdocStoreStub.isReady.value = false; + describe('viewport-change + fit-to-container', () => { + // Letter page: 8.5in * 96 = 816px base width through the page-styles path. + const stubPageStylesEditor = (superdocStub) => { + superdocStub.activeEditor = { + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + }; - const wrapper = await mountComponent(superdocStub); - await nextTick(); + const setContainerWidth = (wrapper, width) => { + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: width }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: width }); + }; - // Set up container width measurement - const rootEl = wrapper.find('.superdoc').element; - const parentEl = rootEl.parentElement; - Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); - if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + const viewportChangeCalls = (superdocStub) => + superdocStub.emit.mock.calls.filter(([name]) => name === 'viewport-change'); - // Trigger recalculation while not ready - wrapper.vm.recalculateCompactCommentsMode(); - await nextTick(); + it('does not emit viewport-change before isReady', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); - // Should not emit before isReady - const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); - expect(layoutChangeCalls.length).toBe(0); - }); + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.isReady.value = false; + await nextTick(); - it('includes documentWidth and fitZoom in layout-change payload when ready', async () => { - const superdocStub = createSuperdocStub(); - superdocStoreStub.isReady.value = true; + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); - const wrapper = await mountComponent(superdocStub); - await nextTick(); + expect(viewportChangeCalls(superdocStub).length).toBe(0); + }); - // Set up container width measurement - const rootEl = wrapper.find('.superdoc').element; - const parentEl = rootEl.parentElement; - Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); - if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + it('emits viewport-change with page-styles-derived widths when ready', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); - // Trigger recalculation - wrapper.vm.recalculateCompactCommentsMode(); - await nextTick(); + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); - const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); - if (layoutChangeCalls.length > 0) { - const payload = layoutChangeCalls[layoutChangeCalls.length - 1][1]; - expect(payload).toHaveProperty('containerWidth'); - expect(payload).toHaveProperty('documentWidth'); - expect(payload).toHaveProperty('fitZoom'); - expect(typeof payload.fitZoom).toBe('number'); - } + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + availableWidth: 1200, + documentWidth: 816, + fitZoom: 147, + }); + }); + + it('keeps documentWidth zoom-independent (page styles win over scaled DOM)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + // A zoom applied before the first measurement must not corrupt the base. + superdocStoreStub.activeZoom.value = 50; + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBe(816); + expect(calls[0][1].fitZoom).toBe(147); + }); + + it('dedupes width changes that round to the same fit', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // 1199 / 816 rounds to the same fitZoom (147) as 1200 / 816. + setContainerWidth(wrapper, 1199); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(1); + + // A materially different width emits again. + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(2); + expect(calls[1][1].fitZoom).toBe(74); + }); + + it('skips emission entirely while no document width is measurable', async () => { + const superdocStub = createSuperdocStub(); + // No activeEditor and no laid-out DOM: base width unresolvable. + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(0); + }); + + const zoomChangeCalls = (superdocStub) => superdocStub.emit.mock.calls.filter(([name]) => name === 'zoomChange'); + + it('stores the latest metrics for getViewportMetrics()', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(superdocStoreStub.viewportMetrics.value).toEqual({ + availableWidth: 1200, + documentWidth: 816, + fitZoom: 147, + }); + }); + + it('fit-width mode applies the fit with default clamping (max 100)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width' }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // fitZoom = 600 / 816 = 74; within [10, 100] so applied as-is. The + // fit writes the store directly (setZoom would flip mode to manual). + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]); + expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(74); + }); + + it('clamps the applied fit but emits the raw fitZoom', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { min: 80 } }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + setContainerWidth(wrapper, 408); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Raw fit is 50 (408 / 816); applied value clamps to min 80. + expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(50); + expect(superdocStoreStub.activeZoom.value).toBe(80); + }); + + it('padding shapes the applied fit only, never the metrics', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { padding: 96 } }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + superdocStoreStub.activeZoom.value = 50; + setContainerWidth(wrapper, 912); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Metrics are policy-free: availableWidth stays 912 and fitZoom is + // the raw ratio. The applied fit reserves the padding: (912 - 96) / + // 816 = 100. + expect(viewportChangeCalls(superdocStub)[0][1]).toEqual({ + availableWidth: 912, + documentWidth: 816, + fitZoom: 112, + }); + expect(superdocStoreStub.activeZoom.value).toBe(100); + }); + + it('does not re-apply zoom when the target equals the current zoom', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { mode: 'fit-width' }; + + const wrapper = await mountComponent(superdocStub); + superdocStoreStub.zoomMode.value = 'fit-width'; + superdocStoreStub.activeZoom.value = 74; + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + // Same-value guard: target 74 equals current zoom, so no write and + // no zoomChange, while the viewport-change event still emits. + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub).length).toBe(0); + expect(viewportChangeCalls(superdocStub).length).toBe(1); + }); + + it('manual mode never applies the fit (metrics still emit)', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = { fitWidth: { min: 25 } }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(viewportChangeCalls(superdocStub).length).toBe(1); + expect(superdocStoreStub.activeZoom.value).toBe(100); + expect(zoomChangeCalls(superdocStub).length).toBe(0); + }); + + it('switching zoomMode to fit-width applies the fit immediately', async () => { + const superdocStub = createSuperdocStub(); + stubPageStylesEditor(superdocStub); + superdocStub.config.zoom = {}; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 600); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(superdocStoreStub.activeZoom.value).toBe(100); + + superdocStoreStub.zoomMode.value = 'fit-width'; + await nextTick(); + + expect(superdocStoreStub.activeZoom.value).toBe(74); + expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]); + }); }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 93111929f2..5e1b0cab6b 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -49,6 +49,7 @@ import { useAi } from './composables/use-ai'; import { useHighContrastMode } from './composables/use-high-contrast-mode'; import { useCommentSmallScreen } from './composables/use-comment-small-screen.js'; import { useCompactCommentPopover } from './composables/use-compact-comment-popover.js'; +import { useViewportFit } from './composables/use-viewport-fit.js'; import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js'; import { useUiFontFamily } from './composables/useUiFontFamily.js'; import { usePasswordPrompt } from './composables/use-password-prompt.js'; @@ -57,7 +58,6 @@ import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracke import SurfaceHost from './components/surfaces/SurfaceHost.vue'; import { DEFAULT_COMMENTS_DISPLAY_MODE, - DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX, RIGHT_CLICK_COMMENT_SUPPRESS_MS, VALID_COMMENTS_DISPLAY_MODES, } from './helpers/comment-small-screen.js'; @@ -78,6 +78,8 @@ const { selectionPosition, activeSelection, activeZoom, + zoomMode, + viewportMetrics, } = storeToRefs(superdocStore); const { handlePageReady, modules, user, getDocument } = superdocStore; @@ -1322,40 +1324,17 @@ watch(showCommentsSidebar, (value) => { proxy.$superdoc.broadcastSidebarToggle(value); }); -// Emit layout-change event when container width changes. -// Capture base document width after layout resolves to avoid stale measurements. -let baseDocumentWidth = null; -let lastEmittedFitZoom = null; - -const emitLayoutChange = () => { - const containerWidth = superdocContainerWidth.value; - if (!proxy.$superdoc || containerWidth <= 0) return; - - // Wait for document layout to resolve before capturing base width - if (baseDocumentWidth === null) { - if (!isReady.value) return; - const docEl = superdocRoot.value?.querySelector('.superdoc__document'); - const measured = docEl?.clientWidth || docEl?.getBoundingClientRect?.().width || 0; - baseDocumentWidth = measured > 0 ? measured : DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX; - } - - const rawFitZoom = (containerWidth / baseDocumentWidth) * 100; - const fitZoom = Math.round(rawFitZoom); - - // Only emit if fitZoom changed - if (fitZoom === lastEmittedFitZoom) return; - lastEmittedFitZoom = fitZoom; - - proxy.$superdoc.emit('layout-change', { - containerWidth, - documentWidth: baseDocumentWidth, - fitZoom, - }); -}; - -watch(superdocContainerWidth, emitLayoutChange); -watch(isReady, (ready) => { - if (ready) emitLayoutChange(); +// Viewport fit tracking: maintains viewport metrics, emits `viewport-change`, +// and applies the fit-width zoom policy. See composables/use-viewport-fit.js. +useViewportFit({ + getSuperdoc: () => proxy.$superdoc, + superdocContainerWidth, + isReady, + activeZoom, + zoomMode, + viewportMetrics, + showCommentsSidebar, + superdocRoot, }); /** diff --git a/packages/superdoc/src/composables/use-viewport-fit.js b/packages/superdoc/src/composables/use-viewport-fit.js new file mode 100644 index 0000000000..05d5ec948c --- /dev/null +++ b/packages/superdoc/src/composables/use-viewport-fit.js @@ -0,0 +1,209 @@ +import { onBeforeUnmount, nextTick, watch } from 'vue'; + +const CSS_PX_PER_INCH = 96; +const SIDEBAR_SELECTOR = '.superdoc__right-sidebar'; + +export const FIT_WIDTH_DEFAULTS = Object.freeze({ + min: 10, + max: 100, + padding: 0, +}); + +// Normalize `config.zoom.fitWidth` into a complete options object. The mode +// (`config.zoom.mode` / `setZoomMode`) decides whether the policy applies; +// these are only its bounds. Invalid field values fall back to defaults; +// min/max are reordered if swapped. +export const resolveFitWidthOptions = (rawFitConfig) => { + const raw = rawFitConfig && typeof rawFitConfig === 'object' ? rawFitConfig : {}; + const positiveOr = (value, fallback) => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback; + const min = positiveOr(raw.min, FIT_WIDTH_DEFAULTS.min); + const max = positiveOr(raw.max, FIT_WIDTH_DEFAULTS.max); + const padding = + typeof raw.padding === 'number' && Number.isFinite(raw.padding) && raw.padding >= 0 + ? raw.padding + : FIT_WIDTH_DEFAULTS.padding; + + return { + min: Math.min(min, max), + max: Math.max(min, max), + padding, + }; +}; + +// Unclamped zoom percentage that fits `documentWidth` into `availableWidth`. +export const computeFitZoom = (availableWidth, documentWidth) => { + if (!(availableWidth > 0) || !(documentWidth > 0)) return null; + return Math.round((availableWidth / documentWidth) * 100); +}; + +// Applied zoom for the fit-width policy: padding reserved, then clamped. +export const computeAppliedFitZoom = (availableWidth, documentWidth, options) => { + const padded = computeFitZoom(availableWidth - options.padding, documentWidth); + if (padded === null) return null; + return Math.round(Math.min(options.max, Math.max(options.min, padded))); +}; + +/** + * Viewport fit tracking. Maintains pure viewport metrics (available width, + * document base width, fit zoom), stores them for `getViewportMetrics()`, + * emits `viewport-change` when they change, and applies the `fit-width` + * policy while `zoomMode` is `'fit-width'`. + * + * Metrics are policy-free measurements: `availableWidth` is the container + * width minus the comments sidebar when visible; `fitZoom` is the raw + * available/document ratio. The fit policy (and only the policy) accounts + * for `config.zoom.fitWidth` padding and clamping. + * + * The base page width is re-resolved on every evaluation (never latched) + * and comes from the page styles first, which are zoom-independent: a zoom + * applied before the first measurement (`zoom.initial`, `setZoom()` in + * `onReady`) cannot corrupt the ratio. DOM measurement, normalized by the + * active zoom, is the fallback when page styles are unavailable. + * + * The fit application writes the zoom state directly instead of calling + * `setZoom()`, which by contract switches the mode to `manual`. + * + * Must be called inside a component `setup()` (registers watchers and an + * unmount hook). + */ +export function useViewportFit({ + getSuperdoc, + superdocContainerWidth, + isReady, + activeZoom, + zoomMode, + viewportMetrics, + showCommentsSidebar, + superdocRoot, +}) { + const resolveBaseDocumentWidth = () => { + const superdoc = getSuperdoc(); + // Without an editor there is no document to measure: the document + // element before editor mount is shell scaffolding whose width is + // container-derived, which would produce a garbage base. + if (!superdoc?.activeEditor) return null; + + let pageStyles = null; + try { + pageStyles = superdoc.activeEditor.getPageStyles?.() ?? null; + } catch { + pageStyles = null; + } + const pageWidthInches = pageStyles?.pageSize?.width; + if (typeof pageWidthInches === 'number' && Number.isFinite(pageWidthInches) && pageWidthInches > 0) { + return pageWidthInches * CSS_PX_PER_INCH; + } + + const docEl = superdocRoot.value?.querySelector?.('.superdoc__document'); + const measured = Number(docEl?.clientWidth) || Number(docEl?.getBoundingClientRect?.().width) || 0; + if (measured > 0) { + // The measured element scales with zoom; divide it back out so the + // returned width is the document's natural size. + const zoomFactor = (activeZoom.value ?? 100) / 100; + return zoomFactor > 0 ? measured / zoomFactor : measured; + } + + return null; + }; + + // Width the comments sidebar takes from the container when visible. + const resolveSidebarWidth = () => { + if (!showCommentsSidebar?.value) return 0; + const sidebarEl = superdocRoot.value?.querySelector?.(SIDEBAR_SELECTOR); + const measured = Number(sidebarEl?.offsetWidth) || Number(sidebarEl?.getBoundingClientRect?.().width) || 0; + return measured > 0 ? measured : 0; + }; + + const applyFitWidth = (superdoc, metrics) => { + const options = resolveFitWidthOptions(superdoc.config?.zoom?.fitWidth); + const target = computeAppliedFitZoom(metrics.availableWidth, metrics.documentWidth, options); + if (target === null) return; + // Same-value guard: applying the fit re-triggers viewport evaluation + // through the render pipeline; skipping no-op zooms is what terminates + // that cycle (the base width is zoom-independent, so the recomputed + // target is stable). + if (target === activeZoom.value) return; + // Write the zoom state directly: setZoom() would flip the mode to + // manual. The activeZoom watcher in SuperDoc.vue propagates the value + // to all presentation surfaces exactly as setZoom() does. + activeZoom.value = target; + superdoc.emit('zoomChange', { zoom: target, mode: 'fit-width' }); + }; + + const evaluateViewport = () => { + const superdoc = getSuperdoc(); + if (!superdoc) return; + + const containerWidth = superdocContainerWidth.value; + if (!(containerWidth > 0)) return; + if (!isReady.value) return; + + const documentWidth = resolveBaseDocumentWidth(); + // No measurable document yet (editors still mounting): skip instead of + // storing a guessed width; the editorCreate/pagination hooks re-run this. + if (documentWidth === null) return; + + const availableWidth = containerWidth - resolveSidebarWidth(); + const fitZoom = computeFitZoom(availableWidth, documentWidth); + if (fitZoom === null) return; + + const metrics = { availableWidth, documentWidth, fitZoom }; + + // Store and emit when the measurements change, including base-width + // changes (page size or orientation) at a constant available width. + const previous = viewportMetrics.value; + const changed = + !previous || + previous.fitZoom !== fitZoom || + Math.round(previous.documentWidth) !== Math.round(documentWidth) || + Math.round(previous.availableWidth) !== Math.round(availableWidth); + if (changed) { + viewportMetrics.value = metrics; + superdoc.emit('viewport-change', metrics); + } + + // The fit policy re-applies on every evaluation while in fit-width mode. + // That is safe: leaving the mode requires setZoom()/setZoomMode(), and + // the same-value guard makes repeat applications no-ops. + if (zoomMode.value === 'fit-width') { + applyFitWidth(superdoc, metrics); + } + }; + + watch(superdocContainerWidth, evaluateViewport); + watch(isReady, (ready) => { + if (ready) evaluateViewport(); + }); + // Entering fit-width applies the fit immediately; the sidebar changes the + // available width without resizing the observed container, so re-measure + // after it mounts/unmounts. + watch(zoomMode, (mode) => { + if (mode === 'fit-width') evaluateViewport(); + }); + if (showCommentsSidebar) { + watch(showCommentsSidebar, () => { + nextTick(() => evaluateViewport()); + }); + } + + // Editors mount after store readiness, and page geometry can change + // without a container resize (orientation, margins, document swap). + // Re-evaluate on the editor lifecycle signals that change the base width. + const handleEditorCreate = () => { + nextTick(() => evaluateViewport()); + }; + const handlePaginationUpdate = () => { + evaluateViewport(); + }; + + const superdocAtSetup = getSuperdoc(); + superdocAtSetup?.on?.('editorCreate', handleEditorCreate); + superdocAtSetup?.on?.('pagination-update', handlePaginationUpdate); + onBeforeUnmount(() => { + superdocAtSetup?.off?.('editorCreate', handleEditorCreate); + superdocAtSetup?.off?.('pagination-update', handlePaginationUpdate); + }); + + return { evaluateViewport }; +} diff --git a/packages/superdoc/src/composables/use-viewport-fit.test.js b/packages/superdoc/src/composables/use-viewport-fit.test.js new file mode 100644 index 0000000000..601cb4cb97 --- /dev/null +++ b/packages/superdoc/src/composables/use-viewport-fit.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { + FIT_WIDTH_DEFAULTS, + resolveFitWidthOptions, + computeFitZoom, + computeAppliedFitZoom, +} from './use-viewport-fit.js'; + +// Full wiring (watchers, metric storage, emit dedup, mode-driven fit +// application) is covered through the component in src/SuperDoc.test.js; +// these tests lock the pure helpers. + +describe('resolveFitWidthOptions', () => { + it('returns defaults when options are absent or not an object', () => { + const defaults = { + min: FIT_WIDTH_DEFAULTS.min, + max: FIT_WIDTH_DEFAULTS.max, + padding: FIT_WIDTH_DEFAULTS.padding, + }; + expect(resolveFitWidthOptions(undefined)).toEqual(defaults); + expect(resolveFitWidthOptions(null)).toEqual(defaults); + expect(resolveFitWidthOptions('fit')).toEqual(defaults); + }); + + it('accepts explicit bounds and padding', () => { + expect(resolveFitWidthOptions({ min: 35, max: 150, padding: 24 })).toEqual({ + min: 35, + max: 150, + padding: 24, + }); + }); + + it('reorders swapped min/max', () => { + const options = resolveFitWidthOptions({ min: 150, max: 35 }); + expect(options.min).toBe(35); + expect(options.max).toBe(150); + }); + + it('falls back to defaults for invalid field values', () => { + expect(resolveFitWidthOptions({ min: -5, max: NaN, padding: -1 })).toEqual({ + min: FIT_WIDTH_DEFAULTS.min, + max: FIT_WIDTH_DEFAULTS.max, + padding: FIT_WIDTH_DEFAULTS.padding, + }); + expect(resolveFitWidthOptions({ min: '50', padding: '10' })).toEqual({ + min: FIT_WIDTH_DEFAULTS.min, + max: FIT_WIDTH_DEFAULTS.max, + padding: FIT_WIDTH_DEFAULTS.padding, + }); + }); + + it('accepts zero padding', () => { + expect(resolveFitWidthOptions({ padding: 0 }).padding).toBe(0); + }); +}); + +describe('computeFitZoom', () => { + it('computes the rounded percentage that fits the document', () => { + expect(computeFitZoom(816, 816)).toBe(100); + expect(computeFitZoom(600, 816)).toBe(74); + expect(computeFitZoom(1200, 816)).toBe(147); + }); + + it('returns null for non-positive inputs', () => { + expect(computeFitZoom(0, 816)).toBeNull(); + expect(computeFitZoom(-10, 816)).toBeNull(); + expect(computeFitZoom(600, 0)).toBeNull(); + expect(computeFitZoom(NaN, 816)).toBeNull(); + }); +}); + +describe('computeAppliedFitZoom', () => { + const options = { min: 35, max: 100, padding: 0 }; + + it('passes through values inside the bounds', () => { + expect(computeAppliedFitZoom(600, 816, options)).toBe(74); + }); + + it('clamps below min and above max', () => { + expect(computeAppliedFitZoom(200, 816, options)).toBe(35); + expect(computeAppliedFitZoom(1200, 816, options)).toBe(100); + }); + + it('reserves padding before computing the fit', () => { + expect(computeAppliedFitZoom(912, 816, { ...options, padding: 96 })).toBe(100); + }); + + it('returns null when padding consumes the available width', () => { + expect(computeAppliedFitZoom(90, 816, { ...options, padding: 96 })).toBeNull(); + }); +}); diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 45ad4fd677..4149ef052b 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -133,6 +133,8 @@ const createAppHarness = () => { reset: vi.fn(), setExceptionHandler: vi.fn(), activeZoom: 100, + zoomMode: 'manual', + viewportMetrics: null, }; const commentsStore = { @@ -2156,7 +2158,118 @@ describe('SuperDoc core', () => { instance.setZoom(200); - expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 200 }); + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 200, mode: 'manual' }); + }); + + it('setZoom switches zoom mode to manual', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + superdocStore.zoomMode = 'fit-width'; + instance.setZoom(125); + + expect(superdocStore.zoomMode).toBe('manual'); + expect(instance.getZoomState().mode).toBe('manual'); + }); + + it('setZoomMode switches between manual and fit-width and rejects invalid values', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + instance.setZoomMode('fit-width'); + expect(superdocStore.zoomMode).toBe('fit-width'); + + instance.setZoomMode('manual'); + expect(superdocStore.zoomMode).toBe('manual'); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + instance.setZoomMode('fit-page'); + expect(superdocStore.zoomMode).toBe('manual'); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('getZoomState reports mode, value, fitZoom, and effective bounds', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + zoom: { fitWidth: { min: 35, max: 150 } }, + }); + await flushMicrotasks(); + + expect(instance.getZoomState()).toEqual({ + mode: 'manual', + value: 100, + fitZoom: null, + min: 35, + max: 150, + }); + + superdocStore.zoomMode = 'fit-width'; + superdocStore.activeZoom = 74; + superdocStore.viewportMetrics = { availableWidth: 600, documentWidth: 816, fitZoom: 74 }; + + expect(instance.getZoomState()).toEqual({ + mode: 'fit-width', + value: 74, + fitZoom: 74, + min: 35, + max: 150, + }); + }); + + it('getZoomState falls back to default bounds and reorders swapped min/max', async () => { + createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + zoom: { fitWidth: { min: 150, max: 35 } }, + }); + await flushMicrotasks(); + + const state = instance.getZoomState(); + expect(state.min).toBe(35); + expect(state.max).toBe(150); + + const defaultsInstance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + const defaults = defaultsInstance.getZoomState(); + expect(defaults.min).toBe(10); + expect(defaults.max).toBe(100); + }); + + it('getViewportMetrics returns null before the first measurement, then the stored metrics', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + expect(instance.getViewportMetrics()).toBeNull(); + + const metrics = { availableWidth: 935, documentWidth: 816, fitZoom: 115 }; + superdocStore.viewportMetrics = metrics; + + expect(instance.getViewportMetrics()).toEqual(metrics); }); it('getZoom reflects value set by setZoom', async () => { diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index c93a41e691..9da73f47ff 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -82,10 +82,14 @@ import type { SuperDocEditorPayload, SuperDocExceptionPayload, SuperDocExceptionStorePayload, - SuperDocLayoutChangePayload, SuperDocLockedPayload, SuperDocReadyPayload, SuperDocState, + SuperDocViewportChangePayload, + SuperDocViewportMetrics, + SuperDocZoomMode, + SuperDocZoomPayload, + SuperDocZoomState, SurfaceHandle, SurfaceRequest, UpgradeToCollaborationOptions, @@ -102,9 +106,6 @@ import type { WhiteboardData } from './whiteboard/Whiteboard.js'; interface SuperDocWhiteboardPayload { whiteboard: Whiteboard; } -interface SuperDocZoomPayload { - zoom: number; -} interface SuperDocFormattingMarksPayload { showFormattingMarks: boolean; superdoc: SuperDoc; @@ -152,7 +153,7 @@ interface SuperDocEventMap { 'whiteboard:enabled': [boolean]; 'whiteboard:tool': [string]; exception: [SuperDocExceptionPayload]; - 'layout-change': [SuperDocLayoutChangePayload]; + 'viewport-change': [SuperDocViewportChangePayload]; } // Notes on the event map above: // @@ -854,6 +855,8 @@ export class SuperDoc extends EventEmitter { this.#onConfig('list-definitions-change', this.config.onListDefinitionsChange); this.#onConfig('pagination-update', this.config.onPaginationUpdate); this.#onConfig('fonts-resolved', this.config.onFontsResolved); + this.#onConfig('zoomChange', this.config.onZoomChange); + this.#onConfig('viewport-change', this.config.onViewportChange); } /** @@ -1990,12 +1993,14 @@ export class SuperDoc extends EventEmitter { } /** - * Set the zoom level for all documents. + * Set the zoom level for all documents and switch the zoom mode to + * `manual` (an explicit numeric zoom expresses intent to leave + * `fit-width`; use `setZoomMode('fit-width')` to re-enter fitting). * Updates the centralized activeZoom state, which propagates to all * presentation editors, PDF viewers, and whiteboard layers via the Vue watcher. * @param percent - The zoom level as a percentage (e.g., 100, 150, 200) * @example - * superdoc.setZoom(150); // Set zoom to 150% + * superdoc.setZoom(150); // Set zoom to 150%, mode becomes 'manual' * superdoc.setZoom(50); // Set zoom to 50% */ setZoom(percent: number) { @@ -2008,9 +2013,69 @@ export class SuperDoc extends EventEmitter { // to all PresentationEditor instances via PresentationEditor.setGlobalZoom(). if (this.superdocStore) { this.superdocStore.activeZoom = percent; + this.superdocStore.zoomMode = 'manual'; } - this.emit('zoomChange', { zoom: percent }); + this.emit('zoomChange', { zoom: percent, mode: 'manual' }); + } + + /** + * Switch the zoom mode. `fit-width` continuously re-fits the + * document to the available container width (clamped by + * `config.zoom.fitWidth`); `manual` holds the current value. + * Switching to `fit-width` applies the fit immediately when + * viewport metrics are available. + * @param mode - The zoom mode: `'manual'` or `'fit-width'` + * @example + * superdoc.setZoomMode('fit-width'); // start fitting to the container + * superdoc.setZoomMode('manual'); // hold the current zoom value + */ + setZoomMode(mode: SuperDocZoomMode) { + if (mode !== 'manual' && mode !== 'fit-width') { + console.warn("[SuperDoc] setZoomMode expects 'manual' or 'fit-width'"); + return; + } + if (this.superdocStore) { + this.superdocStore.zoomMode = mode; + } + } + + /** + * Get a snapshot of the current zoom state: mode, value, the latest + * computed fit zoom (null before the first viewport measurement), + * and the effective fit bounds. + * @returns The current zoom state snapshot + * @example + * const { mode, value, fitZoom } = superdoc.getZoomState(); + */ + getZoomState(): SuperDocZoomState { + const fitWidth = this.config.zoom?.fitWidth; + const positiveOr = (value: unknown, fallback: number) => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback; + const min = positiveOr(fitWidth?.min, 10); + const max = positiveOr(fitWidth?.max, 100); + return { + mode: this.superdocStore?.zoomMode ?? 'manual', + value: this.superdocStore?.activeZoom ?? 100, + fitZoom: this.superdocStore?.viewportMetrics?.fitZoom ?? null, + min: Math.min(min, max), + max: Math.max(min, max), + }; + } + + /** + * Get the latest viewport measurements: the width available to the + * document, the document's base page width at 100% zoom, and the + * unclamped fit zoom. Returns `null` until the first measurement + * (editors still mounting). Subscribe to `viewport-change` (or pass + * `Config.onViewportChange`) for updates. + * @returns The latest viewport metrics, or `null` before the first measurement + * @example + * const metrics = superdoc.getViewportMetrics(); + * if (metrics) superdoc.setZoom(Math.min(100, metrics.fitZoom)); + */ + getViewportMetrics(): SuperDocViewportMetrics | null { + return this.superdocStore?.viewportMetrics ?? null; } /** diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 8205321943..69d8273cc9 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1539,18 +1539,117 @@ export type SuperDocExceptionPayload = | SuperDocExceptionEditorPayload; /** - * Payload emitted when container dimensions change. Useful for implementing - * fit-to-container zoom behavior. + * Zoom mode. `manual` holds whatever value was last set; `fit-width` + * continuously recomputes the zoom that fits the page width into the + * available container width. Calling `setZoom()` switches to + * `manual`; `setZoomMode('fit-width')` re-enters fitting. */ -export interface SuperDocLayoutChangePayload { - /** Current container width in pixels. */ - containerWidth: number; - /** Measured document/page width in pixels. */ +export type SuperDocZoomMode = 'manual' | 'fit-width'; + +/** + * Payload emitted with the `zoomChange` event and passed to + * `Config.onZoomChange`. Fires for every zoom source: `setZoom()`, + * the toolbar zoom control, and fit-width adjustments. + */ +export interface SuperDocZoomPayload { + /** The zoom level as a percentage (e.g. 100, 150). */ + zoom: number; + /** The zoom mode that produced this value. */ + mode: SuperDocZoomMode; +} + +/** + * Payload emitted with the `viewport-change` event and passed to + * `Config.onViewportChange`. Fires when the width available to the + * document or the document's base page width changes. These are pure + * measurements: `zoom.fitWidth` policy options (`min`, `max`, + * `padding`) do not affect them. Useful for implementing custom + * fit behavior; for the common case, prefer `zoom.mode: 'fit-width'`, + * which applies a clamped fit automatically. + */ +export interface SuperDocViewportChangePayload { + /** + * Width available to the document in pixels: the measured container + * width minus the comments sidebar when it is visible. + */ + availableWidth: number; + /** Document base page width in pixels at 100% zoom (zoom-independent). */ documentWidth: number; - /** Calculated zoom to fit document in available width (unclamped). User should clamp to their preferred min/max. */ + /** Zoom percentage that fits the document in the available width (unclamped, padding-free). Clamp before applying. */ fitZoom: number; } +/** + * Latest viewport measurements, readable at any time via + * `superdoc.getViewportMetrics()`. Same shape as the + * `viewport-change` payload; `null` until the first measurement + * (editors still mounting). + */ +export type SuperDocViewportMetrics = SuperDocViewportChangePayload; + +/** + * Options for the `fit-width` zoom mode. `min`/`max` clamp the + * applied zoom percentage; `padding` reserves horizontal space + * inside the available width before computing the applied fit. + * These shape the applied policy only, never the reported metrics. + */ +export interface SuperDocFitWidthOptions { + /** Lower bound for the applied zoom percentage (default: 10). */ + min?: number; + /** + * Upper bound for the applied zoom percentage (default: 100, so + * fitting never enlarges the document past its natural size; raise + * it to let wide containers scale the page up). + */ + max?: number; + /** Horizontal padding in pixels reserved inside the available width before computing the fit (default: 0). */ + padding?: number; +} + +/** + * Snapshot of the current zoom state, readable via + * `superdoc.getZoomState()`. + */ +export interface SuperDocZoomState { + /** Current zoom mode. */ + mode: SuperDocZoomMode; + /** Current zoom value as a percentage. */ + value: number; + /** Latest computed fit zoom (unclamped), or `null` before the first viewport measurement. */ + fitZoom: number | null; + /** Effective lower bound the fit policy applies (config or default). */ + min: number; + /** Effective upper bound the fit policy applies (config or default). */ + max: number; +} + +/** + * Options for `Config.zoom`: the initial zoom level, the starting + * mode, and the fit-width policy bounds. Runtime control stays on + * the instance: `setZoom()` (switches to manual), `setZoomMode()`, + * `getZoomState()`, `getViewportMetrics()`, and the `zoomChange` / + * `viewport-change` events. + */ +export interface SuperDocZoomConfig { + /** + * Initial zoom level as a percentage (default: 100). Applied before + * the first paint, so the document renders directly at this zoom + * with no visible jump. In `fit-width` mode this is the paint zoom + * until the first fit computes. Invalid values (non-finite or <= 0) + * are ignored with a console warning. + */ + initial?: number; + /** + * Starting zoom mode (default: `'manual'`). In `'fit-width'` the + * document continuously re-fits to the available container width; + * the fit is applied through the normal zoom pipeline, so + * `zoomChange` fires for every adjustment. + */ + mode?: SuperDocZoomMode; + /** Bounds and padding for the `fit-width` policy. */ + fitWidth?: SuperDocFitWidthOptions; +} + export interface Config { /** The ID of the SuperDoc. */ superdocId?: string; @@ -1685,6 +1784,19 @@ export interface Config { onPaginationUpdate?: (params: { totalPages: number; superdoc: SuperDoc }) => void; /** Callback when the list definitions change. */ onListDefinitionsChange?: (params: ListDefinitionsPayload) => void; + /** + * Callback when the zoom level changes. Fires for every zoom source: + * `setZoom()`, the toolbar zoom control, and fit-to-container + * adjustments. + */ + onZoomChange?: (params: SuperDocZoomPayload) => void; + /** + * Callback when the width available to the document or the + * document's base page width changes. Registered before the first + * emit, so the initial viewport measurement is never missed (unlike + * subscribing inside `onReady`). + */ + onViewportChange?: (params: SuperDocViewportChangePayload) => void; /** The format of the document (docx, pdf, html). */ format?: string; /** The extensions to load for the editor. */ @@ -1758,6 +1870,11 @@ export interface Config { * path in that case. */ useLayoutEngine?: boolean; + /** + * Zoom behavior: the initial zoom level and the optional automatic + * fit-to-container policy. See `SuperDocZoomConfig`. + */ + zoom?: SuperDocZoomConfig; /** * Callback fired after the editor reports `fonts-resolved`. The payload * contains `documentFonts` and `unsupportedFonts` arrays so hosts can fall diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index ebc02be8ea..c1d67e8a37 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -924,16 +924,10 @@ const init = async () => { currentZoom.value = zoom; }); - superdoc.value?.on('layout-change', ({ fitZoom }) => { - // Clamp zoom between your min/max bounds - console.log('[layout-change]', fitZoom); - if (fitZoom < 50) { - superdoc.value.setZoom(50); - } else if (fitZoom > 200) { - superdoc.value.setZoom(200); - } else { - superdoc.value.setZoom(fitZoom); - } + superdoc.value?.on('viewport-change', ({ availableWidth, documentWidth, fitZoom }) => { + // Passive demo: custom consumers clamp and apply fitZoom themselves via + // setZoom(). For automatic behavior, configure `zoom: { mode: 'fit-width' }`. + console.log('[viewport-change]', { availableWidth, documentWidth, fitZoom }); }); window.superdoc = superdoc.value; diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 1c8699774c..c2c1dffd7d 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -102,11 +102,18 @@ export type { SuperDocExceptionEditorPayload } from '../core/types/index.js'; export type { SuperDocExceptionPayload } from '../core/types/index.js'; export type { SuperDocExceptionRestorePayload } from '../core/types/index.js'; export type { SuperDocExceptionStorePayload } from '../core/types/index.js'; +export type { SuperDocFitWidthOptions } from '../core/types/index.js'; export type { SuperDocLayoutEngineOptions } from '../core/types/index.js'; export type { SuperDocLockedPayload } from '../core/types/index.js'; export type { SuperDocReadyPayload } from '../core/types/index.js'; export type { SuperDocState } from '../core/types/index.js'; export type { SuperDocTelemetryConfig } from '../core/types/index.js'; +export type { SuperDocViewportChangePayload } from '../core/types/index.js'; +export type { SuperDocViewportMetrics } from '../core/types/index.js'; +export type { SuperDocZoomConfig } from '../core/types/index.js'; +export type { SuperDocZoomMode } from '../core/types/index.js'; +export type { SuperDocZoomPayload } from '../core/types/index.js'; +export type { SuperDocZoomState } from '../core/types/index.js'; export type { SurfaceComponentProps } from '../core/types/index.js'; export type { SurfaceFloatingPlacement } from '../core/types/index.js'; export type { SurfaceHandle } from '../core/types/index.js'; diff --git a/packages/superdoc/src/stores/superdoc-store.js b/packages/superdoc/src/stores/superdoc-store.js index bc788aec1e..f05f560d52 100644 --- a/packages/superdoc/src/stores/superdoc-store.js +++ b/packages/superdoc/src/stores/superdoc-store.js @@ -17,6 +17,12 @@ export const useSuperdocStore = defineStore('superdoc', () => { const pages = reactive({}); const documentUsers = ref([]); const activeZoom = ref(100); + /** @type {import('vue').Ref} */ + const zoomMode = ref('manual'); + // Latest viewport measurements (availableWidth / documentWidth / fitZoom), + // written by the viewport-fit composable; null until editors mount. + /** @type {import('vue').Ref} */ + const viewportMetrics = ref(null); const isReady = ref(false); const isInternal = ref(false); @@ -63,6 +69,28 @@ export const useSuperdocStore = defineStore('superdoc', () => { const init = async (config) => { reset(); currentConfig.value = config; + + // Seed the initial zoom before documents initialize so editor creation + // reads it (SuperDoc.vue passes activeZoom into layoutEngineOptions) and + // the first paint renders directly at the configured zoom. + if (config.zoom?.initial !== undefined) { + const initialZoom = config.zoom.initial; + if (typeof initialZoom === 'number' && Number.isFinite(initialZoom) && initialZoom > 0) { + activeZoom.value = initialZoom; + } else { + console.warn('[SuperDoc] zoom.initial expects a positive number representing percentage'); + } + } + + if (config.zoom?.mode !== undefined) { + const mode = config.zoom.mode; + if (mode === 'manual' || mode === 'fit-width') { + zoomMode.value = mode; + } else { + console.warn("[SuperDoc] zoom.mode expects 'manual' or 'fit-width'"); + } + } + const { documents: configDocs, modules: configModules, user: configUser, users: configUsers } = config; documentUsers.value = configUsers || []; @@ -261,6 +289,8 @@ export const useSuperdocStore = defineStore('superdoc', () => { documentUsers, users, activeZoom, + zoomMode, + viewportMetrics, documentScroll, isInternal, diff --git a/packages/superdoc/src/stores/superdoc-store.test.js b/packages/superdoc/src/stores/superdoc-store.test.js index b57db88c7b..46817307fb 100644 --- a/packages/superdoc/src/stores/superdoc-store.test.js +++ b/packages/superdoc/src/stores/superdoc-store.test.js @@ -232,3 +232,84 @@ describe('SuperDoc Store - Blob Support', () => { }); }); }); + +describe('SuperDoc Store - zoom.initial seeding', () => { + let store; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useSuperdocStore(); + }); + + const configWithZoom = (zoom) => + createTestConfig([{ data: new File(['x'], 'test.docx', { type: DOCX }), name: 'test.docx', type: DOCX }], { + zoom, + }); + + it('defaults activeZoom to 100 when zoom.initial is absent', async () => { + await store.init(configWithZoom(undefined)); + expect(store.activeZoom).toBe(100); + }); + + it('seeds activeZoom from zoom.initial before documents initialize', async () => { + await store.init(configWithZoom({ initial: 50 })); + expect(store.activeZoom).toBe(50); + }); + + it('ignores invalid zoom.initial values with a warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await store.init(configWithZoom({ initial: 0 })); + expect(store.activeZoom).toBe(100); + + await store.init(configWithZoom({ initial: -25 })); + expect(store.activeZoom).toBe(100); + + await store.init(configWithZoom({ initial: Number.NaN })); + expect(store.activeZoom).toBe(100); + + await store.init(configWithZoom({ initial: '75' })); + expect(store.activeZoom).toBe(100); + + expect(warn).toHaveBeenCalledTimes(4); + warn.mockRestore(); + }); +}); + +describe('SuperDoc Store - zoom.mode seeding', () => { + let store; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useSuperdocStore(); + }); + + const configWithZoom = (zoom) => + createTestConfig([{ data: new File(['x'], 'test.docx', { type: DOCX }), name: 'test.docx', type: DOCX }], { + zoom, + }); + + it('defaults zoomMode to manual', async () => { + await store.init(configWithZoom(undefined)); + expect(store.zoomMode).toBe('manual'); + }); + + it('seeds zoomMode from zoom.mode', async () => { + await store.init(configWithZoom({ mode: 'fit-width' })); + expect(store.zoomMode).toBe('fit-width'); + }); + + it('ignores invalid zoom.mode values with a warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await store.init(configWithZoom({ mode: 'fit-page' })); + expect(store.zoomMode).toBe('manual'); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('starts with null viewport metrics', async () => { + await store.init(configWithZoom(undefined)); + expect(store.viewportMetrics).toBeNull(); + }); +}); diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json index 383b8236d3..50afe2a423 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json @@ -1,14 +1,14 @@ { "generatedAt": "2026-05-19T11:33:50.546Z", "summary": { - "total": 214, + "total": 221, "byBucket": { "legacy-root": 60, "internal-candidate": 8, - "supported-root": 146 + "supported-root": 153 }, "byConfidence": { - "high": 111, + "high": 118, "medium": 101, "low": 2 } @@ -1763,6 +1763,17 @@ "inEsm": false, "inCjs": false }, + { + "name": "SuperDocFitWidthOptions", + "rationale": "Bounds and padding for the fit-width zoom policy (Config.zoom.fitWidth); named so consumers can type fit policies outside the inline config literal.", + "bucket": "supported-root", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, { "name": "SuperDocLayoutEngineOptions", "bucket": "supported-root", @@ -1818,6 +1829,72 @@ "inEsm": false, "inCjs": false }, + { + "name": "SuperDocViewportChangePayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the viewport-change event and passed to Config.onViewportChange; named so custom fit-to-width consumers can type handlers.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocViewportMetrics", + "rationale": "Return type of getViewportMetrics(); alias of the viewport-change payload so reads and events share one shape.", + "bucket": "supported-root", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocZoomConfig", + "bucket": "supported-root", + "rationale": "Config.zoom domain object (initial + fitToContainer); named so consumers can build zoom configuration values with a public type.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocZoomMode", + "rationale": "Closed zoom mode union (manual | fit-width) used by Config.zoom.mode, setZoomMode, and the zoomChange payload.", + "bucket": "supported-root", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocZoomPayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the zoomChange event and passed to Config.onZoomChange; promoted from an internal interface when the config callback made it consumer-facing.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocZoomState", + "rationale": "Return type of getZoomState(); snapshot of mode, value, fit zoom, and effective bounds for zoom UI.", + "bucket": "supported-root", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, { "name": "SuperEditor", "bucket": "legacy-root", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index f900446ee9..56b73fedd6 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-26T11:53:56.060Z", + "generatedAt": "2026-06-05T15:25:06.207Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -173,11 +173,18 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocLayoutEngineOptions", "SuperDocLockedPayload", "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SuperEditor", "SuperInput", "SuperToolbar", @@ -393,11 +400,18 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocLayoutEngineOptions", "SuperDocLockedPayload", "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SuperEditor", "SuperInput", "SuperToolbar", @@ -547,11 +561,11 @@ } }, "counts": { - "types.import": 214, - "types.require": 214, + "types.import": 221, + "types.require": 221, "import": 41, "require": 41, - "union": 214 + "union": 221 }, "divergences": { "typesImportVsRequire": { @@ -707,11 +721,18 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocLayoutEngineOptions", "SuperDocLockedPayload", "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SurfaceComponentProps", "SurfaceFloatingPlacement", "SurfaceHandle", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index 49225b0f75..0608a0d332 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-05-26T11:53:56.060Z +Generated: 2026-06-05T15:25:06.207Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 214 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 214 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 221 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 221 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **214** | +| **union** | | **221** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 173 +- typed but no runtime export (phantom risk): 180 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -167,11 +167,18 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `SuperDocExceptionPayload` - `SuperDocExceptionRestorePayload` - `SuperDocExceptionStorePayload` +- `SuperDocFitWidthOptions` - `SuperDocLayoutEngineOptions` - `SuperDocLockedPayload` - `SuperDocReadyPayload` - `SuperDocState` - `SuperDocTelemetryConfig` +- `SuperDocViewportChangePayload` +- `SuperDocViewportMetrics` +- `SuperDocZoomConfig` +- `SuperDocZoomMode` +- `SuperDocZoomPayload` +- `SuperDocZoomState` - `SurfaceComponentProps` - `SurfaceFloatingPlacement` - `SurfaceHandle` @@ -221,7 +228,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CollaborationProvider` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 0 | 8 | ✓ | | `CommandProps` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | -| `Comment` | ✓ | ✓ | | | 5 | ✓ | 28 | 3 | 45 | | +| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 3 | 45 | | | `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 3 | | | `CommentConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentElement` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -239,13 +246,13 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 149 | 24 | 59 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Document` | ✓ | ✓ | | | 2 | | 287 | 52 | 111 | ✓ | -| `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 8 | 4 | ✓ | +| `Document` | ✓ | ✓ | | | 2 | | 288 | 56 | 111 | ✓ | +| `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | | `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | | `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `DocxZipper` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | -| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 194 | 19 | 69 | ✓ | +| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 195 | 20 | 69 | ✓ | | `EditorCommands` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `EditorEventMap` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorExtension` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -351,9 +358,9 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SelectionHandle` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 0 | 1 | | | `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | -| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 116 | 0 | 3 | | +| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 21 | | 1014 | 180 | 244 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 21 | | 1030 | 187 | 244 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -361,11 +368,18 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SuperDocExceptionPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionRestorePayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `SuperDocExceptionStorePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocFitWidthOptions` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `SuperDocLayoutEngineOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SuperDocLockedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 2 | 0 | 0 | | | `SuperDocState` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocTelemetryConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | +| `SuperDocViewportChangePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocViewportMetrics` | ✓ | ✓ | | | 2 | | 1 | 0 | 0 | | +| `SuperDocZoomConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `SuperDocZoomMode` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `SuperDocZoomPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocZoomState` | ✓ | ✓ | | | 2 | | 1 | 0 | 0 | | | `SuperEditor` | ✓ | ✓ | ✓ | ✓ | 1 | | 16 | 0 | 5 | | | `SuperInput` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 2 | | | `SuperToolbar` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 4 | ✓ | @@ -381,7 +395,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `TelemetryEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 0 | 7 | | | `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 0 | 4 | | -| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 41 | 0 | 9 | | +| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 0 | 9 | | | `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 7 | 15 | | | `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `TrackChangesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -391,7 +405,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `Transaction` | ✓ | ✓ | | | 3 | ✓ | 5 | 0 | 0 | ✓ | | `UnsupportedContentItem` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `UpgradeToCollaborationOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `User` | ✓ | ✓ | | | 7 | ✓ | 51 | 8 | 30 | | +| `User` | ✓ | ✓ | | | 7 | ✓ | 52 | 8 | 30 | | | `ViewLayout` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ViewOptions` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `ViewingVisibilityConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts index f53ac804d7..4e09a7ce0d 100644 --- a/tests/consumer-typecheck/src/all-public-types.ts +++ b/tests/consumer-typecheck/src/all-public-types.ts @@ -167,11 +167,18 @@ import type { SuperDocExceptionPayload, SuperDocExceptionRestorePayload, SuperDocExceptionStorePayload, + SuperDocFitWidthOptions, SuperDocLayoutEngineOptions, SuperDocLockedPayload, SuperDocReadyPayload, SuperDocState, SuperDocTelemetryConfig, + SuperDocViewportChangePayload, + SuperDocViewportMetrics, + SuperDocZoomConfig, + SuperDocZoomMode, + SuperDocZoomPayload, + SuperDocZoomState, SurfaceComponentProps, SurfaceFloatingPlacement, SurfaceHandle, @@ -349,11 +356,18 @@ const _real_SuperDocExceptionEditorPayload: AssertNotAny = true; const _real_SuperDocExceptionRestorePayload: AssertNotAny = true; const _real_SuperDocExceptionStorePayload: AssertNotAny = true; +const _real_SuperDocFitWidthOptions: AssertNotAny = true; const _real_SuperDocLayoutEngineOptions: AssertNotAny = true; const _real_SuperDocLockedPayload: AssertNotAny = true; const _real_SuperDocReadyPayload: AssertNotAny = true; const _real_SuperDocState: AssertNotAny = true; const _real_SuperDocTelemetryConfig: AssertNotAny = true; +const _real_SuperDocViewportChangePayload: AssertNotAny = true; +const _real_SuperDocViewportMetrics: AssertNotAny = true; +const _real_SuperDocZoomConfig: AssertNotAny = true; +const _real_SuperDocZoomMode: AssertNotAny = true; +const _real_SuperDocZoomPayload: AssertNotAny = true; +const _real_SuperDocZoomState: AssertNotAny = true; const _real_SurfaceComponentProps: AssertNotAny = true; const _real_SurfaceFloatingPlacement: AssertNotAny = true; const _real_SurfaceHandle: AssertNotAny = true; diff --git a/tests/consumer-typecheck/src/config-callback-payloads.ts b/tests/consumer-typecheck/src/config-callback-payloads.ts index 9f681c770c..aa8844e7cd 100644 --- a/tests/consumer-typecheck/src/config-callback-payloads.ts +++ b/tests/consumer-typecheck/src/config-callback-payloads.ts @@ -44,6 +44,8 @@ import type { SuperDocEditorPayload, SuperDocLockedPayload, SuperDocReadyPayload, + SuperDocViewportChangePayload, + SuperDocZoomPayload, } from 'superdoc'; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; @@ -89,6 +91,14 @@ const _onListDefinitionsChangeOk: AssertEqual< // (runtime payload builder always sets them, defaulting to null). const _onEditorUpdateOk: AssertEqual, EditorUpdateEvent> = true; +// ─── onZoomChange ─────────────────────────────────────────────────── +// Fires for every zoom source: setZoom(), toolbar, fit-width mode. +const _onZoomChangeOk: AssertEqual, SuperDocZoomPayload> = true; + +// ─── onViewportChange ─────────────────────────────────────────────── +// Pure measurements: fit policy options (min/max/padding) never affect them. +const _onViewportChangeOk: AssertEqual, SuperDocViewportChangePayload> = true; + void [ _onReadyOk, _onEditorBeforeCreateOk, @@ -99,4 +109,6 @@ void [ _onAwarenessUpdateOk, _onListDefinitionsChangeOk, _onEditorUpdateOk, + _onZoomChangeOk, + _onViewportChangeOk, ]; diff --git a/tests/consumer-typecheck/src/read-and-navigation-apis.ts b/tests/consumer-typecheck/src/read-and-navigation-apis.ts index 85566c8957..392309e960 100644 --- a/tests/consumer-typecheck/src/read-and-navigation-apis.ts +++ b/tests/consumer-typecheck/src/read-and-navigation-apis.ts @@ -17,7 +17,7 @@ * `goToSearchResult.parameters` is already locked in `search-match.ts`; * this file adds the `returns` assertion for the same method. */ -import type { NavigableAddress, SuperDoc } from 'superdoc'; +import type { NavigableAddress, SuperDoc, SuperDocViewportMetrics, SuperDocZoomState } from 'superdoc'; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; type AssertEqual = Equal extends true ? true : never; @@ -41,6 +41,30 @@ const _htmlValueWithOpts: string[] = sd.getHTML({ unflattenLists: true }); const _zoomReturnOk: AssertEqual, number> = true; const _zoomValue: number = sd.getZoom(); +// ─── getZoomState ──────────────────────────────────────────────────── +// Snapshot of mode/value/fitZoom and the effective fit bounds. fitZoom +// is null until the first viewport measurement. +const _zoomStateReturnOk: AssertEqual, SuperDocZoomState> = true; +const _zoomState = sd.getZoomState(); +const _zoomStateMode: 'manual' | 'fit-width' = _zoomState.mode; +const _zoomStateFit: number | null = _zoomState.fitZoom; +void _zoomStateMode; +void _zoomStateFit; + +// ─── getViewportMetrics ────────────────────────────────────────────── +// Latest pure measurements, or null before editors mount. +const _viewportMetricsReturnOk: AssertEqual< + ReturnType, + SuperDocViewportMetrics | null +> = true; +const _viewportMetrics = sd.getViewportMetrics(); +if (_viewportMetrics) { + const _availableWidth: number = _viewportMetrics.availableWidth; + const _documentWidth: number = _viewportMetrics.documentWidth; + const _fitZoom: number = _viewportMetrics.fitZoom; + void [_availableWidth, _documentWidth, _fitZoom]; +} + // ─── navigateTo ────────────────────────────────────────────────────── // Async navigation to a stable address (bookmark, block, comment, // tracked change). Resolves true iff the address was found and diff --git a/tests/consumer-typecheck/src/superdoc-events.ts b/tests/consumer-typecheck/src/superdoc-events.ts index be611fa4f7..1e77adba7a 100644 --- a/tests/consumer-typecheck/src/superdoc-events.ts +++ b/tests/consumer-typecheck/src/superdoc-events.ts @@ -59,9 +59,21 @@ superdoc.on('sidebar-toggle', (isOpened) => { void flag; }); -superdoc.on('zoomChange', ({ zoom }) => { +superdoc.on('zoomChange', ({ zoom, mode }) => { const value: number = zoom; + // `mode` narrows to the closed manual/fit-width union. + const zoomMode: 'manual' | 'fit-width' = mode; void value; + void zoomMode; +}); + +superdoc.on('viewport-change', ({ availableWidth, documentWidth, fitZoom }) => { + const available: number = availableWidth; + const docWidth: number = documentWidth; + const fit: number = fitZoom; + void available; + void docWidth; + void fit; }); superdoc.on('formatting-marks-change', ({ showFormattingMarks, superdoc: instance }) => { diff --git a/tests/consumer-typecheck/src/ui-state-control-apis.ts b/tests/consumer-typecheck/src/ui-state-control-apis.ts index 6b01d3a53e..7b40021576 100644 --- a/tests/consumer-typecheck/src/ui-state-control-apis.ts +++ b/tests/consumer-typecheck/src/ui-state-control-apis.ts @@ -56,6 +56,16 @@ const _setZoomParamsOk: AssertEqual, [percent: n const _setZoomReturnOk: AssertEqual, void> = true; sd.setZoom(150); +// ─── setZoomMode ──────────────────────────────────────────────────── +// Switches between manual and fit-width zoom. Closed union parameter; +// invalid strings must be rejected at compile time. +const _setZoomModeParamsOk: AssertEqual, [mode: 'manual' | 'fit-width']> = true; +const _setZoomModeReturnOk: AssertEqual, void> = true; +sd.setZoomMode('fit-width'); +sd.setZoomMode('manual'); +// @ts-expect-error zoom mode is a closed union; only manual/fit-width. +sd.setZoomMode('fit-page'); + // ─── setHighContrastMode ──────────────────────────────────────────── // Forwards to `activeEditor.setHighContrastMode` and writes to the // highContrastModeStore. No-op until the active editor exists. From ac41f85ff5d807f83d015b5a36ae6ab35ed33e39 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 12:27:23 -0300 Subject: [PATCH 06/34] feat(react): zoom and viewport callback props (SD-3294) Adds onZoomChange and onViewportChange as explicitly plumbed callback props (the callbacksRef pattern), so swapped handler identities stay fresh across rerenders without rebuilding the SuperDoc instance. The zoom config flows through props automatically via SuperDocConfig. Event types re-derive from the core Config so the wrapper cannot drift from the core contract. --- packages/react/src/SuperDocEditor.test.tsx | 66 ++++++++++++++++++++++ packages/react/src/SuperDocEditor.tsx | 30 +++++++++- packages/react/src/index.ts | 2 + packages/react/src/types.ts | 24 +++++++- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index d40ff03b64..94d697d35f 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -162,6 +162,72 @@ describe('SuperDocEditor', () => { }, SUPERDOC_READY_TEST_TIMEOUT, ); + + it( + 'should call onZoomChange when zoom changes through the core wiring', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + const onZoomChange = vi.fn(); + + render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); + + instance?.setZoom(150); + + expect(onZoomChange).toHaveBeenCalledWith({ zoom: 150, mode: 'manual' }); + expect(instance?.getZoom()).toBe(150); + expect(instance?.getZoomState().mode).toBe('manual'); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); + + it( + 'should route onZoomChange through the latest callback after rerender', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + const firstOnZoomChange = vi.fn(); + const secondOnZoomChange = vi.fn(); + + const { rerender } = render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); + + rerender(); + + // Same instance (callback identity changes must not rebuild) and the + // fresh callback receives the event. + expect(ref.current?.getInstance()).toBe(instance); + instance?.setZoom(80); + + expect(firstOnZoomChange).not.toHaveBeenCalled(); + expect(secondOnZoomChange).toHaveBeenCalledWith({ zoom: 80, mode: 'manual' }); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); + + it( + 'should apply zoom.initial through config passthrough', + async () => { + const ref = createRef(); + const onReady = vi.fn(); + + render(); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + + expect(ref.current?.getInstance()?.getZoom()).toBe(50); + }, + SUPERDOC_READY_TEST_TIMEOUT, + ); }); describe('onEditorDestroy', () => { diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index b022552456..5ebe4c9f1d 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -20,6 +20,8 @@ import type { SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, + SuperDocZoomChangeEvent, + SuperDocViewportChangeEvent, } from './types'; /** @@ -50,6 +52,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); @@ -211,6 +229,16 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { + if (!destroyed) { + callbacksRef.current.onZoomChange?.(event); + } + }, + onViewportChange: (event: SuperDocViewportChangeEvent) => { + if (!destroyed) { + callbacksRef.current.onViewportChange?.(event); + } + }, }; instance = new SuperDoc(superdocConfig) as SuperDocInstance; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eba3573a70..d62c9b0933 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -27,4 +27,6 @@ export type { SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, + SuperDocZoomChangeEvent, + SuperDocViewportChangeEvent, } from './types'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a0a378cd24..fd3a5a2757 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -104,6 +104,20 @@ export type SuperDocContentErrorEvent = Parameters>[0]; + +/** + * Event passed to onViewportChange callback. Re-derived from the core + * `Config.onViewportChange` parameter so the React wrapper cannot + * drift from the core contract. + */ +export type SuperDocViewportChangeEvent = Parameters>[0]; + // ============================================================================= // React Component Types // ============================================================================= @@ -131,7 +145,9 @@ type ExplicitCallbackProps = | 'onEditorUpdate' | 'onTransaction' | 'onContentError' - | 'onException'; + | 'onException' + | 'onZoomChange' + | 'onViewportChange'; /** * Explicitly typed callback props to ensure proper TypeScript inference. @@ -158,6 +174,12 @@ export interface CallbackProps { /** Callback when an exception is thrown */ onException?: (event: SuperDocExceptionEvent) => void; + + /** Callback when the zoom level changes (setZoom, toolbar, or fit-to-container) */ + onZoomChange?: (event: SuperDocZoomChangeEvent) => void; + + /** Callback when the width available to the document or its base page width changes */ + onViewportChange?: (event: SuperDocViewportChangeEvent) => void; } /** From e2c93becb33c7e765831adbc1059412aafb3c9c2 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 12:28:05 -0300 Subject: [PATCH 07/34] docs(superdoc): zoom modes, viewport-change event, fit-width config (SD-3294) Documents the zoom config (initial, mode, fitWidth), the new setZoomMode / getZoomState / getViewportMetrics methods, the viewport-change event and its pure-metrics payload, the zoomChange mode field, and the React responsive-zoom pattern. --- apps/docs/editor/superdoc/configuration.mdx | 82 ++++++++++++++++ apps/docs/editor/superdoc/events.mdx | 41 +++++++- apps/docs/editor/superdoc/methods.mdx | 97 ++++++++++++++++++- .../docs/getting-started/frameworks/react.mdx | 17 ++++ 4 files changed, 229 insertions(+), 8 deletions(-) diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx index d0dfdfb729..720f13f4a1 100644 --- a/apps/docs/editor/superdoc/configuration.mdx +++ b/apps/docs/editor/superdoc/configuration.mdx @@ -495,6 +495,68 @@ new SuperDoc({ + + Zoom behavior: the initial zoom level, the starting mode, and the fit-width policy bounds. The zoom system is "mode + value": `manual` holds whatever was last set, `fit-width` continuously re-fits the document to the available container width. Runtime control stays on the instance: `setZoom()` (switches to manual), `setZoomMode()`, `getZoomState()`, `getViewportMetrics()`, and the `zoomChange` / `viewport-change` events. + + + + Initial zoom level as a percentage. Applied before the first paint, so the document renders directly at this zoom with no visible jump. In `fit-width` mode this is the paint zoom until the first fit computes. + + + Starting zoom mode. In `'fit-width'` the document re-fits whenever the container resizes or the page geometry changes; the fit is applied through the normal zoom pipeline, so `zoomChange` fires for every adjustment. Calling `setZoom()` switches back to `'manual'`. + + + Bounds and padding for the `fit-width` policy. These shape the applied zoom only; the metrics reported by `viewport-change` and `getViewportMetrics()` stay pure measurements. + + + Lower bound for the applied zoom percentage. + + + Upper bound for the applied zoom percentage. The default never enlarges the document past its natural size; raise it to let wide containers scale the page up. + + + Horizontal padding in pixels reserved inside the available width before computing the fit. + + + + + + For custom behavior instead of the built-in policy, subscribe to the [`viewport-change`](/editor/superdoc/events#viewport-change) event and apply your own zoom. + + + + ```javascript Usage + const superdoc = new SuperDoc({ + selector: '#editor', + document: file, + zoom: { + mode: 'fit-width', + fitWidth: { min: 35, max: 100, padding: 24 }, + }, + }); + ``` + + ```javascript Full Example + import { SuperDoc } from 'superdoc'; + import 'superdoc/style.css'; + + const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + zoom: { + initial: 100, + mode: 'fit-width', + fitWidth: { min: 35, max: 100, padding: 24 }, + }, + onZoomChange: ({ zoom, mode }) => { + console.log(`Zoom is now ${zoom}% (${mode})`); + }, + }); + ``` + + + + **Removed in v1.0**: Use `viewOptions.layout` instead. `'paginated'` → `'print'`, `'responsive'` → `'web'`. @@ -618,6 +680,26 @@ All handlers are optional functions in the configuration: ``` + + Called when the zoom level changes, from any source: `setZoom()`, the toolbar zoom control, or `fit-width` mode + + ```javascript + onZoomChange: ({ zoom, mode }) => { + setZoomIndicator(zoom, mode); + } + ``` + + + + Called when the width available to the document or its base page width changes. Registered before the first emit, so the initial measurement is never missed. See the [`viewport-change`](/editor/superdoc/events#viewport-change) event. + + ```javascript + onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => { + superdoc.setZoom(Math.min(100, fitZoom)); + } + ``` + + Custom handler for accepting tracked changes from comment bubbles. Replaces default accept behavior when provided. diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index f4038e0cd7..c0884295c5 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -391,12 +391,12 @@ superdoc.on('pagination-update', ({ totalPages, superdoc }) => { ### `zoomChange` -When the zoom level changes via `setZoom()`. +When the zoom level changes, from any source: `setZoom()`, the toolbar zoom control, or [`fit-width` mode](/editor/superdoc/configuration#param-zoom). The payload carries the value and the mode that produced it. Also available as the `onZoomChange` config callback. ```javascript Usage -superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom: ${zoom}%`); +superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); }); ``` @@ -409,8 +409,39 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom: ${zoom}%`); +superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); +}); +``` + + +### `viewport-change` + +When the width available to the document or the document's base page width changes (container resize, comments sidebar toggle, page orientation, document swap). The payload carries pure measurements; `zoom.fitWidth` policy options never affect them: + +- `availableWidth` - container width in pixels, minus the comments sidebar when visible +- `documentWidth` - document base page width in pixels at 100% zoom (zoom-independent) +- `fitZoom` - the unclamped zoom percentage that fits the page into the available width + +The same values are readable at any time via `getViewportMetrics()`. For the common case, prefer [`zoom.mode: 'fit-width'`](/editor/superdoc/configuration#param-zoom), which clamps and applies the fit automatically. Subscribe to this event (or pass the `onViewportChange` config callback, which is registered before the first emit) only when you want to decide the zoom yourself. + + +```javascript Usage +superdoc.on('viewport-change', ({ fitZoom }) => { + superdoc.setZoom(Math.min(100, Math.max(35, fitZoom))); +}); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => { + console.log(`Need ${fitZoom}% to fit ${documentWidth}px into ${availableWidth}px`); + }, }); ``` diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx index 3e807a5000..01e1646a35 100644 --- a/apps/docs/editor/superdoc/methods.mdx +++ b/apps/docs/editor/superdoc/methods.mdx @@ -538,7 +538,7 @@ const superdoc = new SuperDoc({ ### `setZoom` -Set the zoom level for all documents. Propagates to all presentation editors, PDF viewers, and whiteboard layers. +Set the zoom level for all documents and switch the zoom mode to `manual`. Propagates to all presentation editors, PDF viewers, and whiteboard layers. Use `setZoomMode('fit-width')` to re-enter automatic fitting. Zoom level as a percentage (e.g., `100`, `150`, `200`). Must be a positive finite number. @@ -560,8 +560,8 @@ const superdoc = new SuperDoc({ onReady: ({ superdoc }) => { superdoc.setZoom(150); - superdoc.on('zoomChange', ({ zoom }) => { - console.log(`Zoom changed to ${zoom}%`); + superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom changed to ${zoom}% (${mode})`); }); }, }); @@ -569,6 +569,97 @@ const superdoc = new SuperDoc({ +### `setZoomMode` + +Switch the zoom mode. `'fit-width'` continuously re-fits the document to the available container width, clamped by [`zoom.fitWidth`](/editor/superdoc/configuration#param-zoom); `'manual'` holds the current value. Switching to `'fit-width'` applies the fit immediately when viewport metrics are available. + + + The zoom mode to switch to. + + + + +```javascript Usage +superdoc.setZoomMode('fit-width'); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + superdoc.setZoomMode('fit-width'); + + superdoc.on('zoomChange', ({ zoom, mode }) => { + console.log(`Zoom: ${zoom}% (${mode})`); + }); + }, +}); +``` + + + +### `getZoomState` + +Get a snapshot of the current zoom state: mode, value, the latest computed fit zoom, and the effective fit bounds. + +**Returns:** `SuperDocZoomState` - `{ mode, value, fitZoom, min, max }`. `fitZoom` is `null` before the first viewport measurement. + + + +```javascript Usage +const { mode, value, fitZoom } = superdoc.getZoomState(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + const state = superdoc.getZoomState(); + console.log(`Mode: ${state.mode}, value: ${state.value}%`); + }, +}); +``` + + + +### `getViewportMetrics` + +Get the latest viewport measurements: the width available to the document, the document's base page width at 100% zoom, and the unclamped fit zoom. Subscribe to [`viewport-change`](/editor/superdoc/events#viewport-change) for updates. + +**Returns:** `SuperDocViewportMetrics | null` - `{ availableWidth, documentWidth, fitZoom }`, or `null` until the first measurement (editors still mounting). + + + +```javascript Usage +const metrics = superdoc.getViewportMetrics(); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + onReady: ({ superdoc }) => { + const metrics = superdoc.getViewportMetrics(); + if (metrics) { + superdoc.setZoom(Math.min(100, metrics.fitZoom)); + } + }, +}); +``` + + + ### `focus` Focus the active editor or first available. diff --git a/apps/docs/getting-started/frameworks/react.mdx b/apps/docs/getting-started/frameworks/react.mdx index 594e4805e5..4bb434a797 100644 --- a/apps/docs/getting-started/frameworks/react.mdx +++ b/apps/docs/getting-started/frameworks/react.mdx @@ -59,6 +59,23 @@ function App() { ``` +### Responsive zoom + +Pass `zoom` with `mode: 'fit-width'` to keep the document fitted to its container as it resizes. SuperDoc observes the container for you; no resize listeners needed. Calling `setZoom()` (or the user picking a percentage in the toolbar) switches back to manual mode. + +```jsx + console.log(`Zoom: ${zoom}% (${mode})`)} +/> +``` + +For custom behavior, listen to `onViewportChange` instead and apply your own zoom with `getInstance().setZoom()`. See [zoom configuration](/editor/superdoc/configuration#param-zoom). + ## Handle file uploads ```jsx From 647289563894aaea23f1b87cc030d73e1ef0ce2e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 13:06:15 -0300 Subject: [PATCH 08/34] feat(superdoc): observable zoom-mode transitions and format-aware viewport metrics (SD-3294) Two gaps a zoom UI hits in practice: - setZoomMode emitted nothing unless the numeric value later changed, so mode-only transitions (entering fit-width at the clamped value, returning to manual) were invisible to zoomChange subscribers. It now emits zoomChange with the current value on every mode change and no-ops on a same-mode call. - The width resolver required a DOCX activeEditor, so PDF-only instances never produced viewport metrics even though setZoom supports PDFs, and multi-document instances measured only the active editor. The resolver now takes the widest measurable page across all documents: DOCX from per-document page styles, PDF from rendered pages normalized by their actual scale factor back to CSS px at 100% zoom (a 612pt letter page renders 816 CSS px), with a pdf:document-ready re-evaluation hook. HTML documents reflow and contribute nothing; an HTML-only instance reports no metrics. --- apps/docs/editor/superdoc/events.mdx | 4 +- apps/docs/editor/superdoc/methods.mdx | 2 +- packages/superdoc/src/SuperDoc.vue | 1 + .../src/composables/use-viewport-fit.js | 112 +++++++++++++++--- packages/superdoc/src/core/SuperDoc.test.js | 26 ++++ packages/superdoc/src/core/SuperDoc.ts | 6 +- 6 files changed, 129 insertions(+), 22 deletions(-) diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index c0884295c5..d8f30bd991 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -420,9 +420,11 @@ superdoc.on('zoomChange', ({ zoom, mode }) => { When the width available to the document or the document's base page width changes (container resize, comments sidebar toggle, page orientation, document swap). The payload carries pure measurements; `zoom.fitWidth` policy options never affect them: - `availableWidth` - container width in pixels, minus the comments sidebar when visible -- `documentWidth` - document base page width in pixels at 100% zoom (zoom-independent) +- `documentWidth` - the widest document's base page width in pixels at 100% zoom (zoom-independent; DOCX from page styles, PDF from rendered pages) - `fitZoom` - the unclamped zoom percentage that fits the page into the available width +HTML documents reflow to the container, so an HTML-only instance reports no metrics. + The same values are readable at any time via `getViewportMetrics()`. For the common case, prefer [`zoom.mode: 'fit-width'`](/editor/superdoc/configuration#param-zoom), which clamps and applies the fit automatically. Subscribe to this event (or pass the `onViewportChange` config callback, which is registered before the first emit) only when you want to decide the zoom yourself. diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx index 01e1646a35..06acc64ee7 100644 --- a/apps/docs/editor/superdoc/methods.mdx +++ b/apps/docs/editor/superdoc/methods.mdx @@ -571,7 +571,7 @@ const superdoc = new SuperDoc({ ### `setZoomMode` -Switch the zoom mode. `'fit-width'` continuously re-fits the document to the available container width, clamped by [`zoom.fitWidth`](/editor/superdoc/configuration#param-zoom); `'manual'` holds the current value. Switching to `'fit-width'` applies the fit immediately when viewport metrics are available. +Switch the zoom mode. `'fit-width'` continuously re-fits the document to the available container width, clamped by [`zoom.fitWidth`](/editor/superdoc/configuration#param-zoom); `'manual'` holds the current value. Switching to `'fit-width'` applies the fit immediately when viewport metrics are available. Emits `zoomChange` (with the current value) so zoom UIs observe mode-only transitions; calling with the current mode is a no-op. Works for DOCX and PDF documents. The zoom mode to switch to. diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 5e1b0cab6b..8b011a6ff0 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -1335,6 +1335,7 @@ useViewportFit({ viewportMetrics, showCommentsSidebar, superdocRoot, + documents, }); /** diff --git a/packages/superdoc/src/composables/use-viewport-fit.js b/packages/superdoc/src/composables/use-viewport-fit.js index 05d5ec948c..230f254d02 100644 --- a/packages/superdoc/src/composables/use-viewport-fit.js +++ b/packages/superdoc/src/composables/use-viewport-fit.js @@ -1,7 +1,9 @@ import { onBeforeUnmount, nextTick, watch } from 'vue'; +import { DOCX } from '@superdoc/common'; const CSS_PX_PER_INCH = 96; const SIDEBAR_SELECTOR = '.superdoc__right-sidebar'; +const PDF_PAGE_SELECTOR = '.sd-pdf-viewer-page'; export const FIT_WIDTH_DEFAULTS = Object.freeze({ min: 10, @@ -56,10 +58,13 @@ export const computeAppliedFitZoom = (availableWidth, documentWidth, options) => * for `config.zoom.fitWidth` padding and clamping. * * The base page width is re-resolved on every evaluation (never latched) - * and comes from the page styles first, which are zoom-independent: a zoom - * applied before the first measurement (`zoom.initial`, `setZoom()` in - * `onReady`) cannot corrupt the ratio. DOM measurement, normalized by the - * active zoom, is the fallback when page styles are unavailable. + * and is the widest measurable page across all loaded documents: DOCX + * widths come from page styles (zoom-independent, so a zoom applied + * before the first measurement cannot corrupt the ratio), PDF widths from + * rendered pages normalized by their actual scale factor. HTML documents + * reflow and contribute nothing; an HTML-only instance reports no + * metrics. A zoom-normalized DOM measurement is the last-resort fallback + * for a DOCX editor without page styles. * * The fit application writes the zoom state directly instead of calling * `setZoom()`, which by contract switches the mode to `manual`. @@ -76,17 +81,15 @@ export function useViewportFit({ viewportMetrics, showCommentsSidebar, superdocRoot, + documents, }) { - const resolveBaseDocumentWidth = () => { - const superdoc = getSuperdoc(); - // Without an editor there is no document to measure: the document - // element before editor mount is shell scaffolding whose width is - // container-derived, which would produce a garbage base. - if (!superdoc?.activeEditor) return null; - + // Page width in CSS px at 100% zoom for one DOCX editor, from its page + // styles (zoom-independent), or null when unavailable. + const resolveEditorPageWidth = (editor) => { + if (!editor) return null; let pageStyles = null; try { - pageStyles = superdoc.activeEditor.getPageStyles?.() ?? null; + pageStyles = editor.getPageStyles?.() ?? null; } catch { pageStyles = null; } @@ -94,14 +97,80 @@ export function useViewportFit({ if (typeof pageWidthInches === 'number' && Number.isFinite(pageWidthInches) && pageWidthInches > 0) { return pageWidthInches * CSS_PX_PER_INCH; } + return null; + }; + + // Widest rendered PDF page in CSS px at 100% zoom. PDF pages size via + // `calc(var(--scale-factor) * px)` where the scale factor is the + // viewer zoom times the pt-to-CSS-px conversion (96/72). Dividing the + // measured width by the page's actual rendered scale factor yields PDF + // points regardless of zoom-sync state; multiplying by 96/72 converts + // back to the CSS width at 100% zoom (verified: a 612pt letter page + // renders 816 CSS px at 100%). + const resolvePdfPageWidth = () => { + const root = superdocRoot.value; + if (!root?.querySelectorAll) return null; + const PDF_POINTS_TO_CSS_PX = 96 / 72; + let widest = 0; + for (const page of root.querySelectorAll(PDF_PAGE_SELECTOR)) { + const measured = Number(page.clientWidth) || Number(page.getBoundingClientRect?.().width) || 0; + if (!(measured > 0)) continue; + let scaleFactor = NaN; + if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') { + scaleFactor = Number.parseFloat(window.getComputedStyle(page).getPropertyValue('--scale-factor')); + } + let normalized; + if (Number.isFinite(scaleFactor) && scaleFactor > 0) { + normalized = (measured / scaleFactor) * PDF_POINTS_TO_CSS_PX; + } else { + // No scale-factor var: assume the viewer is synced to the global + // zoom and divide that out instead. + const zoomFactor = (activeZoom.value ?? 100) / 100; + normalized = zoomFactor > 0 ? measured / zoomFactor : measured; + } + if (normalized > widest) widest = normalized; + } + return widest > 0 ? widest : null; + }; + + // Widest measurable document width at 100% zoom across all loaded + // documents. Zoom is global, so the fit must target the widest page: + // otherwise one landscape or PDF document overflows while another fits. + // HTML documents reflow to the container and contribute no fixed width. + const resolveBaseDocumentWidth = () => { + const superdoc = getSuperdoc(); + if (!superdoc) return null; + const widths = []; + + const docs = documents?.value ?? []; + for (const doc of docs) { + if (doc?.type !== DOCX) continue; + const width = resolveEditorPageWidth(doc.getEditor?.()); + if (width !== null) widths.push(width); + } + // Store shims in tests (and transitional states) may not expose + // per-document editors; fall back to the active editor's page styles. + if (widths.length === 0) { + const width = resolveEditorPageWidth(superdoc.activeEditor); + if (width !== null) widths.push(width); + } - const docEl = superdocRoot.value?.querySelector?.('.superdoc__document'); - const measured = Number(docEl?.clientWidth) || Number(docEl?.getBoundingClientRect?.().width) || 0; - if (measured > 0) { - // The measured element scales with zoom; divide it back out so the - // returned width is the document's natural size. - const zoomFactor = (activeZoom.value ?? 100) / 100; - return zoomFactor > 0 ? measured / zoomFactor : measured; + const pdfWidth = resolvePdfPageWidth(); + if (pdfWidth !== null) widths.push(pdfWidth); + + if (widths.length > 0) return Math.max(...widths); + + // Last resort for a DOCX editor without page styles: the rendered + // document element, normalized by zoom. Gated on an editor existing; + // before editor mount the element is shell scaffolding whose width is + // container-derived, which would produce a garbage base. + if (superdoc.activeEditor) { + const docEl = superdocRoot.value?.querySelector?.('.superdoc__document'); + const measured = Number(docEl?.clientWidth) || Number(docEl?.getBoundingClientRect?.().width) || 0; + if (measured > 0) { + const zoomFactor = (activeZoom.value ?? 100) / 100; + return zoomFactor > 0 ? measured / zoomFactor : measured; + } } return null; @@ -196,13 +265,18 @@ export function useViewportFit({ const handlePaginationUpdate = () => { evaluateViewport(); }; + const handlePdfDocumentReady = () => { + nextTick(() => evaluateViewport()); + }; const superdocAtSetup = getSuperdoc(); superdocAtSetup?.on?.('editorCreate', handleEditorCreate); superdocAtSetup?.on?.('pagination-update', handlePaginationUpdate); + superdocAtSetup?.on?.('pdf:document-ready', handlePdfDocumentReady); onBeforeUnmount(() => { superdocAtSetup?.off?.('editorCreate', handleEditorCreate); superdocAtSetup?.off?.('pagination-update', handlePaginationUpdate); + superdocAtSetup?.off?.('pdf:document-ready', handlePdfDocumentReady); }); return { evaluateViewport }; diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 4149ef052b..65db2726b7 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -2199,6 +2199,32 @@ describe('SuperDoc core', () => { warn.mockRestore(); }); + it('setZoomMode emits zoomChange for mode-only transitions and no-ops on the same mode', async () => { + createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + const zoomChangeSpy = vi.fn(); + instance.on('zoomChange', zoomChangeSpy); + + // Mode change with an unchanged value is observable. + instance.setZoomMode('fit-width'); + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 100, mode: 'fit-width' }); + expect(zoomChangeSpy).toHaveBeenCalledTimes(1); + + // Same-mode call is a no-op: no state churn, no event. + instance.setZoomMode('fit-width'); + expect(zoomChangeSpy).toHaveBeenCalledTimes(1); + + instance.setZoomMode('manual'); + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 100, mode: 'manual' }); + expect(zoomChangeSpy).toHaveBeenCalledTimes(2); + }); + it('getZoomState reports mode, value, fitZoom, and effective bounds', async () => { const { superdocStore } = createAppHarness(); diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 9da73f47ff..7fae4e6a71 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -2024,7 +2024,9 @@ export class SuperDoc extends EventEmitter { * document to the available container width (clamped by * `config.zoom.fitWidth`); `manual` holds the current value. * Switching to `fit-width` applies the fit immediately when - * viewport metrics are available. + * viewport metrics are available. Emits `zoomChange` (with the + * current value) so zoom UIs observe mode-only transitions; a + * same-mode call is a no-op. * @param mode - The zoom mode: `'manual'` or `'fit-width'` * @example * superdoc.setZoomMode('fit-width'); // start fitting to the container @@ -2035,9 +2037,11 @@ export class SuperDoc extends EventEmitter { console.warn("[SuperDoc] setZoomMode expects 'manual' or 'fit-width'"); return; } + if (this.superdocStore?.zoomMode === mode) return; if (this.superdocStore) { this.superdocStore.zoomMode = mode; } + this.emit('zoomChange', { zoom: this.getZoom(), mode }); } /** From 74d41d5c69e0ffc5f12f321c48cfaf3587d3d53b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 13:06:36 -0300 Subject: [PATCH 09/34] feat(ui): zoom domain, useSuperDocZoom, and zoom-fit-width command (SD-3294) Custom UI gets first-class zoom: ui.zoom exposes one slice (mode, value, fitZoom, bounds, viewport metrics) recomputed on zoomChange and viewport-change, with set(percent) and setMode passthroughs to the host. Hosts without the zoom surface degrade to a static manual/100 snapshot with no-op mutations. React mirrors it with useSuperDocZoom (slice plus bound actions), and the toolbar registry gains a zoom-fit-width toggle command so custom toolbars can offer Fit width without reaching for the host instance. The numeric zoom command is untouched. The built-in toolbar's Fit width affordance stays a follow-up: the state and command layer it needs ships here. --- apps/docs/editor/custom-ui/api-reference.mdx | 13 ++ .../src/headless-toolbar/helpers/document.ts | 22 +++ .../src/headless-toolbar/toolbar-registry.ts | 7 + .../src/headless-toolbar/types.ts | 3 + .../src/ui/create-super-doc-ui.test.ts | 140 ++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 98 +++++++++++- packages/super-editor/src/ui/index.ts | 4 + packages/super-editor/src/ui/react/hooks.ts | 55 ++++++- packages/super-editor/src/ui/react/index.ts | 1 + packages/super-editor/src/ui/types.ts | 104 ++++++++++++- packages/superdoc/src/public/ui-react.test.ts | 2 + packages/superdoc/src/public/ui-react.ts | 1 + packages/superdoc/src/public/ui.ts | 4 + packages/superdoc/src/ui-react.d.ts | 1 + packages/superdoc/src/ui-react.js | 1 + packages/superdoc/src/ui.d.ts | 4 + .../src/imports-ui-react.ts | 3 + tests/consumer-typecheck/src/imports-ui.ts | 6 + .../consumer-typecheck/src/superdoc-events.ts | 15 +- 19 files changed, 477 insertions(+), 7 deletions(-) diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx index b77ad697c4..b17a6bd77b 100644 --- a/apps/docs/editor/custom-ui/api-reference.mdx +++ b/apps/docs/editor/custom-ui/api-reference.mdx @@ -187,6 +187,19 @@ await ui.document.export({ exportType: ['docx'], commentsType: 'external', trigg await ui.document.replaceFile(file); ``` +### `ui.zoom` + +Zoom state, viewport metrics, and the two mutations. The snapshot updates on value changes, mode-only transitions, and viewport metric updates. + +```ts +ui.zoom.getSnapshot(); // { mode, value, fitZoom, min, max, metrics } +ui.zoom.observe((snapshot) => {}); +ui.zoom.set(125); // numeric zoom; switches the host to manual mode +ui.zoom.setMode('fit-width'); // continuous fit to the available width +``` + +In React, `useSuperDocZoom()` returns the same snapshot plus bound `set` / `setMode` actions. The toolbar registry also exposes a `zoom-fit-width` toggle command for custom toolbars. + ### `ui.selection` Live slice, capture, restore, painted geometry. diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts index 958f90e669..ff0023a1d0 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/document.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts @@ -144,6 +144,16 @@ export const createZoomStateDeriver = }; }; +export const createZoomFitWidthStateDeriver = + () => + ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { + const mode = typeof superdoc?.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined; + return { + active: mode === 'fit-width', + disabled: !context || typeof superdoc?.setZoomMode !== 'function', + }; + }; + export const createDocumentModeStateDeriver = () => ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { @@ -182,6 +192,18 @@ export const createZoomExecute = return true; }; +// Toggle fit-width mode. A second activation returns to manual at the +// current value, matching toolbar toggle conventions; numeric zoom stays +// on the separate `zoom` command. +export const createZoomFitWidthExecute = + () => + ({ superdoc }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { + if (typeof superdoc?.setZoomMode !== 'function') return false; + const mode = typeof superdoc.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined; + superdoc.setZoomMode(mode === 'fit-width' ? 'manual' : 'fit-width'); + return true; + }; + export const createDocumentModeExecute = () => ({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts index 3ea56a72d4..bd96c6a229 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts @@ -8,6 +8,8 @@ import { createRulerExecute, createRulerStateDeriver, createZoomExecute, + createZoomFitWidthExecute, + createZoomFitWidthStateDeriver, createZoomStateDeriver, } from './helpers/document.js'; import { @@ -187,6 +189,11 @@ export const createToolbarRegistry = (): Partial { expect(ok).toHaveBeenCalledTimes(2); }); }); + +describe('ui.zoom', () => { + let teardown: Array<() => void> = []; + + afterEach(() => { + teardown.forEach((fn) => fn()); + teardown = []; + }); + + const attachZoomSurface = (superdoc: ReturnType) => { + const zoomHost = { + state: { + mode: 'manual' as 'manual' | 'fit-width', + value: 100, + fitZoom: null as number | null, + min: 10, + max: 100, + }, + metrics: null as { availableWidth: number; documentWidth: number; fitZoom: number } | null, + setZoom: vi.fn(), + setZoomMode: vi.fn(), + }; + superdoc.getZoomState = vi.fn(() => ({ ...zoomHost.state })); + superdoc.getViewportMetrics = vi.fn(() => zoomHost.metrics); + superdoc.setZoom = zoomHost.setZoom; + superdoc.setZoomMode = zoomHost.setZoomMode; + return zoomHost; + }; + + it('degrades to a static manual/100 snapshot when the host lacks the zoom surface', () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + expect(ui.zoom.getSnapshot()).toEqual({ + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, + }); + // Mutations are no-ops, not crashes. + expect(() => ui.zoom.set(150)).not.toThrow(); + expect(() => ui.zoom.setMode('fit-width')).not.toThrow(); + }); + + it('snapshots the host zoom state and metrics', () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + zoomHost.state = { mode: 'fit-width', value: 84, fitZoom: 84, min: 25, max: 100 }; + zoomHost.metrics = { availableWidth: 685, documentWidth: 816, fitZoom: 84 }; + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + expect(ui.zoom.getSnapshot()).toEqual({ + mode: 'fit-width', + value: 84, + fitZoom: 84, + min: 25, + max: 100, + metrics: { availableWidth: 685, documentWidth: 816, fitZoom: 84 }, + }); + }); + + it('observes mode-only transitions via zoomChange', async () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0].mode).toBe('manual'); + + zoomHost.state = { ...zoomHost.state, mode: 'fit-width' }; + superdoc.fireSuperdoc('zoomChange', { zoom: 100, mode: 'fit-width' }); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb.mock.calls[1][0].mode).toBe('fit-width'); + expect(cb.mock.calls[1][0].value).toBe(100); + }); + + it('observes viewport metric updates via viewport-change', async () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + + zoomHost.state = { ...zoomHost.state, fitZoom: 74 }; + zoomHost.metrics = { availableWidth: 600, documentWidth: 816, fitZoom: 74 }; + superdoc.fireSuperdoc('viewport-change', zoomHost.metrics); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb.mock.calls[1][0].fitZoom).toBe(74); + expect(cb.mock.calls[1][0].metrics).toEqual({ availableWidth: 600, documentWidth: 816, fitZoom: 74 }); + }); + + it('does not re-fire observers when zoom state is unchanged', async () => { + const superdoc = makeSuperdocStub(); + attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + teardown.push(ui.zoom.observe(cb)); + expect(cb).toHaveBeenCalledTimes(1); + + // Unrelated recompute trigger with identical zoom state. + superdoc.fireSuperdoc('document-mode-change', { documentMode: 'viewing' }); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('routes set and setMode to the host zoom surface', () => { + const superdoc = makeSuperdocStub(); + const zoomHost = attachZoomSurface(superdoc); + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + ui.zoom.set(125); + expect(zoomHost.setZoom).toHaveBeenCalledWith(125); + + ui.zoom.setMode('fit-width'); + expect(zoomHost.setZoomMode).toHaveBeenCalledWith('fit-width'); + }); +}); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 21fe27bc96..11f61d9aed 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -68,6 +68,9 @@ import type { ViewportHandle, ViewportRect, ViewportRectResult, + ZoomHandle, + ZoomMode, + ZoomSlice, } from './types.js'; /** @@ -106,7 +109,7 @@ const EDITOR_EVENTS = [ */ const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'tracked-changes-changed'] as const; -const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; +const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange', 'viewport-change'] as const; /** * Presentation-editor events the controller listens to. These signal @@ -688,6 +691,61 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { * either field, but they do trigger computeState rebuilds). */ let documentMemo: { slice: DocumentSlice } | null = null; + let zoomMemo: { slice: ZoomSlice } | null = null; + + // Static fallback for hosts without the zoom surface (older builds, + // minimal stubs): manual mode at 100% with no metrics. + const FALLBACK_ZOOM_SLICE: ZoomSlice = Object.freeze({ + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, + }); + + // Read the host zoom state + metrics into one slice. Memoized on the + // field values (metrics by reference: the host stores a new object only + // when measurements change), so `shallowEqual` on `state.zoom` + // short-circuits `ui.zoom.observe` while nothing zoom-related changes. + const computeZoomSlice = (): ZoomSlice => { + if (typeof superdoc.getZoomState !== 'function') return FALLBACK_ZOOM_SLICE; + let state: ReturnType> | null = null; + try { + state = superdoc.getZoomState(); + } catch { + state = null; + } + if (!state) return FALLBACK_ZOOM_SLICE; + let metrics: ZoomSlice['metrics'] = null; + try { + metrics = superdoc.getViewportMetrics?.() ?? null; + } catch { + metrics = null; + } + const prev = zoomMemo?.slice; + if ( + prev && + prev.mode === state.mode && + prev.value === state.value && + prev.fitZoom === state.fitZoom && + prev.min === state.min && + prev.max === state.max && + prev.metrics === metrics + ) { + return prev; + } + const slice: ZoomSlice = { + mode: state.mode, + value: state.value, + fitZoom: state.fitZoom, + min: state.min, + max: state.max, + metrics, + }; + zoomMemo = { slice }; + return slice; + }; /** * Internal dirty flag. Flipped to `true` by any editor transaction @@ -901,6 +959,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { documentMode, document: documentSlice, selection: selectionSlice, + zoom: computeZoomSlice(), toolbar: { context: toolbarSnapshot.context, commands: builtInCommands } as ToolbarSnapshotSlice, comments: { total: commentsListCache.total, @@ -2188,6 +2247,42 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- ui.zoom ----------------------------------------------------------- + // One slice for zoom UIs (mode, value, fit zoom, bounds, metrics) plus + // the two mutations. Recomputes on the host's zoomChange (which now + // includes mode-only transitions) and viewport-change events; both are + // in SUPERDOC_EVENTS. + const zoom: ZoomHandle = { + getSnapshot: () => computeState().zoom, + observe(listener) { + return select((state) => state.zoom, shallowEqual).subscribe((snapshot) => { + try { + listener(snapshot); + } catch { + // see scheduleNotify + } + }); + }, + set(percent: number) { + const setter = superdoc.setZoom; + if (typeof setter !== 'function') return; + try { + setter.call(superdoc, percent); + } catch (err) { + console.error('[superdoc/ui] ui.zoom.set failed:', err); + } + }, + setMode(mode: ZoomMode) { + const setter = superdoc.setZoomMode; + if (typeof setter !== 'function') return; + try { + setter.call(superdoc, mode); + } catch (err) { + console.error('[superdoc/ui] ui.zoom.setMode failed:', err); + } + }, + }; + // Live scopes created via `ui.createScope()`. The controller's // `destroy()` cascades into every entry before tearing down its own // resources, so consumers do not need to call `scope.destroy()` @@ -2393,6 +2488,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { selection, viewport, document, + zoom, createScope: createScopeFn, destroy, }; diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 68e778013e..e3594e6773 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -148,4 +148,8 @@ export type { DocumentExportInput, DocumentHandle, DocumentSlice, + ZoomHandle, + ZoomMode, + ZoomSlice, + ZoomViewportMetrics, } from './types.js'; diff --git a/packages/super-editor/src/ui/react/hooks.ts b/packages/super-editor/src/ui/react/hooks.ts index 7d69ef7818..3608ad9a70 100644 --- a/packages/super-editor/src/ui/react/hooks.ts +++ b/packages/super-editor/src/ui/react/hooks.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { shallowEqual } from '../equality.js'; import type { CommentsSlice, @@ -8,6 +8,8 @@ import type { SelectionSlice, ToolbarSnapshotSlice, UIToolbarCommandState, + ZoomMode, + ZoomSlice, } from '../types.js'; import { useSuperDocSlice, useSuperDocUI } from './provider.js'; @@ -87,6 +89,57 @@ export function useSuperDocDocument(): DocumentSlice { return useSuperDocSlice((ui) => ui.select((state) => state.document, shallowEqual), EMPTY_DOCUMENT); } +const EMPTY_ZOOM: ZoomSlice = { + mode: 'manual', + value: 100, + fitZoom: null, + min: 10, + max: 100, + metrics: null, +}; + +/** + * Zoom state + actions for custom zoom UIs. The snapshot updates on + * value changes, mode-only transitions, and viewport metric updates; + * `set(percent)` switches the host to manual mode by contract, + * `setMode('fit-width')` re-enters automatic fitting. + * + * ```tsx + * const zoom = useSuperDocZoom(); + * return ( + * <> + * {zoom.value}% + * + * + * ); + * ``` + */ +export function useSuperDocZoom(): ZoomSlice & { + set: (percent: number) => void; + setMode: (mode: ZoomMode) => void; +} { + const ui = useSuperDocUI(); + const slice = useSuperDocSlice((controller) => controller.select((state) => state.zoom, shallowEqual), EMPTY_ZOOM); + const set = useCallback( + (percent: number) => { + ui?.zoom.set(percent); + }, + [ui], + ); + const setMode = useCallback( + (mode: ZoomMode) => { + ui?.zoom.setMode(mode); + }, + [ui], + ); + return { ...slice, set, setMode }; +} + const FALLBACK_COMMAND_STATE: UIToolbarCommandState = { active: false, disabled: true, diff --git a/packages/super-editor/src/ui/react/index.ts b/packages/super-editor/src/ui/react/index.ts index a285bb6c15..3f0ea99465 100644 --- a/packages/super-editor/src/ui/react/index.ts +++ b/packages/super-editor/src/ui/react/index.ts @@ -38,4 +38,5 @@ export { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, } from './hooks.js'; diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 805f82cdf5..7838e7bbad 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -41,7 +41,7 @@ export interface Subscribable { * `formatting-marks-change`); a custom UI host stub only has to * support the three events the UI controller actually consumes. */ -export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange'; +export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change'; /** * Structural typing for the SuperDoc instance. Keeps the UI controller @@ -68,6 +68,42 @@ export interface SuperDocLike { * browser test stubs stay valid without a host implementation. */ export?(options?: DocumentExportInput): Promise; + /** + * Optional zoom bridge consumed by `ui.zoom`. Mirrors the superdoc + * package's zoom surface (`setZoom` switches the mode to manual, + * `setZoomMode` toggles fitting, the getters snapshot state and + * viewport metrics). All optional so stubs and older hosts stay + * valid; `ui.zoom` degrades to a static manual/100 snapshot. + */ + setZoom?(percent: number): unknown; + setZoomMode?(mode: ZoomMode): unknown; + getZoomState?(): { + mode: ZoomMode; + value: number; + fitZoom: number | null; + min: number; + max: number; + }; + getViewportMetrics?(): ZoomViewportMetrics | null; +} + +/** + * Zoom mode mirrored from the superdoc package: `manual` holds the + * last-set value, `fit-width` continuously re-fits to the container. + */ +export type ZoomMode = 'manual' | 'fit-width'; + +/** + * Pure viewport measurements mirrored from the superdoc package's + * `viewport-change` payload / `getViewportMetrics()`. + */ +export interface ZoomViewportMetrics { + /** Width available to the document in pixels (container minus the comments sidebar). */ + availableWidth: number; + /** Document base page width in pixels at 100% zoom. */ + documentWidth: number; + /** Unclamped zoom percentage that fits the document in the available width. */ + fitZoom: number; } export interface SuperDocEditorLike { @@ -295,6 +331,33 @@ export interface SuperDocUIState { * document transactions; `activeIds` derives from the selection. */ contentControls: ContentControlsSlice; + /** + * Zoom slice. Sourced from the host's `getZoomState()` / + * `getViewportMetrics()` and recomputed on `zoomChange` / + * `viewport-change`. Hosts without the zoom surface yield a static + * manual/100 snapshot. + */ + zoom: ZoomSlice; +} + +/** + * Zoom snapshot exposed on `state.zoom` and through `ui.zoom`. Combines + * the host's zoom state (mode, value, fit bounds) with the latest + * viewport metrics so a zoom UI renders from one slice. + */ +export interface ZoomSlice { + /** Current zoom mode. */ + mode: ZoomMode; + /** Current zoom value as a percentage. */ + value: number; + /** Latest unclamped fit zoom, or `null` before the first viewport measurement. */ + fitZoom: number | null; + /** Effective lower bound of the fit-width policy. */ + min: number; + /** Effective upper bound of the fit-width policy. */ + max: number; + /** Latest viewport measurements, or `null` before editors mount. */ + metrics: ZoomViewportMetrics | null; } /** @@ -712,6 +775,17 @@ export interface SuperDocUI { */ document: DocumentHandle; + /** + * Zoom domain. One slice for zoom UIs (mode, value, fit zoom, + * bounds, viewport metrics) plus the two mutations: `set(percent)` + * (numeric zoom, switches the host to manual mode) and + * `setMode('fit-width' | 'manual')`. Sugar over `state.zoom` and + * passthroughs to the host's `setZoom` / `setZoomMode`; the slice + * recomputes on the host's `zoomChange` and `viewport-change` + * events, including mode-only transitions. + */ + zoom: ZoomHandle; + /** * Create a {@link SuperDocUIScope} for collecting subscriptions, * custom-command registrations, and DOM listeners under one @@ -949,6 +1023,34 @@ export interface DocumentHandle { replaceFile(file: File): Promise; } +/** + * Zoom domain handle (`ui.zoom`). Read / observe the {@link ZoomSlice} + * and mutate through the host's zoom surface. Hosts without zoom + * methods (older builds, minimal stubs) degrade gracefully: the slice + * is a static manual/100 snapshot and the mutations are no-ops. + */ +export interface ZoomHandle { + /** Current zoom snapshot. */ + getSnapshot(): ZoomSlice; + /** + * Subscribe to zoom snapshots. Fires on value changes, mode-only + * transitions, and viewport metric updates. Returns the unsubscribe + * function; pair with `scope.add(...)` for lifecycle handling. + */ + observe(listener: (snapshot: ZoomSlice) => void): () => void; + /** + * Set a numeric zoom percentage. Routes through `superdoc.setZoom`, + * which switches the mode to `manual` by contract. + */ + set(percent: number): void; + /** + * Switch the zoom mode. Routes through `superdoc.setZoomMode`; + * `'fit-width'` applies the fit immediately when viewport metrics + * are available. + */ + setMode(mode: ZoomMode): void; +} + /** * Selection domain handle exposed on `ui.selection`. Same shape as * `CommentsHandle` / `TrackChangesHandle`: snapshot + subscription. Mirrors diff --git a/packages/superdoc/src/public/ui-react.test.ts b/packages/superdoc/src/public/ui-react.test.ts index 8b30fcb3e1..0fb7179e83 100644 --- a/packages/superdoc/src/public/ui-react.test.ts +++ b/packages/superdoc/src/public/ui-react.test.ts @@ -4,6 +4,7 @@ import { useSuperDocUI, useSuperDocSelection, useSuperDocComments, + useSuperDocZoom, useSetSuperDoc, } from './ui-react.js'; @@ -25,6 +26,7 @@ describe('public facade (ui-react)', () => { it('re-exports domain hooks as functions', () => { expect(typeof useSuperDocSelection).toBe('function'); expect(typeof useSuperDocComments).toBe('function'); + expect(typeof useSuperDocZoom).toBe('function'); expect(typeof useSetSuperDoc).toBe('function'); }); }); diff --git a/packages/superdoc/src/public/ui-react.ts b/packages/superdoc/src/public/ui-react.ts index a3a0e1fd81..08866fd3bc 100644 --- a/packages/superdoc/src/public/ui-react.ts +++ b/packages/superdoc/src/public/ui-react.ts @@ -36,6 +36,7 @@ export { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, } from '@superdoc/super-editor/ui/react'; export type { SuperDocHost } from '@superdoc/super-editor/ui/react'; diff --git a/packages/superdoc/src/public/ui.ts b/packages/superdoc/src/public/ui.ts index f7ed92b0d1..25ad9f1917 100644 --- a/packages/superdoc/src/public/ui.ts +++ b/packages/superdoc/src/public/ui.ts @@ -113,4 +113,8 @@ export type { ViewportPositionHit, ViewportRect, ViewportRectResult, + ZoomHandle, + ZoomMode, + ZoomSlice, + ZoomViewportMetrics, } from '@superdoc/super-editor/ui'; diff --git a/packages/superdoc/src/ui-react.d.ts b/packages/superdoc/src/ui-react.d.ts index 0b7e46d222..99c112b9ca 100644 --- a/packages/superdoc/src/ui-react.d.ts +++ b/packages/superdoc/src/ui-react.d.ts @@ -11,5 +11,6 @@ export { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, type SuperDocHost, } from '@superdoc/super-editor/ui/react'; diff --git a/packages/superdoc/src/ui-react.js b/packages/superdoc/src/ui-react.js index dfa5258963..ce343acade 100644 --- a/packages/superdoc/src/ui-react.js +++ b/packages/superdoc/src/ui-react.js @@ -18,4 +18,5 @@ export { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, } from '@superdoc/super-editor/ui/react'; diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index 4f6faca482..8613d2525e 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -70,4 +70,8 @@ export { type ViewportPositionHit, type ViewportRect, type ViewportRectResult, + type ZoomHandle, + type ZoomMode, + type ZoomSlice, + type ZoomViewportMetrics, } from '@superdoc/super-editor/ui'; diff --git a/tests/consumer-typecheck/src/imports-ui-react.ts b/tests/consumer-typecheck/src/imports-ui-react.ts index 3ae928fb51..5f6d00fd18 100644 --- a/tests/consumer-typecheck/src/imports-ui-react.ts +++ b/tests/consumer-typecheck/src/imports-ui-react.ts @@ -21,6 +21,7 @@ import { useSuperDocToolbar, useSuperDocCommand, useSuperDocDocument, + useSuperDocZoom, } from 'superdoc/ui/react'; import type { SuperDocHost } from 'superdoc/ui/react'; @@ -38,6 +39,7 @@ const _real_useSuperDocTrackChanges: AssertNotAny = true; const _real_useSuperDocCommand: AssertNotAny = true; const _real_useSuperDocDocument: AssertNotAny = true; +const _real_useSuperDocZoom: AssertNotAny = true; const _real_SuperDocHost: AssertNotAny = true; @@ -52,4 +54,5 @@ void _real_useSuperDocTrackChanges; void _real_useSuperDocToolbar; void _real_useSuperDocCommand; void _real_useSuperDocDocument; +void _real_useSuperDocZoom; void _real_SuperDocHost; diff --git a/tests/consumer-typecheck/src/imports-ui.ts b/tests/consumer-typecheck/src/imports-ui.ts index 08e6022d19..39f737080a 100644 --- a/tests/consumer-typecheck/src/imports-ui.ts +++ b/tests/consumer-typecheck/src/imports-ui.ts @@ -33,6 +33,8 @@ import type { ViewportHandle, ViewportRect, DocumentHandle, + ZoomHandle, + ZoomSlice, DocumentSlice, // Document API shapes re-exported through ui CommentInfo, @@ -74,6 +76,8 @@ const _real_ViewportEntityAddress: AssertNotAny = true; const _real_ViewportHandle: AssertNotAny = true; const _real_ViewportRect: AssertNotAny = true; const _real_DocumentHandle: AssertNotAny = true; +const _real_ZoomHandle: AssertNotAny = true; +const _real_ZoomSlice: AssertNotAny = true; const _real_DocumentSlice: AssertNotAny = true; const _real_CommentInfo: AssertNotAny = true; @@ -109,6 +113,8 @@ void _real_ViewportEntityAddress; void _real_ViewportHandle; void _real_ViewportRect; void _real_DocumentHandle; +void _real_ZoomHandle; +void _real_ZoomSlice; void _real_DocumentSlice; void _real_CommentInfo; void _real_CommentsListResult; diff --git a/tests/consumer-typecheck/src/superdoc-events.ts b/tests/consumer-typecheck/src/superdoc-events.ts index 1e77adba7a..8b1ad8fa32 100644 --- a/tests/consumer-typecheck/src/superdoc-events.ts +++ b/tests/consumer-typecheck/src/superdoc-events.ts @@ -270,16 +270,23 @@ import { createSuperDocUI } from 'superdoc/ui'; void createHeadlessToolbar({ superdoc }); void createSuperDocUI({ superdoc }); -// Custom UI host stub typed precisely to the 3 events the UI +// Custom UI host stub typed precisely to the 4 events the UI // controller subscribes to must satisfy `SuperDocLike`. Pinning this // so a future widening of `SuperDocUIHostEvent` (e.g. re-adding // `formatting-marks-change`) doesn't silently regress this stub // shape: such a change would fail this assertion under strict // (property-syntax) variance, and would still be a precision loss -// even under TS method bivariance. +// even under TS method bivariance. `viewport-change` joined the set +// when `ui.zoom` started observing viewport metrics (SD-3294). declare const customUIHost: { - on?(event: 'editorCreate' | 'document-mode-change' | 'zoomChange', handler: (...args: unknown[]) => void): unknown; - off?(event: 'editorCreate' | 'document-mode-change' | 'zoomChange', handler: (...args: unknown[]) => void): unknown; + on?( + event: 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change', + handler: (...args: unknown[]) => void, + ): unknown; + off?( + event: 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change', + handler: (...args: unknown[]) => void, + ): unknown; }; void createSuperDocUI({ superdoc: customUIHost }); From 4ae1d4fbd725d3da2f62f8a9500bbe343eeb8b15 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 15:15:47 -0300 Subject: [PATCH 10/34] test(superdoc): cover pdf normalization, widest-page, and fit-width command (SD-3294) Extracts the pdf measurement math into a pure helper (normalizePdfPageMeasurement) and locks it directly: scale-relative conversion back to CSS px at 100%, the zoom-fallback path, and the zoom-desync case where a seeded zoom has not reached the viewer yet. Component tests cover the widest-page rule across mixed-orientation documents and the pdf DOM path with a stubbed scale factor. Registry tests lock zoom-fit-width active/disabled state and the fit-width/manual toggle. --- .../headless-toolbar/toolbar-registry.test.ts | 69 ++++++++++++++++++ packages/superdoc/src/SuperDoc.test.js | 72 +++++++++++++++++++ .../src/composables/use-viewport-fit.js | 41 ++++++----- .../src/composables/use-viewport-fit.test.js | 26 +++++++ 4 files changed, 189 insertions(+), 19 deletions(-) diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 541e886596..dedc6fe3b2 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -986,6 +986,75 @@ describe('createToolbarRegistry', () => { }); }); + it('derives zoom-fit-width active state from the host zoom mode', () => { + const registry = createToolbarRegistry(); + + const fitActive = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: { + getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })), + setZoomMode: vi.fn(), + }, + }); + expect(fitActive).toEqual({ active: true, disabled: false }); + + const manual = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: { + getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })), + setZoomMode: vi.fn(), + }, + }); + expect(manual).toEqual({ active: false, disabled: false }); + }); + + it('disables zoom-fit-width without a context or a setZoomMode host bridge', () => { + const registry = createToolbarRegistry(); + + const noContext = registry['zoom-fit-width']?.state({ + context: null, + superdoc: { setZoomMode: vi.fn(), getZoomState: vi.fn(() => ({ mode: 'manual' })) }, + }); + expect(noContext?.disabled).toBe(true); + + const noBridge = registry['zoom-fit-width']?.state({ + context: createContext(), + superdoc: {}, + }); + expect(noBridge?.disabled).toBe(true); + }); + + it('zoom-fit-width execute toggles between fit-width and manual', () => { + const registry = createToolbarRegistry(); + + const setZoomMode = vi.fn(); + const fromManual = registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: { + setZoomMode, + getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })), + }, + }); + expect(fromManual).toBe(true); + expect(setZoomMode).toHaveBeenCalledWith('fit-width'); + + const setZoomModeBack = vi.fn(); + registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: { + setZoomMode: setZoomModeBack, + getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })), + }, + }); + expect(setZoomModeBack).toHaveBeenCalledWith('manual'); + + const noBridge = registry['zoom-fit-width']?.execute?.({ + context: createContext(), + superdoc: {}, + }); + expect(noBridge).toBe(false); + }); + it('enables track-changes accept-selection when selection contains tracked changes and action is allowed', () => { collectTrackedChangesMock.mockReturnValueOnce([{ id: 'tc-1' }]); isTrackedChangeActionAllowedMock.mockReturnValueOnce(true); diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 43be743907..c5090f78c0 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -3161,5 +3161,77 @@ describe('SuperDoc.vue', () => { expect(superdocStoreStub.activeZoom.value).toBe(74); expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]); }); + + it('resolves documentWidth as the widest page across documents', async () => { + const superdocStub = createSuperdocStub(); + + const wrapper = await mountComponent(superdocStub); + // Two DOCX documents: portrait letter (8.5in) and landscape (11in). + // Zoom is global, so the fit must target the widest page. + superdocStoreStub.documents.value = [ + { + id: 'doc-portrait', + type: DOCX, + editorMountNonce: ref(0), + getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 8.5, height: 11 } }) })), + setEditor: vi.fn(), + }, + { + id: 'doc-landscape', + type: DOCX, + editorMountNonce: ref(0), + getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 11, height: 8.5 } }) })), + setEditor: vi.fn(), + }, + ]; + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + // 11in * 96 = 1056: the widest page wins over 816. + expect(calls[0][1].documentWidth).toBe(1056); + expect(calls[0][1].fitZoom).toBe(114); + }); + + it('resolves PDF page width scale-relatively (zoom-sync state cannot corrupt the base)', async () => { + const superdocStub = createSuperdocStub(); + // No DOCX editor: the PDF page is the only measurable document. + + const wrapper = await mountComponent(superdocStub); + + // A rendered 612pt PDF page at 50% viewer zoom measures 408px with + // --scale-factor 2/3 (zoom * 96/72). The store zoom claims 100% (a + // seeded zoom the viewer has not applied yet); the resolver must + // trust the page's actual scale factor and report 816 regardless. + const pageEl = document.createElement('div'); + pageEl.className = 'sd-pdf-viewer-page'; + Object.defineProperty(pageEl, 'clientWidth', { configurable: true, value: 408 }); + wrapper.find('.superdoc').element.appendChild(pageEl); + + const originalGetComputedStyle = window.getComputedStyle.bind(window); + const computedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => { + if (el === pageEl) { + return { getPropertyValue: (prop) => (prop === '--scale-factor' ? `${2 / 3}` : '') }; + } + return originalGetComputedStyle(el, pseudo); + }); + + try { + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBeCloseTo(816, 6); + expect(calls[0][1].fitZoom).toBe(147); + } finally { + computedStyleSpy.mockRestore(); + } + }); }); }); diff --git a/packages/superdoc/src/composables/use-viewport-fit.js b/packages/superdoc/src/composables/use-viewport-fit.js index 230f254d02..e332104de6 100644 --- a/packages/superdoc/src/composables/use-viewport-fit.js +++ b/packages/superdoc/src/composables/use-viewport-fit.js @@ -46,6 +46,23 @@ export const computeAppliedFitZoom = (availableWidth, documentWidth, options) => return Math.round(Math.min(options.max, Math.max(options.min, padded))); }; +const PDF_POINTS_TO_CSS_PX = 96 / 72; + +// One measured PDF page width back to CSS px at 100% zoom. PDF pages size +// via `calc(var(--scale-factor) * px)` where the scale factor is the +// viewer zoom times the pt-to-CSS-px conversion (96/72), so dividing by the +// page's actual scale factor yields PDF points regardless of zoom-sync +// state, and multiplying by 96/72 converts back to CSS px at 100% zoom +// (verified live: a 612pt letter page renders 816 CSS px at 100%). Without +// a readable scale factor, fall back to dividing out the assumed zoom. +export const normalizePdfPageMeasurement = (measured, scaleFactor, zoomFactor) => { + if (!(measured > 0)) return null; + if (Number.isFinite(scaleFactor) && scaleFactor > 0) { + return (measured / scaleFactor) * PDF_POINTS_TO_CSS_PX; + } + return zoomFactor > 0 ? measured / zoomFactor : measured; +}; + /** * Viewport fit tracking. Maintains pure viewport metrics (available width, * document base width, fit zoom), stores them for `getViewportMetrics()`, @@ -100,35 +117,21 @@ export function useViewportFit({ return null; }; - // Widest rendered PDF page in CSS px at 100% zoom. PDF pages size via - // `calc(var(--scale-factor) * px)` where the scale factor is the - // viewer zoom times the pt-to-CSS-px conversion (96/72). Dividing the - // measured width by the page's actual rendered scale factor yields PDF - // points regardless of zoom-sync state; multiplying by 96/72 converts - // back to the CSS width at 100% zoom (verified: a 612pt letter page - // renders 816 CSS px at 100%). + // Widest rendered PDF page in CSS px at 100% zoom. See + // `normalizePdfPageMeasurement` for the unit handling. const resolvePdfPageWidth = () => { const root = superdocRoot.value; if (!root?.querySelectorAll) return null; - const PDF_POINTS_TO_CSS_PX = 96 / 72; let widest = 0; for (const page of root.querySelectorAll(PDF_PAGE_SELECTOR)) { const measured = Number(page.clientWidth) || Number(page.getBoundingClientRect?.().width) || 0; - if (!(measured > 0)) continue; let scaleFactor = NaN; if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') { scaleFactor = Number.parseFloat(window.getComputedStyle(page).getPropertyValue('--scale-factor')); } - let normalized; - if (Number.isFinite(scaleFactor) && scaleFactor > 0) { - normalized = (measured / scaleFactor) * PDF_POINTS_TO_CSS_PX; - } else { - // No scale-factor var: assume the viewer is synced to the global - // zoom and divide that out instead. - const zoomFactor = (activeZoom.value ?? 100) / 100; - normalized = zoomFactor > 0 ? measured / zoomFactor : measured; - } - if (normalized > widest) widest = normalized; + const zoomFactor = (activeZoom.value ?? 100) / 100; + const normalized = normalizePdfPageMeasurement(measured, scaleFactor, zoomFactor); + if (normalized !== null && normalized > widest) widest = normalized; } return widest > 0 ? widest : null; }; diff --git a/packages/superdoc/src/composables/use-viewport-fit.test.js b/packages/superdoc/src/composables/use-viewport-fit.test.js index 601cb4cb97..8af8e3b2a2 100644 --- a/packages/superdoc/src/composables/use-viewport-fit.test.js +++ b/packages/superdoc/src/composables/use-viewport-fit.test.js @@ -4,6 +4,7 @@ import { resolveFitWidthOptions, computeFitZoom, computeAppliedFitZoom, + normalizePdfPageMeasurement, } from './use-viewport-fit.js'; // Full wiring (watchers, metric storage, emit dedup, mode-driven fit @@ -89,3 +90,28 @@ describe('computeAppliedFitZoom', () => { expect(computeAppliedFitZoom(90, 816, { ...options, padding: 96 })).toBeNull(); }); }); + +describe('normalizePdfPageMeasurement', () => { + const PT_TO_PX = 96 / 72; + + it('converts a rendered page back to CSS px at 100% zoom via the scale factor', () => { + // 612pt letter page at 100% zoom renders 816 CSS px (scale factor 4/3). + expect(normalizePdfPageMeasurement(816, PT_TO_PX, 1)).toBeCloseTo(816, 6); + // Same page at 50% zoom renders 408 px with scale factor 2/3. + expect(normalizePdfPageMeasurement(408, (2 / 3) * 1, 0.5)).toBeCloseTo(816, 6); + // Zoom-sync state is irrelevant when the scale factor is readable: + // a seeded zoom the viewer has not applied yet cannot corrupt the base. + expect(normalizePdfPageMeasurement(816, PT_TO_PX, 0.5)).toBeCloseTo(816, 6); + }); + + it('falls back to dividing out the assumed zoom without a scale factor', () => { + expect(normalizePdfPageMeasurement(408, NaN, 0.5)).toBeCloseTo(816, 6); + expect(normalizePdfPageMeasurement(816, 0, 1)).toBeCloseTo(816, 6); + }); + + it('returns null for unmeasurable pages', () => { + expect(normalizePdfPageMeasurement(0, PT_TO_PX, 1)).toBeNull(); + expect(normalizePdfPageMeasurement(-5, PT_TO_PX, 1)).toBeNull(); + expect(normalizePdfPageMeasurement(NaN, PT_TO_PX, 1)).toBeNull(); + }); +}); From 90b768d900bb8caf5d31cfd29bfe78f99d5e2d48 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 15:15:49 -0300 Subject: [PATCH 11/34] chore: refresh host-event prose and export snapshots after base merge (SD-3294) The UI host-event comment said three events; viewport-change made it four. Root export snapshots regenerate for the union of this branch's zoom types and the font types the base brought in from main. --- packages/super-editor/src/ui/types.ts | 8 +- .../snapshots/superdoc-root-exports.json | 29 +++- .../snapshots/superdoc-root-exports.md | 162 ++++++++++-------- 3 files changed, 117 insertions(+), 82 deletions(-) diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 405bbc4242..8be299382b 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -36,10 +36,10 @@ export interface Subscribable { /** * Event names the UI controller (`createSuperDocUI`) subscribes to on - * a SuperDoc-like host. Narrower than - * `HeadlessToolbarSuperdocHostEvent` (which adds - * `formatting-marks-change`); a custom UI host stub only has to - * support the three events the UI controller actually consumes. + * a SuperDoc-like host. Differs from `HeadlessToolbarSuperdocHostEvent` + * (which adds `formatting-marks-change` but not `viewport-change`); a + * custom UI host stub only has to support the four events the UI + * controller actually consumes. */ export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change'; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index 791d719abc..b55d8d7dae 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-02T20:51:59.655Z", + "generatedAt": "2026-06-05T18:14:02.432Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -183,6 +183,7 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocFontFace", "SuperDocFontFamily", "SuperDocFontsApi", @@ -191,6 +192,12 @@ "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SuperEditor", "SuperInput", "SuperToolbar", @@ -418,6 +425,7 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocFontFace", "SuperDocFontFamily", "SuperDocFontsApi", @@ -426,6 +434,12 @@ "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SuperEditor", "SuperInput", "SuperToolbar", @@ -577,11 +591,11 @@ } }, "counts": { - "types.import": 229, - "types.require": 229, + "types.import": 236, + "types.require": 236, "import": 41, "require": 41, - "union": 229 + "union": 236 }, "divergences": { "typesImportVsRequire": { @@ -747,6 +761,7 @@ "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", + "SuperDocFitWidthOptions", "SuperDocFontFace", "SuperDocFontFamily", "SuperDocFontsApi", @@ -755,6 +770,12 @@ "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", + "SuperDocViewportChangePayload", + "SuperDocViewportMetrics", + "SuperDocZoomConfig", + "SuperDocZoomMode", + "SuperDocZoomPayload", + "SuperDocZoomState", "SurfaceComponentProps", "SurfaceFloatingPlacement", "SurfaceHandle", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index 515952b933..01ec7ff81a 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-06-02T20:51:59.655Z +Generated: 2026-06-05T18:14:02.432Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 229 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 229 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 236 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 236 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **229** | +| **union** | | **236** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 188 +- typed but no runtime export (phantom risk): 195 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -177,6 +177,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `SuperDocExceptionPayload` - `SuperDocExceptionRestorePayload` - `SuperDocExceptionStorePayload` +- `SuperDocFitWidthOptions` - `SuperDocFontFace` - `SuperDocFontFamily` - `SuperDocFontsApi` @@ -185,6 +186,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `SuperDocReadyPayload` - `SuperDocState` - `SuperDocTelemetryConfig` +- `SuperDocViewportChangePayload` +- `SuperDocViewportMetrics` +- `SuperDocZoomConfig` +- `SuperDocZoomMode` +- `SuperDocZoomPayload` +- `SuperDocZoomState` - `SurfaceComponentProps` - `SurfaceFloatingPlacement` - `SurfaceHandle` @@ -217,16 +224,16 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | Name | dts | dcts | esm | cjs | fixtures | jsdoc | docs | examples | demos | boundaries | |---|---|---|---|---|---|---|---|---|---|---| -| `AIWriter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 4 | 4 | | -| `AnnotatorHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | +| `AIWriter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 4 | | +| `AnnotatorHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | | `AwarenessState` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `AwarenessUser` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `BinaryData` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `BlankDOCX` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 1 | 1 | | +| `BlankDOCX` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 0 | 1 | | | `BlockNavigationAddress` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `BlocksListResult` | ✓ | ✓ | | | 2 | ✓ | 1 | 1 | 1 | ✓ | -| `BookmarkAddress` | ✓ | ✓ | | | 1 | ✓ | 0 | 1 | 1 | | -| `BookmarkInfo` | ✓ | ✓ | | | 2 | ✓ | 1 | 1 | 1 | ✓ | +| `BlocksListResult` | ✓ | ✓ | | | 2 | ✓ | 1 | 0 | 1 | ✓ | +| `BookmarkAddress` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 1 | | +| `BookmarkInfo` | ✓ | ✓ | | | 2 | ✓ | 1 | 0 | 1 | ✓ | | `BoundingRect` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `CanObject` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `CanPerformPermissionParams` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -234,53 +241,53 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `ChainedCommand` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `CollaborationConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CollaborationProvider` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 1 | 8 | ✓ | +| `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 0 | 8 | ✓ | | `CommandProps` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | -| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 48 | 45 | | -| `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 3 | 3 | | +| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 3 | 45 | | +| `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 3 | | | `CommentConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentElement` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `CommentLocationsPayload` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `CommentsPluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 1 | ✓ | +| `CommentsPluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `CommentsType` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `Config` | ✓ | ✓ | | | 8 | ✓ | 2 | 1 | 2 | ✓ | | `ContentControlActiveChangePayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `ContentControlClickPayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | -| `ContextMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 7 | 23 | 31 | | +| `ContextMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 7 | 0 | 31 | | | `ContextMenuConfig` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ContextMenuContext` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ContextMenuItem` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 5 | | | `ContextMenuSection` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CoreCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ | -| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 151 | 32 | 55 | ✓ | +| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 157 | 24 | 55 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Document` | ✓ | ✓ | | | 2 | | 290 | 98 | 110 | ✓ | +| `Document` | ✓ | ✓ | | | 2 | | 291 | 56 | 110 | ✓ | | `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | -| `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 17 | 3 | | -| `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 1 | 1 | | +| `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | +| `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `DocxZipper` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 1 | ✓ | -| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 195 | 38 | 69 | ✓ | +| `DocxZipper` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | +| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 195 | 20 | 69 | ✓ | | `EditorCommands` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `EditorEventMap` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorExtension` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorLifecycleState` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 2 | | -| `EditorState` | ✓ | ✓ | | | 4 | ✓ | 7 | 1 | 1 | ✓ | +| `EditorState` | ✓ | ✓ | | | 4 | ✓ | 7 | 0 | 1 | ✓ | | `EditorSurface` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorTransactionEvent` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorUpdateEvent` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `EditorView` | ✓ | ✓ | | | 4 | ✓ | 2 | 0 | 0 | ✓ | -| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 276 | 11 | 8 | | +| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 276 | 0 | 8 | | | `ExportDocxParams` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ExportFormat` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ExportOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ExportParams` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ExportType` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ExtensionCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ | -| `Extensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 14 | 7 | 3 | ✓ | +| `Extensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 14 | 6 | 3 | ✓ | | `ExternalPopoverRenderContext` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `ExternalSurfaceRenderContext` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `FieldValue` | ✓ | ✓ | | | 1 | ✓ | 7 | 0 | 0 | | @@ -291,20 +298,20 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `FindReplaceResolution` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `FlowBlock` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `FlowMode` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `FontAssetUrlContext` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | -| `FontAssetUrlResolver` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | +| `FontAssetUrlContext` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `FontAssetUrlResolver` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `FontConfig` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `FontFaceConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontFamilyConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `FontResolutionRecord` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | -| `FontsChangedPayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | -| `FontsConfig` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | +| `FontResolutionRecord` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `FontsChangedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `FontsConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `FontsResolvedPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 85 | 157 | 202 | | +| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 87 | 12 | 202 | | | `ImageDeselectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ImageSelectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `IntentSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Layout` | ✓ | ✓ | | | 3 | ✓ | 9 | 22 | 22 | ✓ | +| `Layout` | ✓ | ✓ | | | 3 | ✓ | 9 | 0 | 22 | ✓ | | `LayoutEngineOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `LayoutError` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `LayoutFragment` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -317,11 +324,11 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `LinkPopoverResolution` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `LinkPopoverResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ListDefinitionsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `Measure` | ✓ | ✓ | | | 3 | ✓ | 0 | 1 | 1 | | +| `Measure` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 1 | | | `Modules` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 0 | | | `NavigableAddress` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `OpenOptions` | ✓ | ✓ | | | 3 | ✓ | 1 | 0 | 0 | | -| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 35 | 1 | 1 | ✓ | +| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 37 | 0 | 1 | ✓ | | `PageMargins` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageSize` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageStyles` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -340,7 +347,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `PermissionResolverParams` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `PositionHit` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PresenceOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `PresentationEditor` | ✓ | ✓ | ✓ | ✓ | 3 | | 0 | 44 | 40 | ✓ | +| `PresentationEditor` | ✓ | ✓ | ✓ | ✓ | 3 | | 0 | 0 | 40 | ✓ | | `PresentationEditorOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ProofingCapabilities` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ProofingCheckRequest` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -359,26 +366,26 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `RemoteCursorState` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `RemoteCursorsRenderPayload` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `RemoteUserInfo` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `ResolveRangeOutput` | ✓ | ✓ | | | 3 | ✓ | 1 | 1 | 1 | | +| `ResolveRangeOutput` | ✓ | ✓ | | | 3 | ✓ | 1 | 0 | 1 | | | `ResolvedFindReplaceTexts` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `ResolvedPasswordPromptTexts` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `SaveOptions` | ✓ | ✓ | | | 4 | ✓ | 1 | 0 | 0 | | -| `Schema` | ✓ | ✓ | | | 4 | ✓ | 5 | 4 | 4 | ✓ | +| `Schema` | ✓ | ✓ | | | 4 | ✓ | 5 | 0 | 4 | ✓ | | `ScrollIntoViewInput` | ✓ | ✓ | | | 2 | ✓ | 1 | 0 | 0 | | | `ScrollIntoViewOutput` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SdtRef` | ✓ | ✓ | | | 0 | | 6 | 0 | 0 | | | `SearchMatch` | ✓ | ✓ | | | 2 | ✓ | 3 | 0 | 0 | | -| `SectionHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | +| `SectionHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | | `SectionMetadata` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionApi` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SelectionCommandContext` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionCurrentInput` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SelectionHandle` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 2 | 1 | | -| `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | -| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 10 | 3 | | -| `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1034 | 233 | 249 | ✓ | +| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 0 | 1 | | +| `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | +| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | +| `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 187 | 250 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -386,17 +393,24 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SuperDocExceptionPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionRestorePayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `SuperDocExceptionStorePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocFitWidthOptions` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `SuperDocFontFace` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocFontFamily` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `SuperDocFontsApi` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `SuperDocFontsApi` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocLayoutEngineOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SuperDocLockedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 2 | 0 | 0 | | | `SuperDocState` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocTelemetryConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `SuperEditor` | ✓ | ✓ | ✓ | ✓ | 1 | | 16 | 3 | 5 | | -| `SuperInput` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 2 | 2 | | -| `SuperToolbar` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 4 | ✓ | +| `SuperDocViewportChangePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocViewportMetrics` | ✓ | ✓ | | | 2 | | 1 | 0 | 0 | | +| `SuperDocZoomConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `SuperDocZoomMode` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `SuperDocZoomPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocZoomState` | ✓ | ✓ | | | 2 | | 1 | 0 | 0 | | +| `SuperEditor` | ✓ | ✓ | ✓ | ✓ | 1 | | 16 | 0 | 5 | | +| `SuperInput` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 2 | | +| `SuperToolbar` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 4 | ✓ | | `SurfaceComponentProps` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SurfaceFloatingPlacement` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SurfaceHandle` | ✓ | ✓ | | | 2 | ✓ | 2 | 0 | 0 | | @@ -407,42 +421,42 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SurfaceResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SurfacesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TelemetryEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 10 | 7 | | -| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 2 | 4 | | -| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 8 | 10 | | -| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 12 | 15 | | +| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 0 | 7 | | +| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 0 | 4 | | +| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 0 | 10 | | +| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 7 | 15 | | | `TrackChangeAuthor` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TrackChangesAuthorColorsConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 1 | ✓ | +| `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `TrackChangesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 13 | 3 | 3 | | +| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 13 | 0 | 3 | | | `TrackedChangesMode` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `TrackedChangesOverrides` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Transaction` | ✓ | ✓ | | | 3 | ✓ | 5 | 0 | 0 | ✓ | | `UnsupportedContentItem` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `UpgradeToCollaborationOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `User` | ✓ | ✓ | | | 7 | ✓ | 52 | 9 | 30 | | +| `User` | ✓ | ✓ | | | 7 | ✓ | 52 | 8 | 30 | | | `ViewLayout` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ViewOptions` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `ViewingVisibilityConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `VirtualizationOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `assertNodeType` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 1 | 1 | ✓ | -| `buildTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 4 | 1 | 1 | | -| `compareVersions` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 1 | 1 | | -| `createTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 21 | 9 | 1 | | -| `createZip` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 1 | ✓ | -| `defineMark` | ✓ | ✓ | ✓ | ✓ | 2 | | 3 | 1 | 1 | ✓ | -| `defineNode` | ✓ | ✓ | ✓ | ✓ | 2 | | 4 | 1 | 1 | ✓ | -| `fieldAnnotationHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 1 | 3 | | -| `getActiveFormatting` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 2 | 2 | | -| `getAllowedImageDimensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 1 | 1 | | -| `getFileObject` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 1 | 7 | | -| `getMarksFromSelection` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 2 | 2 | | -| `getRichTextExtensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 1 | 1 | 1 | ✓ | -| `getSchemaIntrospection` | ✓ | ✓ | ✓ | ✓ | 0 | | 3 | 1 | 1 | | -| `getStarterExtensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 8 | 3 | 5 | ✓ | -| `isMarkType` | ✓ | ✓ | ✓ | ✓ | 2 | | 2 | 1 | 1 | ✓ | -| `isNodeType` | ✓ | ✓ | ✓ | ✓ | 2 | | 2 | 1 | 1 | ✓ | -| `registeredHandlers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | -| `superEditorHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | -| `trackChangesHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 1 | 1 | | +| `assertNodeType` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 1 | ✓ | +| `buildTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 4 | 0 | 1 | | +| `compareVersions` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 0 | 1 | | +| `createTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 21 | 8 | 1 | | +| `createZip` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | +| `defineMark` | ✓ | ✓ | ✓ | ✓ | 2 | | 3 | 0 | 1 | ✓ | +| `defineNode` | ✓ | ✓ | ✓ | ✓ | 2 | | 4 | 0 | 1 | ✓ | +| `fieldAnnotationHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 3 | | +| `getActiveFormatting` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 2 | | +| `getAllowedImageDimensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | | +| `getFileObject` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 0 | 7 | | +| `getMarksFromSelection` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 2 | | +| `getRichTextExtensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 1 | 0 | 1 | ✓ | +| `getSchemaIntrospection` | ✓ | ✓ | ✓ | ✓ | 0 | | 3 | 0 | 1 | | +| `getStarterExtensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 8 | 2 | 5 | ✓ | +| `isMarkType` | ✓ | ✓ | ✓ | ✓ | 2 | | 2 | 0 | 1 | ✓ | +| `isNodeType` | ✓ | ✓ | ✓ | ✓ | 2 | | 2 | 0 | 1 | ✓ | +| `registeredHandlers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | +| `superEditorHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | +| `trackChangesHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | From 4192222340a87eb7c3369f31d5695d8adad9496d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 15:29:30 -0300 Subject: [PATCH 12/34] chore: restore base-side bytes the merge format hook rewrote (SD-3294) The pre-commit format hook ran over the merge commit's full staged set and prettified 15 generated and upstream files this branch never touches (mcp catalog, document-api templates, font-system, sdk dispatch). A clean merge takes the base side verbatim for files only one side changed; restore those bytes so the PR diff carries zoom work only. Committed with hooks disabled so the formatter does not reintroduce the drift. --- apps/mcp/src/generated/catalog.ts | 8357 +++++++++-------- .../generated/intent-dispatch.generated.ts | 207 +- .../src/contract/contract.test.ts | 6 +- packages/document-api/src/contract/schemas.ts | 8 +- .../document-api/src/templates/apply.test.ts | 6 +- packages/document-api/src/templates/apply.ts | 6 +- .../sdk/langs/browser/src/intent-dispatch.ts | 207 +- .../contract-conformance.test.ts | 8 +- .../templates/template-assets.ts | 23 +- .../templates/template-xml.ts | 20 +- .../templates-adapter.integration.test.ts | 23 +- .../templates/templates-adapter.ts | 16 +- shared/font-system/src/resolver.test.ts | 4 +- .../src/substitution-evidence.test.ts | 4 +- .../font-system/src/substitution-evidence.ts | 5 +- 15 files changed, 4782 insertions(+), 4118 deletions(-) diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 7b994255c4..b40215a3fe 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1,5192 +1,6021 @@ // Auto-generated from packages/sdk/tools/catalog.json // Do not edit manually — re-run generate:all to update. export const MCP_TOOL_CATALOG = { - contractVersion: '0.1.0', - generatedAt: null, - toolCount: 10, - tools: [ + "contractVersion": "0.1.0", + "generatedAt": null, + "toolCount": 10, + "tools": [ { - toolName: 'superdoc_get_content', - description: - 'Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action "blocks" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action "blocks" with includeText:true so you can identify the correct block and then target it by nodeId. Action "text" and "markdown" return the full document as plain text or Markdown. Action "html" returns HTML. Action "info" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The "blocks" action supports pagination via "offset" and "limit", and filtering via "nodeTypes". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {"action":"blocks"}\n 2. {"action":"blocks","includeText":true,"offset":0,"limit":20}\n 3. {"action":"blocks","offset":0,"limit":20,"nodeTypes":["heading","paragraph"]}\n 4. {"action":"text"}\n 5. {"action":"info"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['blocks', 'extract', 'html', 'info', 'markdown', 'text'], - description: 'The action to perform. One of: blocks, extract, html, info, markdown, text.', - }, - unflattenLists: { - type: 'boolean', - description: - "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions.", - }, - offset: { - type: 'number', - minimum: 0, - description: "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions.", - }, - limit: { - type: 'number', - minimum: 1, - description: - "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions.", - }, - nodeTypes: { - type: 'array', - items: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "toolName": "superdoc_get_content", + "description": "Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action \"blocks\" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action \"blocks\" with includeText:true so you can identify the correct block and then target it by nodeId. Action \"text\" and \"markdown\" return the full document as plain text or Markdown. Action \"html\" returns HTML. Action \"info\" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The \"blocks\" action supports pagination via \"offset\" and \"limit\", and filtering via \"nodeTypes\". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {\"action\":\"blocks\"}\n 2. {\"action\":\"blocks\",\"includeText\":true,\"offset\":0,\"limit\":20}\n 3. {\"action\":\"blocks\",\"offset\":0,\"limit\":20,\"nodeTypes\":[\"heading\",\"paragraph\"]}\n 4. {\"action\":\"text\"}\n 5. {\"action\":\"info\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "blocks", + "extract", + "html", + "info", + "markdown", + "text" + ], + "description": "The action to perform. One of: blocks, extract, html, info, markdown, text." + }, + "unflattenLists": { + "type": "boolean", + "description": "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions." + }, + "offset": { + "type": "number", + "minimum": 0, + "description": "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions." + }, + "limit": { + "type": "number", + "minimum": 1, + "description": "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions." + }, + "nodeTypes": { + "type": "array", + "items": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, - description: - "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions.", - }, - includeText: { - type: 'boolean', - description: - "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions.", + "description": "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions." }, + "includeText": { + "type": "boolean", + "description": "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.getText', - intentAction: 'text', + "operationId": "doc.getText", + "intentAction": "text" }, { - operationId: 'doc.getMarkdown', - intentAction: 'markdown', + "operationId": "doc.getMarkdown", + "intentAction": "markdown" }, { - operationId: 'doc.getHtml', - intentAction: 'html', + "operationId": "doc.getHtml", + "intentAction": "html" }, { - operationId: 'doc.info', - intentAction: 'info', + "operationId": "doc.info", + "intentAction": "info" }, { - operationId: 'doc.extract', - intentAction: 'extract', + "operationId": "doc.extract", + "intentAction": "extract" }, { - operationId: 'doc.blocks.list', - intentAction: 'blocks', - }, - ], + "operationId": "doc.blocks.list", + "intentAction": "blocks" + } + ] }, { - toolName: 'superdoc_edit', - description: - 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"undo"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['delete', 'insert', 'redo', 'replace', 'undo'], - description: 'The action to perform. One of: delete, insert, redo, replace, undo.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - target: { - oneOf: [ + "toolName": "superdoc_edit", + "description": "The primary tool for inserting content into documents. ALWAYS use action \"insert\" with type \"markdown\" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with \"target\" (a BlockNodeAddress like {kind:\"block\", nodeType, nodeId}) and \"placement\" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts \"inline\" (fontFamily, fontSize, bold, underline, color), \"alignment\", and \"scope\" in the same step. Use scope: \"block\" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a \"ref\" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:\"block\", nodeType, nodeId} from superdoc_get_content action \"blocks\" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports \"dryRun\" to preview changes and \"changeMode: tracked\" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build \"target\" objects manually when a ref is available; prefer \"ref\" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {\"action\":\"insert\",\"type\":\"markdown\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"placement\":\"before\",\"value\":\"# Executive Summary\\n\\nThis agreement sets forth the principal terms...\"}\n 2. {\"action\":\"insert\",\"type\":\"markdown\",\"value\":\"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*.\"}\n 3. {\"action\":\"replace\",\"ref\":\"\",\"text\":\"new text here\"}\n 4. {\"action\":\"delete\",\"ref\":\"\"}\n 5. {\"action\":\"undo\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "insert", + "redo", + "replace", + "undo" + ], + "description": "The action to perform. One of: delete, insert, redo, replace, undo." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/BlockNodeAddress', - description: - "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "$ref": "#/$defs/BlockNodeAddress", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], + "required": [ + "kind", + "start", + "end" + ] }, { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], - }, - ], - }, + "required": [ + "kind", + "start", + "end" + ] + } + ] + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", - }, + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - value: { - type: 'string', - description: "Text content to insert. Only for action 'insert'. Omit for other actions.", - }, - type: { - type: 'string', - description: - "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", - enum: ['text', 'markdown', 'html'], - }, - ref: { - oneOf: [ + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "value": { + "type": "string", + "description": "Text content to insert. Only for action 'insert'. Omit for other actions." + }, + "type": { + "type": "string", + "description": "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", + "enum": [ + "text", + "markdown", + "html" + ] + }, + "ref": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'string', - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "type": "string", + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, - content: { - oneOf: [ + "content": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'object', + "type": "object" }, { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "array", + "items": { + "type": "object" + } + } ], - description: 'Document fragment to insert (structured content).', + "description": "Document fragment to insert (structured content)." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: {}, + "type": "object", + "properties": {} }, { - type: 'array', - items: { - type: 'object', - properties: {}, - }, - }, + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } ], - description: 'Document fragment to replace with (structured content).', - }, + "description": "Document fragment to replace with (structured content)." + } ], - description: - "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions.", - }, - placement: { - enum: ['before', 'after', 'insideStart', 'insideEnd'], - description: - "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions.", + "description": "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions." + }, + "placement": { + "enum": [ + "before", + "after", + "insideStart", + "insideEnd" + ], + "description": "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions." }, - nestingPolicy: { - oneOf: [ + "nestingPolicy": { + "oneOf": [ { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - additionalProperties: false, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", + "additionalProperties": false, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." }, { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", - }, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." + } ], - description: - "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions.", - }, - text: { - type: 'string', - description: "Replacement text content. Only for action 'replace'. Omit for other actions.", + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions." }, - behavior: { - $ref: '#/$defs/DeleteBehavior', - description: - "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions.", + "text": { + "type": "string", + "description": "Replacement text content. Only for action 'replace'. Omit for other actions." }, + "behavior": { + "$ref": "#/$defs/DeleteBehavior", + "description": "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.insert', - intentAction: 'insert', - requiredOneOf: [['target', 'value'], ['ref', 'value'], ['value'], ['content']], + "operationId": "doc.insert", + "intentAction": "insert", + "requiredOneOf": [ + [ + "target", + "value" + ], + [ + "ref", + "value" + ], + [ + "value" + ], + [ + "content" + ] + ] }, { - operationId: 'doc.replace', - intentAction: 'replace', - requiredOneOf: [ - ['target', 'text'], - ['ref', 'text'], - ['target', 'content'], - ['ref', 'content'], - ], + "operationId": "doc.replace", + "intentAction": "replace", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "ref", + "text" + ], + [ + "target", + "content" + ], + [ + "ref", + "content" + ] + ] }, { - operationId: 'doc.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['ref']], + "operationId": "doc.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "ref" + ] + ] }, { - operationId: 'doc.history.undo', - intentAction: 'undo', + "operationId": "doc.history.undo", + "intentAction": "undo" }, { - operationId: 'doc.history.redo', - intentAction: 'redo', - }, - ], + "operationId": "doc.history.redo", + "intentAction": "redo" + } + ] }, { - toolName: 'superdoc_format', - description: - 'Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require "all" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action "inline" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via "ref". Action "set_style" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions "set_alignment", "set_indentation", "set_spacing", "set_direction", and "set_flow_options" change paragraph-level properties and require a block target: {kind:"block", nodeType:"paragraph", nodeId:""}, NOT a ref. Use "set_flow_options" with pageBreakBefore:true to start a paragraph on a new page. Supports "dryRun" and "changeMode: tracked" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:"block", start:{kind:"nodeEdge",...}} or selection-like structures for paragraph actions. ONLY {kind:"block", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {"action":"inline","ref":"","inline":{"bold":true}}\n 2. {"action":"inline","ref":"","inline":{"fontFamily":"Calibri","fontSize":11,"color":"#000000","bold":false}}\n 3. {"action":"set_alignment","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"alignment":"center"}\n 4. {"action":"set_flow_options","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"pageBreakBefore":true}\n 5. {"action":"set_spacing","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"lineSpacing":{"rule":"auto","value":1.5}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'inline', - 'set_alignment', - 'set_direction', - 'set_flow_options', - 'set_indentation', - 'set_spacing', - 'set_style', + "toolName": "superdoc_format", + "description": "Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require \"all\" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action \"inline\" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via \"ref\". Action \"set_style\" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions \"set_alignment\", \"set_indentation\", \"set_spacing\", \"set_direction\", and \"set_flow_options\" change paragraph-level properties and require a block target: {kind:\"block\", nodeType:\"paragraph\", nodeId:\"\"}, NOT a ref. Use \"set_flow_options\" with pageBreakBefore:true to start a paragraph on a new page. Supports \"dryRun\" and \"changeMode: tracked\" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:\"block\", start:{kind:\"nodeEdge\",...}} or selection-like structures for paragraph actions. ONLY {kind:\"block\", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"bold\":true}}\n 2. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"fontFamily\":\"Calibri\",\"fontSize\":11,\"color\":\"#000000\",\"bold\":false}}\n 3. {\"action\":\"set_alignment\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"alignment\":\"center\"}\n 4. {\"action\":\"set_flow_options\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"pageBreakBefore\":true}\n 5. {\"action\":\"set_spacing\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"lineSpacing\":{\"rule\":\"auto\",\"value\":1.5}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "inline", + "set_alignment", + "set_direction", + "set_flow_options", + "set_indentation", + "set_spacing", + "set_style" ], - description: - 'The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style.', + "description": "The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'." }, - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ - { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "stylisticSets": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - description: - "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions.", - }, - ref: { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions.", - }, - styleId: { - type: 'string', - minLength: 1, - description: - "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'.", - }, - alignment: { - enum: ['left', 'center', 'right', 'justify'], - description: - "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'.", - }, - left: { - type: 'integer', - minimum: 0, - description: - "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - right: { - type: 'integer', - minimum: 0, - description: - "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - firstLine: { - type: 'integer', - minimum: 0, - description: - "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions.", - }, - hanging: { - type: 'integer', - minimum: 0, - description: - "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions.", - }, - before: { - type: 'integer', - minimum: 0, - description: - "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - after: { - type: 'integer', - minimum: 0, - description: - "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - line: { - type: 'integer', - minimum: 1, - description: - "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions.", - }, - lineRule: { - enum: ['auto', 'exact', 'atLeast'], - description: - "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions.", - }, - contextualSpacing: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - pageBreakBefore: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - suppressAutoHyphens: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - direction: { - type: 'string', - enum: ['ltr', 'rtl'], - description: "Required for action 'set_direction'.", - }, - alignmentPolicy: { - type: 'string', - enum: ['preserve', 'matchDirection'], - description: "Only for action 'set_direction'. Omit for other actions.", + "additionalProperties": false, + "minProperties": 1, + "description": "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions." + }, + "ref": { + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions." + }, + "styleId": { + "type": "string", + "minLength": 1, + "description": "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'." + }, + "alignment": { + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'." + }, + "left": { + "type": "integer", + "minimum": 0, + "description": "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "right": { + "type": "integer", + "minimum": 0, + "description": "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "firstLine": { + "type": "integer", + "minimum": 0, + "description": "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions." + }, + "hanging": { + "type": "integer", + "minimum": 0, + "description": "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions." + }, + "before": { + "type": "integer", + "minimum": 0, + "description": "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "after": { + "type": "integer", + "minimum": 0, + "description": "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions." + }, + "lineRule": { + "enum": [ + "auto", + "exact", + "atLeast" + ], + "description": "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions." + }, + "contextualSpacing": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "pageBreakBefore": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." }, + "suppressAutoHyphens": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "direction": { + "type": "string", + "enum": [ + "ltr", + "rtl" + ], + "description": "Required for action 'set_direction'." + }, + "alignmentPolicy": { + "type": "string", + "enum": [ + "preserve", + "matchDirection" + ], + "description": "Only for action 'set_direction'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.format.apply', - intentAction: 'inline', - requiredOneOf: [ - ['target', 'inline'], - ['ref', 'inline'], - ], + "operationId": "doc.format.apply", + "intentAction": "inline", + "requiredOneOf": [ + [ + "target", + "inline" + ], + [ + "ref", + "inline" + ] + ] }, { - operationId: 'doc.styles.paragraph.setStyle', - intentAction: 'set_style', - required: ['target', 'styleId'], + "operationId": "doc.styles.paragraph.setStyle", + "intentAction": "set_style", + "required": [ + "target", + "styleId" + ] }, { - operationId: 'doc.format.paragraph.setAlignment', - intentAction: 'set_alignment', - required: ['target', 'alignment'], + "operationId": "doc.format.paragraph.setAlignment", + "intentAction": "set_alignment", + "required": [ + "target", + "alignment" + ] }, { - operationId: 'doc.format.paragraph.setIndentation', - intentAction: 'set_indentation', - required: ['target'], + "operationId": "doc.format.paragraph.setIndentation", + "intentAction": "set_indentation", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setSpacing', - intentAction: 'set_spacing', - required: ['target'], + "operationId": "doc.format.paragraph.setSpacing", + "intentAction": "set_spacing", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setFlowOptions', - intentAction: 'set_flow_options', - requiredOneOf: [ - ['target', 'contextualSpacing'], - ['target', 'pageBreakBefore'], - ['target', 'suppressAutoHyphens'], - ], + "operationId": "doc.format.paragraph.setFlowOptions", + "intentAction": "set_flow_options", + "requiredOneOf": [ + [ + "target", + "contextualSpacing" + ], + [ + "target", + "pageBreakBefore" + ], + [ + "target", + "suppressAutoHyphens" + ] + ] }, { - operationId: 'doc.format.paragraph.setDirection', - intentAction: 'set_direction', - required: ['target', 'direction'], - }, - ], + "operationId": "doc.format.paragraph.setDirection", + "intentAction": "set_direction", + "required": [ + "target", + "direction" + ] + } + ] }, { - toolName: 'superdoc_create', - description: - 'IMPORTANT: For headings and paragraphs, use superdoc_edit with type "markdown" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a "heading", use action "heading" with a level (default 1). Use action "paragraph" for regular body text. Position with "at": {kind:"documentEnd"} (default), {kind:"documentStart"}, or {kind:"after"/"before", target:{kind:"block", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next "at" target to maintain correct ordering. Do NOT use newlines in "text" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {"action":"paragraph","text":"New paragraph content.","at":{"kind":"documentEnd"}}\n 2. {"action":"heading","text":"Section Title","level":2,"at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 3. {"action":"paragraph","text":"Chained item.","at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 4. {"action":"table","rows":3,"columns":4,"at":{"kind":"documentEnd"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['heading', 'paragraph', 'table'], - description: 'The action to perform. One of: heading, paragraph, table.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - at: { - oneOf: [ + "toolName": "superdoc_create", + "description": "IMPORTANT: For headings and paragraphs, use superdoc_edit with type \"markdown\" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a \"heading\", use action \"heading\" with a level (default 1). Use action \"paragraph\" for regular body text. Position with \"at\": {kind:\"documentEnd\"} (default), {kind:\"documentStart\"}, or {kind:\"after\"/\"before\", target:{kind:\"block\", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next \"at\" target to maintain correct ordering. Do NOT use newlines in \"text\" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {\"action\":\"paragraph\",\"text\":\"New paragraph content.\",\"at\":{\"kind\":\"documentEnd\"}}\n 2. {\"action\":\"heading\",\"text\":\"Section Title\",\"level\":2,\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 3. {\"action\":\"paragraph\",\"text\":\"Chained item.\",\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 4. {\"action\":\"table\",\"rows\":3,\"columns\":4,\"at\":{\"kind\":\"documentEnd\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "heading", + "paragraph", + "table" + ], + "description": "The action to perform. One of: heading, paragraph, table." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "at": { + "oneOf": [ { - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - ], + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + } + ] }, { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - ], - }, + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + } + ] + } ], - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement." }, - text: { - oneOf: [ + "text": { + "oneOf": [ { - type: 'string', - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "type": "string", + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, { - type: 'string', - description: 'Heading text content.', - }, + "type": "string", + "description": "Heading text content." + } ], - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, - input: { - oneOf: [ + "input": { + "oneOf": [ { - type: 'object', - description: 'Full paragraph input as JSON (alternative to individual text/at params).', + "type": "object", + "description": "Full paragraph input as JSON (alternative to individual text/at params)." }, { - type: 'object', - description: 'Full heading input as JSON (alternative to individual text/level/at params).', - }, + "type": "object", + "description": "Full heading input as JSON (alternative to individual text/level/at params)." + } ], - description: 'Full paragraph input as JSON (alternative to individual text/at params).', - }, - level: { - type: 'integer', - minimum: 1, - maximum: 6, - description: "Heading level (1-6). Required for action 'heading'.", - }, - rows: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, - columns: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, + "description": "Full paragraph input as JSON (alternative to individual text/at params)." + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6). Required for action 'heading'." + }, + "rows": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + }, + "columns": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.create.paragraph', - intentAction: 'paragraph', + "operationId": "doc.create.paragraph", + "intentAction": "paragraph" }, { - operationId: 'doc.create.heading', - intentAction: 'heading', - required: ['level'], + "operationId": "doc.create.heading", + "intentAction": "heading", + "required": [ + "level" + ] }, { - operationId: 'doc.create.table', - intentAction: 'table', - required: ['rows', 'columns'], - }, - ], + "operationId": "doc.create.table", + "intentAction": "table", + "required": [ + "rows", + "columns" + ] + } + ] }, { - toolName: 'superdoc_list', - description: - 'Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:"block", nodeType:"listItem", nodeId:""}. Exceptions: "create" and "attach" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:"blocks"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• "create": make a NEW list from paragraphs. Two modes: mode:"empty" with at:{kind:"block", nodeType:"paragraph", nodeId} converts a single paragraph; mode:"fromParagraphs" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset ("disc"|"circle"|"square"|"dash" for bullets; "decimal"|"decimalParenthesis"|"lowerLetter"|"upperLetter"|"lowerRoman"|"upperRoman" for ordered) or a custom style. Use "create" to start a fresh list: NOT to extend an existing one (use "attach" for that).\n• "attach": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:"block", nodeType:"listItem", nodeId:""} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see "join" below).\n• "set_type": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:"ordered" or "bullet". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• "detach": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• "insert": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:"before"|"after" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• "indent" / "outdent": bump the target item\'s nesting level by one (0-8 range). Pass target:{listItem}.\n• "set_level": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• "set_value": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• "continue_previous": make the target\'s sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• "merge": merge the target\'s sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:"withPrevious" or "withNext". Absorbed items adopt the absorbing sequence\'s numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• "split": split the target\'s sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {"action":"create","mode":"fromParagraphs","preset":"disc","target":{"from":{"kind":"block","nodeType":"paragraph","nodeId":""},"to":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 2. {"action":"set_type","target":{"kind":"block","nodeType":"listItem","nodeId":""},"kind":"ordered"}\n 3. {"action":"insert","target":{"kind":"block","nodeType":"listItem","nodeId":""},"position":"after","text":"New list item"}\n 4. {"action":"indent","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 5. {"action":"merge","target":{"kind":"block","nodeType":"listItem","nodeId":""},"direction":"withPrevious"}\n 6. {"action":"split","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 7. {"action":"set_value","target":{"kind":"block","nodeType":"listItem","nodeId":""},"value":1}\n 8. {"action":"continue_previous","target":{"kind":"block","nodeType":"listItem","nodeId":""}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'attach', - 'continue_previous', - 'create', - 'delete', - 'detach', - 'indent', - 'insert', - 'merge', - 'outdent', - 'set_level', - 'set_type', - 'set_value', - 'split', + "toolName": "superdoc_list", + "description": "Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}. Exceptions: \"create\" and \"attach\" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:\"blocks\"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• \"create\": make a NEW list from paragraphs. Two modes: mode:\"empty\" with at:{kind:\"block\", nodeType:\"paragraph\", nodeId} converts a single paragraph; mode:\"fromParagraphs\" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset (\"disc\"|\"circle\"|\"square\"|\"dash\" for bullets; \"decimal\"|\"decimalParenthesis\"|\"lowerLetter\"|\"upperLetter\"|\"lowerRoman\"|\"upperRoman\" for ordered) or a custom style. Use \"create\" to start a fresh list: NOT to extend an existing one (use \"attach\" for that).\n• \"attach\": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see \"join\" below).\n• \"set_type\": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:\"ordered\" or \"bullet\". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• \"detach\": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• \"insert\": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:\"before\"|\"after\" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• \"indent\" / \"outdent\": bump the target item's nesting level by one (0-8 range). Pass target:{listItem}.\n• \"set_level\": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• \"set_value\": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• \"continue_previous\": make the target's sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• \"merge\": merge the target's sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:\"withPrevious\" or \"withNext\". Absorbed items adopt the absorbing sequence's numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• \"split\": split the target's sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"mode\":\"fromParagraphs\",\"preset\":\"disc\",\"target\":{\"from\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"to\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 2. {\"action\":\"set_type\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"kind\":\"ordered\"}\n 3. {\"action\":\"insert\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"position\":\"after\",\"text\":\"New list item\"}\n 4. {\"action\":\"indent\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 5. {\"action\":\"merge\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"direction\":\"withPrevious\"}\n 6. {\"action\":\"split\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 7. {\"action\":\"set_value\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"value\":1}\n 8. {\"action\":\"continue_previous\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "attach", + "continue_previous", + "create", + "delete", + "detach", + "indent", + "insert", + "merge", + "outdent", + "set_level", + "set_type", + "set_value", + "split" ], - description: - 'The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split.', + "description": "The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ListItemAddress', - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "$ref": "#/$defs/ListItemAddress", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - description: - "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}.", - }, + "$ref": "#/$defs/BlockAddressOrRange", + "description": "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}." + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - }, + "$ref": "#/$defs/BlockAddressOrRange" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } + ], + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'." + }, + "position": { + "enum": [ + "before", + "after" + ], + "description": "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'." + }, + "text": { + "type": "string", + "description": "Text content for the new list item. Only for action 'insert'. Omit for other actions." + }, + "input": { + "type": "object", + "description": "Operation input as JSON object." + }, + "nodeId": { + "type": "string", + "description": "Node ID of the target list item." + }, + "mode": { + "enum": [ + "empty", + "fromParagraphs" ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'.", - }, - position: { - enum: ['before', 'after'], - description: - "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'.", - }, - text: { - type: 'string', - description: "Text content for the new list item. Only for action 'insert'. Omit for other actions.", - }, - input: { - type: 'object', - description: 'Operation input as JSON object.', - }, - nodeId: { - type: 'string', - description: 'Node ID of the target list item.', - }, - mode: { - enum: ['empty', 'fromParagraphs'], - description: - "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'.", - }, - at: { - $ref: '#/$defs/BlockAddress', - description: - "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions.", - }, - kind: { - enum: ['ordered', 'bullet'], - description: - "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'.", - }, - level: { - oneOf: [ + "description": "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'." + }, + "at": { + "$ref": "#/$defs/BlockAddress", + "description": "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions." + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ], + "description": "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'." + }, + "level": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'integer', - minimum: 0, - maximum: 8, - description: 'List nesting level (0-8). 0 is the top level.', + "type": "integer", + "minimum": 0, + "maximum": 8, + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: 'List nesting level (0-8). 0 is the top level.', + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: "List nesting level (0-8). 0 is the top level. Required for action 'set_level'.", - }, - preset: { - enum: [ - 'decimal', - 'decimalParenthesis', - 'lowerLetter', - 'upperLetter', - 'lowerRoman', - 'upperRoman', - 'disc', - 'circle', - 'square', - 'dash', + "description": "List nesting level (0-8). 0 is the top level. Required for action 'set_level'." + }, + "preset": { + "enum": [ + "decimal", + "decimalParenthesis", + "lowerLetter", + "upperLetter", + "lowerRoman", + "upperRoman", + "disc", + "circle", + "square", + "dash" ], - description: - "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions.", - }, - style: { - type: 'object', - properties: { - version: { - const: 1, - type: 'number', + "description": "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions." + }, + "style": { + "type": "object", + "properties": { + "version": { + "const": 1, + "type": "number" }, - levels: { - type: 'array', - items: { - type: 'object', - properties: { - level: { - type: 'integer', - minimum: 0, - maximum: 8, + "levels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 0, + "maximum": 8 }, - numFmt: { - type: 'string', + "numFmt": { + "type": "string" }, - lvlText: { - type: 'string', + "lvlText": { + "type": "string" }, - start: { - type: 'integer', + "start": { + "type": "integer" }, - alignment: { - enum: ['left', 'center', 'right'], + "alignment": { + "enum": [ + "left", + "center", + "right" + ] }, - indents: { - type: 'object', - properties: { - left: { - type: 'integer', - }, - hanging: { - type: 'integer', + "indents": { + "type": "object", + "properties": { + "left": { + "type": "integer" }, - firstLine: { - type: 'integer', + "hanging": { + "type": "integer" }, + "firstLine": { + "type": "integer" + } }, - additionalProperties: false, + "additionalProperties": false }, - trailingCharacter: { - enum: ['tab', 'space', 'nothing'], + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] }, - markerFont: { - type: 'string', + "markerFont": { + "type": "string" }, - pictureBulletId: { - type: 'integer', + "pictureBulletId": { + "type": "integer" }, - tabStopAt: { - type: ['integer', 'null'], - }, - }, - additionalProperties: false, - required: ['level'], - }, - }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + } + } }, - additionalProperties: false, - required: ['version', 'levels'], - description: "Only for action 'create'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "version", + "levels" + ], + "description": "Only for action 'create'. Omit for other actions." }, - sequence: { - oneOf: [ + "sequence": { + "oneOf": [ { - type: 'object', - properties: { - mode: { - const: 'new', - type: 'string', - }, - startAt: { - type: 'integer', - minimum: 1, - }, + "type": "object", + "properties": { + "mode": { + "const": "new", + "type": "string" + }, + "startAt": { + "type": "integer", + "minimum": 1 + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, { - type: 'object', - properties: { - mode: { - const: 'continuePrevious', - type: 'string', - }, + "type": "object", + "properties": { + "mode": { + "const": "continuePrevious", + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } ], - description: "Only for action 'create'. Omit for other actions.", + "description": "Only for action 'create'. Omit for other actions." }, - attachTo: { - $ref: '#/$defs/ListItemAddress', - description: "Required for action 'attach'.", + "attachTo": { + "$ref": "#/$defs/ListItemAddress", + "description": "Required for action 'attach'." }, - direction: { - enum: ['withPrevious', 'withNext'], - description: "Required for action 'merge'.", - }, - restartNumbering: { - type: 'boolean', - description: "Only for action 'split'. Omit for other actions.", + "direction": { + "enum": [ + "withPrevious", + "withNext" + ], + "description": "Required for action 'merge'." }, - value: { - type: ['integer', 'null'], - description: "Required for action 'set_value'.", + "restartNumbering": { + "type": "boolean", + "description": "Only for action 'split'. Omit for other actions." }, - continuity: { - enum: ['preserve', 'none'], - description: - "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions.", + "value": { + "type": [ + "integer", + "null" + ], + "description": "Required for action 'set_value'." }, + "continuity": { + "enum": [ + "preserve", + "none" + ], + "description": "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.lists.insert', - intentAction: 'insert', - required: ['target', 'position'], + "operationId": "doc.lists.insert", + "intentAction": "insert", + "required": [ + "target", + "position" + ] }, { - operationId: 'doc.lists.create', - intentAction: 'create', - required: ['mode'], + "operationId": "doc.lists.create", + "intentAction": "create", + "required": [ + "mode" + ] }, { - operationId: 'doc.lists.attach', - intentAction: 'attach', - required: ['target', 'attachTo'], + "operationId": "doc.lists.attach", + "intentAction": "attach", + "required": [ + "target", + "attachTo" + ] }, { - operationId: 'doc.lists.detach', - intentAction: 'detach', - required: ['target'], + "operationId": "doc.lists.detach", + "intentAction": "detach", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.delete', - intentAction: 'delete', - required: ['target'], + "operationId": "doc.lists.delete", + "intentAction": "delete", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.indent', - intentAction: 'indent', - required: ['target'], + "operationId": "doc.lists.indent", + "intentAction": "indent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.outdent', - intentAction: 'outdent', - required: ['target'], + "operationId": "doc.lists.outdent", + "intentAction": "outdent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.merge', - intentAction: 'merge', - required: ['target', 'direction'], + "operationId": "doc.lists.merge", + "intentAction": "merge", + "required": [ + "target", + "direction" + ] }, { - operationId: 'doc.lists.split', - intentAction: 'split', - required: ['target'], + "operationId": "doc.lists.split", + "intentAction": "split", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setLevel', - intentAction: 'set_level', - required: ['target', 'level'], + "operationId": "doc.lists.setLevel", + "intentAction": "set_level", + "required": [ + "target", + "level" + ] }, { - operationId: 'doc.lists.setValue', - intentAction: 'set_value', - required: ['target', 'value'], + "operationId": "doc.lists.setValue", + "intentAction": "set_value", + "required": [ + "target", + "value" + ] }, { - operationId: 'doc.lists.continuePrevious', - intentAction: 'continue_previous', - required: ['target'], + "operationId": "doc.lists.continuePrevious", + "intentAction": "continue_previous", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setType', - intentAction: 'set_type', - required: ['target', 'kind'], - }, - ], + "operationId": "doc.lists.setType", + "intentAction": "set_type", + "required": [ + "target", + "kind" + ] + } + ] }, { - toolName: 'superdoc_comment', - description: - 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['create', 'delete', 'get', 'list', 'update'], - description: 'The action to perform. One of: create, delete, get, list, update.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - text: { - oneOf: [ + "toolName": "superdoc_comment", + "description": "Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action \"create\" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:\"text\", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:\"text\", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass \"parentId\" with the parent comment ID. Action \"list\" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action \"get\" retrieves a single comment by ID. Action \"update\" changes status to \"resolved\" or marks as internal. Action \"delete\" removes a comment or reply by ID. Do NOT pass \"ref\", \"id\", or \"parentId\" when creating a new top-level comment; only \"action\", \"text\", and \"target\" are needed.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"text\":\"Please review this section.\",\"target\":{\"kind\":\"text\",\"blockId\":\"\",\"range\":{\"start\":5,\"end\":25}}}\n 2. {\"action\":\"list\",\"limit\":20,\"offset\":0}\n 3. {\"action\":\"update\",\"id\":\"\",\"status\":\"resolved\"}\n 4. {\"action\":\"delete\",\"id\":\"\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create", + "delete", + "get", + "list", + "update" + ], + "description": "The action to perform. One of: create, delete, get, list, update." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "text": { + "oneOf": [ { - type: 'string', - description: 'Comment text content.', + "type": "string", + "description": "Comment text content." }, { - type: 'string', - description: 'Updated comment text.', - }, + "type": "string", + "description": "Updated comment text." + } ], - description: "Comment text content. Required for action 'create'.", + "description": "Comment text content. Required for action 'create'." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextAddress" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/SelectionTarget', + "$ref": "#/$defs/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } ], - description: - "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextAddress" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/SelectionTarget', + "$ref": "#/$defs/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, - ], - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] + } ], - description: - "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions." }, - parentId: { - type: 'string', - description: - "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions.", + "parentId": { + "type": "string", + "description": "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions." }, - id: { - type: 'string', - description: "Required for actions 'delete', 'get'.", + "id": { + "type": "string", + "description": "Required for actions 'delete', 'get'." }, - status: { - enum: ['resolved', 'active'], - description: - "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions.", - }, - isInternal: { - type: 'boolean', - description: - "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions.", + "status": { + "enum": [ + "resolved", + "active" + ], + "description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions." }, - includeResolved: { - type: 'boolean', - description: - "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions.", + "isInternal": { + "type": "boolean", + "description": "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions." }, - limit: { - type: 'integer', - description: "Maximum number of comments to return. Only for action 'list'. Omit for other actions.", + "includeResolved": { + "type": "boolean", + "description": "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions." }, - offset: { - type: 'integer', - description: "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions.", + "limit": { + "type": "integer", + "description": "Maximum number of comments to return. Only for action 'list'. Omit for other actions." }, + "offset": { + "type": "integer", + "description": "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.comments.create', - intentAction: 'create', - required: ['text'], + "operationId": "doc.comments.create", + "intentAction": "create", + "required": [ + "text" + ] }, { - operationId: 'doc.comments.patch', - intentAction: 'update', + "operationId": "doc.comments.patch", + "intentAction": "update" }, { - operationId: 'doc.comments.delete', - intentAction: 'delete', - required: ['id'], + "operationId": "doc.comments.delete", + "intentAction": "delete", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.get', - intentAction: 'get', - required: ['id'], + "operationId": "doc.comments.get", + "intentAction": "get", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.list', - intentAction: 'list', - }, - ], + "operationId": "doc.comments.list", + "intentAction": "list" + } + ] }, { - toolName: 'superdoc_track_changes', - description: - 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"kind":"range","range":{"kind":"text","segments":[{"blockId":"","range":{"start":0,"end":5}}]}}}\n 5. {"action":"decide","decision":"reject","target":{"scope":"all"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['decide', 'list'], - description: 'The action to perform. One of: decide, list.', - }, - limit: { - type: 'integer', - description: "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions.", - }, - offset: { - type: 'integer', - description: - "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", - }, - type: { - enum: ['insert', 'delete', 'replacement', 'format'], - description: - "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'decide'. Omit for other actions.", - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: - 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions. Only for action \'decide\'. Omit for other actions.', - }, - decision: { - enum: ['accept', 'reject'], - description: "Required for action 'decide'.", - }, - target: { - oneOf: [ + "toolName": "superdoc_track_changes", + "description": "Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action \"list\" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action \"decide\" accepts or rejects changes. Pass decision:\"accept\" to apply the change permanently, or decision:\"reject\" to discard it. Target a single change with {id:\"\"}, a partial selection with {kind:\"range\", range:{...}}, or all changes at once with {scope:\"all\"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {\"action\":\"list\"}\n 2. {\"action\":\"list\",\"type\":\"replacement\",\"limit\":10}\n 3. {\"action\":\"decide\",\"decision\":\"accept\",\"target\":{\"id\":\"\"}}\n 4. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"kind\":\"range\",\"range\":{\"kind\":\"text\",\"segments\":[{\"blockId\":\"\",\"range\":{\"start\":0,\"end\":5}}]}}}\n 5. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"scope\":\"all\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "decide", + "list" + ], + "description": "The action to perform. One of: decide, list." + }, + "limit": { + "type": "integer", + "description": "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions." + }, + "offset": { + "type": "integer", + "description": "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions." + }, + "type": { + "enum": [ + "insert", + "delete", + "replacement", + "format" + ], + "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'decide'. Omit for other actions." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions. Only for action 'decide'. Omit for other actions." + }, + "decision": { + "enum": [ + "accept", + "reject" + ], + "description": "Required for action 'decide'." + }, + "target": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + } }, - additionalProperties: false, - required: ['id'], + "additionalProperties": false, + "required": [ + "id" + ] }, { - type: 'object', - properties: { - kind: { - const: 'range', - type: 'string', - }, - range: { - $ref: '#/$defs/TextTarget', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, - part: { - type: 'string', - description: 'Optional part discriminator for the range target.', - }, + "type": "object", + "properties": { + "kind": { + "const": "range", + "type": "string" + }, + "range": { + "$ref": "#/$defs/TextTarget" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + }, + "part": { + "type": "string", + "description": "Optional part discriminator for the range target." + } }, - additionalProperties: false, - required: ['kind', 'range'], + "additionalProperties": false, + "required": [ + "kind", + "range" + ] }, { - type: 'object', - properties: { - scope: { - enum: ['all'], - }, - story: { - oneOf: [ + "type": "object", + "properties": { + "scope": { + "enum": [ + "all" + ] + }, + "story": { + "oneOf": [ { - $ref: '#/$defs/StoryLocator', + "$ref": "#/$defs/StoryLocator" }, { - const: 'all', - type: 'string', - }, + "const": "all", + "type": "string" + } ], - description: - "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", - }, + "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story." + } }, - additionalProperties: false, - required: ['scope'], - }, + "additionalProperties": false, + "required": [ + "scope" + ] + } ], - description: "Required for action 'decide'.", - }, + "description": "Required for action 'decide'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.trackChanges.list', - intentAction: 'list', + "operationId": "doc.trackChanges.list", + "intentAction": "list" }, { - operationId: 'doc.trackChanges.decide', - intentAction: 'decide', - required: ['decision', 'target'], - }, - ], + "operationId": "doc.trackChanges.decide", + "intentAction": "decide", + "required": [ + "decision", + "target" + ] + } + ] }, { - toolName: 'superdoc_search', - description: - 'Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in "where" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The "require" parameter controls match cardinality: "first" returns one match, "all" returns every match, "exactlyOne" fails if not exactly one match. Supports scoping via "within" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {"select":{"type":"text","pattern":"Introduction"},"require":"first"}\n 2. {"select":{"type":"text","pattern":"total amount"},"require":"all"}\n 3. {"select":{"type":"node","nodeType":"heading"},"require":"all"}\n 4. {"select":{"type":"text","pattern":"contract"},"within":{"kind":"block","nodeType":"paragraph","nodeId":"abc123"},"require":"first"}', - inputSchema: { - type: 'object', - properties: { - select: { - description: - "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", - oneOf: [ + "toolName": "superdoc_search", + "description": "Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in \"where\" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The \"require\" parameter controls match cardinality: \"first\" returns one match, \"all\" returns every match, \"exactlyOne\" fails if not exactly one match. Supports scoping via \"within\" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {\"select\":{\"type\":\"text\",\"pattern\":\"Introduction\"},\"require\":\"first\"}\n 2. {\"select\":{\"type\":\"text\",\"pattern\":\"total amount\"},\"require\":\"all\"}\n 3. {\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"}\n 4. {\"select\":{\"type\":\"text\",\"pattern\":\"contract\"},\"within\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"abc123\"},\"require\":\"first\"}", + "inputSchema": { + "type": "object", + "properties": { + "select": { + "description": "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', - }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" + }, + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." + }, + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress", + "description": "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "require": { + "enum": [ + "any", + "first", + "exactlyOne", + "all" ], + "description": "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0)." }, - within: { - $ref: '#/$defs/BlockNodeAddress', - description: "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - require: { - enum: ['any', 'first', 'exactlyOne', 'all'], - description: - "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0).", - }, - mode: { - enum: ['strict', 'candidates'], - description: - "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches).", - }, - includeNodes: { - type: 'boolean', - description: 'When true, includes full node data in results. Default: false.', - }, - limit: { - type: 'integer', - minimum: 1, - description: 'Maximum number of matches to return.', - }, - offset: { - type: 'integer', - minimum: 0, - description: 'Number of matches to skip for pagination.', - }, + "mode": { + "enum": [ + "strict", + "candidates" + ], + "description": "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches)." + }, + "includeNodes": { + "type": "boolean", + "description": "When true, includes full node data in results. Default: false." + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matches to return." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Number of matches to skip for pagination." + } }, - required: ['select'], - additionalProperties: false, + "required": [ + "select" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.query.match', - intentAction: 'match', - required: ['select'], - }, - ], + "operationId": "doc.query.match", + "intentAction": "match", + "required": [ + "select" + ] + } + ] }, { - toolName: 'superdoc_mutations', - description: - 'All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"} or {by:"ref", ref:"..."} or {by:"block", nodeType:"paragraph", nodeId:"..."}), and "args" with operation-specific parameters. Use {by:"block", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action "blocks" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:"blocks" and includeText:true, then rewrite the matching block by nodeId. Use {by:"select"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, "where" targets an existing anchor block and args.position ("before" or "after") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require "all", use a node selector to format every heading or paragraph at once: {by:"select", select:{type:"node", nodeType:"heading"}, require:"all"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action "preview" dry-runs the plan. Action "apply" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {"action":"apply","atomic":true,"changeMode":"direct","steps":[{"id":"s1","op":"text.rewrite","where":{"by":"select","select":{"type":"text","pattern":"old term"},"require":"all"},"args":{"replacement":{"text":"new term"}}},{"id":"s2","op":"text.delete","where":{"by":"select","select":{"type":"text","pattern":" (deprecated)"},"require":"all"},"args":{}}]}\n 2. {"action":"apply","steps":[{"id":"r1","op":"text.rewrite","where":{"by":"block","nodeType":"paragraph","nodeId":""},"args":{"replacement":{"text":"Updated clause text."}}},{"id":"f1","op":"format.apply","where":{"by":"select","select":{"type":"node","nodeType":"heading"},"require":"all"},"args":{"inline":{"color":"#FF0000"}}},{"id":"f2","op":"format.apply","where":{"by":"select","select":{"type":"text","pattern":"Confidential Information"},"require":"all"},"args":{"inline":{"bold":true}}}]}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['apply', 'preview'], - description: 'The action to perform. One of: apply, preview.', - }, - expectedRevision: { - type: 'string', - description: - "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions.", - }, - atomic: { - const: true, - type: 'boolean', - description: 'Must be true. All steps execute as one atomic transaction.', - }, - changeMode: { - enum: ['direct', 'tracked'], - description: - "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided.", - }, - steps: { - type: 'array', - items: { - oneOf: [ + "toolName": "superdoc_mutations", + "description": "All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a \"where\" clause for targeting ({by:\"select\", select:{...}, require:\"first\"|\"exactlyOne\"|\"all\"} or {by:\"ref\", ref:\"...\"} or {by:\"block\", nodeType:\"paragraph\", nodeId:\"...\"}), and \"args\" with operation-specific parameters. Use {by:\"block\", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action \"blocks\" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:\"blocks\" and includeText:true, then rewrite the matching block by nodeId. Use {by:\"select\"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, \"where\" targets an existing anchor block and args.position (\"before\" or \"after\") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require \"all\", use a node selector to format every heading or paragraph at once: {by:\"select\", select:{type:\"node\", nodeType:\"heading\"}, require:\"all\"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action \"preview\" dry-runs the plan. Action \"apply\" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {\"action\":\"apply\",\"atomic\":true,\"changeMode\":\"direct\",\"steps\":[{\"id\":\"s1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"old term\"},\"require\":\"all\"},\"args\":{\"replacement\":{\"text\":\"new term\"}}},{\"id\":\"s2\",\"op\":\"text.delete\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\" (deprecated)\"},\"require\":\"all\"},\"args\":{}}]}\n 2. {\"action\":\"apply\",\"steps\":[{\"id\":\"r1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"args\":{\"replacement\":{\"text\":\"Updated clause text.\"}}},{\"id\":\"f1\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"},\"args\":{\"inline\":{\"color\":\"#FF0000\"}}},{\"id\":\"f2\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"Confidential Information\"},\"require\":\"all\"},\"args\":{\"inline\":{\"bold\":true}}}]}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "apply", + "preview" + ], + "description": "The action to perform. One of: apply, preview." + }, + "expectedRevision": { + "type": "string", + "description": "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions." + }, + "atomic": { + "const": true, + "type": "boolean", + "description": "Must be true. All steps execute as one atomic transaction." + }, + "changeMode": { + "enum": [ + "direct", + "tracked" + ], + "description": "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided." + }, + "steps": { + "type": "array", + "items": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.rewrite', - type: 'string', + "op": { + "const": "text.rewrite", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - ref: { - type: 'string', - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - replacement: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "replacement": { + "oneOf": [ { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, { - type: 'object', - properties: { - blocks: { - type: 'array', - items: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "blocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], - }, - }, + "additionalProperties": false, + "required": [ + "text" + ] + } + } }, - additionalProperties: false, - required: ['blocks'], - }, - ], + "additionalProperties": false, + "required": [ + "blocks" + ] + } + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear', 'merge'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear", + "merge" + ], + "type": "string" }, - requireUniform: { - type: 'boolean', + "requireUniform": { + "type": "boolean" }, - onNonUniform: { - enum: ['error', 'useLeadingRun', 'majority', 'union'], + "onNonUniform": { + "enum": [ + "error", + "useLeadingRun", + "majority", + "union" + ] }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, - paragraph: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear'], - type: 'string', - }, + "paragraph": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear" + ], + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['replacement'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "replacement" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.insert', - type: 'string', + "op": { + "const": "text.insert", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - position: { - enum: ['before', 'after'], + "args": { + "type": "object", + "properties": { + "position": { + "enum": [ + "before", + "after" + ] }, - content: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "content": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['inherit', 'set', 'clear'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "inherit", + "set", + "clear" + ], + "type": "string" }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['position', 'content'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "position", + "content" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.delete', - type: 'string', + "op": { + "const": "text.delete", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - ref: { - type: 'string', - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - behavior: { - $ref: '#/$defs/DeleteBehavior', - }, + "args": { + "type": "object", + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } }, - additionalProperties: false, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'format.apply', - type: 'string', + "op": { + "const": "format.apply", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - within: { - $ref: '#/$defs/BlockNodeAddress', - }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ + "stylisticSets": { + "oneOf": [ { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - alignment: { - type: 'string', - enum: ['left', 'center', 'right', 'justify'], - description: - 'Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step.', + "additionalProperties": false, + "minProperties": 1 }, - scope: { - type: 'string', - enum: ['match', 'block'], - description: - 'When "block", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use "block" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: "match".', + "alignment": { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step." }, + "scope": { + "type": "string", + "enum": [ + "match", + "block" + ], + "description": "When \"block\", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use \"block\" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: \"match\"." + } }, - additionalProperties: false, - minProperties: 1, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "minProperties": 1 + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'assert', - type: 'string', + "op": { + "const": "assert", + "type": "string" }, - where: { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "where": { + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'select'], + "additionalProperties": false, + "required": [ + "by", + "select" + ] }, - args: { - type: 'object', - properties: { - expectCount: { - type: 'number', - }, + "args": { + "type": "object", + "properties": { + "expectCount": { + "type": "number" + } }, - additionalProperties: false, - required: ['expectCount'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], - }, - ], + "additionalProperties": false, + "required": [ + "expectCount" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] + } + ] }, - description: - "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'apply'. Omit for other actions.", + "description": "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause." }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'apply'. Omit for other actions." + } }, - required: ['action', 'atomic', 'changeMode', 'steps'], - additionalProperties: false, + "required": [ + "action", + "atomic", + "changeMode", + "steps" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.mutations.preview', - intentAction: 'preview', - required: ['atomic', 'changeMode', 'steps'], + "operationId": "doc.mutations.preview", + "intentAction": "preview", + "required": [ + "atomic", + "changeMode", + "steps" + ] }, { - operationId: 'doc.mutations.apply', - intentAction: 'apply', - required: ['atomic', 'steps', 'changeMode'], - }, - ], + "operationId": "doc.mutations.apply", + "intentAction": "apply", + "required": [ + "atomic", + "steps", + "changeMode" + ] + } + ] }, { - toolName: 'superdoc_table', - description: - 'Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:"blocks"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: "" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:"first"|"last" with no columnIndex. Otherwise columnIndex + position:"left"|"right".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also "auto"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a "make it look [X]" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don\'t disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:"blocks"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST ("make it look [X]" / "style the whole table") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That\'s the #1 cause of "no visual change" complaints.\n Color palette: discover the document\'s palette by reading superdoc_get_content({action:"blocks"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue "1F3864" or dark grey "444444" for accents and "F2F2F2" / "E7E6E6" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:"all" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:"set_alignment". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:"inline". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document\'s default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like "Label: value", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:"table" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:"delete", target:{kind:"block", nodeType:"listItem", nodeId:""}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:"delete" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {"action":"insert_row","nodeId":""}\n 2. {"action":"insert_column","nodeId":"","position":"last"}\n 3. {"action":"merge_cells","nodeId":"","start":{"rowIndex":0,"columnIndex":0},"end":{"rowIndex":1,"columnIndex":1}}\n 4. {"action":"set_cell_text","nodeId":"","rowIndex":0,"columnIndex":0,"text":"Q1 Revenue"}\n 5. {"action":"set_row","nodeId":"","rowIndex":0,"heightPt":24,"rule":"atLeast"}\n 6. {"action":"set_borders","nodeId":"","mode":"applyTo","applyTo":"all","border":{"lineStyle":"single","lineWeightPt":1,"color":"#000000"}}\n 7. {"action":"set_shading","target":{"kind":"block","nodeType":"tableCell","nodeId":""},"color":"#E3F2FD"}\n 8. {"action":"set_style_options","nodeId":"","styleOptions":{"headerRow":true,"bandedRows":true}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'delete', - 'delete_column', - 'delete_row', - 'insert_column', - 'insert_row', - 'merge_cells', - 'set_borders', - 'set_cell', - 'set_cell_text', - 'set_column', - 'set_layout', - 'set_options', - 'set_row', - 'set_row_options', - 'set_shading', - 'set_style_options', - 'unmerge_cells', + "toolName": "superdoc_table", + "description": "Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:\"blocks\"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: \"\" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:\"first\"|\"last\" with no columnIndex. Otherwise columnIndex + position:\"left\"|\"right\".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also \"auto\"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a \"make it look [X]\" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don't disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:\"blocks\"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST (\"make it look [X]\" / \"style the whole table\") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That's the #1 cause of \"no visual change\" complaints.\n Color palette: discover the document's palette by reading superdoc_get_content({action:\"blocks\"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue \"1F3864\" or dark grey \"444444\" for accents and \"F2F2F2\" / \"E7E6E6\" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:\"all\" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:\"set_alignment\". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:\"inline\". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document's default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like \"Label: value\", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:\"table\" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:\"delete\", target:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:\"delete\" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {\"action\":\"insert_row\",\"nodeId\":\"\"}\n 2. {\"action\":\"insert_column\",\"nodeId\":\"\",\"position\":\"last\"}\n 3. {\"action\":\"merge_cells\",\"nodeId\":\"\",\"start\":{\"rowIndex\":0,\"columnIndex\":0},\"end\":{\"rowIndex\":1,\"columnIndex\":1}}\n 4. {\"action\":\"set_cell_text\",\"nodeId\":\"\",\"rowIndex\":0,\"columnIndex\":0,\"text\":\"Q1 Revenue\"}\n 5. {\"action\":\"set_row\",\"nodeId\":\"\",\"rowIndex\":0,\"heightPt\":24,\"rule\":\"atLeast\"}\n 6. {\"action\":\"set_borders\",\"nodeId\":\"\",\"mode\":\"applyTo\",\"applyTo\":\"all\",\"border\":{\"lineStyle\":\"single\",\"lineWeightPt\":1,\"color\":\"#000000\"}}\n 7. {\"action\":\"set_shading\",\"target\":{\"kind\":\"block\",\"nodeType\":\"tableCell\",\"nodeId\":\"\"},\"color\":\"#E3F2FD\"}\n 8. {\"action\":\"set_style_options\",\"nodeId\":\"\",\"styleOptions\":{\"headerRow\":true,\"bandedRows\":true}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "delete_column", + "delete_row", + "insert_column", + "insert_row", + "merge_cells", + "set_borders", + "set_cell", + "set_cell_text", + "set_column", + "set_layout", + "set_options", + "set_row", + "set_row_options", + "set_shading", + "set_style_options", + "unmerge_cells" ], - description: - 'The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells.', + "description": "The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableAddress', + "$ref": "#/$defs/TableAddress" }, { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableCellAddress', - }, - ], + "$ref": "#/$defs/TableCellAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableOrCellAddress', - }, - ], + "$ref": "#/$defs/TableOrCellAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, + "$ref": "#/$defs/BlockNodeAddress" + } ], - description: - "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}.", - }, - nodeId: { - type: 'string', - }, - preferredWidth: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - alignment: { - enum: ['left', 'center', 'right'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - leftIndentPt: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - autoFitMode: { - enum: ['fixedWidth', 'fitContents', 'fitWindow'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - tableDirection: { - enum: ['ltr', 'rtl'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - position: { - enum: ['above', 'below', 'left', 'right', 'first', 'last'], - description: "Required for action 'insert_column'.", - }, - count: { - type: 'integer', - minimum: 1, - description: "Only for actions 'insert_row', 'insert_column'. Omit for other actions.", - }, - rowIndex: { - type: 'integer', - minimum: 0, - description: - "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions.", - }, - heightPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_row'.", - }, - rule: { - enum: ['atLeast', 'exact', 'auto'], - description: "Required for action 'set_row'.", - }, - allowBreakAcrossPages: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - repeatHeader: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - columnIndex: { - type: 'integer', - minimum: 0, - description: "Required for actions 'delete_column', 'set_column'.", - }, - widthPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_column'.", - }, - start: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "description": "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}." + }, + "nodeId": { + "type": "string" + }, + "preferredWidth": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "alignment": { + "enum": [ + "left", + "center", + "right" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "leftIndentPt": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "autoFitMode": { + "enum": [ + "fixedWidth", + "fitContents", + "fitWindow" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "tableDirection": { + "enum": [ + "ltr", + "rtl" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "position": { + "enum": [ + "above", + "below", + "left", + "right", + "first", + "last" + ], + "description": "Required for action 'insert_column'." + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Only for actions 'insert_row', 'insert_column'. Omit for other actions." + }, + "rowIndex": { + "type": "integer", + "minimum": 0, + "description": "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions." + }, + "heightPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_row'." + }, + "rule": { + "enum": [ + "atLeast", + "exact", + "auto" + ], + "description": "Required for action 'set_row'." + }, + "allowBreakAcrossPages": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "repeatHeader": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "columnIndex": { + "type": "integer", + "minimum": 0, + "description": "Required for actions 'delete_column', 'set_column'." + }, + "widthPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_column'." + }, + "start": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", - }, - end: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." + }, + "end": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." }, - preferredWidthPt: { - type: 'number', - description: "Only for action 'set_cell'. Omit for other actions.", + "preferredWidthPt": { + "type": "number", + "description": "Only for action 'set_cell'. Omit for other actions." }, - verticalAlign: { - enum: ['top', 'center', 'bottom'], - description: "Only for action 'set_cell'. Omit for other actions.", + "verticalAlign": { + "enum": [ + "top", + "center", + "bottom" + ], + "description": "Only for action 'set_cell'. Omit for other actions." }, - wrapText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "wrapText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - fitText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "fitText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - text: { - type: 'string', - description: "Required for action 'set_cell_text'.", + "text": { + "type": "string", + "description": "Required for action 'set_cell_text'." }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" }, { - type: 'null', - }, + "type": "null" + } ], - description: "Required for action 'set_shading'.", + "description": "Required for action 'set_shading'." }, - styleId: { - type: 'string', - description: "Only for action 'set_style_options'. Omit for other actions.", + "styleId": { + "type": "string", + "description": "Only for action 'set_style_options'. Omit for other actions." }, - styleOptions: { - type: 'object', - properties: { - headerRow: { - type: 'boolean', + "styleOptions": { + "type": "object", + "properties": { + "headerRow": { + "type": "boolean" }, - lastRow: { - type: 'boolean', + "lastRow": { + "type": "boolean" }, - totalRow: { - type: 'boolean', + "totalRow": { + "type": "boolean" }, - firstColumn: { - type: 'boolean', + "firstColumn": { + "type": "boolean" }, - lastColumn: { - type: 'boolean', + "lastColumn": { + "type": "boolean" }, - bandedRows: { - type: 'boolean', - }, - bandedColumns: { - type: 'boolean', + "bandedRows": { + "type": "boolean" }, + "bandedColumns": { + "type": "boolean" + } }, - additionalProperties: false, - description: "Only for action 'set_style_options'. Omit for other actions.", - }, - mode: { - enum: ['applyTo', 'edges'], - description: "Required for action 'set_borders'.", + "additionalProperties": false, + "description": "Only for action 'set_style_options'. Omit for other actions." }, - applyTo: { - enum: ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'], - description: "Only for action 'set_borders'. Omit for other actions.", + "mode": { + "enum": [ + "applyTo", + "edges" + ], + "description": "Required for action 'set_borders'." + }, + "applyTo": { + "enum": [ + "all", + "outside", + "inside", + "top", + "bottom", + "left", + "right", + "insideH", + "insideV" + ], + "description": "Only for action 'set_borders'. Omit for other actions." }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', - }, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" + }, + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 + }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_borders'. Omit for other actions.", - }, - edges: { - type: 'object', - properties: { - top: { - oneOf: [ - { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "edges": { + "type": "object", + "properties": { + "top": { + "oneOf": [ + { + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bottom: { - oneOf: [ + "bottom": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - left: { - oneOf: [ + "left": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - right: { - oneOf: [ + "right": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideH: { - oneOf: [ + "insideH": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideV: { - oneOf: [ + "insideV": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], - }, - { - type: 'null', - }, - ], - }, + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] + }, + { + "type": "null" + } + ] + } }, - additionalProperties: false, - description: "Only for action 'set_borders'. Omit for other actions.", - }, - defaultCellMargins: { - type: 'object', - properties: { - topPt: { - type: 'number', - minimum: 0, - }, - rightPt: { - type: 'number', - minimum: 0, + "additionalProperties": false, + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "defaultCellMargins": { + "type": "object", + "properties": { + "topPt": { + "type": "number", + "minimum": 0 }, - bottomPt: { - type: 'number', - minimum: 0, + "rightPt": { + "type": "number", + "minimum": 0 }, - leftPt: { - type: 'number', - minimum: 0, + "bottomPt": { + "type": "number", + "minimum": 0 }, + "leftPt": { + "type": "number", + "minimum": 0 + } }, - additionalProperties: false, - required: ['topPt', 'rightPt', 'bottomPt', 'leftPt'], - description: "Only for action 'set_options'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "topPt", + "rightPt", + "bottomPt", + "leftPt" + ], + "description": "Only for action 'set_options'. Omit for other actions." }, - cellSpacingPt: { - oneOf: [ + "cellSpacingPt": { + "oneOf": [ { - type: 'number', - minimum: 0, + "type": "number", + "minimum": 0 }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_options'. Omit for other actions.", - }, + "description": "Only for action 'set_options'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.tables.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setLayout', - intentAction: 'set_layout', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setLayout", + "intentAction": "set_layout", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.insertRow', - intentAction: 'insert_row', - requiredOneOf: [ - ['target', 'position'], - ['target', 'rowIndex', 'position'], - ['nodeId', 'rowIndex', 'position'], - ['target'], - ['nodeId'], - ], + "operationId": "doc.tables.insertRow", + "intentAction": "insert_row", + "requiredOneOf": [ + [ + "target", + "position" + ], + [ + "target", + "rowIndex", + "position" + ], + [ + "nodeId", + "rowIndex", + "position" + ], + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteRow', - intentAction: 'delete_row', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.deleteRow", + "intentAction": "delete_row", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.setRowHeight', - intentAction: 'set_row', - requiredOneOf: [ - ['target', 'heightPt', 'rule'], - ['target', 'rowIndex', 'heightPt', 'rule'], - ['nodeId', 'rowIndex', 'heightPt', 'rule'], - ], + "operationId": "doc.tables.setRowHeight", + "intentAction": "set_row", + "requiredOneOf": [ + [ + "target", + "heightPt", + "rule" + ], + [ + "target", + "rowIndex", + "heightPt", + "rule" + ], + [ + "nodeId", + "rowIndex", + "heightPt", + "rule" + ] + ] }, { - operationId: 'doc.tables.setRowOptions', - intentAction: 'set_row_options', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.setRowOptions", + "intentAction": "set_row_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.insertColumn', - intentAction: 'insert_column', - requiredOneOf: [ - ['position', 'target'], - ['position', 'nodeId'], - ], + "operationId": "doc.tables.insertColumn", + "intentAction": "insert_column", + "requiredOneOf": [ + [ + "position", + "target" + ], + [ + "position", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteColumn', - intentAction: 'delete_column', - requiredOneOf: [ - ['columnIndex', 'target'], - ['columnIndex', 'nodeId'], - ], + "operationId": "doc.tables.deleteColumn", + "intentAction": "delete_column", + "requiredOneOf": [ + [ + "columnIndex", + "target" + ], + [ + "columnIndex", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setColumnWidth', - intentAction: 'set_column', - requiredOneOf: [ - ['columnIndex', 'widthPt', 'target'], - ['columnIndex', 'widthPt', 'nodeId'], - ], + "operationId": "doc.tables.setColumnWidth", + "intentAction": "set_column", + "requiredOneOf": [ + [ + "columnIndex", + "widthPt", + "target" + ], + [ + "columnIndex", + "widthPt", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.mergeCells', - intentAction: 'merge_cells', - requiredOneOf: [ - ['start', 'end', 'target'], - ['start', 'end', 'nodeId'], - ], + "operationId": "doc.tables.mergeCells", + "intentAction": "merge_cells", + "requiredOneOf": [ + [ + "start", + "end", + "target" + ], + [ + "start", + "end", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.unmergeCells', - intentAction: 'unmerge_cells', - requiredOneOf: [ - ['target'], - ['nodeId'], - ['target', 'rowIndex', 'columnIndex'], - ['nodeId', 'rowIndex', 'columnIndex'], - ], + "operationId": "doc.tables.unmergeCells", + "intentAction": "unmerge_cells", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ], + [ + "target", + "rowIndex", + "columnIndex" + ], + [ + "nodeId", + "rowIndex", + "columnIndex" + ] + ] }, { - operationId: 'doc.tables.setCellProperties', - intentAction: 'set_cell', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setCellProperties", + "intentAction": "set_cell", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setCellText', - intentAction: 'set_cell_text', - requiredOneOf: [ - ['target', 'text'], - ['nodeId', 'text'], - ['target', 'rowIndex', 'columnIndex', 'text'], - ['nodeId', 'rowIndex', 'columnIndex', 'text'], - ], + "operationId": "doc.tables.setCellText", + "intentAction": "set_cell_text", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "nodeId", + "text" + ], + [ + "target", + "rowIndex", + "columnIndex", + "text" + ], + [ + "nodeId", + "rowIndex", + "columnIndex", + "text" + ] + ] }, { - operationId: 'doc.tables.setShading', - intentAction: 'set_shading', - requiredOneOf: [ - ['color', 'target'], - ['color', 'nodeId'], - ], + "operationId": "doc.tables.setShading", + "intentAction": "set_shading", + "requiredOneOf": [ + [ + "color", + "target" + ], + [ + "color", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.applyStyle', - intentAction: 'set_style_options', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.applyStyle", + "intentAction": "set_style_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setBorders', - intentAction: 'set_borders', - requiredOneOf: [ - ['mode', 'applyTo', 'border', 'target'], - ['mode', 'applyTo', 'border', 'nodeId'], - ['mode', 'edges', 'target'], - ['mode', 'edges', 'nodeId'], - ], + "operationId": "doc.tables.setBorders", + "intentAction": "set_borders", + "requiredOneOf": [ + [ + "mode", + "applyTo", + "border", + "target" + ], + [ + "mode", + "applyTo", + "border", + "nodeId" + ], + [ + "mode", + "edges", + "target" + ], + [ + "mode", + "edges", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setTableOptions', - intentAction: 'set_options', - requiredOneOf: [['target'], ['nodeId']], - }, - ], - }, - ], + "operationId": "doc.tables.setTableOptions", + "intentAction": "set_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] + } + ] + } + ] } as const; diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index 82bbd96b37..caaba98191 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 531721922b..bfc8dac321 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -31,8 +31,10 @@ const TEMPLATES_APPLY_RECEIPT_FAILURE_CODES = [ // Exhaustiveness: assigning the union to the array's element type (and vice // versa) guarantees the list above covers every TemplatesApplyFailureCode value. -type _TemplatesFailureCoverageForward = - TemplatesApplyFailureCode extends (typeof TEMPLATES_APPLY_RECEIPT_FAILURE_CODES)[number] ? true : never; +type _TemplatesFailureCoverageForward = TemplatesApplyFailureCode extends + (typeof TEMPLATES_APPLY_RECEIPT_FAILURE_CODES)[number] + ? true + : never; const _templatesFailureCoverage: _TemplatesFailureCoverageForward = true; void _templatesFailureCoverage; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 122f25cde1..4c216fb02e 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3816,10 +3816,10 @@ const operationSchemas: Record = { 'sectionDefaults', ], }; - const scopeReportSchema = objectSchema({ scope: scopeEnum, part: { type: 'string' }, detail: { type: 'string' } }, [ - 'scope', - 'part', - ]); + const scopeReportSchema = objectSchema( + { scope: scopeEnum, part: { type: 'string' }, detail: { type: 'string' } }, + ['scope', 'part'], + ); const scopeSkipSchema = objectSchema( { scope: { type: 'string' }, diff --git a/packages/document-api/src/templates/apply.test.ts b/packages/document-api/src/templates/apply.test.ts index d92d3c300e..2fe11296ac 100644 --- a/packages/document-api/src/templates/apply.test.ts +++ b/packages/document-api/src/templates/apply.test.ts @@ -99,9 +99,9 @@ describe('executeTemplatesApply contract', () => { it('throws INVALID_INPUT when path source has empty path', () => { const adapter = makeAdapter(); - expect(() => executeTemplatesApply(adapter, { source: { kind: 'path', path: '' } } as TemplatesApplyInput)).toThrow( - DocumentApiValidationError, - ); + expect(() => + executeTemplatesApply(adapter, { source: { kind: 'path', path: '' } } as TemplatesApplyInput), + ).toThrow(DocumentApiValidationError); }); it('throws INVALID_INPUT when base64 source has empty data', () => { diff --git a/packages/document-api/src/templates/apply.ts b/packages/document-api/src/templates/apply.ts index c7dd9d5d33..2de160ecfc 100644 --- a/packages/document-api/src/templates/apply.ts +++ b/packages/document-api/src/templates/apply.ts @@ -76,7 +76,11 @@ export interface TemplateScopeReport { detail?: string; } -export type TemplateSkipReason = 'NOT_PRESENT_IN_SOURCE' | 'OUT_OF_SCOPE' | 'NO_CHANGE' | 'CAPABILITY_UNAVAILABLE'; +export type TemplateSkipReason = + | 'NOT_PRESENT_IN_SOURCE' + | 'OUT_OF_SCOPE' + | 'NO_CHANGE' + | 'CAPABILITY_UNAVAILABLE'; export interface TemplateScopeSkip { scope: string; diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 5a5a9d24e7..990e1a01d1 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index eba3c7b4b9..5c17c52220 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 @@ -1234,8 +1234,7 @@ function buildStoredZipBase64(parts: Record): string { const central: number[] = []; let offset = 0; const push16 = (arr: number[], v: number) => arr.push(v & 0xff, (v >>> 8) & 0xff); - const push32 = (arr: number[], v: number) => - arr.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); + const push32 = (arr: number[], v: number) => arr.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); for (const f of files) { const nameBytes = enc.encode(f.name); @@ -9835,9 +9834,8 @@ const dryRunVectors: Partial unknown>> = { 'templates.apply': async () => { const editor = makeTemplatesEditor(); initRevision(editor); - const cvt = ( - editor as unknown as { converter: { convertedXml: Record; documentModified: boolean } } - ).converter; + const cvt = (editor as unknown as { converter: { convertedXml: Record; documentModified: boolean } }) + .converter; const before = JSON.stringify(cvt.convertedXml['word/styles.xml']); // templates.apply is async: await the receipt before asserting no mutation // occurred (the async body resolves the source package before returning). diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts index d4319cc07d..8d5f4cc8b5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts @@ -135,7 +135,9 @@ export function importHeaderFooterAssets( // Detect every header/footer part present in the source (even if the page-1 // governing section does not reference it), then attach a representative // source rel id when one exists so the selected sectPr can be rewired. - const hfPartNames = [...byName.keys()].filter((n) => /^word\/(header|footer)\d+\.xml$/.test(n)).sort(); + const hfPartNames = [...byName.keys()] + .filter((n) => /^word\/(header|footer)\d+\.xml$/.test(n)) + .sort(); if (hfPartNames.length === 0) return result; result.detected = true; if (dryRun) { @@ -329,13 +331,7 @@ export function importHeaderFooterAssets( } // Content-type override. - if ( - ensureContentTypeOverride( - converter, - targetPartName, - kind === 'header' ? HEADER_CONTENT_TYPE : FOOTER_CONTENT_TYPE, - ) - ) { + if (ensureContentTypeOverride(converter, targetPartName, kind === 'header' ? HEADER_CONTENT_TYPE : FOOTER_CONTENT_TYPE)) { contentTypesChanged = true; } @@ -405,13 +401,7 @@ export function applyPageOneSectionDefaults( parseXml: (xml: string) => XmlElement, dryRun: boolean, ): SectionDefaultsResult { - const result: SectionDefaultsResult = { - detected: false, - applied: false, - changed: false, - changedParts: [], - warnings: [], - }; + const result: SectionDefaultsResult = { detected: false, applied: false, changed: false, changedParts: [], warnings: [] }; let parsedDoc: XmlElement; try { @@ -437,8 +427,7 @@ export function applyPageOneSectionDefaults( }); return result; } - const bodyProjection = - [...projections].reverse().find((p) => p.target.kind === 'body') ?? projections[projections.length - 1]; + const bodyProjection = [...projections].reverse().find((p) => p.target.kind === 'body') ?? projections[projections.length - 1]; if (!bodyProjection) { result.warnings.push({ code: 'SECTION_DEFAULTS_UNAVAILABLE', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts index e504cc8372..141e462d9b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts @@ -41,10 +41,7 @@ export function clone(value: T): T { function mergeIgnorableValues(currentValue: string | undefined, sourceValue: string | undefined): string | undefined { const merged = [ - ...new Set([ - ...(currentValue ?? '').split(/\s+/).filter(Boolean), - ...(sourceValue ?? '').split(/\s+/).filter(Boolean), - ]), + ...new Set([...(currentValue ?? '').split(/\s+/).filter(Boolean), ...(sourceValue ?? '').split(/\s+/).filter(Boolean)]), ]; return merged.length ? merged.join(' ') : undefined; } @@ -131,13 +128,8 @@ function replaceSingleton(stylesEl: XmlElement, name: string, sourceNode: XmlEle if (!stylesEl.elements) stylesEl.elements = []; const existingSingletons = stylesEl.elements.filter((c) => localName(c) === name); const existingIndex = stylesEl.elements.findIndex((c) => localName(c) === name); - const desiredIndex = - name === 'docDefaults' ? 0 : stylesEl.elements.some((c) => localName(c) === 'docDefaults') ? 1 : 0; - if ( - existingSingletons.length === 1 && - existingIndex === desiredIndex && - xmlDeepEqual(existingSingletons[0], sourceNode) - ) { + const desiredIndex = name === 'docDefaults' ? 0 : stylesEl.elements.some((c) => localName(c) === 'docDefaults') ? 1 : 0; + if (existingSingletons.length === 1 && existingIndex === desiredIndex && xmlDeepEqual(existingSingletons[0], sourceNode)) { return false; } @@ -184,11 +176,7 @@ export function mergeStylesAuthoritative(currentRoot: XmlElement, sourceRoot: Xm if (!curStyles.elements) curStyles.elements = []; // Singletons. - result.docDefaultsAdopted = replaceSingleton( - curStyles, - 'docDefaults', - firstChildByLocalName(srcStyles, 'docDefaults'), - ); + result.docDefaultsAdopted = replaceSingleton(curStyles, 'docDefaults', firstChildByLocalName(srcStyles, 'docDefaults')); result.latentStylesAdopted = replaceSingleton( curStyles, 'latentStyles', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts index e860678672..a1a2e92a83 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts @@ -124,13 +124,10 @@ describe('templates.apply adapter integration', () => { const beforeStyles = JSON.stringify(cvt.convertedXml['word/styles.xml']); const beforeTheme = cvt.convertedXml['word/theme/theme1.xml']; - const receipt = (await editor.doc.templates.apply( - { - source: { kind: 'base64', data }, - bodyPolicy: 'preserve', - }, - { dryRun: true }, - )) as TemplatesApplyReceipt; + const receipt = (await editor.doc.templates.apply({ + source: { kind: 'base64', data }, + bodyPolicy: 'preserve', + }, { dryRun: true })) as TemplatesApplyReceipt; expect(receipt.success).toBe(true); if (!receipt.success) return; @@ -169,12 +166,8 @@ describe('templates.apply adapter integration', () => { }); editor = newEditor(); - const cvt = ( - editor as unknown as { converter: { convertedXml: Record; schemaToXml: (d: unknown) => string } } - ).converter; - const bodyBeforeXml = cvt.schemaToXml( - (cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0], - ); + const cvt = (editor as unknown as { converter: { convertedXml: Record; schemaToXml: (d: unknown) => string } }).converter; + const bodyBeforeXml = cvt.schemaToXml((cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0]); const receipt = (await editor.doc.templates.apply({ source: { kind: 'base64', data } })) as TemplatesApplyReceipt; expect(receipt.success).toBe(true); @@ -191,9 +184,7 @@ describe('templates.apply adapter integration', () => { }); // Body preserved in-memory immediately after apply. - const bodyAfterXml = cvt.schemaToXml( - (cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0], - ); + const bodyAfterXml = cvt.schemaToXml((cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0]); expect(bodyAfterXml).toBe(bodyBeforeXml); // Export and re-unzip the real output. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts index df3db2ddb5..a1887c30d8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts @@ -214,11 +214,9 @@ interface FsLike { } function getBuiltinModule(id: string): T | undefined { - const proc = ( - globalThis as unknown as { - process?: { getBuiltinModule?: (moduleId: string) => unknown }; - } - ).process; + const proc = (globalThis as unknown as { + process?: { getBuiltinModule?: (moduleId: string) => unknown }; + }).process; if (typeof proc?.getBuiltinModule !== 'function') { return undefined; } @@ -242,7 +240,9 @@ function getNodeRequire(): ((id: string) => unknown) | undefined { } try { - return Function('try { return require; } catch { return undefined; }')() as ((id: string) => unknown) | undefined; + return Function('try { return require; } catch { return undefined; }')() as + | ((id: string) => unknown) + | undefined; } catch { return undefined; } @@ -298,9 +298,7 @@ function resolveSourceBytes(input: TemplatesApplyInput): ByteResult { bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); } else { - return { - failure: { code: 'CAPABILITY_UNAVAILABLE', message: 'templates.apply base64 source requires Buffer or atob.' }, - }; + return { failure: { code: 'CAPABILITY_UNAVAILABLE', message: 'templates.apply base64 source requires Buffer or atob.' } }; } return { bytes }; } catch { diff --git a/shared/font-system/src/resolver.test.ts b/shared/font-system/src/resolver.test.ts index 29490b8a41..b2b60080f4 100644 --- a/shared/font-system/src/resolver.test.ts +++ b/shared/font-system/src/resolver.test.ts @@ -462,9 +462,7 @@ describe('category_fallback (non-metric family fallback: Calibri Light -> Carlit physicalFamily: 'Calibri Light', reason: 'as_requested', }); - expect(r.resolvePhysicalFamilyForFace('Calibri Light, sans-serif', R400, noFaces)).toBe( - 'Calibri Light, sans-serif', - ); + expect(r.resolvePhysicalFamilyForFace('Calibri Light, sans-serif', R400, noFaces)).toBe('Calibri Light, sans-serif'); }); it('a customer fonts.map still overrides the category fallback (custom_mapping wins)', () => { diff --git a/shared/font-system/src/substitution-evidence.test.ts b/shared/font-system/src/substitution-evidence.test.ts index 9a441faea0..5bc3da58f3 100644 --- a/shared/font-system/src/substitution-evidence.test.ts +++ b/shared/font-system/src/substitution-evidence.test.ts @@ -24,7 +24,9 @@ describe('substitution evidence -> resolver derivation', () => { expect(resolver.resolvePrimaryPhysicalFamily(logical)).toBe(physical); } // The derivation input is exactly six rows: policyAction 'substitute' with a physical target. - const substituteRows = SUBSTITUTION_EVIDENCE.filter((r) => r.policyAction === 'substitute' && r.physicalFamily); + const substituteRows = SUBSTITUTION_EVIDENCE.filter( + (r) => r.policyAction === 'substitute' && r.physicalFamily, + ); expect(substituteRows).toHaveLength(EXPECTED_SUBSTITUTES.length); }); diff --git a/shared/font-system/src/substitution-evidence.ts b/shared/font-system/src/substitution-evidence.ts index 449d524543..588740186b 100644 --- a/shared/font-system/src/substitution-evidence.ts +++ b/shared/font-system/src/substitution-evidence.ts @@ -134,7 +134,10 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = Object.fre advance: { meanDelta: 0, maxDelta: 0 }, gates: { static: 'pass', metric: 'pass', layout: 'pass', ship: 'pass' }, policyAction: 'substitute', - measurementRefs: ['calibri__carlito#analytic_advance#2026-06-03', 'calibri__carlito#face_aggregate#2026-06-03'], + measurementRefs: [ + 'calibri__carlito#analytic_advance#2026-06-03', + 'calibri__carlito#face_aggregate#2026-06-03', + ], candidateLicense: 'OFL-1.1', exportRule: 'preserve_original_name', }, From 96ab4f824cb5b09b33d3901cc012ff50dc4f4146 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 15:40:56 -0300 Subject: [PATCH 13/34] fix(superdoc): key viewport-change dedup on fit, not raw available width (SD-3294) The mode-model rework widened the emit condition to any rounded availableWidth change, which the dedup unit test correctly rejected in CI: px-level jitter during a window drag would spam consumers with emits that cannot change any fit decision. Restore the intended key (rounded fitZoom plus rounded documentWidth); meaningful available-width changes already surface through fitZoom. --- .../src/composables/use-viewport-fit.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/superdoc/src/composables/use-viewport-fit.js b/packages/superdoc/src/composables/use-viewport-fit.js index e332104de6..d6826feb65 100644 --- a/packages/superdoc/src/composables/use-viewport-fit.js +++ b/packages/superdoc/src/composables/use-viewport-fit.js @@ -66,8 +66,8 @@ export const normalizePdfPageMeasurement = (measured, scaleFactor, zoomFactor) = /** * Viewport fit tracking. Maintains pure viewport metrics (available width, * document base width, fit zoom), stores them for `getViewportMetrics()`, - * emits `viewport-change` when they change, and applies the `fit-width` - * policy while `zoomMode` is `'fit-width'`. + * emits `viewport-change` when the fit they imply changes, and applies the + * `fit-width` policy while `zoomMode` is `'fit-width'`. * * Metrics are policy-free measurements: `availableWidth` is the container * width minus the comments sidebar when visible; `fitZoom` is the raw @@ -222,14 +222,17 @@ export function useViewportFit({ const metrics = { availableWidth, documentWidth, fitZoom }; - // Store and emit when the measurements change, including base-width - // changes (page size or orientation) at a constant available width. + // Store and emit when the fit changes: rounded fitZoom plus base-width + // changes (page size or orientation) at a constant ratio. Deliberately + // NOT keyed on availableWidth itself: px-level jitter during a window + // drag would spam consumers with emits that cannot change any fit + // decision, while every meaningful available-width change (sidebar + // toggle, real resize) already moves fitZoom. The stored metrics can + // therefore lag by a sub-percent sliver of availableWidth between + // emits. const previous = viewportMetrics.value; const changed = - !previous || - previous.fitZoom !== fitZoom || - Math.round(previous.documentWidth) !== Math.round(documentWidth) || - Math.round(previous.availableWidth) !== Math.round(availableWidth); + !previous || previous.fitZoom !== fitZoom || Math.round(previous.documentWidth) !== Math.round(documentWidth); if (changed) { viewportMetrics.value = metrics; superdoc.emit('viewport-change', metrics); From a58352ccc3baab85b4d9588e7ad60dc6531e04ee Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 21:36:50 -0300 Subject: [PATCH 14/34] fix(super-editor): preserve generated line breaks in DOCX export (SD-3278) Multi-line text in text-mode mutations stored newlines as a raw \n inside one , which Word collapses while SuperDoc renders a break. Convert newlines to lineBreak nodes at creation, split any residual raw newline into / on export, and make the read model agree that a lineBreak reads as \n so rewrite/search/query stay consistent. Serializes as a Word-native (ECMA-376 17.3.3.1). - buildTextWithTabs: normalize \n, \r\n, \r to lineBreak nodes, gated on parent admission (probed per edit position) for text*-only parents - materializeLineBreak: prefer lineBreak over hardBreak (soft, not page) - getTextNodeForExport: split residual raw newline into / - del-translator: rename every in a split run to - lineBreak.leafText = '\n' so textBetweenWithTabs / charOffsetToDocPos / text-offset-resolver read a break as \n; idempotent rewrite no longer duplicates it, a rewrite to single-line text removes it - SearchIndex honors leafText, and a single hit spanning text+lineBreak+ text coalesces to one contiguous range so query.match('Alpha\nBeta') works (block separators still split; D5 guard intact) - list paragraph beforeinput removes the placeholder break when text is typed; visible text models skip tracked-deleted leaf nodes --- .../v3/handlers/w/del/del-translator.js | 18 +- .../v3/handlers/w/del/del-translator.test.js | 31 ++ .../w/t/helpers/translate-text-node.js | 37 +- .../w/t/helpers/translate-text-node.test.js | 76 +++ .../helpers/text-offset-resolver.test.ts | 61 ++- .../helpers/text-offset-resolver.ts | 19 +- .../helpers/text-with-tabs.test.ts | 113 ++++- .../helpers/text-with-tabs.ts | 89 +++- .../plan-engine/content-controls-wrappers.ts | 5 +- .../plan-engine/executor.ts | 46 +- .../newline-handling.integration.test.ts | 470 ++++++++++++++++++ .../node-materializer.ts | 10 +- .../structural-write-engine.test.ts | 68 +++ .../v1/extensions/line-break/line-break.js | 11 + .../v1/extensions/paragraph/paragraph.js | 15 +- .../v1/extensions/search/SearchIndex.js | 92 +++- .../v1/extensions/search/SearchIndex.test.js | 73 +++ .../editors/v1/extensions/search/search.js | 3 +- 18 files changed, 1167 insertions(+), 70 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index cb7339e376..888ffc46ba 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -104,12 +104,18 @@ function decode(params) { return null; } - // ECMA-376 renames w:t → w:delText inside . Other inline content — - // w:noBreakHyphen, w:tab, w:br, etc. — stays as-is; the deletion is - // conveyed by the wrapper alone. Guard the rename so non-text - // atoms inside don't crash. - const textNode = translatedTextNode.elements.find((n) => n.name === 'w:t'); - if (textNode) textNode.name = 'w:delText'; + // ECMA-376 (17.3.3.7) requires w:delText for ALL text runs inside . A + // single run can now hold multiple siblings, because the newline export + // safety net splits text around (e.g. AlphaBeta), + // so rename every direct w:t, not just the first; a leftover inside + // would not be treated as deleted. Other inline content + // (w:noBreakHyphen, w:tab, w:br, etc.) stays as-is; the wrapper alone + // conveys the deletion. + (translatedTextNode.elements || []) + .filter((n) => n.name === 'w:t') + .forEach((n) => { + n.name = 'w:delText'; + }); return { name: 'w:del', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 4aceb41c3d..643fc19441 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -177,6 +177,37 @@ describe('w:del translator', () => { expect(result.elements[0].elements[0].name).toBe('w:delText'); }); + it('renames every in a multi-segment run to (newline split)', () => { + const mockTrackedMark = { + type: 'trackDelete', + attrs: { + id: '789', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + + // The newline export safety net produces one run with interleaved w:t/w:br; + // every w:t inside must become w:delText, not just the first. + exportSchemaToJson.mockReturnValue({ + name: 'w:r', + elements: [ + { name: 'w:t', elements: [{ text: 'Alpha', type: 'text' }] }, + { name: 'w:br' }, + { name: 'w:t', elements: [{ text: 'Beta', type: 'text' }] }, + ], + }); + + const node = { type: 'text', text: 'Alpha\nBeta', marks: [mockTrackedMark] }; + const result = config.decode({ node }); + + const run = result.elements[0]; + expect(run.elements.map((n) => n.name)).toEqual(['w:delText', 'w:br', 'w:delText']); + expect(run.elements.some((n) => n.name === 'w:t')).toBe(false); + }); + it('writes sourceId to w:id for round-trip fidelity', () => { const mockTrackedMark = { type: 'trackDelete', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js index e9eee7aca8..0586bc1b6c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js @@ -44,11 +44,38 @@ export function getTextNodeForExport(text, marks, params) { partPath: resolveExportPartPath(params), }); - textNodes.push({ - name: 'w:t', - elements: [{ text, type: 'text' }], - attributes: nodeAttrs, - }); + const textValue = typeof text === 'string' ? text : ''; + // Normalize CRLF/CR to LF so Windows line endings export Word-native breaks + // too, rather than leaving a stray carriage return inside . + const normalizedText = textValue.includes('\r') ? textValue.replace(/\r\n?/g, '\n') : textValue; + if (normalizedText.includes('\n')) { + // Export safety net: a raw newline inside is whitespace that Word + // collapses on open (it is not the OOXML representation of a line break), + // while SuperDoc still renders it as a break: the SD-3278 + // divergence. Emit a Word-native between + // segments instead. Everything stays inside this single run so the + // surrounding / wrappers keep wrapping exactly one run. + const segments = normalizedText.split('\n'); + segments.forEach((segment, index) => { + if (segment.length > 0) { + const segmentNeedsSpace = /^\s|\s$/.test(segment); + textNodes.push({ + name: 'w:t', + elements: [{ text: segment, type: 'text' }], + attributes: segmentNeedsSpace ? { 'xml:space': 'preserve' } : null, + }); + } + if (index < segments.length - 1) { + textNodes.push({ name: 'w:br' }); + } + }); + } else { + textNodes.push({ + name: 'w:t', + elements: [{ text: normalizedText, type: 'text' }], + attributes: nodeAttrs, + }); + } // For custom mark export, we need to add a bookmark start and end tag // And store attributes in the bookmark name diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js index 75f0a66550..979c3b9b37 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js @@ -134,4 +134,80 @@ describe('getTextNodeForExport', () => { const runPropertiesChange = runProperties.elements.find((element) => element.name === 'w:rPrChange'); expect(runPropertiesChange.attributes['w:id']).toBe('7'); }); + + // SD-3278 export safety net: a raw newline left inside a PM text + // node (e.g. from an imported .docx that stored breaks as literal '\n') must + // export as a Word-native , not a collapsed newline inside . + describe('raw newline export safety net', () => { + const contentElements = (result) => result.elements.filter((el) => el.name === 'w:t' || el.name === 'w:br'); + + it('exports a single newline as // within one run', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + expect(result.name).toBe('w:r'); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + expect(content[0].elements[0].text).toBe('Alpha'); + expect(content[2].elements[0].text).toBe('Beta'); + }); + + it('never leaves a raw newline inside a ', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts.some((el) => el.elements[0].text.includes('\n'))).toBe(false); + }); + + it('emits a soft break (no w:type="page") for the ', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const br = result.elements.find((el) => el.name === 'w:br'); + expect(br).toBeDefined(); + expect(br.attributes?.['w:type']).toBeUndefined(); + }); + + it('leaves newline-free text as a single (unchanged)', () => { + const result = getTextNodeForExport('hello world', [], buildParams()); + const content = contentElements(result); + expect(content).toHaveLength(1); + expect(content[0].name).toBe('w:t'); + expect(content[0].elements[0].text).toBe('hello world'); + }); + + it('emits a for each newline including leading, trailing, and consecutive newlines', () => { + const result = getTextNodeForExport('\nA\n\nB\n', [], buildParams()); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:br', 'w:t', 'w:br', 'w:br', 'w:t', 'w:br']); + const texts = content.filter((el) => el.name === 'w:t').map((el) => el.elements[0].text); + expect(texts).toEqual(['A', 'B']); + }); + + it('sets xml:space="preserve" only on segments with edge whitespace', () => { + const result = getTextNodeForExport('Alpha \n Beta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts[0].elements[0].text).toBe('Alpha '); + expect(texts[0].attributes).toEqual({ 'xml:space': 'preserve' }); + expect(texts[1].elements[0].text).toBe(' Beta'); + expect(texts[1].attributes).toEqual({ 'xml:space': 'preserve' }); + }); + + it('does not set xml:space on segments without edge whitespace', () => { + const result = getTextNodeForExport('Alpha\nBeta', [], buildParams()); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts[0].attributes).toBeNull(); + expect(texts[1].attributes).toBeNull(); + }); + + it('normalizes CRLF to a on export', () => { + const content = contentElements(getTextNodeForExport('Alpha\r\nBeta', [], buildParams())); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + expect(content[0].elements[0].text).toBe('Alpha'); + expect(content[2].elements[0].text).toBe('Beta'); + }); + + it('normalizes a bare CR to a without leaving a stray carriage return in ', () => { + const result = getTextNodeForExport('Alpha\rBeta', [], buildParams()); + const content = contentElements(result); + expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']); + const texts = result.elements.filter((el) => el.name === 'w:t'); + expect(texts.some((el) => el.elements[0].text.includes('\r'))).toBe(false); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts index eb1374ccc8..1ab66b302b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -9,6 +9,7 @@ import { type NodeOptions = { text?: string; marks?: Array<{ type: { name: string } }>; + leafText?: (node: ProseMirrorNode) => string; isInline?: boolean; isBlock?: boolean; isLeaf?: boolean; @@ -28,7 +29,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; return { - type: { name: typeName }, + type: { name: typeName, spec: options.leafText ? { leafText: options.leafText } : {} }, text: isText ? text : undefined, nodeSize, isText, @@ -138,6 +139,25 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 6, to: 7 }); }); + + it('maps visible offsets across tracked deleted leaf nodes without counting them', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 1, end: 2 }, { textModel: 'visible' }); + + expect(result).toEqual({ from: 3, to: 4 }); + }); }); describe('computeTextContentLength', () => { @@ -204,6 +224,26 @@ describe('computeTextContentLength', () => { expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2); expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB'); }); + + it('excludes tracked deleted leaf nodes in the visible text model', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + expect(computeTextContentLength(paragraph)).toBe(3); + expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2); + expect(textContentInBlock(paragraph)).toBe('A\nB'); + expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB'); + }); }); describe('pmPositionToTextOffset', () => { @@ -268,4 +308,23 @@ describe('pmPositionToTextOffset', () => { expect(pmPositionToTextOffset(paragraph, 0, 6, { textModel: 'visible' })).toBe(1); expect(pmPositionToTextOffset(paragraph, 0, 7, { textModel: 'visible' })).toBe(2); }); + + it('keeps PM positions inside tracked deleted leaf nodes at the surrounding visible offset', () => { + const textA = createNode('text', [], { text: 'A' }); + const deletedBreak = createNode('lineBreak', [], { + isInline: true, + isLeaf: true, + marks: [{ type: { name: 'trackDelete' } }], + leafText: () => '\n', + }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deletedBreak, textB], { + isBlock: true, + inlineContent: true, + }); + + expect(pmPositionToTextOffset(paragraph, 0, 2, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 3, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 4, { textModel: 'visible' })).toBe(2); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts index 5395f05ce1..17e95eb01d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts @@ -29,6 +29,10 @@ function shouldSkipTextNode(node: ProseMirrorNode, options?: TextOffsetOptions): return isVisibleTextModel(options) && hasTrackDeleteMark(node); } +function shouldSkipLeafNode(node: ProseMirrorNode, options?: TextOffsetOptions): boolean { + return isVisibleTextModel(options) && hasTrackDeleteMark(node); +} + function resolveSegmentPosition( targetOffset: number, segmentStart: number, @@ -87,6 +91,10 @@ export function pmPositionToTextOffset( if (node.isLeaf) { const endPos = docPos + node.nodeSize; + if (shouldSkipLeafNode(node, options)) { + if (pmPos < endPos) done = true; + return; + } if (pmPos >= endPos) { offset += 1; } else { @@ -141,6 +149,7 @@ export function computeTextContentLength(blockNode: ProseMirrorNode, options?: T return; } if (node.isLeaf) { + if (shouldSkipLeafNode(node, options)) return; length += 1; return; } @@ -229,6 +238,7 @@ export function resolveTextRangeInBlock( } if (node.isLeaf) { + if (shouldSkipLeafNode(node, options)) return; advanceSegment(1, docPos, docPos + node.nodeSize); return; } @@ -262,7 +272,14 @@ export function textContentInBlock(blockNode: ProseMirrorNode, options?: TextOff } if (node.isLeaf) { - text += '\ufffc'; + if (shouldSkipLeafNode(node, options)) return; + // Honor a leaf's declared visible text (e.g. lineBreak -> '\n', + // noBreakHyphen -> U+2011) so this content model agrees with the visible + // document and with the offset model. All leafText values are one + // character, matching the 1-per-leaf length used by the offset helpers + // above; other leaves fall back to the U+FFFC placeholder. + const leafText = (node.type?.spec as { leafText?: (n: ProseMirrorNode) => string } | undefined)?.leafText; + text += typeof leafText === 'function' ? leafText(node) : '\ufffc'; return; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts index 8f5c99b9a8..a63576cf03 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { Fragment, Schema } from 'prosemirror-model'; import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from './text-with-tabs.js'; -function makeRealSchema(options: { hasTab?: boolean; hasNoBreakHyphen?: boolean; hasGenericLeaf?: boolean } = {}) { +function makeRealSchema( + options: { hasTab?: boolean; hasLineBreak?: boolean; hasNoBreakHyphen?: boolean; hasGenericLeaf?: boolean } = {}, +) { const nodes: Record = { doc: { content: 'paragraph+' }, paragraph: { group: 'block', content: 'inline*' }, @@ -13,6 +15,11 @@ function makeRealSchema(options: { hasTab?: boolean; hasNoBreakHyphen?: boolean; // Tab is non-leaf, which is why `textBetweenWithTabs` (not PM's built-in textBetween) is needed. nodes.tab = { group: 'inline', inline: true, atom: true, content: 'inline*' }; } + if (options.hasLineBreak) { + // Mirrors the real extensions/line-break/line-break.js shape: inline atom + // that disallows marks (`marks: ''`) and renders to
/ exports to . + nodes.lineBreak = { group: 'inline', inline: true, atom: true, marks: '' }; + } if (options.hasNoBreakHyphen) { // Mirrors the real extensions/no-break-hyphen schema: inline leaf atom with leafText. nodes.noBreakHyphen = { group: 'inline', inline: true, atom: true, leafText: () => '‑' }; @@ -93,12 +100,114 @@ describe('buildTextWithTabs', () => { expect(result.child(0).text).toBe('x'); expect(result.child(0).marks.some((m: any) => m.type.name === 'bold')).toBe(true); expect(result.child(1).type.name).toBe('tab'); - // Tab carries the run's marks — the OOXML exporter reads node.marks on tab + // Tab carries the run's marks. The OOXML exporter reads node.marks on tab // nodes (tab-translator.js:53) to emit matching around . expect(result.child(1).marks.some((m: any) => m.type.name === 'bold')).toBe(true); expect(result.child(2).text).toBe('y'); expect(result.child(2).marks.some((m: any) => m.type.name === 'bold')).toBe(true); }); + + // SD-3278: a literal '\n' inside a text node exports as raw newline + // inside , which Word collapses. It must become a `lineBreak` node so the + // exporter emits a Word-native . + it('splits text around a single newline into text + lineBreak + text', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined); + expect(result).toBeInstanceOf(Fragment); + const fragment = result as Fragment; + expect(fragment.childCount).toBe(3); + expect(fragment.child(0).text).toBe('Alpha'); + expect(fragment.child(1).type.name).toBe('lineBreak'); + expect(fragment.child(2).text).toBe('Beta'); + }); + + it('emits a lineBreak node even when the schema has no tab node type', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined) as Fragment; + expect(result).toBeInstanceOf(Fragment); + expect(result.childCount).toBe(3); + expect(result.child(1).type.name).toBe('lineBreak'); + }); + + it('keeps the raw newline in a single text node when the schema has no lineBreak node type', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('Alpha\nBeta'); + }); + + it('creates the lineBreak node bare in the creation path', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const boldMark = schema.marks.bold.create(); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', [boldMark]) as Fragment; + expect(result.child(0).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(1).marks.length).toBe(0); + expect(result.child(2).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + }); + + it('omits empty segments around leading, trailing, and consecutive newlines', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const lead = buildTextWithTabs(schema, '\nfoo', undefined) as Fragment; + expect(lead.childCount).toBe(2); + expect(lead.child(0).type.name).toBe('lineBreak'); + expect(lead.child(1).text).toBe('foo'); + + const doubled = buildTextWithTabs(schema, 'a\n\nb', undefined) as Fragment; + expect(doubled.childCount).toBe(4); + expect(doubled.child(0).text).toBe('a'); + expect(doubled.child(1).type.name).toBe('lineBreak'); + expect(doubled.child(2).type.name).toBe('lineBreak'); + expect(doubled.child(3).text).toBe('b'); + }); + + it('interleaves tab and lineBreak nodes when both control characters are present', () => { + const schema = makeRealSchema({ hasTab: true, hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'a\tb\nc', undefined) as Fragment; + expect(result.childCount).toBe(5); + expect(result.child(0).text).toBe('a'); + expect(result.child(1).type.name).toBe('tab'); + expect(result.child(2).text).toBe('b'); + expect(result.child(3).type.name).toBe('lineBreak'); + expect(result.child(4).text).toBe('c'); + }); + + it('keeps the raw tab literal but still splits the newline when tabs are disallowed', () => { + const schema = makeRealSchema({ hasTab: true, hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'a\tb\nc', undefined, { parentAllowsTab: false }) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('a\tb'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('c'); + }); + + // Generated/SDK text often uses CRLF; normalize CRLF and bare CR to + // line breaks so no stray carriage return survives in a text segment. + it('normalizes CRLF to a lineBreak node', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\r\nBeta', undefined) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('Alpha'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('Beta'); + }); + + it('normalizes a bare CR to a lineBreak node without leaving a stray carriage return', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\rBeta', undefined) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('Alpha'); + expect(result.child(1).type.name).toBe('lineBreak'); + expect(result.child(2).text).toBe('Beta'); + }); + + // A `text*`-only parent (e.g. total-page-number) rejects lineBreak. + it('keeps the raw newline in a single text node when parentAllowsLineBreak is false', () => { + const schema = makeRealSchema({ hasLineBreak: true }); + const result = buildTextWithTabs(schema, 'Alpha\nBeta', undefined, { parentAllowsLineBreak: false }); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('Alpha\nBeta'); + }); }); describe('parentAllowsNodeAt', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts index 3a44a8582f..82e9bbf64f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts @@ -10,38 +10,77 @@ import type { Transaction } from 'prosemirror-state'; import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; /** - * Build a text-or-fragment suitable for insertion, splitting on '\t' and - * inserting schema `tab` nodes at each split. + * Build a text-or-fragment suitable for insertion, splitting on '\t' and '\n' + * and inserting schema `tab` / `lineBreak` nodes at each split. * - * Returns a plain text node when the schema has no `tab` node type, when the - * parent disallows tab nodes (see {@link parentAllowsTabAt}), or when the text - * contains no tab characters. The raw '\t' is preserved inside the text node - * so exporters and readers still see the character. + * A tab split produces a `tab` node; a newline split produces a `lineBreak` + * node. Each is only performed when the schema exposes the matching node type + * (and, for tabs, when the parent admits them; see {@link parentAllowsNodeAt}). + * A control character that cannot be materialized stays literal inside the + * surrounding text node so exporters and readers still see it. * - * Callers are responsible for ensuring `text` is non-empty (ProseMirror's - * `schema.text` throws on empty input). + * Newlines must become `lineBreak` nodes (not raw '\n' inside a text node): + * a literal newline inside `` is whitespace that Word collapses on open + * (it is not the OOXML representation of a line break), so it would drop the + * visible break while SuperDoc still renders it. The `lineBreak` node + * round-trips to a Word-native ``. See SD-3278. + * + * Returns a plain text node when the text contains no splittable control + * character (the common case). Callers are responsible for ensuring `text` is + * non-empty (ProseMirror's `schema.text` throws on empty input). */ export function buildTextWithTabs( schema: Schema, text: string, marks: readonly ProseMirrorMark[] | undefined, - opts: { parentAllowsTab?: boolean } = {}, + opts: { parentAllowsTab?: boolean; parentAllowsLineBreak?: boolean } = {}, ): ProseMirrorNode | ProseMirrorFragment { - // Check the cheapest/most selective predicate first — most calls carry no '\t'. - if (!text.includes('\t')) return schema.text(text, marks); + // Normalize CRLF/CR to LF up front so Windows line endings (common in + // generated/SDK text) are treated as line breaks too. Only allocate when a + // carriage return is actually present. + const normalized = text.includes('\r') ? text.replace(/\r\n?/g, '\n') : text; + + // Check the cheapest/most selective predicate first; most calls carry neither. + const hasTab = normalized.includes('\t'); + const hasNewline = normalized.includes('\n'); + if (!hasTab && !hasNewline) return schema.text(normalized, marks); + + // `lineBreak` is a normal inline node, but some textblocks restrict their + // content to `text*` (e.g. total-page-number) and reject it. Gate on parent + // admission the same way tabs do: callers that target restrictive parents + // pass the probe result; others default to allowed. When disallowed or absent + // we fall back to literal text, and the export safety net still converts any + // raw newline to `` on export. + const tabNodeType = hasTab && opts.parentAllowsTab !== false ? schema.nodes?.tab : undefined; + const lineBreakNodeType = hasNewline && opts.parentAllowsLineBreak !== false ? schema.nodes?.lineBreak : undefined; + if (!tabNodeType && !lineBreakNodeType) return schema.text(normalized, marks); - const tabNodeType = schema.nodes?.tab; - if (!tabNodeType || opts.parentAllowsTab === false) return schema.text(text, marks); + // `NodeType.create` takes `readonly Mark[] | undefined` (not null) for marks. + const tabMarks: readonly ProseMirrorMark[] | undefined = marks ?? undefined; + + // Split only on the control characters we can replace with a node; any other + // control character stays literal inside the surrounding text segments. + const splitPattern = [tabNodeType ? '\\t' : null, lineBreakNodeType ? '\\n' : null].filter(Boolean).join('|'); + const parts = normalized.split(new RegExp(`(${splitPattern})`)); - const tabMarks = (marks ?? null) as ProseMirrorMark[] | null; - const parts = text.split('\t'); const nodes: ProseMirrorNode[] = []; - for (let i = 0; i < parts.length; i++) { - if (parts[i]) nodes.push(schema.text(parts[i], marks)); - // Carry the surrounding marks onto the tab node so the OOXML exporter - // wraps `` in a matching `` — keeps formatting unbroken - // across the tab (bold-run | tab | bold-run rather than bold | plain | bold). - if (i < parts.length - 1) nodes.push(tabNodeType.create(null, null, tabMarks)); + for (const part of parts) { + if (part === '') continue; // schema.text throws on empty input + if (part === '\t' && tabNodeType) { + // Carry the surrounding marks onto the tab node so the OOXML exporter + // wraps `` in a matching ``, keeping formatting unbroken + // across the tab (bold-run | tab | bold-run rather than bold | plain | bold). + nodes.push(tabNodeType.create(null, null, tabMarks)); + } else if (part === '\n' && lineBreakNodeType) { + // Create the break bare: a soft line break carries no run formatting. + // (Tracking an inserted break so it exports inside is a separate + // concern: br-translator does not yet route node.marks the way + // noBreakHyphen's translator does; see SD-3371. It is not a schema limit: + // a leaf atom can carry marks, governed by the parent run/paragraph.) + nodes.push(lineBreakNodeType.create()); + } else { + nodes.push(schema.text(part, marks)); + } } return Fragment.from(nodes); } @@ -128,8 +167,14 @@ export function textBetweenWithTabs( } if (node.isLeaf) { if (node.isInline) { + if ( + options.textModel === 'visible' && + node.marks?.some((mark: ProseMirrorMark) => mark.type.name === TrackDeleteMarkName) + ) { + return false; + } // Honor PM's `leafText` NodeSpec contract: an inline leaf can declare - // its visible text representation (e.g. noBreakHyphen → U+2011) so + // its visible text representation (e.g. noBreakHyphen -> U+2011) so // flattened reads match the rendered glyph instead of producing the // generic placeholder. Falls back to `leafFallback` when undefined. const leafTextFn = node.type?.spec?.leafText; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index 82ab9e11ff..4deabd771e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -997,7 +997,10 @@ function insertTextAroundSdt( const { tr } = editor.state; const tabType = editor.schema.nodes?.tab; const parentAllowsTab = tabType && content.includes('\t') ? parentAllowsNodeAt(tr, pos, tabType) : false; - tr.insert(pos, buildTextWithTabs(editor.schema, content, undefined, { parentAllowsTab })); + const lineBreakType = editor.schema.nodes?.lineBreak; + const parentAllowsLineBreak = + lineBreakType && /[\r\n]/.test(content) ? parentAllowsNodeAt(tr, pos, lineBreakType) : false; + tr.insert(pos, buildTextWithTabs(editor.schema, content, undefined, { parentAllowsTab, parentAllowsLineBreak })); dispatchTransaction(editor, tr); return 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 cf2c46d86b..0ec53f6615 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 @@ -886,6 +886,16 @@ export function executeTextRewrite( const replacementText = getReplacementText(step.args.replacement); const marks = resolveMarksForRange(editor, target, step); + // A rewrite range can start in a normal parent (a run) and span into a + // `text*`-only inline container (e.g. total-page-number) that rejects + // lineBreak. The edits below land at remapped positions whose parent may + // differ from `absFrom`, so probe admission at each actual edit position + // rather than once at the range start: a newline that lands in a restrictive + // parent falls back to literal text (the export safety net still emits + // ). Probe on the current `tr` so prior in-loop steps are reflected. + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreakAt = (pos: number): boolean => + lineBreakNodeType ? parentAllowsNodeAt(tr, pos, lineBreakNodeType) : false; const structuralRewrite = resolveStructuralRangeRewrite(tr.doc, absFrom, absTo, step); if (structuralRewrite) { @@ -935,7 +945,9 @@ export function executeTextRewrite( tr.delete(absFrom, absTo); return { changed: target.text.length > 0 }; } - const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(absFrom), + }); tr.replaceWith(absFrom, absTo, content); return { changed: replacementText !== target.text }; } @@ -991,10 +1003,14 @@ export function executeTextRewrite( if (change.type === 'delete') { tr.delete(remap(change.docFrom), remap(change.docTo)); } else if (change.type === 'insert') { - const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(remap(change.docPos)), + }); tr.insert(remap(change.docPos), content); } else { - const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(remap(change.docFrom)), + }); tr.replaceWith(remap(change.docFrom), remap(change.docTo), content); } } @@ -1002,12 +1018,15 @@ export function executeTextRewrite( // Pure deletion after trimming: a non-empty replacement whose new text is // fully contained in the old text's common prefix + suffix collapses to an // empty delta (e.g. "best endeavours to:" → "endeavours to:" leaves - // trimmedNew === ""). Delete the removed range rather than building - // schema.text('') — ProseMirror rejects empty text nodes. + // trimmedNew === ""). This also covers removing a lineBreak, now that + // lineBreak counts as "\n" in the diff. Delete the removed range rather than + // building schema.text(''), which ProseMirror rejects for empty text. tr.delete(trimmedFrom, trimmedTo); } else { // 0 or 1 word change: replace just the trimmed range. - const content = buildTextWithTabs(editor.state.schema, trimmedNew, asProseMirrorMarks(marks)); + const content = buildTextWithTabs(editor.state.schema, trimmedNew, asProseMirrorMarks(marks), { + parentAllowsLineBreak: parentAllowsLineBreakAt(trimmedFrom), + }); tr.replaceWith(trimmedFrom, trimmedTo, content); } @@ -1138,7 +1157,12 @@ export function executeTextInsert( const tabNodeType = editor.state.schema.nodes?.tab; const parentAllowsTab = tabNodeType && text.includes('\t') ? parentAllowsNodeAt(tr, absPos, tabNodeType) : false; - tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab })); + // Restrictive parents like total-page-number are `text*` and reject lineBreak; + // probe admission so newline-bearing inserts fall back to literal text there. + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreak = + lineBreakNodeType && /[\r\n]/.test(text) ? parentAllowsNodeAt(tr, absPos, lineBreakNodeType) : false; + tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab, parentAllowsLineBreak })); return { changed: true }; } @@ -1323,7 +1347,13 @@ export function executeSpanTextRewrite( // For single replacement block, use flat replacement into the span if (replacementBlocks.length === 1) { const marks = resolveSpanMarks(editor, target, policy, step.id); - const content = buildTextWithTabs(editor.state.schema, replacementBlocks[0], asProseMirrorMarks(marks)); + // 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). + const lineBreakNodeType = editor.state.schema.nodes?.lineBreak; + const parentAllowsLineBreak = lineBreakNodeType ? parentAllowsNodeAt(tr, absFrom, lineBreakNodeType) : false; + const content = buildTextWithTabs(editor.state.schema, replacementBlocks[0], asProseMirrorMarks(marks), { + parentAllowsLineBreak, + }); tr.replaceWith(absFrom, absTo, content); return { changed: true }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts new file mode 100644 index 0000000000..442c73c1be --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/newline-handling.integration.test.ts @@ -0,0 +1,470 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { executeTextInsert, executeTextRewrite } from './executor.ts'; + +/** + * SD-3278: multi-line text forwarded into text-mode mutations (e.g. + * an agentic SDK workflow) must export Word-native line breaks, not a raw '\n' + * inside one that Word collapses. + * + * Two paths are covered: + * - Creation path: doc.replace / text.rewrite with a single '\n' must build a + * `lineBreak` PM node (not a literal '\n' in a text node). + * - Export safety net: a text node that already holds a raw '\n' (e.g. from an + * imported .docx that stored breaks as literal newlines) must still export a + * Word-native . + */ + +function makeSchemaEditor(paragraphs: string[] = ['hello world']) { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: paragraphs.map((text) => ({ + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }], + })), + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; +} + +function getFirstMatchRef(editor: any, pattern: string): string { + const match = editor.doc.query.match({ select: { type: 'text', pattern }, require: 'first' }); + const ref = match?.items?.[0]?.handle?.ref; + if (!ref) throw new Error(`Could not resolve ref for pattern "${pattern}"`); + return ref; +} + +function hasNodeOfType(editor: any, name: string): boolean { + let found = false; + editor.state.doc.descendants((node: any) => { + if (found) return false; + if (node.type.name === name) { + found = true; + return false; + } + return true; + }); + return found; +} + +function paragraphCount(editor: any): number { + let count = 0; + editor.state.doc.forEach((node: any) => { + if (node.type.name === 'paragraph') count += 1; + }); + return count; +} + +function countNodeType(editor: any, name: string): number { + let count = 0; + editor.state.doc.descendants((node: any) => { + if (node.type.name === name) count += 1; + }); + return count; +} + +describe('text-mode mutations: single newline handling (SD-3278)', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('direct doc.replace with a single newline builds a lineBreak node inside one paragraph', () => { + editor = makeSchemaEditor(['hello world']); + + const receipt = editor.doc.replace( + { ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // A single '\n' stays within the paragraph (only '\n\n+' splits paragraphs). + expect(paragraphCount(editor)).toBe(1); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(true); + // It must NOT become a page break. + expect(hasNodeOfType(editor, 'hardBreak')).toBe(false); + }); + + it('tracked doc.replace with a single newline builds a lineBreak node', () => { + editor = makeSchemaEditor(['hello world']); + + const receipt = editor.doc.replace( + { ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(true); + expect(hasNodeOfType(editor, 'hardBreak')).toBe(false); + }); + + // Read-model consistency (SD-3278): a lineBreak created on the write side must + // read back as '\n' on the diff/search paths, or query.match cannot find + // break-bearing content and an identical rewrite duplicates the break. + it('finds break-bearing content by \\n and is idempotent under an identical rewrite (no duplicate break)', () => { + editor = makeSchemaEditor(['hello world']); + editor.doc.replace({ ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + // query.match must see the break as '\n' to resolve a ref to the content. + const ref = getFirstMatchRef(editor, 'Alpha\nBeta'); + editor.doc.replace({ ref, text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + // The identical rewrite is a no-op: still exactly one break, not two. + expect(countNodeType(editor, 'lineBreak')).toBe(1); + }); + + it('rewriting break-bearing content to single-line text removes the break', () => { + editor = makeSchemaEditor(['hello world']); + editor.doc.replace({ ref: getFirstMatchRef(editor, 'hello world'), text: 'Alpha\nBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + const ref = getFirstMatchRef(editor, 'Alpha\nBeta'); + editor.doc.replace({ ref, text: 'AlphaBeta' }, { changeMode: 'direct' }); + expect(countNodeType(editor, 'lineBreak')).toBe(0); + expect(editor.state.doc.firstChild?.textContent).toBe('AlphaBeta'); + }); + + // Proves the bot P1 (rewrite char-diff counts the break) DIRECTLY, without + // query.match: rewriting `AlphaBeta` to the same visible text must + // be a no-op (the diff reads the break as '\n' via leafText), so it neither + // duplicates nor strands the break. + it('an identical rewrite over an existing lineBreak node is a no-op (direct target)', () => { + // paragraph > run > [ text 'Alpha', lineBreak, text 'Beta' ] + editor = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'text', text: 'Alpha' }, { type: 'lineBreak' }, { type: 'text', text: 'Beta' }], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + expect(countNodeType(editor, 'lineBreak')).toBe(1); + + // Span the inline content: first text/lineBreak start .. last node end. + let absFrom = -1; + let absTo = -1; + editor.state.doc.descendants((node: any, pos: number) => { + if (node.isText || node.type.name === 'lineBreak') { + if (absFrom === -1) absFrom = pos; + absTo = pos + node.nodeSize; + } + }); + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'idem', + op: 'text.rewrite', + blockId: '__selection__', + from: 0, + to: 0, + absFrom, + absTo, + text: 'Alpha\nBeta', + capturedStyle: undefined, + } as any; + const step = { + id: 'idem-rewrite', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'Alpha\nBeta' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any); + editor.dispatch(tr); + + // Still exactly one break: not duplicated (the bot P1 regression) and not removed. + expect(countNodeType(editor, 'lineBreak')).toBe(1); + expect(editor.state.doc.firstChild?.textContent).toBe('Alpha\nBeta'); + }); +}); + +// lineBreak must not be forced into a parent that rejects it. The +// total-page-number node is `content: 'text*'`, so a newline insert there must +// fall back to literal text rather than throwing (mirrors the tab guard). +describe('newline insert into a restrictive (text*) parent', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + function makeEditorWithTotalPageCount() { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'total-page-number', attrs: {}, content: [{ type: 'text', text: '7' }] }], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + } + + function findTotalPageNumberPos(ed: any): number { + let pos: number | undefined; + ed.state.doc.descendants((node: any, nodePos: number) => { + if (pos !== undefined) return false; + if (node.type.name === 'total-page-number') { + pos = nodePos; + return false; + } + return true; + }); + if (pos === undefined) throw new Error('total-page-number node not found'); + return pos; + } + + it('inserts a\\nb into total-page-number without throwing and without creating a lineBreak node', () => { + editor = makeEditorWithTotalPageCount(); + const nodePos = findTotalPageNumberPos(editor); + const innerPos = nodePos + 1; // inside the total-page-number, before its '7' + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'step-1', + op: 'text.insert', + blockId: 'total-page-number-1', + from: 0, + to: 0, + absFrom: innerPos, + absTo: innerPos, + text: '', + marks: [], + } as any; + const step = { + id: 'insert-newline-into-total-page-number', + op: 'text.insert', + where: { by: 'ref', ref: 'ignored' }, + args: { position: 'before', content: { text: 'a\nb' } }, + } as any; + + expect(() => executeTextInsert(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + const totalPageNumber = editor.state.doc.nodeAt(nodePos); + expect(totalPageNumber?.type.name).toBe('total-page-number'); + expect(totalPageNumber?.textContent).toBe('a\nb7'); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); + + // The fix also threads the parent-admission probe into the rewrite path + // (executeTextRewrite / executeSpanTextRewrite), not just text.insert. Rewriting + // text INSIDE a text*-only parent with a newline must not throw or inject a + // lineBreak; it falls back to literal text because the field is `text*` only. + it('rewrites text inside total-page-number with a\\nb without throwing or creating a lineBreak node', () => { + editor = makeEditorWithTotalPageCount(); + const nodePos = findTotalPageNumberPos(editor); + const innerPos = nodePos + 1; // the '7' text node sits at [innerPos, innerPos + 1] + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'rewrite-step', + op: 'text.rewrite', + // '__selection__' makes resolveMarksForRange skip style capture (no block). + blockId: '__selection__', + from: 0, + to: 1, + absFrom: innerPos, + absTo: innerPos + 1, + text: '7', + capturedStyle: undefined, + } as any; + const step = { + id: 'rewrite-newline-into-total-page-number', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'a\nb' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + const totalPageNumber = editor.state.doc.nodeAt(nodePos); + expect(totalPageNumber?.type.name).toBe('total-page-number'); + // No lineBreak node was forced into the text*-only parent. + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); + + // The probe must be taken at the actual edit position, not once at the range + // start: a rewrite can start in a normal run (which allows lineBreak) and the + // newline edit can land inside a total-page-number (text*-only). A single + // probe at the start would mint a lineBreak that the field parent rejects. + it('does not force a lineBreak into total-page-number when a spanning rewrite lands the newline inside the field', () => { + // paragraph > run > [ text 'AB', total-page-number > text '7' ] + editor = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [ + { type: 'text', text: 'AB' }, + { type: 'total-page-number', attrs: {}, content: [{ type: 'text', text: '7' }] }, + ], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + + const nodePos = findTotalPageNumberPos(editor); // total-page-number opens here; 'B' is at nodePos-1 + const tr = editor.state.tr; + // Range 'B7' starts in the run ('B') and spans into the field's text ('7'). + // The replacement appends '\n', whose edit lands at the end of the field's + // text (a text*-only parent). + const target = { + kind: 'range', + stepId: 'span-rewrite', + op: 'text.rewrite', + blockId: '__selection__', + from: 0, + to: 0, + absFrom: nodePos - 1, // 'B' in the run + absTo: nodePos + 2, // just after '7' inside the field + text: 'B7', + capturedStyle: undefined, + } as any; + const step = { + id: 'span-rewrite-into-field', + op: 'text.rewrite', + where: { by: 'ref', ref: 'ignored' }, + args: { replacement: { text: 'B7\n' }, style: { inline: { mode: 'preserve' } } }, + } as any; + + expect(() => executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any)).not.toThrow(); + editor.dispatch(tr); + + expect(editor.state.doc.nodeAt(nodePos)?.type.name).toBe('total-page-number'); + // The newline landed in the text*-only field, so it falls back to literal + // text rather than forcing a (schema-invalid) lineBreak there. + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + }); +}); + +describe('docx export: newline becomes (SD-3278)', () => { + let docData: Awaited>; + let editor: any | undefined; + + beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); + }); + + const makeBlankDocEditor = () => + initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + // Tracked mutations require an author/user on the editor instance. + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('exports a Word-native for a text node that already holds a raw newline', async () => { + editor = makeBlankDocEditor(); + + // Seed a raw '\n' directly via the core command (tr.insertText keeps the + // literal newline) to reproduce an imported .docx whose held a raw + // newline (this exercises the export safety net, not the creation path). + editor.dispatch(editor.state.tr.insertText('Alpha\nBeta', 1)); + expect(editor.state.doc.textContent).toContain('Alpha\nBeta'); + expect(hasNodeOfType(editor, 'lineBreak')).toBe(false); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + expect(xml).toContain(' around (no leftover )', async () => { + editor = makeBlankDocEditor(); + + // Seed "Alpha\nBeta" as one text node, then mark the whole range as a tracked + // deletion. On export the run is split around ; every segment must + // become inside , never a stray . + editor.dispatch(editor.state.tr.insertText('Alpha\nBeta', 1)); + const delMark = editor.schema.marks.trackDelete.create({ + id: 'del-1', + author: 'Reviewer', + authorEmail: 'reviewer@example.com', + date: '2026-01-01T00:00:00.000Z', + }); + editor.dispatch(editor.state.tr.addMark(1, 1 + 'Alpha\nBeta'.length, delMark)); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + expect(xml).toContain(' survives inside the deletion (it would be ignored by Word). + expect(/]*>[\s\S]*?<\/w:del>/.test(xml)).toBe(false); + }); + + it('tracked doc.replace with a newline exports valid tracked OOXML (inserted text in , break preserved as )', async () => { + editor = makeBlankDocEditor(); + + // Seed text to target, then tracked-replace it with multi-line content. + editor.dispatch(editor.state.tr.insertText('hello world', 1)); + const match = editor.doc.query.match({ select: { type: 'text', pattern: 'hello world' }, require: 'first' }); + const ref = match?.items?.[0]?.handle?.ref; + expect(ref).toBeTruthy(); + + editor.doc.replace({ ref, text: 'Alpha\nBeta' }, { changeMode: 'tracked' }); + + const xml = await editor.exportDocx({ exportXmlOnly: true }); + // Inserted text is tracked and the newline survives as a Word-native break. + expect(xml).toContain('/ the way noBreakHyphen's translator does, so the break + // exports as its own run rather than inside . The output is valid + // OOXML; on reject the orphan break remains. This is an export-routing gap, + // not a schema limit (a leaf atom can carry marks). Tracked deletes of an + // existing raw-newline node keep the break inside via the single-run + // path above. + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts index 6b038a2210..1d1af2b13b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts @@ -917,7 +917,15 @@ function materializeTab(schema: Schema): ProseMirrorNode { } function materializeLineBreak(schema: Schema): ProseMirrorNode { - const nodeType = schema.nodes.hardBreak ?? schema.nodes.lineBreak; + // A `kind: 'lineBreak'` item is a soft break and must materialize to `lineBreak` + // (exports ``), not `hardBreak` (exports ``, a page + // break). Block-level page breaks use the distinct `kind: 'break'` path. + // KNOWN AMBIGUITY: structural projection (sd-projection) maps BOTH hardBreak + // and lineBreak to `kind: 'lineBreak'` with no discriminator, so a structural + // read/write echo of an *inline* page break is demoted to a soft break here. + // That projection ambiguity predates this change and needs a discriminator to + // round-trip inline page breaks (separate follow-up). + const nodeType = schema.nodes.lineBreak ?? schema.nodes.hardBreak; if (!nodeType) return schema.text('\n'); return nodeType.create(); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts index 6559632baa..d3e3085c80 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts @@ -895,6 +895,74 @@ describe('materializeFragment — capability gates', () => { // Extension nodes bypass capability gates — should fall through to fallback materializer expect(() => materializeFragment(editor.state.schema, fragment, new Set(), 'insert')).not.toThrow(); }); + + // SD-3278: a `kind: 'lineBreak'` inline must become a soft `lineBreak` + // node (exports to ), NOT a `hardBreak` node (exports to a page break). + // This is the bug that made the structural.replace workaround fail. + it('materializes a structural lineBreak as a soft lineBreak node, not a hardBreak', () => { + const fragment: SDFragment = { + kind: 'paragraph', + paragraph: { + inlines: [ + { kind: 'run', run: { text: 'Alpha' } }, + { kind: 'lineBreak' }, + { kind: 'run', run: { text: 'Beta' } }, + ], + }, + } as any; + + const materialized = materializeFragment(editor.state.schema, fragment, new Set(), 'insert'); + const roots = Array.isArray(materialized) ? materialized : [materialized]; + const typeNames = new Set(); + roots.forEach((root: any) => root.descendants((node: any) => typeNames.add(node.type.name))); + + expect(typeNames.has('lineBreak')).toBe(true); + expect(typeNames.has('hardBreak')).toBe(false); + }); + + // SD-3278: a structural run whose text carries a raw newline (a natural shape + // for SDK/agent-built `structural.replace` content) must split into lineBreak + // nodes, not keep the literal '\n' in a text node. + it('splits a newline inside structural run text into a lineBreak node, not raw text', () => { + const fragment: SDFragment = { + kind: 'paragraph', + paragraph: { + inlines: [{ kind: 'run', run: { text: 'left\nright' } }], + }, + } as any; + + const materialized = materializeFragment(editor.state.schema, fragment, new Set(), 'insert'); + const roots = Array.isArray(materialized) ? materialized : [materialized]; + const typeCounts: Record = {}; + let text = ''; + roots.forEach((root: any) => + root.descendants((node: any) => { + typeCounts[node.type.name] = (typeCounts[node.type.name] ?? 0) + 1; + if (node.isText) text += node.text ?? ''; + }), + ); + + expect(typeCounts.lineBreak).toBe(1); + expect(typeCounts.hardBreak ?? 0).toBe(0); + // No raw newline survives inside a text node. + expect(text.includes('\n')).toBe(false); + expect(text).toContain('left'); + expect(text).toContain('right'); + // The break reads back as '\n' via leafText, so structural reads that + // flatten PM content preserve it on round-trip (SD-3278). + const readableText = roots + .map((root: any) => { + if (typeof root.textBetween !== 'function') return root.textContent ?? ''; + const leafText = (node: any) => { + const specLeafText = node?.type?.spec?.leafText; + return typeof specLeafText === 'function' ? specLeafText(node) : ''; + }; + const size = root.content?.size ?? root.size ?? 0; + return root.textBetween(0, size, '', leafText); + }) + .join(''); + expect(readableText).toBe('left\nright'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js b/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js index 4fbc2a148c..e141dcc41f 100644 --- a/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js +++ b/packages/super-editor/src/editors/v1/extensions/line-break/line-break.js @@ -32,6 +32,17 @@ export const LineBreak = Node.create({ content: '', atom: true, + // The visible text representation of this leaf. Without it, flattening APIs + // see the break as nothing or a U+FFFC placeholder, so the rewrite char-diff + // and the doc-api text model (query.match, structural projection) disagree + // with the visible document. For example, an idempotent text.rewrite over + // `Alpha\nBeta` would duplicate the break, and query.match could not find it. + // Read by PM's built-in `Node.textBetween` (so `node.textContent` too), + // SuperDoc's `textBetweenWithTabs` / `charOffsetToDocPos`, SearchIndex, and + // `text-offset-resolver`. Mirrors the `noBreakHyphen` leaf (U+2011). See + // SD-3278. + leafText: () => '\n', + addOptions() { return {}; }, diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 593f599a5f..8613d95e53 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -386,14 +386,16 @@ export const Paragraph = OxmlNode.create({ // We avoid `findParentNode(isList)` here because `isList` depends on // `getResolvedParagraphProperties`, a WeakMap cache keyed by node // identity. After the numbering plugin's `appendTransaction` sets - // `listRendering`, the paragraph node object is replaced, leaving - // the new node uncached — causing `isList` to return false. + // `listRendering`, the paragraph node object is replaced, so the + // new node is uncached and `isList` can return false. const { $from } = selection; let paragraph = null; + let paragraphDepth = null; for (let d = $from.depth; d >= 0; d--) { const node = $from.node(d); if (node.type.name === 'paragraph') { paragraph = node; + paragraphDepth = d; break; } } @@ -404,7 +406,14 @@ export const Paragraph = OxmlNode.create({ if (!isListParagraph) return false; if (!isVisuallyEmptyParagraph(paragraph) && !hasOnlyBreakContent(paragraph)) return false; - const tr = state.tr.insertText(event.data); + let tr = state.tr; + if (hasOnlyBreakContent(paragraph) && paragraphDepth != null) { + const contentStart = $from.start(paragraphDepth); + const contentEnd = $from.end(paragraphDepth); + tr = tr.delete(contentStart, contentEnd).insertText(event.data, contentStart); + } else { + tr = tr.insertText(event.data); + } view.dispatch(tr); event.preventDefault(); return true; diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js index a830c3d298..238b6f269d 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js @@ -28,6 +28,12 @@ const DELETION_BARRIER = '\u0000'; const DEFAULT_SEARCH_MODEL = 'raw'; const hasTrackDeleteMark = (node) => node?.marks?.some((mark) => mark?.type?.name === 'trackDelete') ?? false; +const readLeafText = (node) => { + const leafText = node?.type?.spec?.leafText; + if (typeof leafText === 'function') return leafText(node); + if (typeof leafText === 'string') return leafText; + return ATOM_PLACEHOLDER; +}; /** * SearchIndex provides a lazily-built, cached index for searching across @@ -69,7 +75,7 @@ export class SearchIndex { this.#buildVisible(doc); } else { // Get the flattened text using ProseMirror's optimized textBetween - this.text = doc.textBetween(0, doc.content.size, BLOCK_SEPARATOR, ATOM_PLACEHOLDER); + this.text = doc.textBetween(0, doc.content.size, BLOCK_SEPARATOR, readLeafText); } this.segments = []; @@ -141,7 +147,12 @@ export class SearchIndex { } if (node.isLeaf) { - parts.push(ATOM_PLACEHOLDER); + if (hasTrackDeleteMark(node)) { + appendDeletionBarrier(); + return; + } + + parts.push(readLeafText(node)); emittedDeletionBarrier = false; return; } @@ -240,29 +251,48 @@ export class SearchIndex { } if (node.isLeaf) { + if (searchModel === 'visible' && hasTrackDeleteMark(node)) { + if (context?.deletionBarrierActive) { + return offset; + } + addSegment({ + offsetStart: offset, + offsetEnd: offset + 1, + docFrom: docPos, + docTo: docPos + node.nodeSize, + kind: 'atom', + }); + if (context) { + context.deletionBarrierActive = true; + } + return offset + 1; + } + if (context && searchModel === 'visible') { context.deletionBarrierActive = false; } + const leafText = readLeafText(node); + if (leafText.length === 0) return offset; // Leaf node (atom): check if it's a hard_break or other atom if (node.type.name === 'hard_break') { addSegment({ offsetStart: offset, - offsetEnd: offset + 1, + offsetEnd: offset + leafText.length, docFrom: docPos, docTo: docPos + node.nodeSize, kind: 'hardBreak', }); - return offset + 1; + return offset + leafText.length; } - // Other atoms get the replacement character + // Other atoms use their declared leaf text or the replacement character. addSegment({ offsetStart: offset, - offsetEnd: offset + 1, + offsetEnd: offset + leafText.length, docFrom: docPos, docTo: docPos + node.nodeSize, kind: 'atom', }); - return offset + 1; + return offset + leafText.length; } // For non-leaf nodes, recurse into content @@ -310,6 +340,14 @@ export class SearchIndex { */ offsetRangeToDocRanges(start, end) { const ranges = []; + // A single search hit is gapless in offset space, so consecutive segments + // (text and inline-leaf atoms like lineBreak) belong to one contiguous + // match. Coalesce them into one doc range — otherwise a hit spanning + // `text + lineBreak + text` yields discontiguous text ranges that the + // downstream D5 contiguity guard rejects (SD-3278). A block separator is a + // real split between blocks and ends the current range. The D5 guard still + // catches genuinely separate edits, which are not offset-contiguous. + let current = null; for (const segment of this.segments) { // Skip segments entirely before our range @@ -317,25 +355,41 @@ export class SearchIndex { // Stop if we're past our range if (segment.offsetStart >= end) break; - // Only include text segments in the result - if (segment.kind !== 'text') continue; + // Block separators split blocks; never coalesce across them. + if (segment.kind === 'blockSep') { + if (current) { + ranges.push({ from: current.from, to: current.to }); + current = null; + } + continue; + } - // Calculate the overlap const overlapStart = Math.max(start, segment.offsetStart); const overlapEnd = Math.min(end, segment.offsetEnd); + if (overlapStart >= overlapEnd) continue; + + let from; + let to; + if (segment.kind === 'text') { + from = segment.docFrom + (overlapStart - segment.offsetStart); + to = segment.docFrom + (overlapEnd - segment.offsetStart); + } else { + // Inline leaf atom (lineBreak, hardBreak, image, ...): occupies its + // whole node span and is part of the contiguous match, not a gap. + from = segment.docFrom; + to = segment.docTo; + } - if (overlapStart < overlapEnd) { - // Map the overlap back to document positions - const startInSegment = overlapStart - segment.offsetStart; - const endInSegment = overlapEnd - segment.offsetStart; - - ranges.push({ - from: segment.docFrom + startInSegment, - to: segment.docFrom + endInSegment, - }); + if (current && segment.offsetStart === current.offsetEnd) { + current.to = Math.max(current.to, to); + current.offsetEnd = overlapEnd; + } else { + if (current) ranges.push({ from: current.from, to: current.to }); + current = { from, to, offsetEnd: overlapEnd }; } } + if (current) ranges.push({ from: current.from, to: current.to }); return ranges; } diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js index bde782ccf8..33075aab83 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; import { SearchIndex } from './SearchIndex.js'; describe('SearchIndex.stripDiacritics', () => { @@ -142,6 +143,38 @@ describe('SearchIndex.searchIgnoringDiacritics', () => { }); }); +describe('SearchIndex lineBreak leaf text', () => { + const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { group: 'block', content: 'inline*' }, + text: { group: 'inline' }, + lineBreak: { + group: 'inline', + inline: true, + atom: true, + leafText: () => '\n', + }, + }, + marks: {}, + }); + + function buildLineBreakDoc() { + return schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('Alpha'), schema.nodes.lineBreak.create(), schema.text('Beta')]), + ]); + } + + it('indexes lineBreak using its declared leafText', () => { + const index = new SearchIndex(); + + index.ensureValid(buildLineBreakDoc()); + + expect(index.text).toBe('Alpha\nBeta'); + expect(index.search('Alpha\nBeta')).toHaveLength(1); + }); +}); + describe('SearchIndex searchModel: visible', () => { function textNode(text, { deleted = false } = {}) { return { @@ -155,6 +188,19 @@ describe('SearchIndex searchModel: visible', () => { }; } + function leafNode(typeName, { deleted = false, leafText = undefined } = {}) { + return { + isText: false, + isLeaf: true, + isInline: true, + isBlock: false, + type: { name: typeName, spec: leafText ? { leafText } : {} }, + nodeSize: 1, + marks: deleted ? [{ type: { name: 'trackDelete' } }] : [], + forEach: () => {}, + }; + } + function containerNode(children, { isBlock = false, textBetween = '' } = {}) { return { isText: false, @@ -204,6 +250,23 @@ describe('SearchIndex searchModel: visible', () => { return { doc }; } + function buildDocWithTrackedDeletedLineBreak() { + const paragraph = containerNode( + [textNode('before'), leafNode('lineBreak', { deleted: true, leafText: () => '\n' }), textNode('after')], + { + isBlock: true, + textBetween: 'before\nafter', + }, + ); + + const doc = containerNode([paragraph], { + isBlock: false, + textBetween: 'before\nafter', + }); + + return { doc }; + } + it('excludes pending tracked deletions in visible model', () => { const { doc } = buildDocWithTrackedDeletion(); const index = new SearchIndex(); @@ -250,4 +313,14 @@ describe('SearchIndex searchModel: visible', () => { expect(ranges[0].to - ranges[0].from).toBe('after'.length); expect(ranges[0]).toEqual({ from: 13, to: 18 }); }); + + it('excludes pending tracked deletions on leaf nodes in visible model', () => { + const { doc } = buildDocWithTrackedDeletedLineBreak(); + const index = new SearchIndex(); + + index.ensureValid(doc, { searchModel: 'visible' }); + + expect(index.search('\n')).toHaveLength(0); + expect(index.search('beforeafter')).toHaveLength(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/search/search.js b/packages/super-editor/src/editors/v1/extensions/search/search.js index 8679aa08e3..e3fcbb196b 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/search.js +++ b/packages/super-editor/src/editors/v1/extensions/search/search.js @@ -49,11 +49,12 @@ const mapIndexMatchesToDocMatches = ({ searchIndex, indexMatches, doc, positionT if (ranges.length === 0) continue; const matchTexts = ranges.map((r) => doc.textBetween(r.from, r.to)); + const matchText = typeof indexMatch.text === 'string' ? indexMatch.text : matchTexts.join(''); const match = { from: ranges[0].from, to: ranges[ranges.length - 1].to, - text: matchTexts.join(''), + text: matchText, id: uuidv4(), ranges, trackerIds: [], From 480ea20e5a719f974252223f04c86782c2a4ac3b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 16:42:51 -0300 Subject: [PATCH 15/34] fix(super-editor): keep caret after replacing break-only list content (SD-3278) Typing into a list item that holds only a placeholder break dropped the caret before the first inserted character, so subsequent native keystrokes prepended instead of appended ("abcdef" landed as "bcdefa"). Move the selection past the inserted text after the delete+insert. --- .../src/editors/v1/extensions/paragraph/paragraph.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 8613d95e53..94a02b611c 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -411,6 +411,10 @@ export const Paragraph = OxmlNode.create({ const contentStart = $from.start(paragraphDepth); const contentEnd = $from.end(paragraphDepth); tr = tr.delete(contentStart, contentEnd).insertText(event.data, contentStart); + // insertText at an explicit position leaves the caret before the inserted + // text, so subsequent native keystrokes prepend instead of append (typing + // "abcdef" lands as "bcdefa"). Place the caret after the inserted text. + tr = tr.setSelection(TextSelection.create(tr.doc, contentStart + event.data.length)); } else { tr = tr.insertText(event.data); } From f9a7b8eb4e98db2c33a84214320de48824259015 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 16:42:52 -0300 Subject: [PATCH 16/34] fix(super-editor): require PM-contiguity when coalescing search ranges (SD-3278) Coalesce adjacent search segments only when they are both offset-contiguous (same hit) and document-adjacent (segment.docFrom === current.to). This merges text + lineBreak + text within one run into a single range without bridging a skipped/tracked-deleted leaf or a run boundary, so the downstream D5 contiguity guard still rejects genuinely separate edits. --- .../v1/extensions/search/SearchIndex.js | 12 ++++++-- .../v1/extensions/search/SearchIndex.test.js | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js index 238b6f269d..162d6e5289 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.js @@ -380,8 +380,16 @@ export class SearchIndex { to = segment.docTo; } - if (current && segment.offsetStart === current.offsetEnd) { - current.to = Math.max(current.to, to); + // Coalesce only when the next segment is BOTH offset-contiguous (same + // search hit) AND PM-contiguous (`from === current.to`, i.e. immediately + // adjacent in the document). This merges `text + lineBreak + text` within + // one run into a single range, but never bridges a document gap — a + // skipped/tracked-deleted leaf, a run boundary, or any content the match + // does not actually cover. A non-coalesced segment becomes its own range; + // since it stays offset-contiguous, the downstream block coalescing still + // sees no gap, while the D5 guard keeps rejecting genuinely separate edits. + if (current && segment.offsetStart === current.offsetEnd && from === current.to) { + current.to = to; current.offsetEnd = overlapEnd; } else { if (current) ranges.push({ from: current.from, to: current.to }); diff --git a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js index 33075aab83..6b92f0f791 100644 --- a/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js +++ b/packages/super-editor/src/editors/v1/extensions/search/SearchIndex.test.js @@ -173,6 +173,34 @@ describe('SearchIndex lineBreak leaf text', () => { expect(index.text).toBe('Alpha\nBeta'); expect(index.search('Alpha\nBeta')).toHaveLength(1); }); + + it('coalesces a hit spanning text + lineBreak + text into one contiguous doc range', () => { + const index = new SearchIndex(); + index.ensureValid(buildLineBreakDoc()); + + // The full 'Alpha\nBeta' span (text + lineBreak + text, all PM-adjacent in + // one block) must map to a single contiguous range, not discontiguous text + // ranges that the downstream D5 contiguity guard would reject. + const ranges = index.offsetRangeToDocRanges(0, index.text.length); + expect(ranges).toHaveLength(1); + }); + + it('does NOT coalesce across a block separator (negative)', () => { + const index = new SearchIndex(); + index.ensureValid( + schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('Alpha')]), + schema.nodes.paragraph.create(null, [schema.text('Beta')]), + ]), + ); + + // 'Alpha\nBeta' here is two paragraphs joined by a block separator, not an + // inline break. The separator is a real split: the span must stay two ranges + // (one per block), never a single editable text range. + expect(index.text).toBe('Alpha\nBeta'); + const ranges = index.offsetRangeToDocRanges(0, index.text.length); + expect(ranges).toHaveLength(2); + }); }); describe('SearchIndex searchModel: visible', () => { From d643fb983577682317715b49e245d72b17a5ea71 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 17:54:18 -0300 Subject: [PATCH 17/34] fix(superdoc): blocker round for zoom modes from review (SD-3294) Five verified issues from the multi-agent and Codex review of #3659: - zoom.initial now reaches every surface at first paint: PdfViewer seeds its scale from a new initialScale prop (the activeZoom watcher never fires for a seeded ref, so a PDF painted 100% while getZoom() said 50, putting overlay math 2x off), and the non-layout-engine CSS fallback applies once from the document/editor ready hooks via the factored style application. - Fit-width targets what the renderer paints: the resolver prefers the widest laid-out page (editor.getPages(), the same source SuperEditor's container sizing uses for landscape sections) with body page styles as the pre-pagination fallback. - setZoom/setZoomMode before init now warn and emit nothing instead of advertising a change that was never persisted. - Stored viewport metrics are always latest (refreshed on any field change, frozen against consumer mutation) while the viewport-change event stays deduped to fit-relevant changes; all five public doc surfaces now state that contract precisely. getZoomState() derives its bounds from the same resolver the policy clamps with. - The applied fit floors at 1 (fractional bounds plus a degenerate container could round to 0, which the presentation engine rejects), and width/pagination evaluations defer a tick so measurement never runs against a mid-flush DOM (also fixes the one-frame sidebar bounce). The PDF page scan is skipped without PDF documents, the sidebar measures through a template ref, and the pt-to-px constant imports from the same module PdfViewerPage writes --scale-factor with. --- apps/docs/editor/superdoc/configuration.mdx | 2 +- apps/docs/editor/superdoc/events.mdx | 2 +- packages/react/src/types.ts | 4 +- packages/super-editor/src/ui/types.ts | 6 +- packages/superdoc/src/SuperDoc.test.js | 27 +++++++ packages/superdoc/src/SuperDoc.vue | 65 ++++++++++++----- .../src/components/PdfViewer/PdfViewer.vue | 17 ++++- .../src/composables/use-viewport-fit.js | 73 +++++++++++++++---- .../src/composables/use-viewport-fit.test.js | 6 ++ packages/superdoc/src/core/SuperDoc.test.js | 25 +++++++ packages/superdoc/src/core/SuperDoc.ts | 37 ++++++---- packages/superdoc/src/core/types/index.ts | 28 ++++--- 12 files changed, 225 insertions(+), 67 deletions(-) diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx index 81732670a9..0f1faf28f2 100644 --- a/apps/docs/editor/superdoc/configuration.mdx +++ b/apps/docs/editor/superdoc/configuration.mdx @@ -757,7 +757,7 @@ All handlers are optional functions in the configuration:
- Called when the width available to the document or its base page width changes. Registered before the first emit, so the initial measurement is never missed. See the [`viewport-change`](/editor/superdoc/events#viewport-change) event. + Called when the implied fit changes (rounded fit zoom or base page width); pixel-level width jitter is deduped and `getViewportMetrics()` always reads latest. Registered before the first emit, so the initial measurement is never missed. See the [`viewport-change`](/editor/superdoc/events#viewport-change) event. ```javascript onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => { diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index 394394a82b..ce3d781cb5 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -466,7 +466,7 @@ superdoc.on('zoomChange', ({ zoom, mode }) => { ### `viewport-change` -When the width available to the document or the document's base page width changes (container resize, comments sidebar toggle, page orientation, document swap). The payload carries pure measurements; `zoom.fitWidth` policy options never affect them: +When the implied fit changes: the rounded `fitZoom` or the document's base page width (container resize, comments sidebar toggle, page orientation). Pixel-level width movement that cannot change any fit decision is deduped; `getViewportMetrics()` always reads the latest measurements. The payload carries pure measurements; `zoom.fitWidth` policy options never affect them: - `availableWidth` - container width in pixels, minus the comments sidebar when visible - `documentWidth` - the widest document's base page width in pixels at 100% zoom (zoom-independent; DOCX from page styles, PDF from rendered pages) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index fd3a5a2757..ccf299f802 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -175,10 +175,10 @@ export interface CallbackProps { /** Callback when an exception is thrown */ onException?: (event: SuperDocExceptionEvent) => void; - /** Callback when the zoom level changes (setZoom, toolbar, or fit-to-container) */ + /** Callback when the zoom level changes (setZoom, toolbar, or fit-width mode) */ onZoomChange?: (event: SuperDocZoomChangeEvent) => void; - /** Callback when the width available to the document or its base page width changes */ + /** Callback when the implied fit changes (rounded fit zoom or base page width); see the core viewport-change event */ onViewportChange?: (event: SuperDocViewportChangeEvent) => void; } diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 8be299382b..7b79a21001 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -1133,8 +1133,10 @@ export interface ZoomHandle { getSnapshot(): ZoomSlice; /** * Subscribe to zoom snapshots. Fires on value changes, mode-only - * transitions, and viewport metric updates. Returns the unsubscribe - * function; pair with `scope.add(...)` for lifecycle handling. + * transitions, and fit-relevant viewport metric updates (the host's + * deduped `viewport-change`); `getSnapshot()` always reads the + * latest stored metrics. Returns the unsubscribe function; pair + * with `scope.add(...)` for lifecycle handling. */ observe(listener: (snapshot: ZoomSlice) => void): () => void; /** diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index c5090f78c0..2fecc09af5 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -3001,13 +3001,18 @@ describe('SuperDoc.vue', () => { setContainerWidth(wrapper, 1199); wrapper.vm.recalculateCompactCommentsMode(); await nextTick(); + await nextTick(); expect(viewportChangeCalls(superdocStub).length).toBe(1); + // The event dedupes, but stored metrics stay latest: reads must see + // the 1199 measurement the deduped event skipped. + expect(superdocStoreStub.viewportMetrics.value.availableWidth).toBe(1199); // A materially different width emits again. setContainerWidth(wrapper, 600); wrapper.vm.recalculateCompactCommentsMode(); await nextTick(); + await nextTick(); const calls = viewportChangeCalls(superdocStub); expect(calls.length).toBe(2); @@ -3196,6 +3201,28 @@ describe('SuperDoc.vue', () => { expect(calls[0][1].fitZoom).toBe(114); }); + it('prefers the widest laid-out page over body page styles (landscape sections)', async () => { + const superdocStub = createSuperdocStub(); + // Portrait body section (8.5in) but a laid-out interior landscape + // page: the fit must target what the renderer paints (getPages max), + // exactly like SuperEditor's own container sizing. + superdocStub.activeEditor = { + getPages: vi.fn(() => [{ size: { w: 816 } }, { size: { w: 1056 } }]), + getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })), + }; + + const wrapper = await mountComponent(superdocStub); + setContainerWidth(wrapper, 1200); + wrapper.vm.recalculateCompactCommentsMode(); + superdocStoreStub.isReady.value = true; + await nextTick(); + + const calls = viewportChangeCalls(superdocStub); + expect(calls.length).toBe(1); + expect(calls[0][1].documentWidth).toBe(1056); + expect(calls[0][1].fitZoom).toBe(114); + }); + it('resolves PDF page width scale-relatively (zoom-sync state cannot corrupt the base)', async () => { const superdocStub = createSuperdocStub(); // No DOCX editor: the PDF page is the only measurable document. diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 2085834a45..cbb3ff86ea 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -223,6 +223,7 @@ const superdocStyleVars = computed(() => { // Refs const superdocRoot = ref(null); const layers = ref(null); +const rightSidebarRef = ref(null); const pdfViewerRef = ref(null); const pendingReplayTrackedChangeSync = ref(false); const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 }); @@ -351,6 +352,7 @@ const handleDocumentReady = (documentId, container) => { if (!proxy.$superdoc.config.collaboration) isReady.value = true; } + ensureInitialFallbackZoom(); isFloatingCommentsReady.value = true; hasInitializedLocations.value = true; proxy.$superdoc.broadcastPdfDocumentReady(); @@ -497,6 +499,9 @@ const onEditorCreate = ({ editor }) => { * @param {PresentationEditor} payload.presentationEditor - The PresentationEditor wrapper */ const onEditorReady = ({ editor, presentationEditor }) => { + // Legacy (non-layout-engine) editors return early below; the seeded + // initial zoom for their CSS-fallback transform must apply first. + ensureInitialFallbackZoom(); if (!presentationEditor) return; // Store presentationEditor reference for mode changes @@ -1438,6 +1443,7 @@ useViewportFit({ zoomMode, viewportMetrics, showCommentsSidebar, + rightSidebarRef, superdocRoot, documents, }); @@ -1770,6 +1776,43 @@ const handlePdfSelectionRaw = ({ selectionBounds, documentId, page }) => { handleSelectionChange(selection); }; +// Web layout without layout engine — apply CSS transform directly +// to non-PDF sub-document containers so zoom works for PM fallback rendering. +// PDF documents are excluded because pdfViewer.updateScale() handles their zoom +// separately; applying both would result in double-zoom. +const applyFallbackZoomStyles = (zoomFactor) => { + const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document'); + subDocs?.forEach((el) => { + if (el.querySelector('.sd-pdf-viewer')) return; + if (zoomFactor === 1) { + el.style.transformOrigin = ''; + el.style.transform = ''; + el.style.width = ''; + } else { + el.style.transformOrigin = 'top left'; + el.style.transform = `scale(${zoomFactor})`; + el.style.width = `${100 / zoomFactor}%`; + } + }); +}; + +// One-time initial application for surfaces that only consume zoom +// imperatively. A seeded `zoom.initial` never fires the activeZoom watcher +// (the ref starts at the seeded value), and the fallback transform targets +// elements that do not exist until documents render — so apply once from +// the per-document ready hooks. PresentationEditor and PdfViewer take +// their initial value at creation (layoutEngineOptions.zoom / +// :initial-scale) and need nothing here. +let initialFallbackZoomApplied = false; +const ensureInitialFallbackZoom = () => { + if (initialFallbackZoomApplied) return; + if (proxy.$superdoc.config.useLayoutEngine !== false) return; + const zoomFactor = (activeZoom.value ?? 100) / 100; + if (zoomFactor === 1) return; + initialFallbackZoomApplied = true; + nextTick(() => applyFallbackZoomStyles(zoomFactor)); +}; + watch( () => activeZoom.value, (zoom) => { @@ -1778,23 +1821,8 @@ watch( if (proxy.$superdoc.config.useLayoutEngine !== false) { PresentationEditor.setGlobalZoom(zoomFactor); } else { - // Web layout without layout engine — apply CSS transform directly - // to non-PDF sub-document containers so zoom works for PM fallback rendering. - // PDF documents are excluded because pdfViewer.updateScale() handles their zoom - // separately below; applying both would result in double-zoom. - const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document'); - subDocs?.forEach((el) => { - if (el.querySelector('.sd-pdf-viewer')) return; - if (zoomFactor === 1) { - el.style.transformOrigin = ''; - el.style.transform = ''; - el.style.width = ''; - } else { - el.style.transformOrigin = 'top left'; - el.style.transform = `scale(${zoomFactor})`; - el.style.width = `${100 / zoomFactor}%`; - } - }); + initialFallbackZoomApplied = true; + applyFallbackZoomStyles(zoomFactor); } const pdfViewer = getPDFViewer(); @@ -1936,6 +1964,7 @@ const getPDFViewer = () => { v-if="doc.type === PDF" :file="doc.data" :file-id="doc.id" + :initial-scale="(activeZoom ?? 100) / 100" :config="pdfConfig" @selection-raw="handlePdfSelectionRaw" @bypass-selection="handlePdfClick" @@ -1966,7 +1995,7 @@ const getPDFViewer = () => { -