From c947433cba7395d60c8f36eb32ae1dd532190615 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:10:08 -0300 Subject: [PATCH 01/43] SD-2541 - fix: avoid scrolling back when dragging selection (#2809) * fix: avoid scrolling back when dragging selection * chore: removed references to tickets * chore: removed references to ticket --- .../presentation-editor/PresentationEditor.ts | 16 ++++++-- .../pointer-events/EditorInputManager.ts | 8 ++++ .../EditorInputManager.dragAutoScroll.test.ts | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 2c9d1dab9d..5a9783e3e9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -333,6 +333,8 @@ export class PresentationEditor extends EventEmitter { /** * When true, the next selection render scrolls the caret/selection head into view. * Only set for user-initiated actions (keyboard/mouse selection, image click, zoom). + * Not set on each `selectionUpdate` while a pointer drag is active — edge auto-scroll + * owns the viewport then; `notifyDragSelectionEnded` restores one scroll after mouseup. * Passive re-renders (virtualization remounts, layout completions, DOM rebuilds) leave * this unset so they don't fight the user's scroll position. */ @@ -3245,8 +3247,11 @@ export class PresentationEditor extends EventEmitter { } }; const handleSelection = () => { - // User-initiated selection change (keyboard, mouse) — scroll caret into view. - this.#shouldScrollSelectionIntoView = true; + // User-initiated selection change — scroll caret/head into view once, except during + // pointer drag: EditorInputManager edge auto-scroll must not fight #scrollActiveEndIntoView. + if (!this.#editorInputManager?.isDragging) { + this.#shouldScrollSelectionIntoView = true; + } // Use immediate rendering for selection-only changes (clicks, arrow keys). // Without immediate, the render is RAF-deferred — leaving a window where // a remote collaborator's edit can cancel the pending render via @@ -3566,6 +3571,10 @@ export class PresentationEditor extends EventEmitter { selectParagraphAt: (pos: number) => this.#selectParagraphAt(pos), finalizeDragSelectionWithDom: (pointer, dragAnchor, dragMode) => this.#finalizeDragSelectionWithDom(pointer, dragAnchor, dragMode), + notifyDragSelectionEnded: () => { + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + }, hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y), }); } @@ -4990,7 +4999,8 @@ export class PresentationEditor extends EventEmitter { // (virtualization remounts, layout completions) never set this flag, so // they won't scroll the viewport to the caret — only real user-initiated // selection changes (keyboard, mouse, image click, zoom) will. - const shouldScrollIntoView = this.#shouldScrollSelectionIntoView; + // Belt-and-suspenders: never scroll from this path while pointer-drag is active. + const shouldScrollIntoView = this.#shouldScrollSelectionIntoView && !this.#editorInputManager?.isDragging; this.#shouldScrollSelectionIntoView = false; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 2cc0e7a524..6dd36c0bf2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -359,6 +359,12 @@ export type EditorInputCallbacks = { dragAnchor: number, dragMode: 'char' | 'word' | 'para', ) => void; + /** + * Called when a pointer text-drag selection ends. + * Used to scroll the selection into view once after auto-scroll stops; during drag, + * selection-driven scroll is suppressed to avoid fighting edge auto-scroll. + */ + notifyDragSelectionEnded?: () => void; /** Hit test table at coordinates */ hitTestTable?: (x: number, y: number) => TableHitResult | null; }; @@ -1406,6 +1412,8 @@ export class EditorInputManager { this.#callbacks.finalizeDragSelectionWithDom?.(pointer, dragAnchor, dragMode); } + this.#callbacks.notifyDragSelectionEnded?.(); + this.#callbacks.scheduleA11ySelectionAnnouncement?.({ immediate: true }); this.#dragLastPointer = null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts index adfe5bfd04..41d1beff84 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts @@ -135,6 +135,7 @@ describe('EditorInputManager - Drag Auto Scroll', () => { normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), updateSelectionVirtualizationPins: vi.fn(), scheduleSelectionUpdate: vi.fn(), + notifyDragSelectionEnded: vi.fn(), }; manager = new EditorInputManager(); @@ -254,6 +255,8 @@ describe('EditorInputManager - Drag Auto Scroll', () => { // Auto-scroll should be stopped expect(rafCallback).toBeNull(); + // one post-drag hook so PresentationEditor can scroll selection into view after auto-scroll stops + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); }); it('does not auto-scroll in header/footer mode', () => { @@ -328,4 +331,41 @@ describe('EditorInputManager - Drag Auto Scroll', () => { expect(scrollContainer.scrollLeft).toBe(0); }); }); + + describe('notifyDragSelectionEnded (selection scroll after drag)', () => { + it('invokes notifyDragSelectionEnded exactly once when a text drag ends after movement', () => { + startDrag(10, 10); + moveDrag(40, 25); + endDrag(40, 25); + + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + + it('invokes notifyDragSelectionEnded when pointer goes down and up without move (click-hold-release)', () => { + startDrag(10, 10); + endDrag(10, 10); + + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + + it('does not invoke notifyDragSelectionEnded on pointer up if no drag was started', () => { + endDrag(10, 10); + + expect(mockCallbacks.notifyDragSelectionEnded).not.toHaveBeenCalled(); + }); + + it('invokes notifyDragSelectionEnded once per completed drag gesture', () => { + startDrag(10, 10); + moveDrag(20, 15); + endDrag(20, 15); + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + + (mockCallbacks.notifyDragSelectionEnded as ReturnType).mockClear(); + + startDrag(50, 50); + moveDrag(60, 55); + endDrag(60, 55); + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + }); }); From 54efb13c7088e88eaa32a300346ac6478b39660f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 01:35:04 +0000 Subject: [PATCH 02/43] chore: merge stable into main --- packages/esign/package.json | 2 +- packages/sdk/langs/node/package.json | 2 +- .../platforms/sdk-darwin-arm64/package.json | 2 +- .../platforms/sdk-darwin-x64/package.json | 2 +- .../platforms/sdk-linux-arm64/package.json | 2 +- .../node/platforms/sdk-linux-x64/package.json | 2 +- .../platforms/sdk-windows-x64/package.json | 2 +- .../pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../superdoc-sdk-cli-linux-x64/pyproject.toml | 2 +- .../pyproject.toml | 2 +- packages/sdk/langs/python/pyproject.toml | 12 +++--- packages/sdk/package.json | 2 +- packages/sdk/version.json | 2 +- packages/superdoc/src/core/types/index.js | 39 +++++++++++++------ 16 files changed, 48 insertions(+), 31 deletions(-) diff --git a/packages/esign/package.json b/packages/esign/package.json index ed55fcae94..e55360b190 100644 --- a/packages/esign/package.json +++ b/packages/esign/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/esign", - "version": "2.2.0", + "version": "2.3.0", "description": "React eSignature component for SuperDoc", "type": "module", "main": "./dist/index.js", diff --git a/packages/sdk/langs/node/package.json b/packages/sdk/langs/node/package.json index 046396078a..3dd4c2ec72 100644 --- a/packages/sdk/langs/node/package.json +++ b/packages/sdk/langs/node/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk", - "version": "1.5.1", + "version": "1.6.0", "private": false, "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json index 7589fa83f4..f6717edbd6 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-arm64", - "version": "1.5.1", + "version": "1.6.0", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json index be9d9ba03f..c01853e338 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-x64", - "version": "1.5.1", + "version": "1.6.0", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json index f68cd34374..a6d8744276 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-arm64", - "version": "1.5.1", + "version": "1.6.0", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json index c4884081f0..aae596480b 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-x64", - "version": "1.5.1", + "version": "1.6.0", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json index d41318a4e2..15d929c618 100644 --- a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-windows-x64", - "version": "1.5.1", + "version": "1.6.0", "os": [ "win32" ], diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml index 620acbfa69..ac01dc78b4 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-arm64" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc CLI binary for macOS ARM64 (Apple Silicon)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml index 7b979a794a..02044a52bc 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-x64" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc CLI binary for macOS x64 (Intel)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml index c316b88715..14567122b4 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-arm64" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc CLI binary for Linux ARM64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml index 7e76dbc64a..e62e12542c 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-x64" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc CLI binary for Linux x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml index 14af847e96..96693c60d4 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-windows-x64" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc CLI binary for Windows x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/pyproject.toml b/packages/sdk/langs/python/pyproject.toml index c375cb3141..517a66b413 100644 --- a/packages/sdk/langs/python/pyproject.toml +++ b/packages/sdk/langs/python/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk" -version = "1.5.1" +version = "1.6.0" description = "SuperDoc SDK (CLI-backed)" readme = "README.md" requires-python = ">=3.9" license = "AGPL-3.0" authors = [{ name = "SuperDoc" }] dependencies = [ - "superdoc-sdk-cli-darwin-arm64==1.5.1; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-darwin-x64==1.5.1; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-x64==1.5.1; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-arm64==1.5.1; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-windows-x64==1.5.1; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-darwin-arm64==1.6.0; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-darwin-x64==1.6.0; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-x64==1.6.0; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-arm64==1.6.0; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-windows-x64==1.6.0; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", ] [tool.setuptools] diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 45c3fb031a..cc01716e6b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-workspace", - "version": "1.5.1", + "version": "1.6.0", "private": true, "type": "module", "scripts": { diff --git a/packages/sdk/version.json b/packages/sdk/version.json index a9e7259f65..2da2b48fdf 100644 --- a/packages/sdk/version.json +++ b/packages/sdk/version.json @@ -1,3 +1,3 @@ { - "sdkVersion": "1.5.1" + "sdkVersion": "1.6.0" } diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 2d9429668d..59af504f54 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -527,7 +527,7 @@ * @property {Object} [links] Link click popover configuration * @property {LinkPopoverResolver} [links.popoverResolver] Custom resolver for the link click popover. * @property {ContextMenuConfig} [contextMenu] Context menu module configuration - * @property {Object} [slashMenu] @deprecated Use contextMenu instead + * @property {Object} [slashMenu] Deprecated. Use contextMenu instead. * @property {SurfacesModuleConfig} [surfaces] Surface system configuration * @property {TrackChangesModuleConfig} [trackChanges] Track changes module configuration */ @@ -614,6 +614,29 @@ * @property {string | null} [sectionType] Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ +/** + * @typedef {Object} SuperDocLayoutEngineOptions + * @property {'paginated' | 'semantic'} [flowMode='paginated'] Layout engine flow mode. + * - 'paginated': standard page-first layout (default) + * - 'semantic': continuous semantic flow without visible pagination boundaries + * @property {Object} [semanticOptions] Internal-only semantic mode tuning options. + * This shape is intentionally not a stable public API in v1. + * @property {Object} [trackedChanges] Deprecated. Use `modules.trackChanges` instead. Optional override for paginated track-changes rendering (e.g., `{ mode: 'original' }` or `{ enabled: false }`). + */ + +/** + * @typedef {Object} ViewingVisibilityConfig + * @property {boolean} [visible] + */ + +/** + * @typedef {Object} SuperDocTelemetryConfig + * @property {boolean} enabled + * @property {string} [endpoint] + * @property {Record} [metadata] + * @property {string} [licenseKey] + */ + /** * @typedef {Object} Config * @property {string} [superdocId] The ID of the SuperDoc @@ -649,13 +672,7 @@ * uiDisplayFallbackFont: '"Inter", Arial, sans-serif' * @property {boolean} [isDev] Whether the SuperDoc is in development mode * @property {boolean} [disablePiniaDevtools=false] Disable Pinia/Vue devtools plugin setup for this SuperDoc instance (useful in non-Vue hosts) - * @property {Object} [layoutEngineOptions] Layout engine overrides passed through to PresentationEditor (page size, margins, virtualization, zoom, debug label, etc.) - * @property {'paginated' | 'semantic'} [layoutEngineOptions.flowMode='paginated'] Layout engine flow mode. - * - 'paginated': standard page-first layout (default) - * - 'semantic': continuous semantic flow without visible pagination boundaries - * @property {Object} [layoutEngineOptions.semanticOptions] Internal-only semantic mode tuning options. - * This shape is intentionally not a stable public API in v1. - * @property {Object} [layoutEngineOptions.trackedChanges] @deprecated Use `modules.trackChanges` instead. Optional override for paginated track-changes rendering (e.g., `{ mode: 'original' }` or `{ enabled: false }`). + * @property {SuperDocLayoutEngineOptions} [layoutEngineOptions] Layout engine overrides passed through to PresentationEditor (page size, margins, virtualization, zoom, debug label, etc.) * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created * @property {(params: EditorTransactionEvent) => void} [onTransaction] Callback when a transaction is made @@ -678,8 +695,8 @@ * @property {boolean} [isInternal] Whether the SuperDoc is internal * @property {string} [title] The title of the SuperDoc * @property {Object[]} [conversations] The conversations to load - * @property {{ visible?: boolean }} [comments] Toggle comment visibility when `documentMode` is `viewing` (default: false) - * @property {{ visible?: boolean }} [trackChanges] @deprecated Use `modules.trackChanges.visible` instead. Toggle tracked-change visibility when `documentMode` is `viewing` (default: false). + * @property {ViewingVisibilityConfig} [comments] Toggle comment visibility when `documentMode` is `viewing` (default: false) + * @property {ViewingVisibilityConfig} [trackChanges] Deprecated. Use `modules.trackChanges.visible` instead. Toggle tracked-change visibility when `documentMode` is `viewing` (default: false). * @property {boolean} [isLocked] Whether the SuperDoc is locked * @property {function(File): Promise} [handleImageUpload] The function to handle image uploads * @property {User} [lockedBy] The user who locked the SuperDoc @@ -699,7 +716,7 @@ * Default behavior (false) lets the document expand to its natural height. * @property {string} [cspNonce] Content Security Policy nonce for dynamically injected styles * @property {string} [licenseKey] License key for organization identification - * @property {{ enabled: boolean, endpoint?: string, metadata?: Record, licenseKey?: string }} [telemetry] Telemetry configuration + * @property {SuperDocTelemetryConfig} [telemetry] Telemetry configuration * @property {ProofingConfig} [proofing] Proofing / spellcheck configuration */ From a178c983d8800ecbb58f312c9ca286b60e89b521 Mon Sep 17 00:00:00 2001 From: Kushal <114013186+kiluazen@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:07:33 +0530 Subject: [PATCH 03/43] fix: replace dead via.placeholder URL (#2900) --- apps/docs/snippets/extensions/node-resizer.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/snippets/extensions/node-resizer.mdx b/apps/docs/snippets/extensions/node-resizer.mdx index de8f8962dc..bf7666a633 100644 --- a/apps/docs/snippets/extensions/node-resizer.mdx +++ b/apps/docs/snippets/extensions/node-resizer.mdx @@ -6,7 +6,7 @@ import { SuperDocEditor } from '/snippets/components/superdoc-editor.jsx' Select an image to see resize handles:

-

Sample image

+

Sample image

Drag the corner handles to resize. The aspect ratio is automatically maintained.

`} height="400px" /> From 6c525deb8c5a894aafbe87a849ed71f09f1af8f5 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:39:30 -0300 Subject: [PATCH 04/43] docs: add kiluazen to community contributors (#2902) Co-authored-by: github-actions[bot] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 77717b8d6b..8a23252a87 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ Special thanks to these community members who have contributed code to SuperDoc: JoaaoVerona michaelreavant ArturQuirino +kiluazen Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started. From 9adf23ee4f2df6051a1c36cd831983f1a62f7274 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 22 Apr 2026 12:09:41 -0300 Subject: [PATCH 05/43] feat: add w:pPrChange translator for paragraph property tracked changes (SD-2417) (#2705) * feat: add translator for w:pPrChange * fix(pPrChange): preserve empty w:pPr on round-trip An empty inside means "the paragraph previously had no properties" per the OOXML spec, but the encoder was dropping it: pPrTranslator.encode() returns undefined for an empty pPr, so the paragraphProperties key never landed in the encoded change. On decode, the existing fallback couldn't fire because the key was absent, and the element vanished on export. Fall back to an empty object when the inner encode returns undefined, so the key is always present when a was in the source XML. The decode path already reconstructs the empty from this shape. * chore(super-converter): drop unused emitWhenAttributesOnly option * fix(super-converter): order sectPr before pPrChange in w:pPr CT_PPr requires sectPr to precede pPrChange, but the exporter always appended sectPr as the last child. Before the w:pPrChange translator existed, no export contained both children, so the ordering bug was latent. With pPrChange now round-tripping, paragraphs that carry both a section break and a tracked property change were emitted in an order Word rejects. Insert sectPr before any existing w:pPrChange child; otherwise append as before. * test(pPrChange): cover mixed sectPr and pPr routing (SD-2417) - pPrChange-translator: add a round-trip that mixes sectPr with other paragraph properties so a regression dropping either half surfaces. - pPr-translator: add two tests that exercise the pPrChange registration end-to-end through the real pPr translator, guarding against a silent SD-2417 regression if the registration is ever removed. --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol --- .../core/super-converter/v3/handlers/index.js | 2 + .../helpers/generate-paragraph-properties.js | 8 +- .../generate-paragraph-properties.test.js | 13 + .../v3/handlers/w/pPr/pPr-base-translators.js | 75 ++++ .../v3/handlers/w/pPr/pPr-translator.js | 77 +--- .../v3/handlers/w/pPr/pPr-translator.test.js | 54 +++ .../v3/handlers/w/pPrChange/index.js | 1 + .../w/pPrChange/pPrChange-translator.js | 99 +++++ .../w/pPrChange/pPrChange-translator.test.js | 415 ++++++++++++++++++ 9 files changed, 669 insertions(+), 75 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js index da72ff0a59..9076bb723d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js @@ -118,6 +118,7 @@ import { translator as w_personalCompose_translator } from './w/personalCompose/ import { translator as w_personalReply_translator } from './w/personalReply/personalReply-translator.js'; import { translator as w_position_translator } from './w/position/position-translator.js'; import { translator as w_pPr_translator } from './w/pPr/pPr-translator.js'; +import { translator as w_pPrChange_translator } from './w/pPrChange/pPrChange-translator.js'; import { translator as w_pStyle_translator } from './w/pStyle/pStyle-translator.js'; import { translator as w_permEnd_translator } from './w/perm-end/perm-end-translator.js'; import { translator as w_permStart_translator } from './w/perm-start/perm-start-translator.js'; @@ -324,6 +325,7 @@ const translatorList = Array.from( w_personalReply_translator, w_position_translator, w_pPr_translator, + w_pPrChange_translator, w_pStyle_translator, w_permStart_translator, w_permEnd_translator, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js index c437589c08..886f5f923b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js @@ -43,7 +43,13 @@ export function generateParagraphProperties(params) { elements: [], }; } - pPr.elements.push(sectPr); + // Per CT_PPr, sectPr must precede pPrChange. + const pPrChangeIdx = pPr.elements.findIndex((el) => el.name === 'w:pPrChange'); + if (pPrChangeIdx === -1) { + pPr.elements.push(sectPr); + } else { + pPr.elements.splice(pPrChangeIdx, 0, sectPr); + } } return pPr; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js index 57cd80e186..b4d098c543 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js @@ -68,6 +68,19 @@ describe('generateParagraphProperties', () => { expect(result.elements[1]).toBe(sectPr); }); + it('inserts sectPr before pPrChange to satisfy CT_PPr ordering', () => { + const jc = { name: 'w:jc' }; + const pPrChange = { name: 'w:pPrChange' }; + const sectPr = { name: 'w:sectPr' }; + const decoded = { type: 'element', name: 'w:pPr', elements: [jc, pPrChange] }; + wPPrNodeTranslator.decode.mockReturnValue(decoded); + const node = { type: 'paragraph', attrs: { paragraphProperties: { sectPr } } }; + + const result = generateParagraphProperties({ node }); + + expect(result.elements).toEqual([jc, sectPr, pPrChange]); + }); + it('creates paragraph properties when decoder returns nothing but sectPr exists', () => { wPPrNodeTranslator.decode.mockReturnValue(undefined); const sectPr = { name: 'w:sectPr', elements: [] }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js new file mode 100644 index 0000000000..6da4587d16 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-base-translators.js @@ -0,0 +1,75 @@ +// @ts-check +import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent'; +import { translator as wAdjustRightIndTranslator } from '../adjustRightInd'; +import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE'; +import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN'; +import { translator as wBidiTranslator } from '../bidi'; +import { translator as wCnfStyleTranslator } from '../cnfStyle'; +import { translator as wContextualSpacingTranslator } from '../contextualSpacing'; +import { translator as wDivIdTranslator } from '../divId'; +import { translator as wFramePrTranslator } from '../framePr'; +import { translator as wIndTranslator } from '../ind'; +import { translator as wJcTranslatorTranslator } from '../jc'; +import { translator as wKeepLinesTranslator } from '../keepLines'; +import { translator as wKeepNextTranslator } from '../keepNext'; +import { translator as wKinsokuTranslator } from '../kinsoku'; +import { translator as wMirrorIndentsTranslator } from '../mirrorIndents'; +import { translator as wNumPrTranslator } from '../numPr'; +import { translator as wOutlineLvlTranslator } from '../outlineLvl'; +import { translator as wOverflowPunctTranslator } from '../overflowPunct'; +import { translator as wPBdrTranslator } from '../pBdr'; +import { translator as wPStyleTranslator } from '../pStyle'; +import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore'; +import { translator as wShdTranslator } from '../shd'; +import { translator as wSnapToGridTranslator } from '../snapToGrid'; +import { translator as wSpacingTranslator } from '../spacing'; +import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens'; +import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers'; +import { translator as wSuppressOverlapTranslator } from '../suppressOverlap'; +import { translator as wTabsTranslator } from '../tabs'; +import { translator as wTextAlignmentTranslator } from '../textAlignment'; +import { translator as wTextDirectionTranslator } from '../textDirection'; +import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap'; +import { translator as wTopLinePunctTranslator } from '../topLinePunct'; +import { translator as wWidowControlTranslator } from '../widowControl'; +import { translator as wWordWrapTranslator } from '../wordWrap'; +import { translator as wRPrTranslator } from '../rpr'; + +/** @type {import('@translator').NodeTranslator[]} */ +export const basePropertyTranslators = [ + mcAlternateContentTranslator, + wAdjustRightIndTranslator, + wAutoSpaceDETranslator, + wAutoSpaceDNTranslator, + wBidiTranslator, + wCnfStyleTranslator, + wContextualSpacingTranslator, + wDivIdTranslator, + wFramePrTranslator, + wIndTranslator, + wJcTranslatorTranslator, + wKeepLinesTranslator, + wKeepNextTranslator, + wKinsokuTranslator, + wMirrorIndentsTranslator, + wNumPrTranslator, + wOutlineLvlTranslator, + wOverflowPunctTranslator, + wPBdrTranslator, + wPStyleTranslator, + wPageBreakBeforeTranslator, + wShdTranslator, + wSnapToGridTranslator, + wSpacingTranslator, + wSuppressAutoHyphensTranslator, + wSuppressLineNumbersTranslator, + wSuppressOverlapTranslator, + wTabsTranslator, + wTextAlignmentTranslator, + wTextDirectionTranslator, + wTextboxTightWrapTranslator, + wTopLinePunctTranslator, + wWidowControlTranslator, + wWordWrapTranslator, + wRPrTranslator, +]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js index 212fe10601..d1d87a92fa 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js @@ -1,82 +1,11 @@ // @ts-check import { NodeTranslator } from '@translator'; import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js'; -import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent'; -import { translator as wAdjustRightIndTranslator } from '../adjustRightInd'; -import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE'; -import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN'; -import { translator as wBidiTranslator } from '../bidi'; -import { translator as wCnfStyleTranslator } from '../cnfStyle'; -import { translator as wContextualSpacingTranslator } from '../contextualSpacing'; -import { translator as wDivIdTranslator } from '../divId'; -import { translator as wFramePrTranslator } from '../framePr'; -import { translator as wIndTranslator } from '../ind'; -import { translator as wJcTranslatorTranslator } from '../jc'; -import { translator as wKeepLinesTranslator } from '../keepLines'; -import { translator as wKeepNextTranslator } from '../keepNext'; -import { translator as wKinsokuTranslator } from '../kinsoku'; -import { translator as wMirrorIndentsTranslator } from '../mirrorIndents'; -import { translator as wNumPrTranslator } from '../numPr'; -import { translator as wOutlineLvlTranslator } from '../outlineLvl'; -import { translator as wOverflowPunctTranslator } from '../overflowPunct'; -import { translator as wPBdrTranslator } from '../pBdr'; -import { translator as wPStyleTranslator } from '../pStyle'; -import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore'; -import { translator as wShdTranslator } from '../shd'; -import { translator as wSnapToGridTranslator } from '../snapToGrid'; -import { translator as wSpacingTranslator } from '../spacing'; -import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens'; -import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers'; -import { translator as wSuppressOverlapTranslator } from '../suppressOverlap'; -import { translator as wTabsTranslator } from '../tabs'; -import { translator as wTextAlignmentTranslator } from '../textAlignment'; -import { translator as wTextDirectionTranslator } from '../textDirection'; -import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap'; -import { translator as wTopLinePunctTranslator } from '../topLinePunct'; -import { translator as wWidowControlTranslator } from '../widowControl'; -import { translator as wWordWrapTranslator } from '../wordWrap'; -import { translator as wRPrTranslator } from '../rpr'; +import { basePropertyTranslators } from './pPr-base-translators.js'; +import { translator as wPPrChangeTranslator } from '../pPrChange'; -// Property translators for w:pPr child elements -// Each translator handles a specific property of the paragraph properties /** @type {import('@translator').NodeTranslator[]} */ -const propertyTranslators = [ - mcAlternateContentTranslator, - wAdjustRightIndTranslator, - wAutoSpaceDETranslator, - wAutoSpaceDNTranslator, - wBidiTranslator, - wCnfStyleTranslator, - wContextualSpacingTranslator, - wDivIdTranslator, - wFramePrTranslator, - wIndTranslator, - wJcTranslatorTranslator, - wKeepLinesTranslator, - wKeepNextTranslator, - wKinsokuTranslator, - wMirrorIndentsTranslator, - wNumPrTranslator, - wOutlineLvlTranslator, - wOverflowPunctTranslator, - wPBdrTranslator, - wPStyleTranslator, - wPageBreakBeforeTranslator, - wShdTranslator, - wSnapToGridTranslator, - wSpacingTranslator, - wSuppressAutoHyphensTranslator, - wSuppressLineNumbersTranslator, - wSuppressOverlapTranslator, - wTabsTranslator, - wTextAlignmentTranslator, - wTextDirectionTranslator, - wTextboxTightWrapTranslator, - wTopLinePunctTranslator, - wWidowControlTranslator, - wWordWrapTranslator, - wRPrTranslator, -]; +const propertyTranslators = [...basePropertyTranslators, wPPrChangeTranslator]; /** * The NodeTranslator instance for the w:pPr element. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js index 67a2a6a9fb..a4abd11584 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js @@ -359,4 +359,58 @@ describe('w:pPr translator', () => { expect(encodedResult).toEqual(initialParagraphProperties); }); }); + + describe('pPrChange integration (SD-2417 regression guard)', () => { + it('routes w:pPrChange through the pPr encode pipeline', () => { + const xmlNode = { + name: 'w:pPr', + elements: [ + { name: 'w:jc', attributes: { 'w:val': 'center' } }, + { + name: 'w:pPrChange', + attributes: { + 'w:id': '0', + 'w:author': 'Regression Guard', + 'w:date': '2026-01-01T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:jc', attributes: { 'w:val': 'left' } }], + }, + ], + }, + ], + }; + + const encoded = translator.encode({ nodes: [xmlNode] }); + + expect(encoded.justification).toBe('center'); + expect(encoded.change).toEqual({ + id: '0', + author: 'Regression Guard', + date: '2026-01-01T00:00:00Z', + paragraphProperties: { justification: 'left' }, + }); + }); + + it('round-trips a paragraph whose pPr carries a pPrChange', () => { + const initialParagraphProperties = { + justification: 'center', + change: { + id: '0', + author: 'Regression Guard', + date: '2026-01-01T00:00:00Z', + paragraphProperties: { justification: 'left' }, + }, + }; + + const decoded = translator.decode({ + node: { attrs: { paragraphProperties: initialParagraphProperties } }, + }); + const encoded = translator.encode({ nodes: [decoded] }); + + expect(encoded).toEqual(initialParagraphProperties); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js new file mode 100644 index 0000000000..5bfa4e4b24 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/index.js @@ -0,0 +1 @@ +export * from './pPrChange-translator.js'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js new file mode 100644 index 0000000000..dc716b0a10 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js @@ -0,0 +1,99 @@ +import { NodeTranslator } from '@translator'; +import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js'; +import { basePropertyTranslators } from '../pPr/pPr-base-translators.js'; + +const pPrTranslator = NodeTranslator.from( + createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators), +); + +const ATTRIBUTE_HANDLERS = [ + createAttributeHandler('w:id'), + createAttributeHandler('w:author'), + createAttributeHandler('w:date'), +]; + +function getSectPr(pPrNode) { + const sectPr = pPrNode?.elements?.find((el) => el.name === 'w:sectPr'); + return sectPr ? carbonCopy(sectPr) : undefined; +} + +/** + * The NodeTranslator instance for the w:pPrChange element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:pPrChange', + sdNodeOrKeyName: 'change', + type: NodeTranslator.translatorTypes.NODE, + attributes: ATTRIBUTE_HANDLERS, + encode: (params, encodedAttrs = {}) => { + const changeNode = params.nodes[0]; + const pPrNode = changeNode?.elements?.find((el) => el.name === 'w:pPr'); + + let paragraphProperties = pPrNode ? (pPrTranslator.encode({ ...params, nodes: [pPrNode] }) ?? {}) : undefined; + const sectPr = getSectPr(pPrNode); + if (sectPr) { + paragraphProperties = { + ...(paragraphProperties || {}), + sectPr, + }; + } + + const result = { + ...encodedAttrs, + ...(paragraphProperties ? { paragraphProperties } : {}), + }; + + return Object.keys(result).length ? result : undefined; + }, + decode: function (params) { + const change = params.node?.attrs?.change; + if (!change || typeof change !== 'object') return undefined; + + const decodedAttrs = this.decodeAttributes({ + node: { ...params.node, attrs: change }, + }); + const hasParagraphProperties = Object.prototype.hasOwnProperty.call(change, 'paragraphProperties'); + const paragraphProperties = hasParagraphProperties ? change.paragraphProperties : undefined; + + let pPrNode = + paragraphProperties && typeof paragraphProperties === 'object' + ? pPrTranslator.decode({ + ...params, + node: { ...params.node, attrs: { paragraphProperties } }, + }) + : undefined; + + const sectPr = paragraphProperties?.sectPr ? carbonCopy(paragraphProperties.sectPr) : undefined; + if (sectPr) { + if (!pPrNode) { + pPrNode = { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [], + }; + } + pPrNode.elements = [...(pPrNode.elements || []), sectPr]; + } + + if (!pPrNode && hasParagraphProperties) { + pPrNode = { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [], + }; + } + + if (!pPrNode && !Object.keys(decodedAttrs).length) return undefined; + + return { + name: 'w:pPrChange', + type: 'element', + attributes: decodedAttrs, + elements: pPrNode ? [pPrNode] : [], + }; + }, +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js new file mode 100644 index 0000000000..94c042b3c0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js @@ -0,0 +1,415 @@ +vi.mock('../../../../exporter.js', () => { + const processOutputMarks = vi.fn((marks) => marks || []); + const generateRunProps = vi.fn((processedMarks) => ({ + name: 'w:rPr', + elements: [], + })); + return { processOutputMarks, generateRunProps }; +}); + +import { describe, it, expect } from 'vitest'; +import { translator } from './pPrChange-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:pPrChange translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:pPrChange'); + expect(translator.sdNodeOrKeyName).toBe('change'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode a w:pPrChange element with attributes and nested w:pPr', () => { + const xmlNode = { + name: 'w:pPrChange', + attributes: { + 'w:id': '0', + 'w:author': 'Luccas Correa', + 'w:date': '2026-04-02T11:25:00Z', + }, + elements: [ + { + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': 'ListParagraph' } }, + { + name: 'w:numPr', + elements: [{ name: 'w:numId', attributes: { 'w:val': '1' } }], + }, + { name: 'w:ind', attributes: { 'w:hanging': '360' } }, + ], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + id: '0', + author: 'Luccas Correa', + date: '2026-04-02T11:25:00Z', + paragraphProperties: { + styleId: 'ListParagraph', + numberingProperties: { numId: 1 }, + indent: { hanging: 360 }, + }, + }); + }); + + it('should encode a w:pPrChange with an empty w:pPr as an empty paragraphProperties object', () => { + const xmlNode = { + name: 'w:pPrChange', + attributes: { + 'w:id': '5', + 'w:author': 'Test Author', + 'w:date': '2026-01-01T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + elements: [], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + id: '5', + author: 'Test Author', + date: '2026-01-01T00:00:00Z', + paragraphProperties: {}, + }); + }); + + it('should encode nested sectPr from the changed paragraph properties', () => { + const sectPr = { + name: 'w:sectPr', + elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }], + }; + const xmlNode = { + name: 'w:pPrChange', + attributes: { + 'w:id': '6', + 'w:author': 'Section Author', + 'w:date': '2026-01-02T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + elements: [sectPr], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + id: '6', + author: 'Section Author', + date: '2026-01-02T00:00:00Z', + paragraphProperties: { + sectPr, + }, + }); + }); + + it('should encode a w:pPrChange with only attributes and no children', () => { + const xmlNode = { + name: 'w:pPrChange', + attributes: { + 'w:id': '3', + 'w:author': 'Author', + 'w:date': '2026-01-01T00:00:00Z', + }, + elements: [], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + id: '3', + author: 'Author', + date: '2026-01-01T00:00:00Z', + }); + }); + + it('should return undefined if no attributes or children are present', () => { + const xmlNode = { + name: 'w:pPrChange', + attributes: {}, + elements: [], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('should decode a change object with attributes and nested paragraphProperties', () => { + const superDocNode = { + attrs: { + change: { + id: '0', + author: 'Luccas Correa', + date: '2026-04-02T11:25:00Z', + paragraphProperties: { + styleId: 'ListParagraph', + numberingProperties: { numId: 1 }, + indent: { hanging: 360 }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:pPrChange'); + expect(result.attributes).toEqual({ + 'w:id': '0', + 'w:author': 'Luccas Correa', + 'w:date': '2026-04-02T11:25:00Z', + }); + expect(result.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'w:pPr', + elements: expect.arrayContaining([ + { name: 'w:pStyle', attributes: { 'w:val': 'ListParagraph' } }, + expect.objectContaining({ + name: 'w:numPr', + elements: [{ name: 'w:numId', attributes: { 'w:val': '1' } }], + }), + { name: 'w:ind', attributes: { 'w:hanging': '360' } }, + ]), + }), + ]), + ); + }); + + it('should decode a change object with only attributes', () => { + const superDocNode = { + attrs: { + change: { + id: '5', + author: 'Test Author', + date: '2026-01-01T00:00:00Z', + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:pPrChange', + type: 'element', + attributes: { + 'w:id': '5', + 'w:author': 'Test Author', + 'w:date': '2026-01-01T00:00:00Z', + }, + elements: [], + }); + }); + + it('should return undefined if change is empty', () => { + const superDocNode = { + attrs: { + change: {}, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toBeUndefined(); + }); + + it('should decode a change with an explicit empty paragraphProperties object', () => { + const superDocNode = { + attrs: { + change: { + id: '8', + author: 'Empty Paragraph Props', + date: '2026-01-03T00:00:00Z', + paragraphProperties: {}, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:pPrChange', + type: 'element', + attributes: { + 'w:id': '8', + 'w:author': 'Empty Paragraph Props', + 'w:date': '2026-01-03T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [], + }, + ], + }); + }); + + it('should decode a change with sectPr-only paragraph properties', () => { + const sectPr = { + name: 'w:sectPr', + elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }], + }; + const superDocNode = { + attrs: { + change: { + id: '7', + author: 'Section Author', + date: '2026-01-02T00:00:00Z', + paragraphProperties: { + sectPr, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:pPrChange', + type: 'element', + attributes: { + 'w:id': '7', + 'w:author': 'Section Author', + 'w:date': '2026-01-02T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [sectPr], + }, + ], + }); + }); + + it('should return undefined if change is missing', () => { + const superDocNode = { + attrs: {}, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toBeUndefined(); + }); + }); + + describe('round-trip', () => { + it('maintains consistency for a pPrChange with nested properties', () => { + const initialChange = { + id: '0', + author: 'Luccas Correa', + date: '2026-04-02T11:25:00Z', + paragraphProperties: { + styleId: 'ListParagraph', + numberingProperties: { numId: 1 }, + indent: { hanging: 360 }, + }, + }; + + const decoded = translator.decode({ node: { attrs: { change: initialChange } } }); + const encoded = translator.encode({ nodes: [decoded] }); + + expect(encoded).toEqual(initialChange); + }); + + it('maintains consistency for a pPrChange with justification', () => { + const initialChange = { + id: '2', + author: 'Another Author', + date: '2026-03-15T10:00:00Z', + paragraphProperties: { + justification: 'center', + spacing: { before: 200, after: 100 }, + }, + }; + + const decoded = translator.decode({ node: { attrs: { change: initialChange } } }); + const encoded = translator.encode({ nodes: [decoded] }); + + expect(encoded).toEqual(initialChange); + }); + + it('preserves an empty w:pPr when starting from XML', () => { + const initialXml = { + name: 'w:pPrChange', + type: 'element', + attributes: { + 'w:id': '10', + 'w:author': 'Empty pPr Round Trip', + 'w:date': '2026-01-05T00:00:00Z', + }, + elements: [ + { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [], + }, + ], + }; + + const encoded = translator.encode({ nodes: [initialXml] }); + const decoded = translator.decode({ node: { attrs: { change: encoded } } }); + + expect(decoded).toEqual(initialXml); + }); + + it('maintains consistency for a pPrChange with sectPr-only paragraph properties', () => { + const initialChange = { + id: '9', + author: 'Section Round Trip', + date: '2026-01-04T00:00:00Z', + paragraphProperties: { + sectPr: { + name: 'w:sectPr', + elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }], + }, + }, + }; + + const decoded = translator.decode({ node: { attrs: { change: initialChange } } }); + const encoded = translator.encode({ nodes: [decoded] }); + + expect(encoded).toEqual(initialChange); + }); + + it('maintains consistency for a pPrChange with sectPr alongside other properties', () => { + const initialChange = { + id: '12', + author: 'Mixed Round Trip', + date: '2026-01-07T00:00:00Z', + paragraphProperties: { + justification: 'center', + indent: { hanging: 360 }, + sectPr: { + name: 'w:sectPr', + elements: [{ name: 'w:type', attributes: { 'w:val': 'nextPage' } }], + }, + }, + }; + + const decoded = translator.decode({ node: { attrs: { change: initialChange } } }); + const encoded = translator.encode({ nodes: [decoded] }); + + expect(encoded).toEqual(initialChange); + }); + }); +}); From 4e299a69ec16f67551c1ad357247f1700417479a Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:18:52 -0300 Subject: [PATCH 06/43] ci(behavior): parallelize tests by browser x shard matrix (#2903) * ci(behavior): parallelize tests by browser x shard matrix Expands the behavior test matrix from 3 shards (each running all browsers) to 12 jobs (3 browsers x 4 shards). Each job installs only the browser it needs and uses a browser-scoped Playwright cache key. Local measurement showed a 1/9 slice at ~4 min on 2 workers; 1/12 should land ~3 min of tests + 2.5 min setup, well under the previous ~20 min wall time. * ci(behavior): self-trigger on workflow file changes --------- Co-authored-by: Caio Pizzol --- .github/workflows/ci-behavior.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml index 4f66da08ff..bd5f37792d 100644 --- a/.github/workflows/ci-behavior.yml +++ b/.github/workflows/ci-behavior.yml @@ -15,6 +15,7 @@ on: - 'packages/preset-geometry/**' - 'tests/behavior/**' - 'shared/**' + - '.github/workflows/ci-behavior.yml' - '!**/*.md' merge_group: workflow_dispatch: @@ -29,7 +30,8 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + browser: [chromium, firefox, webkit] + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v6 @@ -56,20 +58,20 @@ jobs: id: pw-cache with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-${{ matrix.browser }} - - name: Install Playwright browsers + - name: Install Playwright browser if: steps.pw-cache.outputs.cache-hit != 'true' - run: pnpm exec playwright install --with-deps chromium firefox webkit + run: pnpm exec playwright install --with-deps ${{ matrix.browser }} working-directory: tests/behavior - name: Install Playwright system deps if: steps.pw-cache.outputs.cache-hit == 'true' - run: pnpm exec playwright install-deps chromium firefox webkit + run: pnpm exec playwright install-deps ${{ matrix.browser }} working-directory: tests/behavior - - name: Run behavior tests (shard ${{ matrix.shard }}/3) - run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 + - name: Run behavior tests (${{ matrix.browser }} shard ${{ matrix.shard }}/4) + run: pnpm exec playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/4 working-directory: tests/behavior validate: From 3a53eb36c3b7cee8be2dea4e61d6d00876c0094e Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:10:59 +0300 Subject: [PATCH 07/43] fix: improve toolbar responsiveness (#2761) * fix: improve toolbar responsiveness * test(toolbar): fill round-3 coverage gaps (SD-2328) Adds boundary and lifecycle coverage for the responsive toolbar fix: - defaultItems.test.js: XL overflow cutoff (1429 vs 1430) and LG compact-class application (1280 vs 1281). - Toolbar.test.js: positive ResizeObserver branch (constructor, observe, disconnect on unmount). Moves vi.unstubAllGlobals into afterEach so the stub doesn't leak on a thrown assertion. - super-toolbar.test.js: getAvailableWidth branches on responsiveToContainer and falls back to 0 when no container is set. - responsive-container-overflow.spec.ts: fixes a latent selector bug. The original descendant query never matched (class lives on the ButtonGroup root, not a descendant) so the minWidth assertion silently passed on null. Now uses a compound selector and asserts both side groups compact. --------- Co-authored-by: Caio Pizzol --- .../v1/components/toolbar/ButtonGroup.vue | 37 ++++--- .../v1/components/toolbar/Toolbar.test.js | 64 ++++++++++++ .../editors/v1/components/toolbar/Toolbar.vue | 30 ++++-- .../v1/components/toolbar/ToolbarButton.vue | 24 +++-- .../v1/components/toolbar/constants.js | 7 ++ .../v1/components/toolbar/defaultItems.js | 41 +++++--- .../components/toolbar/defaultItems.test.js | 97 +++++++++++++++++++ .../v1/components/toolbar/super-toolbar.js | 14 ++- .../components/toolbar/super-toolbar.test.js | 73 ++++++++++++++ tests/behavior/fixtures/superdoc.ts | 7 ++ tests/behavior/harness/main.ts | 8 ++ .../toolbar/overflow-dropdown-label.spec.ts | 17 +++- .../responsive-container-overflow.spec.ts | 79 +++++++++++++++ 13 files changed, 448 insertions(+), 50 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js create mode 100644 tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue index 1ea11f143c..2a7f980c18 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue @@ -39,30 +39,37 @@ const props = defineProps({ type: Boolean, default: false, }, + compactSideGroups: { + type: Boolean, + default: false, + }, }); const currentItem = ref(null); const { isHighContrastMode } = useHighContrastMode(); // Matches media query from SuperDoc.vue const isMobile = window.matchMedia('(max-width: 768px)').matches; -const styleMap = { - left: { - minWidth: '120px', - justifyContent: 'flex-start', - }, - right: { - minWidth: '120px', - justifyContent: 'flex-end', - }, - default: { + +const getPositionStyle = computed(() => { + if (props.position === 'left') { + return { + minWidth: props.compactSideGroups ? 'auto' : '120px', + justifyContent: 'flex-start', + }; + } + + if (props.position === 'right') { + return { + minWidth: props.compactSideGroups ? 'auto' : '120px', + justifyContent: 'flex-end', + }; + } + + return { // Only grow if not on a mobile device flexGrow: isMobile ? 0 : 1, justifyContent: 'center', - }, -}; - -const getPositionStyle = computed(() => { - return styleMap[props.position] || styleMap.default; + }; }); const isButton = (item) => item.type === 'button'; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js index 27f497687d..a2e878b2e9 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js @@ -17,9 +17,11 @@ function createMockToolbar() { config: { toolbarGroups: ['left', 'center', 'right'], toolbarButtonsExclude: [], + responsiveToContainer: false, }, getToolbarItemByGroup: () => [], getToolbarItemByName: () => null, + getAvailableWidth: () => 1200, onToolbarResize: vi.fn(), emitCommand: vi.fn(), overflowItems: [], @@ -30,6 +32,7 @@ function createMockToolbar() { describe('Toolbar', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('removes resize and keydown listeners on unmount (not only on KeepAlive deactivate)', () => { @@ -111,4 +114,65 @@ describe('Toolbar', () => { addSpy.mockRestore(); removeSpy.mockRestore(); }); + + it('does not attach ResizeObserver when responsiveToContainer is disabled', () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + const ResizeObserverMock = vi.fn(() => ({ observe, disconnect })); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + const mockToolbar = { + ...createMockToolbar(), + toolbarContainer: document.createElement('div'), + }; + + const wrapper = mount(Toolbar, { + global: { + stubs: { ButtonGroup: true }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + expect(ResizeObserverMock).not.toHaveBeenCalled(); + expect(observe).not.toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('attaches ResizeObserver to the container when responsiveToContainer is enabled', () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + const ResizeObserverMock = vi.fn(() => ({ observe, disconnect })); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + const container = document.createElement('div'); + const mockToolbar = { + ...createMockToolbar(), + config: { ...createMockToolbar().config, responsiveToContainer: true }, + toolbarContainer: container, + }; + + const wrapper = mount(Toolbar, { + global: { + stubs: { ButtonGroup: true }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + expect(ResizeObserverMock).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith(container); + expect(disconnect).not.toHaveBeenCalled(); + + wrapper.unmount(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index 59ecd7ace3..f30954053a 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -11,6 +11,7 @@ import { } from 'vue'; import { throttle } from './helpers.js'; import ButtonGroup from './ButtonGroup.vue'; +import { RESPONSIVE_BREAKPOINTS } from './constants.js'; /** * The default font-family to use for toolbar UI surfaces when no custom font is configured. @@ -23,6 +24,8 @@ const { proxy } = getCurrentInstance(); const emit = defineEmits(['command', 'toggle', 'select']); let toolbarKey = ref(1); +const compactSideGroups = ref(false); +let containerResizeObserver = null; /** * Computed property that determines the font-family to use for toolbar UI surfaces. @@ -49,6 +52,9 @@ const getFilteredItems = (position) => { return proxy.$toolbar.getToolbarItemByGroup(position).filter((item) => !excludeButtonsList.includes(item.name.value)); }; +const updateCompactSideGroups = () => { + compactSideGroups.value = proxy.$toolbar.getAvailableWidth() <= RESPONSIVE_BREAKPOINTS.lg; +}; const onKeyDown = async (e) => { if (e.metaKey && e.key === 'f') { const searchItem = proxy.$toolbar.getToolbarItemByName('search'); @@ -65,6 +71,7 @@ const onKeyDown = async (e) => { const onWindowResized = async () => { await proxy.$toolbar.onToolbarResize(); + updateCompactSideGroups(); toolbarKey.value += 1; }; const onResizeThrottled = throttle(onWindowResized, 300); @@ -72,11 +79,25 @@ const onResizeThrottled = throttle(onWindowResized, 300); function teardownWindowListeners() { window.removeEventListener('resize', onResizeThrottled); window.removeEventListener('keydown', onKeyDown); + containerResizeObserver?.disconnect(); + containerResizeObserver = null; } function setupWindowListeners() { + teardownWindowListeners(); window.addEventListener('resize', onResizeThrottled); window.addEventListener('keydown', onKeyDown); + if ( + typeof ResizeObserver !== 'undefined' && + proxy.$toolbar.config?.responsiveToContainer && + proxy.$toolbar.toolbarContainer + ) { + containerResizeObserver = new ResizeObserver(() => { + onResizeThrottled(); + }); + containerResizeObserver.observe(proxy.$toolbar.toolbarContainer); + } + updateCompactSideGroups(); } onMounted(setupWindowListeners); @@ -121,6 +142,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" v-if="showLeftSide" :toolbar-items="getFilteredItems('left')" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="left" @command="handleCommand" @@ -131,6 +153,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" :toolbar-items="getFilteredItems('center')" :overflow-items="proxy.$toolbar.overflowItems" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="center" @command="handleCommand" @@ -140,6 +163,7 @@ const handleToolbarMousedown = (e) => { tabindex="0" v-if="showRightSide" :toolbar-items="getFilteredItems('right')" + :compact-side-groups="compactSideGroups" :ui-font-family="uiFontFamily" position="right" @command="handleCommand" @@ -162,12 +186,6 @@ const handleToolbarMousedown = (e) => { z-index: var(--sd-ui-toolbar-z-index, 10); } -@media (max-width: 1280px) { - .superdoc-toolbar-group-side { - min-width: auto !important; - } -} - @media (max-width: 768px) { .superdoc-toolbar { padding: 4px 10px; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue index 6747576dcb..62720a3858 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue @@ -291,21 +291,19 @@ const caretIcon = computed(() => { height: 10px; } -@media (max-width: 1280px) { - .toolbar-item--doc-mode .button-label { - display: none; - } +.toolbar-item--doc-mode-compact .button-label { + display: none; +} - .toolbar-item--doc-mode .toolbar-icon { - margin-right: 5px; - } +.toolbar-item--doc-mode-compact .toolbar-icon { + margin-right: 5px; +} - .toolbar-item--linked-styles { - width: auto !important; - } +.toolbar-item--linked-styles-compact { + width: auto !important; +} - .toolbar-item--linked-styles .button-label { - display: none; - } +.toolbar-item--linked-styles-compact .button-label { + display: none; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js index 4217900c4e..ab5f4d14f1 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js @@ -54,6 +54,13 @@ export const TOOLBAR_FONT_SIZES = [ { label: '96', key: '96pt', props: { 'data-item': 'btn-fontSize-option' } }, ]; +export const RESPONSIVE_BREAKPOINTS = { + sm: 768, + md: 1024, + lg: 1280, + xl: 1410, +}; + export const HEADLESS_ITEM_MAP = { undo: 'undo', redo: 'redo', diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index e627e9115d..f277c31a8e 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -15,7 +15,7 @@ import { scrollToElement } from './scroll-helpers.js'; import checkIconSvg from '@superdoc/common/icons/check.svg?raw'; import SearchInput from './SearchInput.vue'; -import { TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js'; +import { RESPONSIVE_BREAKPOINTS, TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js'; import { getQuickFormatList } from '@extensions/linked-styles/index.js'; const closeDropdown = (dropdown) => { @@ -996,18 +996,33 @@ export const makeDefaultItems = ({ }), }); - // Responsive toolbar calculations - const breakpoints = { - sm: 768, - md: 1024, - lg: 1280, - xl: 1410, - }; + // Responsive toolbar calculations. + // `availableWidth` comes from SuperToolbar and represents either: + // - container width when `responsiveToContainer: true` + // - viewport/document width when `responsiveToContainer: false` + + // Extra headroom to prevent toolbar jitter at the XL edge. + const XL_OVERFLOW_SAFETY_BUFFER = 20; const stickyItemsWidth = 120; const toolbarPadding = 32; const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler']; const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo']; + const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg; + + if (shouldUseLgCompactStyles) { + documentMode.attributes.value = { + ...documentMode.attributes.value, + className: `${documentMode.attributes.value.className} toolbar-item--doc-mode-compact`, + }; + } + + if (shouldUseLgCompactStyles) { + linkedStyles.attributes.value = { + ...linkedStyles.attributes.value, + className: `${linkedStyles.attributes.value.className} toolbar-item--linked-styles-compact`, + }; + } let toolbarItems = [ undo, @@ -1054,7 +1069,7 @@ export const makeDefaultItems = ({ } // Hide separators on small screens - if (availableWidth <= breakpoints.md && hideButtons) { + if (availableWidth <= RESPONSIVE_BREAKPOINTS.md && hideButtons) { toolbarItems = toolbarItems.filter((item) => item.type !== 'separator'); } @@ -1089,7 +1104,11 @@ export const makeDefaultItems = ({ toolbarItems.forEach((item) => { const itemWidth = controlSizes.get(item.name.value) || controlSizes.get('default'); - if (availableWidth < breakpoints.xl && itemsToHideXL.includes(item.name.value) && hideButtons) { + if ( + availableWidth < RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER && + itemsToHideXL.includes(item.name.value) && + hideButtons + ) { overflowItems.push(item); if (item.name.value === 'linkedStyles') { const linkedStylesIdx = toolbarItems.findIndex((item) => item.name.value === 'linkedStyles'); @@ -1098,7 +1117,7 @@ export const makeDefaultItems = ({ return; } - if (availableWidth < breakpoints.sm && itemsToHideSM.includes(item.name.value) && hideButtons) { + if (availableWidth < RESPONSIVE_BREAKPOINTS.sm && itemsToHideSM.includes(item.name.value) && hideButtons) { overflowItems.push(item); return; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js new file mode 100644 index 0000000000..e6fe42684b --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { makeDefaultItems } from './defaultItems.js'; +import { RESPONSIVE_BREAKPOINTS } from './constants.js'; + +const stubProxy = new Proxy( + {}, + { + get: () => 'stub', + }, +); + +const superToolbar = { + config: { mode: 'docx', superdoc: { config: { modules: { ai: {} } } } }, + activeEditor: null, + emitCommand: () => {}, +}; + +function getItemNames(list) { + return list.map((item) => item.name.value); +} + +function buildItems(availableWidth) { + return makeDefaultItems({ + superToolbar, + toolbarIcons: stubProxy, + toolbarTexts: stubProxy, + toolbarFonts: [], + hideButtons: true, + availableWidth, + }); +} + +describe('makeDefaultItems XL overflow boundary (SD-2328)', () => { + const XL_OVERFLOW_SAFETY_BUFFER = 20; + const XL_CUTOFF = RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER; + const XL_ITEMS = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler']; + + it(`moves XL items into overflow at ${XL_CUTOFF - 1}px (below cutoff)`, () => { + const { defaultItems, overflowItems } = buildItems(XL_CUTOFF - 1); + const overflowNames = getItemNames(overflowItems); + const visibleNames = getItemNames(defaultItems); + + for (const name of XL_ITEMS) { + expect(overflowNames).toContain(name); + expect(visibleNames).not.toContain(name); + } + }); + + it(`keeps XL items visible at ${XL_CUTOFF}px (on cutoff)`, () => { + const { defaultItems, overflowItems } = buildItems(XL_CUTOFF); + const overflowNames = getItemNames(overflowItems); + const visibleNames = getItemNames(defaultItems); + + for (const name of XL_ITEMS) { + expect(visibleNames).toContain(name); + expect(overflowNames).not.toContain(name); + } + }); + + it(`keeps XL items visible at ${XL_CUTOFF + 1}px (above cutoff)`, () => { + const { defaultItems, overflowItems } = buildItems(XL_CUTOFF + 1); + const overflowNames = getItemNames(overflowItems); + const visibleNames = getItemNames(defaultItems); + + for (const name of XL_ITEMS) { + expect(visibleNames).toContain(name); + expect(overflowNames).not.toContain(name); + } + }); +}); + +describe('makeDefaultItems LG compact styles', () => { + const LG_BREAKPOINT = RESPONSIVE_BREAKPOINTS.lg; + + function getItem(defaultItems, overflowItems, name) { + return [...defaultItems, ...overflowItems].find((item) => item.name.value === name); + } + + it(`applies compact classes at ${LG_BREAKPOINT}px (on breakpoint)`, () => { + const { defaultItems, overflowItems } = buildItems(LG_BREAKPOINT); + const documentMode = getItem(defaultItems, overflowItems, 'documentMode'); + const linkedStyles = getItem(defaultItems, overflowItems, 'linkedStyles'); + + expect(documentMode.attributes.value.className).toContain('toolbar-item--doc-mode-compact'); + expect(linkedStyles.attributes.value.className).toContain('toolbar-item--linked-styles-compact'); + }); + + it(`does not apply compact classes at ${LG_BREAKPOINT + 1}px (above breakpoint)`, () => { + const { defaultItems, overflowItems } = buildItems(LG_BREAKPOINT + 1); + const documentMode = getItem(defaultItems, overflowItems, 'documentMode'); + const linkedStyles = getItem(defaultItems, overflowItems, 'linkedStyles'); + + expect(documentMode.attributes.value.className).not.toContain('toolbar-item--doc-mode-compact'); + expect(linkedStyles.attributes.value.className).not.toContain('toolbar-item--linked-styles-compact'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 3457ea05c6..1de5bd9df6 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -386,6 +386,16 @@ export class SuperToolbar extends EventEmitter { return this.toolbarItems.find((item) => item.name.value === name); } + /** + * Get the width used for responsive toolbar decisions. + * @returns {number} Available width in pixels + */ + getAvailableWidth() { + const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar + const containerWidth = this.toolbarContainer?.offsetWidth ?? 0; + return this.config.responsiveToContainer ? containerWidth : documentWidth; + } + /** * Create toolbar items based on configuration * @private @@ -397,9 +407,7 @@ export class SuperToolbar extends EventEmitter { * @returns {void} */ #makeToolbarItems({ superToolbar, icons, texts, fonts, hideButtons, isDev = false } = {}) { - const documentWidth = document.documentElement.clientWidth; // take into account the scrollbar - const containerWidth = this.toolbarContainer?.offsetWidth ?? 0; - const availableWidth = this.config.responsiveToContainer ? containerWidth : documentWidth; + const availableWidth = this.getAvailableWidth(); const { defaultItems, overflowItems } = makeDefaultItems({ superToolbar, diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js new file mode 100644 index 0000000000..7d841b31b4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SuperToolbar } from './super-toolbar.js'; + +vi.mock('prosemirror-history', () => ({ + undoDepth: () => 0, + redoDepth: () => 0, +})); + +vi.mock('@core/helpers/getActiveFormatting.js', () => ({ + getActiveFormatting: vi.fn(() => []), +})); + +vi.mock('@helpers/isInTable.js', () => ({ + isInTable: vi.fn(() => false), +})); + +vi.mock('@extensions/linked-styles/index.js', () => ({ + getQuickFormatList: vi.fn(() => []), +})); + +vi.mock('@extensions/track-changes/permission-helpers.js', () => ({ + collectTrackedChanges: vi.fn(() => []), + isTrackedChangeActionAllowed: vi.fn(() => true), +})); + +vi.mock('./defaultItems.js', () => ({ + makeDefaultItems: () => ({ defaultItems: [], overflowItems: [] }), +})); + +describe('SuperToolbar getAvailableWidth', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns document width when responsiveToContainer is false', () => { + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600); + + const container = document.createElement('div'); + Object.defineProperty(container, 'offsetWidth', { value: 900 }); + + const context = { + toolbarContainer: container, + config: { responsiveToContainer: false }, + }; + + expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(1600); + }); + + it('returns container width when responsiveToContainer is true', () => { + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600); + + const container = document.createElement('div'); + Object.defineProperty(container, 'offsetWidth', { value: 900 }); + + const context = { + toolbarContainer: container, + config: { responsiveToContainer: true }, + }; + + expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(900); + }); + + it('falls back to 0 when responsiveToContainer is true but no container is set', () => { + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1600); + + const context = { + toolbarContainer: null, + config: { responsiveToContainer: true }, + }; + + expect(SuperToolbar.prototype.getAvailableWidth.call(context)).toBe(0); + }); +}); diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index c76268b50f..774334c642 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -9,6 +9,12 @@ const HARNESS_URL = 'http://localhost:9990'; interface HarnessConfig { layout?: boolean; toolbar?: 'none' | 'full'; + /** + * Forwards `modules.toolbar.responsiveToContainer` to SuperDoc when true. + * Default is false (viewport-driven). Set true to drive overflow/compaction + * off the toolbar container's width instead of the viewport. + */ + responsiveToContainer?: boolean; comments?: 'off' | 'on' | 'panel' | 'readonly' | 'disabled'; trackChanges?: boolean; /** @@ -49,6 +55,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { const params = new URLSearchParams(); if (config.layout !== undefined) params.set('layout', config.layout ? '1' : '0'); if (config.toolbar) params.set('toolbar', config.toolbar); + if (config.responsiveToContainer) params.set('responsiveToContainer', '1'); if (config.comments) params.set('comments', config.comments); if (config.trackChanges) params.set('trackChanges', '1'); if (config.replacements) params.set('replacements', config.replacements); diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index b2f77519e9..d83167e1db 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -25,6 +25,7 @@ const layout = params.get('layout') !== '0'; const showCaret = params.get('showCaret') === '1'; const showSelection = params.get('showSelection') === '1'; const toolbar = params.get('toolbar'); +const responsiveToContainer = params.get('responsiveToContainer') === '1'; const comments = params.get('comments'); const trackChanges = params.get('trackChanges') === '1'; const replacementsParam = params.get('replacements'); @@ -94,6 +95,13 @@ function init(file?: File, content?: ContentOverrideInput) { config.toolbar = '#toolbar'; } + if (responsiveToContainer) { + config.modules = { + ...(config.modules ?? {}), + toolbar: { responsiveToContainer: true }, + }; + } + // Comments if (comments === 'on' || comments === 'panel') { config.comments = { visible: true }; diff --git a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts index 4a608f7515..3245704973 100644 --- a/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts +++ b/tests/behavior/tests/toolbar/overflow-dropdown-label.spec.ts @@ -23,12 +23,25 @@ test('font family applies and label updates when selected from overflow menu', a // Open overflow menu await overflowBtn.click(); - await superdoc.page.locator('.overflow-menu_items').waitFor({ state: 'visible', timeout: 5000 }); + // await superdoc.page.locator('.overflow-menu_items').waitFor({ state: 'visible', timeout: 5000 }); + const overflowMenu = superdoc.page.locator('.overflow-menu_items'); + await overflowMenu.waitFor({ state: 'visible', timeout: 5000 }); await superdoc.waitForStable(); // Select Georgia from font family dropdown - await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + // await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + const overflowFontFamilyBtn = overflowMenu.locator('[data-item="btn-fontFamily"]'); + if (!(await overflowFontFamilyBtn.isVisible())) { + test.skip(); + } + await overflowFontFamilyBtn.click(); + await superdoc.waitForStable(); + // Wait for the dropdown options to appear + await superdoc.page + .locator('[data-item="btn-fontFamily-option"]') + .first() + .waitFor({ state: 'visible', timeout: 5000 }); await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); await superdoc.waitForStable(); diff --git a/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts new file mode 100644 index 0000000000..eb3bdc9a38 --- /dev/null +++ b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +/** + * SD-2328 regression. + * + * When `modules.toolbar.responsiveToContainer` is on, overflow and compaction + * must track the toolbar container's width, not the viewport. The bug this + * guards: side panels shrink the toolbar container while the window stays + * wide, and toolbar buttons spill past the container's right edge. + * + * The test keeps the viewport wide (1600px) on purpose so a viewport-driven + * width read would leave the toolbar at full size. A container-driven read + * must trigger compaction and overflow. + */ +test.use({ config: { toolbar: 'full', responsiveToContainer: true } }); + +test('toolbar buttons stay inside the container when it narrows (SD-2328)', async ({ superdoc }) => { + const { page } = superdoc; + + await page.setViewportSize({ width: 1600, height: 900 }); + await superdoc.waitForStable(); + + // Shrink only the container (mirrors the side-panel / drawer scenario). + const containerWidth = 1100; + await page.evaluate((w) => { + const el = document.getElementById('toolbar'); + if (!el) throw new Error('#toolbar not found in harness'); + el.style.width = `${w}px`; + el.style.maxWidth = `${w}px`; + }, containerWidth); + + // Let the ResizeObserver fire through the 300ms throttle. + await page.waitForTimeout(500); + await superdoc.waitForStable(); + + const result = await page.evaluate(() => { + const container = document.getElementById('toolbar'); + if (!container) return null; + const containerRect = container.getBoundingClientRect(); + const items = Array.from(container.querySelectorAll('.button-group > .toolbar-item-ctn')); + const overflowing = items + .map((el) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + return { + id: (el as HTMLElement).getAttribute('data-item-id') ?? '', + right: rect.right, + width: rect.width, + }; + }) + // Skip zero-width items (collapsed / hidden by the overflow pipeline). + .filter((entry) => entry.width > 0 && entry.right > containerRect.right + 1); + // The side-position class is applied to the ButtonGroup root (which is + // also the `.button-group` element), so the two classes land on the same + // node - use a compound selector, not a descendant one. + const sideGroups = Array.from(container.querySelectorAll('.button-group.superdoc-toolbar-group-side')); + const sideGroupMinWidths = sideGroups.map((el) => getComputedStyle(el as Element).minWidth); + return { + containerRight: containerRect.right, + containerWidth: containerRect.width, + overflowing, + sideGroupMinWidths, + }; + }); + + expect(result, 'harness toolbar container must exist').not.toBeNull(); + expect(result!.containerWidth).toBe(containerWidth); + expect( + result!.overflowing, + `buttons must not extend past the toolbar container's right edge (container right = ${result!.containerRight}px)`, + ).toEqual([]); + // At 1100px (≤ lg = 1280) every side group must drop its 120px min-width so + // the center group has room for the overflow menu. Assert both sides: the + // `compactSideGroups` prop is threaded through left, center, and right group + // instances, so one-sided coverage would miss a per-position regression. + expect(result!.sideGroupMinWidths.length, 'expected left and right side groups').toBeGreaterThanOrEqual(2); + for (const minWidth of result!.sideGroupMinWidths) { + expect(minWidth, 'side groups should compact at ≤ lg breakpoint').not.toBe('120px'); + } +}); From 75c8f51e1ea6214dedef5ff5e78087f3f8ecdf52 Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:07:10 +0300 Subject: [PATCH 08/43] fix: applying formatting to header/footer (#2904) Co-authored-by: Artem Nistuley --- .../v1/components/toolbar/Toolbar.test.js | 28 +++++++++++++++++++ .../editors/v1/components/toolbar/Toolbar.vue | 5 +++- .../v1/components/toolbar/super-toolbar.js | 7 +++-- .../helpers/resolveHeaderFooterSelection.js | 5 ++++ .../src/editors/v1/core/commands/setMark.js | 17 ++++------- .../editors/v1/core/commands/unsetAllMarks.js | 9 +++--- .../src/editors/v1/core/commands/unsetMark.js | 8 ++---- .../toolbar/super-toolbar-commands.test.js | 19 +++++++++++++ 8 files changed, 73 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js index a2e878b2e9..9858bfe498 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js @@ -115,6 +115,34 @@ describe('Toolbar', () => { removeSpy.mockRestore(); }); + it('does not restore selection when active editor is header/footer', async () => { + const restoreSelection = vi.fn(); + const mockToolbar = createMockToolbar(); + mockToolbar.activeEditor = { + options: { isHeaderOrFooter: true }, + commands: { restoreSelection }, + }; + + const ButtonGroupStub = defineComponent({ + emits: ['item-clicked'], + template: '', + }); + + const wrapper = mount(Toolbar, { + global: { + stubs: { ButtonGroup: ButtonGroupStub }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + await wrapper.find('[data-test="emit-item-clicked"]').trigger('click'); + expect(restoreSelection).not.toHaveBeenCalled(); + }); + it('does not attach ResizeObserver when responsiveToContainer is disabled', () => { const observe = vi.fn(); const disconnect = vi.fn(); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index f30954053a..d6dc0ca991 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -110,7 +110,10 @@ const handleCommand = ({ item, argument, option }) => { }; const restoreSelection = () => { - proxy.$toolbar.activeEditor?.commands?.restoreSelection(); + const editor = proxy.$toolbar.activeEditor; + if (!editor) return; + if (editor.options?.isHeaderOrFooter) return; + editor.commands?.restoreSelection(); }; /** diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 1de5bd9df6..792b87998f 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -775,9 +775,10 @@ export class SuperToolbar extends EventEmitter { return; } - // If the editor wasn't focused and this is a mark toggle, queue it and keep the button active - // until the next selection update (after the user clicks into the editor). - if (!wasFocused && isMarkToggle) { + // Queue unfocused mark toggles only for body editors. + // Header/footer mark toggles execute immediately to avoid waiting for + // selectionUpdate and requiring an extra selection change. + if (!wasFocused && isMarkToggle && !this.activeEditor?.options?.isHeaderOrFooter) { this.pendingMarkCommands.push({ command, argument, item }); const labelAttr = item?.labelAttr?.value; if (labelAttr && argument) { diff --git a/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js b/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js new file mode 100644 index 0000000000..b2cb432c42 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/helpers/resolveHeaderFooterSelection.js @@ -0,0 +1,5 @@ +export function resolveHeaderFooterSelection({ tr }) { + // Keep selection resolution centralized here so header/footer-specific fallback + // logic can be reintroduced in one place if we need it again. + return tr?.selection; +} diff --git a/packages/super-editor/src/editors/v1/core/commands/setMark.js b/packages/super-editor/src/editors/v1/core/commands/setMark.js index ac7e9c860c..dc19c5a7b0 100644 --- a/packages/super-editor/src/editors/v1/core/commands/setMark.js +++ b/packages/super-editor/src/editors/v1/core/commands/setMark.js @@ -1,13 +1,11 @@ import { Attribute } from '../Attribute.js'; import { getMarkType } from '../helpers/getMarkType.js'; import { isTextSelection } from '../helpers/isTextSelection.js'; +import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js'; import { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; -function canSetMark(editor, state, tr, newMarkType) { - let { selection } = tr; - if (editor.options.isHeaderOrFooter) { - selection = editor.options.lastSelection; - } +function canSetMark(state, tr, newMarkType) { + const selection = resolveHeaderFooterSelection({ tr }); let cursor = null; if (isTextSelection(selection)) { @@ -53,11 +51,8 @@ function canSetMark(editor, state, tr, newMarkType) { * @param attributes Attributes to add. */ //prettier-ignore -export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch, editor }) => { - let { selection } = tr; - if (editor.options.isHeaderOrFooter) { - selection = editor.options.lastSelection; - } +export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => { + const selection = resolveHeaderFooterSelection({ tr }); const { empty, ranges } = selection; const type = getMarkType(typeOrName, state.schema); @@ -107,5 +102,5 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch, } } - return canSetMark(editor, state, tr, type); + return canSetMark(state, tr, type); }; diff --git a/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js b/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js index 8f02995acb..1cd337979f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js +++ b/packages/super-editor/src/editors/v1/core/commands/unsetAllMarks.js @@ -1,3 +1,5 @@ +import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js'; + /** * Remove all marks in the current selection. * @@ -11,11 +13,8 @@ * only, the undo path restores the exact marks that were visible to the user. */ //prettier-ignore -export const unsetAllMarks = () => ({ tr, dispatch, editor }) => { - let { selection } = tr; - if (editor.options.isHeaderOrFooter) { - selection = editor.options.lastSelection; - } +export const unsetAllMarks = () => ({ tr, dispatch }) => { + const selection = resolveHeaderFooterSelection({ tr }); const { empty, ranges } = selection; if (dispatch) { diff --git a/packages/super-editor/src/editors/v1/core/commands/unsetMark.js b/packages/super-editor/src/editors/v1/core/commands/unsetMark.js index d1d0589fae..7a060d7134 100644 --- a/packages/super-editor/src/editors/v1/core/commands/unsetMark.js +++ b/packages/super-editor/src/editors/v1/core/commands/unsetMark.js @@ -1,5 +1,6 @@ import { getMarkRange } from '../helpers/getMarkRange.js'; import { getMarkType } from '../helpers/getMarkType.js'; +import { resolveHeaderFooterSelection } from './helpers/resolveHeaderFooterSelection.js'; import { removeParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; /** @@ -8,12 +9,9 @@ import { removeParagraphRunProperty } from '../helpers/syncParagraphRunPropertie * @param options.extendEmptyMarkRange Removes the mark even across the current selection. */ //prettier-ignore -export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, editor }) => { +export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch }) => { const { extendEmptyMarkRange = false } = options; - let { selection } = tr; - if (editor.options.isHeaderOrFooter) { - selection = editor.options.lastSelection; - } + const selection = resolveHeaderFooterSelection({ tr }); const type = getMarkType(typeOrName, state.schema); const { $from, empty, ranges } = selection; diff --git a/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js b/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js index e8fc53f7c3..9458e75778 100644 --- a/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js +++ b/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js @@ -267,6 +267,25 @@ describe('SuperToolbar sticky mark persistence', () => { expect(item.activate).toHaveBeenCalledWith(); }); + + it('executes mark toggles immediately for header/footer editors instead of queueing', () => { + mockEditor.options.isHeaderOrFooter = true; + mockEditor.view.hasFocus = vi.fn(() => false); + const setFontSize = vi.fn(); + mockEditor.commands.setFontSize = setFontSize; + + const item = { + command: 'setFontSize', + name: { value: 'fontSize' }, + labelAttr: { value: 'fontSize' }, + activate: vi.fn(), + }; + + toolbar.emitCommand({ item, argument: '24pt' }); + + expect(toolbar.pendingMarkCommands).toHaveLength(0); + expect(setFontSize).toHaveBeenCalledWith('24pt'); + }); }); describe('SuperToolbar error handling for command failures', () => { From bc9a71faf61d747dfb08c2562faba2cf41ab7001 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:44:46 -0300 Subject: [PATCH 09/43] chore: merge stable into main (release conflicts auto-resolved) (#2911) Co-authored-by: github-actions[bot] --- packages/superdoc/package.json | 2 +- packages/template-builder/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 9bd209d55b..0a8b63c482 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -1,7 +1,7 @@ { "name": "superdoc", "type": "module", - "version": "1.27.0", + "version": "1.28.0", "license": "AGPL-3.0", "repository": { "type": "git", diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 5898334e61..1d078f0baf 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -53,7 +53,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "superdoc": "^1.24.2" + "superdoc": "^1.28.0" }, "devDependencies": { "@testing-library/jest-dom": "catalog:", From 0038b2c771bc9705d95117541a35f7cda2f3e6c7 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:11:42 -0700 Subject: [PATCH 10/43] ci(release): harden stable release orchestration and recovery (#2908) * ci(release): harden stable release orchestration and recovery * fix: lint * chore: fix dry run --- .github/workflows/release-cli.yml | 9 +- .github/workflows/release-create.yml | 10 +- .github/workflows/release-esign.yml | 15 +- .github/workflows/release-mcp.yml | 10 +- .github/workflows/release-react.yml | 15 +- .github/workflows/release-sdk.yml | 23 +- .github/workflows/release-stable.yml | 173 ++++ .github/workflows/release-superdoc.yml | 9 +- .../workflows/release-template-builder.yml | 15 +- .github/workflows/release-vscode-ext.yml | 15 +- apps/cli/.releaserc.cjs | 1 + apps/cli/scripts/publish.js | 15 +- apps/vscode-ext/.releaserc.cjs | 1 + apps/vscode-ext/package.json | 2 +- packages/esign/.releaserc.cjs | 1 + packages/react/.releaserc.cjs | 1 + packages/sdk/.releaserc.cjs | 1 + .../scripts/__tests__/release-order.test.mjs | 12 +- packages/sdk/scripts/publish-node-sdk.mjs | 15 +- packages/superdoc/.releaserc.cjs | 1 + packages/template-builder/.releaserc.cjs | 1 + scripts/__tests__/release-local.test.mjs | 178 +++- scripts/npm-publish-package.cjs | 94 ++ scripts/publish-react.cjs | 17 +- scripts/publish-superdoc.cjs | 13 +- scripts/release-local-stable.mjs | 827 +++++++++++++++++- scripts/release-local.mjs | 38 +- 27 files changed, 1411 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/release-stable.yml create mode 100644 scripts/npm-publish-package.cjs diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 64a43cc3ff..7e59bd1144 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -1,13 +1,11 @@ -# Auto-releases CLI on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases CLI on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release CLI on: push: branches: - main - - stable paths: # Keep in sync with apps/cli/.releaserc.cjs includePaths (patch-commit-filter). # Workflow paths trigger CI; includePaths control semantic-release commit analysis. @@ -19,6 +17,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'scripts/semantic-release/**' - 'pnpm-workspace.yaml' - '!**/*.md' @@ -29,7 +28,7 @@ permissions: packages: write concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml index fabe7adf34..4bc957a1df 100644 --- a/.github/workflows/release-create.yml +++ b/.github/workflows/release-create.yml @@ -17,8 +17,8 @@ permissions: packages: write concurrency: - group: release-create-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -36,6 +36,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-esign.yml b/.github/workflows/release-esign.yml index 2347fbfd82..8431abc0ae 100644 --- a/.github/workflows/release-esign.yml +++ b/.github/workflows/release-esign.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release esign on: push: branches: - main - - stable paths: - 'packages/esign/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-esign-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index 993cb5fd7e..a8f5f281e7 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -17,8 +17,8 @@ permissions: packages: write concurrency: - group: release-mcp-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -36,6 +36,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-react.yml b/.github/workflows/release-react.yml index 61ebd7b4a8..30bedd8fdf 100644 --- a/.github/workflows/release-react.yml +++ b/.github/workflows/release-react.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release react on: push: branches: - main - - stable paths: - 'packages/react/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-react-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 33488b2651..103bf688de 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,6 +1,5 @@ -# Auto-releases SDK on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases SDK on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. # Also supports manual dispatch as a fallback for one-off releases. name: "\U0001F4E6 Release SDK" @@ -8,7 +7,6 @@ on: push: branches: - main - - stable paths: # Keep in sync with packages/sdk/.releaserc.cjs includePaths (patch-commit-filter). - 'packages/sdk/**' @@ -20,22 +18,23 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'scripts/semantic-release/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: inputs: version: - description: "Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version." + description: 'Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version.' required: false type: string dry-run: - description: "Dry run — build and validate without publishing" + description: 'Dry run — build and validate without publishing' required: false type: boolean default: false npm-tag: - description: "npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override." + description: 'npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override.' required: false type: string default: latest @@ -46,7 +45,7 @@ permissions: id-token: write # PyPI trusted publishing (OIDC) concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: @@ -85,7 +84,7 @@ jobs: with: node-version-file: .nvmrc cache: pnpm - registry-url: "https://registry.npmjs.org" + registry-url: 'https://registry.npmjs.org' - uses: oven-sh/setup-bun@v2 with: @@ -93,7 +92,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - name: Install canvas system dependencies run: | @@ -233,7 +232,7 @@ jobs: with: node-version-file: .nvmrc cache: pnpm - registry-url: "https://registry.npmjs.org" + registry-url: 'https://registry.npmjs.org' - uses: oven-sh/setup-bun@v2 with: @@ -241,7 +240,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000000..08611c6a1a --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,173 @@ +# Sequential stable release orchestrator. +# This is the only workflow that auto-releases on push to stable. +name: 📦 Release stable + +on: + push: + branches: + - stable + workflow_dispatch: + +permissions: + contents: write + packages: write + id-token: write + +concurrency: + # Keep [skip ci] writeback runs out of the shared stable queue so they cannot + # replace a real pending stable push while the orchestrator is active. + group: ${{ github.event_name == 'push' && contains(github.event.head_commit.message, '[skip ci]') && format('release-stable-skip-{0}', github.run_id) || 'release-stable' }} + cancel-in-progress: false + +jobs: + release: + if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-24.04 + environment: pypi + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: stable + token: ${{ steps.generate_token.outputs.token }} + + - name: Refresh stable branch head + run: | + git fetch origin stable --tags + git checkout -B stable origin/stable + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Cache apt packages + uses: actions/cache@v5 + with: + path: ~/apt-cache + key: apt-canvas-${{ runner.os }}-v1 + + - name: Install canvas system dependencies + run: | + mkdir -p ~/apt-cache + sudo apt-get update + sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \ + build-essential \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + libpixman-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Python build tools + run: pip install build + + - name: Build packages + run: pnpm run build + + - name: Test vscode-ext + run: pnpm --prefix apps/vscode-ext run test + + - name: Snapshot SDK tags before release + id: sdk_tags_before + run: echo "tags=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')" >> "$GITHUB_OUTPUT" + + - name: Release stable packages sequentially + id: stable_release + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + VSCE_PAT: ${{ secrets.VSCE_PAT }} + GITHUB_REF_NAME: stable + run: node scripts/release-local-stable.mjs + + - name: Detect SDK release tag at HEAD + id: sdk_release + run: | + BEFORE="${{ steps.sdk_tags_before.outputs.tags }}" + AFTER=$(git tag --list 'sdk-v*' | sort | tr '\n' ',') + RELEASE_TAG=$(git tag --points-at HEAD --list 'sdk-v*' --sort=-version:refname | head -n 1) + if [ -z "$RELEASE_TAG" ]; then + echo "release_present=false" >> "$GITHUB_OUTPUT" + echo "released=false" >> "$GITHUB_OUTPUT" + echo "version=" >> "$GITHUB_OUTPUT" + echo "dist_tag=" >> "$GITHUB_OUTPUT" + echo "No SDK release tag at HEAD." + else + echo "release_present=true" >> "$GITHUB_OUTPUT" + if [ "$BEFORE" = "$AFTER" ]; then + echo "released=false" >> "$GITHUB_OUTPUT" + else + echo "released=true" >> "$GITHUB_OUTPUT" + fi + VERSION="${RELEASE_TAG#sdk-v}" + if [[ "$VERSION" == *-next.* ]]; then + DIST_TAG="next" + else + DIST_TAG="latest" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" + if [ "$BEFORE" = "$AFTER" ]; then + echo "SDK release tag already present at HEAD: $RELEASE_TAG" + else + echo "Released SDK v$VERSION" + fi + fi + + - name: Publish recovered SDK companion Python packages to PyPI + if: steps.stable_release.outputs.sdk_python_snapshot_companion_dir != '' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_companion_dir }} + skip-existing: true + + - name: Publish recovered SDK main Python package to PyPI + if: steps.stable_release.outputs.sdk_python_snapshot_main_dir != '' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_main_dir }} + skip-existing: true + + - name: Build and verify Python SDK + if: steps.sdk_release.outputs.release_present == 'true' + run: node packages/sdk/scripts/build-python-sdk.mjs + + - name: Publish companion Python packages to PyPI + if: steps.sdk_release.outputs.release_present == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/sdk/langs/python/companion-dist/ + skip-existing: true + + - name: Publish main Python SDK to PyPI + if: steps.sdk_release.outputs.release_present == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/sdk/langs/python/dist/ + skip-existing: true diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 4e6ccbcbda..c3f781da39 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,6 +1,5 @@ -# Auto-releases on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. # Manual PR preview: dispatch with pr_number to publish @pr- name: 📦 Release superdoc @@ -8,7 +7,6 @@ on: push: branches: - main - - stable paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' @@ -16,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -31,7 +30,7 @@ permissions: pull-requests: write concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: diff --git a/.github/workflows/release-template-builder.yml b/.github/workflows/release-template-builder.yml index 5573149cff..a4d4561ca7 100644 --- a/.github/workflows/release-template-builder.yml +++ b/.github/workflows/release-template-builder.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release template-builder on: push: branches: - main - - stable paths: - 'packages/template-builder/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-template-builder-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml index 30b127a69c..6e8baecaaa 100644 --- a/.github/workflows/release-vscode-ext.yml +++ b/.github/workflows/release-vscode-ext.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release vscode-ext on: push: branches: - main - - stable paths: - 'apps/vscode-ext/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -22,6 +23,10 @@ permissions: contents: write packages: write +concurrency: + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} + jobs: release: runs-on: ubuntu-latest @@ -38,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 88175df8ce..950fe38849 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/apps/cli/scripts/publish.js b/apps/cli/scripts/publish.js index 02ab3eeafa..a2c985213c 100644 --- a/apps/cli/scripts/publish.js +++ b/apps/cli/scripts/publish.js @@ -81,6 +81,18 @@ export function isAlreadyPublished(packageName, version, authToken, baseEnv = pr throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`); } +function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) { + const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], { + cwd: repoRoot, + stdio: 'inherit', + env: createNpmEnv(baseEnv, authToken), + }); + + if (result.status !== 0) { + throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`); + } +} + function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) { const pkgDir = PACKAGE_DIR_BY_NAME[packageName]; if (!pkgDir) { @@ -89,7 +101,8 @@ function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.e const version = getPackageVersion(packageName); if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) { - console.log(`Skipping ${packageName}@${version} (already published).`); + console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`); + ensureDistTag(packageName, version, tag, authToken, baseEnv); return; } diff --git a/apps/vscode-ext/.releaserc.cjs b/apps/vscode-ext/.releaserc.cjs index 6ef515b19e..214a88ceae 100644 --- a/apps/vscode-ext/.releaserc.cjs +++ b/apps/vscode-ext/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json index 1fc001f91c..33b303b3e9 100644 --- a/apps/vscode-ext/package.json +++ b/apps/vscode-ext/package.json @@ -77,7 +77,7 @@ "build": "pnpm run compile", "watch": "pnpm run compile:ext -- --watch", "package": "vsce package --no-dependencies", - "publish:vsce": "vsce publish --packagePath *.vsix", + "publish:vsce": "vsce publish --packagePath *.vsix --skip-duplicate", "lint": "eslint .", "lint:fix": "eslint --fix .", "format": "prettier --write .", diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs index 4eb28dc71d..f4a177a602 100644 --- a/packages/esign/.releaserc.cjs +++ b/packages/esign/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs index d875ffa4f1..2a427aab25 100644 --- a/packages/react/.releaserc.cjs +++ b/packages/react/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs index 6c7f92118a..bf9c50fca5 100644 --- a/packages/sdk/.releaserc.cjs +++ b/packages/sdk/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/sdk/scripts/__tests__/release-order.test.mjs b/packages/sdk/scripts/__tests__/release-order.test.mjs index 6f8c94d8cb..2622b88e70 100644 --- a/packages/sdk/scripts/__tests__/release-order.test.mjs +++ b/packages/sdk/scripts/__tests__/release-order.test.mjs @@ -94,15 +94,21 @@ test('release-sdk auto workflow resumes releases from sdk-v tags at HEAD', async ); }); -test('release-sdk auto workflow runs on stable and main', async () => { +test('release-sdk auto workflow stays on main while stable uses the central orchestrator', async () => { const content = await readRepoFile('.github/workflows/release-sdk.yml'); + const stableWorkflow = await readRepoFile('.github/workflows/release-stable.yml'); assert.ok( content.includes(' - main'), '.github/workflows/release-sdk.yml: auto-release must continue to run on main', ); - assert.ok( + assert.equal( content.includes(' - stable'), - '.github/workflows/release-sdk.yml: auto-release must run on stable', + false, + '.github/workflows/release-sdk.yml: stable releases should be handled by release-stable.yml', + ); + assert.ok( + stableWorkflow.includes(' - stable'), + '.github/workflows/release-stable.yml: the central stable orchestrator must run on stable', ); }); diff --git a/packages/sdk/scripts/publish-node-sdk.mjs b/packages/sdk/scripts/publish-node-sdk.mjs index 536f45854e..c491a6a68f 100644 --- a/packages/sdk/scripts/publish-node-sdk.mjs +++ b/packages/sdk/scripts/publish-node-sdk.mjs @@ -82,13 +82,26 @@ function isAlreadyPublished(packageName, version, authToken, baseEnv = process.e throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`); } +function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) { + const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], { + cwd: REPO_ROOT, + stdio: 'inherit', + env: createNpmEnv(baseEnv, authToken), + }); + + if (result.status !== 0) { + throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`); + } +} + function runNpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) { const pkgDir = PACKAGE_DIR_BY_NAME[packageName]; if (!pkgDir) throw new Error(`No package directory mapping for ${packageName}`); const version = getPackageVersion(packageName); if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) { - console.log(`Skipping ${packageName}@${version} (already published).`); + console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`); + ensureDistTag(packageName, version, tag, authToken, baseEnv); return; } diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs index 88786ab318..3d2f9de69b 100644 --- a/packages/superdoc/.releaserc.cjs +++ b/packages/superdoc/.releaserc.cjs @@ -13,6 +13,7 @@ const SUPERDOC_PACKAGES = [ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ] diff --git a/packages/template-builder/.releaserc.cjs b/packages/template-builder/.releaserc.cjs index bd468610cd..1b624a4de0 100644 --- a/packages/template-builder/.releaserc.cjs +++ b/packages/template-builder/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 70df5517de..9ad89f8d05 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -34,14 +34,18 @@ test('inferDryRunWouldRelease detects pending release previews', () => { test('release-local helper prunes local-only tags across all release namespaces', async () => { const content = await readRepoFile('scripts/release-local.mjs'); assert.ok( - content.includes('for (const prefix of ALL_TAG_PREFIXES)'), - 'scripts/release-local.mjs: must iterate every known release tag prefix', + content.includes('for (const pattern of ALL_TAG_PATTERNS)'), + 'scripts/release-local.mjs: must iterate every known release tag pattern', ); assert.equal( content.includes("filter((p) => p !== ownTagPrefix)"), false, 'scripts/release-local.mjs: must not skip the current package tag namespace', ); + assert.ok( + content.includes("'v[0-9]*'"), + 'scripts/release-local.mjs: superdoc tag matching must not also match vscode release tags', + ); }); test('stable orchestrator prunes before snapshot and reports would-release previews', async () => { @@ -49,7 +53,7 @@ test('stable orchestrator prunes before snapshot and reports would-release previ assertOrder( content, ' pruneLocalOnlyReleaseTags();', - ' const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`));', + ' const tagsBefore = new Set(listTags(pkg.tagPattern));', 'scripts/release-local-stable.mjs', ); assert.ok( @@ -73,3 +77,171 @@ test('stable orchestrator releases superdoc, cli, then sdk in order', async () = 'scripts/release-local-stable.mjs (cli before sdk)', ); }); + +test('stable workflow isolates skip-ci writebacks from the shared stable queue', async () => { + const content = await readRepoFile('.github/workflows/release-stable.yml'); + assert.equal( + content.includes(' paths:'), + false, + '.github/workflows/release-stable.yml: stable releases must run on every push, not a filtered path subset', + ); + assert.ok( + content.includes("contains(github.event.head_commit.message, '[skip ci]')"), + '.github/workflows/release-stable.yml: concurrency must detect [skip ci] writeback pushes', + ); + assert.ok( + content.includes("format('release-stable-skip-{0}', github.run_id)"), + '.github/workflows/release-stable.yml: skip-ci writebacks must use a separate concurrency group', + ); + assert.ok( + content.includes("if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]')"), + '.github/workflows/release-stable.yml: skip-ci writeback runs must still no-op when they start', + ); +}); + +test('stable release workflows and commit filters include shared workspace coverage', async () => { + const workflowFiles = [ + '.github/workflows/release-superdoc.yml', + '.github/workflows/release-esign.yml', + '.github/workflows/release-react.yml', + '.github/workflows/release-template-builder.yml', + '.github/workflows/release-vscode-ext.yml', + '.github/workflows/release-cli.yml', + '.github/workflows/release-sdk.yml', + ]; + + for (const file of workflowFiles) { + const content = await readRepoFile(file); + assert.ok(content.includes("'shared/**'"), `${file}: shared workspace changes must trigger release workflows`); + } + + const releasercFiles = [ + 'packages/superdoc/.releaserc.cjs', + 'packages/esign/.releaserc.cjs', + 'packages/react/.releaserc.cjs', + 'packages/template-builder/.releaserc.cjs', + 'apps/vscode-ext/.releaserc.cjs', + 'apps/cli/.releaserc.cjs', + 'packages/sdk/.releaserc.cjs', + ]; + + for (const file of releasercFiles) { + const content = await readRepoFile(file); + assert.ok(content.includes("'shared'"), `${file}: semantic-release must analyze shared workspace changes`); + } +}); + +test('stable orchestrator recovers incomplete merged tags and defers stale checkouts', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes("? 'resumed'"), + 'scripts/release-local-stable.mjs: recovered tagged releases must be reported as resumed when no new release is cut', + ); + assert.ok( + content.includes("listMergedTags(pkg.tagPattern, branchRef)[0]"), + 'scripts/release-local-stable.mjs: recovery must inspect the latest merged tag for each package, not only tags at HEAD', + ); + assert.ok( + content.includes("run('git', ['worktree', 'add', '--detach', worktreeRoot, tag])"), + 'scripts/release-local-stable.mjs: recovering older partial releases must use a tagged worktree snapshot', + ); + assert.ok( + content.includes('ensureGitHubRelease'), + 'scripts/release-local-stable.mjs: reruns must repair missing GitHub releases, not only package publishes', + ); + assert.ok( + content.includes('sdk_python_snapshot_companion_dir'), + 'scripts/release-local-stable.mjs: SDK recovery must expose snapshot Python artifacts for workflow publishing', + ); + assert.ok( + content.includes('npm-publish-package.cjs'), + 'scripts/release-local-stable.mjs: reruns must have a generic npm resume path', + ); + assert.ok( + content.includes('sdk-release-publish.mjs'), + 'scripts/release-local-stable.mjs: SDK reruns must resume npm publish explicitly', + ); + assert.ok( + content.includes(": 'deferred'"), + 'scripts/release-local-stable.mjs: stale checkout races must defer instead of failing', + ); + assert.ok( + content.includes('StableBranchAdvancedError'), + 'scripts/release-local-stable.mjs: must detect when stable advances during the run', + ); + assert.ok( + content.includes('Current run stopped before publishing from a stale checkout.'), + 'scripts/release-local-stable.mjs: deferred runs must explain why they stopped', + ); +}); + +test('stable dry runs skip incomplete-release recovery side effects', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes('if (!isDryRun) {'), + 'scripts/release-local-stable.mjs: dry runs must gate recovery behind !isDryRun', + ); + assertOrder( + content, + ' if (!isDryRun) {', + ' recoveredRelease = await maybeRecoverIncompleteRelease(pkg, branchRef);', + 'scripts/release-local-stable.mjs', + ); +}); + +test('stable workflow publishes recovered SDK Python snapshots before any head-tag SDK publish', async () => { + const content = await readRepoFile('.github/workflows/release-stable.yml'); + assertOrder( + content, + '- name: Publish recovered SDK companion Python packages to PyPI', + '- name: Build and verify Python SDK', + '.github/workflows/release-stable.yml', + ); + assert.ok( + content.includes('id: stable_release'), + '.github/workflows/release-stable.yml: stable orchestrator step must expose recovery outputs', + ); + assert.ok( + content.includes("if: steps.stable_release.outputs.sdk_python_snapshot_companion_dir != ''"), + '.github/workflows/release-stable.yml: recovered SDK snapshot companion wheels must publish even when the sdk tag is not at HEAD', + ); + assert.ok( + content.includes("if: steps.stable_release.outputs.sdk_python_snapshot_main_dir != ''"), + '.github/workflows/release-stable.yml: recovered SDK snapshot root wheel must publish even when the sdk tag is not at HEAD', + ); + assert.ok( + content.includes("if: steps.sdk_release.outputs.release_present == 'true'"), + '.github/workflows/release-stable.yml: SDK Python publish must still key off the sdk tag at HEAD', + ); + assert.equal( + content.includes('Resume Node SDK publish for existing release tag'), + false, + '.github/workflows/release-stable.yml: SDK npm resume now belongs to the stable orchestrator', + ); +}); + +test('publish helpers only treat real 404s as missing versions and keep dist-tags consistent', async () => { + const genericHelper = await readRepoFile('scripts/npm-publish-package.cjs'); + const superdocHelper = await readRepoFile('scripts/publish-superdoc.cjs'); + const cliPublish = await readRepoFile('apps/cli/scripts/publish.js'); + const sdkPublish = await readRepoFile('packages/sdk/scripts/publish-node-sdk.mjs'); + + for (const [file, content] of [ + ['scripts/npm-publish-package.cjs', genericHelper], + ['scripts/publish-superdoc.cjs', superdocHelper], + ]) { + assert.ok( + content.includes('E404|Not found|not found|No match found'), + `${file}: missing-version checks must distinguish true 404s from other npm lookup failures`, + ); + } + + assert.ok( + cliPublish.includes('already published, ensuring dist-tag'), + 'apps/cli/scripts/publish.js: reruns must reapply dist-tags for already-published CLI packages', + ); + assert.ok( + sdkPublish.includes('already published, ensuring dist-tag'), + 'packages/sdk/scripts/publish-node-sdk.mjs: reruns must reapply dist-tags for already-published SDK packages', + ); +}); diff --git a/scripts/npm-publish-package.cjs b/scripts/npm-publish-package.cjs new file mode 100644 index 0000000000..85fd5db7e2 --- /dev/null +++ b/scripts/npm-publish-package.cjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); + +const rootDir = path.resolve(__dirname, '..'); +const defaultRegistry = process.env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org'; + +const run = (command, args, cwd = rootDir) => { + execFileSync(command, args, { stdio: 'inherit', cwd }); +}; + +const isVersionLookupNotFoundError = (error) => { + const details = [error?.stderr, error?.stdout, error?.message] + .filter(Boolean) + .join('\n'); + return /E404|Not found|not found|No match found/i.test(details); +}; + +const isVersionPublished = (packageName, version) => { + try { + execFileSync( + 'pnpm', + ['view', `${packageName}@${version}`, 'version', '--registry', defaultRegistry], + { stdio: 'pipe' }, + ); + return true; + } catch (error) { + if (isVersionLookupNotFoundError(error)) { + return false; + } + throw error; + } +}; + +const getPackageMetadata = (packageDir) => { + const pkgPath = path.join(rootDir, packageDir, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (!pkg.name || !pkg.version) { + throw new Error(`Expected ${packageDir}/package.json to include name and version.`); + } + return pkg; +}; + +const publishPackage = ({ packageDir, tag = 'latest', logger = console }) => { + const cwd = path.join(rootDir, packageDir); + const pkg = getPackageMetadata(packageDir); + + if (isVersionPublished(pkg.name, pkg.version)) { + logger.log(`${pkg.name}@${pkg.version} already published, ensuring dist-tag "${tag}" and skipping.`); + run('pnpm', ['dist-tag', 'add', `${pkg.name}@${pkg.version}`, tag, '--registry', defaultRegistry]); + return; + } + + logger.log(`Publishing ${pkg.name} with dist-tag "${tag}"...`); + run('pnpm', ['publish', '--access', 'public', '--tag', tag, '--no-git-checks'], cwd); +}; + +const parseArgs = (argv) => { + let packageDir = ''; + let tag = 'latest'; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--package-dir') { + packageDir = argv[index + 1] || ''; + index += 1; + } else if (arg === '--tag') { + tag = argv[index + 1] || tag; + index += 1; + } + } + + if (!packageDir) { + throw new Error('--package-dir is required'); + } + + return { packageDir, tag }; +}; + +if (require.main === module) { + try { + publishPackage(parseArgs(process.argv.slice(2))); + } catch (error) { + console.error(error.message || error); + process.exit(1); + } +} + +module.exports = { + isVersionLookupNotFoundError, + isVersionPublished, + publishPackage, +}; diff --git a/scripts/publish-react.cjs b/scripts/publish-react.cjs index 4c8f3cb414..9244fb4a43 100644 --- a/scripts/publish-react.cjs +++ b/scripts/publish-react.cjs @@ -1,20 +1,15 @@ #!/usr/bin/env node -const { execFileSync } = require('node:child_process'); -const path = require('node:path'); - -const rootDir = path.resolve(__dirname, '..'); -const reactDir = path.join(rootDir, 'packages', 'react'); +const { publishPackage } = require('./npm-publish-package.cjs'); module.exports = { publish: async (_pluginConfig, context) => { const { nextRelease, logger = console } = context; const distTag = (nextRelease && nextRelease.channel) || 'latest'; - logger.log(`Publishing @superdoc-dev/react with dist-tag "${distTag}"...`); - execFileSync( - 'pnpm', - ['publish', '--access', 'public', '--tag', distTag, '--no-git-checks'], - { stdio: 'inherit', cwd: reactDir } - ); + publishPackage({ + packageDir: 'packages/react', + tag: distTag, + logger, + }); }, }; diff --git a/scripts/publish-superdoc.cjs b/scripts/publish-superdoc.cjs index 0ffed8bf2f..4d6a123019 100644 --- a/scripts/publish-superdoc.cjs +++ b/scripts/publish-superdoc.cjs @@ -23,6 +23,13 @@ const runCapture = (command, args, cwd) => { return execFileSync(command, args, { cwd, encoding: 'utf8' }).trim(); }; +const isVersionLookupNotFoundError = (error) => { + const details = [error?.stderr, error?.stdout, error?.message] + .filter(Boolean) + .join('\n'); + return /E404|Not found|not found|No match found/i.test(details); +}; + const isVersionPublished = (packageName, version) => { try { execFileSync( @@ -32,7 +39,7 @@ const isVersionPublished = (packageName, version) => { ); return true; } catch (error) { - if (error.status === 1) { + if (isVersionLookupNotFoundError(error)) { return false; } throw error; @@ -59,7 +66,7 @@ const publishScopedMirror = (packageJson, distTag, logger = console) => { if (isVersionPublished(scopedName, packageJson.version)) { logger.log(`${scopedName}@${packageJson.version} already published, ensuring dist-tag "${distTag}" and skipping.`); - run('pnpm', ['dist-tag', 'add', `${scopedName}@${packageJson.version}`, distTag], rootDir); + run('pnpm', ['dist-tag', 'add', `${scopedName}@${packageJson.version}`, distTag, '--registry', defaultRegistry], rootDir); return; } @@ -115,7 +122,7 @@ const publishPackages = ({ if (publishUnscoped) { if (isVersionPublished(packageJson.name, packageJson.version)) { logger.log(`superdoc@${packageJson.version} already published, ensuring dist-tag "${distTag}" and skipping.`); - run('pnpm', ['dist-tag', 'add', `${packageJson.name}@${packageJson.version}`, distTag], rootDir); + run('pnpm', ['dist-tag', 'add', `${packageJson.name}@${packageJson.version}`, distTag, '--registry', defaultRegistry], rootDir); } else { logger.log(`Publishing superdoc with dist-tag "${distTag}"...`); run('pnpm', ['publish', '--access', 'public', '--tag', distTag, '--no-git-checks'], superdocDir); diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index f5471f2b54..b4fe84ca04 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -1,7 +1,15 @@ #!/usr/bin/env node /** - * Combined stable orchestrator — releases superdoc, CLI, then SDK in sequence. + * Combined stable orchestrator for auto-released stable packages. + * + * Order matters: + * - Release core/wrappers first so dependent packages see the final stable head. + * - Release CLI before SDK because the SDK publish pipeline packages CLI artifacts. + * - Release SDK last so stable CI can detect an sdk-v* tag at HEAD and resume + * the Python publish step if needed. + * + * Manual-only stable packages (currently create + mcp) stay outside this flow. * * Usage: * pnpm run release:local [-- --dry-run] @@ -9,24 +17,667 @@ * * Flags: * --branch= Override the expected branch (default: stable) - * All other flags are forwarded to both semantic-release invocations. + * All other flags are forwarded to every semantic-release invocation. */ import { execFileSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; +import { appendFileSync, cpSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { listTags, pruneLocalOnlyReleaseTags, runSemanticRelease } from './release-local.mjs'; +import { listTags, pruneLocalOnlyReleaseTags, run, runSemanticRelease } from './release-local.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); +const GITHUB_API_VERSION = '2022-11-28'; + +const CLI_NPM_PACKAGES = [ + '@superdoc-dev/cli-darwin-arm64', + '@superdoc-dev/cli-darwin-x64', + '@superdoc-dev/cli-linux-x64', + '@superdoc-dev/cli-linux-arm64', + '@superdoc-dev/cli-windows-x64', + '@superdoc-dev/cli', +]; + +const SDK_NODE_NPM_PACKAGES = [ + '@superdoc-dev/sdk-darwin-arm64', + '@superdoc-dev/sdk-darwin-x64', + '@superdoc-dev/sdk-linux-x64', + '@superdoc-dev/sdk-linux-arm64', + '@superdoc-dev/sdk-windows-x64', + '@superdoc-dev/sdk', +]; + +const SDK_PYTHON_PACKAGES = [ + 'superdoc-sdk-cli-darwin-arm64', + 'superdoc-sdk-cli-darwin-x64', + 'superdoc-sdk-cli-linux-x64', + 'superdoc-sdk-cli-linux-arm64', + 'superdoc-sdk-cli-windows-x64', + 'superdoc-sdk', +]; + +function runInWorkspace(workspaceRoot, command, args, options = {}) { + const { capture = false, env = process.env } = options; + return execFileSync(command, args, { + cwd: workspaceRoot, + env, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); +} + +function captureInWorkspace(workspaceRoot, command, args, options = {}) { + const { env = process.env } = options; + try { + return { + stdout: execFileSync(command, args, { + cwd: workspaceRoot, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + stderr: '', + status: 0, + error: null, + }; + } catch (error) { + return { + stdout: typeof error.stdout === 'string' ? error.stdout : String(error.stdout ?? ''), + stderr: typeof error.stderr === 'string' ? error.stderr : String(error.stderr ?? ''), + status: Number.isInteger(error.status) ? error.status : null, + error, + }; + } +} -function getCurrentBranch() { +function getCurrentBranch(cwd = REPO_ROOT) { return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf8', + }).trim(); +} + +function getCurrentHead(cwd = REPO_ROOT) { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd, + encoding: 'utf8', + }).trim(); +} + +function getRemoteHead(branchName) { + return execFileSync('git', ['rev-parse', `origin/${branchName}`], { cwd: REPO_ROOT, encoding: 'utf8', }).trim(); } +function getTagCommit(tag) { + return execFileSync('git', ['rev-list', '-1', tag], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); +} + +function listMergedTags(pattern, ref = 'HEAD') { + const output = execFileSync( + 'git', + ['tag', '--merged', ref, '--list', pattern, '--sort=-version:refname'], + { + cwd: REPO_ROOT, + encoding: 'utf8', + }, + ).trim(); + + return output + ? output + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + : []; +} + +function getPreviousMergedReleaseTag(pattern, currentTag, ref = 'HEAD') { + const tags = listMergedTags(pattern, ref); + const currentIndex = tags.indexOf(currentTag); + return currentIndex === -1 ? '' : (tags[currentIndex + 1] ?? ''); +} + +function getDistTagForVersion(version) { + return version.includes('-next.') ? 'next' : 'latest'; +} + +function getVersionFromTag(pkg, tag) { + return tag.startsWith(pkg.tagPrefix) ? tag.slice(pkg.tagPrefix.length) : tag; +} + +function isLikelyStaleHeadFailure(error) { + const message = error && typeof error.message === 'string' ? error.message : String(error); + return ( + message.includes('failed to push some refs') || + message.includes('non-fast-forward') || + message.includes('Updates were rejected because the remote contains work that you do not have locally') || + message.includes('Updates were rejected because the tip of your current branch is behind') + ); +} + +function isVersionLookupNotFound(details) { + return /E404|Not found|not found|No match found/i.test(details); +} + +function isNpmVersionPublished(packageName, version, workspaceRoot = REPO_ROOT) { + const result = captureInWorkspace( + workspaceRoot, + 'npm', + ['view', `${packageName}@${version}`, 'version'], + ); + + if (result.status === 0) { + return true; + } + + const details = `${result.stderr}\n${result.stdout}`.trim(); + if (isVersionLookupNotFound(details)) { + return false; + } + + if (result.error) { + throw result.error; + } + + throw new Error(`Failed to check published version for ${packageName}@${version}: ${details || 'unknown error'}`); +} + +function isVsCodeExtensionNotFound(details) { + return /not found|does not exist|404/i.test(details); +} + +function isVsCodeExtensionVersionPublished(extensionId, version, workspaceRoot = REPO_ROOT) { + const extensionPrefix = join(workspaceRoot, 'apps/vscode-ext'); + const result = captureInWorkspace( + workspaceRoot, + 'pnpm', + ['--prefix', extensionPrefix, 'exec', 'vsce', 'show', extensionId, '--json'], + ); + + if (result.status !== 0) { + const details = `${result.stderr}\n${result.stdout}`.trim(); + if (isVsCodeExtensionNotFound(details)) { + return false; + } + if (result.error) { + throw result.error; + } + throw new Error(`Failed to inspect VS Code extension ${extensionId}: ${details || 'unknown error'}`); + } + + const metadata = JSON.parse(result.stdout); + const versions = Array.isArray(metadata.versions) ? metadata.versions : []; + if (versions.length === 0 && typeof metadata.version === 'string') { + return metadata.version === version; + } + + return versions.some((entry) => { + if (typeof entry === 'string') return entry === version; + return entry && typeof entry.version === 'string' ? entry.version === version : false; + }); +} + +async function isPyPiVersionPublished(packageName, version) { + const response = await fetch(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/json`, { + headers: { Accept: 'application/json' }, + }); + + if (response.status === 404) { + return false; + } + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Failed to check PyPI version for ${packageName}@${version}: ${response.status} ${details}`); + } + + return true; +} + +function getOriginRepository() { + const repository = process.env.GITHUB_REPOSITORY; + if (repository) { + return repository; + } + + try { + const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); + + const sshMatch = remoteUrl.match(/^git@github\.com:(.+?)(?:\.git)?$/); + if (sshMatch) { + return sshMatch[1]; + } + + const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + return httpsMatch[1]; + } + } catch { + return ''; + } + + return ''; +} + +function hasGitHubReleaseContext() { + return Boolean(process.env.GITHUB_TOKEN) && Boolean(getOriginRepository()); +} + +async function githubJsonRequest(pathname, options = {}) { + const { method = 'GET', body, allow404 = false } = options; + const response = await fetch(`https://api.github.com${pathname}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + 'User-Agent': 'superdoc-release-stable', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (allow404 && response.status === 404) { + return null; + } + + if (!response.ok) { + const details = await response.text(); + throw new Error(`GitHub API ${method} ${pathname} failed: ${response.status} ${details}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +async function githubBinaryRequest(url, options = {}) { + const { method = 'POST', body, headers = {} } = options; + const response = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'User-Agent': 'superdoc-release-stable', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + ...headers, + }, + body, + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`GitHub upload ${method} ${url} failed: ${response.status} ${details}`); + } + + return response.json(); +} + +async function getGitHubReleaseByTag(tag) { + if (!hasGitHubReleaseContext()) { + return null; + } + + const repository = getOriginRepository(); + return githubJsonRequest(`/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, { allow404: true }); +} + +async function generateGitHubReleaseNotes({ tag, targetCommit, previousTag }) { + const repository = getOriginRepository(); + try { + return await githubJsonRequest(`/repos/${repository}/releases/generate-notes`, { + method: 'POST', + body: { + tag_name: tag, + target_commitish: targetCommit, + ...(previousTag ? { previous_tag_name: previousTag } : {}), + }, + }); + } catch (error) { + console.warn(`Failed to generate GitHub release notes for ${tag}: ${error.message}`); + return { name: tag, body: `Recovered release metadata for ${tag}.` }; + } +} + +function getExpectedReleaseAssets(pkg, workspaceRoot) { + if (pkg.name !== 'vscode-ext') { + return []; + } + + const extensionDir = join(workspaceRoot, 'apps/vscode-ext'); + let assets = readdirSync(extensionDir) + .filter((entry) => entry.endsWith('.vsix')) + .map((entry) => join(extensionDir, entry)); + + if (assets.length > 0) { + return assets; + } + + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', extensionDir, 'run', 'package']); + assets = readdirSync(extensionDir) + .filter((entry) => entry.endsWith('.vsix')) + .map((entry) => join(extensionDir, entry)); + + if (assets.length === 0) { + throw new Error('Expected VS Code packaging to produce a .vsix artifact.'); + } + + return assets; +} + +async function ensureGitHubReleaseAssets(release, pkg, workspaceRoot) { + const assets = getExpectedReleaseAssets(pkg, workspaceRoot); + const existing = new Set((release.assets ?? []).map((asset) => asset.name)); + const uploadBaseUrl = release.upload_url.replace(/\{\?name,label\}$/, ''); + + for (const assetPath of assets) { + const assetName = basename(assetPath); + if (existing.has(assetName)) { + continue; + } + + const binary = readFileSync(assetPath); + await githubBinaryRequest(`${uploadBaseUrl}?name=${encodeURIComponent(assetName)}`, { + body: binary, + headers: { + 'Content-Length': String(binary.byteLength), + 'Content-Type': 'application/octet-stream', + }, + }); + } +} + +function isGitHubReleaseComplete(pkg, release) { + if (!hasGitHubReleaseContext()) { + return true; + } + + if (!release) { + return false; + } + + if (pkg.name === 'vscode-ext') { + return Array.isArray(release.assets) && release.assets.some((asset) => asset.name.endsWith('.vsix')); + } + + return true; +} + +async function ensureGitHubRelease(pkg, { tag, targetCommit, previousTag, workspaceRoot }) { + if (!hasGitHubReleaseContext()) { + return null; + } + + const repository = getOriginRepository(); + let release = await getGitHubReleaseByTag(tag); + if (!release) { + const generated = await generateGitHubReleaseNotes({ tag, targetCommit, previousTag }); + release = await githubJsonRequest(`/repos/${repository}/releases`, { + method: 'POST', + body: { + tag_name: tag, + target_commitish: targetCommit, + name: generated.name || tag, + body: generated.body || '', + draft: false, + prerelease: false, + }, + }); + } + + if (pkg.name === 'vscode-ext') { + await ensureGitHubReleaseAssets(release, pkg, workspaceRoot); + release = await getGitHubReleaseByTag(tag); + } + + return release; +} + +function installWorkspaceDependencies(workspaceRoot) { + console.log(`Installing dependencies in tagged snapshot: ${workspaceRoot}`); + runInWorkspace(workspaceRoot, 'pnpm', ['install', '--frozen-lockfile']); +} + +async function withTemporaryWorktree(tag, callback) { + const tempRoot = mkdtempSync(join(tmpdir(), 'stable-release-')); + const worktreeRoot = join(tempRoot, 'worktree'); + + run('git', ['worktree', 'add', '--detach', worktreeRoot, tag]); + + try { + return await callback(worktreeRoot); + } finally { + run('git', ['worktree', 'remove', '--force', worktreeRoot]); + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function copySdkPythonArtifacts(workspaceRoot, tag) { + const tempRoot = mkdtempSync(join(tmpdir(), `sdk-python-${tag.replace(/[^a-zA-Z0-9.-]/g, '-')}-`)); + const companionDir = join(tempRoot, 'companion-dist'); + const mainDir = join(tempRoot, 'dist'); + + cpSync(join(workspaceRoot, 'packages/sdk/langs/python/companion-dist'), companionDir, { recursive: true }); + cpSync(join(workspaceRoot, 'packages/sdk/langs/python/dist'), mainDir, { recursive: true }); + + return { tag, companionDir, mainDir }; +} + +function setStepOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value ?? ''}\n`); +} + +function recordSdkPythonSnapshot(snapshot) { + if (!snapshot) { + return; + } + + setStepOutput('sdk_python_snapshot_tag', snapshot.tag); + setStepOutput('sdk_python_snapshot_companion_dir', snapshot.companionDir); + setStepOutput('sdk_python_snapshot_main_dir', snapshot.mainDir); +} + +async function inspectPackageReleaseState(pkg, { tag, version, workspaceRoot = REPO_ROOT }) { + let publishComplete = true; + if (pkg.vsCodeExtensionId) { + publishComplete = isVsCodeExtensionVersionPublished(pkg.vsCodeExtensionId, version, workspaceRoot); + } else if (pkg.npmPackages) { + publishComplete = pkg.npmPackages.every((packageName) => + isNpmVersionPublished(packageName, version, workspaceRoot), + ); + } + + const release = hasGitHubReleaseContext() ? await getGitHubReleaseByTag(tag) : null; + const githubComplete = isGitHubReleaseComplete(pkg, release); + + let sdkPythonPublished = true; + if (pkg.pythonPackages) { + const publishedFlags = await Promise.all( + pkg.pythonPackages.map((packageName) => isPyPiVersionPublished(packageName, version)), + ); + sdkPythonPublished = publishedFlags.every(Boolean); + } + + return { + publishComplete, + githubComplete, + sdkPythonPublished, + release, + }; +} + +function isTagAtHead(tag) { + return getTagCommit(tag) === getCurrentHead(); +} + +function refreshRemoteState(branchName) { + run('git', ['fetch', 'origin', branchName, '--tags']); +} + +class StableBranchAdvancedError extends Error {} + +function ensureBranchHeadCurrent(branchName) { + const localHead = getCurrentHead(); + const remoteHead = getRemoteHead(branchName); + + if (localHead !== remoteHead) { + throw new StableBranchAdvancedError( + `Branch ${branchName} advanced during the release run (local ${localHead.slice(0, 12)} vs remote ${remoteHead.slice(0, 12)}).`, + ); + } +} + +function resumePackagePublish(pkg, distTag, options = {}) { + const { workspaceRoot = REPO_ROOT, skipBuild = workspaceRoot === REPO_ROOT } = options; + + switch (pkg.name) { + case 'superdoc': { + const args = [join(workspaceRoot, 'scripts/publish-superdoc.cjs'), '--dist-tag', distTag]; + if (skipBuild) { + args.push('--skip-build'); + } + runInWorkspace(workspaceRoot, 'node', args); + break; + } + case 'esign': + case 'react': + case 'template-builder': + runInWorkspace(workspaceRoot, 'node', [ + join(workspaceRoot, 'scripts/npm-publish-package.cjs'), + '--package-dir', + pkg.packageCwd, + '--tag', + distTag, + ]); + break; + case 'vscode-ext': + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/vscode-ext'), 'run', 'package']); + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/vscode-ext'), 'run', 'publish:vsce']); + break; + case 'cli': + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/cli'), 'run', 'build:prepublish']); + runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'apps/cli/scripts/publish.js'), '--tag', distTag]); + break; + case 'sdk': + runInWorkspace(workspaceRoot, 'node', [ + join(workspaceRoot, 'packages/sdk/scripts/sdk-release-publish.mjs'), + '--tag', + distTag, + '--npm-only', + ]); + break; + default: + throw new Error(`No resume command configured for ${pkg.name}`); + } +} + +async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, initialState = null }) { + const targetCommit = getTagCommit(tag); + const tagAtHead = isTagAtHead(tag); + + const recoverInWorkspace = async (workspaceRoot, { snapshot }) => { + if (snapshot) { + installWorkspaceDependencies(workspaceRoot); + } + + let state = initialState ?? (await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot })); + const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot; + const needsPublishResume = !state.publishComplete || needsSnapshotPython; + + if (needsPublishResume) { + console.log( + `${pkg.name} release ${tag} is incomplete; resuming publish (${distTag})${snapshot ? ' from tagged snapshot' : ''}.`, + ); + resumePackagePublish(pkg, distTag, { workspaceRoot, skipBuild: !snapshot }); + state = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot }); + } + + let sdkPythonSnapshot = null; + if (pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot) { + console.log(`Preparing Python SDK artifacts for recovered ${tag} snapshot.`); + runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'packages/sdk/scripts/build-python-sdk.mjs')]); + sdkPythonSnapshot = copySdkPythonArtifacts(workspaceRoot, tag); + } + + const previousTag = getPreviousMergedReleaseTag(pkg.tagPattern, tag, branchRef); + await ensureGitHubRelease(pkg, { + tag, + targetCommit, + previousTag, + workspaceRoot, + }); + + const finalState = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot }); + const readyForWorkflowPython = pkg.name !== 'sdk' || finalState.sdkPythonPublished || sdkPythonSnapshot || tagAtHead; + const missingParts = [ + finalState.publishComplete ? '' : 'package publish', + finalState.githubComplete ? '' : 'GitHub release', + readyForWorkflowPython ? '' : 'Python snapshot artifacts', + ].filter(Boolean); + + if (missingParts.length > 0) { + throw new Error(`Recovery for ${pkg.name} ${tag} is still incomplete: ${missingParts.join(', ')}`); + } + + return { tag, version, distTag, sdkPythonSnapshot }; + }; + + if (tagAtHead) { + return recoverInWorkspace(REPO_ROOT, { snapshot: false }); + } + + return withTemporaryWorktree(tag, (workspaceRoot) => recoverInWorkspace(workspaceRoot, { snapshot: true })); +} + +async function maybeRecoverIncompleteRelease(pkg, branchRef) { + const latestTag = listMergedTags(pkg.tagPattern, branchRef)[0]; + if (!latestTag) { + return null; + } + + const version = getVersionFromTag(pkg, latestTag); + const distTag = getDistTagForVersion(version); + const state = await inspectPackageReleaseState(pkg, { + tag: latestTag, + version, + }); + + const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && !isTagAtHead(latestTag); + const needsRecovery = !state.publishComplete || !state.githubComplete || needsSnapshotPython; + if (!needsRecovery) { + return null; + } + + console.log( + `\nRecovering incomplete ${pkg.name} release ${latestTag}${isTagAtHead(latestTag) ? ' from current HEAD' : ' from tagged snapshot'}.`, + ); + const recovery = await recoverPackageRelease(pkg, { + tag: latestTag, + version, + distTag, + branchRef, + initialState: state, + }); + recordSdkPythonSnapshot(recovery.sdkPythonSnapshot); + return recovery; +} + // --------------------------------------------------------------------------- // Parse own flags vs forwarded flags // --------------------------------------------------------------------------- @@ -42,6 +693,10 @@ for (const arg of process.argv.slice(2)) { } } +setStepOutput('sdk_python_snapshot_tag', ''); +setStepOutput('sdk_python_snapshot_companion_dir', ''); +setStepOutput('sdk_python_snapshot_main_dir', ''); + // --------------------------------------------------------------------------- // Branch guard // --------------------------------------------------------------------------- @@ -54,20 +709,68 @@ if (currentBranch !== expectedBranch) { } const isDryRun = forwardedArgs.includes('--dry-run') || forwardedArgs.includes('-d'); +const branchRef = `origin/${expectedBranch}`; // --------------------------------------------------------------------------- // Release pipeline // --------------------------------------------------------------------------- const packages = [ - { name: 'superdoc', packageCwd: 'packages/superdoc', tagPrefix: 'v' }, - { name: 'cli', packageCwd: 'apps/cli', tagPrefix: 'cli-v' }, - { name: 'sdk', packageCwd: 'packages/sdk', tagPrefix: 'sdk-v' }, + { + name: 'superdoc', + packageCwd: 'packages/superdoc', + tagPrefix: 'v', + tagPattern: 'v[0-9]*', + npmPackages: ['superdoc', '@harbour-enterprises/superdoc'], + }, + { + name: 'esign', + packageCwd: 'packages/esign', + tagPrefix: 'esign-v', + tagPattern: 'esign-v*', + npmPackages: ['@superdoc-dev/esign'], + }, + { + name: 'react', + packageCwd: 'packages/react', + tagPrefix: 'react-v', + tagPattern: 'react-v*', + npmPackages: ['@superdoc-dev/react'], + }, + { + name: 'template-builder', + packageCwd: 'packages/template-builder', + tagPrefix: 'template-builder-v', + tagPattern: 'template-builder-v*', + npmPackages: ['@superdoc-dev/template-builder'], + }, + { + name: 'vscode-ext', + packageCwd: 'apps/vscode-ext', + tagPrefix: 'vscode-v', + tagPattern: 'vscode-v*', + vsCodeExtensionId: 'superdoc-dev.superdoc-vscode-ext', + }, + { + name: 'cli', + packageCwd: 'apps/cli', + tagPrefix: 'cli-v', + tagPattern: 'cli-v*', + npmPackages: CLI_NPM_PACKAGES, + }, + { + name: 'sdk', + packageCwd: 'packages/sdk', + tagPrefix: 'sdk-v', + tagPattern: 'sdk-v*', + npmPackages: SDK_NODE_NPM_PACKAGES, + pythonPackages: SDK_PYTHON_PACKAGES, + }, ]; /** * @typedef {object} PackageResult - * @property {'released' | 'would-release' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status + * @property {'released' | 'resumed' | 'would-release' | 'no-op' | 'deferred' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status * @property {string[]} newTags - Tags created during this release attempt. */ @@ -75,13 +778,52 @@ const packages = [ const results = new Map(); let hasFailed = false; +let deferredReason = ''; + +function markRemainingSkipped(startIndex) { + for (let index = startIndex; index < packages.length; index += 1) { + results.set(packages[index].name, { status: 'skipped', newTags: [] }); + } +} + +for (let index = 0; index < packages.length; index += 1) { + const pkg = packages[index]; -for (const pkg of packages) { if (hasFailed) { results.set(pkg.name, { status: 'skipped', newTags: [] }); continue; } + refreshRemoteState(expectedBranch); + + let recoveredRelease = null; + if (!isDryRun) { + try { + recoveredRelease = await maybeRecoverIncompleteRelease(pkg, branchRef); + } catch (error) { + console.error(`\n${pkg.name} recovery failed:\n${error.message || error}`); + results.set(pkg.name, { status: 'FAILED', newTags: [] }); + hasFailed = true; + continue; + } + } + + try { + ensureBranchHeadCurrent(expectedBranch); + } catch (error) { + if (error instanceof StableBranchAdvancedError) { + deferredReason = error.message; + console.log(`\n${deferredReason}`); + results.set(pkg.name, { + status: recoveredRelease ? 'resumed' : 'deferred', + newTags: recoveredRelease ? [recoveredRelease.tag] : [], + }); + markRemainingSkipped(index + 1); + break; + } + throw error; + } + // Remove stale local-only tags first, including tags in the current package // namespace, before snapshotting. Otherwise a leftover local tag can skew // semantic-release's lastRelease lookup or mask a newly created tag. @@ -90,24 +832,65 @@ for (const pkg of packages) { // Snapshot tags before release to detect new tags. On real releases // semantic-release creates+pushes the tag before publish plugins run, so a // publish-time failure can still leave behind a real release tag. - const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`)); + const tagsBefore = new Set(listTags(pkg.tagPattern)); try { const runResult = runSemanticRelease(pkg.packageCwd, forwardedArgs); - const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); + const tagsAfter = new Set(listTags(pkg.tagPattern)); const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); const status = runResult.dryRun - ? (runResult.wouldRelease ? 'would-release' : 'no-op') - : (newTags.length > 0 ? 'released' : 'no-op'); - results.set(pkg.name, { status, newTags }); + ? runResult.wouldRelease + ? 'would-release' + : recoveredRelease + ? 'resumed' + : 'no-op' + : newTags.length > 0 + ? 'released' + : recoveredRelease + ? 'resumed' + : 'no-op'; + const reportedTags = newTags.length > 0 ? newTags : recoveredRelease ? [recoveredRelease.tag] : []; + results.set(pkg.name, { status, newTags: reportedTags }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); + const tagsAfter = new Set(listTags(pkg.tagPattern)); + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); + + if (isLikelyStaleHeadFailure(error) && newTags.length === 0) { + deferredReason = `Stable advanced while releasing ${pkg.name}; deferring the remaining work to the queued run on the latest stable head.`; + console.log(`\n${deferredReason}`); + results.set(pkg.name, { + status: recoveredRelease ? 'resumed' : 'deferred', + newTags: recoveredRelease ? [recoveredRelease.tag] : [], + }); + markRemainingSkipped(index + 1); + break; + } + console.error(`\n${pkg.name} release failed:\n${message}`); - // Check whether a tag was created before the failure (partial release). - const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); - const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); + if (newTags.length > 0) { + const recoveryTag = newTags[0]; + const recoveryVersion = getVersionFromTag(pkg, recoveryTag); + const recoveryDistTag = getDistTagForVersion(recoveryVersion); + + try { + console.log(`Attempting recovery for tagged ${pkg.name} release ${recoveryTag}.`); + const recovery = await recoverPackageRelease(pkg, { + tag: recoveryTag, + version: recoveryVersion, + distTag: recoveryDistTag, + branchRef, + }); + recordSdkPythonSnapshot(recovery.sdkPythonSnapshot); + results.set(pkg.name, { status: 'released', newTags }); + continue; + } catch (recoveryError) { + console.error(`Recovery for ${pkg.name} ${recoveryTag} failed:\n${recoveryError.message || recoveryError}`); + } + } + const status = newTags.length > 0 ? 'FAILED (partial)' : 'FAILED'; results.set(pkg.name, { status, newTags }); hasFailed = true; @@ -136,10 +919,12 @@ if (hasFailed) { process.exitCode = 1; } +if (deferredReason && !hasFailed) { + console.log('\nCurrent run stopped before publishing from a stale checkout. The next queued stable run should continue from the latest branch head.'); +} + // Remind operator about @semantic-release/git behavior on stable const anyReleased = [...results.values()].some((r) => r.status === 'released'); if (anyReleased && !isDryRun) { - console.log( - '\n@semantic-release/git automatically pushes version commits and tags on the stable branch.', - ); + console.log('\n@semantic-release/git automatically pushes version commits and tags on the stable branch.'); } diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs index 374108284f..db16ca9e37 100644 --- a/scripts/release-local.mjs +++ b/scripts/release-local.mjs @@ -30,25 +30,26 @@ function getCurrentBranch() { } /** - * Allowlist of every tag prefix used across the monorepo. + * Allowlist of every release tag pattern used across the monorepo. * Used by pruneLocalOnlyReleaseTags to avoid leaking local-only * tags from any package namespace, including the current one, into * semantic-release's version detection. * * MAINTENANCE: when adding a new releasable package with its own - * tagFormat in .releaserc.*, add its prefix here too. You can find + * tagFormat in .releaserc.*, add its pattern here too. You can find * all current tagFormat values with: * grep -r 'tagFormat' --include='*.cjs' --include='*.js' --include='*.mjs' . */ -const ALL_TAG_PREFIXES = [ - 'v', // superdoc (packages/superdoc/.releaserc.cjs) - 'cli-v', // CLI (apps/cli/.releaserc.cjs) - 'sdk-v', // SDK - 'react-v', // React - 'vscode-v', // VS Code - 'mcp-v', // MCP - 'esign-v', // esign - 'template-builder-v', // template-builder +const ALL_TAG_PATTERNS = [ + 'v[0-9]*', // superdoc (packages/superdoc/.releaserc.cjs) + 'cli-v*', // CLI (apps/cli/.releaserc.cjs) + 'create-v*', // Create + 'sdk-v*', // SDK + 'react-v*', // React + 'vscode-v*', // VS Code + 'mcp-v*', // MCP + 'esign-v*', // esign + 'template-builder-v*', // template-builder ]; export function run(command, args, options = {}) { @@ -63,7 +64,12 @@ export function run(command, args, options = {}) { export function listTags(pattern) { const output = run('git', ['tag', '--list', pattern], { capture: true }).trim(); - return output ? output.split('\n').map((tag) => tag.trim()).filter(Boolean) : []; + return output + ? output + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + : []; } export function getRemoteTags() { @@ -91,8 +97,8 @@ export function pruneLocalOnlyReleaseTags() { const pruned = []; const remoteTags = getRemoteTags(); - for (const prefix of ALL_TAG_PREFIXES) { - const tags = listTags(`${prefix}*`); + for (const pattern of ALL_TAG_PATTERNS) { + const tags = listTags(pattern); for (const tag of tags) { if (remoteTags.has(tag)) continue; run('git', ['tag', '-d', tag]); @@ -101,9 +107,7 @@ export function pruneLocalOnlyReleaseTags() { } if (pruned.length > 0) { - console.log( - `Pruned ${pruned.length} local-only foreign tags before release: ${pruned.join(', ')}`, - ); + console.log(`Pruned ${pruned.length} local-only foreign tags before release: ${pruned.join(', ')}`); } } From 84aa0e47bcbce7e570b7415643b439a09389bf2f Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:27:06 +0300 Subject: [PATCH 11/43] fix: prevent corrupt docx export for legacy w:pict vml rect content (#2905) Co-authored-by: Artem Nistuley --- .../alternate-content-translator.js | 30 +++++++++++++++- .../alternate-content-translator.test.js | 25 ++++++------- .../w/pict/helpers/translate-content-block.js | 30 ++++++++++++++-- .../helpers/translate-content-block.test.js | 36 +++++++++++++++++++ .../extensions/content-block/content-block.js | 10 ++++++ .../v1/extensions/types/node-attributes.ts | 4 +++ 6 files changed, 117 insertions(+), 18 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js index 7bb530a8ab..3556625b47 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js @@ -57,6 +57,9 @@ function encode(params) { function decode(params) { const { node } = params; const { drawingContent } = node.attrs; + if (!hasValidDrawingContent(drawingContent)) { + return null; + } // Handle modern DrawingML content (existing logic) const drawing = { @@ -70,9 +73,19 @@ function decode(params) { elements: [drawing], }; + const fallback = { + name: 'mc:Fallback', + elements: [ + { + name: 'w:drawing', + elements: carbonCopy(drawing.elements || []), + }, + ], + }; + return { name: 'mc:AlternateContent', - elements: [choice], + elements: [choice, fallback], }; } @@ -139,3 +152,18 @@ function buildPath(existingPath = [], node, branch) { if (branch) path.push(branch); return path; } + +/** + * @param {unknown} drawingContent + * @returns {boolean} + */ +function hasValidDrawingContent(drawingContent) { + const drawingChildren = drawingContent?.elements; + if (!Array.isArray(drawingChildren) || drawingChildren.length === 0) { + return false; + } + + return drawingChildren.some( + (child) => child && typeof child === 'object' && (child.name === 'wp:inline' || child.name === 'wp:anchor'), + ); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js index abbef78c25..04a59c4337 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js @@ -125,7 +125,7 @@ describe('mc:AltermateContent translator', () => { }); describe('decode', () => { - it('returns mc:AlternateContent structure with w:drawing inside mc:Choice', () => { + it('returns mc:AlternateContent with valid choice and fallback drawing branches', () => { const params = { node: { attrs: { @@ -149,30 +149,25 @@ describe('mc:AltermateContent translator', () => { }, ], }, - ], - }); - }); - - it('handles empty drawingContent gracefully', () => { - const params = { node: { attrs: {} } }; - const result = translator.decode(params); - - expect(result).toEqual({ - name: 'mc:AlternateContent', - elements: [ { - name: 'mc:Choice', - attributes: { Requires: 'wps' }, + name: 'mc:Fallback', elements: [ { name: 'w:drawing', - elements: [], + elements: [{ name: 'wp:inline' }], }, ], }, ], }); }); + + it('returns null when drawingContent is missing/invalid', () => { + const params = { node: { attrs: {} } }; + const result = translator.decode(params); + + expect(result).toBeNull(); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js index 9d0e1896d5..c37d2c8eb4 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js @@ -8,17 +8,43 @@ import { wrapTextInRun } from '@converter/exporter'; */ export function translateContentBlock(params) { const { node } = params; - const { vmlAttributes, horizontalRule } = node.attrs; + const { vmlAttributes, horizontalRule, attributes, style } = node.attrs; + const hasLegacyVmlMarkers = detectLegacyVmlRectMarkers(attributes, style); // Handle VML v:rect elements (like horizontal rules) - if (vmlAttributes || horizontalRule) { + if (vmlAttributes || horizontalRule || hasLegacyVmlMarkers) { return translateVRectContentBlock(params); } const alternateContent = alternateChoiceTranslator.decode(params); + if (!alternateContent) { + return null; + } return wrapTextInRun(alternateContent); } +/** + * Detects legacy VML rect signatures preserved in contentBlock attrs. + * This prevents generic legacy w:pict content from being routed into + * DrawingML alternate-content export paths. + * + * @param {Record|null|undefined} rawAttributes + * @param {unknown} style + * @returns {boolean} + */ +function detectLegacyVmlRectMarkers(rawAttributes, style) { + if (style) return true; + if (!rawAttributes || typeof rawAttributes !== 'object') return false; + + const attrs = rawAttributes; + + if (typeof attrs.style === 'string' && attrs.style.trim().length > 0) return true; + if (attrs.fillcolor != null) return true; + if (attrs.stroked != null) return true; + + return Object.keys(attrs).some((key) => key.startsWith('o:')); +} + // Nominal full-width value for VML style. Word ignores this when o:hr="t" // is present and renders the rect at full page width instead. const FULL_WIDTH_PT = '468pt'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js index 1af103dbd6..093e4b0b78 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js @@ -63,6 +63,42 @@ describe('translateContentBlock', () => { expect(alternateChoiceTranslator.decode).not.toHaveBeenCalled(); expect(result.elements[0].name).toBe('w:pict'); }); + + it('should use translateVRectContentBlock when legacy VML markers exist in attributes', () => { + const params = { + node: { + attrs: { + attributes: { + style: 'position:absolute;width:100pt;height:20pt', + fillcolor: 'yellow', + stroked: 'f', + }, + }, + }, + }; + + generateRandomSigned32BitIntStrId.mockReturnValue('12345678'); + + const result = translateContentBlock(params); + + expect(alternateChoiceTranslator.decode).not.toHaveBeenCalled(); + expect(result.elements[0].name).toBe('w:pict'); + }); + + it('should return null when alternateChoiceTranslator returns null', () => { + alternateChoiceTranslator.decode.mockReturnValue(null); + + const params = { + node: { + attrs: {}, + }, + }; + + const result = translateContentBlock(params); + + expect(result).toBeNull(); + expect(wrapTextInRun).not.toHaveBeenCalled(); + }); }); describe('translateVRectContentBlock', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/content-block/content-block.js b/packages/super-editor/src/editors/v1/extensions/content-block/content-block.js index 3c1521b1ef..d4d7f53b85 100644 --- a/packages/super-editor/src/editors/v1/extensions/content-block/content-block.js +++ b/packages/super-editor/src/editors/v1/extensions/content-block/content-block.js @@ -142,6 +142,16 @@ export const ContentBlock = Node.create({ rendered: false, }, + vmlAttributes: { + default: null, + rendered: false, + }, + + style: { + default: null, + rendered: false, + }, + attributes: { rendered: false, }, diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index ed75a69109..48528bd9a5 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -986,6 +986,10 @@ export interface ContentBlockAttrs extends InlineNodeAttributes { size?: ContentBlockSize | null; /** Background color */ background?: string | null; + /** @internal VML attributes for legacy w:pict round-trip */ + vmlAttributes?: Record | null; + /** @internal Preserved inline style for legacy VML content */ + style?: string | null; /** @internal Drawing content data */ drawingContent?: unknown; /** @internal Attributes storage */ From da38a243cc456ed4c1282a2d8988efa66e5c524e Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:44:43 -0300 Subject: [PATCH 12/43] chore: merge stable into main (release conflicts auto-resolved) (#2913) Co-authored-by: github-actions[bot] --- packages/template-builder/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 1d078f0baf..303ba9ca93 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/template-builder", - "version": "1.5.0", + "version": "1.6.0", "description": "React template builder component for SuperDoc", "type": "module", "main": "./dist/index.js", From f276b34ae2e0653900305eb0bd7b2d777caf1449 Mon Sep 17 00:00:00 2001 From: Kendall Ernst <84405229+kendaller@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:57:45 -0700 Subject: [PATCH 13/43] feat(pm-adapter): synthesize internal link for PAGEREF with \h switch (#2899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(super-editor): render imported heading/bookmark cross-references (SD-2495) Wire the sd:crossReference v3 translator into the v2 importer entity list so REF / NOTEREF / STYLEREF fields imported from DOCX actually produce PM crossReference nodes instead of being silently dropped by the dispatch loop. Walk nested run wrappers when extracting the field's cached display text, and render the cross-reference as an internal hyperlink when the instruction carries the \\h switch. Route clicks on internal #bookmark anchors through goToAnchor so rendered cross-references navigate to their target in the document. Fixes IT-949 — Word cross-references (e.g. "Section 15") now appear in the viewer and are searchable, matching Word's output. * fix(presentation-editor): anchor nav writes scrollTop to the real scroll container #scrollPageIntoView wrote scrollTop to #visibleHost, which is typically overflow: visible and therefore not the actual scroll target. Anchor navigation (TOC clicks and SD-2495 cross-reference click-to-navigate) silently did nothing whenever the bookmark target was outside the current viewport — the PM selection moved but the viewport never scrolled. Write to #scrollContainer (the resolved scrollable ancestor) as the primary target, plus #visibleHost for backward compatibility with legacy layouts and the existing test harness that mocks scrollTop on the host element. This unblocks SD-2495's cross-reference click-to-navigate on docs where cross-references and their targets live on different pages. * feat(layout): show-bookmarks bracket indicators (SD-2454) Opt-in visual indicators for bookmark positions — mirrors Word's "Show bookmarks" (File > Options > Advanced). Off by default. - Pm-adapter bookmark-start and new bookmark-end converters emit gray `[` and `]` marker TextRuns when `layoutEngineOptions.showBookmarks` is true. Markers flow through pagination and line breaking as real characters, matching Word's own visual model. - Auto-generated bookmarks (`_Toc…`, `_Ref…`, `_GoBack`) are hidden even when the feature is on — matching Word. A `renderedBookmarkIds` set on the converter context pairs suppression so closing brackets don't orphan open ones. - PresentationEditor.setShowBookmarks toggles at runtime: clears the flow-block cache and schedules a re-render. - SuperDoc.setShowBookmarks is the public API passthrough. - Dev app gets a Show/Hide bookmarks toggle button in the header. - CSS: subtle gray, non-selectable so users don't include brackets in copied text. Bookmark name surfaces via the native title tooltip on the opening bracket. * test: close regression gaps for SD-2495 / SD-2454 / anchor-nav fix Fills the test gaps surfaced by the testing-excellence review of this PR: - crossReferenceImporter.integration.test.js (new, 4 tests): exercises the full v2 body pipeline (preprocessor -> dispatcher -> entity handler -> v3 translator). Asserts crossReferenceEntity is a member of the defaultNodeListHandler entities list, so the exact root cause that produced IT-949 ("Section 15" vanishing) fails loudly if a future refactor drops the wire-up. Unit tests of the translator alone cannot catch this — they bypass the dispatcher. - EditorInputManager.anchorClick.test.ts (new, 4 tests): pins the SD-2537 click-to-navigate routing. Clicking #bookmark hrefs routes through goToAnchor (was TOC-only before). External and empty-fragment hrefs are explicitly NOT routed. - cross-reference.test.ts: added marks-propagation test (node.marks flow into the emitted TextRun so italic/textStyle on xref text survives — SD-2537 "preserve surrounding run styling" AC). - bookmark-markers.test.ts: converted the `for` loop over auto-generated bookmark names into `it.each`. Each input now reports per-case on failure, complies with testing-excellence's "no control flow inside test bodies" guideline. - PresentationEditor.test.ts: documents why the scrollContainer-vs- visibleHost branch of the SD-2495 scrollPageIntoView fix isn't unit- testable here (happy-dom doesn't propagate inline overflow through getComputedStyle, which is what findScrollableAncestor uses). * feat(pm-adapter): synthesize internal link for PAGEREF with \h switch Mirrors the pattern added for crossReference in #2882. When a PAGEREF field instruction carries the `\h` switch, attach a FlowRunLink via `buildFlowRunLink({ anchor: bookmarkId })` so clicks on the rendered page number navigate to the referenced bookmark through the existing anchor-link routing (`EditorInputManager.#handleLinkClick` → `goToAnchor`). Previously the PAGEREF inline converter emitted the `pageReference` token run with `pageRefMetadata.bookmarkId` but no `link`, so the DOM layer never produced a clickable element for PAGEREFs. TOC entries and other hyperlinked page references imported from Word therefore failed to navigate on click, even though Word honored the `\h` switch. Depends on #2882 for `buildFlowRunLink` export and the generalized anchor-click routing. * fix(pm-adapter): match PAGEREF \h switch case-insensitively Word field switches are case-insensitive per the field-code grammar, so `\H` should produce the hyperlink the same as `\h`. Reviewer (codex-connector) flagged that the original `/\\h\b/` regex skipped the link synthesis for instructions like `PAGEREF _Toc123 \H`, leaving them non-navigable even though the author had requested hyperlink behavior. Adds the `i` flag and a regression test with an uppercase switch. * test: add coverage for standalone PAGEREF \h + REF-family preprocessors Behavior test (tests/behavior/tests/navigation/pageref-standalone-click.spec.ts): Covers the PR's load-bearing case - a PAGEREF \h field NOT wrapped in a . The existing toc-anchor-scroll.spec.ts only exercises the wrapped-in-hyperlink shape, where the outer link mark already propagates via marksAsAttrs and the PR is a no-op. Fixtures exercise both \h and \H (case-insensitivity per ECMA-376 17.16.1). Preprocessor unit tests (ref/noteref/styleref): These three importer modules were added in #2882 without tests. Each verifies the preprocessor produces a sd:crossReference node with the right fieldType and preserves the instruction text verbatim. * test(superdoc): cover setShowBookmarks propagation and no-op guard Closes the Codecov patch-coverage gap on SuperDoc.js flagged on PR #2899. The uncovered setShowBookmarks method came in via the SD-2454 merge and had no test for its config mutation, no-op short-circuit, or Boolean() coercion. Models the existing setDisableContextMenu test pattern. * refactor(pm-adapter): drop TextRun casts + case-insensitive \h in cross-reference Addresses review feedback on #2899: - page-reference.ts: remove redundant `as TextRun` casts — textNodeToRun already returns TextRun, so the casts are noise (cross-reference.ts:34 already does this cleanly). Shorten the \h comment. - cross-reference.ts: add the `i` flag to the \h switch regex to match ECMA-376 §17.16.1, same fix as 7f19358 for page-reference. Add a regression test covering `\H`. * test: add unit coverage for scroll fan-out when ancestor != host Stubs window.getComputedStyle to mark a wrapper element as scrollable so #findScrollableAncestor returns the wrapper, then asserts both the wrapper and the visibleHost receive scrollTop. This pins the SD-2495 fix: a revert to the pre-fix one-liner (writing only to the visibleHost) now fails. --------- Co-authored-by: Tadeu Tupinamba Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol --- .../painters/dom/src/renderer.ts | 8 + .../layout-engine/painters/dom/src/styles.ts | 15 ++ .../pm-adapter/src/converter-context.ts | 17 ++ .../inline-converters/bookmark-end.ts | 44 ++++ .../bookmark-markers.test.ts | 113 +++++++++++ .../inline-converters/bookmark-start.ts | 66 ++++-- .../inline-converters/cross-reference.test.ts | 116 +++++++++++ .../inline-converters/cross-reference.ts | 30 ++- .../inline-converters/page-reference.test.ts | 98 +++++++++ .../inline-converters/page-reference.ts | 18 +- .../pm-adapter/src/converters/paragraph.ts | 4 + .../layout-engine/pm-adapter/src/internal.ts | 6 + .../layout-engine/pm-adapter/src/types.ts | 7 + .../presentation-editor/PresentationEditor.ts | 40 +++- .../pointer-events/EditorInputManager.ts | 7 +- .../EditorInputManager.anchorClick.test.ts | 150 ++++++++++++++ .../tests/PresentationEditor.test.ts | 97 +++++++++ .../v1/core/presentation-editor/types.ts | 9 + .../noteref-preprocessor.test.js | 34 ++++ .../ref-preprocessor.test.js | 36 ++++ .../styleref-preprocessor.test.js | 36 ++++ ...crossReferenceImporter.integration.test.js | 191 ++++++++++++++++++ .../v2/importer/crossReferenceImporter.js | 7 + .../v2/importer/docxImporter.js | 2 + .../crossReference-translator.js | 23 ++- .../crossReference-translator.test.js | 77 +++++++ packages/superdoc/src/core/SuperDoc.js | 19 ++ packages/superdoc/src/core/SuperDoc.test.js | 44 ++++ .../src/dev/components/SuperdocDev.vue | 10 + .../fixtures/pageref-standalone-h.docx | Bin 0 -> 13825 bytes .../pageref-standalone-uppercase-h.docx | Bin 0 -> 13828 bytes .../pageref-standalone-click.spec.ts | 60 ++++++ 32 files changed, 1352 insertions(+), 32 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js create mode 100644 tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx create mode 100644 tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx create mode 100644 tests/behavior/tests/navigation/pageref-standalone-click.spec.ts diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ce6869b26d..e6e030be30 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5383,6 +5383,14 @@ export class DomPainter { elem.style.zIndex = '1'; applyRunDataAttributes(elem as HTMLElement, (run as TextRun).dataAttrs); + // SD-2454: bookmark marker runs carry a data-bookmark-name attribute. + // Surface the bookmark name as a native `title` tooltip so hovering the + // opening bracket identifies which bookmark is being marked. + const bookmarkName = (run as TextRun).dataAttrs?.['data-bookmark-name']; + if (bookmarkName) { + (elem as HTMLElement).title = bookmarkName; + } + // Assert PM positions are present for cursor fallback assertPmPositions(run, 'paragraph text run'); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index b548d37107..302f1d60a6 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -197,6 +197,21 @@ const LINK_AND_TOC_STYLES = ` } } +/* SD-2454: bookmark bracket indicators. + * When the showBookmarks layout option is enabled, the pm-adapter emits + * [ and ] marker TextRuns at bookmark start/end positions. Mirror Word's + * visual treatment: subtle gray, non-selectable so users can't accidentally + * include the brackets in copied text. The bookmark name is surfaced via + * the native title tooltip on the opening bracket. */ +[data-bookmark-marker="start"], +[data-bookmark-marker="end"] { + color: #8b8b8b; + user-select: none; + cursor: default; + font-weight: normal; +} + + /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .superdoc-link { diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 250b1237f6..89e75b5e76 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -54,6 +54,23 @@ export type ConverterContext = { * Used by table creation paths to determine which style to apply to new tables. */ defaultTableStyleId?: string; + /** + * When true, emit visible gray `[` and `]` marker TextRuns at bookmarkStart + * and bookmarkEnd positions — matching Word's "Show bookmarks" feature + * (File > Options > Advanced). Off by default because bookmarks are a + * structural concept, not a visual one. SD-2454. + */ + showBookmarks?: boolean; + + /** + * Populated by the bookmark-start inline converter during conversion: the + * set of bookmark numeric ids (as strings) that actually rendered a start + * marker. The bookmark-end converter reads this set to suppress emitting + * an orphan `]` for a start it also suppressed (e.g. `_Toc…` / `_Ref…` + * auto-generated bookmarks filtered out by the `showBookmarks` feature). + * SD-2454. + */ + renderedBookmarkIds?: Set; }; /** diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts new file mode 100644 index 0000000000..5c68518d39 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts @@ -0,0 +1,44 @@ +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import { textNodeToRun } from './text-run.js'; +import { type InlineConverterParams } from './common.js'; + +/** + * Converts a `bookmarkEnd` PM node. + * + * SD-2454: when `converterContext.showBookmarks` is true, emit a visible gray + * `]` marker at the bookmark end. Matches Word's "Show bookmarks" rendering. + * Returns void (no visual output) when the option is off, preserving today's + * behavior where bookmarkEnd is an invisible structural marker. + * + * The PM schema does not store the bookmark name on bookmarkEnd — only the + * numeric `id` that matches the corresponding bookmarkStart. We therefore + * don't set a tooltip on the closing bracket (Word also omits the name on + * the closing bracket's hover). Styling and identification happen on the + * opening bracket. + */ +export function bookmarkEndNodeToRun(params: InlineConverterParams): TextRun | void { + const { node, converterContext } = params; + if (converterContext?.showBookmarks !== true) return; + + const nodeAttrs = + typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {}; + const bookmarkId = typeof nodeAttrs.id === 'string' || typeof nodeAttrs.id === 'number' ? String(nodeAttrs.id) : ''; + + // Only emit `]` if we emitted the matching `[`. Keeps brackets paired and + // prevents an orphan closing bracket for a suppressed auto-generated + // bookmark (`_Toc…`, `_Ref…`, `_GoBack`). + const rendered = converterContext?.renderedBookmarkIds; + if (rendered && bookmarkId && !rendered.has(bookmarkId)) return; + + const run = textNodeToRun({ + ...params, + node: { type: 'text', text: ']', marks: [...(node.marks ?? [])] } as PMNode, + }); + run.dataAttrs = { + ...(run.dataAttrs ?? {}), + 'data-bookmark-marker': 'end', + ...(bookmarkId ? { 'data-bookmark-id': bookmarkId } : {}), + }; + return run; +} diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts new file mode 100644 index 0000000000..21d5b1f0ef --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +import { bookmarkStartNodeToBlocks } from './bookmark-start.js'; +import { bookmarkEndNodeToRun } from './bookmark-end.js'; + +function makeParams( + node: PMNode, + opts: { showBookmarks?: boolean; bookmarks?: Map; renderedBookmarkIds?: Set } = {}, +): InlineConverterParams { + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: { + translatedNumbering: {}, + translatedLinkedStyles: { docDefaults: {}, latentStyles: {}, styles: {} }, + showBookmarks: opts.showBookmarks ?? false, + renderedBookmarkIds: opts.renderedBookmarkIds, + } as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: opts.bookmarks, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + } as InlineConverterParams; +} + +describe('bookmarkStartNodeToBlocks (SD-2454)', () => { + it('emits no visible run when showBookmarks is off (default)', () => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: false })); + expect(result).toBeUndefined(); + }); + + it('emits a `[` TextRun with bookmark-name data attr when showBookmarks is on', () => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); + expect(result).toBeDefined(); + expect(result!.text).toBe('['); + expect(result!.dataAttrs).toEqual({ + 'data-bookmark-name': 'chapter1', + 'data-bookmark-marker': 'start', + }); + }); + + // Matches Word behavior: `_Toc…`, `_Ref…`, `_GoBack` etc. are hidden from + // Show Bookmarks because they are internally generated for headings, + // fields, or navigation — showing them would clutter the document. + it.each(['_Toc1234', '_Ref506192326', '_GoBack'])('suppresses marker for auto-generated bookmark "%s"', (name) => { + const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } }; + const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true })); + expect(result).toBeUndefined(); + }); + + it('still records bookmark position for cross-reference resolution regardless of showBookmarks', () => { + const bookmarks = new Map(); + const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } }; + const params = makeParams(node, { showBookmarks: false, bookmarks }); + // Seed the position map + params.positions.set(node, { start: 42, end: 42 }); + bookmarkStartNodeToBlocks(params); + expect(bookmarks.get('chapter1')).toBe(42); + }); +}); + +describe('bookmarkEndNodeToRun (SD-2454)', () => { + it('emits no run when showBookmarks is off (default)', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } }; + const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: false })); + expect(result).toBeUndefined(); + }); + + it('emits a `]` TextRun when the matching start was rendered', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } }; + const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['1']) })); + expect(result).toBeDefined(); + expect(result!.text).toBe(']'); + expect(result!.dataAttrs).toEqual({ + 'data-bookmark-marker': 'end', + 'data-bookmark-id': '1', + }); + }); + + it('suppresses `]` when the matching start was also suppressed (no orphan brackets)', () => { + const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '42' } }; + // Start with id 42 was suppressed — renderedBookmarkIds does not include it + const result = bookmarkEndNodeToRun( + makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['99']) }), + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts index 779c95934b..a91ff1193a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts @@ -1,26 +1,68 @@ -import { type InlineConverterParams } from './common'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import { textNodeToRun } from './text-run.js'; +import { type InlineConverterParams } from './common.js'; -export function bookmarkStartNodeToBlocks({ - node, - positions, - bookmarks, - visitNode, - inheritedMarks, - sdtMetadata, - runProperties, -}: InlineConverterParams): void { - // Track bookmark position for cross-reference resolution +/** + * Converts a `bookmarkStart` PM node. + * + * Primary job: record the bookmark's PM position in the `bookmarks` Map so + * cross-reference navigation (goToAnchor) can resolve `#` + * hrefs to a document position. + * + * SD-2454: when `converterContext.showBookmarks` is true, also emit a visible + * gray `[` marker at the bookmark start, matching Word's opt-in "Show + * bookmarks" feature. The marker is a regular TextRun so it flows through + * pagination and line breaking like any other character; `dataAttrs` tag it + * so DomPainter can style it gray and set a tooltip with the bookmark name. + * + * When `showBookmarks` is false (the default), the converter still descends + * into any content inside the bookmark span but emits no visual output. + */ +export function bookmarkStartNodeToBlocks(params: InlineConverterParams): TextRun | void { + const { node, positions, bookmarks, visitNode, inheritedMarks, sdtMetadata, runProperties, converterContext } = + params; const nodeAttrs = typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record) : {}; const bookmarkName = typeof nodeAttrs.name === 'string' ? nodeAttrs.name : undefined; + if (bookmarkName && bookmarks) { const nodePos = positions.get(node); if (nodePos) { bookmarks.set(bookmarkName, nodePos.start); } } - // Process any content inside the bookmark (usually empty) + + // Word hides `_Toc…` / `_Ref…` / other `_`-prefixed bookmarks from its Show + // Bookmarks rendering because they're autogenerated (headings, fields). + // Mirror that so opt-in markers don't pollute every heading and xref target. + const shouldRender = + converterContext?.showBookmarks === true && typeof bookmarkName === 'string' && !bookmarkName.startsWith('_'); + + let run: TextRun | undefined; + if (shouldRender) { + run = textNodeToRun({ + ...params, + node: { type: 'text', text: '[', marks: [...(node.marks ?? [])] } as PMNode, + }); + run.dataAttrs = { + ...(run.dataAttrs ?? {}), + 'data-bookmark-name': bookmarkName!, + 'data-bookmark-marker': 'start', + }; + // Record the id so the matching bookmarkEnd converter knows to emit `]`. + // Without this, suppressing a `_`-prefixed start leaves an orphan `]`. + const bookmarkIdRaw = nodeAttrs.id; + const bookmarkId = + typeof bookmarkIdRaw === 'string' || typeof bookmarkIdRaw === 'number' ? String(bookmarkIdRaw) : ''; + if (bookmarkId && converterContext?.renderedBookmarkIds) { + converterContext.renderedBookmarkIds.add(bookmarkId); + } + } + if (Array.isArray(node.content)) { node.content.forEach((child) => visitNode(child, inheritedMarks, sdtMetadata, runProperties)); } + + return run; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts new file mode 100644 index 0000000000..4f5097bbdd --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +import { crossReferenceNodeToRun } from './cross-reference.js'; + +function makeParams( + attrs: Record, + overrides: Partial = {}, +): InlineConverterParams { + const node: PMNode = { type: 'crossReference', attrs }; + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: {} as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + ...overrides, + } as InlineConverterParams; +} + +describe('crossReferenceNodeToRun (SD-2495)', () => { + it('emits a TextRun carrying the resolved display text', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }), + ); + expect(run).not.toBeNull(); + expect(run!.text).toBe('15'); + }); + + it('synthesizes an internal link when the instruction has the \\h switch', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w \\h' }), + ); + expect(run!.link).toBeDefined(); + expect(run!.link?.anchor).toBe('_Ref506192326'); + }); + + it('does not attach a link when the \\h switch is absent', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\w' }), + ); + expect(run!.link).toBeUndefined(); + }); + + it('still emits a TextRun (not null) when the cached text is empty', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '', target: '_Ref_missing', instruction: 'REF _Ref_missing \\h' }), + ); + expect(run).not.toBeNull(); + expect(run!.text).toBe(''); + // Still links to target so surrounding layout isn't broken and the click target + // is preserved if the text later becomes non-empty via a re-import. + expect(run!.link?.anchor).toBe('_Ref_missing'); + }); + + it('does not match a literal `h` character as the \\h switch', () => { + // Guards against naive substring check — instruction like `REF bh-target` + // must not produce a hyperlink just because `h` appears somewhere. + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: 'label', target: 'bh-target', instruction: 'REF bh-target' }), + ); + expect(run!.link).toBeUndefined(); + }); + + it('matches the \\H switch case-insensitively per ECMA-376 §17.16.1', () => { + const run = crossReferenceNodeToRun( + makeParams({ resolvedText: '15', target: '_Ref506192326', instruction: 'REF _Ref506192326 \\H' }), + ); + expect(run!.link?.anchor).toBe('_Ref506192326'); + }); + + it('forwards node.marks to textNodeToRun so surrounding styles (italic, textStyle) survive', async () => { + // Guards against SD-2537's "preserve surrounding run styling" AC — + // a refactor that dropped node.marks from the synthesized text node + // would silently strip italic/color from every cross-reference. + const { textNodeToRun } = await import('./text-run.js'); + vi.mocked(textNodeToRun).mockClear(); + const marks = [ + { type: 'italic', attrs: {} }, + { type: 'textStyle', attrs: { color: '#ff0000' } }, + ]; + const node: PMNode = { + type: 'crossReference', + attrs: { resolvedText: '15', target: '_Ref1', instruction: 'REF _Ref1 \\h' }, + marks, + }; + crossReferenceNodeToRun(makeParams(node.attrs as Record, { node })); + + const call = vi.mocked(textNodeToRun).mock.calls.at(-1)?.[0]; + expect(call?.node?.marks).toEqual(marks); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts index c321a37099..d74ad61cbd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts @@ -1,25 +1,41 @@ import type { TextRun } from '@superdoc/contracts'; -import type { PMNode, PMMark } from '../../types.js'; +import type { PMNode } from '../../types.js'; import { textNodeToRun } from './text-run.js'; -import { applyMarksToRun } from '../../marks/index.js'; -import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; +import { buildFlowRunLink } from '../../marks/links.js'; +import { type InlineConverterParams } from './common.js'; /** * Converts a crossReference PM node to a TextRun with the resolved display text. + * + * Renders Word REF / NOTEREF / STYLEREF fields imported from DOCX. Uses the + * cached result text from Word (`attrs.resolvedText`) — we do not recompute + * outline numbers for `\w`/`\r`/`\n` switches, we trust Word's cache. + * + * When the instruction carries the `\h` switch, the reference renders as an + * internal hyperlink pointing at `#` so clicks navigate to the + * corresponding bookmark via the existing anchor-link navigation path. */ export function crossReferenceNodeToRun(params: InlineConverterParams): TextRun | null { - const { node, positions, defaultFont, defaultSize, inheritedMarks, sdtMetadata, runProperties, converterContext } = - params; + const { node, positions, sdtMetadata } = params; const attrs = (node.attrs ?? {}) as Record; - const resolvedText = (attrs.resolvedText as string) || (attrs.target as string) || ''; - if (!resolvedText) return null; + const resolvedText = typeof attrs.resolvedText === 'string' ? attrs.resolvedText : ''; + const target = typeof attrs.target === 'string' ? attrs.target : ''; + const instruction = typeof attrs.instruction === 'string' ? attrs.instruction : ''; const run = textNodeToRun({ ...params, node: { type: 'text', text: resolvedText, marks: [...(node.marks ?? [])] } as PMNode, }); + // \h switch - case-insensitive per ECMA-376 §17.16.1. + if (target && /\\h\b/i.test(instruction)) { + const synthesized = buildFlowRunLink({ anchor: target }); + if (synthesized) { + run.link = run.link ? { ...run.link, ...synthesized, anchor: target } : synthesized; + } + } + const pos = positions.get(node); if (pos) { run.pmStart = pos.start; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts new file mode 100644 index 0000000000..11aaf36913 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { PMNode } from '../../types.js'; +import type { InlineConverterParams } from './common.js'; + +vi.mock('./text-run.js', () => ({ + textNodeToRun: vi.fn( + (params: InlineConverterParams): TextRun => ({ + text: params.node.text || '', + fontFamily: params.defaultFont, + fontSize: params.defaultSize, + }), + ), +})); + +vi.mock('../../sdt/index.js', () => ({ + getNodeInstruction: vi.fn((node: PMNode) => { + const attrs = (node.attrs ?? {}) as Record; + return typeof attrs.instruction === 'string' ? attrs.instruction : ''; + }), +})); + +vi.mock('@superdoc/style-engine/ooxml', () => ({ + resolveRunProperties: vi.fn(() => ({})), +})); + +import { pageReferenceNodeToBlock } from './page-reference.js'; + +function makeParams( + attrs: Record, + overrides: Partial = {}, +): InlineConverterParams { + const node: PMNode = { + type: 'pageReference', + attrs, + content: [{ type: 'text', text: '15' } as PMNode], + }; + return { + node, + positions: new WeakMap(), + defaultFont: 'Calibri', + defaultSize: 16, + inheritedMarks: [], + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: {} as unknown as InlineConverterParams['converterContext'], + enableComments: false, + visitNode: vi.fn(), + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: vi.fn(), + ...overrides, + } as InlineConverterParams; +} + +describe('pageReferenceNodeToBlock', () => { + it('emits a pageReference token run with the resolved fallback text and bookmarkId', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined; + expect(run).toBeDefined(); + expect(run!.token).toBe('pageReference'); + expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123'); + }); + + it('synthesizes an internal link when the instruction has the \\h switch', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\h' })) as TextRun | undefined; + expect(run!.link).toBeDefined(); + expect(run!.link?.anchor).toBe('_Toc123'); + }); + + it('does not attach a link when the \\h switch is absent', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123' })) as TextRun | undefined; + expect(run!.link).toBeUndefined(); + }); + + it('does not match a literal `h` character as the \\h switch', () => { + // Guards against naive substring check — instruction like `PAGEREF bh-target` + // must not produce a hyperlink just because `h` appears somewhere. + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF bh-target' })) as TextRun | undefined; + expect(run!.link).toBeUndefined(); + }); + + it('handles bookmark ids wrapped in quotes in the instruction', () => { + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF "_Toc123" \\h' })) as TextRun | undefined; + expect(run!.pageRefMetadata?.bookmarkId).toBe('_Toc123'); + expect(run!.link?.anchor).toBe('_Toc123'); + }); + + it('matches the \\h switch case-insensitively', () => { + // Word field switches are case-insensitive — `\H` should produce a link + // just like `\h`. + const run = pageReferenceNodeToBlock(makeParams({ instruction: 'PAGEREF _Toc123 \\H' })) as TextRun | undefined; + expect(run!.link?.anchor).toBe('_Toc123'); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts index 8d647b58eb..aa4c335907 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts @@ -3,6 +3,7 @@ import { type InlineConverterParams } from './common'; import { getNodeInstruction } from '../../sdt/index.js'; import type { PMNode, PMMark } from '../../types.js'; import { textNodeToRun } from './text-run.js'; +import { buildFlowRunLink } from '../../marks/links.js'; import { type RunProperties, resolveRunProperties } from '@superdoc/style-engine/ooxml'; export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun | void { @@ -60,14 +61,23 @@ export function pageReferenceNodeToBlock(params: InlineConverterParams): TextRun // Copy PM positions from parent pageReference node if (pageRefPos) { - (tokenRun as TextRun).pmStart = pageRefPos.start; - (tokenRun as TextRun).pmEnd = pageRefPos.end; + tokenRun.pmStart = pageRefPos.start; + tokenRun.pmEnd = pageRefPos.end; } - (tokenRun as TextRun).token = 'pageReference'; - (tokenRun as TextRun).pageRefMetadata = { + tokenRun.token = 'pageReference'; + tokenRun.pageRefMetadata = { bookmarkId, instruction, }; + + // \h switch - case-insensitive per ECMA-376 §17.16.1. + if (/\\h\b/i.test(instruction)) { + const synthesized = buildFlowRunLink({ anchor: bookmarkId }); + if (synthesized) { + tokenRun.link = tokenRun.link ? { ...tokenRun.link, ...synthesized, anchor: bookmarkId } : synthesized; + } + } + if (sdtMetadata) { tokenRun.sdt = sdtMetadata; } diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 0bc5a4d59b..58b887aa3a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -49,6 +49,7 @@ import { structuredContentNodeToBlocks } from './inline-converters/structured-co import { pageReferenceNodeToBlock } from './inline-converters/page-reference.js'; import { fieldAnnotationNodeToRun } from './inline-converters/field-annotation.js'; import { bookmarkStartNodeToBlocks } from './inline-converters/bookmark-start.js'; +import { bookmarkEndNodeToRun } from './inline-converters/bookmark-end.js'; import { tabNodeToRun } from './inline-converters/tab.js'; import { tokenNodeToRun } from './inline-converters/generic-token.js'; import { imageNodeToRun } from './inline-converters/image.js'; @@ -927,6 +928,9 @@ const INLINE_CONVERTERS_REGISTRY: Record = { bookmarkStart: { inlineConverter: bookmarkStartNodeToBlocks, }, + bookmarkEnd: { + inlineConverter: bookmarkEndNodeToRun, + }, tab: { inlineConverter: tabNodeToRun, }, diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 4ffd9da91d..f127e8fc20 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -155,6 +155,12 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultFont, defaultSize, ); + if (options?.showBookmarks !== undefined) { + converterContext.showBookmarks = options.showBookmarks; + } + if (converterContext.showBookmarks) { + converterContext.renderedBookmarkIds = new Set(); + } const blocks: FlowBlock[] = []; const bookmarks = new Map(); diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 5a98b205ed..cd9148ac1d 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -122,6 +122,13 @@ export interface AdapterOptions { */ emitSectionBreaks?: boolean; + /** + * When true, render visible gray `[` / `]` marker runs at bookmarkStart and + * bookmarkEnd positions (SD-2454). Matches Word's opt-in "Show bookmarks" + * behavior. Off by default because bookmarks are structural, not visual. + */ + showBookmarks?: boolean; + /** * Optional instrumentation hook for fidelity logging. */ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 5a9783e3e9..46b51c0c6e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -470,6 +470,7 @@ export class PresentationEditor extends EventEmitter { emitCommentPositionsInViewing: options.layoutEngineOptions?.emitCommentPositionsInViewing, enableCommentsInViewing: options.layoutEngineOptions?.enableCommentsInViewing, presence: validatedPresence, + showBookmarks: options.layoutEngineOptions?.showBookmarks ?? false, }; this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges; @@ -2020,6 +2021,24 @@ export class PresentationEditor extends EventEmitter { this.#scheduleRerender(); } + /** + * Toggle the SD-2454 "Show bookmarks" bracket indicators at runtime. + * + * When enabled, the pm-adapter emits visible gray `[` / `]` marker runs at + * bookmarkStart / bookmarkEnd positions (mirroring Word's opt-in behavior). + * Because markers are real characters that participate in text measurement + * and line breaking, toggling invalidates the flow-block cache and triggers + * a full re-layout. + */ + setShowBookmarks(showBookmarks: boolean): void { + const next = !!showBookmarks; + if (this.#layoutOptions.showBookmarks === next) return; + this.#layoutOptions.showBookmarks = next; + this.#flowBlockCache?.clear(); + this.#pendingDocChange = true; + this.#scheduleRerender(); + } + /** * Convert a viewport coordinate into a document hit using the current layout. */ @@ -4203,6 +4222,7 @@ export class PresentationEditor extends EventEmitter { themeColors: this.#editor?.converter?.themeColors ?? undefined, converterContext, flowBlockCache: this.#flowBlockCache, + showBookmarks: this.#layoutOptions.showBookmarks ?? false, ...(positionMap ? { positions: positionMap } : {}), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); @@ -5849,8 +5869,24 @@ export class PresentationEditor extends EventEmitter { yPosition += pageHeight + virtualGap; } - // Scroll viewport to the calculated position - if (this.#visibleHost) { + // Scroll viewport to the calculated position. + // + // The authoritative scrollable ancestor is `#scrollContainer` — setting + // scrollTop on the visible host alone is a no-op when the host is + // `overflow: visible` (the standard layout). Without this, anchor + // navigation (TOC clicks, cross-reference click-to-navigate under + // SD-2495) silently does nothing whenever the target page is outside + // the current viewport. + // + // We also write to `#visibleHost` for backwards compatibility: legacy + // layouts may make the visible host itself scrollable, and tests mock + // scrollTop on the host element. + if (this.#scrollContainer instanceof Window) { + this.#scrollContainer.scrollTo({ top: yPosition }); + } else if (this.#scrollContainer) { + this.#scrollContainer.scrollTop = yPosition; + } + if (this.#visibleHost && this.#visibleHost !== this.#scrollContainer) { this.#visibleHost.scrollTop = yPosition; } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 6dd36c0bf2..af38ffb68e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1563,9 +1563,12 @@ export class EditorInputManager { #handleLinkClick(event: MouseEvent, linkEl: HTMLAnchorElement): void { const href = linkEl.getAttribute('href') ?? ''; const isAnchorLink = href.startsWith('#') && href.length > 1; - const isTocLink = linkEl.closest('.superdoc-toc-entry') !== null; - if (isAnchorLink && isTocLink) { + // SD-2495: route any internal-anchor click (`#`) to in-document + // navigation. Covers TOC entries, heading/bookmark cross-references + // (REF fields with `\h`), and any other internal-hyperlink case — they all + // should scroll to the bookmark target instead of navigating the browser. + if (isAnchorLink) { event.preventDefault(); event.stopPropagation(); this.#callbacks.goToAnchor?.(href); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts new file mode 100644 index 0000000000..1ee225aa10 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.anchorClick.test.ts @@ -0,0 +1,150 @@ +/** + * SD-2495 / SD-2537 regression guard for cross-reference click-to-navigate. + * + * The existing behavior before this PR only routed TOC-entry clicks through + * `goToAnchor`. Cross-reference rendered anchors (``) were + * dispatched as generic `superdoc-link-click` custom events — host apps had + * to handle navigation themselves, and most didn't, so clicks silently + * opened nothing. The fix generalized the internal-anchor branch in + * `#handleLinkClick` to cover every `#…` href. + * + * This test pins the new behavior: + * - clicks on `` invoke `goToAnchor('#someBookmark')` + * - the browser's default navigation is prevented + * so a future refactor that narrows the branch back to TOC-only breaks this. + */ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { + EditorInputManager, + type EditorInputDependencies, + type EditorInputCallbacks, +} from '../pointer-events/EditorInputManager.js'; + +describe('EditorInputManager — anchor-href click routing (SD-2537)', () => { + let manager: EditorInputManager; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let goToAnchor: Mock; + let mockEditor: { + isEditable: boolean; + state: { doc: { content: { size: number } }; selection: { $anchor: null } }; + view: { dispatch: Mock; dom: HTMLElement; focus: Mock; hasFocus: Mock }; + on: Mock; + off: Mock; + emit: Mock; + }; + + beforeEach(() => { + viewportHost = document.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + visibleHost = document.createElement('div'); + visibleHost.className = 'presentation-editor__visible'; + visibleHost.appendChild(viewportHost); + document.body.appendChild(visibleHost); + + mockEditor = { + isEditable: true, + state: { doc: { content: { size: 100 } }, selection: { $anchor: null } }, + view: { dispatch: vi.fn(), dom: document.createElement('div'), focus: vi.fn(), hasFocus: vi.fn(() => false) }, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + + const deps: EditorInputDependencies = { + getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getLayoutState: vi.fn(() => ({ layout: {} as never, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + })) as unknown as EditorInputDependencies['getEpochMapper'], + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical' as const), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing' as const), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }; + + goToAnchor = vi.fn(); + const callbacks: EditorInputCallbacks = { + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + scheduleSelectionUpdate: vi.fn(), + updateSelectionDebugHud: vi.fn(), + goToAnchor, + }; + + manager = new EditorInputManager(); + manager.setDependencies(deps); + manager.setCallbacks(callbacks); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + const makeAnchor = (href: string): HTMLAnchorElement => { + const a = document.createElement('a'); + a.className = 'superdoc-link'; + a.setAttribute('href', href); + a.textContent = '15'; + viewportHost.appendChild(a); + return a; + }; + + const firePointerDown = (el: HTMLElement) => { + const PointerEventImpl = + (globalThis as unknown as { PointerEvent?: typeof PointerEvent }).PointerEvent ?? globalThis.MouseEvent; + el.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + } as PointerEventInit), + ); + }; + + it('routes `#` anchor clicks through goToAnchor', () => { + const a = makeAnchor('#_Ref506192326'); + firePointerDown(a); + expect(goToAnchor).toHaveBeenCalledWith('#_Ref506192326'); + }); + + it('routes TOC-inside `#…` anchor clicks through goToAnchor (backward compat)', () => { + // The pre-PR behavior was TOC-only. Make sure generalizing the branch + // didn't accidentally exclude TOC entries. + const tocWrapper = document.createElement('span'); + tocWrapper.className = 'superdoc-toc-entry'; + const a = document.createElement('a'); + a.className = 'superdoc-link'; + a.setAttribute('href', '#_Toc123'); + tocWrapper.appendChild(a); + viewportHost.appendChild(tocWrapper); + + firePointerDown(a); + expect(goToAnchor).toHaveBeenCalledWith('#_Toc123'); + }); + + it('does not route external hrefs through goToAnchor', () => { + const a = makeAnchor('https://example.com/page'); + firePointerDown(a); + expect(goToAnchor).not.toHaveBeenCalled(); + }); + + it('does not route bare `#` (empty fragment) to goToAnchor', () => { + const a = makeAnchor('#'); + firePointerDown(a); + expect(goToAnchor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 40f9fd8b4e..60e212d368 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -1025,6 +1025,103 @@ describe('PresentationEditor', () => { } }, ); + + // SD-2495 anchor-nav fix: when the scrollable ancestor differs from the + // visible host (the real-world shape - the host is overflow:visible and + // a parent constrains height), scrollTop must land on the ancestor, not + // just the host. Happy-dom doesn't propagate inline overflow through + // getComputedStyle, so we stub it to mark a wrapper as scrollable. + it('writes scrollTop to both the scrollable ancestor and the visibleHost when they differ', async () => { + const scrollableWrapper = document.createElement('div'); + document.body.removeChild(container); + scrollableWrapper.appendChild(container); + document.body.appendChild(scrollableWrapper); + + const originalGetComputedStyle = window.getComputedStyle.bind(window); + const getComputedStyleSpy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((el: Element, pseudo?: string | null) => { + if (el === scrollableWrapper) { + return { overflowY: 'auto' } as CSSStyleDeclaration; + } + return originalGetComputedStyle(el, pseudo ?? null); + }); + + mockIncrementalLayout.mockResolvedValueOnce(buildMixedPageLayout()); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-scroll-multi-target', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + mode: 'docx', + layoutEngineOptions: { + virtualization: { enabled: true, gap: 10, window: 1, overscan: 0 }, + }, + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const expectedPageTop = 600 + 10 + 1200 + 10; + + let wrapperScrollTop = 0; + let hostScrollTop = 0; + let mountedPageEl: HTMLElement | null = null; + const mountPageIfScrolled = (value: number) => { + if (!mountedPageEl && Math.abs(value - expectedPageTop) < 0.5) { + mountedPageEl = document.createElement('div'); + mountedPageEl.setAttribute('data-page-index', '2'); + Object.defineProperty(mountedPageEl, 'scrollIntoView', { + value: vi.fn(), + configurable: true, + }); + pagesHost.appendChild(mountedPageEl); + } + }; + Object.defineProperty(scrollableWrapper, 'scrollTop', { + get: () => wrapperScrollTop, + set: (next) => { + wrapperScrollTop = Number(next); + mountPageIfScrolled(wrapperScrollTop); + }, + configurable: true, + }); + Object.defineProperty(container, 'scrollTop', { + get: () => hostScrollTop, + set: (next) => { + hostScrollTop = Number(next); + mountPageIfScrolled(hostScrollTop); + }, + configurable: true, + }); + + let now = 0; + const performanceNowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + now += 100; + cb(now); + return 1; + }); + + try { + const didScroll = await editor.scrollToPage(3, 'auto'); + + expect(didScroll).toBe(true); + // Both writes must land: the ancestor (real scrollable) AND the host + // (back-compat for layouts where the host itself is scrollable). + expect(wrapperScrollTop).toBe(expectedPageTop); + expect(hostScrollTop).toBe(expectedPageTop); + } finally { + rafSpy.mockRestore(); + performanceNowSpy.mockRestore(); + getComputedStyleSpy.mockRestore(); + // Restore DOM layout so the outer afterEach can clean up normally. + if (scrollableWrapper.parentNode) { + document.body.removeChild(scrollableWrapper); + } + document.body.appendChild(container); + } + }); }); describe('setDocumentMode', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index e442a6f34e..a77100a932 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -165,6 +165,15 @@ export type LayoutEngineOptions = { ruler?: RulerOptions; /** Proofing / spellcheck configuration. */ proofing?: ProofingConfig; + /** + * Render visible gray `[` / `]` bracket markers at bookmark start/end + * positions — matching Word's opt-in "Show bookmarks" (File > Options > + * Advanced). Off by default because bookmarks are a structural concept, + * not a visual one. Auto-generated bookmarks (names starting with `_`, + * such as `_Toc…` or `_Ref…`) are hidden even when enabled, mirroring + * Word's behavior. SD-2454. + */ + showBookmarks?: boolean; }; export type PresentationEditorOptions = ConstructorParameters[0] & { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js new file mode 100644 index 0000000000..7e7c22a22c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.test.js @@ -0,0 +1,34 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessNoterefInstruction } from './noteref-preprocessor.js'; + +describe('preProcessNoterefInstruction', () => { + const mockNodesToCombine = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }]; + + it('wraps the cached runs in a sd:crossReference node with NOTEREF fieldType', () => { + const instruction = 'NOTEREF _Ref9876 \\h'; + const result = preProcessNoterefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'NOTEREF _Ref9876 \\h', + fieldType: 'NOTEREF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves the instruction text verbatim including the \\f footnote switch', () => { + const instruction = 'NOTEREF _Ref9876 \\h \\f'; + const [node] = preProcessNoterefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('NOTEREF _Ref9876 \\h \\f'); + }); + + it('handles an empty runs list', () => { + const result = preProcessNoterefInstruction([], 'NOTEREF _Ref9876 \\h'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js new file mode 100644 index 0000000000..889fbaed6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.test.js @@ -0,0 +1,36 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessRefInstruction } from './ref-preprocessor.js'; + +describe('preProcessRefInstruction', () => { + const mockNodesToCombine = [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Section 15' }] }] }, + ]; + + it('wraps the cached runs in a sd:crossReference node with REF fieldType', () => { + const instruction = 'REF _Ref123456 \\h'; + const result = preProcessRefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'REF _Ref123456 \\h', + fieldType: 'REF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves the instruction text verbatim including all switches', () => { + const instruction = 'REF _Ref123 \\h \\w \\* MERGEFORMAT'; + const [node] = preProcessRefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('REF _Ref123 \\h \\w \\* MERGEFORMAT'); + }); + + it('handles an empty runs list', () => { + const result = preProcessRefInstruction([], 'REF _Ref123 \\h'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js new file mode 100644 index 0000000000..d386fa0c71 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.test.js @@ -0,0 +1,36 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessStylerefInstruction } from './styleref-preprocessor.js'; + +describe('preProcessStylerefInstruction', () => { + const mockNodesToCombine = [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Heading 1' }] }] }, + ]; + + it('wraps the cached runs in a sd:crossReference node with STYLEREF fieldType', () => { + const instruction = 'STYLEREF "Heading 1" \\l'; + const result = preProcessStylerefInstruction(mockNodesToCombine, instruction); + expect(result).toEqual([ + { + name: 'sd:crossReference', + type: 'element', + attributes: { + instruction: 'STYLEREF "Heading 1" \\l', + fieldType: 'STYLEREF', + }, + elements: mockNodesToCombine, + }, + ]); + }); + + it('preserves quoted style names that contain spaces', () => { + const instruction = 'STYLEREF "Last Name" \\l'; + const [node] = preProcessStylerefInstruction(mockNodesToCombine, instruction); + expect(node.attributes.instruction).toBe('STYLEREF "Last Name" \\l'); + }); + + it('handles an empty runs list', () => { + const result = preProcessStylerefInstruction([], 'STYLEREF "Heading 1"'); + expect(result[0].elements).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js new file mode 100644 index 0000000000..9e6c070c06 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.integration.test.js @@ -0,0 +1,191 @@ +/** + * Integration tests for SD-2495 / IT-949. + * + * Why this file exists (root cause recap): + * The `sd:crossReference` v3 translator was registered in `registeredHandlers` + * but NOT wired into the v2 importer's `defaultNodeListHandler` entity list. + * The passthrough fallback refused to wrap it (because it was "registered"), + * and no entity claimed it, so every REF field in every imported DOCX was + * silently dropped — erasing "Section 15" and every other cross-reference + * from the viewer. + * + * These tests exercise the full v2 body pipeline: preprocessor → dispatcher → + * entity handler → v3 translator → PM node. If any link in that chain breaks + * (most likely: the entity gets removed from the entities list during a + * refactor), the `crossReference` PM node disappears and these tests fail. + * + * The unit tests of the translator alone (`crossReference-translator.test.js`) + * don't catch this class of regression because they bypass the dispatcher. + */ +import { describe, it, expect } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; +import { preProcessNodesForFldChar } from '../../field-references/index.js'; +import { crossReferenceEntity } from './crossReferenceImporter.js'; + +const createEditorStub = () => ({ + schema: { + nodes: { + run: { isInline: true, spec: { group: 'inline' } }, + crossReference: { isInline: true, spec: { group: 'inline', atom: true } }, + }, + }, +}); + +// Produces the exact XML shape Word emits for a REF field with `\h` — matches +// the Brillio lease fragment that produces the "Section 15" customer bug. +const buildRefField = (target, cachedText) => { + const run = (inner) => ({ + name: 'w:r', + elements: [{ name: 'w:rPr', elements: [{ name: 'w:i' }] }, ...inner], + }); + return [ + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }]), + run([ + { + name: 'w:instrText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: ` REF ${target} \\w \\h ` }], + }, + ]), + run([]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }]), + run([{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }]), + ]; +}; + +describe('SD-2495 v2 importer wiring (IT-949 regression guard)', () => { + it('registers crossReferenceEntity in defaultNodeListHandler — guards the miss that produced IT-949', () => { + // This membership assertion is the cheapest possible regression guard + // against the exact bug root cause: if a future refactor drops the + // entity from the entities list, this fails immediately. + expect(defaultNodeListHandler().handlerEntities).toContain(crossReferenceEntity); + }); + + it('REF field inside a paragraph produces a crossReference PM node with cached text + target', () => { + const paragraph = { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'If terminated under this Section ' }] }], + }, + ...buildRefField('_Ref506192326', '15'), + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: ', Landlord...' }] }], + }, + ], + }; + + // Mirror the real body pipeline: preprocess fldChar runs into + // sd:crossReference, then dispatch through the v2 entity list. + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const para = pmNodes.find((n) => n.type === 'paragraph'); + expect(para, 'paragraph should be produced').toBeTruthy(); + + const crossRefs = collectNodesOfType(para, 'crossReference'); + expect(crossRefs).toHaveLength(1); + expect(crossRefs[0].attrs.target).toBe('_Ref506192326'); + expect(crossRefs[0].attrs.resolvedText).toBe('15'); + // Instruction preserves the `\h` switch — the render layer reads this to + // decide whether to attach an internal-link mark (SD-2537 hyperlink vs + // plain-text variant). + expect(crossRefs[0].attrs.instruction).toMatch(/\\h/); + }); + + it('REF with \\h switch records the target so the render layer can navigate on click', () => { + const paragraph = { + name: 'w:p', + elements: [...buildRefField('_Ref123', '7')], + }; + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const crossRef = collectNodesOfType(pmNodes[0], 'crossReference')[0]; + expect(crossRef).toBeTruthy(); + // `display` is derived from switches so PM-adapter knows which variant. + // `\w \h` → numberFullContext. If this regresses, cross-ref visuals change. + expect(crossRef.attrs.display).toBe('numberFullContext'); + }); + + it('plain text surrounding a REF field still reaches PM unchanged (guards against REF dispatch consuming sibling runs)', () => { + // The `xml:space="preserve"` attribute is what keeps trailing whitespace + // around. Without it, OOXML parsers strip leading/trailing whitespace from + // w:t elements. The real customer document (Brillio lease) preserves this + // attribute on runs adjacent to REF fields so "Section " doesn't collapse + // to "Section" before the number. Mirror that here. + const textRun = (text) => ({ + name: 'w:r', + elements: [{ name: 'w:t', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text }] }], + }); + const paragraph = { + name: 'w:p', + elements: [textRun('Section '), ...buildRefField('_Ref1', '15'), textRun(', Landlord')], + }; + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const [pmPara] = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + // Children of the paragraph, in order, excluding the crossReference (it's + // an atom — its cached text contributes to the visual output but lives + // inside the xref node, not beside it). We care here about the SURROUNDING + // text surviving unchanged. + const collectSiblingTextBeforeAndAfterXref = (paraNode) => { + const parts = { before: '', after: '' }; + let sawXref = false; + const visitChildren = (nodes) => { + for (const child of nodes ?? []) { + if (child?.type === 'crossReference') { + sawXref = true; + continue; + } + if (Array.isArray(child?.content)) visitChildren(child.content); + else if (child?.type === 'text' && typeof child.text === 'string') { + if (sawXref) parts.after += child.text; + else parts.before += child.text; + } + } + }; + visitChildren(paraNode.content ?? []); + return parts; + }; + + const { before, after } = collectSiblingTextBeforeAndAfterXref(pmPara); + expect(before).toBe('Section '); + expect(after).toBe(', Landlord'); + }); +}); + +/** Collect all descendants of a given type from a nested PM node tree. */ +function collectNodesOfType(root, type) { + const out = []; + const visit = (node) => { + if (!node) return; + if (node.type === type) out.push(node); + if (Array.isArray(node.content)) node.content.forEach(visit); + }; + visit(root); + return out; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js new file mode 100644 index 0000000000..6e896a236e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/crossReferenceImporter.js @@ -0,0 +1,7 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/sd/crossReference/crossReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const crossReferenceEntity = generateV2HandlerEntity('crossReferenceNodeHandler', translator); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index e1b0b058d4..b99eefff88 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -17,6 +17,7 @@ import { alternateChoiceHandler } from './alternateChoiceImporter.js'; import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js'; import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; +import { crossReferenceEntity } from './crossReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; @@ -249,6 +250,7 @@ export const defaultNodeListHandler = () => { autoTotalPageCountEntity, documentStatFieldHandlerEntity, pageReferenceEntity, + crossReferenceEntity, permStartHandlerEntity, permEndHandlerEntity, mathNodeHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js index 343333bcc5..3f6419f00b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js @@ -108,16 +108,29 @@ function parseDisplay(instruction) { } /** - * Extracts resolved text from processed content. + * Extracts resolved text from processed content. Walks recursively because the + * cached result between w:fldChar separate/end is typically wrapped in a `run` + * node (or deeper: run -> text with marks), so a top-level text-only filter + * misses the field's display text. * @param {Array} content * @returns {string} */ function extractResolvedText(content) { if (!Array.isArray(content)) return ''; - return content - .filter((n) => n.type === 'text') - .map((n) => n.text || '') - .join(''); + let out = ''; + /** @param {Array} nodes */ + const walk = (nodes) => { + for (const node of nodes) { + if (!node) continue; + if (node.type === 'text') { + out += node.text || ''; + } else if (Array.isArray(node.content)) { + walk(node.content); + } + } + }; + walk(content); + return out; } /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js index 080024cc7c..9eb61b2d8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { exportSchemaToJson } from '../../../../exporter.js'; import { translator as runTranslator } from '../../w/r/r-translator.js'; +import { translator as crossReferenceTranslator } from './crossReference-translator.js'; const CROSS_REFERENCE_INSTRUCTION = 'REF bm-target \\h'; @@ -61,3 +62,79 @@ describe('crossReference export routing', () => { expect(exportedRuns.some((node) => hasFieldCharType(node, 'end'))).toBe(true); }); }); + +describe('crossReference import resolvedText extraction (SD-2495)', () => { + // Mirrors the Brillio-style REF cached payload: cached text lives inside a w:r + // wrapper, so a top-level-only `n.type === 'text'` filter returns empty. The + // recursive walk must descend through run wrappers to find the display text. + const buildRun = (innerElements) => ({ + type: 'element', + name: 'w:r', + elements: [{ type: 'element', name: 'w:rPr', elements: [{ type: 'element', name: 'w:i' }] }, ...innerElements], + }); + + const buildSdCrossReference = (instr, cachedRuns) => ({ + name: 'sd:crossReference', + type: 'element', + attributes: { instruction: instr, fieldType: 'REF' }, + elements: cachedRuns, + }); + + it('extracts cached text from runs wrapped around a w:t (Brillio shape)', () => { + const xmlNode = buildSdCrossReference('REF _Ref506192326 \\w \\h', [ + buildRun([]), // empty formatting-carrier run + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '15' }] }]), + ]); + const encoded = crossReferenceTranslator.encode({ + nodes: [xmlNode], + nodeListHandler: { + handler: ({ nodes }) => { + // Simulate w:r translator wrapping content in SuperDoc run nodes + return nodes + .map((run) => { + const textEl = run.elements?.find((el) => el?.name === 'w:t'); + if (!textEl) return null; + const text = (textEl.elements || []) + .map((child) => (typeof child?.text === 'string' ? child.text : '')) + .join(''); + if (!text) return null; + return { type: 'run', attrs: {}, content: [{ type: 'text', text }] }; + }) + .filter(Boolean); + }, + }, + }); + + expect(encoded.type).toBe('crossReference'); + expect(encoded.attrs.target).toBe('_Ref506192326'); + expect(encoded.attrs.resolvedText).toBe('15'); + expect(encoded.attrs.display).toBe('numberFullContext'); + }); + + it('concatenates cached text across multiple run wrappers', () => { + const xmlNode = buildSdCrossReference('REF _RefABC \\h', [ + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: '4(b' }] }]), + buildRun([{ type: 'element', name: 'w:t', elements: [{ type: 'text', text: ')(2)' }] }]), + ]); + const encoded = crossReferenceTranslator.encode({ + nodes: [xmlNode], + nodeListHandler: { + handler: ({ nodes }) => + nodes.map((run) => ({ + type: 'run', + attrs: {}, + content: [ + { + type: 'text', + text: (run.elements?.find((el) => el?.name === 'w:t')?.elements ?? []) + .map((c) => c.text || '') + .join(''), + }, + ], + })), + }, + }); + + expect(encoded.attrs.resolvedText).toBe('4(b)(2)'); + }); +}); diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index e1c59146ba..f92fe56295 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1240,6 +1240,25 @@ export class SuperDoc extends EventEmitter { }); } + /** + * SD-2454: Toggle bookmark bracket indicators (opt-in, off by default). + * Matches Word's "Show bookmarks" option. Triggers a re-layout on change + * because the brackets are visible characters participating in text flow. + * @param {boolean} show + * @returns {void} + */ + setShowBookmarks(show = true) { + const nextValue = Boolean(show); + const layoutOptions = (this.config.layoutEngineOptions = this.config.layoutEngineOptions || {}); + if (layoutOptions.showBookmarks === nextValue) return; + layoutOptions.showBookmarks = nextValue; + + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setShowBookmarks?.(nextValue); + }); + } + /** * Set the document mode. * @param {DocumentMode} type diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 14f1c2dc95..4320c4f18a 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -868,6 +868,50 @@ describe('SuperDoc core', () => { expect(setOptions).toHaveBeenLastCalledWith({ disableContextMenu: false }); }); + it('propagates setShowBookmarks to presentation editors and skips no-op toggles', async () => { + const { superdocStore } = createAppHarness(); + const setShowBookmarks = vi.fn(); + const docStub = { + getPresentationEditor: vi.fn(() => ({ setShowBookmarks })), + }; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + role: 'editor', + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + superdocStore.documents = [docStub]; + + // Enabling flips the flag and reaches the presentation editor. + instance.setShowBookmarks(true); + expect(instance.config.layoutEngineOptions.showBookmarks).toBe(true); + expect(setShowBookmarks).toHaveBeenCalledWith(true); + + // Same value again is a no-op. + instance.setShowBookmarks(true); + expect(setShowBookmarks).toHaveBeenCalledTimes(1); + + // Disabling flips it back. + instance.setShowBookmarks(false); + expect(instance.config.layoutEngineOptions.showBookmarks).toBe(false); + expect(setShowBookmarks).toHaveBeenLastCalledWith(false); + + // Default argument coerces to true. + instance.setShowBookmarks(); + expect(setShowBookmarks).toHaveBeenLastCalledWith(true); + + // Non-boolean values go through Boolean(). + instance.setShowBookmarks(null); + expect(setShowBookmarks).toHaveBeenLastCalledWith(false); + }); + it('skips rendering comments list when role is viewer', async () => { createAppHarness(); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 1054f013c1..c9f7a5e51f 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -43,6 +43,7 @@ const testUserEmail = urlParams.get('email') || 'user@superdoc.com'; const testUserName = urlParams.get('name') || `SuperDoc ${Math.floor(1000 + Math.random() * 9000)}`; const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); +const showBookmarks = ref(urlParams.get('bookmarks') === '1'); const useWebLayout = ref(urlParams.get('view') === 'web'); // Tracked-change replacement model. 'paired' groups ins+del into one change // (Google Docs model); 'independent' keeps each as its own revision (Word / ECMA-376). @@ -687,6 +688,7 @@ const init = async () => { layoutEngineOptions: { flowMode: useWebLayout.value ? 'semantic' : 'paginated', ...(useWebLayout.value ? { semanticOptions: { marginsMode: 'none' } } : {}), + showBookmarks: showBookmarks.value, }, rulers: true, rulerContainer: '#ruler-container', @@ -1204,6 +1206,11 @@ const toggleLayoutEngine = () => { window.location.href = url.toString(); }; +const toggleShowBookmarks = () => { + showBookmarks.value = !showBookmarks.value; + superdoc.value?.setShowBookmarks?.(showBookmarks.value); +}; + const toggleViewLayout = () => { const nextValue = !useWebLayout.value; const url = new URL(window.location.href); @@ -1501,6 +1508,9 @@ if (scrollTestMode.value) { @change="handleCompareFile" /> + diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-h.docx new file mode 100644 index 0000000000000000000000000000000000000000..406033ddbc73f2ef61405a712fc8faf1b18da8a4 GIT binary patch literal 13825 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 G|NbBE&;mvP literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx new file mode 100644 index 0000000000000000000000000000000000000000..06829ef754943d472aa3c1a8547d88dbf0474e74 GIT binary patch literal 13828 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 element. +// The wrapped case (Word TOCs) is covered by toc-anchor-scroll.spec.ts. +// +// Both fixtures are a 7-entry TOC where the first entry's outer +// has been removed, leaving only the inner PAGEREF \h field for that row. +// The other entries retain their wrappers and serve as a control. +// +// The "Introduction" entry uses bookmark id _Toc227765979. Its page number +// anchor only exists if the PAGEREF \h switch produces a link on its own. + +test.skip(!fs.existsSync(LOWERCASE_DOC), 'Standalone PAGEREF fixture missing'); +test.skip(!fs.existsSync(UPPERCASE_DOC), 'Uppercase PAGEREF fixture missing'); + +test('@behavior SD-2537: standalone PAGEREF with \\h renders a clickable anchor', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + // The first TOC entry has its outer stripped. The page + // number should still be an anchor because the PAGEREF \h synthesizes one. + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); + +test('@behavior SD-2537: clicking standalone PAGEREF navigates to the bookmark', async ({ superdoc }) => { + await superdoc.loadDocument(LOWERCASE_DOC); + await superdoc.waitForStable(2000); + + const selBefore = await superdoc.getSelection(); + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]').first(); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); + await pageNumberLink.click(); + await superdoc.waitForStable(2000); + + // goToAnchor moves the caret to the bookmark target. + const selAfter = await superdoc.getSelection(); + expect(selAfter.from).not.toBe(selBefore.from); +}); + +test('@behavior SD-2537: standalone PAGEREF with uppercase \\H also renders a clickable anchor', async ({ + superdoc, +}) => { + // ECMA-376 §17.16.1 says field switches are case-insensitive. \H should + // behave identically to \h. + await superdoc.loadDocument(UPPERCASE_DOC); + await superdoc.waitForStable(2000); + + const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]'); + await expect(pageNumberLink).toBeVisible({ timeout: 10_000 }); +}); From 2c38373ce6629d95ff5c3a2f344ad075237ebaa7 Mon Sep 17 00:00:00 2001 From: Kendall Ernst <84405229+kendaller@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:57:54 -0700 Subject: [PATCH 14/43] fix(paragraph): guard listRendering destructure against null (#2896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(paragraph): guard listRendering destructure against null ParagraphNodeView destructured `{ suffix, justification }` from `this.node.attrs.listRendering` without a null-check in #updateListStyles, and accessed `listRendering.markerText`/`.suffix` directly in #initList. When a paragraph node carried `listRendering: null` — which can happen after certain editor mutations (e.g. dispatching a transaction that combines `setDocAttribute('bodySectPr', …)` with a paragraph delete) — the post-transaction list-styles re-pass threw: TypeError: Cannot destructure property 'suffix' of 'this.node.attrs.listRendering' as it is null Use `?? {}` and optional chaining so a null value falls back through the existing defaults (`suffix ?? 'tab'` and the `suffix == null` branch in #createSeparator). Adds a regression test. * fix(paragraph): no-op list style updates when listRendering is null Previously the null-guarded path fell back to `suffix = 'tab'` and still invoked `#calculateMarkerStyle`/`#calculateTabSeparatorStyle`. Reviewer (codex-connector) flagged that in mixed-suffix updates — where a queued RAF callback runs after a node transitions from `suffix: 'space'` to `listRendering: null` — the separator may still be a Text node. Writing `this.separator.style.cssText` on a Text node throws. Change #updateListStyles and #initList to return early when `listRendering` is null, leaving the existing marker/separator untouched. Future updates (when the node gets a real listRendering or isList() returns false) will clean up as before. Adds a regression test covering the space→null transition. * test(paragraph): cover constructor mount and null-to-tab recovery Addresses review feedback on #2896: - Update #initList JSDoc `@param` to include `| null`, matching the no-op-on-null behavior added in the previous commit. - Add a test that mounts a ParagraphNodeView with `listRendering: null` (the constructor path, not just update()), confirming the null guards in #initList and #updateListStyles cover first-render too. - Add a test for the space→null→tab transition that verifies the separator swaps from a text node back to a span when listRendering returns with a different suffix. --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- .../extensions/paragraph/ParagraphNodeView.js | 19 ++++- .../paragraph/ParagraphNodeView.test.js | 76 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js index 94b3717102..b5156a4586 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js @@ -232,7 +232,16 @@ export class ParagraphNodeView { * @returns {boolean} */ #updateListStyles() { - let { suffix, justification } = this.node.attrs.listRendering; + const listRendering = this.node.attrs.listRendering; + // When listRendering is null (can happen transiently during certain + // transactions, e.g. after a setDocAttribute + paragraph delete), leave + // the existing marker/separator untouched. Forcing a default `suffix` here + // would risk writing tab-style CSS onto a text-node separator created by + // a prior 'space'/'nothing' suffix and scheduled RAF pass. + if (!listRendering) { + return true; + } + let { suffix, justification } = listRendering; suffix = suffix ?? 'tab'; this.#calculateMarkerStyle(justification); if (suffix === 'tab') { @@ -280,9 +289,15 @@ export class ParagraphNodeView { } /** - * @param {{ markerText: string, suffix?: string }} listRendering + * @param {{ markerText: string, suffix?: string } | null} listRendering */ #initList(listRendering) { + // See #updateListStyles: when listRendering is null the previous marker/ + // separator are left in place; avoid invoking the create helpers with + // undefined values. + if (!listRendering) { + return; + } this.#createMarker(listRendering.markerText); this.#createSeparator(listRendering.suffix); } diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js index 4575db07f8..08376ef5d7 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js @@ -199,6 +199,82 @@ describe('ParagraphNodeView', () => { expect(nodeView.separator.textContent).toBe('\u00A0'); }); + it('does not throw when listRendering is null', () => { + // Regression: #updateListStyles destructured `{ suffix, justification }` + // from `this.node.attrs.listRendering` without a null-check, throwing + // `TypeError: Cannot destructure property 'suffix' of ... as it is null` + // whenever a paragraph node carried `listRendering: null`. + isList.mockReturnValue(true); + const baseAttrs = createNode().attrs; + const { nodeView } = mountNodeView({ attrs: { ...baseAttrs } }); + + const nextNode = createNode({ + attrs: { + ...baseAttrs, + listRendering: null, + }, + }); + + expect(() => nodeView.update(nextNode, [])).not.toThrow(); + }); + + it('does not try to style a text-node separator when switching to null listRendering', () => { + // Regression: when transitioning from a 'space'/'nothing' suffix (which + // creates a text-node separator) to `listRendering: null`, the null-guarded + // path must not fall back to the 'tab' branch, since writing + // `this.separator.style.cssText` on a Text node throws. + isList.mockReturnValue(true); + const spaceAttrs = { + ...createNode().attrs, + listRendering: { suffix: 'space', justification: 'left', markerText: '1.' }, + }; + const { nodeView } = mountNodeView({ attrs: spaceAttrs }); + // The separator should be a Text node under the 'space' suffix. + expect(nodeView.separator?.nodeType).toBe(Node.TEXT_NODE); + const textSeparator = nodeView.separator; + + const nullNode = createNode({ + attrs: { ...spaceAttrs, listRendering: null }, + }); + + expect(() => nodeView.update(nullNode, [])).not.toThrow(); + // The text-node separator must be left alone (not replaced, not styled). + expect(nodeView.separator).toBe(textSeparator); + }); + + it('does not throw when mounted with listRendering null', () => { + // Regression: the null guards in #initList and #updateListStyles must also + // cover the constructor path — mounting a paragraph whose listRendering is + // already null previously threw before update() ever ran. + isList.mockReturnValue(true); + const nullAttrs = { ...createNode().attrs, listRendering: null }; + expect(() => mountNodeView({ attrs: nullAttrs })).not.toThrow(); + }); + + it('recovers marker/separator when listRendering returns from null to tab', () => { + // Regression: the null-guarded path leaves the existing marker/separator in + // place. When listRendering clears and later returns with a different suffix + // (here: space → null → tab), the separator has to swap from a text node + // back to a span element — #createSeparator handles this only if the + // recovery path actually runs, so exercise it end-to-end. + isList.mockReturnValue(true); + const spaceAttrs = { + ...createNode().attrs, + listRendering: { suffix: 'space', justification: 'left', markerText: '1.' }, + }; + const { nodeView } = mountNodeView({ attrs: spaceAttrs }); + + nodeView.update(createNode({ attrs: { ...spaceAttrs, listRendering: null } }), []); + + const tabNode = createNode({ + attrs: { ...spaceAttrs, listRendering: { suffix: 'tab', justification: 'left', markerText: '2.' } }, + }); + nodeView.update(tabNode, []); + + expect(nodeView.marker?.textContent).toBe('2.'); + expect(nodeView.separator?.tagName?.toLowerCase()).toBe('span'); + }); + it('uses hanging indent width for right-justified tabs and skips tab helper', () => { isList.mockReturnValue(true); const attrs = { From db9add05ad329626f51fe889141b1a23ca98cce1 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:58:12 -0300 Subject: [PATCH 15/43] chore: merge stable into main (release conflicts auto-resolved) (#2915) Co-authored-by: github-actions[bot] --- packages/esign/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx index ab3a6e9813..d1746887dc 100644 --- a/packages/esign/src/index.tsx +++ b/packages/esign/src/index.tsx @@ -527,4 +527,5 @@ const SuperDocESign = forwardRef Date: Wed, 22 Apr 2026 18:58:22 -0300 Subject: [PATCH 16/43] docs: add kendaller to community contributors (#2916) Co-authored-by: github-actions[bot] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a23252a87..9b34c8b2d9 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Special thanks to these community members who have contributed code to SuperDoc: michaelreavant ArturQuirino kiluazen +kendaller Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started. From 84b5fbcc8364a151c6029c440f8205b493cbb84f Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:26:46 +0300 Subject: [PATCH 17/43] fix: rebuild fragment on geometry change (#2842) --- .../layout-engine/painters/dom/src/index.test.ts | 12 +++++++++--- packages/layout-engine/painters/dom/src/renderer.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 3a1e8f57bf..35f24eb71d 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3029,7 +3029,7 @@ describe('DomPainter', () => { expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5); }); - it('reuses fragment DOM nodes when layout geometry changes', () => { + it('rebuilds fragment DOM nodes when layout geometry changes to keep line epochs in sync', () => { const painter = createTestPainter({ blocks: [block], measures: [measure] }); painter.paint(layout, mount); @@ -3051,9 +3051,12 @@ describe('DomPainter', () => { painter.paint(movedLayout, mount); const fragmentAfter = mount.querySelector('.superdoc-fragment') as HTMLElement; + const lineAfter = fragmentAfter.querySelector('.superdoc-line') as HTMLElement; - expect(fragmentAfter).toBe(fragmentBefore); + expect(fragmentAfter).not.toBe(fragmentBefore); expect(fragmentAfter.style.left).toBe('60px'); + expect(fragmentAfter.dataset.layoutEpoch).toBeTruthy(); + expect(lineAfter.dataset.layoutEpoch).toBe(fragmentAfter.dataset.layoutEpoch); }); it('rebuilds fragment DOM when block content changes via setData', () => { @@ -5016,10 +5019,13 @@ describe('DomPainter', () => { painter.paint(updatedLayout, mount); const updatedWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement; - expect(updatedWrapper).toBe(initialWrapper); + const updatedLine = updatedWrapper.querySelector('.superdoc-line') as HTMLElement; + expect(updatedWrapper).not.toBe(initialWrapper); expect(updatedWrapper.style.left).toBe('90px'); expect(updatedWrapper.style.top).toBe('55px'); expect(updatedWrapper.style.width).toBe('310px'); + expect(updatedWrapper.dataset.layoutEpoch).toBeTruthy(); + expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch); }); it('applies resolved zIndex only to anchored media fragments', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index e6e030be30..6c9eb68ee4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2748,6 +2748,7 @@ export class DomPainter { if (current) { existing.delete(key); + const geometryChanged = hasFragmentGeometryChanged(current.fragment, fragment); const sdtBoundaryMismatch = shouldRebuildForSdtBoundary(current.element, sdtBoundary); // Detect mismatch in any between-border property const betweenBorderMismatch = @@ -2764,6 +2765,7 @@ export class DomPainter { current.element.dataset.pmStart != null && this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; const needsRebuild = + geometryChanged || this.changedBlocks.has(fragment.blockId) || current.signature !== fragmentSignature(fragment, this.blockLookup) || sdtBoundaryMismatch || @@ -7330,6 +7332,16 @@ const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => { return base; }; +const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean => + previous.x !== next.x || + previous.y !== next.y || + previous.width !== next.width || + ('height' in previous && + 'height' in next && + typeof previous.height === 'number' && + typeof next.height === 'number' && + previous.height !== next.height); + const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { if (!metadata) return ''; if ('id' in metadata && metadata.id != null) { From 58c3fca8ae997cc3a6f0d8fd13e48669675ef879 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:17:26 -0700 Subject: [PATCH 18/43] ci: automate stable promotion pr preparation (#2917) --- .github/workflows/ci-behavior.yml | 2 +- .github/workflows/ci-superdoc.yml | 2 +- .github/workflows/promote-stable.yml | 184 +++++++++++++++++++++++++++ .github/workflows/visual-test.yml | 2 +- cicd.md | 20 +-- 5 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/promote-stable.yml diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml index bd5f37792d..a631113816 100644 --- a/.github/workflows/ci-behavior.yml +++ b/.github/workflows/ci-behavior.yml @@ -5,7 +5,7 @@ permissions: on: pull_request: - branches: [main] + branches: [main, stable] paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index 62a0ab065b..208916f66e 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -5,7 +5,7 @@ permissions: on: pull_request: - branches: [main, 'release/**'] + branches: [main, stable, 'release/**'] paths-ignore: - 'apps/docs/**' - 'apps/mcp/**' diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml new file mode 100644 index 0000000000..7a54dbc296 --- /dev/null +++ b/.github/workflows/promote-stable.yml @@ -0,0 +1,184 @@ +name: 🚀 Promote to stable + +on: + workflow_dispatch: + inputs: + branch_name: + description: Optional candidate branch name (defaults to merge/main-into-stable-YYYY-MM-DD) + required: false + type: string + schedule: + - cron: '0 7 * * *' + +permissions: + contents: write + pull-requests: write + +concurrency: + group: promote-stable + cancel-in-progress: false + +jobs: + prepare-stable-pr: + runs-on: ubuntu-latest + env: + BASE_BRANCH: stable + SOURCE_BRANCH: main + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} + + - name: Prepare candidate branch + id: prepare + env: + REQUESTED_BRANCH_NAME: ${{ inputs.branch_name }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch origin "${BASE_BRANCH}" "${SOURCE_BRANCH}" --prune + + DEFAULT_BRANCH_NAME="merge/main-into-stable-$(date -u +%Y-%m-%d)" + BRANCH_NAME="${REQUESTED_BRANCH_NAME}" + if [[ -z "${BRANCH_NAME}" ]]; then + BRANCH_NAME="${DEFAULT_BRANCH_NAME}" + fi + + if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then + echo "Remote branch already exists: ${BRANCH_NAME}" + echo "Preserving the existing frozen candidate branch." + echo "branch_name=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + echo "merge_status=existing" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + git checkout -B "${BRANCH_NAME}" "origin/${BASE_BRANCH}" + + if git merge --no-ff --no-edit "origin/${SOURCE_BRANCH}"; then + MERGE_STATUS="clean" + else + MERGE_STATUS="conflicts" + git add -A + git commit -m "chore: merge main into stable (conflicts need resolution)" + fi + + if git diff --quiet "origin/${BASE_BRANCH}"...HEAD; then + echo "No changes to promote from ${SOURCE_BRANCH} into ${BASE_BRANCH}." + echo "branch_name=" >> "${GITHUB_OUTPUT}" + echo "merge_status=noop" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + git push origin "${BRANCH_NAME}" + + echo "branch_name=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + echo "merge_status=${MERGE_STATUS}" >> "${GITHUB_OUTPUT}" + + - name: Open pull request + if: steps.prepare.outputs.branch_name != '' + id: pr + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + BRANCH_NAME: ${{ steps.prepare.outputs.branch_name }} + MERGE_STATUS: ${{ steps.prepare.outputs.merge_status }} + run: | + set -euo pipefail + + if [[ "${MERGE_STATUS}" == "conflicts" ]]; then + PR_TITLE="Merge main into stable (conflicts need resolution)" + PR_BODY="$(cat </dev/null || true)" + + if [[ -n "${EXISTING_PR_URL}" ]]; then + gh pr edit "${EXISTING_PR_URL}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}" + echo "url=${EXISTING_PR_URL}" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + PR_URL="$(gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${BASE_BRANCH}" \ + --head "${BRANCH_NAME}" \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}")" + + echo "url=${PR_URL}" >> "${GITHUB_OUTPUT}" + + - name: Write workflow summary + run: | + { + echo "### Promote to stable" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Event | \`${{ github.event_name }}\` |" + echo "| Source branch | \`${SOURCE_BRANCH}\` |" + echo "| Base branch | \`${BASE_BRANCH}\` |" + echo "| Candidate branch | \`${{ steps.prepare.outputs.branch_name || 'n/a' }}\` |" + echo "| Merge status | \`${{ steps.prepare.outputs.merge_status || 'n/a' }}\` |" + echo "| PR | ${{ steps.pr.outputs.url || 'n/a' }} |" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/visual-test.yml b/.github/workflows/visual-test.yml index bf9e54ef8f..09c5378af6 100644 --- a/.github/workflows/visual-test.yml +++ b/.github/workflows/visual-test.yml @@ -6,7 +6,7 @@ permissions: on: pull_request: - branches: [main] + branches: [main, stable] paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' diff --git a/cicd.md b/cicd.md index 0171f724f9..1e13844416 100644 --- a/cicd.md +++ b/cicd.md @@ -69,15 +69,17 @@ main (next) → stable (latest) → X.x (maintenance) #### 3. Promote to Stable (`promote-stable.yml`) -**Trigger**: Manual workflow dispatch +**Trigger**: Manual workflow dispatch or daily schedule at `07:00 UTC` -**Input**: Optional tag to promote (defaults to latest from main) +**Input**: Optional candidate branch name (defaults to `merge/main-into-stable-YYYY-MM-DD`) **Actions**: -- Merges specified version to stable branch -- Triggers automatic stable release -- Updates npm @latest tag +- Creates a fresh candidate branch from `stable` +- Merges `main` into that branch +- Opens a PR targeting `stable` +- If the merge conflicts, commits the conflicted merge to the branch so a human can resolve it there +- Merging that PR triggers the automatic stable release workflow #### 4. Create Patch Branch (`create-patch.yml`) @@ -208,9 +210,11 @@ These skip semantic-release entirely — useful for re-publishing a failed platf ### Scenario 2: Creating Stable Release 1. Run "Promote to Stable" workflow -2. Merges main to stable -3. Automatically publishes `1.1.0` as @latest -4. Syncs back to main with version bump +2. Review the generated PR from the candidate branch into `stable` +3. If needed, resolve merge conflicts on the candidate branch +4. Merge the PR into `stable` +5. Automatically publishes `1.1.0` as @latest +6. Syncs back to main with version bump ### Scenario 3: Hotfix to Current Stable From c3b2cef6c64b7dec4ee20fa658eb8415166d9b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Wed, 22 Apr 2026 20:35:33 -0300 Subject: [PATCH 19/43] refactor(layout): lift page metadata into ResolvedPage (#2810) --- .../contracts/src/resolved-layout.ts | 33 +++- .../layout-resolved/src/resolveLayout.test.ts | 182 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 16 +- .../painters/dom/src/renderer.ts | 86 ++++++--- 4 files changed, 286 insertions(+), 31 deletions(-) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 9170e1e202..d0eae8f951 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -1,4 +1,14 @@ -import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js'; +import type { + DrawingBlock, + FlowMode, + Fragment, + ImageBlock, + Line, + PageMargins, + SectionVerticalAlign, + TableBlock, + TableMeasure, +} from './index.js'; /** A fully resolved layout ready for the next-generation paint pipeline. */ export type ResolvedLayout = { @@ -10,6 +20,8 @@ export type ResolvedLayout = { pageGap: number; /** Resolved pages with normalized dimensions. */ pages: ResolvedPage[]; + /** Document epoch identifier from the source layout. Used for change tracking in the painter. */ + layoutEpoch?: number; }; /** A single resolved page with stable identity and normalized dimensions. */ @@ -26,6 +38,25 @@ export type ResolvedPage = { height: number; /** Resolved paint items for this page. */ items: ResolvedPaintItem[]; + /** Page margins from the source page. Used for ruler rendering and header/footer positioning. */ + margins?: PageMargins; + /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */ + footnoteReserved?: number; + /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ + numberText?: string; + /** Vertical alignment of content within this page. */ + vAlign?: SectionVerticalAlign; + /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ + baseMargins?: { top: number; bottom: number }; + /** 0-based index of the section this page belongs to. */ + sectionIndex?: number; + /** Header/footer reference IDs for this page's section. */ + sectionRefs?: { + headerRefs?: { default?: string; first?: string; even?: string; odd?: string }; + footerRefs?: { default?: string; first?: string; even?: string; odd?: string }; + }; + /** Page orientation. */ + orientation?: 'portrait' | 'landscape'; }; /** Union of all resolved paint item kinds. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index a9df355da8..04a9fc805b 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -1487,4 +1487,186 @@ describe('resolveLayout', () => { expect(content.lines[0].availableWidth).toBe(360); }); }); + + describe('page metadata fields', () => { + it('carries margins through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36, gutter: 0 }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].margins).toEqual({ + top: 72, + right: 72, + bottom: 72, + left: 72, + header: 36, + footer: 36, + gutter: 0, + }); + }); + + it('leaves margins undefined when page has no margins', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].margins).toBeUndefined(); + }); + + it('carries footnoteReserved through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], footnoteReserved: 48 }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].footnoteReserved).toBe(48); + }); + + it('carries numberText through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], numberText: 'i' }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].numberText).toBe('i'); + }); + + it('carries vAlign and baseMargins through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + vAlign: 'center', + baseMargins: { top: 72, bottom: 72 }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].vAlign).toBe('center'); + expect(result.pages[0].baseMargins).toEqual({ top: 72, bottom: 72 }); + }); + + it('carries sectionIndex through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], sectionIndex: 2 }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].sectionIndex).toBe(2); + }); + + it('carries sectionRefs through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + sectionRefs: { + headerRefs: { default: 'hdr1', first: 'hdr-first' }, + footerRefs: { default: 'ftr1' }, + }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].sectionRefs).toEqual({ + headerRefs: { default: 'hdr1', first: 'hdr-first' }, + footerRefs: { default: 'ftr1' }, + }); + }); + + it('carries orientation through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 792, h: 612 }, + pages: [{ number: 1, fragments: [], orientation: 'landscape' }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].orientation).toBe('landscape'); + }); + + it('leaves optional metadata undefined when not set on source page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const page = result.pages[0]; + expect(page.margins).toBeUndefined(); + expect(page.footnoteReserved).toBeUndefined(); + expect(page.numberText).toBeUndefined(); + expect(page.vAlign).toBeUndefined(); + expect(page.baseMargins).toBeUndefined(); + expect(page.sectionIndex).toBeUndefined(); + expect(page.sectionRefs).toBeUndefined(); + expect(page.orientation).toBeUndefined(); + }); + + it('carries all metadata fields together on a fully-populated page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 3, + fragments: [], + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + footnoteReserved: 24, + numberText: 'iii', + vAlign: 'bottom', + baseMargins: { top: 96, bottom: 96 }, + sectionIndex: 1, + sectionRefs: { + headerRefs: { default: 'h1' }, + footerRefs: { default: 'f1', even: 'f-even' }, + }, + orientation: 'portrait', + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const page = result.pages[0]; + expect(page.margins).toEqual({ top: 72, right: 72, bottom: 72, left: 72 }); + expect(page.footnoteReserved).toBe(24); + expect(page.numberText).toBe('iii'); + expect(page.vAlign).toBe('bottom'); + expect(page.baseMargins).toEqual({ top: 96, bottom: 96 }); + expect(page.sectionIndex).toBe(1); + expect(page.sectionRefs).toEqual({ + headerRefs: { default: 'h1' }, + footerRefs: { default: 'f1', even: 'f-even' }, + }); + expect(page.orientation).toBe('portrait'); + }); + }); + + describe('layoutEpoch', () => { + it('carries layoutEpoch from source layout to resolved layout', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [], + layoutEpoch: 42, + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.layoutEpoch).toBe(42); + }); + + it('defaults layoutEpoch to undefined when not set', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.layoutEpoch).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index fd16d0b15d..1c7e513981 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -168,12 +168,26 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap), ), + margins: page.margins, + footnoteReserved: page.footnoteReserved, + numberText: page.numberText, + vAlign: page.vAlign, + baseMargins: page.baseMargins, + sectionIndex: page.sectionIndex, + sectionRefs: page.sectionRefs, + orientation: page.orientation, })); - return { + const resolved: ResolvedLayout = { version: 1, flowMode, pageGap: layout.pageGap ?? 0, pages, }; + + if (layout.layoutEpoch != null) { + resolved.layoutEpoch = layout.layoutEpoch; + } + + return resolved; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 6c9eb68ee4..c4b3976298 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1689,7 +1689,7 @@ export class DomPainter { } this.layoutVersion += 1; - this.layoutEpoch = layout.layoutEpoch ?? 0; + this.layoutEpoch = this.resolvedLayout?.layoutEpoch ?? layout.layoutEpoch ?? 0; this.mount = mount; this.beginPaintSnapshot(layout); @@ -2200,6 +2200,7 @@ export class DomPainter { if (!this.doc) { throw new Error('DomPainter: document is not available'); } + const resolvedPage = this.getResolvedPage(pageIndex); const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(width, height, this.getEffectivePageStyles())); @@ -2210,7 +2211,7 @@ export class DomPainter { // Render per-page ruler if enabled (suppressed in semantic flow mode) if (!this.isSemanticFlow && this.options.ruler?.enabled) { - const rulerEl = this.renderPageRuler(width, page); + const rulerEl = this.renderPageRuler(width, page, resolvedPage); if (rulerEl) { el.appendChild(rulerEl); } @@ -2220,7 +2221,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2234,9 +2235,8 @@ export class DomPainter { this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem), ); }); - this.renderDecorationsForPage(el, page, pageIndex); - this.renderColumnSeparators(el, page, width, height); - + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); + this.renderColumnSeparators(el, page, width, height, resolvedPage); return el; } @@ -2258,18 +2258,18 @@ export class DomPainter { * - Uses DEFAULT_PAGE_HEIGHT_PX (1056px = 11 inches) if page.size.h is not available * - Defaults margins to 0 if not explicitly provided */ - private renderPageRuler(pageWidthPx: number, page: Page): HTMLElement | null { + private renderPageRuler(pageWidthPx: number, page: Page, resolvedPage?: ResolvedPage | null): HTMLElement | null { if (!this.doc) { console.warn('[renderPageRuler] Cannot render ruler: document is not available.'); return null; } - if (!page.margins) { + const margins = resolvedPage?.margins ?? page.margins; + if (!margins) { console.warn(`[renderPageRuler] Cannot render ruler for page ${page.number}: margins not available.`); return null; } - const margins = page.margins; const leftMargin = margins.left ?? 0; const rightMargin = margins.right ?? 0; @@ -2317,14 +2317,23 @@ export class DomPainter { } } - private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void { + private renderColumnSeparators( + pageEl: HTMLElement, + page: Page, + pageWidth: number, + pageHeight: number, + resolvedPage?: ResolvedPage | null, + ): void { if (!this.doc) return; - if (!page.margins) return; + pageEl.querySelectorAll('[data-superdoc-column-separator="true"]').forEach((separator) => separator.remove()); - const leftMargin = page.margins.left ?? 0; - const rightMargin = page.margins.right ?? 0; - const topMargin = page.margins.top ?? 0; - const bottomMargin = page.margins.bottom ?? 0; + const pageMargins = resolvedPage?.margins ?? page.margins; + if (!pageMargins) return; + + const leftMargin = pageMargins.left ?? 0; + const rightMargin = pageMargins.right ?? 0; + const topMargin = pageMargins.top ?? 0; + const bottomMargin = pageMargins.bottom ?? 0; const contentWidth = pageWidth - leftMargin - rightMargin; // Prefer columnRegions (per-region configs for pages with continuous @@ -2356,6 +2365,7 @@ export class DomPainter { for (const separatorX of separatorPositions) { const separatorEl = this.doc.createElement('div'); + separatorEl.dataset.superdocColumnSeparator = 'true'; separatorEl.style.position = 'absolute'; separatorEl.style.left = `${separatorX}px`; @@ -2403,10 +2413,15 @@ export class DomPainter { return separatorPositions; } - private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { + private renderDecorationsForPage( + pageEl: HTMLElement, + page: Page, + pageIndex: number, + resolvedPage?: ResolvedPage | null, + ): void { if (this.isSemanticFlow) return; - this.renderDecorationSection(pageEl, page, pageIndex, 'header'); - this.renderDecorationSection(pageEl, page, pageIndex, 'footer'); + this.renderDecorationSection(pageEl, page, pageIndex, 'header', resolvedPage); + this.renderDecorationSection(pageEl, page, pageIndex, 'footer', resolvedPage); } /** @@ -2444,17 +2459,19 @@ export class DomPainter { page: Page, kind: 'header' | 'footer', effectiveOffset: number, + resolvedPage?: ResolvedPage | null, ): number { if (kind === 'header') { return effectiveOffset; } - const bottomMargin = page.margins?.bottom; + const pageMargins = resolvedPage?.margins ?? page.margins; + const bottomMargin = pageMargins?.bottom; if (bottomMargin == null) { return effectiveOffset; } - const footnoteReserve = page.footnoteReserved ?? 0; + const footnoteReserve = resolvedPage?.footnoteReserved ?? page.footnoteReserved ?? 0; const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve); const styledPageHeight = Number.parseFloat(pageEl.style.height || ''); const pageHeight = @@ -2465,11 +2482,18 @@ export class DomPainter { return Math.max(0, pageHeight - adjustedBottomMargin); } - private renderDecorationSection(pageEl: HTMLElement, page: Page, pageIndex: number, kind: 'header' | 'footer'): void { + private renderDecorationSection( + pageEl: HTMLElement, + page: Page, + pageIndex: number, + kind: 'header' | 'footer', + resolvedPage?: ResolvedPage | null, + ): void { if (!this.doc) return; const provider = kind === 'header' ? this.headerProvider : this.footerProvider; const className = kind === 'header' ? CLASS_NAMES.pageHeader : CLASS_NAMES.pageFooter; const existing = pageEl.querySelector(`.${className}`); + // Provider still receives legacy page — its signature is not changed in this PR const data = provider ? provider(page.number, page.margins, page) : null; if (!data || data.fragments.length === 0) { @@ -2482,7 +2506,8 @@ export class DomPainter { container.innerHTML = ''; const baseOffset = data.offset ?? (kind === 'footer' ? pageEl.clientHeight - data.height : 0); const marginLeft = data.marginLeft ?? 0; - const marginRight = page.margins?.right ?? 0; + const pageMargins = resolvedPage?.margins ?? page.margins; + const marginRight = pageMargins?.right ?? 0; // For footers, if content is taller than reserved space, expand container upward // The container bottom stays anchored at footerMargin from page bottom @@ -2522,7 +2547,7 @@ export class DomPainter { // Header page-relative anchors use raw inner-layout Y and are handled with // the simpler effectiveOffset subtraction (unchanged from the baseline). const footerAnchorPageOriginY = - kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset) : 0; + kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset, resolvedPage) : 0; const footerAnchorContainerOffsetY = kind === 'footer' ? footerAnchorPageOriginY - effectiveOffset : 0; // For footers, calculate offset to push content to bottom of container @@ -2547,7 +2572,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: kind, - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2719,6 +2744,7 @@ export class DomPainter { } private patchPage(state: PageDomState, page: Page, pageSize: { w: number; h: number }, pageIndex: number): void { + const resolvedPage = this.getResolvedPage(pageIndex); const pageEl = state.element; applyStyles(pageEl, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); this.applySemanticPageOverrides(pageEl); @@ -2735,7 +2761,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2815,7 +2841,8 @@ export class DomPainter { }); state.fragments = nextFragments; - this.renderDecorationsForPage(pageEl, page, pageIndex); + this.renderDecorationsForPage(pageEl, page, pageIndex, resolvedPage); + this.renderColumnSeparators(pageEl, page, pageSize.w, pageSize.h, resolvedPage); } /** @@ -2875,6 +2902,7 @@ export class DomPainter { if (!this.doc) { throw new Error('DomPainter.createPageState requires a document'); } + const resolvedPage = this.getResolvedPage(pageIndex); const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); @@ -2885,6 +2913,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2910,9 +2939,8 @@ export class DomPainter { }; }); - this.renderDecorationsForPage(el, page, pageIndex); - this.renderColumnSeparators(el, page, pageSize.w, pageSize.h); - + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); + this.renderColumnSeparators(el, page, pageSize.w, pageSize.h, resolvedPage); return { element: el, fragments: fragmentStates }; } From 032d4c10cf07341ba92e9d2ca756d81dca9e6260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Wed, 22 Apr 2026 21:41:02 -0300 Subject: [PATCH 20/43] [2/16] refactor(layout): lift fragment metadata into resolved paint items (#2811) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * fix(layout): use resolved continuation state for paragraph first-line width --- .../contracts/src/resolved-layout.ts | 29 ++ .../layout-resolved/src/resolveDrawing.ts | 5 +- .../layout-resolved/src/resolveImage.ts | 6 +- .../layout-resolved/src/resolveLayout.test.ts | 363 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 22 +- .../layout-resolved/src/resolveTable.ts | 7 +- .../painters/dom/src/index.test.ts | 70 ++++ .../painters/dom/src/renderer.ts | 92 +++-- 8 files changed, 555 insertions(+), 39 deletions(-) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index d0eae8f951..7d28407900 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -3,6 +3,7 @@ import type { FlowMode, Fragment, ImageBlock, + ImageFragmentMetadata, Line, PageMargins, SectionVerticalAlign, @@ -105,6 +106,16 @@ export type ResolvedFragmentItem = { blockId: string; /** Index within page.fragments — bridge to legacy content rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; + /** Whether this fragment continues from a previous page. */ + continuesFromPrev?: boolean; + /** Whether this fragment continues on the next page. */ + continuesOnNext?: boolean; + /** List marker box width in pixels (para/list-item only). */ + markerWidth?: number; /** Pre-resolved paragraph content for non-table paragraph fragments. */ content?: ResolvedParagraphContent; }; @@ -205,6 +216,14 @@ export type ResolvedTableItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; + /** Whether this table fragment continues from a previous page. */ + continuesFromPrev?: boolean; + /** Whether this table fragment continues on the next page. */ + continuesOnNext?: boolean; /** Pre-extracted TableBlock (replaces blockLookup.get()). */ block: TableBlock; /** Pre-extracted TableMeasure (replaces blockLookup.get()). */ @@ -241,8 +260,14 @@ export type ResolvedImageItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; /** Pre-extracted ImageBlock (replaces blockLookup.get()). */ block: ImageBlock; + /** Image metadata for interactive resizing (original dimensions, aspect ratio). */ + metadata?: ImageFragmentMetadata; }; /** @@ -271,6 +296,10 @@ export type ResolvedDrawingItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; /** Pre-extracted DrawingBlock (replaces blockLookup.get()). */ block: DrawingBlock; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts index de0db6741d..9d3d39ff13 100644 --- a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts +++ b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts @@ -17,7 +17,7 @@ export function resolveDrawingItem( ): ResolvedDrawingItem { const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing'); - return { + const item: ResolvedDrawingItem = { kind: 'fragment', fragmentKind: 'drawing', id: resolveDrawingFragmentId(fragment), @@ -31,4 +31,7 @@ export function resolveDrawingItem( fragmentIndex, block, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + return item; } diff --git a/packages/layout-engine/layout-resolved/src/resolveImage.ts b/packages/layout-engine/layout-resolved/src/resolveImage.ts index d1747585f9..e09632c7aa 100644 --- a/packages/layout-engine/layout-resolved/src/resolveImage.ts +++ b/packages/layout-engine/layout-resolved/src/resolveImage.ts @@ -17,7 +17,7 @@ export function resolveImageItem( ): ResolvedImageItem { const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'image', 'image', 'image'); - return { + const item: ResolvedImageItem = { kind: 'fragment', fragmentKind: 'image', id: resolveImageFragmentId(fragment), @@ -31,4 +31,8 @@ export function resolveImageItem( fragmentIndex, block, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + if (fragment.metadata != null) item.metadata = fragment.metadata; + return item; } diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 04a9fc805b..2e935e82a3 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -638,6 +638,369 @@ describe('resolveLayout', () => { }); }); + describe('fragment metadata lifting', () => { + it('lifts pmStart and pmEnd from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + pmStart: 5, + pmEnd: 42, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.pmStart).toBe(5); + expect(item.pmEnd).toBe(42); + }); + + it('omits pmStart and pmEnd when not present on paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + + it('lifts continuesFromPrev and continuesOnNext from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 1, + toLine: 3, + x: 72, + y: 72, + width: 468, + continuesFromPrev: true, + continuesOnNext: true, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(true); + }); + + it('omits continuesFromPrev and continuesOnNext when not set', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBeUndefined(); + expect(item.continuesOnNext).toBeUndefined(); + }); + + it('lifts markerWidth from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.markerWidth).toBe(36); + }); + + it('lifts continuesFromPrev, continuesOnNext, and markerWidth from a list-item fragment', () => { + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 1, + toLine: 2, + x: 108, + y: 200, + width: 432, + markerWidth: 36, + continuesFromPrev: true, + continuesOnNext: false, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] }, + }, + ], + }, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + { fromRun: 0, fromChar: 5, toRun: 0, toChar: 10, width: 180, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 40, + }, + }, + ], + totalHeight: 40, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(false); + expect(item.markerWidth).toBe(36); + }); + + it('lifts pmStart, pmEnd, continuesFromPrev, and continuesOnNext from a table fragment', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 3, + x: 72, + y: 100, + width: 468, + height: 300, + pmStart: 10, + pmEnd: 200, + continuesFromPrev: true, + continuesOnNext: false, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] }; + const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.pmStart).toBe(10); + expect(item.pmEnd).toBe(200); + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(false); + }); + + it('omits pmStart and pmEnd from table fragment when not set', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 30, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] }; + const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + + it('lifts pmStart, pmEnd, and metadata from an image fragment', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + pmStart: 15, + pmEnd: 16, + metadata: { + originalWidth: 600, + originalHeight: 500, + maxWidth: 468, + maxHeight: 700, + aspectRatio: 1.2, + minWidth: 50, + minHeight: 42, + }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const blocks: FlowBlock[] = [imageBlock]; + const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + expect(item.pmStart).toBe(15); + expect(item.pmEnd).toBe(16); + expect(item.metadata).toEqual({ + originalWidth: 600, + originalHeight: 500, + maxWidth: 468, + maxHeight: 700, + aspectRatio: 1.2, + minWidth: 50, + minHeight: 42, + }); + }); + + it('omits metadata from image fragment when not set', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const blocks: FlowBlock[] = [imageBlock]; + const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + expect(item.metadata).toBeUndefined(); + }); + + it('lifts pmStart and pmEnd from a drawing fragment', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + isAnchored: true, + zIndex: 3, + geometry: { width: 200, height: 150 }, + scale: 1, + pmStart: 30, + pmEnd: 31, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + const blocks: FlowBlock[] = [drawingBlock as any]; + const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(item.pmStart).toBe(30); + expect(item.pmEnd).toBe(31); + }); + + it('omits pmStart and pmEnd from drawing fragment when not set', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + const blocks: FlowBlock[] = [drawingBlock as any]; + const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + }); + describe('paragraph content resolution', () => { const makeLine = ( overrides: Partial = {}, diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 1c7e513981..3f1d19d4de 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -6,11 +6,14 @@ import type { Fragment, DrawingFragment, ImageFragment, + ListItemFragment, + ParaFragment, TableFragment, Line, ResolvedLayout, ResolvedPage, ResolvedPaintItem, + ResolvedFragmentItem, ResolvedParagraphContent, ListMeasure, ParagraphBlock, @@ -136,9 +139,9 @@ function resolveFragmentItem( return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); case 'drawing': return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); - default: + default: { // para, list-item — existing generic resolution - return { + const item: ResolvedFragmentItem = { kind: 'fragment', id: resolveFragmentId(fragment), pageIndex, @@ -152,6 +155,21 @@ function resolveFragmentItem( fragmentIndex, content: resolveParagraphContentIfApplicable(fragment, blockMap), }; + if (fragment.kind === 'para') { + const para = fragment as ParaFragment; + if (para.pmStart != null) item.pmStart = para.pmStart; + if (para.pmEnd != null) item.pmEnd = para.pmEnd; + if (para.continuesFromPrev != null) item.continuesFromPrev = para.continuesFromPrev; + if (para.continuesOnNext != null) item.continuesOnNext = para.continuesOnNext; + if (para.markerWidth != null) item.markerWidth = para.markerWidth; + } else if (fragment.kind === 'list-item') { + const listItem = fragment as ListItemFragment; + if (listItem.continuesFromPrev != null) item.continuesFromPrev = listItem.continuesFromPrev; + if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext; + if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth; + } + return item; + } } } diff --git a/packages/layout-engine/layout-resolved/src/resolveTable.ts b/packages/layout-engine/layout-resolved/src/resolveTable.ts index f88a692109..588634987e 100644 --- a/packages/layout-engine/layout-resolved/src/resolveTable.ts +++ b/packages/layout-engine/layout-resolved/src/resolveTable.ts @@ -25,7 +25,7 @@ export function resolveTableItem( ): ResolvedTableItem { const { block, measure } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'table', 'table', 'table'); - return { + const item: ResolvedTableItem = { kind: 'fragment', fragmentKind: 'table', id: resolveTableFragmentId(fragment), @@ -42,4 +42,9 @@ export function resolveTableItem( cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + if (fragment.continuesFromPrev != null) item.continuesFromPrev = fragment.continuesFromPrev; + if (fragment.continuesOnNext != null) item.continuesOnNext = fragment.continuesOnNext; + return item; } diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 35f24eb71d..ecf05ecb8c 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6365,6 +6365,76 @@ describe('DomPainter', () => { expect(page2Line.style.textIndent).toBe('0px'); }); + it('uses resolved continuesFromPrev for first-line width calculations', () => { + const continuedBlock: FlowBlock = { + kind: 'paragraph', + id: 'resolved-continued-block', + runs: [{ text: 'alpha beta gamma', fontFamily: 'Arial', fontSize: 16 }], + attrs: { alignment: 'justify', indent: { left: 20, hanging: 40 } }, + }; + + const continuedMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 16, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const continuedLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'resolved-continued-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + continuesOnNext: true, + }, + ], + }, + ], + }; + + const resolvedLayout = createSinglePageResolvedLayout({ + kind: 'fragment', + id: 'resolved-continued-item', + pageIndex: 0, + x: 0, + y: 0, + width: 200, + height: 20, + fragmentKind: 'para', + blockId: 'resolved-continued-block', + fragmentIndex: 0, + continuesFromPrev: true, + continuesOnNext: true, + }); + + const painter = createTestPainter({ blocks: [continuedBlock], measures: [continuedMeasure] }); + painter.setResolvedLayout(resolvedLayout); + painter.paint(continuedLayout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + expect(lineEl.style.textIndent).toBe('0px'); + expect(lineEl.style.wordSpacing).toBe('30px'); + }); + it('removes fragment-level indent styles to prevent double-application', () => { const doubleIndentBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c4b3976298..356d29ef88 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2412,7 +2412,6 @@ export class DomPainter { return separatorPositions; } - private renderDecorationsForPage( pageEl: HTMLElement, page: Page, @@ -3039,13 +3038,18 @@ export class DomPainter { const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; + // Prefer resolved item metadata over legacy fragment reads + const paraContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev; + const paraContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext; + const paraMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth; + const fragmentEl = this.doc.createElement('div'); fragmentEl.classList.add(CLASS_NAMES.fragment); // For TOC entries, override white-space to prevent wrapping const isTocEntry = block.attrs?.isTocEntry; // For fragments with markers, allow overflow to show markers positioned at negative left - const hasMarker = !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker; + const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; // SDT containers need overflow visible for tooltips/labels positioned above const hasSdtContainer = block.attrs?.sdt?.type === 'documentSection' || @@ -3072,10 +3076,10 @@ export class DomPainter { fragmentEl.classList.add('superdoc-toc-entry'); } - if (fragment.continuesFromPrev) { + if (paraContinuesFromPrev) { fragmentEl.dataset.continuesFromPrev = 'true'; } - if (fragment.continuesOnNext) { + if (paraContinuesOnNext) { fragmentEl.dataset.continuesOnNext = 'true'; } @@ -3131,7 +3135,7 @@ export class DomPainter { } else { const dropCapDescriptor = block.attrs?.dropCapDescriptor; const dropCapMeasure = measure.dropCap; - if (dropCapDescriptor && dropCapMeasure && !fragment.continuesFromPrev) { + if (dropCapDescriptor && dropCapMeasure && !paraContinuesFromPrev) { const dropCapEl = this.renderDropCap(dropCapDescriptor, dropCapMeasure); fragmentEl.appendChild(dropCapEl); } @@ -3257,7 +3261,7 @@ export class DomPainter { const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; const listFirstLineTextStartPx = - !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker + !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker ? resolvePainterListTextStartPx({ wordLayout, indentLeftPx: paraIndentLeft, @@ -3268,8 +3272,8 @@ export class DomPainter { : undefined; const shouldUseSharedInlinePrefixGeometry = - !fragment.continuesFromPrev && - fragment.markerWidth && + !paraContinuesFromPrev && + paraMarkerWidth && wordLayout?.marker?.justification === 'left' && wordLayout.firstLineIndentMode !== true && typeof fragment.markerTextWidth === 'number' && @@ -3287,7 +3291,7 @@ export class DomPainter { let listTabWidth = 0; let markerStartPos = 0; - if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) { + if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) { const markerTextWidth = fragment.markerTextWidth!; const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); const markerJustification = wordLayout.marker.justification ?? 'left'; @@ -3322,8 +3326,7 @@ export class DomPainter { lines.forEach((line, index) => { const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; - const hasListFirstLineMarker = - index === 0 && !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker; + const hasListFirstLineMarker = index === 0 && !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; const shouldUseResolvedListTextStart = hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null; @@ -3337,7 +3340,7 @@ export class DomPainter { } // Adjust availableWidth for first-line text indent (hanging indent). - const isFirstLine = index === 0 && !fragment.continuesFromPrev; + const isFirstLine = index === 0 && !paraContinuesFromPrev; const isListFirstLine = Boolean(hasListFirstLineMarker && fragment.markerTextWidth); if (isFirstLine && !isListFirstLine && !hasExplicitSegmentPositioning) { availableWidthOverride = adjustAvailableWidthForTextIndent( @@ -3348,7 +3351,7 @@ export class DomPainter { } const isLastLineOfFragment = index === lines.length - 1; - const isLastLineOfParagraph = isLastLineOfFragment && !fragment.continuesOnNext; + const isLastLineOfParagraph = isLastLineOfFragment && !paraContinuesOnNext; const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; const lineEl = this.renderLine( @@ -3384,7 +3387,7 @@ export class DomPainter { if (paraIndentRight && paraIndentRight > 0) { lineEl.style.paddingRight = `${paraIndentRight}px`; } - if (!fragment.continuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) { + if (!paraContinuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) { if (!hasExplicitSegmentPositioning) { lineEl.style.textIndent = `${firstLineOffset}px`; } @@ -3580,6 +3583,11 @@ export class DomPainter { throw new Error(`DomPainter: missing list item ${fragment.itemId}`); } + // Prefer resolved item metadata over legacy fragment reads + const listContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev; + const listContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext; + const listMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth; + const fragmentEl = this.doc.createElement('div'); fragmentEl.classList.add(CLASS_NAMES.fragment, `${CLASS_NAMES.fragment}-list-item`); applyStyles(fragmentEl, fragmentStyles); @@ -3605,10 +3613,10 @@ export class DomPainter { sdtBoundary, ); - if (fragment.continuesFromPrev) { + if (listContinuesFromPrev) { fragmentEl.dataset.continuesFromPrev = 'true'; } - if (fragment.continuesOnNext) { + if (listContinuesOnNext) { fragmentEl.dataset.continuesOnNext = 'true'; } @@ -3623,7 +3631,7 @@ export class DomPainter { if (marker) { markerEl.textContent = marker.markerText ?? null; markerEl.style.display = 'inline-block'; - markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`; + markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`; markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`; markerEl.style.textAlign = marker.justification ?? 'left'; @@ -3638,7 +3646,7 @@ export class DomPainter { // Fallback: legacy behavior markerEl.textContent = item.marker.text; markerEl.style.display = 'inline-block'; - markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`; + markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`; markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`; if (item.marker.align) { markerEl.style.textAlign = item.marker.align; @@ -3738,16 +3746,19 @@ export class DomPainter { } // Add PM position markers for transaction targeting - if (fragment.pmStart != null) { - fragmentEl.dataset.pmStart = String(fragment.pmStart); + const imgPmStart = resolvedItem?.pmStart ?? fragment.pmStart; + if (imgPmStart != null) { + fragmentEl.dataset.pmStart = String(imgPmStart); } - if (fragment.pmEnd != null) { - fragmentEl.dataset.pmEnd = String(fragment.pmEnd); + const imgPmEnd = resolvedItem?.pmEnd ?? fragment.pmEnd; + if (imgPmEnd != null) { + fragmentEl.dataset.pmEnd = String(imgPmEnd); } // Add metadata for interactive image resizing (skip watermarks - they should not be interactive) - if (fragment.metadata && !block.attrs?.vmlWatermark) { - fragmentEl.setAttribute('data-image-metadata', JSON.stringify(fragment.metadata)); + const imgMetadata = resolvedItem?.metadata ?? fragment.metadata; + if (imgMetadata && !block.attrs?.vmlWatermark) { + fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); } // behindDoc images are supported via z-index; suppress noisy debug logs @@ -6839,8 +6850,14 @@ export class DomPainter { /** * Applies PM position data attributes from a legacy Fragment. * Extracted from applyFragmentFrame for use in the resolved wrapper path. + * When a resolvedItem is provided, its fields take precedence over fragment fields. */ - private applyFragmentPmAttributes(el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer'): void { + private applyFragmentPmAttributes( + el: HTMLElement, + fragment: Fragment, + section?: 'body' | 'header' | 'footer', + resolvedItem?: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem, + ): void { // Footnote content is read-only: prevent cursor placement and typing if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { el.setAttribute('contenteditable', 'false'); @@ -6850,22 +6867,28 @@ export class DomPainter { if (section === 'body' || section === undefined) { assertFragmentPmPositions(fragment, 'paragraph fragment'); } - if (fragment.pmStart != null) { - el.dataset.pmStart = String(fragment.pmStart); + // Narrow to ResolvedFragmentItem to access para-specific resolved fields + const resolvedFrag = resolvedItem as ResolvedFragmentItem | undefined; + const pmStart = resolvedFrag?.pmStart ?? (fragment as ParaFragment).pmStart; + if (pmStart != null) { + el.dataset.pmStart = String(pmStart); } else { delete el.dataset.pmStart; } - if (fragment.pmEnd != null) { - el.dataset.pmEnd = String(fragment.pmEnd); + const pmEnd = resolvedFrag?.pmEnd ?? (fragment as ParaFragment).pmEnd; + if (pmEnd != null) { + el.dataset.pmEnd = String(pmEnd); } else { delete el.dataset.pmEnd; } - if (fragment.continuesFromPrev) { + const continuesFromPrev = resolvedFrag?.continuesFromPrev ?? (fragment as ParaFragment).continuesFromPrev; + if (continuesFromPrev) { el.dataset.continuesFromPrev = 'true'; } else { delete el.dataset.continuesFromPrev; } - if (fragment.continuesOnNext) { + const continuesOnNext = resolvedFrag?.continuesOnNext ?? (fragment as ParaFragment).continuesOnNext; + if (continuesOnNext) { el.dataset.continuesOnNext = 'true'; } else { delete el.dataset.continuesOnNext; @@ -6949,7 +6972,7 @@ export class DomPainter { el.style.height = `${item.height}px`; } - this.applyFragmentPmAttributes(el, fragment, section); + this.applyFragmentPmAttributes(el, fragment, section, item); } /** @@ -6966,8 +6989,9 @@ export class DomPainter { section?: 'body' | 'header' | 'footer', ): void { this.applyResolvedFragmentFrame(el, item, fragment, section); - el.style.left = `${item.x - fragment.markerWidth}px`; - el.style.width = `${item.width + fragment.markerWidth}px`; + const mw = item.markerWidth ?? fragment.markerWidth; + el.style.left = `${item.x - mw}px`; + el.style.width = `${item.width + mw}px`; } /** From c1f616e45c482cc0c283f9d24c9165bcca1608ec Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:47:45 -0700 Subject: [PATCH 21/43] chore: add initial scaffold for labs release check orchestration (#2919) * chore: add initial scaffold for labs release check orchestration * chore: tweak gha --- .../release-qualification-dispatch.yml | 124 ++++++++++++++++++ cicd.md | 36 +++-- 2 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release-qualification-dispatch.yml diff --git a/.github/workflows/release-qualification-dispatch.yml b/.github/workflows/release-qualification-dispatch.yml new file mode 100644 index 0000000000..309cd81b2f --- /dev/null +++ b/.github/workflows/release-qualification-dispatch.yml @@ -0,0 +1,124 @@ +name: 🧪 Dispatch Release Qualification + +on: + pull_request: + branches: + - stable + types: + - opened + - ready_for_review + - reopened + - synchronize + +permissions: + contents: read + pull-requests: read + +concurrency: + group: release-qualification-dispatch-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dispatch-release-qualification: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Build dispatch payload + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + MERGE_PREPARATION_STATUS="unknown" + if [[ "${PR_TITLE}" == *"conflicts need resolution"* ]]; then + MERGE_PREPARATION_STATUS="conflicts" + fi + + cat < release-qualification-payload.json + { + "repositoryOwner": "${{ github.repository_owner }}", + "repositoryName": "${GITHUB_REPOSITORY#*/}", + "repositoryFullName": "${{ github.repository }}", + "pullRequestNumber": ${{ github.event.pull_request.number }}, + "pullRequestUrl": "${{ github.event.pull_request.html_url }}", + "baseRef": "${{ github.event.pull_request.base.ref }}", + "headRef": "${{ github.event.pull_request.head.ref }}", + "headSha": "${{ github.event.pull_request.head.sha }}", + "mergePreparationStatus": "${MERGE_PREPARATION_STATUS}", + "triggerEvent": "${{ github.event.action }}" + } + EOF + + - name: Dispatch to Labs release orchestrator + id: dispatch + env: + LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }} + LABS_RELEASE_QUALIFICATION_URL: ${{ vars.LABS_RELEASE_QUALIFICATION_URL }} + run: | + set -euo pipefail + + if [[ -z "${LABS_RELEASE_QUALIFICATION_URL}" ]]; then + echo "LABS_RELEASE_QUALIFICATION_URL is required." >&2 + exit 1 + fi + + if [[ -z "${LABS_RELEASE_QUALIFICATION_TOKEN}" ]]; then + echo "LABS_RELEASE_QUALIFICATION_TOKEN is required." >&2 + exit 1 + fi + + RESPONSE_FILE="$(mktemp)" + set +e + HTTP_STATUS="$(curl \ + --fail-with-body \ + --silent \ + --show-error \ + --output "${RESPONSE_FILE}" \ + --write-out '%{http_code}' \ + -X POST \ + -H 'content-type: application/json' \ + -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \ + --data @release-qualification-payload.json \ + "${LABS_RELEASE_QUALIFICATION_URL}")" + CURL_EXIT=$? + set -e + + if [[ "${CURL_EXIT}" -ne 0 ]]; then + cat "${RESPONSE_FILE}" + exit "${CURL_EXIT}" + fi + + if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then + cat "${RESPONSE_FILE}" + exit 1 + fi + + RUN_ID="$(jq -r '.run.runId // empty' "${RESPONSE_FILE}")" + RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")" + CREATED="$(jq -r '.created // false' "${RESPONSE_FILE}")" + + if [[ -z "${RUN_ID}" || -z "${RUN_STATUS}" ]]; then + cat "${RESPONSE_FILE}" + echo "Labs response did not include the expected run metadata." >&2 + exit 1 + fi + + echo "run_id=${RUN_ID}" >> "${GITHUB_OUTPUT}" + echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}" + echo "created=${CREATED}" >> "${GITHUB_OUTPUT}" + + - name: Write workflow summary + run: | + { + echo "### Release Qualification Dispatch" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| PR | #${{ github.event.pull_request.number }} |" + echo "| Base branch | \`${{ github.event.pull_request.base.ref }}\` |" + echo "| Head branch | \`${{ github.event.pull_request.head.ref }}\` |" + echo "| Head SHA | \`${{ github.event.pull_request.head.sha }}\` |" + echo "| Labs run | \`${{ steps.dispatch.outputs.run_id }}\` |" + echo "| Labs status | \`${{ steps.dispatch.outputs.run_status }}\` |" + echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/cicd.md b/cicd.md index 1e13844416..fdec60dfbd 100644 --- a/cicd.md +++ b/cicd.md @@ -81,7 +81,25 @@ main (next) → stable (latest) → X.x (maintenance) - If the merge conflicts, commits the conflicted merge to the branch so a human can resolve it there - Merging that PR triggers the automatic stable release workflow -#### 4. Create Patch Branch (`create-patch.yml`) +#### 4. Release Qualification Dispatch (`release-qualification-dispatch.yml`) + +**Trigger**: Pull requests targeting `stable` (`opened`, `reopened`, `synchronize`, `ready_for_review`) + +**Actions**: + +- Sends the PR head SHA and branch metadata to the Labs release-orchestrator service +- Lets Labs create a generic GitHub check run on that SHA +- Does not wait on or expose any private Labs details in the repository +- Re-triggers automatically when new commits are pushed to the PR branch + +Only same-repository PRs dispatch to Labs. Forked PRs are intentionally skipped so private Labs credentials are never exposed to untrusted branches. + +**Required configuration**: + +- variable: `LABS_RELEASE_QUALIFICATION_URL` +- secret: `LABS_RELEASE_QUALIFICATION_TOKEN` + +#### 5. Create Patch Branch (`create-patch.yml`) **Trigger**: Manual workflow dispatch @@ -92,7 +110,7 @@ main (next) → stable (latest) → X.x (maintenance) - Creates `X.x` branch from last stable tag - Enables patching of old versions -#### 5. Forward Port (`forward-port.yml`) +#### 6. Forward Port (`forward-port.yml`) **Triggers**: @@ -107,7 +125,7 @@ main (next) → stable (latest) → X.x (maintenance) ### Support Workflows -#### 6. Test Suite (`test-suite.yml`) +#### 7. Test Suite (`test-suite.yml`) **Type**: Reusable workflow @@ -118,7 +136,7 @@ main (next) → stable (latest) → X.x (maintenance) - Visual regression tests (Playwright) - E2E tests (external service) -#### 7. Visual Tests (`test-example-apps.yml`) +#### 8. Visual Tests (`test-example-apps.yml`) **Triggers**: @@ -211,10 +229,12 @@ These skip semantic-release entirely — useful for re-publishing a failed platf 1. Run "Promote to Stable" workflow 2. Review the generated PR from the candidate branch into `stable` -3. If needed, resolve merge conflicts on the candidate branch -4. Merge the PR into `stable` -5. Automatically publishes `1.1.0` as @latest -6. Syncs back to main with version bump +3. Labs receives the PR head SHA and creates the `Release Qualification` GitHub check run +4. If needed, resolve merge conflicts on the candidate branch and push fixes +5. Re-run or wait for qualification on the new PR head SHA +6. Merge the PR into `stable` +7. Automatically publishes `1.1.0` as @latest +8. Syncs back to main with version bump ### Scenario 3: Hotfix to Current Stable From 5730b6cb74d10e9606c35a66ddb1f6df4d4e5389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Wed, 22 Apr 2026 22:38:59 -0300 Subject: [PATCH 22/43] [3/16] refactor(layout): pre-compute SDT container keys in resolved layout (#2812) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout (#2813) --- .../contracts/src/resolved-layout.ts | 13 + .../src/paragraphBorderHash.ts | 33 + .../layout-resolved/src/resolveLayout.test.ts | 578 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 64 +- .../layout-resolved/src/sdtContainerKey.ts | 40 ++ .../paragraph-borders/group-analysis.ts | 46 +- .../painters/dom/src/renderer.ts | 40 +- packages/react/package.json | 1 + 8 files changed, 801 insertions(+), 14 deletions(-) create mode 100644 packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts create mode 100644 packages/layout-engine/layout-resolved/src/sdtContainerKey.ts diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 7d28407900..ca615e563b 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -6,6 +6,7 @@ import type { ImageFragmentMetadata, Line, PageMargins, + ParagraphBorders, SectionVerticalAlign, TableBlock, TableMeasure, @@ -118,6 +119,12 @@ export type ResolvedFragmentItem = { markerWidth?: number; /** Pre-resolved paragraph content for non-table paragraph fragments. */ content?: ResolvedParagraphContent; + /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ + sdtContainerKey?: string | null; + /** Pre-computed hash of paragraph borders for between-border grouping. */ + paragraphBorderHash?: string; + /** Pre-extracted paragraph borders for between-border rendering. */ + paragraphBorders?: ParagraphBorders; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ @@ -232,6 +239,8 @@ export type ResolvedTableItem = { cellSpacingPx: number; /** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */ effectiveColumnWidths: number[]; + /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ + sdtContainerKey?: string | null; }; /** @@ -268,6 +277,8 @@ export type ResolvedImageItem = { block: ImageBlock; /** Image metadata for interactive resizing (original dimensions, aspect ratio). */ metadata?: ImageFragmentMetadata; + /** Pre-computed SDT container key for boundary grouping (typically null for images). */ + sdtContainerKey?: string | null; }; /** @@ -302,6 +313,8 @@ export type ResolvedDrawingItem = { pmEnd?: number; /** Pre-extracted DrawingBlock (replaces blockLookup.get()). */ block: DrawingBlock; + /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */ + sdtContainerKey?: string | null; }; /** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */ diff --git a/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts new file mode 100644 index 0000000000..b49022cc69 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts @@ -0,0 +1,33 @@ +import type { ParagraphBorder, ParagraphBorders } from '@superdoc/contracts'; + +/** + * Hashes a single paragraph border for equality comparison. + * + * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a + * circular dependency (painter-dom → layout-resolved is not allowed). + * Keep the two copies in sync. + */ +const hashParagraphBorder = (border: ParagraphBorder): string => { + const parts: string[] = []; + if (border.style !== undefined) parts.push(`s:${border.style}`); + if (border.width !== undefined) parts.push(`w:${border.width}`); + if (border.color !== undefined) parts.push(`c:${border.color}`); + if (border.space !== undefined) parts.push(`sp:${border.space}`); + return parts.join(','); +}; + +/** + * Hashes a full paragraph borders object for grouping comparison. + * + * Two paragraph fragments with the same hash belong to the same border group + * per ECMA-376 §17.3.1.24. + */ +export const hashParagraphBorders = (borders: ParagraphBorders): string => { + const parts: string[] = []; + if (borders.top) parts.push(`t:[${hashParagraphBorder(borders.top)}]`); + if (borders.right) parts.push(`r:[${hashParagraphBorder(borders.right)}]`); + if (borders.bottom) parts.push(`b:[${hashParagraphBorder(borders.bottom)}]`); + if (borders.left) parts.push(`l:[${hashParagraphBorder(borders.left)}]`); + if (borders.between) parts.push(`bw:[${hashParagraphBorder(borders.between)}]`); + return parts.join(';'); +}; diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 2e935e82a3..5921c55e7e 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2032,4 +2032,582 @@ describe('resolveLayout', () => { expect(result.layoutEpoch).toBeUndefined(); }); }); + describe('sdtContainerKey resolution', () => { + it('sets sdtContainerKey for a paragraph with block structuredContent sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('structuredContent:sdt-1'); + }); + + it('sets sdtContainerKey for a paragraph with documentSection sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection', id: 'sec-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:sec-1'); + }); + + it('uses sdBlockId for documentSection when id is absent', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection', sdBlockId: 'blk-99' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:blk-99'); + }); + + it('falls back to containerSdt when primary sdt has no container config', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { + sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' }, + containerSdt: { type: 'documentSection', id: 'sec-2' }, + }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:sec-2'); + }); + + it('returns null (omits sdtContainerKey) for inline structuredContent scope', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('omits sdtContainerKey when paragraph has no sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('sets sdtContainerKey for a list-item fragment from its item paragraph sdt', () => { + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 0, + toLine: 1, + x: 108, + y: 200, + width: 432, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { + kind: 'paragraph', + id: 'item-a-p', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'list-sdt-1' } }, + }, + }, + ], + }, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 }, + ], + totalHeight: 24, + }, + }, + ], + totalHeight: 24, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('structuredContent:list-sdt-1'); + }); + + it('sets sdtContainerKey for a table fragment with sdt', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 30, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { + kind: 'table' as const, + id: 'tbl1', + rows: [], + attrs: { sdt: { type: 'documentSection' as const, id: 'tbl-sec-1' } }, + }; + const tableMeasure = { + kind: 'table' as const, + rows: [], + columnWidths: [], + totalWidth: 0, + totalHeight: 0, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.sdtContainerKey).toBe('documentSection:tbl-sec-1'); + }); + + it('omits sdtContainerKey for image and drawing fragments', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + }; + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment, drawingFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [imageBlock, drawingBlock as any], + measures: [ + { kind: 'image', width: 300, height: 250 }, + { kind: 'drawing', width: 200, height: 150 }, + ], + }); + const imgItem = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + const drItem = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(imgItem.sdtContainerKey).toBeUndefined(); + expect(drItem.sdtContainerKey).toBeUndefined(); + }); + + it('returns null (omits key) for structuredContent block scope with no id', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + }); + + describe('paragraphBorders pre-computation', () => { + it('populates paragraphBorders and paragraphBorderHash for a paragraph with borders', () => { + const borders = { + top: { style: 'solid' as const, width: 4, color: '#000000' }, + bottom: { style: 'solid' as const, width: 4, color: '#000000' }, + left: { style: 'solid' as const, width: 4, color: '#000000' }, + right: { style: 'solid' as const, width: 4, color: '#000000' }, + between: { style: 'solid' as const, width: 4, color: '#000000' }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toEqual(borders); + expect(item.paragraphBorderHash).toBeDefined(); + expect(typeof item.paragraphBorderHash).toBe('string'); + expect(item.paragraphBorderHash!.length).toBeGreaterThan(0); + }); + + it('omits paragraphBorders and paragraphBorderHash when paragraph has no borders', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toBeUndefined(); + expect(item.paragraphBorderHash).toBeUndefined(); + }); + + it('produces matching hashes for identical border definitions', () => { + const borders = { + top: { style: 'solid' as const, width: 4, color: '#000000' }, + bottom: { style: 'solid' as const, width: 4, color: '#000000' }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }, + { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 }, + ], + }, + ], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { borders: { ...borders } } }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item0.paragraphBorderHash).toBe(item1.paragraphBorderHash); + }); + + it('produces different hashes for different border definitions', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }, + { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 }, + ], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { borders: { top: { style: 'solid' as const, width: 4, color: '#000000' } } }, + }, + { + kind: 'paragraph', + id: 'p2', + runs: [], + attrs: { borders: { top: { style: 'dashed' as const, width: 2, color: '#FF0000' } } }, + }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item0.paragraphBorderHash).not.toBe(item1.paragraphBorderHash); + }); + + it('populates paragraphBorders for list-item fragments', () => { + const borders = { + top: { style: 'solid' as const, width: 2, color: '#0000FF' }, + between: { style: 'solid' as const, width: 1, color: '#0000FF' }, + }; + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [], attrs: { borders } }, + }, + ], + }, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }, + }, + ], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toEqual(borders); + expect(item.paragraphBorderHash).toBeDefined(); + }); + + it('does not add paragraphBorders to table items', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 100, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any]; + const measures: Measure[] = [ + { + kind: 'table', + columnWidths: [468], + rows: [{ cells: [{ width: 468, height: 100 }] }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.paragraphBorders).toBeUndefined(); + expect(item.paragraphBorderHash).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 3f1d19d4de..de5666c2f9 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -10,12 +10,14 @@ import type { ParaFragment, TableFragment, Line, + ParagraphBorders, ResolvedLayout, ResolvedPage, ResolvedPaintItem, ResolvedFragmentItem, ResolvedParagraphContent, ListMeasure, + ListBlock, ParagraphBlock, ParagraphMeasure, } from '@superdoc/contracts'; @@ -24,6 +26,8 @@ import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; +import { computeSdtContainerKey } from './sdtContainerKey.js'; +import { hashParagraphBorders } from './paragraphBorderHash.js'; export type ResolveLayoutInput = { layout: Layout; @@ -125,16 +129,64 @@ function resolveParagraphContentIfApplicable( return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure); } +function resolveFragmentParagraphBorders( + fragment: Fragment, + blockMap: Map, +): ParagraphBorders | undefined { + const entry = blockMap.get(fragment.blockId); + if (!entry) return undefined; + + if (fragment.kind === 'para' && entry.block.kind === 'paragraph') { + return (entry.block as ParagraphBlock).attrs?.borders; + } + + if (fragment.kind === 'list-item' && entry.block.kind === 'list') { + const block = entry.block as ListBlock; + const item = block.items.find((listItem) => listItem.id === fragment.itemId); + return item?.paragraph.attrs?.borders; + } + + return undefined; +} + +function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map): string | null { + const entry = blockMap.get(fragment.blockId); + if (!entry) return null; + const block = entry.block; + + if (fragment.kind === 'para' && block.kind === 'paragraph') { + return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + } + + if (fragment.kind === 'list-item' && block.kind === 'list') { + const listBlock = block as ListBlock; + const item = listBlock.items.find((listItem) => listItem.id === fragment.itemId); + return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + } + + if (fragment.kind === 'table' && block.kind === 'table') { + return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + } + + // image, drawing — no SDT container keys + return null; +} + function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number, blockMap: Map, ): ResolvedPaintItem { + const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); + // Route to kind-specific resolvers for types that carry extracted block/measure data. switch (fragment.kind) { - case 'table': - return resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); + case 'table': { + const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + return item; + } case 'image': return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); case 'drawing': @@ -155,6 +207,14 @@ function resolveFragmentItem( fragmentIndex, content: resolveParagraphContentIfApplicable(fragment, blockMap), }; + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + + // Pre-compute paragraph border data for between-border grouping + const borders = resolveFragmentParagraphBorders(fragment, blockMap); + if (borders) { + item.paragraphBorders = borders; + item.paragraphBorderHash = hashParagraphBorders(borders); + } if (fragment.kind === 'para') { const para = fragment as ParaFragment; if (para.pmStart != null) item.pmStart = para.pmStart; diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts new file mode 100644 index 0000000000..4cee08673f --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts @@ -0,0 +1,40 @@ +import type { SdtMetadata } from '@superdoc/contracts'; + +/** + * Returns a stable key for grouping consecutive fragments in the same SDT container. + * + * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` + * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. + * Only the key derivation is needed; DOM styling helpers are not. + */ +export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (!metadata.id) return null; + return `structuredContent:${metadata.id}`; + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (!sectionId) return null; + return `documentSection:${sectionId}`; + } + + return null; +} + +function isSdtContainer(sdt?: SdtMetadata | null): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { + if (isSdtContainer(sdt)) return sdt ?? null; + if (isSdtContainer(containerSdt)) return containerSdt ?? null; + return null; +} diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts index d2996105ef..c225a92810 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts @@ -15,6 +15,8 @@ import type { ListMeasure, ParagraphBlock, ParagraphAttrs, + ResolvedPaintItem, + ResolvedFragmentItem, } from '@superdoc/contracts'; import type { BlockLookup } from './types.js'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; @@ -124,9 +126,23 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => { * * Middle fragments in a chain of 3+ get both flags. */ + +/** + * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) + * with pre-computed paragraph border data. + */ +function isResolvedFragmentWithBorders( + item: ResolvedPaintItem | undefined, +): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { + return ( + item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined + ); +} + export const computeBetweenBorderFlags = ( fragments: readonly Fragment[], blockLookup: BlockLookup, + resolvedItems?: readonly ResolvedPaintItem[], ): Map => { // Phase 1: determine which consecutive pairs form between-border groups const pairFlags = new Set(); @@ -137,7 +153,10 @@ export const computeBetweenBorderFlags = ( if (frag.kind !== 'para' && frag.kind !== 'list-item') continue; if (frag.continuesOnNext) continue; - const borders = getFragmentParagraphBorders(frag, blockLookup); + const resolvedCur = resolvedItems?.[i]; + const borders = isResolvedFragmentWithBorders(resolvedCur) + ? resolvedCur.paragraphBorders + : getFragmentParagraphBorders(frag, blockLookup); if (!borders) continue; const next = fragments[i + 1]; @@ -152,9 +171,24 @@ export const computeBetweenBorderFlags = ( ) continue; - const nextBorders = getFragmentParagraphBorders(next, blockLookup); + const resolvedNext = resolvedItems?.[i + 1]; + const nextBorders = isResolvedFragmentWithBorders(resolvedNext) + ? resolvedNext.paragraphBorders + : getFragmentParagraphBorders(next, blockLookup); if (!nextBorders) continue; - if (hashParagraphBorders(borders) !== hashParagraphBorders(nextBorders)) continue; + + // Compare using pre-computed hashes when available, falling back to computing on-the-fly. + const curHash = + resolvedCur && 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash + ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash! + : hashParagraphBorders(borders); + const nextHash = + resolvedNext && + 'paragraphBorderHash' in resolvedNext && + (resolvedNext as ResolvedFragmentItem).paragraphBorderHash + ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash! + : hashParagraphBorders(nextBorders); + if (curHash !== nextHash) continue; // Skip fragments in different columns (different x positions) if (frag.x !== next.x) continue; @@ -175,7 +209,11 @@ export const computeBetweenBorderFlags = ( for (const i of pairFlags) { const frag = fragments[i]; const next = fragments[i + 1]; - const fragHeight = getFragmentHeight(frag, blockLookup); + const resolvedCur = resolvedItems?.[i]; + const fragHeight = + resolvedCur && 'height' in resolvedCur && resolvedCur.height != null + ? resolvedCur.height + : getFragmentHeight(frag, blockLookup); const gapBelow = Math.max(0, next.y - (frag.y + fragHeight)); const isNoBetween = noBetweenPairs.has(i); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 356d29ef88..6254d066b2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2225,8 +2225,13 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); page.fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -2753,8 +2758,13 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -2916,8 +2926,13 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index); @@ -7237,9 +7252,18 @@ const computeSdtBoundaries = ( fragments: readonly Fragment[], blockLookup: BlockLookup, sdtLabelsRendered: Set, + resolvedItems?: readonly ResolvedPaintItem[], ): Map => { const boundaries = new Map(); - const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); + const containerKeys: (string | null)[] = resolvedItems + ? resolvedItems.map((item) => { + if ('sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }) + : fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); let i = 0; while (i < fragments.length) { @@ -7268,7 +7292,7 @@ const computeSdtBoundaries = ( let paddingBottomOverride: number | undefined; if (!isEnd) { const nextFragment = fragments[k + 1]; - const currentHeight = getFragmentHeight(fragment, blockLookup); + const currentHeight = resolvedItems?.[k]?.height ?? getFragmentHeight(fragment, blockLookup); const currentBottom = fragment.y + currentHeight; const gapToNext = nextFragment.y - currentBottom; if (gapToNext > 0) { diff --git a/packages/react/package.json b/packages/react/package.json index 5acbc31285..b1263315ef 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -21,6 +21,7 @@ "build": "vite build", "dev": "vite build --watch", "test": "vitest run", + "pretype-check": "node ../../apps/cli/scripts/ensure-superdoc-build.js --types", "type-check": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx", "prepublishOnly": "pnpm run build" From ffb8b4581e4191a31ea81b7ff95bbf5b4ab625f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Wed, 22 Apr 2026 23:28:02 -0300 Subject: [PATCH 23/43] [5/16] refactor(layout): move change detection into resolved layout stage (#2814) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * fix: avoid duplicate block version hashing in DomPainter --- .../contracts/src/resolved-layout.ts | 10 + .../layout-resolved/src/hashUtils.ts | 116 ++++ .../layout-resolved/src/resolveLayout.test.ts | 347 ++++++++++++ .../layout-resolved/src/resolveLayout.ts | 47 +- .../layout-resolved/src/versionSignature.ts | 535 ++++++++++++++++++ .../painters/dom/src/index.test.ts | 69 +++ .../painters/dom/src/renderer.ts | 37 +- 7 files changed, 1147 insertions(+), 14 deletions(-) create mode 100644 packages/layout-engine/layout-resolved/src/hashUtils.ts create mode 100644 packages/layout-engine/layout-resolved/src/versionSignature.ts diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index ca615e563b..4701ec17a2 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -20,6 +20,8 @@ export type ResolvedLayout = { flowMode: FlowMode; /** Gap between pages in pixels (0 when unset). */ pageGap: number; + /** Pre-computed block versions for painter-side cache invalidation. */ + blockVersions?: Record; /** Resolved pages with normalized dimensions. */ pages: ResolvedPage[]; /** Document epoch identifier from the source layout. Used for change tracking in the painter. */ @@ -125,6 +127,8 @@ export type ResolvedFragmentItem = { paragraphBorderHash?: string; /** Pre-extracted paragraph borders for between-border rendering. */ paragraphBorders?: ParagraphBorders; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ @@ -241,6 +245,8 @@ export type ResolvedTableItem = { effectiveColumnWidths: number[]; /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** @@ -279,6 +285,8 @@ export type ResolvedImageItem = { metadata?: ImageFragmentMetadata; /** Pre-computed SDT container key for boundary grouping (typically null for images). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** @@ -315,6 +323,8 @@ export type ResolvedDrawingItem = { block: DrawingBlock; /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */ diff --git a/packages/layout-engine/layout-resolved/src/hashUtils.ts b/packages/layout-engine/layout-resolved/src/hashUtils.ts new file mode 100644 index 0000000000..ff2b4c38ad --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/hashUtils.ts @@ -0,0 +1,116 @@ +import type { BorderSpec, CellBorders, Run, TableBorders, TableBorderValue } from '@superdoc/contracts'; + +/** + * Hash helpers for block version computation. + * + * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a circular + * dependency (painter-dom -> layout-resolved is not allowed). Keep the two + * copies in sync. + */ + +// --------------------------------------------------------------------------- +// Table/Cell border hashing +// --------------------------------------------------------------------------- + +const isNoneBorder = (value: TableBorderValue): value is { none: true } => { + return typeof value === 'object' && value !== null && 'none' in value && (value as { none: true }).none === true; +}; + +const isBorderSpec = (value: unknown): value is BorderSpec => { + return typeof value === 'object' && value !== null && !('none' in value); +}; + +export const hashBorderSpec = (border: BorderSpec): string => { + const parts: string[] = []; + if (border.style !== undefined) parts.push(`s:${border.style}`); + if (border.width !== undefined) parts.push(`w:${border.width}`); + if (border.color !== undefined) parts.push(`c:${border.color}`); + if (border.space !== undefined) parts.push(`sp:${border.space}`); + return parts.join(','); +}; + +const hashTableBorderValue = (borderValue: TableBorderValue | undefined): string => { + if (borderValue === undefined) return ''; + if (borderValue === null) return 'null'; + if (isNoneBorder(borderValue)) return 'none'; + if (isBorderSpec(borderValue)) { + return hashBorderSpec(borderValue); + } + return ''; +}; + +export const hashTableBorders = (borders: TableBorders | undefined): string => { + if (!borders) return ''; + const parts: string[] = []; + if (borders.top !== undefined) parts.push(`t:[${hashTableBorderValue(borders.top)}]`); + if (borders.right !== undefined) parts.push(`r:[${hashTableBorderValue(borders.right)}]`); + if (borders.bottom !== undefined) parts.push(`b:[${hashTableBorderValue(borders.bottom)}]`); + if (borders.left !== undefined) parts.push(`l:[${hashTableBorderValue(borders.left)}]`); + if (borders.insideH !== undefined) parts.push(`ih:[${hashTableBorderValue(borders.insideH)}]`); + if (borders.insideV !== undefined) parts.push(`iv:[${hashTableBorderValue(borders.insideV)}]`); + return parts.join(';'); +}; + +export const hashCellBorders = (borders: CellBorders | undefined): string => { + if (!borders) return ''; + const parts: string[] = []; + if (borders.top) parts.push(`t:[${hashBorderSpec(borders.top)}]`); + if (borders.right) parts.push(`r:[${hashBorderSpec(borders.right)}]`); + if (borders.bottom) parts.push(`b:[${hashBorderSpec(borders.bottom)}]`); + if (borders.left) parts.push(`l:[${hashBorderSpec(borders.left)}]`); + return parts.join(';'); +}; + +// --------------------------------------------------------------------------- +// Run property accessors +// --------------------------------------------------------------------------- + +const hasStringProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'string'; +}; + +const hasNumberProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'number'; +}; + +const hasBooleanProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'boolean'; +}; + +export const getRunStringProp = (run: Run, prop: string): string => { + if (hasStringProp(run, prop)) { + return run[prop]; + } + return ''; +}; + +export const getRunNumberProp = (run: Run, prop: string): number => { + if (hasNumberProp(run, prop)) { + return run[prop]; + } + return 0; +}; + +export const getRunBooleanProp = (run: Run, prop: string): boolean => { + if (hasBooleanProp(run, prop)) { + return run[prop]; + } + return false; +}; + +export const getRunUnderlineStyle = (run: Run): string => { + if ('underline' in run && typeof run.underline === 'boolean') { + return run.underline ? 'single' : ''; + } + if ('underline' in run && run.underline && typeof run.underline === 'object') { + return (run.underline as { style?: string }).style ?? ''; + } + return ''; +}; + +export const getRunUnderlineColor = (run: Run): string => { + if ('underline' in run && run.underline && typeof run.underline === 'object') { + return (run.underline as { color?: string }).color ?? ''; + } + return ''; +}; diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 5921c55e7e..2f1b21d7d4 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -90,6 +90,33 @@ describe('resolveLayout', () => { expect(a).toEqual(b); }); + it('includes precomputed block versions for every supplied block', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 0, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'visible', fontFamily: 'Arial', fontSize: 12 }] } as any, + { kind: 'paragraph', id: 'p2', runs: [{ text: 'lookup-only', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [ + { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any, + { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + + expect(result.blockVersions).toBeDefined(); + expect(result.blockVersions).toHaveProperty('p1'); + expect(result.blockVersions).toHaveProperty('p2'); + expect(result.blockVersions?.p1).not.toBe(result.blockVersions?.p2); + }); + it('defaults pageGap to 0 when layout.pageGap is undefined', () => { const result = resolveLayout({ layout: baseLayout, flowMode: 'paginated', blocks: [], measures: [] }); expect(result.pageGap).toBe(0); @@ -2610,4 +2637,324 @@ describe('resolveLayout', () => { expect(item.paragraphBorderHash).toBeUndefined(); }); }); + + describe('version signature', () => { + it('sets version on paragraph fragment items', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + expect(item.version.length).toBeGreaterThan(0); + }); + + it('sets version on table fragment items', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 0, + width: 468, + height: 100, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any]; + const measures: Measure[] = [ + { + kind: 'table', + columnWidths: [468], + rows: [{ cells: [{ width: 468, height: 100 }] }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on image fragment items', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 72, + y: 0, + width: 200, + height: 150, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'image', id: 'img1', src: 'test.png', width: 200, height: 150 } as any]; + const measures: Measure[] = [{ kind: 'image' } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on drawing fragment items', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'dr1', + drawingKind: 'image', + x: 72, + y: 0, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + drawingKind: 'image', + id: 'dr1', + src: 'test.png', + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + } as any, + ]; + const measures: Measure[] = [{ kind: 'drawing' } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on list-item fragment items', () => { + const listFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + items: [ + { + id: 'item1', + marker: { text: '1.' }, + paragraph: { + kind: 'paragraph', + id: 'p-item1', + runs: [{ text: 'item', fontFamily: 'Arial', fontSize: 12 }], + }, + }, + ], + } as any, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [{ itemId: 'item1', paragraph: { kind: 'paragraph', lines: [{ lineHeight: 20 }] } }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('produces different versions when block content changes', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const blocks1: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const blocks2: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'world', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + + const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks1, measures }); + const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks2, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).not.toBe(ver2); + }); + + it('produces same version for identical inputs', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).toBe(ver2); + }); + + it('produces different versions when fragment line range changes', () => { + const fragment1: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const fragment2: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 2, + x: 72, + y: 0, + width: 468, + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any]; + + const layout1: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [fragment1] }], + }; + const layout2: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [fragment2] }], + }; + + const result1 = resolveLayout({ layout: layout1, flowMode: 'paginated', blocks, measures }); + const result2 = resolveLayout({ layout: layout2, flowMode: 'paginated', blocks, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).not.toBe(ver2); + }); + + it('caches block version across fragments sharing the same block', () => { + const frag1: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const frag2: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 1, + toLine: 2, + x: 72, + y: 20, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [frag1, frag2] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello world', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const ver1 = (result.pages[0].items[0] as any).version; + const ver2 = (result.pages[0].items[1] as any).version; + + // Both versions should be defined + expect(ver1).toBeDefined(); + expect(ver2).toBeDefined(); + // They should differ (different line ranges) + expect(ver1).not.toBe(ver2); + // But both share the same block version prefix + const prefix1 = ver1.split('|')[0]; + const prefix2 = ver2.split('|')[0]; + expect(prefix1).toBe(prefix2); + }); + + it('uses "missing" for fragments with no matching block', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'nonexistent', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(item.version).toContain('missing'); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index de5666c2f9..7e6b56abac 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -28,6 +28,7 @@ import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; +import { deriveBlockVersion, fragmentSignature } from './versionSignature.js'; export type ResolveLayoutInput = { layout: Layout; @@ -172,25 +173,53 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map, + cache: Map, +): string { + const cached = cache.get(blockId); + if (cached !== undefined) return cached; + const entry = blockMap.get(blockId); + if (!entry) { + cache.set(blockId, 'missing'); + return 'missing'; + } + const version = deriveBlockVersion(entry.block); + cache.set(blockId, version); + return version; +} function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number, blockMap: Map, + blockVersionCache: Map, ): ResolvedPaintItem { const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); + const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache); + const version = fragmentSignature(fragment, blockVer); // Route to kind-specific resolvers for types that carry extracted block/measure data. switch (fragment.kind) { case 'table': { const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; + return item; + } + case 'image': { + const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; + return item; + } + case 'drawing': { + const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; return item; } - case 'image': - return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); - case 'drawing': - return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); default: { // para, list-item — existing generic resolution const item: ResolvedFragmentItem = { @@ -228,6 +257,7 @@ function resolveFragmentItem( if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext; if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth; } + item.version = version; return item; } } @@ -236,6 +266,7 @@ function resolveFragmentItem( export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { const { layout, flowMode, blocks, measures } = input; const blockMap = buildBlockMap(blocks, measures); + const blockVersionCache = new Map(); const pages: ResolvedPage[] = layout.pages.map((page, pageIndex) => ({ id: `page-${pageIndex}`, @@ -244,7 +275,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { width: page.size?.w ?? layout.pageSize.w, height: page.size?.h ?? layout.pageSize.h, items: page.fragments.map((fragment, fragmentIndex) => - resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap), + resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache), ), margins: page.margins, footnoteReserved: page.footnoteReserved, @@ -263,6 +294,12 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { pages, }; + if (blocks.length > 0) { + resolved.blockVersions = Object.fromEntries( + blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]), + ); + } + if (layout.layoutEpoch != null) { resolved.layoutEpoch = layout.layoutEpoch; } diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts new file mode 100644 index 0000000000..8b2b15bb15 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -0,0 +1,535 @@ +import type { + DrawingBlock, + FieldAnnotationRun, + FlowBlock, + Fragment, + ImageBlock, + ImageDrawing, + ImageRun, + ParagraphAttrs, + ParagraphBlock, + SdtMetadata, + ShapeGroupDrawing, + TableAttrs, + TableBlock, + TableCellAttrs, + TextRun, + VectorShapeDrawing, +} from '@superdoc/contracts'; +import { hashParagraphBorders } from './paragraphBorderHash.js'; +import { + hashCellBorders, + hashTableBorders, + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from './hashUtils.js'; + +// --------------------------------------------------------------------------- +// SDT metadata helpers +// --------------------------------------------------------------------------- + +const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + if ('id' in metadata && metadata.id != null) { + return String(metadata.id); + } + return ''; +}; + +const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; +}; + +const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); +}; + +// --------------------------------------------------------------------------- +// Clip path helpers +// --------------------------------------------------------------------------- + +const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +const readClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readClipPathValue(record.clipPath); +}; + +const resolveBlockClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + +// --------------------------------------------------------------------------- +// List marker validation +// --------------------------------------------------------------------------- + +const hasListMarkerProperties = ( + attrs: unknown, +): attrs is { + numberingProperties: { numId?: number | string; ilvl?: number }; + wordLayout?: { marker?: { markerText?: string } }; +} => { + if (!attrs || typeof attrs !== 'object') return false; + const obj = attrs as Record; + + if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; + const numProps = obj.numberingProperties as Record; + + if ('numId' in numProps) { + const numId = numProps.numId; + if (typeof numId !== 'number' && typeof numId !== 'string') return false; + } + + if ('ilvl' in numProps) { + const ilvl = numProps.ilvl; + if (typeof ilvl !== 'number') return false; + } + + if ('wordLayout' in obj && obj.wordLayout !== undefined) { + if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; + const wordLayout = obj.wordLayout as Record; + + if ('marker' in wordLayout && wordLayout.marker !== undefined) { + if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; + const marker = wordLayout.marker as Record; + + if ('markerText' in marker && marker.markerText !== undefined) { + if (typeof marker.markerText !== 'string') return false; + } + } + } + + return true; +}; + +// --------------------------------------------------------------------------- +// FNV-1a hash helpers (for table block hashing) +// --------------------------------------------------------------------------- + +const hashString = (seed: number, value: string): number => { + let hash = seed >>> 0; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +}; + +const hashNumber = (seed: number, value: number | undefined | null): number => { + const n = Number.isFinite(value) ? (value as number) : 0; + let hash = seed ^ n; + hash = Math.imul(hash, 16777619); + hash ^= hash >>> 13; + return hash >>> 0; +}; + +// --------------------------------------------------------------------------- +// deriveBlockVersion +// --------------------------------------------------------------------------- + +/** + * Derives a version string for a flow block based on its content and styling properties. + * + * This version string is used for cache invalidation. When any visual property of the block + * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. + * + * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to + * pre-compute block versions without depending on painter-dom. Keep the two copies in sync + * until the painter fully migrates to resolved versions. + */ +export const deriveBlockVersion = (block: FlowBlock): string => { + if (block.kind === 'paragraph') { + const markerVersion = hasListMarkerProperties(block.attrs) + ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` + : ''; + + const runsVersion = block.runs + .map((run) => { + if (run.kind === 'image') { + const imgRun = run as ImageRun; + return [ + 'img', + imgRun.src, + imgRun.width, + imgRun.height, + imgRun.alt ?? '', + imgRun.title ?? '', + imgRun.clipPath ?? '', + imgRun.distTop ?? '', + imgRun.distBottom ?? '', + imgRun.distLeft ?? '', + imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + ].join(','); + } + + if (run.kind === 'lineBreak') { + return 'linebreak'; + } + + if (run.kind === 'tab') { + return [run.text ?? '', 'tab'].join(','); + } + + if (run.kind === 'fieldAnnotation') { + const fieldRun = run as FieldAnnotationRun; + const size = fieldRun.size ? `${fieldRun.size.width ?? ''}x${fieldRun.size.height ?? ''}` : ''; + const highlighted = fieldRun.highlighted !== false ? 1 : 0; + return [ + 'field', + fieldRun.variant ?? '', + fieldRun.displayLabel ?? '', + fieldRun.fieldColor ?? '', + fieldRun.borderColor ?? '', + highlighted, + fieldRun.hidden ? 1 : 0, + fieldRun.visibility ?? '', + fieldRun.imageSrc ?? '', + fieldRun.linkUrl ?? '', + fieldRun.rawHtml ?? '', + size, + fieldRun.fontFamily ?? '', + fieldRun.fontSize ?? '', + fieldRun.textColor ?? '', + fieldRun.textHighlight ?? '', + fieldRun.bold ? 1 : 0, + fieldRun.italic ? 1 : 0, + fieldRun.underline ? 1 : 0, + fieldRun.fieldId ?? '', + fieldRun.fieldType ?? '', + ].join(','); + } + + const textRun = run as TextRun; + return [ + textRun.text ?? '', + textRun.fontFamily, + textRun.fontSize, + textRun.bold ? 1 : 0, + textRun.italic ? 1 : 0, + textRun.color ?? '', + textRun.underline?.style ?? '', + textRun.underline?.color ?? '', + textRun.strike ? 1 : 0, + textRun.highlight ?? '', + textRun.letterSpacing != null ? textRun.letterSpacing : '', + textRun.vertAlign ?? '', + textRun.baselineShift != null ? textRun.baselineShift : '', + textRun.token ?? '', + textRun.trackedChange ? 1 : 0, + textRun.comments?.length ?? 0, + ].join(','); + }) + .join('|'); + + const attrs = block.attrs as ParagraphAttrs | undefined; + + const paragraphAttrsVersion = attrs + ? [ + attrs.alignment ?? '', + attrs.spacing?.before ?? '', + attrs.spacing?.after ?? '', + attrs.spacing?.line ?? '', + attrs.spacing?.lineRule ?? '', + attrs.indent?.left ?? '', + attrs.indent?.right ?? '', + attrs.indent?.firstLine ?? '', + attrs.indent?.hanging ?? '', + attrs.borders ? hashParagraphBorders(attrs.borders) : '', + attrs.shading?.fill ?? '', + attrs.shading?.color ?? '', + attrs.direction ?? '', + attrs.rtl ? '1' : '', + attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', + ].join(':') + : ''; + + const sdtAttrs = (block.attrs as ParagraphAttrs | undefined)?.sdt; + const sdtVersion = getSdtMetadataVersion(sdtAttrs); + + const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); + return parts.join('|'); + } + + if (block.kind === 'list') { + return block.items.map((item) => `${item.id}:${item.marker.text}:${deriveBlockVersion(item.paragraph)}`).join('|'); + } + + if (block.kind === 'image') { + const imgSdt = (block as ImageBlock).attrs?.sdt; + const imgSdtVersion = getSdtMetadataVersion(imgSdt); + return [ + block.src ?? '', + block.width ?? '', + block.height ?? '', + block.alt ?? '', + block.title ?? '', + resolveBlockClipPath(block), + imgSdtVersion, + ].join('|'); + } + + if (block.kind === 'drawing') { + if (block.drawingKind === 'image') { + const imageLike = block as ImageDrawing; + return [ + 'drawing:image', + imageLike.src ?? '', + imageLike.width ?? '', + imageLike.height ?? '', + imageLike.alt ?? '', + resolveBlockClipPath(imageLike), + ].join('|'); + } + if (block.drawingKind === 'vectorShape') { + const vector = block as VectorShapeDrawing; + return [ + 'drawing:vector', + vector.shapeKind ?? '', + vector.fillColor ?? '', + vector.strokeColor ?? '', + vector.strokeWidth ?? '', + vector.geometry.width, + vector.geometry.height, + vector.geometry.rotation ?? 0, + vector.geometry.flipH ? 1 : 0, + vector.geometry.flipV ? 1 : 0, + ].join('|'); + } + if (block.drawingKind === 'shapeGroup') { + const group = block as ShapeGroupDrawing; + const childSignature = group.shapes + .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`) + .join(';'); + return [ + 'drawing:group', + group.geometry.width, + group.geometry.height, + group.groupTransform ? JSON.stringify(group.groupTransform) : '', + childSignature, + ].join('|'); + } + if (block.drawingKind === 'chart') { + return [ + 'drawing:chart', + block.chartData?.chartType ?? '', + block.chartData?.series?.length ?? 0, + block.geometry.width, + block.geometry.height, + block.chartRelId ?? '', + ].join('|'); + } + const _exhaustive: never = block; + return `drawing:unknown:${(block as DrawingBlock).id}`; + } + + if (block.kind === 'table') { + const tableBlock = block as TableBlock; + + let hash = 2166136261; + hash = hashString(hash, block.id); + hash = hashNumber(hash, tableBlock.rows.length); + hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash); + + const rows = tableBlock.rows ?? []; + for (const row of rows) { + if (!row || !Array.isArray(row.cells)) continue; + hash = hashNumber(hash, row.cells.length); + for (const cell of row.cells) { + if (!cell) continue; + const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); + hash = hashNumber(hash, cellBlocks.length); + hash = hashNumber(hash, cell.rowSpan ?? 1); + hash = hashNumber(hash, cell.colSpan ?? 1); + + if (cell.attrs) { + const cellAttrs = cell.attrs as TableCellAttrs; + if (cellAttrs.borders) { + hash = hashString(hash, hashCellBorders(cellAttrs.borders)); + } + if (cellAttrs.padding) { + const p = cellAttrs.padding; + hash = hashNumber(hash, p.top ?? 0); + hash = hashNumber(hash, p.right ?? 0); + hash = hashNumber(hash, p.bottom ?? 0); + hash = hashNumber(hash, p.left ?? 0); + } + if (cellAttrs.verticalAlign) { + hash = hashString(hash, cellAttrs.verticalAlign); + } + if (cellAttrs.background) { + hash = hashString(hash, cellAttrs.background); + } + } + + for (const cellBlock of cellBlocks) { + hash = hashString(hash, cellBlock?.kind ?? 'unknown'); + if (cellBlock?.kind === 'paragraph') { + const paragraphBlock = cellBlock as ParagraphBlock; + const runs = paragraphBlock.runs ?? []; + hash = hashNumber(hash, runs.length); + + const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; + + if (attrs) { + hash = hashString(hash, attrs.alignment ?? ''); + hash = hashNumber(hash, attrs.spacing?.before ?? 0); + hash = hashNumber(hash, attrs.spacing?.after ?? 0); + hash = hashNumber(hash, attrs.spacing?.line ?? 0); + hash = hashString(hash, attrs.spacing?.lineRule ?? ''); + hash = hashNumber(hash, attrs.indent?.left ?? 0); + hash = hashNumber(hash, attrs.indent?.right ?? 0); + hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); + hash = hashNumber(hash, attrs.indent?.hanging ?? 0); + hash = hashString(hash, attrs.shading?.fill ?? ''); + hash = hashString(hash, attrs.shading?.color ?? ''); + hash = hashString(hash, attrs.direction ?? ''); + hash = hashString(hash, attrs.rtl ? '1' : ''); + if (attrs.borders) { + hash = hashString(hash, hashParagraphBorders(attrs.borders)); + } + } + + for (const run of runs) { + if ('text' in run && typeof run.text === 'string') { + hash = hashString(hash, run.text); + } + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + + hash = hashString(hash, getRunStringProp(run, 'color')); + hash = hashString(hash, getRunStringProp(run, 'highlight')); + hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); + hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); + hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); + hash = hashString(hash, getRunStringProp(run, 'fontFamily')); + hash = hashString(hash, getRunUnderlineStyle(run)); + hash = hashString(hash, getRunUnderlineColor(run)); + hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); + hash = hashString(hash, getRunStringProp(run, 'vertAlign')); + hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + } + } + } + } + } + + if (tableBlock.attrs) { + const tblAttrs = tableBlock.attrs as TableAttrs; + if (tblAttrs.borders) { + hash = hashString(hash, hashTableBorders(tblAttrs.borders)); + } + if (tblAttrs.borderCollapse) { + hash = hashString(hash, tblAttrs.borderCollapse); + } + if (tblAttrs.cellSpacing !== undefined) { + const cs = tblAttrs.cellSpacing; + if (typeof cs === 'number') { + hash = hashNumber(hash, cs); + } else { + const v = (cs as { value?: number; type?: string }).value ?? 0; + const t = (cs as { value?: number; type?: string }).type ?? 'px'; + hash = hashString(hash, `cs:${v}:${t}`); + } + } + if (tblAttrs.sdt) { + hash = hashString(hash, tblAttrs.sdt.type); + hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt)); + hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt)); + } + } + + return [block.id, tableBlock.rows.length, hash.toString(16)].join('|'); + } + + return block.id; +}; + +// --------------------------------------------------------------------------- +// fragmentSignature +// --------------------------------------------------------------------------- + +/** + * Computes a change-detection signature for a layout fragment. + * + * Combines the block-level version with fragment-specific data (line range, + * continuation flags, marker width, drawing geometry, table row range, etc.) + * so that each fragment has a unique identity for incremental re-rendering. + * + * Adapted from painters/dom/src/renderer.ts fragmentSignature(). The painter + * version accepts a BlockLookup map; this version takes a pre-computed + * blockVersion string directly. + */ +export const fragmentSignature = (fragment: Fragment, blockVersion: string): string => { + if (fragment.kind === 'para') { + return [ + blockVersion, + fragment.fromLine, + fragment.toLine, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + fragment.markerWidth ?? '', + ].join('|'); + } + if (fragment.kind === 'list-item') { + return [ + blockVersion, + fragment.itemId, + fragment.fromLine, + fragment.toLine, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + ].join('|'); + } + if (fragment.kind === 'image') { + return [blockVersion, fragment.width, fragment.height].join('|'); + } + if (fragment.kind === 'drawing') { + return [ + blockVersion, + fragment.drawingKind, + fragment.drawingContentId ?? '', + fragment.width, + fragment.height, + fragment.geometry.width, + fragment.geometry.height, + fragment.geometry.rotation ?? 0, + fragment.scale ?? 1, + fragment.zIndex ?? '', + ].join('|'); + } + if (fragment.kind === 'table') { + const partialSig = fragment.partialRow + ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}` + : ''; + return [ + blockVersion, + fragment.fromRow, + fragment.toRow, + fragment.width, + fragment.height, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + fragment.repeatHeaderCount ?? 0, + partialSig, + ].join('|'); + } + return blockVersion; +}; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index ecf05ecb8c..fa8d1aa682 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5028,6 +5028,75 @@ describe('DomPainter', () => { expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch); }); + it('uses resolved block versions for block change tracking', () => { + const blockId = 'resolved-version-block'; + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: blockId, + runs: [{ text: 'Stable content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 14 }], + }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 14, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const paragraphLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId, fromLine: 0, toLine: 1, x: 24, y: 24, width: 300, pmStart: 0, pmEnd: 14 }, + ], + }, + ], + }; + + const item = { + kind: 'fragment' as const, + id: `para:${blockId}:0:1`, + pageIndex: 0, + x: 24, + y: 24, + width: 300, + height: 20, + fragmentKind: 'para' as const, + blockId, + fragmentIndex: 0, + version: 'stable-fragment-version', + }; + + const painter = createTestPainter({ blocks: [paragraphBlock], measures: [paragraphMeasure] }); + + painter.setResolvedLayout({ + ...createSinglePageResolvedLayout(item), + blockVersions: { [blockId]: 'resolved-block-version-1' }, + }); + painter.paint(paragraphLayout, mount); + + const initialWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement; + + painter.setResolvedLayout({ + ...createSinglePageResolvedLayout(item), + blockVersions: { [blockId]: 'resolved-block-version-2' }, + }); + painter.paint(paragraphLayout, mount); + + const updatedWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(updatedWrapper).not.toBe(initialWrapper); + }); + it('applies resolved zIndex only to anchored media fragments', () => { const anchoredDrawingBlock: FlowBlock = { kind: 'drawing', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 6254d066b2..5156b74927 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1611,13 +1611,18 @@ export class DomPainter { private updateBlockLookup(input: DomPainterInput): void { const { blocks, measures, headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input; + const resolvedBlockVersions = this.resolvedLayout?.blockVersions; // Build lookup for main document blocks - const nextLookup = this.buildBlockLookup(blocks, measures); + const nextLookup = this.buildBlockLookup(blocks, measures, resolvedBlockVersions); const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); if (normalizedHeader) { - const headerLookup = this.buildBlockLookup(normalizedHeader.blocks, normalizedHeader.measures); + const headerLookup = this.buildBlockLookup( + normalizedHeader.blocks, + normalizedHeader.measures, + resolvedBlockVersions, + ); headerLookup.forEach((entry, id) => { nextLookup.set(id, entry); }); @@ -1625,7 +1630,11 @@ export class DomPainter { const normalizedFooter = this.normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures); if (normalizedFooter) { - const footerLookup = this.buildBlockLookup(normalizedFooter.blocks, normalizedFooter.measures); + const footerLookup = this.buildBlockLookup( + normalizedFooter.blocks, + normalizedFooter.measures, + resolvedBlockVersions, + ); footerLookup.forEach((entry, id) => { nextLookup.set(id, entry); }); @@ -2799,10 +2808,12 @@ export class DomPainter { newPmStart != null && current.element.dataset.pmStart != null && this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; + const resolvedSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; const needsRebuild = geometryChanged || this.changedBlocks.has(fragment.blockId) || - current.signature !== fragmentSignature(fragment, this.blockLookup) || + current.signature !== (resolvedSig ?? fragmentSignature(fragment, this.blockLookup)) || sdtBoundaryMismatch || betweenBorderMismatch || mappingUnreliable; @@ -2811,7 +2822,7 @@ export class DomPainter { const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.replaceChild(replacement, current.element); current.element = replacement; - current.signature = fragmentSignature(fragment, this.blockLookup); + current.signature = resolvedSig ?? fragmentSignature(fragment, this.blockLookup); } else if (this.currentMapping) { // Fragment NOT rebuilt - update position attributes to reflect document changes this.updatePositionAttributes(current.element, this.currentMapping); @@ -2831,11 +2842,13 @@ export class DomPainter { const fresh = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.insertBefore(fresh, pageEl.children[index] ?? null); + const freshSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; nextFragments.push({ key, fragment, element: fresh, - signature: fragmentSignature(fragment, this.blockLookup), + signature: freshSig ?? fragmentSignature(fragment, this.blockLookup), context: contextBase, }); }); @@ -2944,9 +2957,11 @@ export class DomPainter { resolvedItem, ); el.appendChild(fragmentEl); + const initSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; return { key: fragmentKey(fragment), - signature: fragmentSignature(fragment, this.blockLookup), + signature: initSig ?? fragmentSignature(fragment, this.blockLookup), fragment, element: fragmentEl, context: contextBase, @@ -7042,7 +7057,11 @@ export class DomPainter { return 0; } - private buildBlockLookup(blocks: FlowBlock[], measures: Measure[]): BlockLookup { + private buildBlockLookup( + blocks: FlowBlock[], + measures: Measure[], + precomputedVersions?: Record, + ): BlockLookup { if (blocks.length !== measures.length) { throw new Error('DomPainter requires the same number of blocks and measures'); } @@ -7052,7 +7071,7 @@ export class DomPainter { lookup.set(block.id, { block, measure: measures[index], - version: deriveBlockVersion(block), + version: precomputedVersions?.[block.id] ?? deriveBlockVersion(block), }); }); return lookup; From e26689eadaadc3250d60371dc549280d6b1e93a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Wed, 22 Apr 2026 23:55:11 -0300 Subject: [PATCH 24/43] [6/16] refactor(layout): lift paragraph/list block and measure into resolved items (#2818) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items --- .../contracts/src/resolved-layout.ts | 8 + .../layout-resolved/src/resolveLayout.test.ts | 156 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 13 +- .../painters/dom/src/renderer.ts | 46 ++++-- 4 files changed, 208 insertions(+), 15 deletions(-) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 4701ec17a2..c26cd784a9 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -5,8 +5,12 @@ import type { ImageBlock, ImageFragmentMetadata, Line, + ListBlock, + ListMeasure, PageMargins, + ParagraphBlock, ParagraphBorders, + ParagraphMeasure, SectionVerticalAlign, TableBlock, TableMeasure, @@ -129,6 +133,10 @@ export type ResolvedFragmentItem = { paragraphBorders?: ParagraphBorders; /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ version?: string; + /** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */ + block?: ParagraphBlock | ListBlock; + /** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */ + measure?: ParagraphMeasure | ListMeasure; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 2f1b21d7d4..e6245f491a 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -665,6 +665,162 @@ describe('resolveLayout', () => { }); }); + describe('paragraph/list-item block and measure lifting', () => { + it('lifts block and measure from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [paragraphBlock], + measures: [paragraphMeasure], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBe(paragraphBlock); + expect(item.measure).toBe(paragraphMeasure); + }); + + it('lifts block and measure from a list-item fragment', () => { + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 0, + toLine: 1, + x: 108, + y: 200, + width: 432, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const listBlock: FlowBlock = { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] }, + }, + ], + }; + const listMeasure: Measure = { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 }, + ], + totalHeight: 24, + }, + }, + ], + totalHeight: 24, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [listBlock], + measures: [listMeasure], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBe(listBlock); + expect(item.measure).toBe(listMeasure); + }); + + it('leaves block and measure undefined when the block entry is missing', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'missing', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBeUndefined(); + expect(item.measure).toBeUndefined(); + }); + + it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 't1', + fromRow: 0, + toRow: 1, + x: 10, + y: 20, + width: 400, + height: 80, + columnWidths: [200, 200], + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { + kind: 'table' as const, + id: 't1', + rows: [], + columnWidths: [200, 200], + }; + const tableMeasure = { + kind: 'table' as const, + columnWidths: [200, 200], + rows: [], + totalHeight: 80, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + // Table items carry block/measure as ResolvedTableItem typed fields. + // They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch). + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.fragmentKind).toBe('table'); + expect(item.block).toBe(tableBlock); + expect(item.measure).toBe(tableMeasure); + }); + }); describe('fragment metadata lifting', () => { it('lifts pmStart and pmEnd from a paragraph fragment', () => { const paraFragment: ParaFragment = { diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 7e6b56abac..1c2d870741 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -238,6 +238,18 @@ function resolveFragmentItem( }; if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + // Pre-extract block/measure for para and list-item fragments so the painter + // can prefer resolved data over a blockLookup read. + const entry = blockMap.get(fragment.blockId); + if (entry) { + if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') { + item.block = entry.block as ParagraphBlock; + item.measure = entry.measure as ParagraphMeasure; + } else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') { + item.block = entry.block as ListBlock; + item.measure = entry.measure as ListMeasure; + } + } // Pre-compute paragraph border data for between-border grouping const borders = resolveFragmentParagraphBorders(fragment, blockMap); if (borders) { @@ -299,7 +311,6 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]), ); } - if (layout.layoutEpoch != null) { resolved.layoutEpoch = layout.layoutEpoch; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5156b74927..2c538cfc34 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3054,17 +3054,26 @@ export class DomPainter { resolvedItem?: ResolvedFragmentItem, ): HTMLElement { try { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') { - throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`); - } - if (!this.doc) { throw new Error('DomPainter: document is not available'); } - const block = lookup.block as ParagraphBlock; - const measure = lookup.measure as ParagraphMeasure; + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. + let block: ParagraphBlock; + let measure: ParagraphMeasure; + const resolvedBlock = resolvedItem?.block; + const resolvedMeasure = resolvedItem?.measure; + if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') { + block = resolvedBlock as ParagraphBlock; + measure = resolvedMeasure as ParagraphMeasure; + } else { + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') { + throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`); + } + block = lookup.block as ParagraphBlock; + measure = lookup.measure as ParagraphMeasure; + } const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; @@ -3596,17 +3605,26 @@ export class DomPainter { resolvedItem?: ResolvedFragmentItem, ): HTMLElement { try { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') { - throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`); - } - if (!this.doc) { throw new Error('DomPainter: document is not available'); } - const block = lookup.block as ListBlock; - const measure = lookup.measure as ListMeasure; + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. + let block: ListBlock; + let measure: ListMeasure; + const resolvedBlock = resolvedItem?.block; + const resolvedMeasure = resolvedItem?.measure; + if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') { + block = resolvedBlock as ListBlock; + measure = resolvedMeasure as ListMeasure; + } else { + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') { + throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`); + } + block = lookup.block as ListBlock; + measure = lookup.measure as ListMeasure; + } const item = block.items.find((entry) => entry.id === fragment.itemId); const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId); if (!item || !itemMeasure) { From 94d47f17cbc5a95750cb01739ec781d09c641845 Mon Sep 17 00:00:00 2001 From: Codex Test Date: Wed, 22 Apr 2026 20:15:27 -0700 Subject: [PATCH 25/43] ci: poll Labs for release qualification status in Actions --- .../release-qualification-dispatch.yml | 92 ++++++++++++++++++- cicd.md | 6 +- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-qualification-dispatch.yml b/.github/workflows/release-qualification-dispatch.yml index 309cd81b2f..79566f1b13 100644 --- a/.github/workflows/release-qualification-dispatch.yml +++ b/.github/workflows/release-qualification-dispatch.yml @@ -20,8 +20,10 @@ concurrency: jobs: dispatch-release-qualification: + name: Release Qualification if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Build dispatch payload env: @@ -95,6 +97,7 @@ jobs: RUN_ID="$(jq -r '.run.runId // empty' "${RESPONSE_FILE}")" RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")" + RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")" CREATED="$(jq -r '.created // false' "${RESPONSE_FILE}")" if [[ -z "${RUN_ID}" || -z "${RUN_STATUS}" ]]; then @@ -103,14 +106,98 @@ jobs: exit 1 fi + RUN_STATUS_URL="${LABS_RELEASE_QUALIFICATION_URL%/}/${RUN_ID}" + echo "run_id=${RUN_ID}" >> "${GITHUB_OUTPUT}" echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}" + echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}" + echo "run_status_url=${RUN_STATUS_URL}" >> "${GITHUB_OUTPUT}" echo "created=${CREATED}" >> "${GITHUB_OUTPUT}" + - name: Wait for Labs release qualification result + id: await + env: + INITIAL_RUN_STATUS: ${{ steps.dispatch.outputs.run_status }} + INITIAL_RUN_STATUS_MESSAGE: ${{ steps.dispatch.outputs.run_status_message }} + LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }} + RUN_STATUS_URL: ${{ steps.dispatch.outputs.run_status_url }} + run: | + set -euo pipefail + + RUN_STATUS="${INITIAL_RUN_STATUS}" + RUN_STATUS_MESSAGE="${INITIAL_RUN_STATUS_MESSAGE}" + + while [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; do + RESPONSE_FILE="$(mktemp)" + set +e + HTTP_STATUS="$(curl \ + --fail-with-body \ + --silent \ + --show-error \ + --output "${RESPONSE_FILE}" \ + --write-out '%{http_code}' \ + -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \ + "${RUN_STATUS_URL}")" + CURL_EXIT=$? + set -e + + if [[ "${CURL_EXIT}" -ne 0 ]]; then + cat "${RESPONSE_FILE}" + exit "${CURL_EXIT}" + fi + + if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then + cat "${RESPONSE_FILE}" + exit 1 + fi + + RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")" + RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")" + + if [[ -z "${RUN_STATUS}" ]]; then + cat "${RESPONSE_FILE}" + echo "Labs run lookup did not include a terminal status." >&2 + exit 1 + fi + + if [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; then + sleep 10 + fi + done + + echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}" + echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}" + + - name: Enforce Labs release qualification result + env: + FINAL_RUN_STATUS: ${{ steps.await.outputs.run_status }} + FINAL_RUN_STATUS_MESSAGE: ${{ steps.await.outputs.run_status_message }} + run: | + set -euo pipefail + + case "${FINAL_RUN_STATUS}" in + succeeded) + exit 0 + ;; + superseded) + echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification was superseded by a newer run.}" + exit 0 + ;; + failed|action_required) + echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification failed.}" >&2 + exit 1 + ;; + *) + echo "Unexpected Labs release qualification status: ${FINAL_RUN_STATUS}" >&2 + exit 1 + ;; + esac + - name: Write workflow summary + if: always() run: | { - echo "### Release Qualification Dispatch" + echo "### Release Qualification" echo echo "| Field | Value |" echo "| --- | --- |" @@ -119,6 +206,7 @@ jobs: echo "| Head branch | \`${{ github.event.pull_request.head.ref }}\` |" echo "| Head SHA | \`${{ github.event.pull_request.head.sha }}\` |" echo "| Labs run | \`${{ steps.dispatch.outputs.run_id }}\` |" - echo "| Labs status | \`${{ steps.dispatch.outputs.run_status }}\` |" + echo "| Labs status | \`${{ steps.await.outputs.run_status }}\` |" + echo "| Labs status message | ${{ steps.await.outputs.run_status_message || 'n/a' }} |" echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |" } >> "${GITHUB_STEP_SUMMARY}" diff --git a/cicd.md b/cicd.md index fdec60dfbd..c2fb25d1d2 100644 --- a/cicd.md +++ b/cicd.md @@ -88,8 +88,8 @@ main (next) → stable (latest) → X.x (maintenance) **Actions**: - Sends the PR head SHA and branch metadata to the Labs release-orchestrator service -- Lets Labs create a generic GitHub check run on that SHA -- Does not wait on or expose any private Labs details in the repository +- Polls Labs for the terminal release-qualification state +- Uses the GitHub Actions job itself as the required public status check - Re-triggers automatically when new commits are pushed to the PR branch Only same-repository PRs dispatch to Labs. Forked PRs are intentionally skipped so private Labs credentials are never exposed to untrusted branches. @@ -229,7 +229,7 @@ These skip semantic-release entirely — useful for re-publishing a failed platf 1. Run "Promote to Stable" workflow 2. Review the generated PR from the candidate branch into `stable` -3. Labs receives the PR head SHA and creates the `Release Qualification` GitHub check run +3. Labs receives the PR head SHA, records the qualification run, and the workflow job polls Labs for the terminal result 4. If needed, resolve merge conflicts on the candidate branch and push fixes 5. Re-run or wait for qualification on the new PR head SHA 6. Merge the PR into `stable` From 04aae44595f170f03fa02070cb6780f55c1ce1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 23 Apr 2026 00:17:58 -0300 Subject: [PATCH 26/43] [7/16] refactor(painter): extract block/measure resolution helper (#2819) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items * refactor(painter): extract block/measure resolution helper --- .../painters/dom/src/renderer.ts | 123 ++++++++++-------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2c538cfc34..11d73d3008 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3058,22 +3058,16 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. - let block: ParagraphBlock; - let measure: ParagraphMeasure; - const resolvedBlock = resolvedItem?.block; - const resolvedMeasure = resolvedItem?.measure; - if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') { - block = resolvedBlock as ParagraphBlock; - measure = resolvedMeasure as ParagraphMeasure; - } else { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') { - throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`); - } - block = lookup.block as ParagraphBlock; - measure = lookup.measure as ParagraphMeasure; - } + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup + // for header/footer fragments that don't have a resolved item. + const { block, measure } = this.resolveBlockAndMeasure( + fragment, + resolvedItem?.block, + resolvedItem?.measure, + 'paragraph', + 'paragraph', + 'paragraph block/measure', + ); const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; @@ -3609,22 +3603,16 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. - let block: ListBlock; - let measure: ListMeasure; - const resolvedBlock = resolvedItem?.block; - const resolvedMeasure = resolvedItem?.measure; - if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') { - block = resolvedBlock as ListBlock; - measure = resolvedMeasure as ListMeasure; - } else { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') { - throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`); - } - block = lookup.block as ListBlock; - measure = lookup.measure as ListMeasure; - } + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup + // for header/footer fragments that don't have a resolved item. + const { block, measure } = this.resolveBlockAndMeasure( + fragment, + resolvedItem?.block, + resolvedItem?.measure, + 'list', + 'list', + 'list block/measure', + ); const item = block.items.find((entry) => entry.id === fragment.itemId); const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId); if (!item || !itemMeasure) { @@ -3759,17 +3747,9 @@ export class DomPainter { resolvedItem?: ResolvedImageItem, ): HTMLElement { try { - // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item - // is a legacy ResolvedFragmentItem without the block field. - const block: ImageBlock = - resolvedItem?.block ?? - (() => { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'image' || lookup.measure.kind !== 'image') { - throw new Error(`DomPainter: missing image block for fragment ${fragment.blockId}`); - } - return lookup.block as ImageBlock; - })(); + // Prefer pre-extracted block from the resolved item; fall back to blockLookup + // for header/footer fragments that don't have a resolved item. + const block = this.resolveBlock(fragment, resolvedItem?.block, 'image', 'image block'); if (!this.doc) { throw new Error('DomPainter: document is not available'); @@ -3967,17 +3947,9 @@ export class DomPainter { resolvedItem?: ResolvedDrawingItem, ): HTMLElement { try { - // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item - // is a legacy ResolvedFragmentItem without the block field. - const block: DrawingBlock = - resolvedItem?.block ?? - (() => { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'drawing' || lookup.measure.kind !== 'drawing') { - throw new Error(`DomPainter: missing drawing block for fragment ${fragment.blockId}`); - } - return lookup.block as DrawingBlock; - })(); + // Prefer pre-extracted block from the resolved item; fall back to blockLookup + // for header/footer fragments that don't have a resolved item. + const block = this.resolveBlock(fragment, resolvedItem?.block, 'drawing', 'drawing block'); if (!this.doc) { throw new Error('DomPainter: document is not available'); } @@ -7075,6 +7047,49 @@ export class DomPainter { return 0; } + /** + * Resolves the block + measure pair for a fragment. Body fragments get these from the + * ResolvedFragmentItem; header/footer fragments fall back to the blockLookup map. + */ + private resolveBlockAndMeasure( + fragment: { blockId: string }, + resolvedBlock: FlowBlock | undefined, + resolvedMeasure: Measure | undefined, + blockKind: B['kind'], + measureKind: M['kind'], + errorLabel: string, + ): { block: B; measure: M } { + if (resolvedBlock?.kind === blockKind && resolvedMeasure?.kind === measureKind) { + return { block: resolvedBlock as B, measure: resolvedMeasure as M }; + } + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== blockKind || lookup.measure.kind !== measureKind) { + throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); + } + return { block: lookup.block as B, measure: lookup.measure as M }; + } + + /** + * Resolves only the block for a fragment (image/drawing rendering doesn't consume the measure). + * Body fragments get this from the ResolvedImageItem/ResolvedDrawingItem; header/footer + * fragments fall back to the blockLookup map. + */ + private resolveBlock( + fragment: { blockId: string }, + resolvedBlock: B | undefined, + blockKind: B['kind'], + errorLabel: string, + ): B { + if (resolvedBlock?.kind === blockKind) { + return resolvedBlock; + } + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== blockKind) { + throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); + } + return lookup.block as B; + } + private buildBlockLookup( blocks: FlowBlock[], measures: Measure[], From 55bf52ef1ca9d6c5c116535e71c8c0d496deeb1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 23 Apr 2026 01:15:29 -0300 Subject: [PATCH 27/43] [8/16] refactor(painter): remove body blocks/measures from DomPainterInput (#2820) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items * refactor(painter): extract block/measure resolution helper * refactor(painter): remove body blocks/measures from DomPainterInput Body block and measure data now flows exclusively through the resolved layout. The painter only builds a blockLookup from header/footer data, which is the last remaining fallback surface for fragments that do not yet have a resolved path. Complex-transaction rebuild detection now walks the resolved layout items directly instead of iterating the body blockLookup. The legacy createDomPainter wrapper derives a resolved layout from its legacyState blocks/measures on the fly so the benchmark path and direct createDomPainter(options).paint(Layout) callers keep working without setResolvedLayout. * fix: dompainter body input contract on first paint --- .../layout-resolved/src/resolveLayout.ts | 3 + .../layout-engine/painters/dom/package.json | 1 + .../painters/dom/src/index.test.ts | 68 ++++++++++++----- .../layout-engine/painters/dom/src/index.ts | 73 +++++++++++++++++-- .../painters/dom/src/renderer.ts | 25 +++++-- .../painters/dom/src/virtualization.test.ts | 16 +++- .../layout-engine/painters/dom/tsconfig.json | 1 + .../presentation-editor/PresentationEditor.ts | 20 ++--- .../tests/PresentationEditor.test.ts | 31 ++++++++ pnpm-lock.yaml | 3 + 10 files changed, 198 insertions(+), 43 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 1c2d870741..771ce6c067 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -189,6 +189,7 @@ function computeBlockVersion( cache.set(blockId, version); return version; } + function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, @@ -250,12 +251,14 @@ function resolveFragmentItem( item.measure = entry.measure as ListMeasure; } } + // Pre-compute paragraph border data for between-border grouping const borders = resolveFragmentParagraphBorders(fragment, blockMap); if (borders) { item.paragraphBorders = borders; item.paragraphBorderHash = hashParagraphBorders(borders); } + if (fragment.kind === 'para') { const para = fragment as ParaFragment; if (para.pmStart != null) item.pmStart = para.pmStart; diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index b5757c3844..940b673d32 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -21,6 +21,7 @@ "@superdoc/contracts": "workspace:*", "@superdoc/dom-contract": "workspace:*", "@superdoc/font-utils": "workspace:*", + "@superdoc/layout-resolved": "workspace:*", "@superdoc/preset-geometry": "workspace:*", "@superdoc/url-validation": "workspace:*" }, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index fa8d1aa682..b52913da70 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; import { DomPainter } from './renderer.js'; +import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js'; import type { @@ -42,17 +43,38 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } let footerBlocks: FlowBlock[] | undefined; let footerMeasures: Measure[] | undefined; + let resolvedLayoutOverridden = false; + return { paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { + const effectiveResolved = resolvedLayoutOverridden + ? currentResolved + : resolveLayout({ + layout, + flowMode: opts.flowMode ?? 'paginated', + blocks: currentBlocks, + measures: currentMeasures, + }); + // Tests historically pass header/footer blocks via the main `blocks` array and + // rely on the blockLookup containing them. Merge body blocks into headerBlocks + // so header/footer fragments from providers can resolve their block data. + const mergedHeaderBlocks = + headerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(headerBlocks ?? [])] : undefined; + const mergedHeaderMeasures = + headerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(headerMeasures ?? [])] : undefined; + const mergedFooterBlocks = + footerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(footerBlocks ?? [])] : undefined; + const mergedFooterMeasures = + footerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(footerMeasures ?? [])] : undefined; const input: DomPainterInput = { - resolvedLayout: currentResolved, + resolvedLayout: effectiveResolved, sourceLayout: layout, blocks: currentBlocks, measures: currentMeasures, - headerBlocks, - headerMeasures, - footerBlocks, - footerMeasures, + headerBlocks: mergedHeaderBlocks, + headerMeasures: mergedHeaderMeasures, + footerBlocks: mergedFooterBlocks, + footerMeasures: mergedFooterMeasures, }; painter.paint(input, mount, mapping as any); }, @@ -73,6 +95,7 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } }, setResolvedLayout(rl: ResolvedLayout | null) { currentResolved = rl ?? emptyResolved; + resolvedLayoutOverridden = true; }, setProviders: painter.setProviders, setVirtualizationPins: painter.setVirtualizationPins, @@ -1357,7 +1380,10 @@ describe('DomPainter', () => { expect(lines[1].style.wordSpacing).toBe(''); }); - it('renders an error placeholder when a legacy table fragment is missing its lookup entry', () => { + it('surfaces a missing-block error from resolveLayout when a table fragment references an unknown block', () => { + // Previous behavior: painter rendered a placeholder for missing lookup entries. + // New behavior: resolveLayout validates block/measure integrity upstream and throws + // before the painter runs. Missing-block bugs are now caught at the resolved stage. const missingTableLayout: Layout = { pageSize: { w: 300, h: 300 }, pages: [ @@ -1379,19 +1405,8 @@ describe('DomPainter', () => { ], }; - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { - // Intentionally empty - suppress expected error logging during this regression test. - }); - const painter = createTestPainter({ blocks: [], measures: [] }); - expect(() => painter.paint(missingTableLayout, mount)).not.toThrow(); - - const placeholder = mount.querySelector('.render-error-placeholder') as HTMLElement | null; - expect(placeholder).toBeTruthy(); - expect(placeholder?.textContent).toContain('[Render Error: missing-table]'); - expect(consoleErrorSpy).toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); + expect(() => painter.paint(missingTableLayout, mount)).toThrow(/Missing block\/measure/); }); it('renders an error placeholder when table-cell line rendering throws', () => { @@ -1680,8 +1695,23 @@ describe('DomPainter', () => { }); it('throws if blocks and measures length mismatch', () => { + // Block/measure integrity is now validated at the resolve-layout stage. const painter = createTestPainter({ blocks: [block], measures: [] }); - expect(() => painter.paint(layout, mount)).toThrow(/same number of blocks/); + expect(() => painter.paint(layout, mount)).toThrow(); + }); + + it('rejects resolved-layout-only paint input until body lookups are removed', () => { + const painter = createDomPainter({}); + + expect(() => + painter.paint( + { + resolvedLayout: emptyResolved, + sourceLayout: layout, + } as DomPainterInput, + mount, + ), + ).toThrow('DomPainterInput requires body blocks and measures'); }); it('renders placeholder content for empty lines', () => { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index fcbe74c7f4..8acc396001 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -1,5 +1,6 @@ import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts'; import { DomPainter } from './renderer.js'; +import { resolveLayout } from '@superdoc/layout-resolved'; import type { PageStyles } from './styles.js'; import type { DomPainterInput, PaintSnapshot, PositionMapping, RulerOptions, FlowMode } from './renderer.js'; @@ -144,6 +145,11 @@ type BlockMeasurePair = { measures: Measure[]; }; +type DomPainterInputCandidate = Partial & { + resolvedLayout?: ResolvedLayout; + sourceLayout?: Layout; +}; + export type DomPainterHandle = { paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void; /** @@ -177,6 +183,19 @@ function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], meas } } +function normalizeRequiredBlockMeasurePair( + label: 'body', + blocks: FlowBlock[] | undefined, + measures: Measure[] | undefined, +): BlockMeasurePair { + if (!Array.isArray(blocks) || !Array.isArray(measures)) { + throw new Error('DomPainterInput requires body blocks and measures; resolved-layout-only input is not supported.'); + } + + assertRequiredBlockMeasurePair(label, blocks, measures); + return { blocks, measures }; +} + function normalizeOptionalBlockMeasurePair( label: 'header' | 'footer', blocks: FlowBlock[] | undefined, @@ -193,6 +212,10 @@ function normalizeOptionalBlockMeasurePair( return undefined; } + if (!Array.isArray(blocks) || !Array.isArray(measures)) { + throw new Error(`${label}Blocks and ${label}Measures must be arrays when provided.`); + } + assertRequiredBlockMeasurePair(label, blocks, measures); return { blocks, measures }; } @@ -206,8 +229,29 @@ function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: numb }; } -function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput { - return 'resolvedLayout' in value && 'sourceLayout' in value && 'blocks' in value && 'measures' in value; +function isLegacyLayoutInput(value: DomPainterInput | Layout): value is Layout { + return 'pages' in value; +} + +function normalizeDomPainterInput(input: DomPainterInputCandidate): DomPainterInput { + if (!input.resolvedLayout || !input.sourceLayout) { + throw new Error('DomPainterInput requires resolvedLayout and sourceLayout.'); + } + + const body = normalizeRequiredBlockMeasurePair('body', input.blocks, input.measures); + const header = normalizeOptionalBlockMeasurePair('header', input.headerBlocks, input.headerMeasures); + const footer = normalizeOptionalBlockMeasurePair('footer', input.footerBlocks, input.footerMeasures); + + return { + resolvedLayout: input.resolvedLayout, + sourceLayout: input.sourceLayout, + blocks: body.blocks, + measures: body.measures, + headerBlocks: header?.blocks, + headerMeasures: header?.measures, + footerBlocks: footer?.blocks, + footerMeasures: footer?.measures, + }; } function buildLegacyPaintInput( @@ -216,8 +260,25 @@ function buildLegacyPaintInput( flowMode: FlowMode | undefined, pageGap: number | undefined, ): DomPainterInput { + // Derive a resolved layout from the legacy block/measure state when the caller + // has not supplied one via `setResolvedLayout`. The painter now reads all body + // fragment data from the resolved layout, so an empty resolved layout would + // produce a blank render. + let resolvedLayout: ResolvedLayout; + if (legacyState.resolvedLayout) { + resolvedLayout = legacyState.resolvedLayout; + } else if (legacyState.blocks.length === 0 && legacyState.measures.length === 0) { + resolvedLayout = createEmptyResolvedLayout(flowMode, pageGap); + } else { + resolvedLayout = resolveLayout({ + layout, + flowMode: flowMode ?? 'paginated', + blocks: legacyState.blocks, + measures: legacyState.measures, + }); + } return { - resolvedLayout: legacyState.resolvedLayout ?? createEmptyResolvedLayout(flowMode, pageGap), + resolvedLayout, sourceLayout: layout, blocks: legacyState.blocks, measures: legacyState.measures, @@ -253,9 +314,9 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle = return { paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping) { - const normalizedInput = isDomPainterInput(input) - ? input - : buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap); + const normalizedInput = isLegacyLayoutInput(input) + ? buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap) + : normalizeDomPainterInput(input); painter.paint(normalizedInput, mount, mapping); }, setData( diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 11d73d3008..29f5d5f2b1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -250,17 +250,20 @@ export type RenderedLineInfo = { * Input to `DomPainter.paint()`. * * `resolvedLayout` is the canonical resolved data. The remaining fields are - * bridge data carried for internal rendering of non-paragraph fragments - * (tables, images, drawings) that have not yet been migrated to resolved items. + * still required bridge data until the painter can render solely from resolved + * items for lookups, change tracking, and non-paragraph fragment rendering. */ export type DomPainterInput = { resolvedLayout: ResolvedLayout; - /** Raw Layout for internal fragment access (bridge — will be removed once all fragment types are resolved). */ + /** Raw Layout for internal fragment access (bridge, will be removed once render loops iterate resolved items). */ sourceLayout: Layout; + /** Main document blocks/measures used for lookups and version tracking. */ blocks: FlowBlock[]; measures: Measure[]; + /** Header block data (still needed for decoration rendering, no resolved path yet). */ headerBlocks?: FlowBlock[]; headerMeasures?: Measure[]; + /** Footer block data (still needed for decoration rendering, no resolved path yet). */ footerBlocks?: FlowBlock[]; footerMeasures?: Measure[]; }; @@ -1640,7 +1643,7 @@ export class DomPainter { }); } - // Track changed blocks + // Track changed blocks (decoration only now, body change detection uses resolved version) const changed = new Set(); nextLookup.forEach((entry, id) => { const previous = this.blockLookup.get(id); @@ -1674,7 +1677,13 @@ export class DomPainter { // Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild. const isSimpleTransaction = mapping && mapping.maps.length === 1; if (mapping && !isSimpleTransaction) { - // Complex transaction - force all fragments to rebuild (safe fallback) + // Complex transaction, force all body fragments to rebuild (safe fallback). + for (const page of input.resolvedLayout.pages) { + for (const item of page.items) { + if ('blockId' in item) this.changedBlocks.add(item.blockId); + } + } + // Also mark all header/footer blocks as changed. this.blockLookup.forEach((_, id) => this.changedBlocks.add(id)); this.currentMapping = null; } else { @@ -2426,6 +2435,7 @@ export class DomPainter { return separatorPositions; } + private renderDecorationsForPage( pageEl: HTMLElement, page: Page, @@ -5059,6 +5069,11 @@ export class DomPainter { // Inner cell fragments still use legacy applyFragmentFrame via deps closure. if (resolvedItem) { this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section); + // Re-apply the SDT group width override after the resolved frame, so block-SDT + // containers can stretch table fragments to match sibling paragraph widths. + if (sdtBoundary?.widthOverride != null) { + el.style.width = `${sdtBoundary.widthOverride}px`; + } } return el; diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 97f27781e7..68c96d6542 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createDomPainter } from './index.js'; +import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; import type { FlowBlock, Measure, Layout, Fragment, PageMargins, ResolvedLayout } from '@superdoc/contracts'; @@ -21,11 +22,24 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } return { paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { + const effectiveResolved = + currentBlocks.length === 0 && currentMeasures.length === 0 + ? currentResolved + : resolveLayout({ + layout, + flowMode: opts.flowMode ?? 'paginated', + blocks: currentBlocks, + measures: currentMeasures, + }); const input: DomPainterInput = { - resolvedLayout: currentResolved, + resolvedLayout: effectiveResolved, sourceLayout: layout, blocks: currentBlocks, measures: currentMeasures, + headerBlocks: undefined, + headerMeasures: undefined, + footerBlocks: undefined, + footerMeasures: undefined, }; painter.paint(input, mount, mapping as any); }, diff --git a/packages/layout-engine/painters/dom/tsconfig.json b/packages/layout-engine/painters/dom/tsconfig.json index e1df276edc..bf7c501521 100644 --- a/packages/layout-engine/painters/dom/tsconfig.json +++ b/packages/layout-engine/painters/dom/tsconfig.json @@ -12,6 +12,7 @@ "references": [ { "path": "../../contracts/tsconfig.json" }, { "path": "../../dom-contract/tsconfig.json" }, + { "path": "../../layout-resolved/tsconfig.json" }, { "path": "../../measuring/dom/tsconfig.json" }, { "path": "../../../../shared/common/tsconfig.json" } ] diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 46b51c0c6e..9eb6c38752 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4278,6 +4278,8 @@ export class PresentationEditor extends EventEmitter { let layout: Layout; let measures: Measure[]; let resolvedLayout: ReturnType; + let bodyBlocksForPaint: FlowBlock[] = blocksForLayout; + let bodyMeasuresForPaint: Measure[] = []; let headerLayouts: HeaderFooterLayoutResult[] | undefined; let footerLayouts: HeaderFooterLayoutResult[] | undefined; let extraBlocks: FlowBlock[] | undefined; @@ -4321,14 +4323,14 @@ export class PresentationEditor extends EventEmitter { // Include footnote-injected blocks (separators, footnote paragraphs) so // resolveLayout can find them when resolving page fragments. - const resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; - const resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures; + bodyBlocksForPaint = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; + bodyMeasuresForPaint = extraMeasures ? [...measures, ...extraMeasures] : measures; resolvedLayout = resolveLayout({ layout, flowMode: this.#layoutOptions.flowMode ?? 'paginated', - blocks: resolveBlocks, - measures: resolveMeasures, + blocks: bodyBlocksForPaint, + measures: bodyMeasuresForPaint, }); headerLayouts = result.headers; @@ -4440,12 +4442,6 @@ export class PresentationEditor extends EventEmitter { } } - // Merge any extra lookup blocks (e.g., footnotes injected into page fragments) - if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { - footerBlocks.push(...extraBlocks); - footerMeasures.push(...extraMeasures); - } - // Avoid MutationObserver overhead while repainting large DOM trees. this.#domIndexObserverManager?.pause(); // Pass the transaction mapping for efficient position attribute updates. @@ -4456,8 +4452,8 @@ export class PresentationEditor extends EventEmitter { const paintInput: DomPainterInput = { resolvedLayout, sourceLayout: layout, - blocks: blocksForLayout, - measures, + blocks: bodyBlocksForPaint, + measures: bodyMeasuresForPaint, headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined, headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined, footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 60e212d368..70546b08bf 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -423,6 +423,37 @@ describe('PresentationEditor', () => { }); describe('semantic flow mode configuration', () => { + it('passes body blocks and measures to the painter on blank-document render', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'blank-render-contract-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const painterInstance = (mockCreateDomPainter as unknown as Mock).mock.results[ + (mockCreateDomPainter as unknown as Mock).mock.results.length - 1 + ].value as { + paint: Mock; + }; + + await vi.waitFor(() => expect(painterInstance.paint).toHaveBeenCalled()); + + const [paintInput] = painterInstance.paint.mock.calls[painterInstance.paint.mock.calls.length - 1] as [ + { + blocks: unknown[]; + measures: unknown[]; + resolvedLayout: unknown; + sourceLayout: unknown; + }, + ]; + + expect(paintInput.blocks).toEqual([]); + expect(paintInput.measures).toEqual([]); + expect(paintInput.resolvedLayout).toBeTruthy(); + expect(paintInput.sourceLayout).toBeTruthy(); + }); + it('forces vertical layout and disables virtualization when flowMode is semantic', async () => { editor = new PresentationEditor({ element: container, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c691e95d..1b11108180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2567,6 +2567,9 @@ importers: '@superdoc/font-utils': specifier: workspace:* version: link:../../../../shared/font-utils + '@superdoc/layout-resolved': + specifier: workspace:* + version: link:../../layout-resolved '@superdoc/preset-geometry': specifier: workspace:* version: link:../../../preset-geometry From 1c76b4f1e925d11c70a3e9bc64c6a25c69255371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 23 Apr 2026 02:21:13 -0300 Subject: [PATCH 28/43] [9/16] refactor(layout): add resolveHeaderFooterLayout helper for decoration layouts (#2826) * refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items * refactor(painter): extract block/measure resolution helper * refactor(painter): remove body blocks/measures from DomPainterInput Body block and measure data now flows exclusively through the resolved layout. The painter only builds a blockLookup from header/footer data, which is the last remaining fallback surface for fragments that do not yet have a resolved path. Complex-transaction rebuild detection now walks the resolved layout items directly instead of iterating the body blockLookup. The legacy createDomPainter wrapper derives a resolved layout from its legacyState blocks/measures on the fly so the benchmark path and direct createDomPainter(options).paint(Layout) callers keep working without setResolvedLayout. * refactor(layout): add resolveHeaderFooterLayout helper for decoration layouts * chore: fix lock file * fix: preserve per-page header/footer resolution data --- packages/layout-engine/contracts/src/index.ts | 12 ++ .../contracts/src/resolved-layout.ts | 16 ++ .../layout-bridge/src/layoutHeaderFooter.ts | 2 + .../test/headerFooterLayout.test.ts | 32 +++ .../layout-resolved/src/index.ts | 1 + .../src/resolveHeaderFooter.test.ts | 188 ++++++++++++++++++ .../src/resolveHeaderFooter.ts | 44 ++++ .../layout-resolved/src/resolveLayout.ts | 4 +- .../layout-engine/painters/dom/package.json | 1 + .../painters/dom/src/renderer.ts | 1 - pnpm-lock.yaml | 1 - 11 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts create mode 100644 packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 4cc339e09c..7f29f5f9d0 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1901,6 +1901,16 @@ export type HeaderFooterPage = { number: number; fragments: Fragment[]; numberText?: string; + /** + * Optional page-local block clones backing this page's resolved fragments. + * Present when header/footer tokens were laid out per page or per bucket. + */ + blocks?: FlowBlock[]; + /** + * Optional page-local measures aligned with `blocks`. + * Present when header/footer tokens were laid out per page or per bucket. + */ + measures?: Measure[]; }; export type HeaderFooterLayout = { @@ -1980,6 +1990,8 @@ export type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, + ResolvedHeaderFooterPage, + ResolvedHeaderFooterLayout, } from './resolved-layout.js'; export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js'; diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index c26cd784a9..8e4355c432 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -350,6 +350,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item; } +/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ +export type ResolvedHeaderFooterPage = { + number: number; + numberText?: string; + items: ResolvedPaintItem[]; +}; + +/** A resolved header/footer layout — mirrors HeaderFooterLayout but with resolved pages. */ +export type ResolvedHeaderFooterLayout = { + height: number; + minY?: number; + maxY?: number; + renderHeight?: number; + pages: ResolvedHeaderFooterPage[]; +}; + /** Resolved list marker rendering data with pre-computed positioning. */ export type ResolvedListMarkerItem = { /** Marker text content (e.g., "1.", "a)", bullet). */ diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index de6310ada4..60afb042ed 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -322,6 +322,8 @@ export async function layoutHeaderFooterWithCache( pages: pages.map((p) => ({ number: p.number, fragments: p.fragments, + blocks: p.blocks, + measures: p.measures, })), }; diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts index 04a8903946..1ed4aa8cff 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts @@ -81,6 +81,38 @@ describe('layoutHeaderFooterWithCache', () => { expect(measureBlock).not.toHaveBeenCalled(); }); + it('stores page-local block clones for tokenized header/footer pages', async () => { + const sections = { + default: [ + { + kind: 'paragraph', + id: 'page-token-header', + runs: [ + { text: 'Page ', fontFamily: 'Arial', fontSize: 16 }, + { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 }, + ], + } satisfies FlowBlock, + ], + }; + const measureBlock = vi.fn(async () => makeMeasure(12)); + + const result = await layoutHeaderFooterWithCache( + sections, + { width: 300, height: 40 }, + measureBlock, + undefined, + undefined, + (pageNumber) => ({ displayText: String(pageNumber), totalPages: 2 }), + 'header', + ); + + expect(result.default?.layout.pages).toHaveLength(2); + expect(result.default?.layout.pages[0].blocks?.[0].runs[1]?.text).toBe('1'); + expect(result.default?.layout.pages[1].blocks?.[0].runs[1]?.text).toBe('2'); + expect(result.default?.layout.pages[0].measures).toHaveLength(1); + expect(result.default?.layout.pages[1].measures).toHaveLength(1); + }); + describe('integration test', () => { it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => { // 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter) diff --git a/packages/layout-engine/layout-resolved/src/index.ts b/packages/layout-engine/layout-resolved/src/index.ts index af3f0a23c7..c504917f6f 100644 --- a/packages/layout-engine/layout-resolved/src/index.ts +++ b/packages/layout-engine/layout-resolved/src/index.ts @@ -1,2 +1,3 @@ export { resolveLayout } from './resolveLayout.js'; export type { ResolveLayoutInput } from './resolveLayout.js'; +export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts new file mode 100644 index 0000000000..7862da9026 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; +import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; + +describe('resolveHeaderFooterLayout', () => { + it('resolves a header/footer with one paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + + const result = resolveHeaderFooterLayout(layout, blocks, measures); + expect(result.pages).toHaveLength(1); + const item = result.pages[0].items[0] as ResolvedFragmentItem; + expect(item.version).toBeDefined(); + expect(item.block?.kind).toBe('paragraph'); + expect(item.measure?.kind).toBe('paragraph'); + }); + + it('preserves height, minY, maxY, renderHeight from input', () => { + const layout: HeaderFooterLayout = { + height: 100, + minY: 5, + maxY: 120, + renderHeight: 115, + pages: [], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.height).toBe(100); + expect(result.minY).toBe(5); + expect(result.maxY).toBe(120); + expect(result.renderHeight).toBe(115); + }); + + it('preserves numberText on pages', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [ + { number: 1, fragments: [], numberText: 'i' }, + { number: 2, fragments: [], numberText: 'ii' }, + ], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages[0].numberText).toBe('i'); + expect(result.pages[1].numberText).toBe('ii'); + }); + + it('returns empty items array for empty fragments array', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages).toHaveLength(1); + expect(result.pages[0].items).toEqual([]); + }); + + it('leaves block/measure undefined when block entry is missing', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'missing-id', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + const item = result.pages[0].items[0] as ResolvedFragmentItem; + expect(item.block).toBeUndefined(); + expect(item.measure).toBeUndefined(); + }); + + it('resolves each page against its own cloned block data', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'page-token', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 120, + }; + const pageOneBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'page-token', + runs: [ + { text: 'Page ', fontFamily: 'Arial', fontSize: 16 }, + { text: '1', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 }, + ], + }, + ]; + const pageTwoBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'page-token', + runs: [ + { text: 'Page ', fontFamily: 'Arial', fontSize: 16 }, + { text: '2', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 }, + ], + }, + ]; + const makeMeasure = (text: string): Measure => ({ + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 1, toChar: text.length, width: 120, ascent: 10, descent: 3, lineHeight: 18 }, + ], + totalHeight: 18, + }); + const layout: HeaderFooterLayout = { + height: 50, + pages: [ + { number: 1, fragments: [paraFragment], blocks: pageOneBlocks, measures: [makeMeasure('Page 1')] }, + { number: 2, fragments: [paraFragment], blocks: pageTwoBlocks, measures: [makeMeasure('Page 2')] }, + ], + }; + + const result = resolveHeaderFooterLayout(layout, pageOneBlocks, [makeMeasure('Page 1')]); + const firstItem = result.pages[0].items[0] as ResolvedFragmentItem; + const secondItem = result.pages[1].items[0] as ResolvedFragmentItem; + + expect(firstItem.block?.kind).toBe('paragraph'); + expect(secondItem.block?.kind).toBe('paragraph'); + expect(firstItem.block?.runs[1]?.text).toBe('1'); + expect(secondItem.block?.runs[1]?.text).toBe('2'); + expect(firstItem.version).not.toBe(secondItem.version); + }); + + it('uses document page indices for sparse header/footer pages', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [ + { number: 5, fragments: [paraFragment], numberText: '5' }, + { number: 50, fragments: [paraFragment], numberText: '50' }, + { number: 500, fragments: [paraFragment], numberText: '500' }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + + const result = resolveHeaderFooterLayout(layout, blocks, measures); + + expect((result.pages[0].items[0] as ResolvedFragmentItem).pageIndex).toBe(4); + expect((result.pages[1].items[0] as ResolvedFragmentItem).pageIndex).toBe(49); + expect((result.pages[2].items[0] as ResolvedFragmentItem).pageIndex).toBe(499); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts new file mode 100644 index 0000000000..9988a337c3 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -0,0 +1,44 @@ +import type { + FlowBlock, + HeaderFooterLayout, + Measure, + ResolvedHeaderFooterLayout, + ResolvedHeaderFooterPage, +} from '@superdoc/contracts'; +import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js'; + +/** + * Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`. + * + * Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`. + * The caller stores results indexed by the same key (type or rId) as the originals; + * alignment between fragments and resolved items is guaranteed by construction. + */ +export function resolveHeaderFooterLayout( + layout: HeaderFooterLayout, + blocks: FlowBlock[], + measures: Measure[], +): ResolvedHeaderFooterLayout { + const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => { + const pageBlocks = page.blocks ?? blocks; + const pageMeasures = page.measures ?? measures; + const blockMap = buildBlockMap(pageBlocks, pageMeasures); + const blockVersionCache = new Map(); + + return { + number: page.number, + numberText: page.numberText, + items: page.fragments.map((fragment, fragmentIndex) => + resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache), + ), + }; + }); + + return { + height: layout.height, + minY: layout.minY, + maxY: layout.maxY, + renderHeight: layout.renderHeight, + pages, + }; +} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 771ce6c067..78b5be1f13 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -37,7 +37,7 @@ export type ResolveLayoutInput = { measures: Measure[]; }; -function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map { +export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map { const map = new Map(); for (let i = 0; i < blocks.length; i++) { map.set(blocks[i].id, { block: blocks[i], measure: measures[i] }); @@ -190,7 +190,7 @@ function computeBlockVersion( return version; } -function resolveFragmentItem( +export function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number, diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index 940b673d32..cf39d4348f 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@superdoc/layout-engine": "workspace:*", + "@superdoc/layout-resolved": "workspace:*", "vitest": "catalog:" } } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 29f5d5f2b1..32260fc06f 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2435,7 +2435,6 @@ export class DomPainter { return separatorPositions; } - private renderDecorationsForPage( pageEl: HTMLElement, page: Page, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b11108180..9d59efac41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6405,7 +6405,6 @@ 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': From 0db8e2fb6e01bb5b96c8d0d62e857c23595e10ac Mon Sep 17 00:00:00 2001 From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:30:41 +0200 Subject: [PATCH 29/43] docs: enhance OpenAPI specification for sign API (#2920) * docs: enhance OpenAPI specification for sign API - Updated the `document` object description to clarify the requirement of providing either `base64` or `url`. - Expanded the `signer` object description to specify required fields and optional fields, including examples. - Improved the `auditTrail` description to emphasize compliance requirements. - Added a new `certificate` object to configure the audit trail certificate page. - Updated the API documentation in `backend.mdx` to reflect the changes in the `signer` fields and their requirements. * docs: update OpenAPI specification for event payload structure - Replaced the `field` and `value` properties with a new `data` object in the event schema. - The `data` object now supports an event-specific payload with detailed descriptions for different event types, enhancing clarity and usability. * docs: update documentation for event payload and signer fields - Enhanced the OpenAPI specification to clarify the structure of the event payload, including updates to the `field_change` type to support additional data types. - Simplified the `signer` object in the backend documentation by removing unnecessary properties and added a warning about trusting browser-submitted `ip` and `userAgent` values. --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- apps/docs/openapi.json | 101 ++++++++++++++++++++++++-- apps/docs/solutions/esign/backend.mdx | 17 +++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index d6b1e41a79..f2559eb087 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -197,19 +197,110 @@ }, "document": { "type": "object", - "description": "PDF or DOCX input provided as either base64 or URL" + "description": "PDF or DOCX input. Provide exactly one of `base64` or `url`.", + "oneOf": [ + { + "type": "object", + "title": "base64", + "required": ["base64"], + "properties": { + "base64": { + "type": "string", + "format": "byte", + "minLength": 100, + "description": "Base64-encoded PDF or DOCX file" + } + } + }, + { + "type": "object", + "title": "url", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to fetch the document from" + } + } + } + ] }, "signer": { "type": "object", - "description": "Signer details (name, email, etc.)" + "description": "Details of the person applying the signature. `email` and `name` are required; `ip` and `userAgent` are optional and recorded in the audit trail / certificate page when provided. No other fields are accepted — use `metadata` for application-specific context.", + "required": ["email", "name"], + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 255, + "description": "Signer's email address", + "example": "jane@example.com" + }, + "name": { + "type": "string", + "minLength": 2, + "maxLength": 255, + "description": "Signer's full name as it should appear on the signature", + "example": "Jane Smith" + }, + "ip": { + "type": "string", + "format": "ipv4", + "description": "IPv4 address the signer submitted from. Included in the audit trail certificate for compliance.", + "example": "203.0.113.42" + }, + "userAgent": { + "type": "string", + "description": "Browser user agent string the signer submitted from. Included in the audit trail certificate for compliance.", + "example": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + } + } }, "auditTrail": { "type": "array", - "description": "Array of signing events and user interactions" + "description": "Complete event trail of user interactions. Must include at least one `submit` event for e-signature compliance.", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "timestamp"], + "properties": { + "type": { + "type": "string", + "enum": ["ready", "scroll", "field_change", "submit"], + "description": "Event kind" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp for the event" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Event-specific payload emitted by the e-sign SDK. Shape depends on `type`:\n- `scroll` - `{ percent: number }`\n- `field_change` - `{ fieldId: string, value: string | boolean | number | null, previousValue?: string | boolean | number | null }`\n- `ready`, `submit` - typically omitted" + } + } + } }, "metadata": { "type": "object", - "description": "Optional metadata (IP, user agent, custom fields)" + "additionalProperties": true, + "description": "Optional application-specific metadata. Free-form object for any context you want to attach to the signing event (e.g. tenantId, contractId, custom audit fields)." + }, + "certificate": { + "type": "object", + "description": "Configuration for the audit trail certificate page that is appended to the signed PDF.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether to append an audit trail certificate page to the signed document" + } + } } } }, @@ -792,7 +883,7 @@ "post": { "summary": "Sign", "tags": ["Signature"], - "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document`: object containing either `base64` or `url`\n- `signer`: object with signer details (name, email, etc.)\n- `auditTrail`: array of signing events\n- `eventId`: optional unique identifier\n- `metadata`: optional metadata (IP, user agent, custom fields)\n\nThe response returns the signed PDF as base64.", + "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document` (required): object containing either `base64` or `url`\n- `signer` (required): signer details — `email` and `name` are required; `ip` and `userAgent` are optional and, when provided, are recorded in the audit trail certificate. No other signer fields are accepted; use `metadata` for anything application-specific.\n- `auditTrail` (required): array of signing events. Must include at least one `submit` event for e-signature compliance.\n- `eventId` (optional): unique identifier for the signing event\n- `metadata` (optional): free-form object for application-specific context (tenantId, contractId, etc.)\n- `certificate` (optional): `{ enabled: boolean }` — controls whether an audit trail certificate page is appended (default: `true`)\n\nThe response returns the signed PDF as base64.", "requestBody": { "content": { "application/json": { diff --git a/apps/docs/solutions/esign/backend.mdx b/apps/docs/solutions/esign/backend.mdx index 4fbb52ec02..560b7590ae 100644 --- a/apps/docs/solutions/esign/backend.mdx +++ b/apps/docs/solutions/esign/backend.mdx @@ -251,6 +251,23 @@ The frontend sends the `onSubmit` payload plus a document reference and signer d } ``` +### Signer fields + +When forwarding this payload to `POST /v1/sign`, only the following `signer` fields are accepted: + +| Field | Required | Description | +| ----------- | -------- | --------------------------------------------------------------------------- | +| `name` | yes | Signer's full name (2–255 chars). Rendered on the signature. | +| `email` | yes | Signer's email address (valid email, max 255 chars). | +| `ip` | no | IPv4 address of the signer. Included in the audit trail certificate. | +| `userAgent` | no | Browser user agent string. Included in the audit trail certificate. | + +No other properties are accepted on `signer` — the request is rejected with a validation error if extra keys are sent. For application-specific context (tenantId, contractId, etc.), pass a top-level `metadata` object instead. + + + Do **not** trust `ip` or `userAgent` values from the browser-submitted payload. Derive them server-side from the incoming request (e.g. `req.ip`, `req.headers['user-agent']`) before forwarding to `/v1/sign`, so the audit trail certificate reflects what your server actually observed. + + ## API reference - [Authentication](/api-reference/authentication) - Get your API key From 5cb9691e129c42092787d270d146bd40b34ac269 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:33:34 -0700 Subject: [PATCH 30/43] feat: tracked changes in headers and footers (#2922) * feat(layout): make tracked changes story-aware in resolved layout * feat(editor): add story editing sessions and runtime plumbing * feat(doc-api): expose story-aware tracked change operations * feat(superdoc): surface story tracked changes in comments ui * fix(notes): only strip separators after note reference runs * fix(superdoc): restore tracked-change comment rendering and CI tests * fix: sidebar activation state for tcs * fix: footnote caret selection --- .../document-api/available-operations.mdx | 3 +- .../reference/_generated-manifest.json | 4 +- .../reference/capabilities/get.mdx | 46 + .../reference/content-controls/create.mdx | 7 + .../document-api/reference/core/index.mdx | 1 + .../document-api/reference/create/heading.mdx | 6 +- .../reference/create/paragraph.mdx | 6 +- apps/docs/document-api/reference/extract.mdx | 222 ++ apps/docs/document-api/reference/index.mdx | 3 +- .../document-api/reference/lists/insert.mdx | 6 +- .../reference/track-changes/decide.mdx | 9 +- .../reference/track-changes/get.mdx | 17 +- .../reference/track-changes/list.mdx | 18 +- packages/document-api/src/README.md | 14 +- packages/document-api/src/contract/schemas.ts | 10 +- packages/document-api/src/index.test.ts | 8 + .../src/track-changes/track-changes.ts | 17 +- packages/document-api/src/types/address.ts | 2 + .../src/types/track-changes.types.ts | 16 + packages/layout-engine/contracts/src/index.ts | 9 + .../layout-bridge/src/incrementalLayout.ts | 134 +- .../layout-engine/layout-bridge/src/index.ts | 110 +- .../src/sectionAwareHeaderFooter.ts | 231 ++ .../layout-bridge/src/text-measurement.ts | 18 +- .../test/selectionToRects.test.ts | 98 + .../test/text-measurement.test.ts | 19 + .../layout-engine/src/index.d.ts | 2 + .../layout-engine/src/index.test.ts | 19 + .../layout-engine/layout-engine/src/index.ts | 65 +- .../dom/src/renderer-position-mapping.test.ts | 49 + .../painters/dom/src/renderer.ts | 29 +- .../pm-adapter/src/converters/image.ts | 4 +- .../converters/inline-converters/common.ts | 2 + .../inline-converters/generic-token.test.ts | 24 + .../inline-converters/generic-token.ts | 3 +- .../converters/inline-converters/tab.test.ts | 37 +- .../src/converters/inline-converters/tab.ts | 3 +- .../inline-converters/text-run.test.ts | 7 + .../converters/inline-converters/text-run.ts | 5 +- .../src/converters/paragraph.test.ts | 1 + .../pm-adapter/src/converters/paragraph.ts | 16 +- .../pm-adapter/src/converters/table.ts | 12 +- .../pm-adapter/src/index.test.ts | 27 + .../layout-engine/pm-adapter/src/internal.ts | 1 + .../pm-adapter/src/marks/application.ts | 12 +- .../pm-adapter/src/tracked-changes.test.ts | 10 +- .../pm-adapter/src/tracked-changes.ts | 14 +- .../layout-engine/pm-adapter/src/types.ts | 10 + .../v1/assets/styles/layout/global.css | 4 +- .../editors/v1/core/Editor.setOptions.test.ts | 32 + .../src/editors/v1/core/Editor.ts | 21 +- .../v1/core/commands/core-command-map.d.ts | 12 +- .../header-footer/EditorOverlayManager.ts | 9 + .../HeaderFooterPerRidLayout.test.ts | 82 +- .../header-footer/HeaderFooterPerRidLayout.ts | 290 +-- .../HeaderFooterRegistry.test.ts | 80 + .../header-footer/HeaderFooterRegistry.ts | 42 +- .../header-footer/HeaderFooterRegistryInit.ts | 19 - .../editors/v1/core/header-footer/types.ts | 2 +- .../adapters/header-footer-part-descriptor.ts | 37 +- .../presentation-editor/PresentationEditor.ts | 1939 +++++++++++++++-- .../dom/EditorStyleInjector.test.ts | 3 - .../dom/EditorStyleInjector.ts | 16 - .../HeaderFooterSessionManager.ts | 833 +++++-- .../input/PresentationInputBridge.ts | 267 ++- .../layout/EndnotesBuilder.ts | 167 ++ .../layout/FootnotesBuilder.ts | 80 +- .../pointer-events/EditorInputManager.ts | 685 ++++-- .../LocalSelectionOverlayRendering.ts | 5 +- .../selection/VisibleTextOffsetGeometry.ts | 470 ++++ .../StoryPresentationSessionManager.test.ts | 354 +++ .../StoryPresentationSessionManager.ts | 337 +++ .../createStoryHiddenHost.test.ts | 50 + .../story-session/createStoryHiddenHost.ts | 55 + .../story-session/index.ts | 22 + .../story-session/types.ts | 137 ++ .../tests/DomPositionIndex.test.ts | 20 + .../EditorInputManager.footnoteClick.test.ts | 639 +++++- .../tests/FootnotesBuilder.test.ts | 121 + .../tests/HeaderFooterSessionManager.test.ts | 298 ++- .../LocalSelectionOverlayRendering.test.ts | 1 + .../PresentationEditor.collaboration.test.ts | 1 + ...sentationEditor.footnotesPmMarkers.test.ts | 7 +- .../tests/PresentationEditor.media.test.ts | 1 + .../tests/PresentationEditor.test.ts | 357 ++- .../tests/PresentationInputBridge.test.ts | 127 ++ .../tests/VisibleTextOffsetGeometry.test.ts | 223 ++ .../v1/core/presentation-editor/types.ts | 6 +- .../utils/CommentPositionCollection.ts | 73 +- .../v1/core/story-editor-factory.test.ts | 64 + .../editors/v1/core/story-editor-factory.ts | 29 +- .../v2/importer/documentFootnotesImporter.js | 15 +- .../v2/importer/docxImporter.js | 10 +- .../v2/importer/endnoteReferenceImporter.js | 7 + .../v2/importer/trackedChangeIdMapper.js | 61 +- .../v2/importer/trackedChangeIdMapper.test.js | 92 +- .../v3/handlers/w/del/del-translator.js | 9 +- .../v3/handlers/w/del/del-translator.test.js | 16 +- .../v3/handlers/w/ins/ins-translator.js | 9 +- .../v3/handlers/w/ins/ins-translator.test.js | 16 +- .../src/editors/v1/core/types/EditorEvents.ts | 19 +- .../v1/dev/components/DeveloperPlayground.vue | 4 +- .../helpers/note-pm-json.test.ts | 292 +++ .../helpers/note-pm-json.ts | 167 ++ .../helpers/tracked-change-resolver.ts | 107 +- .../tracked-change-runtime-ref.test.ts | 46 + .../helpers/tracked-change-runtime-ref.ts | 82 + .../track-changes-wrappers.test.ts | 151 ++ .../plan-engine/track-changes-wrappers.ts | 309 ++- .../header-footer-story-runtime.ts | 22 +- .../story-runtime/index.ts | 5 + .../live-story-session-runtime-registry.ts | 101 + .../story-runtime/note-story-runtime.test.ts | 134 ++ .../story-runtime/note-story-runtime.ts | 115 +- .../resolve-story-runtime.test.ts | 79 + .../story-runtime/resolve-story-runtime.ts | 5 + .../story-runtime/story-types.ts | 14 + .../__tests__/tracked-change-index.test.ts | 296 +++ .../tracked-changes/enumerate-stories.test.ts | 68 + .../tracked-changes/enumerate-stories.ts | 88 + .../tracked-changes/story-labels.test.ts | 77 + .../tracked-changes/story-labels.ts | 76 + .../tracked-changes/tracked-change-index.ts | 362 +++ .../tracked-change-snapshot.ts | 44 + .../v1/dom-observer/DomPointerMapping.test.ts | 93 + .../v1/dom-observer/DomPointerMapping.ts | 87 +- .../v1/dom-observer/DomPositionIndex.ts | 16 +- .../src/editors/v1/dom-observer/index.ts | 7 +- .../pagination/pagination-helpers.js | 36 +- .../pagination/pagination-helpers.test.js | 56 +- packages/super-editor/src/editors/v1/index.js | 31 + .../v1/tests/import/footnotesImporter.test.js | 39 + packages/super-editor/src/index.ts | 1 + packages/superdoc/src/SuperDoc.test.js | 134 +- packages/superdoc/src/SuperDoc.vue | 62 +- .../CommentsLayer/CommentDialog.test.js | 188 +- .../CommentsLayer/CommentDialog.vue | 140 +- .../CommentsLayer/FloatingComments.vue | 12 +- .../commentsList/commentsList.vue | 6 +- .../commentsList/super-comments-list.js | 10 + .../commentsList/super-comments-list.test.js | 24 + .../components/CommentsLayer/use-comment.js | 12 + .../superdoc/src/stores/comments-store.js | 328 ++- .../src/stores/comments-store.test.js | 824 ++++++- scripts/validate-command-types.mjs | 137 +- tests/behavior/fixtures/superdoc.ts | 2 +- tests/behavior/harness/index.html | 43 +- tests/behavior/harness/main.ts | 101 + tests/behavior/harness/vite.config.ts | 14 + tests/behavior/helpers/comments.ts | 20 +- tests/behavior/helpers/document-api.ts | 25 +- tests/behavior/helpers/story-fixtures.ts | 368 ++++ tests/behavior/helpers/story-replacements.ts | 55 + tests/behavior/helpers/story-surfaces.ts | 273 +++ .../behavior/helpers/story-tracked-changes.ts | 244 +++ ...stays-in-body-during-footnote-edit.spec.ts | 137 ++ ...-footer-live-tracked-change-bounds.spec.ts | 229 ++ ...eader-footer-tracked-change-bubble.spec.ts | 70 + ...-surface-tracked-change-activation.spec.ts | 161 ++ ...reject-format-suggestion-selection.spec.ts | 3 +- ...-1960-word-replacement-no-comments.spec.ts | 3 +- .../story-surface-import-bootstrap.spec.ts | 46 + .../story-surface-replacement-bubble.spec.ts | 173 ++ ...tory-surface-tracked-change-decide.spec.ts | 150 ++ ...ory-surface-tracked-change-sidebar.spec.ts | 34 + ...ked-change-independent-replacement.spec.ts | 124 ++ ...ked-change-independent-replacement.spec.ts | 14 +- .../tracked-change-partial-resolution.spec.ts | 10 +- .../undo-redo-tracked-change-sidebar.spec.ts | 8 +- ...ndo-tracked-insert-removes-sidebar.spec.ts | 31 +- .../double-click-edit-endnote.spec.ts | 50 + .../double-click-edit-footnote.spec.ts | 868 ++++++++ .../headers/double-click-edit-header.spec.ts | 298 ++- .../headers/header-footer-line-height.spec.ts | 60 +- .../header-footer-selection-overlay.spec.ts | 136 +- .../tests/search/search-and-navigate.spec.ts | 11 +- .../part-surface-multiclick-selection.spec.ts | 330 +++ 177 files changed, 17015 insertions(+), 1620 deletions(-) create mode 100644 apps/docs/document-api/reference/extract.mdx create mode 100644 packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts create mode 100644 packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts create mode 100644 tests/behavior/helpers/story-fixtures.ts create mode 100644 tests/behavior/helpers/story-replacements.ts create mode 100644 tests/behavior/helpers/story-surfaces.ts create mode 100644 tests/behavior/helpers/story-tracked-changes.ts create mode 100644 tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts create mode 100644 tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts create mode 100644 tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts create mode 100644 tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts create mode 100644 tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts create mode 100644 tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts create mode 100644 tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts create mode 100644 tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index a19aa9b2fa..666c871990 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -20,7 +20,7 @@ Use the tables below to see what operations are available and where each one is | Citations | 15 | 0 | 15 | [Reference](/document-api/reference/citations/index) | | Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) | | Content Controls | 55 | 0 | 55 | [Reference](/document-api/reference/content-controls/index) | -| Core | 13 | 0 | 13 | [Reference](/document-api/reference/core/index) | +| Core | 14 | 0 | 14 | [Reference](/document-api/reference/core/index) | | Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Cross-References | 5 | 0 | 5 | [Reference](/document-api/reference/cross-refs/index) | | Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) | @@ -148,6 +148,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.getHtml(...) | [`getHtml`](/document-api/reference/get-html) | | editor.doc.markdownToFragment(...) | [`markdownToFragment`](/document-api/reference/markdown-to-fragment) | | editor.doc.info(...) | [`info`](/document-api/reference/info) | +| editor.doc.extract(...) | [`extract`](/document-api/reference/extract) | | editor.doc.clearContent(...) | [`clearContent`](/document-api/reference/clear-content) | | editor.doc.insert(...) | [`insert`](/document-api/reference/insert) | | editor.doc.replace(...) | [`replace`](/document-api/reference/replace) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 8887fe4a9e..b886c7b66d 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -130,6 +130,7 @@ "apps/docs/document-api/reference/diff/capture.mdx", "apps/docs/document-api/reference/diff/compare.mdx", "apps/docs/document-api/reference/diff/index.mdx", + "apps/docs/document-api/reference/extract.mdx", "apps/docs/document-api/reference/fields/get.mdx", "apps/docs/document-api/reference/fields/index.mdx", "apps/docs/document-api/reference/fields/insert.mdx", @@ -436,6 +437,7 @@ "getHtml", "markdownToFragment", "info", + "extract", "clearContent", "insert", "replace", @@ -1016,5 +1018,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "b61fad6a3a330af8a57b78ded260c8d8918486c9829b50804227fbeb15e8bf53" + "sourceHash": "e74a36833ec8587b67447a79517de348cfc9b4bba1c564729c184f6d5464a018" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f64034b1be..d928604dd0 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -855,6 +855,11 @@ _No fields._ | `operations.diff.compare.dryRun` | boolean | yes | | | `operations.diff.compare.reasons` | enum[] | no | | | `operations.diff.compare.tracked` | boolean | yes | | +| `operations.extract` | object | yes | | +| `operations.extract.available` | boolean | yes | | +| `operations.extract.dryRun` | boolean | yes | | +| `operations.extract.reasons` | enum[] | no | | +| `operations.extract.tracked` | boolean | yes | | | `operations.fields.get` | object | yes | | | `operations.fields.get.available` | boolean | yes | | | `operations.fields.get.dryRun` | boolean | yes | | @@ -3071,6 +3076,11 @@ _No fields._ "dryRun": false, "tracked": false }, + "extract": { + "available": true, + "dryRun": false, + "tracked": false + }, "fields.get": { "available": true, "dryRun": false, @@ -10179,6 +10189,41 @@ _No fields._ ], "type": "object" }, + "extract": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "fields.get": { "additionalProperties": false, "properties": { @@ -19570,6 +19615,7 @@ _No fields._ "getHtml", "markdownToFragment", "info", + "extract", "clearContent", "insert", "replace", diff --git a/apps/docs/document-api/reference/content-controls/create.mdx b/apps/docs/document-api/reference/content-controls/create.mdx index 177cb4c016..620c897a9e 100644 --- a/apps/docs/document-api/reference/content-controls/create.mdx +++ b/apps/docs/document-api/reference/content-controls/create.mdx @@ -27,6 +27,10 @@ Returns a ContentControlMutationResult with the created content control target. | Field | Type | Required | Description | | --- | --- | --- | --- | | `alias` | string | no | | +| `at` | SelectionTarget | no | SelectionTarget | +| `at.end` | SelectionPoint | no | SelectionPoint | +| `at.kind` | `"selection"` | no | Constant: `"selection"` | +| `at.start` | SelectionPoint | no | SelectionPoint | | `content` | string | no | | | `controlType` | string | no | | | `kind` | enum | yes | `"block"`, `"inline"` | @@ -120,6 +124,9 @@ Returns a ContentControlMutationResult with the created content control target. "alias": { "type": "string" }, + "at": { + "$ref": "#/$defs/SelectionTarget" + }, "content": { "type": "string" }, diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx index 19c242887f..6f4931ff98 100644 --- a/apps/docs/document-api/reference/core/index.mdx +++ b/apps/docs/document-api/reference/core/index.mdx @@ -21,6 +21,7 @@ Primary read and write operations. | getHtml | `getHtml` | No | `idempotent` | No | No | | markdownToFragment | `markdownToFragment` | No | `idempotent` | No | No | | info | `info` | No | `idempotent` | No | No | +| extract | `extract` | No | `idempotent` | No | No | | clearContent | `clearContent` | Yes | `conditional` | No | No | | insert | `insert` | Yes | `non-idempotent` | Yes | Yes | | replace | `replace` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/create/heading.mdx b/apps/docs/document-api/reference/create/heading.mdx index 1d7f43d48c..06bbbdc051 100644 --- a/apps/docs/document-api/reference/create/heading.mdx +++ b/apps/docs/document-api/reference/create/heading.mdx @@ -99,7 +99,11 @@ Returns a CreateHeadingResult with the new heading block ID and address. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx index e2d1c4a43a..c115b81b33 100644 --- a/apps/docs/document-api/reference/create/paragraph.mdx +++ b/apps/docs/document-api/reference/create/paragraph.mdx @@ -97,7 +97,11 @@ Returns a CreateParagraphResult with the new paragraph block ID and address. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx new file mode 100644 index 0000000000..0eb276f66a --- /dev/null +++ b/apps/docs/document-api/reference/extract.mdx @@ -0,0 +1,222 @@ +--- +title: extract +sidebarTitle: extract +description: Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +## Summary + +Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). + +- Operation ID: `extract` +- API member path: `editor.doc.extract(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ExtractResult with blocks (nodeId, type, text, headingLevel), comments (entityId, text, anchoredText, blockId, status, author), tracked changes (entityId, type, excerpt, author, date), and revision. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `blocks` | object[] | yes | | +| `comments` | object[] | yes | | +| `revision` | string | yes | | +| `trackedChanges` | object[] | yes | | + +### Example response + +```json +{ + "blocks": [ + { + "headingLevel": 1, + "nodeId": "node-def456", + "text": "Hello, world.", + "type": "example" + } + ], + "comments": [ + { + "anchoredText": "example", + "entityId": "entity-789", + "status": "open", + "text": "Hello, world." + } + ], + "revision": "example", + "trackedChanges": [ + { + "author": "Jane Doe", + "entityId": "entity-789", + "excerpt": "Sample excerpt...", + "type": "insert" + } + ] +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "additionalProperties": false, + "properties": { + "headingLevel": { + "description": "Heading level (1–6). Only present for headings.", + "type": "integer" + }, + "nodeId": { + "description": "Stable block ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "text": { + "description": "Full plain text content of the block.", + "type": "string" + }, + "type": { + "description": "Block type: paragraph, heading, listItem, table, image, etc.", + "type": "string" + } + }, + "required": [ + "nodeId", + "type", + "text" + ], + "type": "object" + }, + "type": "array" + }, + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "anchoredText": { + "description": "The document text the comment is anchored to.", + "type": "string" + }, + "author": { + "description": "Comment author name.", + "type": "string" + }, + "blockId": { + "description": "Block ID the comment is anchored to.", + "type": "string" + }, + "entityId": { + "description": "Comment entity ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "status": { + "enum": [ + "open", + "resolved" + ], + "type": "string" + }, + "text": { + "description": "Comment body text.", + "type": "string" + } + }, + "required": [ + "entityId", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "revision": { + "description": "Document revision at the time of extraction.", + "type": "string" + }, + "trackedChanges": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "description": "Change author name.", + "type": "string" + }, + "date": { + "description": "Change date (ISO string).", + "type": "string" + }, + "entityId": { + "description": "Tracked change entity ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "excerpt": { + "description": "Short text excerpt of the changed content.", + "type": "string" + }, + "type": { + "enum": [ + "insert", + "delete", + "format" + ], + "type": "string" + } + }, + "required": [ + "entityId", + "type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "blocks", + "comments", + "trackedChanges", + "revision" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index ec6d5c293e..8ddf5e92d2 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -19,7 +19,7 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | -| Core | 13 | 0 | 13 | [Open](/document-api/reference/core/index) | +| Core | 14 | 0 | 14 | [Open](/document-api/reference/core/index) | | Blocks | 3 | 0 | 3 | [Open](/document-api/reference/blocks/index) | | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | @@ -70,6 +70,7 @@ The tables below are grouped by namespace. | getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. | | markdownToFragment | editor.doc.markdownToFragment(...) | Convert a Markdown string into an SDM/1 structural fragment. | | info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. | +| extract | editor.doc.extract(...) | Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). | | clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx index ce184c103e..05294706fe 100644 --- a/apps/docs/document-api/reference/lists/insert.mdx +++ b/apps/docs/document-api/reference/lists/insert.mdx @@ -98,7 +98,11 @@ Returns a ListsInsertResult with the new list item address and block ID. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index 5b0a3ba124..cfd98e37fb 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -35,7 +35,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan { "decision": "accept", "target": { - "id": "id-001" + "id": "id-001", + "story": { + "kind": "story", + "storyType": "body" + } } } ``` @@ -114,6 +118,9 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "id": { "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" } }, "required": [ diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index c57851388e..f3d9ab8a54 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -27,12 +27,17 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | +| `story` | StoryLocator | no | StoryLocator | ### Example request ```json { - "id": "id-001" + "id": "id-001", + "story": { + "kind": "story", + "storyType": "body" + } } ``` @@ -44,6 +49,7 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `address.entityId` | string | yes | | | `address.entityType` | `"trackedChange"` | yes | Constant: `"trackedChange"` | | `address.kind` | `"entity"` | yes | Constant: `"entity"` | +| `address.story` | StoryLocator | no | StoryLocator | | `author` | string | no | | | `authorEmail` | string | no | | | `authorImage` | string | no | | @@ -63,7 +69,11 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "address": { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } }, "author": "Jane Doe", "id": "id-001", @@ -92,6 +102,9 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "properties": { "id": { "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" } }, "required": [ diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index bcb7e86ada..6411a19b16 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -26,6 +26,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r | Field | Type | Required | Description | | --- | --- | --- | --- | +| `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` | | `limit` | integer | no | | | `offset` | integer | no | | | `type` | enum | no | `"insert"`, `"delete"`, `"format"` | @@ -61,7 +62,11 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "address": { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } }, "author": "Jane Doe", "handle": { @@ -101,6 +106,17 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r { "additionalProperties": false, "properties": { + "in": { + "description": "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.", + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "const": "all" + } + ] + }, "limit": { "description": "Maximum number of tracked changes to return.", "type": "integer" diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index 7934baf06e..f1dc0b3bde 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -82,7 +82,7 @@ Deterministic outcomes: - Missing tracked-change capabilities must fail with `CAPABILITY_UNAVAILABLE`. - Text/format targets that cannot be resolved after remote edits must fail deterministically (`TARGET_NOT_FOUND` / `NO_OP`), never silently mutate the wrong range. - Tracked entity IDs returned by mutation receipts (`insert` / `replace` / `delete`) and `create.paragraph.trackedChangeRefs` must match canonical IDs from `trackChanges.list`. -- `trackChanges.get` / `accept` / `reject` accept canonical IDs only. +- `trackChanges.get` / `trackChanges.decide` accept canonical tracked-change IDs. Include `story` when targeting a non-body change. ## Common Workflows @@ -699,27 +699,27 @@ List all comments in the document. Optionally include resolved comments. ### `trackChanges.list` -List tracked changes in the document. Supports filtering by `type` and pagination via `limit`/`offset`. +List tracked changes in the document. Supports filtering by `type`, pagination via `limit`/`offset`, and story scoping via `in`. -- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type? }`) +- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type?, in?: StoryLocator | 'all' }`) - **Output**: `TrackChangesListResult` (`{ items, total }`) - **Mutates**: No - **Idempotency**: idempotent ### `trackChanges.get` -Retrieve full information for a single tracked change by its canonical ID. Throws `TARGET_NOT_FOUND` when the ID is invalid. +Retrieve full information for a single tracked change by its canonical ID. Include `story` for non-body changes. Throws `TARGET_NOT_FOUND` when the ID is invalid. -- **Input**: `TrackChangesGetInput` (`{ id }`) +- **Input**: `TrackChangesGetInput` (`{ id, story? }`) - **Output**: `TrackChangeInfo` (includes `wordRevisionIds` with raw imported Word OOXML `w:id` values when available) - **Mutates**: No - **Idempotency**: idempotent ### `trackChanges.decide` -Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. +Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. Include `story` when the change lives outside the body. -- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id } | { scope: 'all' } }`) +- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id, story? } | { scope: 'all' } }`) - **Output**: `Receipt` - **Mutates**: Yes - **Idempotency**: conditional diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index a3d1c9c321..4e2e73d1ff 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -390,6 +390,7 @@ const SHARED_DEFS: Record = { kind: { const: 'entity' }, entityType: { const: 'trackedChange' }, entityId: { type: 'string' }, + story: ref('StoryLocator'), }, ['kind', 'entityType', 'entityId'], ), @@ -4707,11 +4708,16 @@ const operationSchemas: Record = { enum: ['insert', 'delete', 'format'], description: "Filter by change type: 'insert', 'delete', or 'format'.", }, + in: { + oneOf: [storyLocatorSchema, { const: 'all' }], + description: + "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.", + }, }), output: trackChangesListResultSchema, }, 'trackChanges.get': { - input: objectSchema({ id: { type: 'string' } }, ['id']), + input: objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), output: trackChangeInfoSchema, }, 'trackChanges.decide': { @@ -4721,7 +4727,7 @@ const operationSchemas: Record = { decision: { enum: ['accept', 'reject'] }, target: { oneOf: [ - objectSchema({ id: { type: 'string' } }, ['id']), + objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), objectSchema({ scope: { enum: ['all'] } }, ['scope']), ], }, diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index d03716a034..9aff031ee7 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -795,6 +795,7 @@ describe('createDocumentApi', () => { it('delegates trackChanges read operations', () => { const trackAdpt = makeTrackChangesAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -811,15 +812,20 @@ describe('createDocumentApi', () => { const listResult = api.trackChanges.list({ limit: 1 }); const getResult = api.trackChanges.get({ id: 'tc-1' }); + api.trackChanges.list({ in: footnoteStory, type: 'insert' }); + api.trackChanges.get({ id: 'tc-2', story: footnoteStory }); expect(listResult.total).toBe(0); expect(getResult.id).toBe('tc-1'); expect(trackAdpt.list).toHaveBeenCalledWith({ limit: 1 }); expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-1' }); + expect(trackAdpt.list).toHaveBeenCalledWith({ in: footnoteStory, type: 'insert' }); + expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }); }); it('delegates trackChanges.decide to trackChanges adapter methods', () => { const trackAdpt = makeTrackChangesAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -836,6 +842,7 @@ describe('createDocumentApi', () => { const acceptResult = api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } }); const rejectResult = api.trackChanges.decide({ decision: 'reject', target: { id: 'tc-1' } }); + api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-2', story: footnoteStory } }); const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } }); const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); @@ -845,6 +852,7 @@ describe('createDocumentApi', () => { expect(rejectAllResult.success).toBe(true); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); + expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }, undefined); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 30ec5d433f..06f1a90f64 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,4 +1,5 @@ import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; +import type { StoryLocator } from '../types/story.types.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; @@ -6,14 +7,20 @@ export type TrackChangesListInput = TrackChangesListQuery; export interface TrackChangesGetInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export interface TrackChangesAcceptInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export interface TrackChangesRejectInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export type TrackChangesAcceptAllInput = Record; @@ -25,8 +32,8 @@ export type TrackChangesRejectAllInput = Record; // --------------------------------------------------------------------------- export type ReviewDecideInput = - | { decision: 'accept'; target: { id: string } } - | { decision: 'reject'; target: { id: string } } + | { decision: 'accept'; target: { id: string; story?: StoryLocator } } + | { decision: 'reject'; target: { id: string; story?: StoryLocator } } | { decision: 'accept'; target: { scope: 'all' } } | { decision: 'reject'; target: { scope: 'all' } }; @@ -133,11 +140,13 @@ export function executeTrackChangesDecide( } } + const story = (target as { story?: StoryLocator }).story; + if (input.decision === 'accept') { if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.accept({ id: target.id as string }, options); + return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); } if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); - return adapter.reject({ id: target.id as string }, options); + return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); } diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 1c9484d051..7414740445 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -125,6 +125,8 @@ export type TrackedChangeAddress = { kind: 'entity'; entityType: 'trackedChange'; entityId: string; + /** Story containing this tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; }; export type EntityAddress = CommentAddress | TrackedChangeAddress; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 8f3adeb92f..3fa319211e 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -1,8 +1,17 @@ import type { TrackedChangeAddress } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; +import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; +/** + * Scope marker used by {@link TrackChangesListQuery.in} to request changes + * across every revision-capable story (body + headers + footers + footnotes + + * endnotes). Equivalent to a multi-story aggregate list. + */ +export const TRACK_CHANGES_IN_ALL = 'all' as const; +export type TrackChangesInAll = typeof TRACK_CHANGES_IN_ALL; + /** * Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. * @@ -36,6 +45,13 @@ export interface TrackChangesListQuery { limit?: number; offset?: number; type?: TrackChangeType; + /** + * Story scope. + * - `undefined` (default) — body only (backward compatible). + * - A {@link StoryLocator} — only that story. + * - `'all'` — flat list across body + every revision-capable non-body story. + */ + in?: StoryLocator | TrackChangesInAll; } /** diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 7f29f5f9d0..968b950327 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -160,6 +160,15 @@ export type RunMark = { export type TrackedChangeMeta = { kind: TrackedChangeKind; id: string; + /** + * Internal story key identifying which content story owns this tracked + * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). + * + * Set by the PM adapter during conversion and stamped on the rendered DOM + * as `data-story-key` so downstream code can distinguish anchors across + * stories without re-resolving the story runtime. + */ + storyKey?: string; author?: string; authorEmail?: string; authorImage?: string; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 79a4423c87..95cfe45a5a 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -25,6 +25,10 @@ import { remeasureParagraph } from './remeasure'; import { computeDirtyRegions } from './diff'; import { MeasureCache } from './cache'; import { layoutHeaderFooterWithCache, HeaderFooterLayoutCache, type HeaderFooterBatch } from './layoutHeaderFooter'; +import { + buildSectionAwareHeaderFooterLayoutKey, + buildSectionAwareHeaderFooterMeasurementGroups, +} from './sectionAwareHeaderFooter'; import { FeatureFlags } from './featureFlags'; import { PageTokenLogger, HeaderFooterCacheLogger, globalMetrics } from './instrumentation'; import { HeaderFooterCacheState, invalidateHeaderFooterCache } from './cacheInvalidation'; @@ -886,10 +890,83 @@ export async function incrementalLayout( * Values are the actual content heights in pixels. */ let headerContentHeightsByRId: Map | undefined; + let headerContentHeightsBySectionRef: Map | undefined; // Check if we have headers via either headerBlocks (by variant) or headerBlocksByRId (by relationship ID) const hasHeaderBlocks = headerFooter?.headerBlocks && Object.keys(headerFooter.headerBlocks).length > 0; const hasHeaderBlocksByRId = headerFooter?.headerBlocksByRId && headerFooter.headerBlocksByRId.size > 0; + const sectionMetadata = options.sectionMetadata ?? []; + + const measureHeightsByReference = async ( + kind: 'header' | 'footer', + blocksByRId: Map | undefined, + constraints: HeaderFooterConstraints, + measureFn: HeaderFooterMeasureFn, + ): Promise<{ + heightsByRId?: Map; + heightsBySectionRef?: Map; + }> => { + if (!blocksByRId || blocksByRId.size === 0) { + return {}; + } + + const heightsByRId = new Map(); + const heightsBySectionRef = new Map(); + const sectionAwareGroups = buildSectionAwareHeaderFooterMeasurementGroups( + kind, + blocksByRId, + sectionMetadata, + constraints, + ); + + if (sectionAwareGroups.length > 0) { + for (const group of sectionAwareGroups) { + const blocks = blocksByRId.get(group.rId); + if (!blocks || blocks.length === 0) continue; + + const measureConstraints = { + maxWidth: group.sectionConstraints.width, + maxHeight: group.sectionConstraints.height, + }; + const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); + const layout = layoutHeaderFooter(blocks, measures, group.sectionConstraints, kind); + if (!(layout.height > 0)) continue; + + const nextHeight = Math.max(0, layout.height); + const currentHeight = heightsByRId.get(group.rId) ?? 0; + if (nextHeight > currentHeight) { + heightsByRId.set(group.rId, nextHeight); + } + + for (const sectionIndex of group.sectionIndices) { + heightsBySectionRef.set(buildSectionAwareHeaderFooterLayoutKey(group.rId, sectionIndex), nextHeight); + } + } + + return { + heightsByRId: heightsByRId.size > 0 ? heightsByRId : undefined, + heightsBySectionRef: heightsBySectionRef.size > 0 ? heightsBySectionRef : undefined, + }; + } + + for (const [rId, blocks] of blocksByRId) { + if (!blocks || blocks.length === 0) continue; + + const measureConstraints = { + maxWidth: constraints.width, + maxHeight: constraints.height, + }; + const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); + const layout = layoutHeaderFooter(blocks, measures, constraints, kind); + if (layout.height > 0) { + heightsByRId.set(rId, layout.height); + } + } + + return { + heightsByRId: heightsByRId.size > 0 ? heightsByRId : undefined, + }; + }; if (headerFooter?.constraints && (hasHeaderBlocks || hasHeaderBlocksByRId)) { const hfPreStart = performance.now(); @@ -953,22 +1030,14 @@ export async function incrementalLayout( // Also extract heights from headerBlocksByRId (for multi-section documents) // Store each rId's height separately for per-page margin calculation if (hasHeaderBlocksByRId && headerFooter.headerBlocksByRId) { - headerContentHeightsByRId = new Map(); - for (const [rId, blocks] of headerFooter.headerBlocksByRId) { - if (!blocks || blocks.length === 0) continue; - // Measure blocks to get height - const measureConstraints = { - maxWidth: headerFooter.constraints.width, - maxHeight: headerFooter.constraints.height, - }; - const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); - // Layout to get actual height — pass full constraints for page-relative normalization - const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'header'); - if (layout.height > 0) { - // Store height by rId for per-page margin calculation - headerContentHeightsByRId.set(rId, layout.height); - } - } + const measuredHeights = await measureHeightsByReference( + 'header', + headerFooter.headerBlocksByRId, + headerFooter.constraints, + measureFn, + ); + headerContentHeightsByRId = measuredHeights.heightsByRId; + headerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef; } const hfPreEnd = performance.now(); @@ -993,6 +1062,7 @@ export async function incrementalLayout( * Values are the actual content heights in pixels. */ let footerContentHeightsByRId: Map | undefined; + let footerContentHeightsBySectionRef: Map | undefined; // Check if we have footers via either footerBlocks (by variant) or footerBlocksByRId (by relationship ID) const hasFooterBlocks = headerFooter?.footerBlocks && Object.keys(headerFooter.footerBlocks).length > 0; @@ -1064,22 +1134,14 @@ export async function incrementalLayout( // Also extract heights from footerBlocksByRId (for multi-section documents) // Store each rId's height separately for per-page margin calculation if (hasFooterBlocksByRId && headerFooter.footerBlocksByRId) { - footerContentHeightsByRId = new Map(); - for (const [rId, blocks] of headerFooter.footerBlocksByRId) { - if (!blocks || blocks.length === 0) continue; - // Measure blocks to get height - const measureConstraints = { - maxWidth: headerFooter.constraints.width, - maxHeight: headerFooter.constraints.height, - }; - const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); - // Layout to get actual height — pass full constraints for page-relative normalization - const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'footer'); - if (layout.height > 0) { - // Store height by rId for per-page margin calculation - footerContentHeightsByRId.set(rId, layout.height); - } - } + const measuredHeights = await measureHeightsByReference( + 'footer', + headerFooter.footerBlocksByRId, + headerFooter.constraints, + measureFn, + ); + footerContentHeightsByRId = measuredHeights.heightsByRId; + footerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef; } } catch (error) { console.error('[Layout] Footer pre-layout failed:', error); @@ -1095,7 +1157,9 @@ export async function incrementalLayout( ...options, headerContentHeights, // Pass header heights to prevent overlap (per-variant) footerContentHeights, // Pass footer heights to prevent overlap (per-variant) + headerContentHeightsBySectionRef, // Pass header heights by rId+section for exact page-specific margin calculation headerContentHeightsByRId, // Pass header heights by rId for per-page margin calculation + footerContentHeightsBySectionRef, // Pass footer heights by rId+section for exact page-specific margin calculation footerContentHeightsByRId, // Pass footer heights by rId for per-page margin calculation remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), @@ -1179,7 +1243,9 @@ export async function incrementalLayout( ...options, headerContentHeights, // Pass header heights to prevent overlap (per-variant) footerContentHeights, // Pass footer heights to prevent overlap (per-variant) + headerContentHeightsBySectionRef, // Pass header heights by rId+section for exact page-specific margin calculation headerContentHeightsByRId, // Pass header heights by rId for per-page margin calculation + footerContentHeightsBySectionRef, // Pass footer heights by rId+section for exact page-specific margin calculation footerContentHeightsByRId, // Pass footer heights by rId for per-page margin calculation remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), @@ -1771,6 +1837,10 @@ export async function incrementalLayout( footnoteReservedByPageIndex, headerContentHeights, footerContentHeights, + headerContentHeightsBySectionRef, + headerContentHeightsByRId, + footerContentHeightsBySectionRef, + footerContentHeightsByRId, remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), }); diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 9eb9fa4018..8d199afbe5 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -56,6 +56,18 @@ export { export type { HeaderFooterBatch, DigitBucket } from './layoutHeaderFooter'; export { findWordBoundaries, findParagraphBoundaries } from './text-boundaries'; export type { BoundaryRange } from './text-boundaries'; +export { + buildSectionAwareHeaderFooterLayoutKey, + buildSectionContentWidth, + buildEffectiveHeaderFooterRefsBySection, + collectReferencedHeaderFooterRIds, + buildSectionAwareHeaderFooterMeasurementGroups, +} from './sectionAwareHeaderFooter'; +export type { + HeaderFooterSectionKind, + HeaderFooterRefs, + SectionAwareHeaderFooterMeasurementGroup, +} from './sectionAwareHeaderFooter'; export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout'; export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout'; // Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering @@ -576,6 +588,8 @@ export function selectionToRects( // (accounts for gaps in PM positions between runs) const charOffsetFrom = pmPosToCharOffset(block, line, sliceFrom); const charOffsetTo = pmPosToCharOffset(block, line, sliceTo); + const visualCharOffsetFrom = pmPosToVisualCharOffset(block, line, sliceFrom); + const visualCharOffsetTo = pmPosToVisualCharOffset(block, line, sliceTo); // Detect list items by checking for marker presence const markerWidth = fragment.markerWidth ?? measure.marker?.markerWidth ?? 0; const isListItemFlag = isListItem(markerWidth, block); @@ -589,7 +603,7 @@ export function selectionToRects( const startX = mapPmToX( block, line, - charOffsetFrom, + visualCharOffsetFrom, fragment.width, alignmentOverride, isFirstLine, @@ -598,7 +612,7 @@ export function selectionToRects( const endX = mapPmToX( block, line, - charOffsetTo, + visualCharOffsetTo, fragment.width, alignmentOverride, isFirstLine, @@ -676,6 +690,8 @@ export function selectionToRects( sliceTo, charOffsetFrom, charOffsetTo, + visualCharOffsetFrom, + visualCharOffsetTo, startX, endX, rect: { x: rectX, y: rectY, width: rectWidth, height: line.lineHeight }, @@ -686,8 +702,15 @@ export function selectionToRects( Math.max(charOffsetFrom, charOffsetTo), ), indent: (block.attrs as { indent?: unknown } | undefined)?.indent, + alignment: (block.attrs as { alignment?: unknown } | undefined)?.alignment, marker: measure.marker, + markerWidth, + isListItemFlag, + alignmentOverride, lineSegments: line.segments, + lineSpaceCount: (line as { spaceCount?: unknown }).spaceCount, + lineNaturalWidth: (line as { naturalWidth?: unknown }).naturalWidth, + lineMaxWidth: (line as { maxWidth?: unknown }).maxWidth, }); } }); @@ -903,13 +926,15 @@ export function selectionToRects( const charOffsetFrom = pmPosToCharOffset(info.block, line, sliceFrom); const charOffsetTo = pmPosToCharOffset(info.block, line, sliceTo); + const visualCharOffsetFrom = pmPosToVisualCharOffset(info.block, line, sliceFrom); + const visualCharOffsetTo = pmPosToVisualCharOffset(info.block, line, sliceTo); const availableWidth = Math.max(1, cellMeasure.width - padding.left - padding.right); const isFirstLine = index === 0; const cellMarkerTextWidth = info.measure?.marker?.markerTextWidth ?? undefined; const startX = mapPmToX( info.block, line, - charOffsetFrom, + visualCharOffsetFrom, availableWidth, alignmentOverride, isFirstLine, @@ -918,7 +943,7 @@ export function selectionToRects( const endX = mapPmToX( info.block, line, - charOffsetTo, + visualCharOffsetTo, availableWidth, alignmentOverride, isFirstLine, @@ -1325,6 +1350,83 @@ export function pmPosToCharOffset(block: FlowBlock, line: Line, pmPos: number): return charOffset; } +/** + * Convert a ProseMirror position to a rendered character offset within a line. + * + * Unlike {@link pmPosToCharOffset}, this helper includes visual-only text runs + * that do not carry PM positions. That matters for selection highlighting when + * a line starts with rendered chrome such as a synthetic footnote number: + * the marker consumes horizontal space in the painter, but it is not part of + * the editable PM story. Using a PM-only offset would place the highlight too + * far left by the marker's width. + * + * The returned offset is intended for visual X mapping, not for slicing PM text. + */ +export function pmPosToVisualCharOffset(block: FlowBlock, line: Line, pmPos: number): number { + if (block.kind !== 'paragraph') return 0; + + let visualOffset = 0; + + for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { + const run = block.runs[runIndex]; + if (!run) continue; + + const text = + 'src' in run || + run.kind === 'lineBreak' || + run.kind === 'break' || + run.kind === 'fieldAnnotation' || + run.kind === 'math' + ? '' + : (run.text ?? ''); + const runTextLength = text.length; + if (runTextLength === 0) { + continue; + } + + const isFirstRun = runIndex === line.fromRun; + const isLastRun = runIndex === line.toRun; + const lineStartChar = isFirstRun ? line.fromChar : 0; + const lineEndChar = isLastRun ? line.toChar : runTextLength; + const runSliceCharCount = lineEndChar - lineStartChar; + if (runSliceCharCount <= 0) { + continue; + } + + const runPmStart = run.pmStart ?? null; + const runPmEnd = run.pmEnd ?? (runPmStart != null ? runPmStart + runTextLength : null); + + if (runPmStart == null || runPmEnd == null) { + visualOffset += runSliceCharCount; + continue; + } + + const runPmRange = runPmEnd - runPmStart; + const runSlicePmStart = runPmStart + (lineStartChar / runTextLength) * runPmRange; + const runSlicePmEnd = runPmStart + (lineEndChar / runTextLength) * runPmRange; + + if (pmPos >= runSlicePmStart && pmPos <= runSlicePmEnd) { + const runSlicePmRange = runSlicePmEnd - runSlicePmStart; + if (runSlicePmRange <= 0) { + return visualOffset; + } + + const pmOffsetInSlice = pmPos - runSlicePmStart; + const visualOffsetInSlice = Math.round((pmOffsetInSlice / runSlicePmRange) * runSliceCharCount); + return visualOffset + Math.min(visualOffsetInSlice, runSliceCharCount); + } + + if (pmPos > runSlicePmEnd) { + visualOffset += runSliceCharCount; + continue; + } + + return visualOffset; + } + + return visualOffset; +} + // determineColumn, findLineIndexAtY are now in position-hit.ts and re-exported above. const lineHeightBeforeIndex = (measure: Measure, absoluteLineIndex: number): number => { diff --git a/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts new file mode 100644 index 0000000000..121171e712 --- /dev/null +++ b/packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts @@ -0,0 +1,231 @@ +import type { FlowBlock, SectionMetadata, SectionRefType } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR } from '@superdoc/contracts'; +import type { HeaderFooterConstraints } from '@superdoc/layout-engine'; + +export type HeaderFooterSectionKind = 'header' | 'footer'; +export type HeaderFooterRefs = Partial>; + +export type SectionAwareHeaderFooterMeasurementGroup = { + rId: string; + sectionIndices: Set; + sectionConstraints: HeaderFooterConstraints; + effectiveWidth: number; +}; + +type TableWidthSpec = { + type: 'pct' | 'grid' | 'px'; + value: number; +}; + +const HEADER_FOOTER_VARIANTS: SectionRefType[] = ['default', 'first', 'even', 'odd']; + +export function buildSectionAwareHeaderFooterLayoutKey(rId: string, sectionIndex: number): string { + return `${rId}::s${sectionIndex}`; +} + +export function buildSectionContentWidth(section: SectionMetadata, fallback: HeaderFooterConstraints): number { + const pageWidth = section.pageSize?.w ?? fallback.pageWidth ?? 0; + const marginLeft = section.margins?.left ?? fallback.margins?.left ?? 0; + const marginRight = section.margins?.right ?? fallback.margins?.right ?? 0; + + return pageWidth - marginLeft - marginRight; +} + +export function buildEffectiveHeaderFooterRefsBySection( + sectionMetadata: SectionMetadata[], + kind: HeaderFooterSectionKind, +): Map { + const effectiveRefsBySection = new Map(); + let inheritedRefs: HeaderFooterRefs = {}; + + for (const section of sectionMetadata) { + const explicitRefs = kind === 'header' ? section.headerRefs : section.footerRefs; + const effectiveRefs: HeaderFooterRefs = { ...inheritedRefs }; + + for (const variant of HEADER_FOOTER_VARIANTS) { + const refId = explicitRefs?.[variant]; + if (refId) { + effectiveRefs[variant] = refId; + } + } + + if (Object.keys(effectiveRefs).length > 0) { + effectiveRefsBySection.set(section.sectionIndex, effectiveRefs); + } + + inheritedRefs = effectiveRefs; + } + + return effectiveRefsBySection; +} + +export function collectReferencedHeaderFooterRIds(effectiveRefsBySection: Map): Set { + const referencedRIds = new Set(); + + for (const refs of effectiveRefsBySection.values()) { + for (const variant of HEADER_FOOTER_VARIANTS) { + const refId = refs[variant]; + if (refId) { + referencedRIds.add(refId); + } + } + } + + return referencedRIds; +} + +function buildConstraintsForSection( + section: SectionMetadata, + fallback: HeaderFooterConstraints, + minWidth?: number, +): HeaderFooterConstraints { + const pageWidth = section.pageSize?.w ?? fallback.pageWidth ?? 0; + const pageHeight = section.pageSize?.h ?? fallback.pageHeight; + const marginLeft = section.margins?.left ?? fallback.margins?.left ?? 0; + const marginRight = section.margins?.right ?? fallback.margins?.right ?? 0; + const marginTop = section.margins?.top ?? fallback.margins?.top; + const marginBottom = section.margins?.bottom ?? fallback.margins?.bottom; + const headerMargin = section.margins?.header ?? fallback.margins?.header; + const footerMargin = section.margins?.footer ?? fallback.margins?.footer; + const contentWidth = pageWidth - marginLeft - marginRight; + const maxWidth = pageWidth - marginLeft; + const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth; + const sectionMarginTop = marginTop ?? 0; + const sectionMarginBottom = marginBottom ?? 0; + const sectionHeight = + pageHeight != null ? Math.max(1, pageHeight - sectionMarginTop - sectionMarginBottom) : fallback.height; + + return { + width: effectiveWidth, + height: sectionHeight, + pageWidth, + pageHeight, + margins: { + left: marginLeft, + right: marginRight, + top: marginTop, + bottom: marginBottom, + header: headerMargin, + footer: footerMargin, + }, + overflowBaseHeight: fallback.overflowBaseHeight, + }; +} + +function getTableWidthSpec(blocks: FlowBlock[]): TableWidthSpec | undefined { + let widestSpec: TableWidthSpec | undefined; + let maxResolvedWidth = 0; + + for (const block of blocks) { + if (block.kind !== 'table') continue; + + const tableWidth = (block as { attrs?: { tableWidth?: { width?: number; value?: number; type?: string } } }).attrs + ?.tableWidth; + const widthValue = tableWidth?.width ?? tableWidth?.value; + + if (tableWidth?.type === 'pct' && typeof widthValue === 'number' && widthValue > 0) { + if (!widestSpec || widestSpec.type !== 'pct' || widthValue > widestSpec.value) { + widestSpec = { type: 'pct', value: widthValue }; + maxResolvedWidth = Number.POSITIVE_INFINITY; + } + continue; + } + + if ((tableWidth?.type === 'px' || tableWidth?.type === 'pixel') && typeof widthValue === 'number') { + if (widthValue > maxResolvedWidth) { + maxResolvedWidth = widthValue; + widestSpec = { type: 'px', value: widthValue }; + } + continue; + } + + if (block.columnWidths && block.columnWidths.length > 0) { + const gridWidth = block.columnWidths.reduce((sum, columnWidth) => sum + columnWidth, 0); + if (gridWidth > maxResolvedWidth) { + maxResolvedWidth = gridWidth; + widestSpec = { type: 'grid', value: gridWidth }; + } + } + } + + return widestSpec; +} + +function resolveTableMinWidth(spec: TableWidthSpec | undefined, contentWidth: number): number { + if (!spec) return 0; + if (spec.type === 'pct') { + return contentWidth * (spec.value / OOXML_PCT_DIVISOR); + } + + return spec.value; +} + +export function buildSectionAwareHeaderFooterMeasurementGroups( + kind: HeaderFooterSectionKind, + blocksByRId: Map | undefined, + sectionMetadata: SectionMetadata[], + fallbackConstraints: HeaderFooterConstraints, +): SectionAwareHeaderFooterMeasurementGroup[] { + if (!blocksByRId || sectionMetadata.length === 0) { + return []; + } + + const effectiveRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, kind); + const tableWidthSpecByRId = new Map(); + + for (const [rId, blocks] of blocksByRId) { + const tableWidthSpec = getTableWidthSpec(blocks); + if (tableWidthSpec) { + tableWidthSpecByRId.set(rId, tableWidthSpec); + } + } + + const groups = new Map(); + + for (const section of sectionMetadata) { + const refs = effectiveRefsBySection.get(section.sectionIndex); + if (!refs) continue; + + const uniqueRIds = new Set(); + for (const variant of HEADER_FOOTER_VARIANTS) { + const refId = refs[variant]; + if (refId) { + uniqueRIds.add(refId); + } + } + + for (const rId of uniqueRIds) { + if (!blocksByRId.has(rId)) continue; + + const contentWidth = buildSectionContentWidth(section, fallbackConstraints); + const tableWidthSpec = tableWidthSpecByRId.get(rId); + const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); + const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); + const effectiveWidth = sectionConstraints.width; + const groupKey = [ + rId, + `w${effectiveWidth}`, + `ph${sectionConstraints.pageHeight ?? ''}`, + `mt${sectionConstraints.margins?.top ?? ''}`, + `mb${sectionConstraints.margins?.bottom ?? ''}`, + `mh${sectionConstraints.margins?.header ?? ''}`, + `mf${sectionConstraints.margins?.footer ?? ''}`, + ].join('::'); + + const existingGroup = groups.get(groupKey); + if (existingGroup) { + existingGroup.sectionIndices.add(section.sectionIndex); + continue; + } + + groups.set(groupKey, { + rId, + sectionIndices: new Set([section.sectionIndex]), + sectionConstraints, + effectiveWidth, + }); + } + } + + return Array.from(groups.values()); +} diff --git a/packages/layout-engine/layout-bridge/src/text-measurement.ts b/packages/layout-engine/layout-bridge/src/text-measurement.ts index 9618b1cb38..c8a95d80ae 100644 --- a/packages/layout-engine/layout-bridge/src/text-measurement.ts +++ b/packages/layout-engine/layout-bridge/src/text-measurement.ts @@ -20,6 +20,21 @@ let measurementCtx: CanvasRenderingContext2D | null = null; const TAB_CHAR_LENGTH = 1; +const getRunCharacterLength = (run: Run | undefined): number => { + if (!run) return 0; + if (isTabRun(run)) return TAB_CHAR_LENGTH; + if ( + 'src' in run || + run.kind === 'lineBreak' || + run.kind === 'break' || + run.kind === 'fieldAnnotation' || + run.kind === 'math' + ) { + return 0; + } + return run.text?.length ?? 0; +}; + /** * Characters considered as spaces for justify alignment calculations. * Only includes regular space (U+0020) and non-breaking space (U+00A0). @@ -224,7 +239,8 @@ const getJustifyAdjustment = ( // This ensures measurement matches rendering even when callers don't pass these flags. const lastRunIndex = block.runs.length - 1; const lastRun = block.runs[lastRunIndex]; - const derivedIsLastLine = line.toRun >= lastRunIndex; + const lastRunLength = getRunCharacterLength(lastRun); + const derivedIsLastLine = line.toRun > lastRunIndex || (line.toRun === lastRunIndex && line.toChar >= lastRunLength); const derivedEndsWithLineBreak = lastRun ? lastRun.kind === 'lineBreak' : false; // Determine if justify should be applied using shared logic const shouldJustify = shouldApplyJustify({ diff --git a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts index 96bcf50c5b..cfce735914 100644 --- a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts +++ b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts @@ -74,6 +74,104 @@ describe('selectionToRects', () => { expect(rects[0].x).toBeGreaterThan(tableLayout.pages[0].fragments[0].x); }); + it('accounts for visual-only prefix runs when mapping PM selections to X coordinates', () => { + const blockWithoutMarker: FlowBlock = { + kind: 'paragraph', + id: 'note-without-marker', + runs: [{ text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 }], + attrs: {}, + }; + + const blockWithMarker: FlowBlock = { + kind: 'paragraph', + id: 'note-with-marker', + runs: [ + { text: '1', fontFamily: 'Arial', fontSize: 10 }, + { text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 }, + ], + attrs: {}, + }; + + const measureWithoutMarker: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 16, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const measureWithMarker: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 1, toChar: 16, width: 110, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layoutWithoutMarker: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'note-without-marker', + fromLine: 0, + toLine: 1, + x: 10, + y: 20, + width: 200, + pmStart: 2, + pmEnd: 18, + }, + ], + }, + ], + }; + + const layoutWithMarker: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'note-with-marker', + fromLine: 0, + toLine: 1, + x: 10, + y: 20, + width: 200, + pmStart: 2, + pmEnd: 18, + }, + ], + }, + ], + }; + + const selectionFrom = 3; + const selectionTo = 9; + + const rectWithoutMarker = selectionToRects( + layoutWithoutMarker, + [blockWithoutMarker], + [measureWithoutMarker], + selectionFrom, + selectionTo, + )[0]; + const rectWithMarker = selectionToRects( + layoutWithMarker, + [blockWithMarker], + [measureWithMarker], + selectionFrom, + selectionTo, + )[0]; + + expect(rectWithoutMarker).toBeTruthy(); + expect(rectWithMarker).toBeTruthy(); + expect(rectWithMarker.x).toBeGreaterThan(rectWithoutMarker.x); + expect(rectWithMarker.x - rectWithoutMarker.x).toBeGreaterThan(1); + }); + describe('table cell spacing.before', () => { it('includes effective spacing.before in rect Y when paragraph has spacing.before', () => { const rects = selectionToRects( diff --git a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts index 7124decaf9..f73f30fffd 100644 --- a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts +++ b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts @@ -571,6 +571,25 @@ describe('text measurement utility', () => { expect(lastX).toBe(lastXNormal); }); + it('applies justify spacing to wrapped non-last lines within a single text run', () => { + const block = createBlock([{ text: 'A B C D E F', fontFamily: 'Arial', fontSize: 16 }]); + (block as any).attrs = { alignment: 'justify' }; + + const line = baseLine({ + fromRun: 0, + toRun: 0, + fromChar: 0, + toChar: 9, // Wrapped line consumes only part of the single text run + width: 90, + maxWidth: 120, + }); + + const xWithNaturalWidth = measureCharacterX(block, line, 7, 90); + const xWithSlack = measureCharacterX(block, line, 7, 120); + + expect(xWithSlack).toBeGreaterThan(xWithNaturalWidth); + }); + it('skips justify spacing for manual tabs without explicit segments', () => { const trailingText = 'Item body'; const tabWidth = 48; diff --git a/packages/layout-engine/layout-engine/src/index.d.ts b/packages/layout-engine/layout-engine/src/index.d.ts index 795acc4fd0..e57ff6cc02 100644 --- a/packages/layout-engine/layout-engine/src/index.d.ts +++ b/packages/layout-engine/layout-engine/src/index.d.ts @@ -48,6 +48,7 @@ export type HeaderFooterConstraints = { * `left`/`right`: horizontal page-relative conversion. * `top`/`bottom`: vertical margin-relative conversion and footer band origin. * `header`: header distance from page top edge (header band origin). + * `footer`: footer distance from page bottom edge (footer band origin). */ margins?: { left: number; @@ -55,6 +56,7 @@ export type HeaderFooterConstraints = { top?: number; bottom?: number; header?: number; + footer?: number; }; /** * Optional base height used to bound behindDoc overflow handling. diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 84ff86b583..4063d3abaf 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -5849,6 +5849,25 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages[1].margins?.top).toBeCloseTo(90, 0); }); + it('prefers section-aware header heights over the plain rId fallback', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdSharedHeader' } }], + headerContentHeightsByRId: new Map([['rIdSharedHeader', 40]]), + headerContentHeightsBySectionRef: new Map([['rIdSharedHeader::s0', 100]]), + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages).toHaveLength(1); + + const pageOneFragment = layout.pages[0].fragments.find((fragment) => fragment.blockId === 'p1'); + expect(pageOneFragment).toBeDefined(); + expect(pageOneFragment!.y).toBeCloseTo(130, 0); + expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0); + }); + it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => { // Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2 // has titlePg=true and starts on docPN=4. diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 1b0574b964..77d582b811 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -153,6 +153,10 @@ function getMeasureHeight(block: FlowBlock, measure: Measure): number { } } +function buildSectionAwareReferenceKey(refId: string, sectionIndex: number): string { + return `${refId}::s${sectionIndex}`; +} + // ConstraintBoundary and PageState now come from paginator /** @@ -503,6 +507,14 @@ export type LayoutOptions = { * Values are the actual content heights in pixels. */ headerContentHeightsByRId?: Map; + /** + * Actual measured header content heights per section-specific reference. + * + * Keys combine the relationship ID and section index using the form + * `${rId}::s${sectionIndex}` so the reserve path can distinguish documents + * that reuse the same header part across sections with different geometry. + */ + headerContentHeightsBySectionRef?: Map; /** * Actual measured footer content heights per relationship ID. * Used for multi-section documents where each section may have unique @@ -512,6 +524,14 @@ export type LayoutOptions = { * Values are the actual content heights in pixels. */ footerContentHeightsByRId?: Map; + /** + * Actual measured footer content heights per section-specific reference. + * + * Keys combine the relationship ID and section index using the form + * `${rId}::s${sectionIndex}` so the reserve path can distinguish documents + * that reuse the same footer part across sections with different geometry. + */ + footerContentHeightsBySectionRef?: Map; /** * Allow body layout to synthesize page 1 for anchored tables when a document has * no anchor paragraphs and would otherwise render zero pages. @@ -554,6 +574,7 @@ export type HeaderFooterConstraints = { * `left`/`right`: horizontal page-relative conversion. * `top`/`bottom`: vertical margin-relative conversion and footer band origin. * `header`: header distance from page top edge (header band origin). + * `footer`: footer distance from page bottom edge (footer band origin). */ margins?: { left: number; @@ -561,6 +582,7 @@ export type HeaderFooterConstraints = { top?: number; bottom?: number; header?: number; + footer?: number; }; /** * Optional base height used to bound behindDoc overflow handling. @@ -675,7 +697,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const headerContentHeights = options.headerContentHeights; const footerContentHeights = options.footerContentHeights; const headerContentHeightsByRId = options.headerContentHeightsByRId; + const headerContentHeightsBySectionRef = options.headerContentHeightsBySectionRef; const footerContentHeightsByRId = options.footerContentHeightsByRId; + const footerContentHeightsBySectionRef = options.footerContentHeightsBySectionRef; /** * Determines the header/footer variant type for a given page based on section settings. @@ -716,12 +740,23 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options * @param headerRef - Optional relationship ID from section's headerRefs * @returns The appropriate header content height, or 0 if not found */ - const getHeaderHeightForPage = (variantType: 'default' | 'first' | 'even' | 'odd', headerRef?: string): number => { - // Priority 1: Check per-rId heights if we have a specific rId + const getHeaderHeightForPage = ( + variantType: 'default' | 'first' | 'even' | 'odd', + headerRef?: string, + sectionIndex?: number, + ): number => { + // Priority 1: Check section-aware heights when the same part is reused across sections. + if (headerRef && sectionIndex != null) { + const sectionKey = buildSectionAwareReferenceKey(headerRef, sectionIndex); + if (headerContentHeightsBySectionRef?.has(sectionKey)) { + return validateContentHeight(headerContentHeightsBySectionRef.get(sectionKey)); + } + } + // Priority 2: Check per-rId heights if we have a specific rId if (headerRef && headerContentHeightsByRId?.has(headerRef)) { return validateContentHeight(headerContentHeightsByRId.get(headerRef)); } - // Priority 2: Fall back to per-variant heights + // Priority 3: Fall back to per-variant heights if (headerContentHeights) { return validateContentHeight(headerContentHeights[variantType]); } @@ -737,12 +772,23 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options * @param footerRef - Optional relationship ID from section's footerRefs * @returns The appropriate footer content height, or 0 if not found */ - const getFooterHeightForPage = (variantType: 'default' | 'first' | 'even' | 'odd', footerRef?: string): number => { - // Priority 1: Check per-rId heights if we have a specific rId + const getFooterHeightForPage = ( + variantType: 'default' | 'first' | 'even' | 'odd', + footerRef?: string, + sectionIndex?: number, + ): number => { + // Priority 1: Check section-aware heights when the same part is reused across sections. + if (footerRef && sectionIndex != null) { + const sectionKey = buildSectionAwareReferenceKey(footerRef, sectionIndex); + if (footerContentHeightsBySectionRef?.has(sectionKey)) { + return validateContentHeight(footerContentHeightsBySectionRef.get(sectionKey)); + } + } + // Priority 2: Check per-rId heights if we have a specific rId if (footerRef && footerContentHeightsByRId?.has(footerRef)) { return validateContentHeight(footerContentHeightsByRId.get(footerRef)); } - // Priority 2: Fall back to per-variant heights + // Priority 3: Fall back to per-variant heights if (footerContentHeights) { return validateContentHeight(footerContentHeights[variantType]); } @@ -811,8 +857,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Initial effective margins use default variant (will be adjusted per-page) const headerDistance = margins.header ?? margins.top; const footerDistance = margins.footer ?? margins.bottom; - const defaultHeaderHeight = getHeaderHeightForPage('default', undefined); - const defaultFooterHeight = getFooterHeightForPage('default', undefined); + const defaultHeaderHeight = getHeaderHeightForPage('default', undefined, 0); + const defaultFooterHeight = getFooterHeightForPage('default', undefined, 0); const effectiveTopMargin = calculateEffectiveTopMargin(defaultHeaderHeight, headerDistance, margins.top); const effectiveBottomMargin = calculateEffectiveBottomMargin(defaultFooterHeight, footerDistance, margins.bottom); @@ -1365,10 +1411,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Calculate the actual header/footer heights for this page's variant // Use effectiveVariantType for header height lookup to match the fallback - const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef); + const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef, activeSectionIndex); const footerHeight = getFooterHeightForPage( variantType !== 'default' && !activeSectionRefs?.footerRefs?.[variantType] ? 'default' : variantType, footerRef, + activeSectionIndex, ); // Adjust margins based on the actual header/footer for this page. diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts new file mode 100644 index 0000000000..21dea44686 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { DomPainter } from './renderer.js'; + +function makeFragment(blockId: string, pmStart: number, pmEnd: number) { + const fragment = document.createElement('div'); + fragment.dataset.blockId = blockId; + fragment.dataset.pmStart = String(pmStart); + fragment.dataset.pmEnd = String(pmEnd); + + const span = document.createElement('span'); + span.dataset.pmStart = String(pmStart); + span.dataset.pmEnd = String(pmEnd); + fragment.appendChild(span); + + return { fragment, span }; +} + +const shiftByTwo = { + map(pos: number) { + return pos + 2; + }, + maps: [{}], +}; + +describe('DomPainter.updatePositionAttributes', () => { + it('does not remap footnote fragments with body transaction mappings', () => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment('footnote-1-abc', 2, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('2'); + expect(fragment.dataset.pmEnd).toBe('30'); + expect(span.dataset.pmStart).toBe('2'); + expect(span.dataset.pmEnd).toBe('30'); + }); + + it('still remaps body fragments when the mapping applies', () => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment('body-paragraph-1', 25, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('27'); + expect(fragment.dataset.pmEnd).toBe('32'); + expect(span.dataset.pmStart).toBe('27'); + expect(span.dataset.pmEnd).toBe('32'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 32260fc06f..76b5dac508 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2886,6 +2886,10 @@ export class DomPainter { if (fragmentEl.closest('.superdoc-page-header, .superdoc-page-footer')) { return; } + // Notes use local story positions, so body mappings must not rewrite them. + if (isNonBodyStoryBlockId(fragmentEl.dataset.blockId)) { + return; + } // Wrap mapping logic in try-catch to prevent corrupted mappings from crashing paint cycle try { @@ -6776,6 +6780,7 @@ export class DomPainter { elem.dataset.trackChangeId = meta.id; elem.dataset.trackChangeKind = meta.kind; + elem.dataset.storyKey = meta.storyKey ?? 'body'; if (meta.author) { elem.dataset.trackChangeAuthor = meta.author; } @@ -7484,6 +7489,13 @@ const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean typeof next.height === 'number' && previous.height !== next.height); +const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => + typeof blockId === 'string' && + (blockId.startsWith('footnote-') || + blockId.startsWith('endnote-') || + blockId.startsWith('__sd_semantic_footnote-') || + blockId.startsWith('__sd_semantic_endnote-')); + const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { if (!metadata) return ''; if ('id' in metadata && metadata.id != null) { @@ -7651,6 +7663,19 @@ const deriveBlockVersion = (block: FlowBlock): string => { // Handle TextRun (kind is 'text' or undefined) const textRun = run as TextRun; + const trackedChangeVersion = textRun.trackedChange + ? [ + textRun.trackedChange.kind ?? '', + textRun.trackedChange.id ?? '', + textRun.trackedChange.storyKey ?? '', + textRun.trackedChange.author ?? '', + textRun.trackedChange.authorEmail ?? '', + textRun.trackedChange.authorImage ?? '', + textRun.trackedChange.date ?? '', + textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', + textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', + ].join(':') + : ''; return [ textRun.text ?? '', textRun.fontFamily, @@ -7668,8 +7693,8 @@ const deriveBlockVersion = (block: FlowBlock): string => { textRun.baselineShift != null ? textRun.baselineShift : '', // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection textRun.token ?? '', - // Tracked changes - force re-render when added or removed tracked change - textRun.trackedChange ? 1 : 0, + // Tracked changes - force re-render when any rendered tracked-change metadata changes. + trackedChangeVersion, // Comment annotations - force re-render when comments are enabled/disabled textRun.comments?.length ?? 0, ].join(','); diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index dc49f5a900..3779a0b1a3 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -338,7 +338,9 @@ export function imageNodeToBlock( export function handleImageNode(node: PMNode, context: NodeHandlerContext): ImageBlock | void { const { blocks, recordBlockKind, nextBlockId, positions, trackedChangesConfig } = context; - const trackedMeta = trackedChangesConfig.enabled ? collectTrackedChangeFromMarks(node.marks ?? []) : undefined; + const trackedMeta = trackedChangesConfig.enabled + ? collectTrackedChangeFromMarks(node.marks ?? [], context.storyKey) + : undefined; if (shouldHideTrackedNode(trackedMeta, trackedChangesConfig)) { return; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts index 002611fd3d..8f1c4b2d02 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts @@ -38,6 +38,7 @@ export class NotInlineNodeError extends Error { export type InlineConverterParams = { node: PMNode; positions: PositionMap; + storyKey?: string; inheritedMarks: PMMark[]; defaultFont: string; defaultSize: number; @@ -60,6 +61,7 @@ export type BlockConverterOptions = { nextBlockId: BlockIdGenerator; nextId: () => string; positions: WeakMap; + storyKey?: string; trackedChangesConfig: NodeHandlerContext['trackedChangesConfig']; defaultFont: string; defaultSize: number; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts index 25d369f31c..8f3d263cd6 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts @@ -88,6 +88,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -107,6 +110,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -128,6 +134,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -146,6 +155,9 @@ describe('tokenNodeToRun', () => { expect.any(Array), hyperlinkConfig, undefined, + undefined, + true, + undefined, ); }); @@ -198,6 +210,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -214,6 +229,9 @@ describe('tokenNodeToRun', () => { [], { enableRichHyperlinks: false }, undefined, + undefined, + true, + undefined, ); }); @@ -232,6 +250,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -261,6 +282,9 @@ describe('tokenNodeToRun', () => { [], { enableRichHyperlinks: false }, undefined, + undefined, + true, + undefined, ); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts index 12580d9b04..fe77a18543 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts @@ -19,6 +19,7 @@ import { TOKEN_INLINE_TYPES } from '../../constants.js'; export function tokenNodeToRun({ node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks, @@ -58,7 +59,7 @@ export function tokenNodeToRun({ const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs; const marks = [...effectiveMarks, ...(inheritedMarks ?? [])]; - applyMarksToRun(run, marks, hyperlinkConfig, themeColors); + applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey); applyInlineRunProperties(run, runProperties, converterContext); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts index 06dd66a9ff..ffe2ab1ca7 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts @@ -221,9 +221,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('calls applyMarksToRun with inherited marks', () => { @@ -238,9 +244,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('combines node marks and inherited marks', () => { @@ -258,10 +270,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'bold' }, - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'bold' }, { type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('does not call applyMarksToRun when no marks present', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts index dfde920094..da8b2bd4ff 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts @@ -15,6 +15,7 @@ import { type InlineConverterParams } from './common.js'; export function tabNodeToRun({ node, positions, + storyKey, tabOrdinal, paragraphAttrs, inheritedMarks, @@ -42,7 +43,7 @@ export function tabNodeToRun({ // Apply marks (e.g., underline) to the tab run const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; if (marks.length > 0) { - applyMarksToRun(run, marks); + applyMarksToRun(run, marks, undefined, undefined, undefined, true, storyKey); } return run; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts index 4787983d58..e9c3d29a9a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts @@ -74,6 +74,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -125,6 +126,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -147,6 +149,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -171,6 +174,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -220,6 +224,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -298,6 +303,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -337,6 +343,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts index 06722ac7bc..c051b8fe8e 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts @@ -28,6 +28,7 @@ import { applyInlineRunProperties, type InlineConverterParams } from './common.j export function textNodeToRun({ node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks = [], @@ -59,6 +60,7 @@ export function textNodeToRun({ themeColors, converterContext?.backgroundColor, enableComments, + storyKey, ); if (sdtMetadata) { run.sdt = sdtMetadata; @@ -89,6 +91,7 @@ export function tokenNodeToRun( token: TextRun['token'], hyperlinkConfig: HyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, themeColors?: ThemeColorPalette, + storyKey?: string, ): TextRun { // Tokens carry a placeholder character so measurers reserve width; painters will replace it with the real value. const run: TextRun = { @@ -115,7 +118,7 @@ export function tokenNodeToRun( const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs; const marks = [...effectiveMarks, ...(inheritedMarks ?? [])]; - applyMarksToRun(run, marks, hyperlinkConfig, themeColors); + applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey); // If marksAsAttrs carried font styling, mark the run so downstream defaults don't overwrite it. if (marksAsAttrs.length > 0) { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index dfa44dbf3c..cbcb6ca314 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2731,6 +2731,7 @@ describe('paragraph converters', () => { applyMarksToRun, undefined, true, + undefined, ); const paraBlock = blocks[0] as ParagraphBlock; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 58b887aa3a..6f29bb2452 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -250,7 +250,10 @@ const toTrackChangeAttrs = (value: unknown): Record | undefined // Paragraph-mark revisions are stored in paragraphProperties.runProperties (pPr/rPr), not inline text marks. // Convert them into mark-like metadata so tracked-change filtering can reuse the same projection pipeline. -const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties): TrackedChangeMeta | undefined => { +const getParagraphMarkTrackedChange = ( + paragraphProperties: ParagraphProperties, + storyKey?: string, +): TrackedChangeMeta | undefined => { const runProperties = paragraphProperties?.runProperties && typeof paragraphProperties.runProperties === 'object' ? (paragraphProperties.runProperties as Record) @@ -272,7 +275,7 @@ const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties) if (trackDeleteAttrs) { marks.push({ type: 'trackDelete', attrs: trackDeleteAttrs }); } - return collectTrackedChangeFromMarks(marks); + return collectTrackedChangeFromMarks(marks, storyKey); }; const isEmptyTextRun = (run: Run): boolean => { @@ -510,6 +513,7 @@ export function paragraphToFlowBlocks({ para, nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, @@ -573,7 +577,7 @@ export function paragraphToFlowBlocks({ if (paragraphProps.runProperties?.vanish) { return blocks; } - const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps); + const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey); // Get the PM position of the empty paragraph for caret rendering const paraPos = positions.get(para); const emptyRun: TextRun = { @@ -620,6 +624,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); // Ghost list artifact suppression only applies in markup/review modes. @@ -727,6 +732,7 @@ export function paragraphToFlowBlocks({ const inlineConverterParams = { node: node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks: inheritedMarks ?? [], @@ -749,6 +755,7 @@ export function paragraphToFlowBlocks({ nextBlockId: stableNextBlockId, nextId, positions, + storyKey, trackedChangesConfig, defaultFont, defaultSize, @@ -863,6 +870,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); if (trackedChangesConfig.enabled && filteredRuns.length === 0) { return; @@ -1086,6 +1094,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): para: node, nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1114,6 +1123,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): para: node, nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 452383b756..a570c8dd6f 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -108,6 +108,7 @@ function normalizeLegacyBorderStyle(value: string | undefined): BorderStyle { type TableParserDependencies = { nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; bookmarks: Map; hyperlinkConfig: HyperlinkConfig; @@ -340,6 +341,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { para: childNode, nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -361,6 +363,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { para: nestedNode, nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -376,6 +379,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { const tableBlock = tableNodeToBlock(nestedNode, { nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -398,6 +402,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { const tableBlock = tableNodeToBlock(childNode, { nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -414,7 +419,9 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { if (childNode.type === 'image' && context.converters?.imageNodeToBlock) { const mergedMarks = [...(childNode.marks ?? [])]; - const trackedMeta = context.trackedChangesConfig ? collectTrackedChangeFromMarks(mergedMarks) : undefined; + const trackedMeta = context.trackedChangesConfig + ? collectTrackedChangeFromMarks(mergedMarks, context.storyKey) + : undefined; if (shouldHideTrackedNode(trackedMeta, context.trackedChangesConfig)) { continue; } @@ -788,6 +795,7 @@ export function tableNodeToBlock( { nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -804,6 +812,7 @@ export function tableNodeToBlock( const parserDeps: TableParserDependencies = { nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1037,6 +1046,7 @@ export function handleTableNode(node: PMNode, context: NodeHandlerContext): void const tableBlock = tableNodeToBlock(node, { nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 4f387dd52b..441b210e73 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3656,6 +3656,25 @@ describe('toFlowBlocks', () => { expect(blocks[0].attrs?.trackedChangesEnabled).toBe(true); }); + it('propagates storyKey into tracked change metadata for non-body stories', () => { + const pmDoc = buildDocWithMarks([ + { + type: 'trackInsert', + attrs: { + id: 'ins-story', + }, + }, + ]); + + const { blocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' }); + const run = blocks[0].runs[0] as never; + expect(run.trackedChange).toMatchObject({ + kind: 'insert', + id: 'ins-story', + storyKey: 'hf:part:rId7', + }); + }); + it('hides insertions when trackedChangesMode is original', () => { const pmDoc = { type: 'doc', @@ -3875,6 +3894,14 @@ describe('toFlowBlocks', () => { const reviewImage = reviewBlocks.find((block): block is ImageBlock => block.kind === 'image'); expect(reviewImage?.attrs?.trackedChange).toMatchObject({ id: 'del-img', kind: 'delete' }); + const { blocks: storyBlocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' }); + const storyImage = storyBlocks.find((block): block is ImageBlock => block.kind === 'image'); + expect(storyImage?.attrs?.trackedChange).toMatchObject({ + id: 'del-img', + kind: 'delete', + storyKey: 'hf:part:rId7', + }); + const { blocks: finalBlocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'final' }); expect(finalBlocks.some((block) => block.kind === 'image')).toBe(false); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index f127e8fc20..1223d7ec7e 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -195,6 +195,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): recordBlockKind, nextBlockId, blockIdPrefix: idPrefix, + storyKey: options?.storyKey, positions, defaultFont, defaultSize, diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 3c2ee5467f..493b43232f 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -451,7 +451,7 @@ const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record { +export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => { const kind = pickTrackedChangeKind(mark.type); if (!kind) return undefined; const attrs = mark.attrs ?? {}; @@ -475,6 +475,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before); meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after); } + if (typeof storyKey === 'string' && storyKey.length > 0) { + meta.storyKey = storyKey; + } return meta; }; @@ -522,10 +525,10 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { * @param marks - Array of ProseMirror marks to process * @returns The highest-priority TrackedChangeMeta, or undefined if none found */ -export const collectTrackedChangeFromMarks = (marks?: PMMark[]): TrackedChangeMeta | undefined => { +export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { if (!marks || !marks.length) return undefined; return marks.reduce((current, mark) => { - const meta = buildTrackedChangeMetaFromMark(mark); + const meta = buildTrackedChangeMetaFromMark(mark, storyKey); if (!meta) return current; return selectTrackedChangeMeta(current, meta); }, undefined); @@ -835,6 +838,7 @@ export const applyMarksToRun = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments = true, + storyKey?: string, ): void => { // If comments are disabled, clear any existing annotations before processing marks. if (!enableComments && 'comments' in run && (run as TextRun).comments) { @@ -856,7 +860,7 @@ export const applyMarksToRun = ( case TRACK_FORMAT_MARK: { // Tracked change marks only apply to TextRun if (!isTabRun) { - const tracked = buildTrackedChangeMetaFromMark(mark); + const tracked = buildTrackedChangeMetaFromMark(mark, storyKey); if (tracked) { run.trackedChange = selectTrackedChangeMeta(run.trackedChange, tracked); } diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts index c3b185d1d1..cb84f6ffe6 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts @@ -745,7 +745,15 @@ describe('tracked-changes', () => { const applyMarksToRun = vi.fn(); applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun); - expect(applyMarksToRun).toHaveBeenCalledWith(run, beforeMarks, hyperlinkConfig, undefined, undefined, true); + expect(applyMarksToRun).toHaveBeenCalledWith( + run, + beforeMarks, + hyperlinkConfig, + undefined, + undefined, + true, + undefined, + ); }); it('should handle errors in applyMarksToRun by resetting formatting', () => { diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index 687f48c4a1..e69c9ee99b 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -213,7 +213,7 @@ export const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record { +export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => { const kind = pickTrackedChangeKind(mark.type); if (!kind) return undefined; const attrs = mark.attrs ?? {}; @@ -237,6 +237,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before); meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after); } + if (typeof storyKey === 'string' && storyKey.length > 0) { + meta.storyKey = storyKey; + } return meta; }; @@ -363,9 +366,11 @@ export const applyFormatChangeMarks = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments?: boolean, + storyKey?: string, ) => void, themeColors?: ThemeColorPalette, enableComments = true, + storyKey?: string, ): void => { const tracked = run.trackedChange; if (!tracked || tracked.kind !== 'format') { @@ -402,7 +407,7 @@ export const applyFormatChangeMarks = ( resetRunFormatting(run); try { - applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments); + applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments, storyKey); } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn('[PM-Adapter] Error applying format change marks, resetting formatting:', error); @@ -433,9 +438,11 @@ export const applyTrackedChangesModeToRuns = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments?: boolean, + storyKey?: string, ) => void, themeColors?: ThemeColorPalette, enableComments = true, + storyKey?: string, ): Run[] => { if (!config) { return runs; @@ -451,7 +458,7 @@ export const applyTrackedChangesModeToRuns = ( // Apply format changes even when not filtering insertions/deletions runs.forEach((run) => { if (isTextRun(run)) { - applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments); + applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments, storyKey); } }); } @@ -491,6 +498,7 @@ export const applyTrackedChangesModeToRuns = ( applyMarksToRun, themeColors, enableComments, + storyKey, ); } }); diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index cd9148ac1d..d60515c39b 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -93,6 +93,13 @@ export interface AdapterOptions { */ blockIdPrefix?: string; + /** + * Story key for the document being converted. Used to stamp tracked-change + * metadata so rendered DOM anchors can distinguish body, header/footer, and + * note stories. + */ + storyKey?: string; + /** * Optional list of ProseMirror node type names that should be treated as atom/leaf nodes * for position mapping. Use this to keep PM positions correct when custom atom nodes exist. @@ -286,6 +293,7 @@ export interface NodeHandlerContext { // ID generation & positions nextBlockId: BlockIdGenerator; blockIdPrefix?: string; + storyKey?: string; positions: PositionMap; // Style & defaults @@ -340,6 +348,7 @@ export type ParagraphToFlowBlocksParams = { para: PMNode; nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; themeColors?: ThemeColorPalette; @@ -355,6 +364,7 @@ export type ParagraphToFlowBlocksParams = { export type TableNodeToBlockParams = { nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; bookmarks: Map; hyperlinkConfig: HyperlinkConfig; diff --git a/packages/super-editor/src/editors/v1/assets/styles/layout/global.css b/packages/super-editor/src/editors/v1/assets/styles/layout/global.css index ba6b428a27..53a4bcaa5b 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/layout/global.css +++ b/packages/super-editor/src/editors/v1/assets/styles/layout/global.css @@ -17,7 +17,7 @@ } .presentation-editor__selection-caret { - animation: superdoc-caret-blink 1.2s steps(2, start) infinite; + animation: superdoc-caret-blink 1.2s ease-in-out infinite; } .presentation-editor__permission-overlay { @@ -36,6 +36,6 @@ } 55%, 100% { - opacity: 0; + opacity: 0.55; } } diff --git a/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts new file mode 100644 index 0000000000..98223daf8d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Editor } from './Editor.ts'; + +describe('Editor.setOptions', () => { + it('preserves non-enumerable option metadata across updates', () => { + const parentEditor = { id: 'parent-editor' }; + const options: Record = { editable: false }; + Object.defineProperty(options, 'parentEditor', { + enumerable: false, + configurable: true, + get() { + return parentEditor; + }, + }); + + const context = { + options, + view: { + setProps: vi.fn(), + updateState: vi.fn(), + }, + state: { doc: null }, + isDestroyed: false, + }; + + Editor.prototype.setOptions.call(context as unknown as Editor, { documentMode: 'editing' }); + + expect((context.options as { parentEditor?: unknown }).parentEditor).toBe(parentEditor); + expect(Object.getOwnPropertyDescriptor(context.options, 'parentEditor')?.enumerable).toBe(false); + expect(context.view.updateState).toHaveBeenCalledWith(context.state); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 61cfe81c35..d43ec9f6ba 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1864,11 +1864,28 @@ export class Editor extends EventEmitter { * Set editor options and update state. */ setOptions(options: Partial = {}): void { - this.options = { - ...this.options, + const previousOptions = this.options ?? {}; + const nextOptions = { + ...previousOptions, ...options, }; + // Preserve non-enumerable option metadata (for example the story editor's + // `parentEditor` getter) across option updates. Plain object spreading drops + // those descriptors, which breaks commit routing for child/story editors. + const previousDescriptors = Object.getOwnPropertyDescriptors(previousOptions); + for (const [key, descriptor] of Object.entries(previousDescriptors)) { + if (descriptor.enumerable) { + continue; + } + if (Object.prototype.hasOwnProperty.call(options, key)) { + continue; + } + Object.defineProperty(nextOptions, key, descriptor); + } + + this.options = nextOptions; + if ((this.options.isNewFile || !this.options.ydoc) && this.options.isCommentsEnabled) { this.options.shouldLoadComments = true; } diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index 94cf113f76..6c3cd27d22 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -9,6 +9,7 @@ type CoreCommandNames = | 'first' | 'command' | 'insertTabChar' + | 'insertTabCharacter' | 'insertTabNode' | 'setMeta' | 'splitBlock' @@ -21,6 +22,7 @@ type CoreCommandNames = | 'unsetAllMarks' | 'toggleMark' | 'toggleMarkCascade' + | 'isStyleTokenEnabled' | 'clearNodes' | 'setNode' | 'toggleNode' @@ -46,6 +48,7 @@ type CoreCommandNames = | 'increaseListIndent' | 'decreaseListIndent' | 'changeListLevel' + | 'updateNumberingProperties' | 'removeNumberingProperties' | 'insertListItemAt' | 'setListTypeAt' @@ -53,7 +56,14 @@ type CoreCommandNames = | 'restoreSelection' | 'setTextSelection' | 'insertTableAt' - | 'getSelectionMarks'; + | 'getSelectionMarks' + | 'backspaceEmptyRunParagraph' + | 'backspaceSkipEmptyRun' + | 'backspaceNextToRun' + | 'backspaceAcrossRuns' + | 'deleteSkipEmptyRun' + | 'deleteNextToRun' + | 'skipTab'; export type CoreCommandSignatures = { [K in CoreCommandNames]: ExtractCommandSignature<(typeof CoreCommandExports)[K]>; diff --git a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts index 0ece308cb5..420b3b10bc 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts @@ -11,6 +11,12 @@ * - Toggle visibility between static decoration content and live editors * - Manage dimming overlay for body content during editing * - Control selection overlay visibility to prevent double caret rendering + * + * @deprecated (legacy) + * This visible child-PM overlay predates the story-session/hidden-host + * editing model. PresentationEditor no longer routes header/footer editing + * through this overlay, and it remains only as retired legacy scaffolding + * until the surrounding dead code is deleted. */ import type { HeaderFooterRegion } from './types.js'; @@ -184,6 +190,9 @@ export class EditorOverlayManager { // Find the editor container (first child with super-editor class) const editorContainer = editorHost.querySelector('.super-editor'); if (editorContainer instanceof HTMLElement) { + // Reset any stale transform from prior footer sessions before + // reapplying the top offset for the current region. + editorContainer.style.transform = ''; // Instead of top: 0, position from the calculated offset editorContainer.style.top = `${contentOffset}px`; } diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 23d1f9412d..dd6eb27e81 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -8,11 +8,14 @@ const { mockLayoutHeaderFooterWithCache, mockComputeDisplayPageNumber, mockMeasu mockMeasureBlock: vi.fn(), })); -vi.mock('@superdoc/layout-bridge', () => ({ - OOXML_PCT_DIVISOR: 5000, - computeDisplayPageNumber: mockComputeDisplayPageNumber, - layoutHeaderFooterWithCache: mockLayoutHeaderFooterWithCache, -})); +vi.mock('@superdoc/layout-bridge', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + computeDisplayPageNumber: mockComputeDisplayPageNumber, + layoutHeaderFooterWithCache: mockLayoutHeaderFooterWithCache, + }; +}); vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: mockMeasureBlock, @@ -129,4 +132,73 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-first')).toBe(true); expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + + it('lays out first-page header refs in multi-section documents with per-section constraints', async () => { + const headerBlocksByRId = new Map([ + ['rId-header-default', [makeBlock('block-default')]], + ['rId-header-first', [makeBlock('block-first')]], + ['rId-header-section-1', [makeBlock('block-section-1')]], + ]); + + const headerFooterInput = { + headerBlocksByRId, + footerBlocksByRId: undefined, + headerBlocks: undefined, + footerBlocks: undefined, + constraints: { + width: 400, + height: 80, + pageWidth: 600, + pageHeight: 800, + margins: { + top: 50, + right: 50, + bottom: 50, + left: 50, + header: 20, + }, + }, + }; + + const layout = { + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + ], + } as unknown as Layout; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 20 }, + headerRefs: { + default: 'rId-header-default', + first: 'rId-header-first', + }, + }, + { + sectionIndex: 1, + margins: { top: 55, right: 55, bottom: 55, left: 55, header: 20 }, + headerRefs: { + default: 'rId-header-section-1', + }, + }, + ]; + + const deps = { + headerLayoutsByRId: new Map(), + footerLayoutsByRId: new Map(), + }; + + await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps); + + const laidOutBlockIds = new Set( + mockLayoutHeaderFooterWithCache.mock.calls.map((call) => call[0].default?.[0]?.id).filter(Boolean), + ); + + expect(laidOutBlockIds).toEqual(new Set(['block-default', 'block-first', 'block-section-1'])); + expect(deps.headerLayoutsByRId.has('rId-header-default::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-header-first::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-header-section-1::s1')).toBe(true); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index e705c4a41b..1228456dc2 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -1,6 +1,13 @@ -import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata, SectionRefType } from '@superdoc/contracts'; -import { OOXML_PCT_DIVISOR } from '@superdoc/contracts'; -import { computeDisplayPageNumber, layoutHeaderFooterWithCache } from '@superdoc/layout-bridge'; +import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata } from '@superdoc/contracts'; +import { + computeDisplayPageNumber, + layoutHeaderFooterWithCache, + buildSectionAwareHeaderFooterLayoutKey, + buildSectionContentWidth, + buildEffectiveHeaderFooterRefsBySection, + collectReferencedHeaderFooterRIds, + buildSectionAwareHeaderFooterMeasurementGroups, +} from '@superdoc/layout-bridge'; import type { HeaderFooterLayoutResult, HeaderFooterConstraints } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; @@ -13,211 +20,6 @@ export type HeaderFooterPerRidLayoutInput = { }; type Constraints = HeaderFooterConstraints; -type HeaderFooterRefs = Partial>; -const HEADER_FOOTER_VARIANTS: SectionRefType[] = ['default', 'first', 'even', 'odd']; - -/** - * Compute the content width for a section, falling back to global constraints. - */ -function buildSectionContentWidth(section: SectionMetadata, fallback: Constraints): number { - const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0; - const marginL = section.margins?.left ?? fallback.margins?.left ?? 0; - const marginR = section.margins?.right ?? fallback.margins?.right ?? 0; - return pageW - marginL - marginR; -} - -/** - * Build constraints for a section using its margins/pageSize, falling back to global. - * When a table's grid width exceeds the content width, use the grid width instead (SD-1837). - * Word allows auto-width tables in headers/footers to extend beyond the body margins. - */ -function buildConstraintsForSection(section: SectionMetadata, fallback: Constraints, minWidth?: number): Constraints { - const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0; - const pageH = section.pageSize?.h ?? fallback.pageHeight; - const marginL = section.margins?.left ?? fallback.margins?.left ?? 0; - const marginR = section.margins?.right ?? fallback.margins?.right ?? 0; - const marginT = section.margins?.top ?? fallback.margins?.top; - const marginB = section.margins?.bottom ?? fallback.margins?.bottom; - const marginHeader = section.margins?.header ?? fallback.margins?.header; - const contentWidth = pageW - marginL - marginR; - // Allow tables to extend beyond right margin when grid width > content width. - // Capped at pageWidth - marginLeft to avoid going past the page edge. - const maxWidth = pageW - marginL; - const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth; - - // Recompute body content height if section has its own page size / vertical margins - const sectionMarginTop = marginT ?? 0; - const sectionMarginBottom = marginB ?? 0; - const sectionHeight = pageH != null ? Math.max(1, pageH - sectionMarginTop - sectionMarginBottom) : fallback.height; - - return { - width: effectiveWidth, - height: sectionHeight, - pageWidth: pageW, - pageHeight: pageH, - margins: { left: marginL, right: marginR, top: marginT, bottom: marginB, header: marginHeader }, - overflowBaseHeight: fallback.overflowBaseHeight, - }; -} - -/** - * Table width specification extracted from footer/header blocks. - * Used to compute the minimum constraint width per section. - */ -type TableWidthSpec = { - /** 'pct' for percentage-based, 'grid' for auto-width using grid columns, 'px' for fixed pixel */ - type: 'pct' | 'grid' | 'px'; - /** For 'pct': OOXML percentage value (e.g. 5161 = 103.22%). For 'grid'/'px': width in pixels. */ - value: number; -}; - -/** - * Extract table width specifications from a set of blocks. - * Returns the spec for the widest table, distinguishing percentage-based from auto/fixed. - * - * For percentage tables (tblW type="pct"), the width must be resolved per-section since it - * depends on the section's content width. The measuring-dom clamps pct tables to the constraint - * width, so we must pre-expand the constraint to contentWidth * pct/5000. - * - * For auto-width tables (no tblW or tblW type="auto"), the grid columns are the layout basis. - */ -function getTableWidthSpec(blocks: FlowBlock[]): TableWidthSpec | undefined { - let result: TableWidthSpec | undefined; - let maxResolvedWidth = 0; - - for (const block of blocks) { - if (block.kind !== 'table') continue; - - const tableWidth = (block as { attrs?: { tableWidth?: { width?: number; value?: number; type?: string } } }).attrs - ?.tableWidth; - const widthValue = tableWidth?.width ?? tableWidth?.value; - - if (tableWidth?.type === 'pct' && typeof widthValue === 'number' && widthValue > 0) { - // Percentage-based table: store the raw pct value for per-section resolution. - // Use a nominal large value for comparison so pct tables take priority. - if (!result || result.type !== 'pct' || widthValue > result.value) { - result = { type: 'pct', value: widthValue }; - maxResolvedWidth = Infinity; // pct always takes priority - } - } else if ((tableWidth?.type === 'px' || tableWidth?.type === 'pixel') && typeof widthValue === 'number') { - // Fixed pixel width - if (widthValue > maxResolvedWidth) { - maxResolvedWidth = widthValue; - result = { type: 'px', value: widthValue }; - } - } else if (block.columnWidths && block.columnWidths.length > 0) { - // Auto-width: use grid columns as minimum width - const gridTotal = block.columnWidths.reduce((sum, w) => sum + w, 0); - if (gridTotal > maxResolvedWidth) { - maxResolvedWidth = gridTotal; - result = { type: 'grid', value: gridTotal }; - } - } - } - - return result; -} - -/** - * Resolve the minimum constraint width for a section based on its table width spec. - * For percentage-based tables, computes the percentage of the section's content width. - * For auto/grid tables, returns the grid total directly. - * - * The measuring-dom clamps pct tables to Math.min(resolvedWidth, maxWidth), so for - * pct > 100% the table would be limited to the constraint. We pre-compute the resolved - * pct width and use it as the minimum constraint so the table can overflow properly. - */ -function resolveTableMinWidth(spec: TableWidthSpec | undefined, contentWidth: number): number { - if (!spec) return 0; - if (spec.type === 'pct') { - return contentWidth * (spec.value / OOXML_PCT_DIVISOR); - } - return spec.value; // grid or px: already in pixels -} - -function getRefsForKind(section: SectionMetadata, kind: 'header' | 'footer'): HeaderFooterRefs | undefined { - return kind === 'header' ? section.headerRefs : section.footerRefs; -} - -/** - * Resolve the effective header/footer references for each section. - * - * Word inherits missing header/footer references from the previous section. This - * helper applies that inheritance for every supported variant so downstream - * layout only measures content that can actually be selected at render time. - */ -function buildEffectiveRefsBySection( - sectionMetadata: SectionMetadata[], - kind: 'header' | 'footer', -): Map { - const result = new Map(); - let inheritedRefs: HeaderFooterRefs = {}; - - for (const section of sectionMetadata) { - const explicitRefs = getRefsForKind(section, kind); - const effectiveRefs: HeaderFooterRefs = { ...inheritedRefs }; - - for (const variant of HEADER_FOOTER_VARIANTS) { - const rId = explicitRefs?.[variant]; - if (rId) { - effectiveRefs[variant] = rId; - } - } - - if (Object.keys(effectiveRefs).length > 0) { - result.set(section.sectionIndex, effectiveRefs); - } - - inheritedRefs = effectiveRefs; - } - - return result; -} - -function collectReferencedRIdsBySection(effectiveRefsBySection: Map): Set { - const result = new Set(); - - for (const refs of effectiveRefsBySection.values()) { - for (const variant of HEADER_FOOTER_VARIANTS) { - const rId = refs[variant]; - if (rId) { - result.add(rId); - } - } - } - - return result; -} - -/** - * Resolve the default header/footer rId for each section. - * - * Multi-section layout has historically measured only the default variant with - * section-specific constraints. Preserve that behavior to avoid changing - * established rendering for documents that use first/even/odd variants. - */ -function resolveDefaultRIdPerSection( - sectionMetadata: SectionMetadata[], - kind: 'header' | 'footer', -): Map { - const result = new Map(); - let inheritedDefaultRId: string | undefined; - - for (const section of sectionMetadata) { - const refs = getRefsForKind(section, kind); - const explicitDefaultRId = refs?.default; - - if (explicitDefaultRId) { - inheritedDefaultRId = explicitDefaultRId; - } - - if (inheritedDefaultRId) { - result.set(section.sectionIndex, inheritedDefaultRId); - } - } - - return result; -} /** * Layout header/footer blocks per rId, respecting per-section margins. @@ -276,12 +78,12 @@ export async function layoutPerRIdHeaderFooters( ); } else { // Single-section or uniform margins: use original single-constraint path - const effectiveHeaderRefsBySection = buildEffectiveRefsBySection(sectionMetadata, 'header'); - const effectiveFooterRefsBySection = buildEffectiveRefsBySection(sectionMetadata, 'footer'); + const effectiveHeaderRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, 'header'); + const effectiveFooterRefsBySection = buildEffectiveHeaderFooterRefsBySection(sectionMetadata, 'footer'); await layoutBlocksByRId( 'header', headerBlocksByRId, - collectReferencedRIdsBySection(effectiveHeaderRefsBySection), + collectReferencedHeaderFooterRIds(effectiveHeaderRefsBySection), constraints, pageResolver, deps.headerLayoutsByRId, @@ -289,7 +91,7 @@ export async function layoutPerRIdHeaderFooters( await layoutBlocksByRId( 'footer', footerBlocksByRId, - collectReferencedRIdsBySection(effectiveFooterRefsBySection), + collectReferencedHeaderFooterRIds(effectiveFooterRefsBySection), constraints, pageResolver, deps.footerLayoutsByRId, @@ -410,59 +212,15 @@ async function layoutWithPerSectionConstraints( layoutsByRId: Map, ): Promise { if (!blocksByRId) return; - - const defaultRIdPerSection = resolveDefaultRIdPerSection(sectionMetadata, kind); - - // Extract table width specs per rId (SD-1837). - // Word allows tables in headers/footers to extend beyond content margins. - // For pct tables, the width is relative to the section's content width. - // For auto-width tables, the grid columns define the minimum width. - const tableWidthSpecByRId = new Map(); - for (const [rId, blocks] of blocksByRId) { - const spec = getTableWidthSpec(blocks); - if (spec) { - tableWidthSpecByRId.set(rId, spec); - } - } - - // Group sections by (rId, effectiveWidth) to measure each unique pair only once - // Key: `${rId}::w${effectiveWidth}`, Value: { constraints, sections[] } - const groups = new Map< - string, - { sectionConstraints: Constraints; sectionIndices: number[]; rId: string; effectiveWidth: number } - >(); - - for (const section of sectionMetadata) { - const rId = defaultRIdPerSection.get(section.sectionIndex); - if (!rId || !blocksByRId.has(rId)) continue; - - // Resolve the minimum width needed for tables in this section. - // For pct tables, this depends on the section's content width. - const contentWidth = buildSectionContentWidth(section, fallbackConstraints); - const tableWidthSpec = tableWidthSpecByRId.get(rId); - const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); - const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); - const effectiveWidth = sectionConstraints.width; - // Include vertical geometry in the key so sections with different page heights, - // vertical margins, or header distance get separate layouts (page-relative anchors - // and header band origin resolve differently). - const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; - - let group = groups.get(groupKey); - if (!group) { - group = { - sectionConstraints, - sectionIndices: [], - rId, - effectiveWidth, - }; - groups.set(groupKey, group); - } - group.sectionIndices.push(section.sectionIndex); - } - - // Measure and layout each unique (rId, effectiveWidth) group - for (const [, group] of groups) { + const groups = buildSectionAwareHeaderFooterMeasurementGroups( + kind, + blocksByRId, + sectionMetadata, + fallbackConstraints, + ); + + // Measure and layout each unique (rId, effectiveWidth) group. + for (const group of groups) { const blocks = blocksByRId.get(group.rId); if (!blocks || blocks.length === 0) continue; @@ -506,7 +264,7 @@ async function layoutWithPerSectionConstraints( effectiveWidth: needsFrameAdjust ? group.effectiveWidth : undefined, }; - layoutsByRId.set(`${group.rId}::s${sectionIndex}`, result); + layoutsByRId.set(buildSectionAwareHeaderFooterLayoutKey(group.rId, sectionIndex), result); } } } catch (error) { diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index 6c6b6d372f..c7af0a33ee 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -521,6 +521,86 @@ describe('HeaderFooterLayoutAdapter', () => { expect(options?.mediaFiles).toEqual(manager.rootEditor.converter.media); }); + it('stamps header/footer FlowBlocks with the part story key', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + + const [, options] = mockToFlowBlocks.mock.calls[0] || []; + expect(options?.storyKey).toBe('hf:part:rId-header-default'); + }); + + it('passes tracked change render config through to header/footer flow blocks', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: false }); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + + const [, options] = mockToFlowBlocks.mock.calls[0] || []; + expect(options?.trackedChangesMode).toBe('final'); + expect(options?.enableTrackedChanges).toBe(false); + }); + + it('invalidates cached header/footer flow blocks when tracked change render config changes', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + adapter.getBatch('header'); + expect(mockToFlowBlocks).toHaveBeenCalledTimes(1); + + adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: true }); + adapter.getBatch('header'); + expect(mockToFlowBlocks).toHaveBeenCalledTimes(2); + }); + it('returns undefined when no descriptors have FlowBlocks', () => { const manager = { getDescriptors: () => [{ id: 'missing', kind: 'header', variant: 'default' }], diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index 2978355844..8c61429e08 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -1,11 +1,12 @@ import { toFlowBlocks } from '@superdoc/pm-adapter'; import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js'; -import type { FlowBlock } from '@superdoc/contracts'; +import type { FlowBlock, TrackedChangesMode } from '@superdoc/contracts'; import type { HeaderFooterBatch } from '@superdoc/layout-bridge'; import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; +import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; const DEFAULT_HEADER_FOOTER_HEIGHT = 100; @@ -78,9 +79,15 @@ export interface HeaderFooterDocument { type HeaderFooterLayoutCacheEntry = { docRef: unknown; + renderConfigKey: string; blocks: FlowBlock[]; }; +export type HeaderFooterTrackedChangesRenderConfig = { + mode: TrackedChangesMode; + enabled: boolean; +}; + type HeaderFooterEditorEntry = { descriptor: HeaderFooterDescriptor; editor: Editor; @@ -1006,6 +1013,10 @@ export class HeaderFooterLayoutAdapter { #manager: HeaderFooterEditorManager; #mediaFiles?: Record; #blockCache: Map = new Map(); + #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: 'review', + enabled: true, + }; /** * Creates a new HeaderFooterLayoutAdapter. @@ -1018,6 +1029,23 @@ export class HeaderFooterLayoutAdapter { this.#mediaFiles = mediaFiles; } + setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void { + const nextConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: config.mode, + enabled: config.enabled, + }; + + if ( + this.#trackedChangesRenderConfig.mode === nextConfig.mode && + this.#trackedChangesRenderConfig.enabled === nextConfig.enabled + ) { + return; + } + + this.#trackedChangesRenderConfig = nextConfig; + this.invalidateAll(); + } + /** * Retrieves FlowBlock batches for all variants of a given header/footer kind. * @@ -1159,8 +1187,9 @@ export class HeaderFooterLayoutAdapter { const doc = this.#manager.getDocumentJson(descriptor); if (!doc) return undefined; + const renderConfigKey = this.#serializeRenderConfig(); const cacheEntry = this.#blockCache.get(descriptor.id); - if (cacheEntry?.docRef === doc) { + if (cacheEntry?.docRef === doc && cacheEntry.renderConfigKey === renderConfigKey) { return cacheEntry.blocks; } @@ -1186,13 +1215,20 @@ export class HeaderFooterLayoutAdapter { converterContext, defaultFont, defaultSize, + trackedChangesMode: this.#trackedChangesRenderConfig.mode, + enableTrackedChanges: this.#trackedChangesRenderConfig.enabled, + storyKey: buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId: descriptor.id }), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); const blocks = result.blocks; - this.#blockCache.set(descriptor.id, { docRef: doc, blocks }); + this.#blockCache.set(descriptor.id, { docRef: doc, renderConfigKey, blocks }); return blocks; } + + #serializeRenderConfig(): string { + return `${this.#trackedChangesRenderConfig.mode}|${this.#trackedChangesRenderConfig.enabled ? '1' : '0'}`; + } /** * Extracts converter context needed for FlowBlock conversion. * Uses type guard for safe access to converter property. diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts index 15d9fe71d7..55b04b0920 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistryInit.ts @@ -6,12 +6,8 @@ import { HeaderFooterLayoutAdapter, type HeaderFooterDescriptor, } from './HeaderFooterRegistry.js'; -import { EditorOverlayManager } from './EditorOverlayManager.js'; export type InitHeaderFooterRegistryDeps = { - painterHost: HTMLElement; - visibleHost: HTMLElement; - selectionOverlay: HTMLElement | null; editor: Editor; converter: Parameters[0]; mediaFiles?: Record; @@ -19,15 +15,12 @@ export type InitHeaderFooterRegistryDeps = { initBudgetMs: number; resetSession: () => void; requestRerender: () => void; - exitHeaderFooterMode: () => void; previousCleanups: Array<() => void>; previousAdapter: HeaderFooterLayoutAdapter | null; previousManager: HeaderFooterEditorManager | null; - previousOverlayManager: EditorOverlayManager | null; }; export type InitHeaderFooterRegistryResult = { - overlayManager: EditorOverlayManager; headerFooterIdentifier: HeaderFooterIdentifier | null; headerFooterManager: HeaderFooterEditorManager; headerFooterAdapter: HeaderFooterLayoutAdapter; @@ -35,9 +28,6 @@ export type InitHeaderFooterRegistryResult = { }; export function initHeaderFooterRegistry({ - painterHost, - visibleHost, - selectionOverlay, editor, converter, mediaFiles, @@ -45,11 +35,9 @@ export function initHeaderFooterRegistry({ initBudgetMs, resetSession, requestRerender, - exitHeaderFooterMode, previousCleanups, previousAdapter, previousManager, - previousOverlayManager, }: InitHeaderFooterRegistryDeps): InitHeaderFooterRegistryResult { const startTime = performance.now(); @@ -62,15 +50,9 @@ export function initHeaderFooterRegistry({ }); previousAdapter?.clear(); previousManager?.destroy(); - previousOverlayManager?.destroy(); resetSession(); - // Initialize EditorOverlayManager for in-place editing - const overlayManager = new EditorOverlayManager(painterHost, visibleHost, selectionOverlay); - // Set callback for when user clicks on dimming overlay to exit edit mode - overlayManager.setOnDimmingClick(exitHeaderFooterMode); - const headerFooterIdentifier = extractIdentifierFromConverter(converter); const headerFooterManager = new HeaderFooterEditorManager(editor); const headerFooterAdapter = new HeaderFooterLayoutAdapter( @@ -99,7 +81,6 @@ export function initHeaderFooterRegistry({ } return { - overlayManager, headerFooterIdentifier, headerFooterManager, headerFooterAdapter, diff --git a/packages/super-editor/src/editors/v1/core/header-footer/types.ts b/packages/super-editor/src/editors/v1/core/header-footer/types.ts index f60b0556fd..561cd6c02d 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/types.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/types.ts @@ -2,7 +2,7 @@ * Shared header/footer types. * * Canonical definitions for header/footer region data used across - * PresentationEditor, EditorOverlayManager, and HeaderFooterSessionManager. + * PresentationEditor and HeaderFooterSessionManager. */ /** diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts index fdb1aaa81d..9e4bc83f3e 100644 --- a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts +++ b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts @@ -43,7 +43,17 @@ function getConverter(editor: Editor): ConverterForHeaderFooter | undefined { // Part ID Parsing // --------------------------------------------------------------------------- -/** Mutation source tag for local header/footer sub-editor edits. */ +/** + * Mutation source tag for local header/footer sub-editor edits. + * + * @remarks + * This tag remains a coordination signal used to suppress redundant refresh + * fan-out when a local sub-editor has already propagated an edit. The + * refactor described in + * `plans/story-backed-parts-presentation-editing.md` (Phase 5) aims to stop + * relying on local UI code to pre-update converter caches; the tag stays, but + * the descriptor path should become authoritative for cache rebuilds. + */ export const SOURCE_HEADER_FOOTER_LOCAL = 'header-footer-sync:local'; const HEADER_PATTERN = /^word\/header\d+\.xml$/; @@ -125,14 +135,17 @@ export function ensureHeaderFooterDescriptor(partId: PartId, sectionId: string): const resolvedSectionId = ctx.sectionId ?? sectionId; - // Local edits (header-footer-sync:local) already update the PM cache - // and refresh other sub-editors in onHeaderFooterDataUpdate. Running - // refreshActiveSubEditors here would re-replace the originating editor, - // causing a redundant update cycle with cursor churn. + // Local edits still emit SOURCE_HEADER_FOOTER_LOCAL as a coordination + // signal so we can suppress redundant live-editor fan-out, but the + // descriptor path is authoritative for rebuilding the PM cache from the + // committed OOXML. This avoids depending on UI callers to pre-update + // converter state before mutatePart runs. const isLocalSync = ctx.source === SOURCE_HEADER_FOOTER_LOCAL; - // For remote applies, rebuild the PM JSON from the updated OOXML - if (!isLocalSync && typeof converter.reimportHeaderFooterPart === 'function') { + // Rebuild the PM JSON cache from the updated OOXML for both local and + // remote applies. Local sync suppresses only the live-editor refresh + // fan-out below. + if (typeof converter.reimportHeaderFooterPart === 'function') { try { const pmJson = converter.reimportHeaderFooterPart(ctx.partId); if (pmJson) { @@ -222,14 +235,18 @@ function destroySubEditors(converter: ConverterForHeaderFooter, type: 'header' | function registerHeaderFooterInvalidationHandler(partId: PartId): void { registerInvalidationHandler(partId, (editor) => { + const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view; + if (!view?.dispatch) { + return; + } + try { const tr = (editor as unknown as { state: { tr: unknown } }).state.tr; const setMeta = (tr as unknown as { setMeta: (key: string, value: boolean) => unknown }).setMeta; setMeta.call(tr, 'forceUpdatePagination', true); - const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view; - view?.dispatch?.(tr); + view.dispatch(tr); } catch { - // View may not be ready + // UI invalidation is best-effort only. } }); } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9eb6c38752..e893e79419 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -26,6 +26,10 @@ import { computeDomCaretPageLocal as computeDomCaretPageLocalFromDom, computeSelectionRectsFromDom as computeSelectionRectsFromDomFromDom, } from '../../dom-observer/DomSelectionGeometry.js'; +import { + readLayoutEpochFromDom as readLayoutEpochFromDomFromDom, + resolvePositionWithinFragmentDom as resolvePositionWithinFragmentDomFromDom, +} from '../../dom-observer/index.js'; import { convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform, getPageOffsetX as getPageOffsetXFromTransform, @@ -58,6 +62,11 @@ import { debugLog, updateSelectionDebugHud, type SelectionDebugHudState } from ' import { renderCellSelectionOverlay } from './selection/CellSelectionOverlay.js'; import { renderCaretOverlay, renderSelectionRects } from './selection/LocalSelectionOverlayRendering.js'; import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from './selection/CaretGeometry.js'; +import { + computeCaretRectFromVisibleTextOffset as computeCaretRectFromVisibleTextOffsetFromHelper, + computeSelectionRectsFromVisibleTextOffsets as computeSelectionRectsFromVisibleTextOffsetsFromHelper, + measureVisibleTextOffset as measureVisibleTextOffsetFromHelper, +} from './selection/VisibleTextOffsetGeometry.js'; import { collectCommentPositions as collectCommentPositionsFromHelper } from './utils/CommentPositionCollection.js'; import { getCurrentSectionPageStyles as getCurrentSectionPageStylesFromHelper } from './layout/SectionPageStyles.js'; import { @@ -73,6 +82,12 @@ import { import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; +import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js'; +import type { StoryPresentationSession } from './story-session/types.js'; +import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; +import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; +import { createStoryEditor } from '../story-editor-factory.js'; +import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js'; import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { @@ -123,6 +138,64 @@ type ThreadAnchorScrollPlan = { achievedClientY: number; applyScroll: (behavior: ScrollBehavior) => void; }; + +type RenderedNoteTarget = { + storyType: 'footnote' | 'endnote'; + noteId: string; +}; + +type NoteStorySession = StoryPresentationSession & { + locator: Extract; +}; + +type BoundedCommentPositionEntry = { + threadId: string; + start?: number; + end?: number; + pos?: number; + key?: string; + storyKey?: string; + kind?: 'trackedChange' | 'comment'; + bounds?: unknown; + rects?: unknown; + pageIndex?: number; +}; + +type NoteLayoutContext = { + target: RenderedNoteTarget; + blocks: FlowBlock[]; + measures: Measure[]; + firstPageIndex: number; + hostWidthPx: number; +}; + +type RenderedNoteFragmentHit = { + fragmentElement: HTMLElement; + pageIndex: number; +}; + +function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { + if (typeof blockId !== 'string' || blockId.length === 0) { + return null; + } + + if (blockId.startsWith('footnote-')) { + const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('__sd_semantic_footnote-')) { + const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('endnote-')) { + const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'endnote', noteId } : null; + } + + return null; +} import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; import { DOM_CLASS_NAMES, buildSdtBlockSelector } from '@superdoc/dom-contract'; import { @@ -130,10 +203,19 @@ import { ensureEditorFieldAnnotationInteractionStyles, } from './dom/EditorStyleInjector.js'; -import type { ResolveRangeOutput, DocumentApi, NavigableAddress, BlockNavigationAddress } from '@superdoc/document-api'; +import type { + ResolveRangeOutput, + DocumentApi, + NavigableAddress, + BlockNavigationAddress, + StoryLocator, +} from '@superdoc/document-api'; +import { isStoryLocator } from '@superdoc/document-api'; import { getBlockIndex } from '../../document-api-adapters/helpers/index-cache.js'; import { findBlockByNodeIdOnly, findBlockById } from '../../document-api-adapters/helpers/node-address-resolver.js'; import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js'; +import { makeTrackedChangeAnchorKey } from '../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; +import { getTrackedChangeIndex } from '../../document-api-adapters/tracked-changes/tracked-change-index.js'; import type { SelectionHandle } from '../selection-state.js'; const DOCUMENT_RELS_PART_ID = 'word/_rels/document.xml.rels'; @@ -313,6 +395,8 @@ export class PresentationEditor extends EventEmitter { #hiddenHostWrapper: HTMLElement; #layoutOptions: LayoutEngineOptions; #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; + #layoutLookupBlocks: FlowBlock[] = []; + #layoutLookupMeasures: Measure[] = []; /** Cache for incremental toFlowBlocks conversion */ #flowBlockCache: FlowBlockCache = new FlowBlockCache(); #footnoteNumberSignature: string | null = null; @@ -369,6 +453,14 @@ export class PresentationEditor extends EventEmitter { #trackedChangesOverrides: TrackedChangesOverrides | undefined; // Header/footer session management #headerFooterSession: HeaderFooterSessionManager | null = null; + /** + * Generic story-backed presentation-session manager. + * + * Story-backed parts (headers, footers, footnotes, endnotes) all use this + * manager to keep ProseMirror off-screen while DomPainter remains the sole + * visible renderer. + */ + #storySessionManager: StoryPresentationSessionManager | null = null; #hoverOverlay: HTMLElement | null = null; #hoverTooltip: HTMLElement | null = null; #modeBanner: HTMLElement | null = null; @@ -377,6 +469,9 @@ export class PresentationEditor extends EventEmitter { #a11yLastAnnouncedSelectionKey: string | null = null; #headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null; #headerFooterEditor: Editor | null = null; + #storySessionSelectionHandler: ((...args: unknown[]) => void) | null = null; + #storySessionTransactionHandler: ((...args: unknown[]) => void) | null = null; + #storySessionEditor: Editor | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; pmStart: number; @@ -631,6 +726,10 @@ export class PresentationEditor extends EventEmitter { modeBanner: this.#modeBanner, }); this.#headerFooterSession.setDocumentMode(this.#documentMode); + this.#headerFooterSession.setTrackedChangesRenderConfig({ + mode: this.#trackedChangesMode, + enabled: this.#trackedChangesEnabled, + }); this.#ariaLiveRegion = doc.createElement('div'); this.#ariaLiveRegion.className = 'presentation-editor__aria-live'; @@ -674,7 +773,7 @@ export class PresentationEditor extends EventEmitter { editorProps: normalizedEditorProps, documentMode: this.#documentMode, }); - this.#wrapHiddenEditorFocus(); + this.#wrapOffscreenEditorFocus(this.#editor); // Set bidirectional reference for renderer-neutral helpers // Type assertion is safe here as we control both Editor and PresentationEditor (this.#editor as Editor & { presentationEditor?: PresentationEditor | null }).presentationEditor = this; @@ -686,6 +785,7 @@ export class PresentationEditor extends EventEmitter { } this.#setupHeaderFooterSession(); + this.#setupStorySessionManager(); this.#applyZoom(); this.#setupEditorListeners(); this.#initializeEditorInputManager(); @@ -693,6 +793,7 @@ export class PresentationEditor extends EventEmitter { this.#setupDragHandlers(); this.#setupInputBridge(); this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); this.#setupSemanticResizeObserver(); this.#initializeProofing(); @@ -721,25 +822,33 @@ export class PresentationEditor extends EventEmitter { } /** - * Wraps the hidden editor's focus method to prevent unwanted scrolling when it receives focus. + * Wraps an off-screen editor's focus method to preserve selection and avoid scroll jumps. * - * The hidden ProseMirror editor is positioned off-screen but must remain focusable for - * accessibility. When it receives focus, browsers may attempt to scroll it into view, - * disrupting the user's viewport position. This method wraps the view's focus function - * to prevent that scroll behavior using multiple fallback strategies. + * PresentationEditor keeps the body editor and hidden-host story-session editors + * mounted off-screen. These editors must stay focusable for accessibility and + * input routing, but a raw focus call can do two harmful things: + * + * 1. Scroll the page toward the off-screen contenteditable. + * 2. Let the browser's stale DOM selection overwrite the ProseMirror selection + * before the active story has a chance to re-apply its real caret position. + * + * This wrapper installs the same focus contract on any off-screen editor we own: + * focus without scrolling, suppress transient selectionchange drift, then let + * ProseMirror re-synchronize its DOM selection. * * @remarks * **Why this exists:** - * - The hidden editor provides semantic document structure for screen readers - * - It must be focusable, but is positioned off-screen with `left: -9999px` + * - Hidden editors provide semantic document structure for screen readers + * - They must be focusable, but are positioned off-screen with `left: -9999px` * - Some browsers scroll to bring focused elements into view, breaking the user experience - * - This wrapper prevents that scroll while maintaining focus behavior + * - Story sessions can temporarily lose native focus to the body editor or a UI surface + * - Restoring focus must preserve the active story selection, not restart at position 1 * - * **Fallback strategies (in order):** + * **Focus strategies (in order):** * 1. Try `view.dom.focus({ preventScroll: true })` - the standard approach * 2. If that fails, try `view.dom.focus()` without options and restore scroll position - * 3. If both fail, call the original ProseMirror focus method as last resort - * 4. Always restore scroll position if it changed during any focus attempt + * 3. Always run the original ProseMirror focus logic so `selectionToDOM()` replays + * 4. Restore scroll position if any focus attempt changed it * * **Idempotency:** * - Safe to call multiple times - checks `__sdPreventScrollFocus` flag to avoid re-wrapping @@ -749,8 +858,8 @@ export class PresentationEditor extends EventEmitter { * - Skips wrapping if the focus function has a `mock` property (Vitest/Jest mocks) * - Prevents interference with test assertions and mock function tracking */ - #wrapHiddenEditorFocus(): void { - const view = this.#editor?.view; + #wrapOffscreenEditorFocus(editor: Editor | null | undefined): void { + const view = editor?.view; if (!view || !view.dom || typeof view.focus !== 'function') { return; } @@ -787,54 +896,60 @@ export class PresentationEditor extends EventEmitter { const beforeX = win.scrollX; const beforeY = win.scrollY; const alreadyFocused = view.hasFocus(); - let focused = false; + + if (!alreadyFocused) { + // When focus jumps back into an off-screen editor, browsers can emit a + // transient DOM selection at the document start before ProseMirror has + // re-applied the current PM selection. Suppress that drift first. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (view as any).domObserver.suppressSelectionUpdates(); + } + + let domFocused = false; // Strategy 1: Try focus with preventScroll option (modern browsers) try { view.dom.focus({ preventScroll: true }); - focused = true; + domFocused = true; } catch (error) { - debugLog('warn', 'Hidden editor focus: preventScroll failed', { + debugLog('warn', 'Off-screen editor focus: preventScroll failed', { error: String(error), strategy: 'preventScroll', }); } // Strategy 2: Fall back to focus without options - if (!focused) { + if (!domFocused) { try { view.dom.focus(); - focused = true; + domFocused = true; } catch (error) { - debugLog('warn', 'Hidden editor focus: standard focus failed', { + debugLog('warn', 'Off-screen editor focus: standard focus failed', { error: String(error), strategy: 'standard', }); } } - // Strategy 3: Last resort - call original ProseMirror focus - if (!focused) { - try { - originalFocus(); - } catch (error) { - debugLog('error', 'Hidden editor focus: all strategies failed', { + // Always let ProseMirror replay its own focus logic after the native DOM + // focus step. This is what writes the current PM selection back into the + // hidden contenteditable, which is critical for story-session carets. + try { + originalFocus(); + } catch (error) { + if (!domFocused) { + debugLog('error', 'Off-screen editor focus: all strategies failed', { + error: String(error), + strategy: 'original', + }); + } else { + debugLog('warn', 'Off-screen editor focus: ProseMirror selection sync failed', { error: String(error), strategy: 'original', }); } } - // When the editor was not focused before, the browser places the DOM selection - // at an arbitrary position inside the off-screen contenteditable. ProseMirror's - // DOMObserver would read this stale position via a selectionchange event and - // overwrite PM state, causing the cursor to jump. Suppress selection updates - // for the next 50ms so PM re-applies its own selection to the DOM instead. - if (!alreadyFocused) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (view as any).domObserver.suppressSelectionUpdates(); - } - // Restore scroll position if any focus attempt changed it if (win.scrollX !== beforeX || win.scrollY !== beforeY) { win.scrollTo(beforeX, beforeY); @@ -1087,6 +1202,11 @@ export class PresentationEditor extends EventEmitter { * ``` */ getActiveEditor(): Editor { + // An active story session (header/footer in hidden-host mode, or a note + // session) always owns the editable surface. + const storySession = this.#storySessionManager?.getActiveSession(); + if (storySession) return storySession.editor; + const session = this.#headerFooterSession?.session; const activeHfEditor = this.#headerFooterSession?.activeEditor; if (!session || session.mode === 'body' || !activeHfEditor) { @@ -1095,6 +1215,60 @@ export class PresentationEditor extends EventEmitter { return activeHfEditor; } + #getActiveStorySession(): StoryPresentationSession | null { + return this.#storySessionManager?.getActiveSession() ?? null; + } + + #getActiveNoteStorySession(): NoteStorySession | null { + const session = this.#getActiveStorySession(); + if (!session || session.kind !== 'note') { + return null; + } + if (session.locator.storyType !== 'footnote' && session.locator.storyType !== 'endnote') { + return null; + } + return session as NoteStorySession; + } + + #getActiveTrackedChangeStorySurface(): { storyKey: string; editor: Editor } | null { + const storySession = this.#getActiveStorySession(); + if (storySession) { + return { + storyKey: buildStoryKey(storySession.locator), + editor: storySession.editor, + }; + } + + const headerFooterSession = this.#headerFooterSession?.session; + const activeHeaderFooterEditor = this.#headerFooterSession?.activeEditor; + const headerFooterRefId = + headerFooterSession && headerFooterSession.mode !== 'body' ? headerFooterSession.headerFooterRefId : null; + + if (!headerFooterRefId || !activeHeaderFooterEditor) { + return null; + } + + return { + storyKey: buildStoryKey({ + kind: 'story', + storyType: 'headerFooterPart', + refId: headerFooterRefId, + }), + editor: activeHeaderFooterEditor, + }; + } + + /** + * Access the generic story-session manager. + * + * PresentationEditor uses one story-session model for all story-backed + * surfaces. This getter exists so tests and other editor-internal helpers + * can inspect the active session. + */ + getStorySessionManager(): StoryPresentationSessionManager | null { + return this.#storySessionManager; + } + // ------------------------------------------------------------------- // Selection bridge — tracked handles + snapshot convenience // ------------------------------------------------------------------- @@ -1375,9 +1549,11 @@ export class PresentationEditor extends EventEmitter { this.#documentMode = mode; this.#editor.setDocumentMode(mode); this.#headerFooterSession?.setDocumentMode(mode); + this.#syncActiveStorySessionDocumentMode(this.#storySessionManager?.getActiveSession() ?? null); this.#syncDocumentModeClass(); this.#syncHiddenEditorA11yAttributes(); const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); // Re-render if mode changed OR tracked changes preferences changed. // Mode change affects enableComments in toFlowBlocks even if tracked changes didn't change. if (modeChanged || trackedChangesChanged) { @@ -1424,6 +1600,7 @@ export class PresentationEditor extends EventEmitter { this.#trackedChangesOverrides = overrides; this.#layoutOptions.trackedChanges = overrides; const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); if (trackedChangesChanged) { // Clear flow block cache since conversion-affecting settings changed this.#flowBlockCache.clear(); @@ -1538,22 +1715,17 @@ export class PresentationEditor extends EventEmitter { * Return layout-relative rects for the current document selection. */ getSelectionRects(relativeTo?: HTMLElement): RangeRect[] { - const selection = this.#editor.state?.selection; + const selection = this.getActiveEditor().state?.selection; if (!selection || selection.empty) return []; return this.getRangeRects(selection.from, selection.to, relativeTo); } - /** - * Convert an arbitrary document range into layout-based bounding rects. - * - * @param from - Start position in the ProseMirror document - * @param to - End position in the ProseMirror document - * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates - * relative to this element's bounding rect. If omitted, returns absolute viewport - * coordinates relative to the selection overlay. - * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height) - */ - getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] { + #computeRangeRects( + from: number, + to: number, + relativeTo?: HTMLElement, + options: { forceBodySurface?: boolean } = {}, + ): RangeRect[] { if (!this.#selectionOverlay) return []; if (!Number.isFinite(from) || !Number.isFinite(to)) return []; @@ -1570,10 +1742,16 @@ export class PresentationEditor extends EventEmitter { let usedDomRects = false; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; + const activeNoteSession = this.#getActiveNoteStorySession(); + const useHeaderFooterSurface = !options.forceBodySurface && sessionMode !== 'body'; + const useNoteSurface = !options.forceBodySurface && activeNoteSession != null; const layoutRectSource = () => { - if (sessionMode !== 'body') { + if (useHeaderFooterSurface) { return this.#computeHeaderFooterSelectionRects(start, end); } + if (useNoteSurface) { + return this.#computeNoteSelectionRects(start, end) ?? []; + } const domRects = this.#computeSelectionRectsFromDom(start, end); if (domRects != null) { usedDomRects = true; @@ -1598,7 +1776,7 @@ export class PresentationEditor extends EventEmitter { let domCaretStart: { pageIndex: number; x: number; y: number } | null = null; let domCaretEnd: { pageIndex: number; x: number; y: number } | null = null; const pageDelta: Record = {}; - if (!usedDomRects) { + if (!usedDomRects && !useNoteSurface) { // Geometry fallback path: apply a small DOM-based delta to reduce drift. try { domCaretStart = this.#computeDomCaretPageLocal(start); @@ -1618,12 +1796,9 @@ export class PresentationEditor extends EventEmitter { } } - // Fix Issue #1: Get actual header/footer page height instead of hardcoded 1 - // When in header/footer mode, we need to use the real page height from the layout context - // to correctly map coordinates for selection highlighting - const pageHeight = sessionMode === 'body' ? this.#getBodyPageHeight() : this.#getHeaderFooterPageHeight(); - const pageGap = this.#layoutState.layout?.pageGap ?? 0; - const finalRects = rawRects + const pageHeight = this.#getBodyPageHeight(); + const pageGap = useHeaderFooterSurface || !this.#layoutState.layout ? 0 : (this.#layoutState.layout.pageGap ?? 0); + return rawRects .map((rect: LayoutRect, idx: number, allRects: LayoutRect[]) => { let adjustedX = rect.x; let adjustedY = rect.y; @@ -1667,8 +1842,20 @@ export class PresentationEditor extends EventEmitter { }; }) .filter((rect: RangeRect | null): rect is RangeRect => Boolean(rect)); + } - return finalRects; + /** + * Convert an arbitrary document range into layout-based bounding rects. + * + * @param from - Start position in the ProseMirror document + * @param to - End position in the ProseMirror document + * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates + * relative to this element's bounding rect. If omitted, returns absolute viewport + * coordinates relative to the selection overlay. + * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height) + */ + getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] { + return this.#computeRangeRects(from, to, relativeTo); } /** @@ -1703,6 +1890,42 @@ export class PresentationEditor extends EventEmitter { }; } + #getThreadSelectionBounds( + data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown }, + relativeTo: HTMLElement | undefined, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + const start = Number.isFinite(data.start ?? data.pos) ? Number(data.start ?? data.pos) : undefined; + const end = Number.isFinite(data.end) ? Number(data.end) : start; + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + const rects = + storyKey === BODY_STORY_KEY + ? this.#computeRangeRects(start!, end!, relativeTo, { forceBodySurface: true }) + : this.getRangeRects(start!, end!, relativeTo); + + if (!rects.length) { + return null; + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return null; + } + + return { + rects, + bounds, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + /** * Remap comment positions to layout coordinates with bounds and rects. * Takes a positions object with threadIds as keys and position data as values. @@ -1755,6 +1978,19 @@ export class PresentationEditor extends EventEmitter { remapped[threadId] = data; return; } + + const storyTrackedBounds = this.#getStoryTrackedChangeBounds(data, relativeTo); + if (storyTrackedBounds) { + hasUpdates = true; + remapped[threadId] = { + ...data, + bounds: storyTrackedBounds.bounds, + rects: storyTrackedBounds.rects, + pageIndex: storyTrackedBounds.pageIndex, + }; + return; + } + const start = data.start ?? data.pos; const end = data.end ?? start; if (!Number.isFinite(start) || !Number.isFinite(end)) { @@ -1762,7 +1998,7 @@ export class PresentationEditor extends EventEmitter { return; } - const layoutRange = this.getSelectionBounds(start!, end!, relativeTo); + const layoutRange = this.#getThreadSelectionBounds(data, relativeTo); if (!layoutRange) { remapped[threadId] = data; return; @@ -1780,6 +2016,23 @@ export class PresentationEditor extends EventEmitter { return hasUpdates ? remapped : positions; } + #shouldEmitCommentPositions(): boolean { + const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true; + return this.#documentMode !== 'viewing' || allowViewingCommentPositions; + } + + #emitCommentPositions(relativeTo?: HTMLElement): void { + if (!this.#shouldEmitCommentPositions()) { + return; + } + + const commentPositions = this.#collectCommentPositions(); + const positionsWithBounds = + relativeTo != null ? this.getCommentBounds(commentPositions, relativeTo) : commentPositions; + + this.emit('commentPositions', { positions: positionsWithBounds }); + } + /** * Collect all comment and tracked change positions from the PM document. * @@ -1791,99 +2044,339 @@ export class PresentationEditor extends EventEmitter { * * @returns Map of threadId -> { threadId, start, end } */ - #collectCommentPositions(): Record { - return collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, { - commentMarkName: CommentMarkName, - trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName], - }); - } - - /** - * Return a snapshot of the latest layout state. - */ - getLayoutSnapshot(): { - layout: Layout | null; - blocks: FlowBlock[]; - measures: Measure[]; - sectionMetadata: SectionMetadata[]; - } { + #collectCommentPositions(): Record< + string, + { + threadId: string; + start?: number; + end?: number; + key?: string; + storyKey?: string; + kind?: 'trackedChange' | 'comment'; + } + > { return { - layout: this.#layoutState.layout, - blocks: this.#layoutState.blocks, - measures: this.#layoutState.measures, - sectionMetadata: this.#sectionMetadata, + ...collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, { + commentMarkName: CommentMarkName, + trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName], + storyKey: BODY_STORY_KEY, + }), + ...this.#collectIndexedTrackedChangePositions(), + ...this.#collectRenderedTrackedChangePositions(), }; } - /** - * Expose the current layout engine options. - */ - getLayoutOptions(): LayoutEngineOptions { - return { ...this.#layoutOptions }; - } + #collectIndexedTrackedChangePositions(): Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + start?: number; + end?: number; + } + > { + const positions: Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + start?: number; + end?: number; + } + > = {}; - #isSemanticFlowMode(): boolean { - return this.#layoutOptions.flowMode === 'semantic'; - } + let snapshots: ReadonlyArray<{ + anchorKey?: unknown; + runtimeRef?: { rawId?: unknown; storyKey?: unknown }; + range?: { from?: unknown; to?: unknown }; + }> = []; - #resolveSemanticMargins(margins: PageMargins): { left: number; right: number; top: number; bottom: number } { - const mode = this.#layoutOptions.semanticOptions?.marginsMode ?? 'firstSection'; - if (mode === 'none') { - return { left: 0, right: 0, top: 0, bottom: 0 }; + try { + snapshots = getTrackedChangeIndex(this.#editor).getAll(); + } catch { + return positions; } - const clamp = (value: number | undefined, fallback: number): number => { - const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback; - return v >= 0 ? v : fallback; - }; + snapshots.forEach((snapshot) => { + const key = typeof snapshot?.anchorKey === 'string' ? snapshot.anchorKey : null; + const storyKey = typeof snapshot?.runtimeRef?.storyKey === 'string' ? snapshot.runtimeRef.storyKey : null; + const rawId = snapshot?.runtimeRef?.rawId; + const threadId = rawId == null ? null : String(rawId); - if (mode === 'custom') { - const custom = this.#layoutOptions.semanticOptions?.customMargins; - return { - left: clamp(custom?.left, clamp(margins.left, DEFAULT_MARGINS.left!)), - right: clamp(custom?.right, clamp(margins.right, DEFAULT_MARGINS.right!)), - top: clamp(custom?.top, clamp(margins.top, DEFAULT_MARGINS.top!)), - bottom: clamp(custom?.bottom, clamp(margins.bottom, DEFAULT_MARGINS.bottom!)), + if (!key || !storyKey || !threadId || storyKey === BODY_STORY_KEY || positions[key]) { + return; + } + + const start = Number.isFinite(snapshot?.range?.from) ? Number(snapshot.range.from) : undefined; + const end = Number.isFinite(snapshot?.range?.to) ? Number(snapshot.range.to) : undefined; + + positions[key] = { + threadId, + key, + storyKey, + kind: 'trackedChange', + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), }; - } - // mode === 'firstSection' — keep horizontal margins from the first DOCX section - // but zero vertical margins so stacked pages form a seamless continuous surface. - return { - left: clamp(margins.left, DEFAULT_MARGINS.left!), - right: clamp(margins.right, DEFAULT_MARGINS.right!), - top: 0, - bottom: 0, - }; + }); + + return positions; } - #resolveSemanticContainerInnerWidth(): number { - const host = this.#visibleHost; - if (!host) return DEFAULT_PAGE_SIZE.w; - const win = host.ownerDocument?.defaultView ?? window; - const style = win.getComputedStyle(host); - const paddingLeft = Number.parseFloat(style.paddingLeft ?? '0'); - const paddingRight = Number.parseFloat(style.paddingRight ?? '0'); - const horizontalPadding = - (Number.isFinite(paddingLeft) ? paddingLeft : 0) + (Number.isFinite(paddingRight) ? paddingRight : 0); - const clientWidth = host.clientWidth; - if (Number.isFinite(clientWidth) && clientWidth > 0) { - return Math.max(1, clientWidth - horizontalPadding); + #collectRenderedTrackedChangePositions(): Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; } - const rectWidth = host.getBoundingClientRect().width; - if (Number.isFinite(rectWidth) && rectWidth > 0) { - return Math.max(1, rectWidth - horizontalPadding); + > { + const positions: Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + } + > = {}; + const host = this.#visibleHost; + + if (!host) { + return positions; } - return Math.max(1, DEFAULT_PAGE_SIZE.w - horizontalPadding); - } - #setupSemanticResizeObserver(): void { - if (!this.#isSemanticFlowMode()) return; - const view = this.#visibleHost.ownerDocument?.defaultView ?? window; - const ResizeObs = view.ResizeObserver; - if (typeof ResizeObs !== 'function') return; + const elements = host.querySelectorAll('[data-track-change-id][data-story-key]'); + elements.forEach((element) => { + const storyKey = element.dataset.storyKey?.trim(); + const rawId = element.dataset.trackChangeId?.trim(); + if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) { + return; + } - this.#lastSemanticContainerWidth = this.#resolveSemanticContainerInnerWidth(); - this.#semanticResizeObserver = new ResizeObs(() => { + const key = makeTrackedChangeAnchorKey({ storyKey, rawId }); + if (positions[key]) { + return; + } + + positions[key] = { + threadId: rawId, + key, + storyKey, + kind: 'trackedChange', + }; + }); + + return positions; + } + + #getStoryTrackedChangeBounds( + data: { threadId?: unknown; storyKey?: unknown; kind?: unknown; start?: unknown; end?: unknown }, + relativeTo?: HTMLElement, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + if (data?.kind !== 'trackedChange') { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + if (!storyKey || storyKey === BODY_STORY_KEY) { + return null; + } + + const activeSurface = this.#getActiveTrackedChangeStorySurface(); + if (!activeSurface || activeSurface.storyKey !== storyKey) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const start = Number.isFinite(data.start) ? Number(data.start) : undefined; + const end = Number.isFinite(data.end) ? Number(data.end) : start; + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const rects = this.getRangeRects(start!, end!, relativeTo); + if (!rects.length) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + return { + bounds, + rects, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + + #getRenderedTrackedChangeBounds( + data: { threadId?: unknown; storyKey?: unknown; kind?: unknown }, + relativeTo?: HTMLElement, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + if (data?.kind !== 'trackedChange') { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + const rawId = typeof data.threadId === 'string' ? data.threadId : null; + if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) { + return null; + } + + const elements = this.#findRenderedTrackedChangeElements(rawId, storyKey); + if (!elements.length) { + return null; + } + + const relativeRect = relativeTo?.getBoundingClientRect?.(); + const rects = elements + .map((element) => { + const rect = element.getBoundingClientRect(); + if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) { + return null; + } + + const pageIndex = Number(element.closest('.superdoc-page')?.dataset?.pageIndex ?? 0); + return { + pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0, + left: rect.left - (relativeRect?.left ?? 0), + top: rect.top - (relativeRect?.top ?? 0), + right: rect.right - (relativeRect?.left ?? 0), + bottom: rect.bottom - (relativeRect?.top ?? 0), + width: rect.width, + height: rect.height, + } satisfies RangeRect; + }) + .filter((rect): rect is RangeRect => Boolean(rect)); + + if (!rects.length) { + return null; + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return null; + } + + return { + bounds, + rects, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + + #findRenderedTrackedChangeElements(rawId: string, storyKey?: string): HTMLElement[] { + const host = this.#visibleHost; + if (!host) { + return []; + } + + const baseSelector = `[data-track-change-id="${escapeAttrValue(rawId)}"]`; + const selector = storyKey ? `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]` : baseSelector; + return Array.from(host.querySelectorAll(selector)); + } + + /** + * Return a snapshot of the latest layout state. + */ + getLayoutSnapshot(): { + layout: Layout | null; + blocks: FlowBlock[]; + measures: Measure[]; + sectionMetadata: SectionMetadata[]; + } { + return { + layout: this.#layoutState.layout, + blocks: this.#layoutState.blocks, + measures: this.#layoutState.measures, + sectionMetadata: this.#sectionMetadata, + }; + } + + /** + * Expose the current layout engine options. + */ + getLayoutOptions(): LayoutEngineOptions { + return { ...this.#layoutOptions }; + } + + #isSemanticFlowMode(): boolean { + return this.#layoutOptions.flowMode === 'semantic'; + } + + #resolveSemanticMargins(margins: PageMargins): { left: number; right: number; top: number; bottom: number } { + const mode = this.#layoutOptions.semanticOptions?.marginsMode ?? 'firstSection'; + if (mode === 'none') { + return { left: 0, right: 0, top: 0, bottom: 0 }; + } + + const clamp = (value: number | undefined, fallback: number): number => { + const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback; + return v >= 0 ? v : fallback; + }; + + if (mode === 'custom') { + const custom = this.#layoutOptions.semanticOptions?.customMargins; + return { + left: clamp(custom?.left, clamp(margins.left, DEFAULT_MARGINS.left!)), + right: clamp(custom?.right, clamp(margins.right, DEFAULT_MARGINS.right!)), + top: clamp(custom?.top, clamp(margins.top, DEFAULT_MARGINS.top!)), + bottom: clamp(custom?.bottom, clamp(margins.bottom, DEFAULT_MARGINS.bottom!)), + }; + } + // mode === 'firstSection' — keep horizontal margins from the first DOCX section + // but zero vertical margins so stacked pages form a seamless continuous surface. + return { + left: clamp(margins.left, DEFAULT_MARGINS.left!), + right: clamp(margins.right, DEFAULT_MARGINS.right!), + top: 0, + bottom: 0, + }; + } + + #resolveSemanticContainerInnerWidth(): number { + const host = this.#visibleHost; + if (!host) return DEFAULT_PAGE_SIZE.w; + const win = host.ownerDocument?.defaultView ?? window; + const style = win.getComputedStyle(host); + const paddingLeft = Number.parseFloat(style.paddingLeft ?? '0'); + const paddingRight = Number.parseFloat(style.paddingRight ?? '0'); + const horizontalPadding = + (Number.isFinite(paddingLeft) ? paddingLeft : 0) + (Number.isFinite(paddingRight) ? paddingRight : 0); + const clientWidth = host.clientWidth; + if (Number.isFinite(clientWidth) && clientWidth > 0) { + return Math.max(1, clientWidth - horizontalPadding); + } + const rectWidth = host.getBoundingClientRect().width; + if (Number.isFinite(rectWidth) && rectWidth > 0) { + return Math.max(1, rectWidth - horizontalPadding); + } + return Math.max(1, DEFAULT_PAGE_SIZE.w - horizontalPadding); + } + + #setupSemanticResizeObserver(): void { + if (!this.#isSemanticFlowMode()) return; + const view = this.#visibleHost.ownerDocument?.defaultView ?? window; + const ResizeObs = view.ResizeObserver; + if (typeof ResizeObs !== 'function') return; + + this.#lastSemanticContainerWidth = this.#resolveSemanticContainerInnerWidth(); + this.#semanticResizeObserver = new ResizeObs(() => { this.#scheduleSemanticResizeRelayout(); }); this.#semanticResizeObserver.observe(this.#visibleHost); @@ -2071,7 +2564,41 @@ export class PresentationEditor extends EventEmitter { y: headerPageIndex * headerPageHeight + (localY - headerPageIndex * headerPageHeight), }; const hit = clickToPositionGeometry(context.layout, context.blocks, context.measures, headerPoint) ?? null; - return hit; + if (!hit) { + return null; + } + + const doc = this.getActiveEditor().state?.doc; + if (!doc) { + return hit; + } + + return { + ...hit, + pos: Math.max(0, Math.min(hit.pos, doc.content.size)), + }; + } + + const noteContext = this.#buildActiveNoteLayoutContext(); + if (noteContext) { + const rawHit = + this.#resolveNoteDomHit(noteContext, clientX, clientY) ?? + clickToPositionGeometry(this.#layoutState.layout, noteContext.blocks, noteContext.measures, normalized, { + geometryHelper: this.#pageGeometryHelper ?? undefined, + }); + if (!rawHit) { + return null; + } + + const doc = this.getActiveEditor().state?.doc; + if (!doc) { + return rawHit; + } + + return { + ...rawHit, + pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)), + }; } if (!this.#layoutState.layout) { @@ -2307,11 +2834,14 @@ export class PresentationEditor extends EventEmitter { // Get selection rects from the header/footer layout (already transformed to viewport) const rects = this.#computeHeaderFooterSelectionRects(pos, pos); - if (!rects || rects.length === 0) { + let rect = rects?.[0] ?? null; + if (!rect) { + rect = this.#computeHeaderFooterCaretRect(pos); + } + if (!rect) { return null; } - const rect = rects[0]; const zoom = this.#layoutOptions.zoom ?? 1; const containerRect = this.#visibleHost.getBoundingClientRect(); const scrollLeft = this.#visibleHost.scrollLeft ?? 0; @@ -2332,6 +2862,36 @@ export class PresentationEditor extends EventEmitter { }; } + if (this.#getActiveNoteStorySession()) { + const rects = this.#computeNoteSelectionRects(pos, pos) ?? []; + let rect = rects?.[0] ?? null; + if (!rect) { + rect = this.#computeNoteCaretRect(pos); + } + if (!rect) { + return null; + } + + const zoom = this.#layoutOptions.zoom ?? 1; + const containerRect = this.#visibleHost.getBoundingClientRect(); + const scrollLeft = this.#visibleHost.scrollLeft ?? 0; + const scrollTop = this.#visibleHost.scrollTop ?? 0; + const pageHeight = this.#getBodyPageHeight(); + const pageGap = this.#layoutState.layout?.pageGap ?? 0; + const pageLocalY = rect.y - rect.pageIndex * (pageHeight + pageGap); + const coords = this.#convertPageLocalToOverlayCoords(rect.pageIndex, rect.x, pageLocalY); + if (!coords) return null; + + return { + top: coords.y * zoom - scrollTop + containerRect.top, + bottom: coords.y * zoom - scrollTop + containerRect.top + rect.height * zoom, + left: coords.x * zoom - scrollLeft + containerRect.left, + right: coords.x * zoom - scrollLeft + containerRect.left + Math.max(1, rect.width) * zoom, + width: Math.max(1, rect.width) * zoom, + height: rect.height * zoom, + }; + } + // In body mode, use main document layout const rects = this.getRangeRects(pos, pos); if (rects && rects.length > 0) { @@ -2430,7 +2990,7 @@ export class PresentationEditor extends EventEmitter { options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {}, ): boolean { // Cancel any pending focus-scroll RAF so this intentional scroll is not undone - // by the wrapHiddenEditorFocus safety net (e.g. search navigation after focus). + // by the wrapOffscreenEditorFocus safety net (e.g. search navigation after focus). if (this.#focusScrollRafId != null) { const win = this.#visibleHost.ownerDocument?.defaultView; if (win) win.cancelAnimationFrame(this.#focusScrollRafId); @@ -2520,12 +3080,17 @@ export class PresentationEditor extends EventEmitter { #buildThreadAnchorScrollPlan(threadId: string, targetClientY: number): ThreadAnchorScrollPlan | null { if (!threadId || !Number.isFinite(targetClientY)) return null; - const threadPosition = this.#collectCommentPositions()[threadId]; + const threadPosition = this.#resolveCommentPositionEntry(threadId); if (!threadPosition) return null; - const selectionBounds = this.getSelectionBounds(threadPosition.start, threadPosition.end); - const currentTop = selectionBounds?.bounds?.top; - if (!Number.isFinite(currentTop)) return null; + const boundedEntry = (this.getCommentBounds({ [threadId]: threadPosition })[threadId] ?? + threadPosition) as BoundedCommentPositionEntry; + const currentTopValue = + typeof boundedEntry.bounds === 'object' && boundedEntry.bounds != null + ? (boundedEntry.bounds as { top?: unknown }).top + : undefined; + if (!Number.isFinite(currentTopValue)) return null; + const currentTop = Number(currentTopValue); const requestedScrollDelta = currentTop - targetClientY; const scrollTarget = this.#scrollContainer ?? this.#visibleHost; @@ -2541,6 +3106,16 @@ export class PresentationEditor extends EventEmitter { return null; } + #resolveCommentPositionEntry(threadId: string): BoundedCommentPositionEntry | null { + const positions = this.#collectCommentPositions(); + const directMatch = positions[threadId]; + if (directMatch) { + return directMatch; + } + + return Object.values(positions).find((entry) => entry?.key === threadId || entry?.threadId === threadId) ?? null; + } + #buildWindowThreadAnchorScrollPlan( scrollTarget: Window, currentTop: number, @@ -2956,6 +3531,8 @@ export class PresentationEditor extends EventEmitter { this.#a11ySelectionAnnounceTimeout = null; } + this.#teardownStorySessionEventBridge(); + // Unregister from static registry if (this.#registryKey) { PresentationEditor.#instances.delete(this.#registryKey); @@ -2968,8 +3545,16 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession = null; }, 'Header/footer session manager'); + // Clean up generic story-session manager (if the flag enabled it) + safeCleanup(() => { + this.#storySessionManager?.destroy(); + this.#storySessionManager = null; + }, 'Story presentation session manager'); + // Clear flow block cache to free memory this.#flowBlockCache.clear(); + this.#layoutLookupBlocks = []; + this.#layoutLookupMeasures = []; this.#painterAdapter.reset(); this.#pageGeometryHelper = null; @@ -3218,6 +3803,7 @@ export class PresentationEditor extends EventEmitter { #setupEditorListeners() { const handleUpdate = ({ transaction }: { transaction?: Transaction }) => { const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); if (transaction) { this.#epochMapper.recordTransaction(transaction); this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch()); @@ -3363,6 +3949,7 @@ export class PresentationEditor extends EventEmitter { // These modify the OOXML part and derived cache but don't change the PM document, // so the normal 'update' event won't trigger a layout refresh. const handleNotesPartChanged = () => { + this.#flowBlockCache.setHasExternalChanges(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); @@ -3556,6 +4143,7 @@ export class PresentationEditor extends EventEmitter { getDocumentMode: () => this.#documentMode, getPageElement: (pageIndex: number) => this.#getPageElement(pageIndex), isSelectionAwareVirtualizationEnabled: () => this.#isSelectionAwareVirtualizationEnabled(), + getActiveStorySession: () => this.#getActiveStorySession(), }); // Set callbacks - functions that the manager calls to interact with PresentationEditor @@ -3573,7 +4161,7 @@ export class PresentationEditor extends EventEmitter { hitTestHeaderFooterRegion: (x: number, y: number, pageIndex?: number, pageLocalY?: number) => this.#hitTestHeaderFooterRegion(x, y, pageIndex, pageLocalY), exitHeaderFooterMode: () => this.#exitHeaderFooterMode(), - activateHeaderFooterRegion: (region) => this.#activateHeaderFooterRegion(region), + activateHeaderFooterRegion: (region, options) => this.#activateHeaderFooterRegion(region, options), emitHeaderFooterEditBlocked: (reason: string) => this.#emitHeaderFooterEditBlocked(reason), findRegionForPage: (kind, pageIndex) => this.#findRegionForPage(kind, pageIndex), getCurrentPageIndex: () => this.#getCurrentPageIndex(), @@ -3581,6 +4169,7 @@ export class PresentationEditor extends EventEmitter { updateSelectionDebugHud: () => this.#updateSelectionDebugHud(), clearHoverRegion: () => this.#clearHoverRegion(), renderHoverRegion: (region) => this.#renderHoverRegion(region), + hitTest: (clientX: number, clientY: number) => this.hitTest(clientX, clientY), focusEditorAfterImageSelection: () => this.#focusEditorAfterImageSelection(), resolveInlineImageElementByPmStart: (pmStart) => this.#painterAdapter.getInlineImageElementByPmStart(pmStart), resolveImageFragmentElementByPmStart: (pmStart) => this.#painterAdapter.getImageFragmentElementByPmStart(pmStart), @@ -3595,6 +4184,8 @@ export class PresentationEditor extends EventEmitter { this.#scheduleSelectionUpdate({ immediate: true }); }, hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y), + activateRenderedNoteSession: (target, options) => this.#activateRenderedNoteSession(target, options), + exitActiveStorySession: () => this.#exitActiveStorySession(), }); } @@ -3794,6 +4385,11 @@ export class PresentationEditor extends EventEmitter { this.#visibleHost, () => this.#getActiveDomTarget(), () => !this.#isViewLocked(), + () => this.#editorInputManager?.notifyTargetChanged(), + { + useWindowFallback: true, + getTargetEditor: () => this.getActiveEditor(), + }, ); this.#inputBridge.bind(); } @@ -3822,6 +4418,7 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, + getStorySessionManager: () => this.#ensureStorySessionManager(), }); // Set up callbacks @@ -3897,6 +4494,21 @@ export class PresentationEditor extends EventEmitter { }); }, onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => { + const documentTransaction = + transaction && typeof transaction === 'object' ? (transaction as { docChanged?: boolean }) : null; + if (documentTransaction?.docChanged && headerId) { + this.#invalidateTrackedChangesForStory({ + kind: 'story', + storyType: 'headerFooterPart', + refId: headerId, + }); + this.#headerFooterSession?.invalidateLayoutForRefs([headerId]); + this.#flowBlockCache.setHasExternalChanges(true); + this.#pendingDocChange = true; + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + this.#emitCommentPositions(); + } this.emit('headerFooterTransaction', { editor: this.#editor, sourceEditor, @@ -3913,48 +4525,180 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession.initialize(); } - /** - * Attempts to perform a table hit test for the given normalized coordinates. - * - * @param normalizedX - X coordinate in layout space - * @param normalizedY - Y coordinate in layout space - * @returns TableHitResult if the point is inside a table cell, null otherwise - * @private - */ - #hitTestTable(normalizedX: number, normalizedY: number): TableHitResult | null { - const configuredPageHeight = (this.#layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE).h; - return hitTestTableFromHelper( - this.#layoutState.layout, - this.#layoutState.blocks, - this.#layoutState.measures, - normalizedX, - normalizedY, - configuredPageHeight, - this.#getEffectivePageGap(), - this.#pageGeometryHelper, - ); + #teardownStorySessionEventBridge(): void { + if (this.#storySessionEditor) { + if (this.#storySessionSelectionHandler) { + this.#storySessionEditor.off?.('selectionUpdate', this.#storySessionSelectionHandler); + } + if (this.#storySessionTransactionHandler) { + this.#storySessionEditor.off?.('transaction', this.#storySessionTransactionHandler); + } + } + this.#storySessionEditor = null; + this.#storySessionSelectionHandler = null; + this.#storySessionTransactionHandler = null; } - /** - * Selects the word at the given document position. - * - * This method traverses up the document tree to find the nearest textblock ancestor, - * then expands the selection to word boundaries using Unicode-aware word character - * detection. This handles cases where the position is within nested structures like - * list items or table cells. - * - * Algorithm: - * 1. Traverse ancestors until a textblock is found (paragraphs, headings, list items) - * 2. From the click position, expand backward while characters match word regex - * 3. Expand forward while characters match word regex - * 4. Create a text selection spanning the word boundaries - * - * @param pos - The absolute document position where the double-click occurred - * @returns true if a word was selected successfully, false otherwise - * @private - */ + #syncStorySessionEventBridge(session: StoryPresentationSession | null): void { + this.#teardownStorySessionEventBridge(); + + if (!session) { + this.#scheduleSelectionUpdate({ immediate: true }); + return; + } + + const handler = () => { + this.#scheduleSelectionUpdate(); + this.#scheduleA11ySelectionAnnouncement(); + }; + const transactionHandler = ({ transaction }: { transaction?: { docChanged?: boolean } }) => { + if (!transaction?.docChanged) { + return; + } + + if (session.kind === 'note') { + this.#invalidateTrackedChangesForStory(session.locator); + this.#flowBlockCache.setHasExternalChanges(true); + this.#pendingDocChange = true; + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + } + }; + + session.editor.on?.('selectionUpdate', handler); + session.editor.on?.('transaction', transactionHandler); + this.#storySessionEditor = session.editor; + this.#storySessionSelectionHandler = handler; + this.#storySessionTransactionHandler = transactionHandler; + this.#scheduleSelectionUpdate({ immediate: true }); + this.#scheduleA11ySelectionAnnouncement({ immediate: true }); + } + + #syncActiveStorySessionDocumentMode(session: StoryPresentationSession | null): void { + if (!session || session.kind !== 'note') { + return; + } + + // Story editors default to viewing mode at construction time. When a note + // session becomes the active presentation surface, it must inherit the + // current document mode so double-clicking produces an actually editable + // footnote/endnote surface. + if (typeof session.editor.setDocumentMode === 'function') { + session.editor.setDocumentMode(this.#documentMode); + return; + } + + session.editor.setEditable?.(this.#documentMode !== 'viewing'); + session.editor.setOptions?.({ documentMode: this.#documentMode }); + } + + #invalidateTrackedChangesForStory(locator: StoryLocator): void { + try { + getTrackedChangeIndex(this.#editor).invalidate(locator); + } catch { + // Tracked-change sync is best-effort while a live story session is typing. + } + } + + #ensureStorySessionManager(): StoryPresentationSessionManager { + if (this.#storySessionManager) { + return this.#storySessionManager; + } + + this.#storySessionManager = new StoryPresentationSessionManager({ + resolveRuntime: (locator) => resolveStoryRuntime(this.#editor, locator, { intent: 'write' }), + getMountContainer: () => { + const doc = this.#visibleHost?.ownerDocument; + return doc?.body ?? this.#visibleHost ?? null; + }, + editorFactory: ({ runtime, hostElement, activationOptions }) => { + const existing = runtime.editor; + const pmJson = existing.getJSON() as unknown as Record; + const editorContext = activationOptions.editorContext ?? {}; + const fresh = createStoryEditor(this.#editor, pmJson, { + documentId: runtime.storyKey, + isHeaderOrFooter: runtime.kind === 'headerFooter', + headless: false, + element: hostElement, + currentPageNumber: editorContext.currentPageNumber, + totalPageCount: editorContext.totalPageCount, + }); + + return { + editor: fresh, + dispose: () => { + try { + fresh.destroy(); + } catch { + // best-effort teardown + } + }, + }; + }, + onActiveSessionChanged: () => { + const activeSession = this.#storySessionManager?.getActiveSession() ?? null; + if (activeSession?.hostWrapper) { + this.#wrapOffscreenEditorFocus(activeSession.editor); + } + this.#syncActiveStorySessionDocumentMode(activeSession); + this.#syncStorySessionEventBridge(activeSession); + this.#inputBridge?.notifyTargetChanged(); + }, + }); + + return this.#storySessionManager; + } + + /** + * Set up the generic story-session manager. + */ + #setupStorySessionManager() { + this.#ensureStorySessionManager(); + } + + /** + * Attempts to perform a table hit test for the given normalized coordinates. + * + * @param normalizedX - X coordinate in layout space + * @param normalizedY - Y coordinate in layout space + * @returns TableHitResult if the point is inside a table cell, null otherwise + * @private + */ + #hitTestTable(normalizedX: number, normalizedY: number): TableHitResult | null { + const configuredPageHeight = (this.#layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE).h; + return hitTestTableFromHelper( + this.#layoutState.layout, + this.#layoutState.blocks, + this.#layoutState.measures, + normalizedX, + normalizedY, + configuredPageHeight, + this.#getEffectivePageGap(), + this.#pageGeometryHelper, + ); + } + + /** + * Selects the word at the given document position. + * + * This method traverses up the document tree to find the nearest textblock ancestor, + * then expands the selection to word boundaries using Unicode-aware word character + * detection. This handles cases where the position is within nested structures like + * list items or table cells. + * + * Algorithm: + * 1. Traverse ancestors until a textblock is found (paragraphs, headings, list items) + * 2. From the click position, expand backward while characters match word regex + * 3. Expand forward while characters match word regex + * 4. Create a text selection spanning the word boundaries + * + * @param pos - The absolute document position where the double-click occurred + * @returns true if a word was selected successfully, false otherwise + * @private + */ #selectWordAt(pos: number): boolean { - const state = this.#editor.state; + const activeEditor = this.getActiveEditor(); + const state = activeEditor.state; if (!state?.doc) { return false; } @@ -3966,7 +4710,7 @@ export class PresentationEditor extends EventEmitter { const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to)); try { - this.#editor.view?.dispatch(tr); + activeEditor.view?.dispatch(tr); return true; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -3992,7 +4736,8 @@ export class PresentationEditor extends EventEmitter { * @private */ #selectParagraphAt(pos: number): boolean { - const state = this.#editor.state; + const activeEditor = this.getActiveEditor(); + const state = activeEditor.state; if (!state?.doc) { return false; } @@ -4002,7 +4747,7 @@ export class PresentationEditor extends EventEmitter { } const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to)); try { - this.#editor.view?.dispatch(tr); + activeEditor.view?.dispatch(tr); return true; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -4266,7 +5011,16 @@ export class PresentationEditor extends EventEmitter { const semanticFootnoteBlocks = isSemanticFlow ? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode) : []; - const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks; + const endnoteBlocks = buildEndnoteBlocks( + this.#editor?.state, + (this.#editor as EditorWithConverter)?.converter, + converterContext, + this.#editor?.converter?.themeColors ?? undefined, + ); + const blocksForLayout = + semanticFootnoteBlocks.length > 0 || endnoteBlocks.length > 0 + ? [...blocks, ...semanticFootnoteBlocks, ...endnoteBlocks] + : blocks; const layoutOptions = !isSemanticFlow && footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } @@ -4284,6 +5038,8 @@ export class PresentationEditor extends EventEmitter { let footerLayouts: HeaderFooterLayoutResult[] | undefined; let extraBlocks: FlowBlock[] | undefined; let extraMeasures: Measure[] | undefined; + let resolveBlocks: FlowBlock[] = blocksForLayout; + let resolveMeasures: Measure[] = previousMeasures; const headerFooterInput = this.#buildHeaderFooterInput(); try { const incrementalLayoutStart = perfNow(); @@ -4322,9 +5078,12 @@ export class PresentationEditor extends EventEmitter { (layout as Layout & { layoutEpoch?: number }).layoutEpoch = layoutEpoch; // Include footnote-injected blocks (separators, footnote paragraphs) so - // resolveLayout can find them when resolving page fragments. + // resolveLayout, painter lookups, and note/story navigation all operate + // on the same block/measure set. bodyBlocksForPaint = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; bodyMeasuresForPaint = extraMeasures ? [...measures, ...extraMeasures] : measures; + resolveBlocks = bodyBlocksForPaint; + resolveMeasures = bodyMeasuresForPaint; resolvedLayout = resolveLayout({ layout, @@ -4353,6 +5112,8 @@ export class PresentationEditor extends EventEmitter { } const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocksForLayout); this.#layoutState = { blocks: blocksForLayout, measures, layout, bookmarks, anchorMap }; + this.#layoutLookupBlocks = resolveBlocks; + this.#layoutLookupMeasures = resolveMeasures; // Build blockId → pageNumber map for TOC page-number resolution. // Stored on editor.storage so the document-api adapter layer can read it @@ -4494,11 +5255,7 @@ export class PresentationEditor extends EventEmitter { // Emit fresh comment positions after layout completes. // Always emit — even when empty — so the store can clear stale positions // (e.g. when undo removes the last tracked-change mark). - const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true; - if (this.#documentMode !== 'viewing' || allowViewingCommentPositions) { - const commentPositions = this.#collectCommentPositions(); - this.emit('commentPositions', { positions: commentPositions }); - } + this.#emitCommentPositions(); this.#selectionSync.requestRender({ immediate: true }); @@ -5019,9 +5776,19 @@ export class PresentationEditor extends EventEmitter { const shouldScrollIntoView = this.#shouldScrollSelectionIntoView && !this.#editorInputManager?.isDragging; this.#shouldScrollSelectionIntoView = false; + const activeStorySession = this.#getActiveStorySession(); + if (activeStorySession?.kind === 'headerFooter') { + this.#updateHeaderFooterSelection(shouldScrollIntoView); + return; + } + if (activeStorySession?.kind === 'note') { + this.#updateNoteSelection(shouldScrollIntoView); + return; + } + const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - this.#updateHeaderFooterSelection(); + this.#updateHeaderFooterSelection(shouldScrollIntoView); return; } @@ -5622,9 +6389,42 @@ export class PresentationEditor extends EventEmitter { return this.#headerFooterSession?.hitTestRegion(x, y, this.#layoutState.layout, pageIndex, pageLocalY) ?? null; } - #activateHeaderFooterRegion(region: HeaderFooterRegion) { - // Delegate to session manager - this.#headerFooterSession?.activateRegion(region); + #activateHeaderFooterRegion( + region: HeaderFooterRegion, + options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' }, + ) { + void this.#activateHeaderFooterRegionAtPoint(region, options); + } + + async #activateHeaderFooterRegionAtPoint( + region: HeaderFooterRegion, + options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' }, + ): Promise { + const editor = + (await this.#headerFooterSession?.activateRegion(region, { + initialSelection: options ? 'defer' : 'end', + })) ?? null; + + if (!editor || !options) { + return; + } + + const doc = editor.state?.doc; + const hit = this.hitTest(options.clientX, options.clientY); + if (!doc || !hit) { + return; + } + + try { + const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos); + const tr = editor.state.tr.setSelection(selection); + editor.view?.dispatch(tr); + editor.view?.focus?.(); + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + } catch { + // Ignore stale activation hits during rerender races. + } } #exitHeaderFooterMode() { @@ -5636,7 +6436,281 @@ export class PresentationEditor extends EventEmitter { this.#editor.view?.focus(); } + #buildNoteLayoutContext(target: RenderedNoteTarget | null | undefined): NoteLayoutContext | null { + const layout = this.#layoutState.layout; + if (!target || !layout) { + return null; + } + + const blocks: FlowBlock[] = []; + const measures: Measure[] = []; + const noteBlockIds = new Set(); + + this.#layoutLookupBlocks.forEach((block, index) => { + const blockId = typeof block?.id === 'string' ? block.id : ''; + const parsed = parseRenderedNoteTarget(blockId); + if (!parsed) { + return; + } + if (parsed.storyType !== target.storyType || parsed.noteId !== target.noteId) { + return; + } + blocks.push(block); + measures.push(this.#layoutLookupMeasures[index]); + noteBlockIds.add(blockId); + }); + + if (blocks.length === 0 || measures.length !== blocks.length) { + return null; + } + + let firstPageIndex = -1; + let hostWidthPx = 0; + + layout.pages.forEach((page, pageIndex) => { + page.fragments.forEach((fragment) => { + if (!noteBlockIds.has(fragment.blockId)) { + return; + } + if (firstPageIndex < 0) { + firstPageIndex = pageIndex; + } + const fragmentWidth = typeof fragment.width === 'number' ? fragment.width : 0; + hostWidthPx = Math.max(hostWidthPx, fragmentWidth); + }); + }); + + if (firstPageIndex < 0) { + firstPageIndex = 0; + } + + if (!(hostWidthPx > 0)) { + const page = layout.pages[firstPageIndex]; + const pageWidth = page?.size?.w ?? layout.pageSize.w ?? DEFAULT_PAGE_SIZE.w; + const margins = page?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; + const marginLeft = margins.left ?? DEFAULT_MARGINS.left ?? 0; + const marginRight = margins.right ?? DEFAULT_MARGINS.right ?? 0; + hostWidthPx = Math.max(1, pageWidth - marginLeft - marginRight); + } + + return { + target, + blocks, + measures, + firstPageIndex, + hostWidthPx: Math.max(1, hostWidthPx), + }; + } + + #buildActiveNoteLayoutContext(): NoteLayoutContext | null { + const session = this.#getActiveNoteStorySession(); + if (!session) { + return null; + } + return this.#buildNoteLayoutContext({ + storyType: session.locator.storyType, + noteId: session.locator.noteId, + }); + } + + #collectNoteBlockIds(context: NoteLayoutContext): Set { + return new Set( + context.blocks + .map((block) => (typeof block?.id === 'string' ? block.id : null)) + .filter((blockId): blockId is string => !!blockId), + ); + } + + #resolveRenderedPageIndexForElement(element: HTMLElement): number { + const pageElement = element.closest('[data-page-index]'); + const pageIndex = Number(pageElement?.dataset.pageIndex ?? 'NaN'); + if (Number.isFinite(pageIndex) && pageIndex >= 0) { + return pageIndex; + } + + const blockId = element.getAttribute('data-block-id') ?? ''; + const layout = this.#layoutState.layout; + if (!blockId || !layout) { + return 0; + } + + for (let index = 0; index < layout.pages.length; index += 1) { + if (layout.pages[index]?.fragments?.some((fragment) => fragment.blockId === blockId)) { + return index; + } + } + + return 0; + } + + #getRenderedNoteFragmentElements(noteBlockIds: ReadonlySet): HTMLElement[] { + if (!this.#viewportHost || noteBlockIds.size === 0) { + return []; + } + + return Array.from(this.#viewportHost.querySelectorAll('[data-block-id]')).filter((element) => + noteBlockIds.has(element.getAttribute('data-block-id') ?? ''), + ); + } + + #findRenderedNoteFragmentAtPoint( + noteBlockIds: ReadonlySet, + clientX: number, + clientY: number, + ): RenderedNoteFragmentHit | null { + const doc = this.#viewportHost.ownerDocument ?? document; + const elementsFromPoint = typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint.bind(doc) : null; + + const toFragmentHit = (element: Element | null): RenderedNoteFragmentHit | null => { + const fragmentElement = element instanceof HTMLElement ? element.closest('[data-block-id]') : null; + const blockId = fragmentElement?.getAttribute('data-block-id') ?? ''; + if (!fragmentElement || !noteBlockIds.has(blockId)) { + return null; + } + + return { + fragmentElement, + pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement), + }; + }; + + if (elementsFromPoint) { + for (const element of elementsFromPoint(clientX, clientY)) { + const fragmentHit = toFragmentHit(element); + if (fragmentHit) { + return fragmentHit; + } + } + } + + for (const fragmentElement of this.#getRenderedNoteFragmentElements(noteBlockIds)) { + const rect = fragmentElement.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { + continue; + } + + return { + fragmentElement, + pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement), + }; + } + + return null; + } + + #resolveNoteDomHit(context: NoteLayoutContext, clientX: number, clientY: number): PositionHit | null { + const layout = this.#layoutState.layout; + if (!layout) { + return null; + } + + const noteBlockIds = this.#collectNoteBlockIds(context); + if (noteBlockIds.size === 0) { + return null; + } + + const fragmentHit = this.#findRenderedNoteFragmentAtPoint(noteBlockIds, clientX, clientY); + if (!fragmentHit) { + return null; + } + + const pos = resolvePositionWithinFragmentDomFromDom(fragmentHit.fragmentElement, clientX, clientY); + if (pos == null) { + return null; + } + + return { + pos, + layoutEpoch: + readLayoutEpochFromDomFromDom(fragmentHit.fragmentElement, clientX, clientY) ?? layout.layoutEpoch ?? 0, + blockId: fragmentHit.fragmentElement.getAttribute('data-block-id') ?? '', + pageIndex: fragmentHit.pageIndex, + column: 0, + lineIndex: -1, + }; + } + + #createCollapsedSelectionNearInlineContent(doc: ProseMirrorNode, pos: number): Selection { + const clampedPos = Math.max(0, Math.min(pos, doc.content.size)); + const directSelection = TextSelection.create(doc, clampedPos); + if (directSelection.$from.parent.inlineContent) { + return directSelection; + } + + const bias = clampedPos >= doc.content.size ? -1 : 1; + return Selection.near(doc.resolve(clampedPos), bias); + } + + #activateRenderedNoteSession( + target: RenderedNoteTarget, + options: { clientX: number; clientY: number; pageIndex?: number }, + ): boolean { + const storySessionManager = this.#ensureStorySessionManager(); + + if (target.storyType !== 'footnote' && target.storyType !== 'endnote') { + return false; + } + + const targetContext = this.#buildNoteLayoutContext(target); + const totalPageCount = this.#layoutState.layout?.pages?.length ?? 1; + const pageNumber = Math.max(1, (options.pageIndex ?? targetContext?.firstPageIndex ?? 0) + 1); + + const session = storySessionManager.activate( + { + kind: 'story', + storyType: target.storyType, + noteId: target.noteId, + }, + { + // Notes need to repaint while the user types; otherwise the hidden-host + // editor is active but the rendered footnote appears frozen until exit. + commitPolicy: 'continuous', + preferHiddenHost: true, + hostWidthPx: targetContext?.hostWidthPx ?? this.#visibleHost.clientWidth ?? 1, + editorContext: { + currentPageNumber: pageNumber, + totalPageCount: Math.max(1, totalPageCount), + surfaceKind: target.storyType === 'endnote' ? 'endnote' : 'note', + }, + }, + ); + + const hit = this.hitTest(options.clientX, options.clientY); + const doc = session.editor.state?.doc; + if (hit && doc) { + try { + const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos); + const tr = session.editor.state.tr.setSelection(selection); + session.editor.view?.dispatch(tr); + } catch { + // Ignore stale pointer hits during activation races. + } + } + + session.editor.view?.focus(); + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + return true; + } + + #exitActiveStorySession(): void { + const session = this.#getActiveStorySession(); + if (!session) { + return; + } + + this.#storySessionManager?.exit(); + this.#pendingDocChange = true; + this.#scheduleRerender(); + this.#editor.view?.focus(); + } + #getActiveDomTarget(): HTMLElement | null { + // While a story session is active, forwarded input targets the session + // editor's DOM rather than the body's hidden editor DOM. + const storyTarget = this.#storySessionManager?.getActiveEditorDomTarget(); + if (storyTarget) return storyTarget; + const session = this.#headerFooterSession?.session; if (session && session.mode !== 'body') { const activeEditor = this.#headerFooterSession?.activeEditor; @@ -5940,7 +7014,7 @@ export class PresentationEditor extends EventEmitter { return await this.#navigateToComment(target.entityId); } if (target.entityType === 'trackedChange') { - return await this.#navigateToTrackedChange(target.entityId); + return await this.#navigateToTrackedChange(target.entityId, resolveStoryKeyFromAddress(target.story)); } } @@ -6022,11 +7096,25 @@ export class PresentationEditor extends EventEmitter { return true; } - async #navigateToTrackedChange(entityId: string): Promise { + async #navigateToTrackedChange(entityId: string, storyKey?: string): Promise { const editor = this.#editor; if (!editor) return false; - const setCursorById = editor.commands?.setCursorById; + if (storyKey && storyKey !== BODY_STORY_KEY) { + if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { + return true; + } + + if (await this.#activateTrackedChangeStorySurface(entityId, storyKey)) { + if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { + return true; + } + } + + return this.#scrollToRenderedTrackedChange(entityId, storyKey); + } + + const setCursorById = editor.commands?.setCursorById; // Try direct cursor placement, then scroll to the new selection. if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) { @@ -6036,7 +7124,9 @@ export class PresentationEditor extends EventEmitter { // Fall back to resolving the tracked change position and scrolling. const resolved = resolveTrackedChange(editor, entityId); - if (!resolved) return false; + if (!resolved) { + return this.#scrollToRenderedTrackedChange(entityId); + } // Try with the raw ID (tracked changes may use a different internal ID). if (typeof setCursorById === 'function' && resolved.rawId !== entityId) { @@ -6058,6 +7148,133 @@ export class PresentationEditor extends EventEmitter { return true; } + async #activateTrackedChangeStorySurface(entityId: string, storyKey: string): Promise { + let locator: StoryLocator | null = null; + try { + locator = parseStoryKey(storyKey); + } catch { + return false; + } + + if (!locator || locator.storyType === 'body') { + return false; + } + + const candidate = this.#findRenderedTrackedChangeElements(entityId, storyKey)[0] ?? null; + if (!candidate) { + return false; + } + + const rect = candidate.getBoundingClientRect(); + const clientX = rect.left + Math.max(rect.width / 2, 1); + const clientY = rect.top + Math.max(rect.height / 2, 1); + const pageIndex = this.#resolveRenderedPageIndexForElement(candidate); + + if (locator.storyType === 'footnote' || locator.storyType === 'endnote') { + try { + if ( + !this.#activateRenderedNoteSession( + { + storyType: locator.storyType, + noteId: locator.noteId, + }, + { clientX, clientY, pageIndex }, + ) + ) { + return false; + } + } catch { + return false; + } + + return this.#waitForTrackedChangeStorySurface(storyKey); + } + + if (locator.storyType !== 'headerFooterPart') { + return false; + } + + const pageElement = candidate.closest('.superdoc-page'); + const pageRect = pageElement?.getBoundingClientRect(); + const pageLocalY = pageRect ? clientY - pageRect.top : undefined; + const region = this.#hitTestHeaderFooterRegion(clientX, clientY, pageIndex, pageLocalY); + if (!region) { + return false; + } + + this.#activateHeaderFooterRegion(region, { + clientX, + clientY, + pageIndex, + source: 'programmatic', + }); + return this.#waitForTrackedChangeStorySurface(storyKey); + } + + async #waitForTrackedChangeStorySurface(storyKey: string, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 16)); + } + + return this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey; + } + + #navigateToActiveStoryTrackedChange(entityId: string, storyKey: string): boolean { + const activeSurface = this.#getActiveTrackedChangeStorySurface(); + if (!activeSurface || activeSurface.storyKey !== storyKey) { + return false; + } + + const sessionEditor = activeSurface.editor; + const setCursorById = sessionEditor.commands?.setCursorById; + + if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) { + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + + const resolved = resolveTrackedChange(sessionEditor, entityId); + if (!resolved) { + return false; + } + + if (typeof setCursorById === 'function' && resolved.rawId !== entityId) { + if (setCursorById(resolved.rawId, { preferredActiveThreadId: resolved.rawId })) { + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + } + + sessionEditor.commands?.setTextSelection?.({ from: resolved.from, to: resolved.from }); + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + + #focusAndRevealActiveStorySelection(editor: Editor): void { + editor.view?.focus?.(); + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + } + + async #scrollToRenderedTrackedChange(entityId: string, storyKey?: string): Promise { + const candidates = this.#findRenderedTrackedChangeElements(entityId, storyKey); + if (!candidates.length) { + return false; + } + + try { + candidates[0]?.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + return true; + } catch { + return false; + } + } + /** * Navigate to a bookmark/anchor in the current document (e.g., TOC links). * @@ -6255,6 +7472,155 @@ export class PresentationEditor extends EventEmitter { return this.#headerFooterSession?.computeSelectionRects(from, to) ?? []; } + #computeHeaderFooterCaretRect(pos: number): LayoutRect | null { + return this.#headerFooterSession?.computeCaretRect(pos) ?? null; + } + + /** + * Translate an active hidden-editor position into a visible-text offset. + * + * `domAtPos()` gives the correct DOM boundary inside the hidden note editor, + * even when the PM position sits inside tracked-change wrapper structure. We + * then measure that boundary as visible text so it can be projected onto the + * painted note surface without relying on raw PM ranges. + */ + #measureActiveEditorVisibleTextOffset(pos: number): number | null { + if (!Number.isFinite(pos)) { + return null; + } + + const activeEditor = this.getActiveEditor(); + const view = activeEditor?.view; + const root = view?.dom as HTMLElement | null; + if (!view || !root) { + return null; + } + + try { + const domPoint = view.domAtPos(pos); + if (!domPoint?.node) { + return null; + } + + return measureVisibleTextOffsetFromHelper(root, domPoint.node, domPoint.offset); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to measure active editor visible text offset:', error); + } + return null; + } + } + + #computeNoteSelectionRectsFromDom(context: NoteLayoutContext, from: number, to: number): LayoutRect[] | null { + const layout = this.#layoutState.layout; + if (!layout) { + return null; + } + + const startOffset = this.#measureActiveEditorVisibleTextOffset(Math.min(from, to)); + const endOffset = this.#measureActiveEditorVisibleTextOffset(Math.max(from, to)); + if (startOffset == null || endOffset == null) { + return null; + } + + const noteFragments = this.#getRenderedNoteFragmentElements(this.#collectNoteBlockIds(context)); + if (!noteFragments.length) { + return null; + } + + return computeSelectionRectsFromVisibleTextOffsetsFromHelper( + { + containers: noteFragments, + zoom: this.#layoutOptions.zoom ?? 1, + pageHeight: this.#getBodyPageHeight(), + pageGap: layout.pageGap ?? this.#getEffectivePageGap(), + }, + startOffset, + endOffset, + ); + } + + #computeNoteSelectionRects(from: number, to: number): LayoutRect[] | null { + const context = this.#buildActiveNoteLayoutContext(); + const layout = this.#layoutState.layout; + if (!context || !layout) { + return null; + } + + const domRects = this.#computeNoteSelectionRectsFromDom(context, from, to); + if (domRects != null) { + return domRects; + } + + return selectionToRects(layout, context.blocks, context.measures, from, to, this.#pageGeometryHelper ?? undefined); + } + + #computeNoteDomCaretRect(context: NoteLayoutContext, pos: number): LayoutRect | null { + const layout = this.#layoutState.layout; + if (!layout) { + return null; + } + + const noteBlockIds = this.#collectNoteBlockIds(context); + if (noteBlockIds.size === 0) { + return null; + } + + const textOffset = this.#measureActiveEditorVisibleTextOffset(pos); + if (textOffset == null) { + return null; + } + + return computeCaretRectFromVisibleTextOffsetFromHelper( + { + containers: this.#getRenderedNoteFragmentElements(noteBlockIds), + zoom: this.#layoutOptions.zoom ?? 1, + pageHeight: this.#getBodyPageHeight(), + pageGap: layout.pageGap ?? this.#getEffectivePageGap(), + }, + textOffset, + ); + } + + #computeNoteCaretRect(pos: number): LayoutRect | null { + const context = this.#buildActiveNoteLayoutContext(); + const layout = this.#layoutState.layout; + if (!context || !layout) { + return null; + } + + const domRect = this.#computeNoteDomCaretRect(context, pos); + if (domRect) { + return domRect; + } + + const geometry = computeCaretLayoutRectGeometryFromHelper( + { + layout, + blocks: context.blocks, + measures: context.measures, + painterHost: this.#painterHost, + viewportHost: this.#viewportHost, + visibleHost: this.#visibleHost, + zoom: this.#layoutOptions.zoom ?? 1, + }, + pos, + false, + ); + if (!geometry) { + return null; + } + + const pageStride = this.#getBodyPageHeight() + (layout.pageGap ?? 0); + return { + pageIndex: geometry.pageIndex, + x: geometry.x, + y: geometry.pageIndex * pageStride + geometry.y, + width: 1, + height: geometry.height, + }; + } + #syncTrackedChangesPreferences(): boolean { const mode = this.#deriveTrackedChangesMode(); const enabled = this.#deriveTrackedChangesEnabled(); @@ -6266,6 +7632,13 @@ export class PresentationEditor extends EventEmitter { return hasChanged; } + #syncHeaderFooterTrackedChangesRenderConfig(): void { + this.#headerFooterSession?.setTrackedChangesRenderConfig({ + mode: this.#trackedChangesMode, + enabled: this.#trackedChangesEnabled, + }); + } + #deriveTrackedChangesMode(): TrackedChangesMode { const overrideMode = this.#trackedChangesOverrides?.mode; if (overrideMode) { @@ -6746,6 +8119,21 @@ export class PresentationEditor extends EventEmitter { if (session && session.mode !== 'body') { return session.pageIndex ?? 0; } + if (this.#getActiveNoteStorySession()) { + const selection = this.getActiveEditor().state?.selection; + if (!selection) { + return this.#buildActiveNoteLayoutContext()?.firstPageIndex ?? 0; + } + const rects = this.#computeNoteSelectionRects(selection.from, selection.to) ?? []; + if (rects.length > 0) { + return rects[0]?.pageIndex ?? 0; + } + return ( + this.#computeNoteCaretRect(selection.from)?.pageIndex ?? + this.#buildActiveNoteLayoutContext()?.firstPageIndex ?? + 0 + ); + } const layout = this.#layoutState.layout; const selection = this.#editor.state?.selection; if (!layout || !selection) { @@ -6870,10 +8258,10 @@ export class PresentationEditor extends EventEmitter { * selection rectangles in layout space, then renders them into the shared * selection overlay so selection behaves consistently with body content. * - * Caret rendering is left to the ProseMirror header/footer editor; this - * overlay only mirrors non-collapsed selections. + * In hidden-host mode this also renders the caret from the active story + * editor's hidden DOM geometry. */ - #updateHeaderFooterSelection() { + #updateHeaderFooterSelection(shouldScrollIntoView = false) { this.#clearSelectedFieldAnnotationClass(); if (!this.#localSelectionLayer) { @@ -6891,11 +8279,35 @@ export class PresentationEditor extends EventEmitter { const { from, to } = selection; - // Let the header/footer ProseMirror editor handle caret rendering. if (from === to) { + const caretRect = this.#computeHeaderFooterCaretRect(from); + if (!caretRect) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + try { this.#localSelectionLayer.innerHTML = ''; - } catch {} + renderCaretOverlay({ + localSelectionLayer: this.#localSelectionLayer, + caretLayout: { + pageIndex: caretRect.pageIndex, + x: caretRect.x, + y: caretRect.y - caretRect.pageIndex * this.#getBodyPageHeight(), + height: caretRect.height, + }, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render header/footer caret:', error); + } + } + if (shouldScrollIntoView) { + this.#scrollActiveEndIntoView(caretRect.pageIndex); + } return; } @@ -6925,6 +8337,94 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render header/footer selection rects:', error); } } + + if (shouldScrollIntoView) { + const selectionHead = activeEditor?.state?.selection?.head ?? to; + const headCaretRect = this.#computeHeaderFooterCaretRect(selectionHead); + const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex; + if (Number.isFinite(headPageIndex)) { + this.#scrollActiveEndIntoView(headPageIndex!); + } + } + } + + #updateNoteSelection(shouldScrollIntoView = false) { + this.#clearSelectedFieldAnnotationClass(); + + if (!this.#localSelectionLayer) { + return; + } + + const activeEditor = this.getActiveEditor(); + const selection = activeEditor?.state?.selection; + if (!selection) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + + const { from, to } = selection; + + if (from === to) { + const caretRect = this.#computeNoteCaretRect(from); + if (!caretRect) { + return; + } + + try { + this.#localSelectionLayer.innerHTML = ''; + renderCaretOverlay({ + localSelectionLayer: this.#localSelectionLayer, + caretLayout: { + pageIndex: caretRect.pageIndex, + x: caretRect.x, + y: + caretRect.y - + caretRect.pageIndex * (this.#getBodyPageHeight() + (this.#layoutState.layout?.pageGap ?? 0)), + height: caretRect.height, + }, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render note caret:', error); + } + } + if (shouldScrollIntoView) { + this.#scrollActiveEndIntoView(caretRect.pageIndex); + } + return; + } + + const rects = this.#computeNoteSelectionRects(from, to); + if (rects == null || !rects.length) { + return; + } + + try { + this.#localSelectionLayer.innerHTML = ''; + renderSelectionRects({ + localSelectionLayer: this.#localSelectionLayer, + rects, + pageHeight: this.#getBodyPageHeight(), + pageGap: this.#layoutState.layout?.pageGap ?? 0, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render note selection rects:', error); + } + } + + if (shouldScrollIntoView) { + const selectionHead = activeEditor?.state?.selection?.head ?? to; + const headCaretRect = this.#computeNoteCaretRect(selectionHead); + const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex; + if (Number.isFinite(headPageIndex)) { + this.#scrollActiveEndIntoView(headPageIndex!); + } + } } #dismissErrorBanner() { @@ -6962,3 +8462,28 @@ export class PresentationEditor extends EventEmitter { return this.#documentMode === 'viewing'; } } + +function escapeAttrValue(value: string): string { + const cssApi = + typeof globalThis === 'object' && globalThis && 'CSS' in globalThis + ? (globalThis.CSS as { escape?: (input: string) => string } | undefined) + : undefined; + + if (typeof cssApi?.escape === 'function') { + return cssApi.escape(value); + } + + return value.replace(/["\\]/g, (char) => `\\${char}`); +} + +function resolveStoryKeyFromAddress(story: StoryLocator | unknown): string | undefined { + if (!isStoryLocator(story)) { + return undefined; + } + + try { + return buildStoryKey(story); + } catch { + return undefined; + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts index 48826151b1..fd1ae02e1e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts @@ -43,9 +43,6 @@ describe('ensureEditorNativeSelectionStyles', () => { expect(css).toContain('.superdoc-layout *::selection'); expect(css).toContain('.superdoc-layout *::-moz-selection'); expect(css).toContain('background: transparent'); - expect(css).toContain('.superdoc-layout .superdoc-header-editor-host *::selection'); - expect(css).toContain('.superdoc-layout .superdoc-footer-editor-host *::selection'); - expect(css).toContain('color: HighlightText'); }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts index 05c07bcb33..308be64218 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts @@ -31,22 +31,6 @@ const NATIVE_SELECTION_STYLES = ` background: transparent; } -/* Keep native selection visible inside live header/footer editors. - * Unlike the main document surface, header/footer editing uses a visible - * ProseMirror host. If we suppress native selection there, users can end up - * with no obvious selection feedback when the custom overlay is subtle or - * still syncing to the current drag gesture. */ -.superdoc-layout .superdoc-header-editor-host *::selection, -.superdoc-layout .superdoc-footer-editor-host *::selection { - background: Highlight; - color: HighlightText; -} - -.superdoc-layout .superdoc-header-editor-host *::-moz-selection, -.superdoc-layout .superdoc-footer-editor-host *::-moz-selection { - background: Highlight; - color: HighlightText; -} `; let nativeSelectionStylesInjected = false; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 698f155d34..eceab88aa0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -4,7 +4,7 @@ * This class encapsulates all the state and logic for: * - Header/footer region tracking and hit testing * - Session state machine (body/header/footer modes) - * - Editor overlay management for H/F editing + * - Hidden-host story-session coordination for H/F editing * - Decoration providers for rendering * - Hover UI for edit affordances * @@ -13,6 +13,8 @@ import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; +import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import type { Editor } from '../../Editor.js'; import type { @@ -27,8 +29,8 @@ import { HeaderFooterEditorManager, HeaderFooterLayoutAdapter, type HeaderFooterDescriptor, + type HeaderFooterTrackedChangesRenderConfig, } from '../../header-footer/HeaderFooterRegistry.js'; -import { EditorOverlayManager } from '../../header-footer/EditorOverlayManager.js'; import { initHeaderFooterRegistry } from '../../header-footer/HeaderFooterRegistryInit.js'; import { layoutPerRIdHeaderFooters } from '../../header-footer/HeaderFooterPerRidLayout.js'; import { @@ -37,13 +39,16 @@ import { getHeaderFooterTypeForSection, getBucketForPageNumber, getBucketRepresentative, + buildSectionAwareHeaderFooterLayoutKey, type HeaderFooterIdentifier, type HeaderFooterLayoutResult, type MultiSectionHeaderFooterIdentifier, type HeaderFooterConstraints, } from '@superdoc/layout-bridge'; +import { selectionToRects } from '@superdoc/layout-bridge'; import { deduplicateOverlappingRects } from '../../../dom-observer/DomSelectionGeometry.js'; import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js'; +import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from '../selection/CaretGeometry.js'; import { ensureExplicitHeaderFooterSlot, normalizeVariant, @@ -53,6 +58,155 @@ import { // Types // ============================================================================= +type SurfacePmEntry = { + pmStart: number; + pmEnd: number; + el: HTMLElement; +}; + +function buildSurfacePmEntries(surface: HTMLElement): SurfacePmEntry[] { + const nodes = Array.from(surface.querySelectorAll('[data-pm-start][data-pm-end]')); + const nonLeaf = new WeakSet(); + const nodeSet = new WeakSet(); + nodes.forEach((node) => nodeSet.add(node)); + + for (const node of nodes) { + let parent = node.parentElement; + while (parent && parent !== surface) { + if (nodeSet.has(parent)) { + nonLeaf.add(parent); + } + parent = parent.parentElement; + } + } + + const entries: SurfacePmEntry[] = []; + for (const node of nodes) { + if (node.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) { + continue; + } + if (nonLeaf.has(node)) { + continue; + } + + const pmStart = Number(node.dataset.pmStart ?? 'NaN'); + const pmEnd = Number(node.dataset.pmEnd ?? 'NaN'); + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || pmEnd < pmStart) { + continue; + } + + entries.push({ pmStart, pmEnd, el: node }); + } + + entries.sort((a, b) => (a.pmStart - b.pmStart !== 0 ? a.pmStart - b.pmStart : a.pmEnd - b.pmEnd)); + return entries; +} + +function findSurfaceEntriesInRange( + entries: SurfacePmEntry[], + from: number, + to: number, + options?: { boundaryInclusive?: boolean }, +): SurfacePmEntry[] { + if (!Number.isFinite(from) || !Number.isFinite(to) || entries.length === 0) { + return []; + } + + const start = Math.min(from, to); + const end = Math.max(from, to); + if (start === end) { + return []; + } + + const boundaryInclusive = options?.boundaryInclusive === true; + return entries.filter((entry) => + boundaryInclusive ? entry.pmStart <= end && entry.pmEnd >= start : entry.pmStart < end && entry.pmEnd > start, + ); +} + +function findSurfaceEntryAtPos(entries: SurfacePmEntry[], pos: number): SurfacePmEntry | null { + if (!Number.isFinite(pos) || entries.length === 0) { + return null; + } + + const exactEntry = entries.find((entry) => pos >= entry.pmStart && pos <= entry.pmEnd); + if (exactEntry) { + return exactEntry; + } + + const nextEntry = entries.find((entry) => pos < entry.pmStart); + if (nextEntry) { + return nextEntry; + } + + return entries[entries.length - 1] ?? null; +} + +function mapPmPosToTextOffset(pos: number, pmStart: number, pmEnd: number, textLength: number): number { + if (!Number.isFinite(pos) || !Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || textLength <= 0) { + return 0; + } + + const pmRange = pmEnd - pmStart; + if (!Number.isFinite(pmRange) || pmRange <= 0) { + return 0; + } + + if (pmRange === textLength) { + return Math.min(textLength, Math.max(0, pos - pmStart)); + } + + if (pos <= pmStart) { + return 0; + } + if (pos >= pmEnd) { + return textLength; + } + + const midpoint = pmStart + pmRange / 2; + return pos <= midpoint ? 0 : textLength; +} + +function setSurfaceRangeStart(range: Range, entry: SurfacePmEntry, pos: number): boolean { + const textNode = entry.el.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + range.setStart(textNode, mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, (textNode as Text).length)); + return true; + } + + if (!entry.el.isConnected || !entry.el.parentNode) { + return false; + } + + if (pos <= entry.pmStart) { + range.setStartBefore(entry.el); + return true; + } + + range.setStartAfter(entry.el); + return true; +} + +function setSurfaceRangeEnd(range: Range, entry: SurfacePmEntry, pos: number): boolean { + const textNode = entry.el.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + range.setEnd(textNode, mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, (textNode as Text).length)); + return true; + } + + if (!entry.el.isConnected || !entry.el.parentNode) { + return false; + } + + if (pos <= entry.pmStart) { + range.setEndBefore(entry.el); + return true; + } + + range.setEndAfter(entry.el); + return true; +} + /** * Options for initializing the HeaderFooterSessionManager. */ @@ -135,6 +289,11 @@ export type SessionManagerDependencies = { setPendingDocChange: () => void; /** Get total page count from body layout */ getBodyPageCount: () => number; + /** Get the generic story-session manager when enabled */ + getStorySessionManager?: () => { + activate: (locator: HeaderFooterPartStoryLocator, options?: Record) => { editor: Editor }; + exit: () => void; + } | null; }; /** @@ -176,6 +335,10 @@ export type SessionManagerCallbacks = { }) => void; }; +type HeaderFooterActivationOptions = { + initialSelection?: 'end' | 'defer'; +}; + // ============================================================================= // HeaderFooterSessionManager // ============================================================================= @@ -194,7 +357,6 @@ export class HeaderFooterSessionManager { #headerFooterAdapter: HeaderFooterLayoutAdapter | null = null; #headerFooterIdentifier: HeaderFooterIdentifier | null = null; #multiSectionIdentifier: MultiSectionHeaderFooterIdentifier | null = null; - #overlayManager: EditorOverlayManager | null = null; #managerCleanups: Array<() => void> = []; // Layout results @@ -224,6 +386,10 @@ export class HeaderFooterSessionManager { // Document mode #documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing'; + #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: 'review', + enabled: true, + }; constructor(options: HeaderFooterSessionManagerOptions) { this.#options = options; @@ -318,11 +484,6 @@ export class HeaderFooterSessionManager { }); } - /** Editor overlay manager */ - get overlayManager(): EditorOverlayManager | null { - return this.#overlayManager; - } - /** Header layout results */ get headerLayoutResults(): HeaderFooterLayoutResult[] | null { return this.#headerLayoutResults; @@ -420,6 +581,26 @@ export class HeaderFooterSessionManager { */ setDocumentMode(mode: 'editing' | 'viewing' | 'suggesting'): void { this.#documentMode = mode; + if (this.#activeEditor) { + this.#applyChildEditorDocumentMode(this.#activeEditor, mode); + } + } + + setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void { + const nextConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: config.mode, + enabled: config.enabled, + }; + + if ( + this.#trackedChangesRenderConfig.mode === nextConfig.mode && + this.#trackedChangesRenderConfig.enabled === nextConfig.enabled + ) { + return; + } + + this.#trackedChangesRenderConfig = nextConfig; + this.#headerFooterAdapter?.setTrackedChangesRenderConfig(nextConfig); } /** @@ -451,9 +632,6 @@ export class HeaderFooterSessionManager { const mediaFiles = optionsMedia ?? storageMedia; const result = initHeaderFooterRegistry({ - painterHost: this.#options.painterHost, - visibleHost: this.#options.visibleHost, - selectionOverlay: this.#options.selectionOverlay, editor: this.#options.editor, converter, mediaFiles, @@ -470,19 +648,15 @@ export class HeaderFooterSessionManager { this.#deps?.setPendingDocChange(); this.#deps?.scheduleRerender(); }, - exitHeaderFooterMode: () => { - this.exitMode(); - }, previousCleanups: this.#managerCleanups, previousAdapter: this.#headerFooterAdapter, previousManager: this.#headerFooterManager, - previousOverlayManager: this.#overlayManager, }); - this.#overlayManager = result.overlayManager; this.#headerFooterIdentifier = result.headerFooterIdentifier; this.#headerFooterManager = result.headerFooterManager; this.#headerFooterAdapter = result.headerFooterAdapter; + this.#headerFooterAdapter?.setTrackedChangesRenderConfig(this.#trackedChangesRenderConfig); this.#managerCleanups = result.cleanups; } @@ -708,13 +882,13 @@ export class HeaderFooterSessionManager { /** * Activate a header/footer region for editing. */ - activateRegion(region: HeaderFooterRegion): void { + activateRegion(region: HeaderFooterRegion, options?: HeaderFooterActivationOptions): Promise { const permission = this.#validateEditPermission(); if (!permission.allowed) { this.#callbacks.onEditBlocked?.(permission.reason ?? 'restricted'); - return; + return Promise.resolve(null); } - void this.#enterMode(region); + return this.#enterMode(region, options); } /** @@ -725,15 +899,11 @@ export class HeaderFooterSessionManager { // Capture headerFooterRefId before clearing session - needed for cache invalidation const editedHeaderId = this.#session.headerFooterRefId; - if (this.#activeEditor) { - this.#activeEditor.setEditable(false); - this.#activeEditor.setOptions({ documentMode: 'viewing' }); + this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing'); } this.#teardownActiveEditorEventBridge(); - - this.#overlayManager?.hideEditingOverlay(); - this.#overlayManager?.showSelectionOverlay(); + this.#deps?.getStorySessionManager?.()?.exit(); this.#activeEditor = null; this.#session = { mode: 'body' }; @@ -765,21 +935,49 @@ export class HeaderFooterSessionManager { this.activateRegion(region); } - async #enterMode(region: HeaderFooterRegion): Promise { + #activateStorySessionForRegion(region: HeaderFooterRegion, descriptor: HeaderFooterDescriptor): Editor | null { + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; + if (!storySessionManager) { + return null; + } + + const locator: HeaderFooterPartStoryLocator = { + kind: 'story', + storyType: 'headerFooterPart', + refId: descriptor.id, + }; + + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; + const session = storySessionManager.activate(locator, { + commitPolicy: 'continuous', + preferHiddenHost: true, + hostWidthPx: Math.max(1, region.width), + editorContext: { + availableWidth: Math.max(1, region.width), + availableHeight: Math.max(1, region.height), + currentPageNumber: Math.max(1, region.pageNumber ?? 1), + totalPageCount: Math.max(1, bodyPageCount), + surfaceKind: region.kind, + }, + }); + + return session?.editor ?? null; + } + + async #enterMode(region: HeaderFooterRegion, options?: HeaderFooterActivationOptions): Promise { try { - if (!this.#headerFooterManager || !this.#overlayManager) { + if (!this.#headerFooterManager) { this.clearHover(); - return; + return null; } // Clean up previous session if switching between pages while in editing mode if (this.#session.mode !== 'body') { if (this.#activeEditor) { - this.#activeEditor.setEditable(false); - this.#activeEditor.setOptions({ documentMode: 'viewing' }); + this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing'); } this.#teardownActiveEditorEventBridge(); - this.#overlayManager.hideEditingOverlay(); + this.#deps?.getStorySessionManager?.()?.exit(); this.#activeEditor = null; this.#session = { mode: 'body' }; } @@ -809,12 +1007,12 @@ export class HeaderFooterSessionManager { region, ); this.clearHover(); - return; + return null; } if (!descriptor.id) { console.warn('[HeaderFooterSessionManager] Descriptor missing id:', descriptor); this.clearHover(); - return; + return null; } // Virtualized pages may not be mounted - scroll into view if needed @@ -830,7 +1028,7 @@ export class HeaderFooterSessionManager { error: new Error('Failed to mount page for editing'), context: 'enterMode', }); - return; + return null; } pageElement = this.#deps?.getPageElement(region.pageIndex) ?? null; } catch (scrollError) { @@ -840,7 +1038,7 @@ export class HeaderFooterSessionManager { error: scrollError, context: 'enterMode.pageMount', }); - return; + return null; } } @@ -851,115 +1049,65 @@ export class HeaderFooterSessionManager { error: new Error('Page element not found after mount'), context: 'enterMode', }); - return; + return null; } - const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; - const { success, editorHost, reason } = this.#overlayManager.showEditingOverlay( - pageElement, - region, - layoutOptions.zoom ?? 1, - ); - if (!success || !editorHost) { - console.error('[HeaderFooterSessionManager] Failed to create editor host:', reason); + let editor; + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; + if (!storySessionManager) { this.clearHover(); this.#callbacks.onError?.({ - error: new Error(`Failed to create editor host: ${reason}`), - context: 'enterMode.showOverlay', + error: new Error('Story session manager unavailable'), + context: 'enterMode.storySessionUnavailable', }); - return; + return null; } - const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; - let editor; try { - editor = await this.#headerFooterManager.ensureEditor(descriptor, { - editorHost, - availableWidth: region.width, - availableHeight: region.height, - currentPageNumber: region.pageNumber, - totalPageCount: bodyPageCount, - }); + editor = this.#activateStorySessionForRegion(region, descriptor); } catch (editorError) { - console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); - this.#overlayManager.hideEditingOverlay(); + console.error('[HeaderFooterSessionManager] Error creating story session:', editorError); this.clearHover(); this.#callbacks.onError?.({ error: editorError, - context: 'enterMode.ensureEditor', + context: 'enterMode.storySession', }); - return; + return null; } if (!editor) { console.warn('[HeaderFooterSessionManager] Failed to ensure editor for descriptor:', descriptor); - this.#overlayManager.hideEditingOverlay(); this.clearHover(); this.#callbacks.onError?.({ error: new Error('Failed to create editor instance'), context: 'enterMode.ensureEditor', }); - return; - } - - // For footers, apply positioning adjustments - if (region.kind === 'footer') { - const editorContainer = editorHost.firstElementChild; - if (editorContainer instanceof HTMLElement) { - editorContainer.style.overflow = 'visible'; - if (region.minY != null && region.minY < 0) { - const shiftDown = Math.abs(region.minY); - editorContainer.style.transform = `translateY(${shiftDown}px)`; - } else { - editorContainer.style.transform = ''; - } - } + return null; } try { - editor.setEditable(true); - editor.setOptions({ documentMode: 'editing' }); - - // Ensure the header/footer editor receives focus on user interaction. - // Without this, subsequent clicks in newly-activated editors may not - // update ProseMirror selection because the view never regains focus. - try { - const editorView = editor.view; - if (editorView && editorHost) { - const focusHandler = () => { - try { - editorView.focus(); - } catch { - // Ignore focus errors; selection updates will still work when possible. - } - }; - editorHost.addEventListener('mousedown', focusHandler); - this.#managerCleanups.push(() => editorHost.removeEventListener('mousedown', focusHandler)); + this.#applyChildEditorDocumentMode(editor, this.#documentMode); + + if (options?.initialSelection !== 'defer') { + try { + const doc = editor.state?.doc; + if (doc) { + const endPos = doc.content.size - 1; + const pos = Math.max(1, endPos); + editor.commands?.setTextSelection?.({ from: pos, to: pos }); + } + } catch (cursorError) { + console.warn('[HeaderFooterSessionManager] Could not set cursor to end:', cursorError); } - } catch { - // Best-effort: if we can't wire the focus handler, continue without it. - } - - // Move caret to end of content - try { - const doc = editor.state?.doc; - if (doc) { - const endPos = doc.content.size - 1; - const pos = Math.max(1, endPos); - editor.commands?.setTextSelection?.({ from: pos, to: pos }); - } - } catch (cursorError) { - console.warn('[HeaderFooterSessionManager] Could not set cursor to end:', cursorError); } } catch (editableError) { console.error('[HeaderFooterSessionManager] Error setting editor editable:', editableError); - this.#overlayManager.hideEditingOverlay(); this.clearHover(); this.#callbacks.onError?.({ error: editableError, context: 'enterMode.setEditable', }); - return; + return null; } this.#activeEditor = editor; @@ -984,13 +1132,13 @@ export class HeaderFooterSessionManager { this.#emitModeChanged(); this.#emitEditingContext(editor); this.#deps?.notifyInputBridgeTargetChanged(); + return editor; } catch (error) { console.error('[HeaderFooterSessionManager] Unexpected error in enterMode:', error); // Attempt cleanup try { - this.#overlayManager?.hideEditingOverlay(); - this.#overlayManager?.showSelectionOverlay(); + this.#deps?.getStorySessionManager?.()?.exit(); this.clearHover(); this.#teardownActiveEditorEventBridge(); this.#activeEditor = null; @@ -1003,6 +1151,33 @@ export class HeaderFooterSessionManager { error, context: 'enterMode', }); + return null; + } + } + + #applyChildEditorDocumentMode(editor: Editor, mode: 'editing' | 'viewing' | 'suggesting'): void { + const pm = editor.view?.dom ?? null; + + if (mode === 'viewing') { + editor.commands?.enableTrackChangesShowOriginal?.(); + editor.setOptions?.({ documentMode: 'viewing' }); + editor.setEditable?.(false); + } else if (mode === 'suggesting') { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.enableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'suggesting' }); + editor.setEditable?.(true); + } else { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.disableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'editing' }); + editor.setEditable?.(true); + } + + if (pm instanceof HTMLElement) { + pm.setAttribute('aria-readonly', mode === 'viewing' ? 'true' : 'false'); + pm.setAttribute('documentmode', mode); + pm.classList.toggle('view-mode', mode === 'viewing'); } } @@ -1377,15 +1552,9 @@ export class HeaderFooterSessionManager { /** * Compute selection rectangles in header/footer mode. * - * This method intentionally does NOT use layout-engine geometry. Header/footer - * editing is driven by a dedicated ProseMirror editor instance mounted inside - * an overlay host. For selection, we rely on the browser's native DOM selection - * rectangles from that editor and then remap them into layout coordinates using - * the current region and body page height. - * - * Selection rectangles are therefore derived from: - * - Native ProseMirror selection → DOM Range → client rects - * - Header/footer region → pageIndex / local offset + * Header/footer editing uses a hidden off-screen ProseMirror host, so the + * visible selection overlay must be derived from the rendered header/footer + * layout rather than from the editor DOM. */ computeSelectionRects(from: number, to: number): LayoutRect[] { // Guard: must be in header/footer mode with an active editor and region context. @@ -1411,30 +1580,15 @@ export class HeaderFooterSessionManager { const region = context.region; const pageIndex = region.pageIndex; + const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; - // Compute DOM-based rectangles local to the editor host. We intentionally - // ignore the numeric from/to arguments and any cached ProseMirror - // selection, and instead rely solely on the live DOM selection inside the - // active header/footer editor. This avoids stale selection state when - // switching between multiple header/footer editors. - const domSelection = view.dom.ownerDocument?.getSelection?.(); - let domRectList: DOMRect[] = []; - - if (domSelection && domSelection.rangeCount > 0) { - for (let i = 0; i < domSelection.rangeCount; i += 1) { - const range = domSelection.getRangeAt(i); - if (!range) continue; - const rangeRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; - domRectList.push(...rangeRects); - } - - // Normalize to a minimal set of rects. Browsers often return both a - // line-box rect and a text-content rect on the same line; without - // deduplication this produces overlapping highlights that look like - // intersecting selections. - domRectList = deduplicateOverlappingRects(domRectList); + const hiddenHostRects = this.#computeHiddenHostSelectionRects(context, from, to, bodyPageHeight); + if (hiddenHostRects) { + return hiddenHostRects; } + const domRectList = this.#computeEditorRangeClientRects(view, from, to); + if (!domRectList.length) { return []; } @@ -1447,7 +1601,6 @@ export class HeaderFooterSessionManager { // deltas and sizes must be converted back out of zoom space here. const editorDom = view.dom as HTMLElement; const editorHostRect = editorDom.getBoundingClientRect(); - const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; const zoom = typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 @@ -1487,6 +1640,356 @@ export class HeaderFooterSessionManager { return layoutRects; } + #computeHiddenHostSelectionRects( + context: HeaderFooterLayoutContext, + from: number, + to: number, + bodyPageHeight: number, + ): LayoutRect[] | null { + const activeEditor = this.#activeEditor; + const editorDom = activeEditor?.view?.dom as HTMLElement | null; + if (!editorDom?.closest?.('.presentation-editor__story-hidden-host')) { + return null; + } + + const visibleSurfaceRects = this.#computeVisibleSurfaceSelectionRects(context, from, to, bodyPageHeight); + if (visibleSurfaceRects?.length) { + return visibleSurfaceRects; + } + + const localRects = selectionToRects(context.layout, context.blocks, context.measures, from, to) ?? []; + if (localRects.length) { + return localRects.map((rect) => ({ + pageIndex: context.region.pageIndex, + x: context.region.localX + rect.x, + y: context.region.pageIndex * bodyPageHeight + context.region.localY + rect.y, + width: rect.width, + height: rect.height, + })); + } + + const liveRect = activeEditor + ? this.#computeHiddenHostLiveRangeRect(activeEditor, from, to, context, bodyPageHeight) + : null; + return liveRect ? [liveRect] : []; + } + + #computeVisibleSurfaceSelectionRects( + context: HeaderFooterLayoutContext, + from: number, + to: number, + bodyPageHeight: number, + ): LayoutRect[] | null { + const pageElement = this.#deps?.getPageElement(context.region.pageIndex); + if (!pageElement) { + return null; + } + + const surfaceSelector = this.#session.mode === 'header' ? '.superdoc-page-header' : '.superdoc-page-footer'; + const surfaceElement = pageElement.querySelector(surfaceSelector); + if (!surfaceElement) { + return null; + } + + const entries = buildSurfacePmEntries(surfaceElement); + const surfaceEntries = findSurfaceEntriesInRange(entries, from, to, { boundaryInclusive: true }); + if (!surfaceEntries.length) { + return null; + } + + const start = Math.min(from, to); + const end = Math.max(from, to); + const startEntry = + surfaceEntries.find((entry) => start >= entry.pmStart && start <= entry.pmEnd) ?? surfaceEntries[0] ?? null; + const endEntry = + surfaceEntries.find((entry) => end >= entry.pmStart && end <= entry.pmEnd) ?? + surfaceEntries[surfaceEntries.length - 1] ?? + null; + if (!startEntry || !endEntry) { + return null; + } + + const doc = pageElement.ownerDocument; + if (!doc?.createRange) { + return null; + } + + const range = doc.createRange(); + try { + if (!setSurfaceRangeStart(range, startEntry, start)) { + return null; + } + if (!setSurfaceRangeEnd(range, endEntry, end)) { + return null; + } + } catch { + return null; + } + + let clientRects: DOMRect[] = []; + try { + clientRects = deduplicateOverlappingRects(Array.from(range.getClientRects()) as unknown as DOMRect[]); + } catch { + return null; + } + + if (!clientRects.length) { + return null; + } + + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const zoom = + typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 + ? layoutOptions.zoom + : 1; + const pageRect = pageElement.getBoundingClientRect(); + + const layoutRects: LayoutRect[] = []; + for (const clientRect of clientRects) { + const width = clientRect.width / zoom; + const height = clientRect.height / zoom; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + continue; + } + + const localX = (clientRect.left - pageRect.left) / zoom; + const localY = (clientRect.top - pageRect.top) / zoom; + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + continue; + } + + layoutRects.push({ + pageIndex: context.region.pageIndex, + x: localX, + y: context.region.pageIndex * bodyPageHeight + localY, + width: Math.max(1, width), + height: Math.max(1, height), + }); + } + + return layoutRects.length ? layoutRects : null; + } + + #computeVisibleSurfaceCaretRect( + context: HeaderFooterLayoutContext, + pos: number, + bodyPageHeight: number, + ): LayoutRect | null { + const pageElement = this.#deps?.getPageElement(context.region.pageIndex); + if (!pageElement) { + return null; + } + + const surfaceSelector = this.#session.mode === 'header' ? '.superdoc-page-header' : '.superdoc-page-footer'; + const surfaceElement = pageElement.querySelector(surfaceSelector); + if (!surfaceElement) { + return null; + } + + const entries = buildSurfacePmEntries(surfaceElement); + const entry = findSurfaceEntryAtPos(entries, pos); + if (!entry) { + return null; + } + + const pageRect = pageElement.getBoundingClientRect(); + const zoom = + typeof this.#deps?.getLayoutOptions()?.zoom === 'number' && + Number.isFinite(this.#deps?.getLayoutOptions()?.zoom) && + (this.#deps?.getLayoutOptions()?.zoom ?? 0) > 0 + ? (this.#deps?.getLayoutOptions()?.zoom as number) + : 1; + + const textNode = Array.from(entry.el.childNodes).find((node): node is Text => node.nodeType === Node.TEXT_NODE); + if (textNode) { + const range = entry.el.ownerDocument?.createRange(); + if (!range) { + return null; + } + + const charIndex = mapPmPosToTextOffset(pos, entry.pmStart, entry.pmEnd, textNode.length); + range.setStart(textNode, charIndex); + range.setEnd(textNode, charIndex); + + const rangeRect = range.getBoundingClientRect(); + if (!Number.isFinite(rangeRect.left) || !Number.isFinite(rangeRect.top) || rangeRect.height <= 0) { + return null; + } + + return { + pageIndex: context.region.pageIndex, + x: (rangeRect.left - pageRect.left) / zoom, + y: context.region.pageIndex * bodyPageHeight + (rangeRect.top - pageRect.top) / zoom, + width: 1, + height: Math.max(1, rangeRect.height / zoom), + }; + } + + const elementRect = entry.el.getBoundingClientRect(); + if (!Number.isFinite(elementRect.left) || !Number.isFinite(elementRect.top) || elementRect.height <= 0) { + return null; + } + + const localX = (pos <= entry.pmStart ? elementRect.left : elementRect.right) - pageRect.left; + return { + pageIndex: context.region.pageIndex, + x: localX / zoom, + y: context.region.pageIndex * bodyPageHeight + (elementRect.top - pageRect.top) / zoom, + width: 1, + height: Math.max(1, elementRect.height / zoom), + }; + } + + #computeHiddenHostLiveRangeRect( + editor: Editor, + from: number, + to: number, + context: HeaderFooterLayoutContext, + bodyPageHeight: number, + ): LayoutRect | null { + const view = editor.view as + | (Editor['view'] & { + coordsAtPos?: (pos: number, side?: number) => { left: number; right: number; top: number; bottom: number }; + }) + | null + | undefined; + + if (!view || typeof view.coordsAtPos !== 'function') { + return null; + } + + const docSize = editor.state?.doc?.content?.size ?? 0; + const start = Math.max(0, Math.min(Math.min(from, to), docSize)); + const end = Math.max(0, Math.min(Math.max(from, to), docSize)); + + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const zoom = + typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 + ? layoutOptions.zoom + : 1; + const editorHostRect = view.dom.getBoundingClientRect(); + + try { + const startCoords = view.coordsAtPos(start); + const endCoords = start === end ? startCoords : view.coordsAtPos(end, -1); + const left = Math.min(startCoords.left, endCoords.left); + const right = Math.max(startCoords.right, endCoords.right); + const top = Math.min(startCoords.top, endCoords.top); + const bottom = Math.max(startCoords.bottom, endCoords.bottom); + const width = Math.max(1, (right - left) / zoom); + const height = Math.max(1, (bottom - top) / zoom); + const localX = (left - editorHostRect.left) / zoom; + const localY = (top - editorHostRect.top) / zoom; + + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex: context.region.pageIndex, + x: context.region.localX + localX, + y: context.region.pageIndex * bodyPageHeight + context.region.localY + localY, + width, + height, + }; + } catch { + return null; + } + } + + #computeEditorRangeClientRects(view: Editor['view'], from: number, to: number): DOMRect[] { + if (!Number.isFinite(from) || !Number.isFinite(to)) { + return []; + } + + const docSize = view.state?.doc?.content?.size ?? 0; + const start = Math.max(0, Math.min(Math.min(from, to), docSize)); + const end = Math.max(0, Math.min(Math.max(from, to), docSize)); + if (start === end || typeof view.domAtPos !== 'function') { + return []; + } + + const doc = view.dom.ownerDocument; + const range = doc?.createRange?.(); + if (!range) { + return []; + } + + try { + const startBoundary = view.domAtPos(start); + const endBoundary = view.domAtPos(end); + range.setStart(startBoundary.node, startBoundary.offset); + range.setEnd(endBoundary.node, endBoundary.offset); + } catch { + return []; + } + + try { + const clientRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; + return deduplicateOverlappingRects(clientRects); + } catch { + return []; + } + } + + computeCaretRect(pos: number): LayoutRect | null { + if (this.#session.mode === 'body') { + return null; + } + + const context = this.getContext(); + if (!context) { + return null; + } + + const region = context.region; + const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; + const visibleSurfaceCaretRect = this.#computeVisibleSurfaceCaretRect(context, pos, bodyPageHeight); + if (visibleSurfaceCaretRect) { + return visibleSurfaceCaretRect; + } + + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const geometry = computeCaretLayoutRectGeometryFromHelper( + { + layout: context.layout, + blocks: context.blocks, + measures: context.measures, + painterHost: null, + viewportHost: this.#options.visibleHost, + visibleHost: this.#options.visibleHost, + zoom: layoutOptions.zoom ?? 1, + }, + pos, + false, + ); + + if (geometry) { + return { + pageIndex: region.pageIndex, + x: region.localX + geometry.x, + y: region.pageIndex * bodyPageHeight + region.localY + geometry.y, + width: 1, + height: geometry.height, + }; + } + + const liveRect = this.#activeEditor + ? this.#computeHiddenHostLiveRangeRect(this.#activeEditor, pos, pos, context, bodyPageHeight) + : null; + if (liveRect) { + return { + pageIndex: liveRect.pageIndex, + x: liveRect.x, + y: liveRect.y, + width: 1, + height: liveRect.height, + }; + } + + return null; + } + /** * Get the current header/footer layout context. */ @@ -1504,27 +2007,18 @@ export class HeaderFooterSessionManager { return null; } - const results = this.#session.mode === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults; - if (!results || results.length === 0) { + const activeLayoutResult = this.#resolveActiveLayoutResult(region); + if (!activeLayoutResult) { console.warn('[HeaderFooterSessionManager] Header/footer layout results not available'); return null; } - const variant = results.find((entry) => entry.type === this.#session.sectionType) ?? results[0] ?? null; - if (!variant) { - console.warn( - '[HeaderFooterSessionManager] Header/footer variant not found for sectionType:', - this.#session.sectionType, - ); - return null; - } - const pageWidth = Math.max(1, region.width); - const pageHeight = Math.max(1, variant.layout.height ?? region.height ?? 1); + const pageHeight = Math.max(1, activeLayoutResult.layout.height ?? region.height ?? 1); const layoutLike: Layout = { pageSize: { w: pageWidth, h: pageHeight }, - pages: variant.layout.pages.map((page: Page) => ({ + pages: activeLayoutResult.layout.pages.map((page: Page) => ({ number: page.number, numberText: page.numberText, fragments: page.fragments, @@ -1533,12 +2027,32 @@ export class HeaderFooterSessionManager { return { layout: layoutLike, - blocks: variant.blocks, - measures: variant.measures, + blocks: activeLayoutResult.blocks, + measures: activeLayoutResult.measures, region, }; } + #resolveActiveLayoutResult(region: HeaderFooterRegion): HeaderFooterLayoutResult | null { + const layoutsByRId = this.#session.mode === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId; + const concreteRefId = this.#session.headerFooterRefId ?? region.headerFooterRefId ?? null; + + if (concreteRefId && layoutsByRId.size > 0) { + const compositeKey = buildSectionAwareHeaderFooterLayoutKey(concreteRefId, region.sectionIndex ?? 0); + const layoutByRef = layoutsByRId.get(compositeKey) ?? layoutsByRId.get(concreteRefId) ?? null; + if (layoutByRef) { + return layoutByRef; + } + } + + const results = this.#session.mode === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults; + if (!results || results.length === 0) { + return null; + } + + return results.find((entry) => entry.type === this.#session.sectionType) ?? results[0] ?? null; + } + /** * Get the page height for header/footer mode. */ @@ -1816,8 +2330,5 @@ export class HeaderFooterSessionManager { this.#hoverTooltip = null; this.#modeBanner = null; this.#hoverRegion = null; - - // Clear overlay manager - this.#overlayManager = null; } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts index f7d6f7e8be..711900a7ef 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts @@ -1,10 +1,18 @@ import { isInRegisteredSurface } from '../utils/uiSurfaceRegistry.js'; import { CONTEXT_MENU_HANDLED_FLAG } from '../../../components/context-menu/event-flags.js'; +const BRIDGE_FORWARDED_FLAG = Symbol('presentation-input-bridge-forwarded'); + export class PresentationInputBridge { #windowRoot: Window; #layoutSurfaces: Set; #getTargetDom: () => HTMLElement | null; + #getTargetEditor?: () => { + focus?: () => void; + view?: { + dom?: HTMLElement | null; + }; + } | null; /** Callback that returns whether the editor is in an editable mode (editing/suggesting vs viewing) */ #isEditable: () => boolean; #onTargetChanged?: (target: HTMLElement | null) => void; @@ -27,6 +35,8 @@ export class PresentationInputBridge { * @param onTargetChanged - Optional callback invoked when the target editor DOM element changes * @param options - Optional configuration including: * - useWindowFallback: Whether to attach window-level event listeners as fallback + * - getTargetEditor: Returns the active editor so focus restoration can + * use editor-aware focus logic instead of raw DOM focus */ constructor( windowRoot: Window, @@ -34,11 +44,20 @@ export class PresentationInputBridge { getTargetDom: () => HTMLElement | null, isEditable: () => boolean, onTargetChanged?: (target: HTMLElement | null) => void, - options?: { useWindowFallback?: boolean }, + options?: { + useWindowFallback?: boolean; + getTargetEditor?: () => { + focus?: () => void; + view?: { + dom?: HTMLElement | null; + }; + } | null; + }, ) { this.#windowRoot = windowRoot; this.#layoutSurfaces = new Set([layoutSurface]); this.#getTargetDom = getTargetDom; + this.#getTargetEditor = options?.getTargetEditor; this.#isEditable = isEditable; this.#onTargetChanged = onTargetChanged; this.#listeners = []; @@ -46,6 +65,15 @@ export class PresentationInputBridge { } bind() { + if (this.#useWindowFallback) { + this.#addListener('keydown', this.#captureStaleKeyboardEvent, this.#windowRoot, true); + this.#addListener('beforeinput', this.#captureStaleTextEvent, this.#windowRoot, true); + this.#addListener('input', this.#captureStaleTextEvent, this.#windowRoot, true); + this.#addListener('compositionstart', this.#captureStaleCompositionEvent, this.#windowRoot, true); + this.#addListener('compositionupdate', this.#captureStaleCompositionEvent, this.#windowRoot, true); + this.#addListener('compositionend', this.#captureStaleCompositionEvent, this.#windowRoot, true); + } + const keyboardTargets = this.#getListenerTargets(); keyboardTargets.forEach((target) => { this.#addListener('keydown', this.#forwardKeyboardEvent, target); @@ -120,12 +148,30 @@ export class PresentationInputBridge { } #dispatchToTarget(originalEvent: Event, synthetic: Event) { - if (this.#destroyed) return; - const target = this.#getTargetDom(); - this.#currentTarget = target; + const target = this.#resolveDispatchTarget(); if (!target) return; + this.#dispatchToResolvedTarget(originalEvent, synthetic, target); + } + + #dispatchToResolvedTarget( + originalEvent: Event, + synthetic: Event, + target: HTMLElement, + options?: { focusTarget?: boolean; suppressOriginal?: boolean }, + ) { + if (this.#destroyed) return; const isConnected = (target as { isConnected?: boolean }).isConnected; if (isConnected === false) return; + + if (options?.suppressOriginal) { + this.#suppressOriginalEvent(originalEvent); + } + + if (options?.focusTarget) { + this.#focusTargetDom(target); + } + + this.#currentTarget = target; try { const canceled = !target.dispatchEvent(synthetic) || synthetic.defaultPrevented; if (canceled) { @@ -138,6 +184,91 @@ export class PresentationInputBridge { } } + #resolveDispatchTarget(): HTMLElement | null { + const target = this.#getTargetDom(); + this.#currentTarget = target; + if (!target) return null; + const isConnected = (target as { isConnected?: boolean }).isConnected; + if (isConnected === false) return null; + return target; + } + + #focusTargetDom(target: HTMLElement) { + const targetEditor = this.#getTargetEditor?.() ?? null; + const targetEditorDom = targetEditor?.view?.dom ?? null; + if (targetEditorDom === target && typeof targetEditor?.focus === 'function') { + targetEditor.focus(); + return; + } + + const doc = target.ownerDocument ?? document; + const active = doc.activeElement as HTMLElement | null; + const activeIsTarget = active === target || (!!active && target.contains(active)); + if (activeIsTarget) { + return; + } + + try { + target.focus({ preventScroll: true }); + } catch { + target.focus(); + } + } + + #suppressOriginalEvent(event: Event) { + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === 'function') { + event.stopImmediatePropagation(); + } + } + + /** + * Resolve a hidden editor DOM that still owns native focus even though a + * different editor surface is currently active. + * + * This happens when body focus survives or is restored while a footnote / + * header / footer session is visually active. Native input then targets the + * stale hidden editor directly, bypassing the visible-surface bridge unless we + * intercept and reroute it. + */ + #resolveStaleEditorOrigin(event: Event): { activeTarget: HTMLElement; staleEditorTarget: HTMLElement } | null { + const activeTarget = this.#resolveDispatchTarget(); + if (!activeTarget) { + return null; + } + + if (this.#isEventOnActiveTarget(event)) { + return null; + } + + if (this.#isInLayoutSurface(event)) { + return null; + } + + if (isInRegisteredSurface(event)) { + return null; + } + + const originNode = event.target as Node | null; + const originElement = + originNode instanceof HTMLElement + ? originNode + : originNode?.parentElement instanceof HTMLElement + ? originNode.parentElement + : null; + const staleEditorTarget = originElement?.closest?.('.ProseMirror[contenteditable="true"]') as HTMLElement | null; + + if (!staleEditorTarget || staleEditorTarget === activeTarget) { + return null; + } + + return { + activeTarget, + staleEditorTarget, + }; + } + /** * Forwards keyboard events to the hidden editor, skipping IME composition events * and plain character keys (which are handled by beforeinput instead). @@ -146,6 +277,9 @@ export class PresentationInputBridge { * @param event - The keyboard event from the layout surface */ #forwardKeyboardEvent(event: KeyboardEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -161,6 +295,7 @@ export class PresentationInputBridge { if (this.#isPlainCharacterKey(event)) { return; } + this.#markForwardedByBridge(event); // Dispatch synchronously so browser defaults can still be prevented const synthetic = new KeyboardEvent(event.type, { @@ -178,6 +313,47 @@ export class PresentationInputBridge { this.#dispatchToTarget(event, synthetic); } + #captureStaleKeyboardEvent(event: KeyboardEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + // Plain text and IME composition complete through beforeinput/input. + // Restore the active editor view first so the browser routes the follow-up + // text events into the current story surface instead of the stale body DOM. + // Non-text commands (Backspace, Enter, arrows, shortcuts) must also be + // rerouted here because there may be no beforeinput. + this.#focusTargetDom(staleOrigin.activeTarget); + if (this.#isCompositionKeyboardEvent(event) || this.#isPlainCharacterKey(event)) { + return; + } + + const synthetic = new KeyboardEvent(event.type, { + key: event.key, + code: event.code, + location: event.location, + repeat: event.repeat, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + bubbles: true, + cancelable: true, + }); + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards text input events (beforeinput) to the hidden editor. * Uses microtask deferral for cooperative handling. @@ -185,6 +361,9 @@ export class PresentationInputBridge { * @param event - The input event from the layout surface */ #forwardTextEvent(event: InputEvent | TextEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -194,6 +373,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); const dispatchSyntheticEvent = () => { // Only re-check mutable state - surface check was already done @@ -225,6 +405,39 @@ export class PresentationInputBridge { queueMicrotask(dispatchSyntheticEvent); } + #captureStaleTextEvent(event: InputEvent | TextEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + let synthetic: Event; + if (typeof InputEvent !== 'undefined') { + synthetic = new InputEvent(event.type, { + data: (event as InputEvent).data ?? (event as TextEvent).data ?? null, + inputType: (event as InputEvent).inputType ?? 'insertText', + dataTransfer: (event as InputEvent).dataTransfer ?? null, + isComposing: (event as InputEvent).isComposing ?? false, + bubbles: true, + cancelable: true, + }); + } else { + synthetic = new Event(event.type, { bubbles: true, cancelable: true }); + } + + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards composition events (compositionstart, compositionupdate, compositionend) * to the hidden editor for IME input handling. @@ -232,6 +445,9 @@ export class PresentationInputBridge { * @param event - The composition event from the layout surface */ #forwardCompositionEvent(event: CompositionEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -241,6 +457,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); let synthetic: Event; if (typeof CompositionEvent !== 'undefined') { @@ -255,6 +472,36 @@ export class PresentationInputBridge { this.#dispatchToTarget(event, synthetic); } + #captureStaleCompositionEvent(event: CompositionEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + let synthetic: Event; + if (typeof CompositionEvent !== 'undefined') { + synthetic = new CompositionEvent(event.type, { + data: event.data ?? '', + bubbles: true, + cancelable: true, + }); + } else { + synthetic = new Event(event.type, { bubbles: true, cancelable: true }); + } + + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards context menu events to the hidden editor. * @@ -272,6 +519,9 @@ export class PresentationInputBridge { if (handledByContextMenu) { return; } + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -281,6 +531,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); const synthetic = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, @@ -359,6 +610,14 @@ export class PresentationInputBridge { return origin ? this.#layoutSurfaces.has(origin) : false; } + #wasForwardedByBridge(event: Event): boolean { + return Boolean((event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG]); + } + + #markForwardedByBridge(event: Event) { + (event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG] = true; + } + /** * Returns the set of event targets to attach listeners to. * Includes registered layout surfaces and optionally the window for fallback. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts new file mode 100644 index 0000000000..c4ec3c5b3b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts @@ -0,0 +1,167 @@ +import type { EditorState } from 'prosemirror-state'; +import type { FlowBlock, Run as LayoutRun, TextRun } from '@superdoc/contracts'; +import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; +import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; + +import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; +import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; +import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; + +export type EndnoteConverterLike = { + endnotes?: Array<{ id?: unknown; content?: unknown[] }>; +}; + +type ParagraphBlock = FlowBlock & { + kind: 'paragraph'; + runs?: LayoutRun[]; +}; + +const ENDNOTE_MARKER_DATA_ATTR = 'data-sd-endnote-number'; +const DEFAULT_MARKER_FONT_FAMILY = 'Arial'; +const DEFAULT_MARKER_FONT_SIZE = 12; + +export function buildEndnoteBlocks( + editorState: EditorState | null | undefined, + converter: EndnoteConverterLike | null | undefined, + converterContext: ConverterContext | undefined, + themeColors: unknown, +): FlowBlock[] { + if (!editorState) return []; + + const endnoteNumberById = converterContext?.endnoteNumberById; + const importedEndnotes = Array.isArray(converter?.endnotes) ? converter.endnotes : []; + if (importedEndnotes.length === 0) return []; + + const orderedEndnoteIds: string[] = []; + const seen = new Set(); + + editorState.doc.descendants((node) => { + if (node.type?.name !== 'endnoteReference') return; + const id = node.attrs?.id; + if (id == null) return; + const key = String(id); + if (!key || seen.has(key)) return; + seen.add(key); + orderedEndnoteIds.push(key); + }); + + if (orderedEndnoteIds.length === 0) return []; + + const blocks: FlowBlock[] = []; + + orderedEndnoteIds.forEach((id) => { + const entry = findNoteEntryById(importedEndnotes, id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) return; + + try { + const clonedContent = JSON.parse(JSON.stringify(content)); + const endnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); + const result = toFlowBlocks(endnoteDoc, { + blockIdPrefix: `endnote-${id}-`, + storyKey: buildStoryKey({ kind: 'story', storyType: 'endnote', noteId: id }), + enableRichHyperlinks: true, + themeColors: themeColors as never, + converterContext: converterContext as never, + }); + + if (result?.blocks?.length) { + ensureEndnoteMarker(result.blocks, id, endnoteNumberById); + blocks.push(...result.blocks); + } + } catch {} + }); + + return blocks; +} + +function isTextRun(run: LayoutRun): run is TextRun { + return (run.kind === 'text' || run.kind == null) && typeof (run as { text?: unknown }).text === 'string'; +} + +function isEndnoteMarker(run: LayoutRun): boolean { + return isTextRun(run) && Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]); +} + +function resolveDisplayNumber(id: string, endnoteNumberById: Record | undefined): number { + if (!endnoteNumberById || typeof endnoteNumberById !== 'object') return 1; + const num = endnoteNumberById[id]; + if (typeof num === 'number' && Number.isFinite(num) && num > 0) return num; + return 1; +} + +function resolveMarkerFontFamily(firstTextRun: TextRun | undefined): string { + return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY; +} + +function resolveMarkerBaseFontSize(firstTextRun: TextRun | undefined): number { + if ( + typeof firstTextRun?.fontSize === 'number' && + Number.isFinite(firstTextRun.fontSize) && + firstTextRun.fontSize > 0 + ) { + return firstTextRun.fontSize; + } + + return DEFAULT_MARKER_FONT_SIZE; +} + +function buildMarkerRun(markerText: string, firstTextRun: TextRun | undefined): TextRun { + const markerRun: TextRun = { + kind: 'text', + text: markerText, + dataAttrs: { [ENDNOTE_MARKER_DATA_ATTR]: 'true' }, + fontFamily: resolveMarkerFontFamily(firstTextRun), + fontSize: resolveMarkerBaseFontSize(firstTextRun) * SUBSCRIPT_SUPERSCRIPT_SCALE, + vertAlign: 'superscript', + }; + + if (typeof firstTextRun?.bold === 'boolean') markerRun.bold = firstTextRun.bold; + if (typeof firstTextRun?.italic === 'boolean') markerRun.italic = firstTextRun.italic; + if (typeof firstTextRun?.letterSpacing === 'number' && Number.isFinite(firstTextRun.letterSpacing)) { + markerRun.letterSpacing = firstTextRun.letterSpacing; + } + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + + return markerRun; +} + +function syncMarkerRun(target: TextRun, source: TextRun): void { + target.kind = source.kind; + target.text = source.text; + target.dataAttrs = source.dataAttrs; + target.fontFamily = source.fontFamily; + target.fontSize = source.fontSize; + target.bold = source.bold; + target.italic = source.italic; + target.letterSpacing = source.letterSpacing; + target.color = source.color; + target.vertAlign = source.vertAlign; + target.baselineShift = source.baselineShift; + delete target.pmStart; + delete target.pmEnd; +} + +function ensureEndnoteMarker( + blocks: FlowBlock[], + id: string, + endnoteNumberById: Record | undefined, +): void { + const firstParagraph = blocks.find((block): block is ParagraphBlock => block.kind === 'paragraph'); + if (!firstParagraph) return; + + const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + firstParagraph.runs = runs; + + const firstTextRun = runs.find( + (run): run is TextRun => isTextRun(run) && !isEndnoteMarker(run) && run.text.length > 0, + ); + const markerRun = buildMarkerRun(String(resolveDisplayNumber(id, endnoteNumberById)), firstTextRun); + + if (runs[0] && isTextRun(runs[0]) && isEndnoteMarker(runs[0])) { + syncMarkerRun(runs[0], markerRun); + return; + } + + runs.unshift(markerRun); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 9a73cadd3b..9f323f53b2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -6,14 +6,15 @@ * * ## Key Concepts * - * - `pmStart`/`pmEnd`: ProseMirror document positions that map layout elements - * back to their source positions in the editor. Used for selection, cursor - * placement, and click-to-position functionality. - * * - `data-sd-footnote-number`: A data attribute marking the superscript number * run (e.g., "1") at the start of footnote content. Used to distinguish the * marker from actual footnote text during rendering and selection. * + * The synthetic marker is visual chrome, not part of the editable note story. + * It must not carry `pmStart`/`pmEnd`, otherwise the rendered marker consumes + * horizontal space that the hidden story editor does not own. That creates + * caret drift and inaccurate click-to-position at the start of the note. + * * @module presentation-editor/layout/FootnotesBuilder */ @@ -24,6 +25,8 @@ import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; +import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; +import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; // Re-export types for consumers export type { FootnoteReference, FootnotesLayoutInput }; @@ -125,9 +128,10 @@ export function buildFootnotesInput( try { // Deep clone to prevent mutation of the original converter data const clonedContent = JSON.parse(JSON.stringify(content)); - const footnoteDoc = { type: 'doc', content: clonedContent }; + const footnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); const result = toFlowBlocks(footnoteDoc, { blockIdPrefix: `footnote-${id}-`, + storyKey: buildStoryKey({ kind: 'story', storyType: 'footnote', noteId: id }), enableRichHyperlinks: true, themeColors: themeColors as never, converterContext: converterContext as never, @@ -167,25 +171,6 @@ function isFootnoteMarker(run: Run): boolean { return Boolean(run.dataAttrs?.[FOOTNOTE_MARKER_DATA_ATTR]); } -/** - * Finds the first run with valid ProseMirror position data. - * Used to inherit position info for the marker run. - * - * @param runs - Array of runs to search - * @returns The first run with pmStart/pmEnd, or undefined - */ -function findRunWithPositions(runs: Run[]): Run | undefined { - return runs.find((r) => { - if (isFootnoteMarker(r)) return false; - return ( - typeof r.pmStart === 'number' && - Number.isFinite(r.pmStart) && - typeof r.pmEnd === 'number' && - Number.isFinite(r.pmEnd) - ); - }); -} - /** * Resolves the display number for a footnote. * Falls back to 1 if the footnote ID is not in the mapping or invalid. @@ -211,33 +196,6 @@ function resolveMarkerText(value: unknown): string { return String(value ?? ''); } -/** - * Computes the PM position range for the marker run. - * - * The marker inherits position info from an existing run so that clicking - * on the footnote number positions the cursor correctly. The end position - * is clamped to not exceed the original run's range. - * - * @param baseRun - The run to inherit positions from - * @param markerLength - Length of the marker text - * @returns Object with pmStart and pmEnd, or nulls if no base run - */ -function computeMarkerPositions( - baseRun: Run | undefined, - markerLength: number, -): { pmStart: number | null; pmEnd: number | null } { - if (baseRun?.pmStart == null) { - return { pmStart: null, pmEnd: null }; - } - - const pmStart = baseRun.pmStart; - // Clamp pmEnd to not exceed the base run's end position - const pmEnd = - baseRun.pmEnd != null ? Math.max(pmStart, Math.min(baseRun.pmEnd, pmStart + markerLength)) : pmStart + markerLength; - - return { pmStart, pmEnd }; -} - function resolveMarkerFontFamily(firstTextRun: Run | undefined): string { return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY; } @@ -254,11 +212,7 @@ function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number { return DEFAULT_MARKER_FONT_SIZE; } -function buildMarkerRun( - markerText: string, - firstTextRun: Run | undefined, - positions: { pmStart: number | null; pmEnd: number | null }, -): Run { +function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run { const markerRun: Run = { kind: 'text', text: markerText, @@ -274,8 +228,6 @@ function buildMarkerRun( markerRun.letterSpacing = firstTextRun.letterSpacing; } if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; - if (positions.pmStart != null) markerRun.pmStart = positions.pmStart; - if (positions.pmEnd != null) markerRun.pmEnd = positions.pmEnd; return markerRun; } @@ -292,8 +244,8 @@ function syncMarkerRun(target: Run, source: Run): void { target.color = source.color; target.vertAlign = source.vertAlign; target.baselineShift = source.baselineShift; - target.pmStart = source.pmStart ?? target.pmStart; - target.pmEnd = source.pmEnd ?? target.pmEnd; + delete target.pmStart; + delete target.pmEnd; } /** @@ -303,7 +255,8 @@ function syncMarkerRun(target: Run, source: Run): void { * number rendered as a normal digit with superscript styling. This function * prepends that marker to the first paragraph's runs. * - * If a marker already exists, updates its PM positions if missing. + * If a marker already exists, normalizes it back to the synthetic visual-only + * shape so stale PM ranges do not leak into the active editing surface. * Modifies the blocks array in place. * * @param blocks - Array of FlowBlocks to modify @@ -321,11 +274,8 @@ function ensureFootnoteMarker( const runs: Run[] = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; const displayNumber = resolveDisplayNumber(id, footnoteNumberById); const markerText = resolveMarkerText(displayNumber); - - const baseRun = findRunWithPositions(runs); - const positions = computeMarkerPositions(baseRun, markerText.length); const firstTextRun = runs.find((run) => typeof run.text === 'string' && !isFootnoteMarker(run)); - const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun, positions); + const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun); // Check if marker already exists const existingMarker = runs.find(isFootnoteMarker); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index af38ffb68e..2d64ad536e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -23,6 +23,7 @@ import type { PositionHit, PageGeometryHelper, TableHitResult } from '@superdoc/ import type { SelectionDebugHudState } from '../selection/SelectionDebug.js'; import type { EpochPositionMapper } from '../layout/EpochPositionMapper.js'; import type { HeaderFooterSessionManager } from '../header-footer/HeaderFooterSessionManager.js'; +import type { StoryPresentationSession } from '../story-session/types.js'; import { getFragmentAtPosition } from '@superdoc/layout-bridge'; import { resolvePointerPositionHit } from '../input/PositionHitResolver.js'; @@ -55,6 +56,9 @@ const AUTO_SCROLL_MAX_SPEED_PX = 24; const SCROLL_DETECTION_TOLERANCE_PX = 1; const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight'; const TRACK_CHANGE_SELECTOR = '[data-track-change-id]'; +const PM_TRACK_CHANGE_SELECTOR = '.track-insert[data-id], .track-delete[data-id], .track-format[data-id]'; +const VISIBLE_HEADER_FOOTER_SELECTOR = '.superdoc-page-header, .superdoc-page-footer'; +const VISIBLE_BODY_CONTENT_SELECTOR = '.superdoc-line, .superdoc-fragment, [data-block-id]'; const COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], @@ -71,12 +75,54 @@ type CommentThreadHit = { }; /** - * Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder). + * Block IDs for note content use `footnote-{id}-` / `endnote-{id}-` prefixes. * Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from * shared constants — it matches both heading and body footnote block IDs. */ -function isFootnoteBlockId(blockId: string): boolean { - return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId)); +function isRenderedNoteBlockId(blockId: string): boolean { + return ( + typeof blockId === 'string' && + (blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId)) + ); +} + +type RenderedNoteTarget = { + storyType: 'footnote' | 'endnote'; + noteId: string; +}; + +function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { + if (typeof blockId !== 'string' || blockId.length === 0) { + return null; + } + + if (blockId.startsWith('footnote-')) { + const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('__sd_semantic_footnote-')) { + const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('endnote-')) { + const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'endnote', noteId } : null; + } + + return null; +} + +function isSameRenderedNoteTarget( + left: RenderedNoteTarget | null | undefined, + right: RenderedNoteTarget | null | undefined, +): boolean { + if (!left || !right) { + return false; + } + + return left.storyType === right.storyType && left.noteId === right.noteId; } function getCommentHighlightThreadIds(target: EventTarget | null): string[] { @@ -111,8 +157,10 @@ function resolveTrackChangeThreadId(target: EventTarget | null): string | null { return null; } - const trackedChangeElement = target.closest(TRACK_CHANGE_SELECTOR); - const threadId = trackedChangeElement?.getAttribute('data-track-change-id')?.trim(); + const trackedChangeElement = target.closest(`${TRACK_CHANGE_SELECTOR}, ${PM_TRACK_CHANGE_SELECTOR}`); + const threadId = + trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ?? + trackedChangeElement?.getAttribute('data-id')?.trim(); return threadId ? threadId : null; } @@ -177,6 +225,54 @@ function collectElementsNearPointerTarget(target: EventTarget | null, clientX: n return candidates; } +function elementContainsPointerSample(element: Element, clientX: number, clientY: number): boolean { + const rect = element.getBoundingClientRect(); + if (![rect.left, rect.top, rect.right, rect.bottom].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0) { + return false; + } + + for (const [offsetX, offsetY] of COMMENT_THREAD_HIT_SAMPLE_OFFSETS) { + const sampleX = clientX + offsetX; + const sampleY = clientY + offsetY; + if (sampleX >= rect.left && sampleX <= rect.right && sampleY >= rect.top && sampleY <= rect.bottom) { + return true; + } + } + + return false; +} + +function resolveCommentThreadIdFromGeometry( + elements: Iterable, + clientX: number, + clientY: number, +): string | null { + let resolvedThreadId: string | null = null; + + for (const element of elements) { + if (!elementContainsPointerSample(element, clientX, clientY)) { + continue; + } + + const hit = resolveCommentThreadHit(element); + if (hit.isAmbiguous) { + return null; + } + + if (!hit.threadId) { + continue; + } + + if (resolvedThreadId && resolvedThreadId !== hit.threadId) { + return null; + } + + resolvedThreadId = hit.threadId; + } + + return resolvedThreadId; +} + function resolveCommentThreadIdNearPointer( target: EventTarget | null, clientX: number, @@ -204,6 +300,45 @@ function resolveCommentThreadIdNearPointer( return null; } +type VisiblePointerSurfaceHit = { kind: 'headerFooter'; surface: HTMLElement } | { kind: 'bodyContent' }; + +function resolveVisibleSurfaceAtPointer( + target: EventTarget | null, + clientX: number, + clientY: number, +): VisiblePointerSurfaceHit | null { + const ownerDocument = target instanceof Element ? target.ownerDocument : document; + const ownerWindow = ownerDocument.defaultView; + + if (typeof ownerDocument.elementFromPoint !== 'function' || !ownerWindow) { + return null; + } + + const sampleX = clamp(clientX, 0, Math.max(ownerWindow.innerWidth - 1, 0)); + const sampleY = clamp(clientY, 0, Math.max(ownerWindow.innerHeight - 1, 0)); + const sampledElements = + typeof ownerDocument.elementsFromPoint === 'function' + ? ownerDocument.elementsFromPoint(sampleX, sampleY) + : [ownerDocument.elementFromPoint(sampleX, sampleY)]; + + for (const element of sampledElements) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const visibleHeaderFooterSurface = element.closest(VISIBLE_HEADER_FOOTER_SELECTOR) as HTMLElement | null; + if (visibleHeaderFooterSurface) { + return { kind: 'headerFooter', surface: visibleHeaderFooterSurface }; + } + + if (element.closest(VISIBLE_BODY_CONTENT_SELECTOR)) { + return { kind: 'bodyContent' }; + } + } + + return null; +} + function getActiveCommentThreadId(editor: Editor): string | null { const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null; const activeThreadId = pluginState?.activeThreadId; @@ -288,6 +423,8 @@ export type EditorInputDependencies = { getPageElement: (pageIndex: number) => HTMLElement | null; /** Check if selection-aware virtualization is enabled */ isSelectionAwareVirtualizationEnabled: () => boolean; + /** Get the currently active non-body story session, if any */ + getActiveStorySession?: () => StoryPresentationSession | null; }; /** @@ -324,7 +461,10 @@ export type EditorInputCallbacks = { /** Exit header/footer mode */ exitHeaderFooterMode?: () => void; /** Activate header/footer region */ - activateHeaderFooterRegion?: (region: HeaderFooterRegion) => void; + activateHeaderFooterRegion?: ( + region: HeaderFooterRegion, + options?: { clientX: number; clientY: number; pageIndex?: number; source?: 'pointerDoubleClick' | 'programmatic' }, + ) => void; /** Emit header/footer edit blocked */ emitHeaderFooterEditBlocked?: (reason: string) => void; /** Find region for page */ @@ -367,6 +507,15 @@ export type EditorInputCallbacks = { notifyDragSelectionEnded?: () => void; /** Hit test table at coordinates */ hitTestTable?: (x: number, y: number) => TableHitResult | null; + /** Hit test the currently active editing surface */ + hitTest?: (clientX: number, clientY: number) => PositionHit | null; + /** Activate a rendered note session from a visible note block click */ + activateRenderedNoteSession?: ( + target: RenderedNoteTarget, + options: { clientX: number; clientY: number; pageIndex?: number }, + ) => boolean; + /** Exit the active generic story session */ + exitActiveStorySession?: () => void; }; // ============================================================================= @@ -605,6 +754,18 @@ export class EditorInputManager { return this.#lastSelectedImageBlockId; } + /** + * Resets click-derived interaction state when the active editing surface + * changes (for example body -> footnote or footnote -> header). + * + * Without this, a single click in the previous surface can be mistaken for + * the first click of a double/triple click in the next surface. + */ + notifyTargetChanged(): void { + this.#resetMultiClickTracking(); + this.#pendingMarginClick = null; + } + /** Drag anchor page index */ get dragAnchorPageIndex(): number | null { return this.#dragAnchorPageIndex; @@ -659,6 +820,12 @@ export class EditorInputManager { this.#cellDragMode = 'none'; } + #resetMultiClickTracking(): void { + this.#clickCount = 0; + this.#lastClickTime = 0; + this.#lastClickPosition = null; + } + #registerPointerClick(event: MouseEvent): number { const nextState = registerPointerClickFromHelper( event, @@ -682,10 +849,86 @@ export class EditorInputManager { } #getFirstTextPosition(): number { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); return getFirstTextPositionFromHelper(editor?.state?.doc ?? null); } + #resolveBodyPointerHit( + layoutState: ReturnType, + normalized: { x: number; y: number }, + clientX: number, + clientY: number, + ): PositionHit | null { + const viewportHost = this.#deps?.getViewportHost(); + const pageGeometryHelper = this.#deps?.getPageGeometryHelper(); + if (!viewportHost) { + return null; + } + + return ( + resolvePointerPositionHit({ + layout: layoutState.layout, + blocks: layoutState.blocks, + measures: layoutState.measures, + containerPoint: normalized, + domContainer: viewportHost, + clientX, + clientY, + geometryHelper: pageGeometryHelper ?? undefined, + }) ?? null + ); + } + + #resolveSelectionPointerHit(options: { + layoutState: ReturnType; + normalized: { x: number; y: number }; + clientX: number; + clientY: number; + editor: Editor; + useActiveSurfaceHitTest: boolean; + }): { rawHit: PositionHit | null; hit: PositionHit | null } { + const { layoutState, normalized, clientX, clientY, editor, useActiveSurfaceHitTest } = options; + const doc = editor.state?.doc; + const rawHit = + useActiveSurfaceHitTest && this.#callbacks.hitTest + ? this.#callbacks.hitTest(clientX, clientY) + : this.#resolveBodyPointerHit(layoutState, normalized, clientX, clientY); + + if (!rawHit || !doc) { + return { rawHit, hit: null }; + } + + if (useActiveSurfaceHitTest) { + return { + rawHit, + hit: { + ...rawHit, + pos: clamp(rawHit.pos, 0, doc.content.size), + }, + }; + } + + const epochMapper = this.#deps?.getEpochMapper(); + if (!epochMapper) { + return { rawHit, hit: null }; + } + + const mapped = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1); + if (!mapped.ok) { + debugLog('warn', 'pointer mapping failed', mapped); + return { rawHit, hit: null }; + } + + return { + rawHit, + hit: { + ...rawHit, + pos: clamp(mapped.pos, 0, doc.content.size), + layoutEpoch: mapped.toEpoch, + }, + }; + } + #calculateExtendedSelection( anchor: number, head: number, @@ -1061,17 +1304,50 @@ export class EditorInputManager { return; } - const editor = this.#deps.getEditor(); - if (this.#handleSingleCommentHighlightClick(event, target, editor)) { - return; - } + const bodyEditor = this.#deps.getEditor(); + const layoutState = this.#deps.getLayoutState(); + const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY); - if (this.#handleRepeatClickOnActiveComment(event, target, editor)) { - return; - } + // Check header/footer session state + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + let activeStorySession = this.#deps.getActiveStorySession?.() ?? null; + let activeNoteSession = activeStorySession?.kind === 'note' ? activeStorySession : null; + const activeNoteTarget = this.#getActiveRenderedNoteTarget(); - const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) { + if (clickedNoteTarget && !isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget)) { + if (!isDraggableAnnotation) { + event.preventDefault(); + } + const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + }); + if (activated) { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); + return; + } + this.#focusEditor(); + return; + } + + if (!clickedNoteTarget && activeNoteSession) { + this.#callbacks.exitActiveStorySession?.(); + } + + const isActiveStorySurface = sessionMode !== 'body' || activeNoteSession != null; + if (!isActiveStorySurface) { + if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { + return; + } + + if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { + return; + } + } else { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); + } + this.#handleClickWithoutLayout(event, isDraggableAnnotation); return; } @@ -1082,17 +1358,44 @@ export class EditorInputManager { const { x, y } = normalizedPoint; this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - // Disallow cursor placement in footnote lines: keep current selection and only focus editor. - const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null; - const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? ''; - if (isFootnoteBlockId(clickedBlockId)) { - if (!isDraggableAnnotation) event.preventDefault(); - this.#focusEditor(); - return; + if (clickedNoteTarget) { + const isSameActiveNote = isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget); + if (!isSameActiveNote) { + if (!isDraggableAnnotation) event.preventDefault(); + const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalizedPoint.pageIndex, + }); + if (activated) { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); + return; + } + this.#focusEditor(); + return; + } + } else if (activeNoteSession) { + this.#callbacks.exitActiveStorySession?.(); + activeStorySession = null; + activeNoteSession = null; } - // Check header/footer session state - const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const isActiveStorySurface = sessionMode !== 'body' || activeStorySession != null; + if (!isActiveStorySurface) { + if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { + return; + } + + if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { + return; + } + } else { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); + } + + const isNoteEditing = activeNoteSession != null; + const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null; + const editor = sessionMode === 'body' && !isNoteEditing ? bodyEditor : this.#deps.getActiveEditor(); if (sessionMode !== 'body') { if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY)) return; @@ -1106,37 +1409,21 @@ export class EditorInputManager { normalizedPoint.pageLocalY, ); if (headerFooterRegion) { - event.preventDefault(); // Prevent native selection before double-click handles it - return; // Will be handled by double-click + if (sessionMode === 'body') { + event.preventDefault(); // Prevent native selection before double-click handles it + return; // Will be handled by double-click + } } - // Get hit position - const viewportHost = this.#deps.getViewportHost(); - const pageGeometryHelper = this.#deps.getPageGeometryHelper(); - const rawHit = resolvePointerPositionHit({ - layout: layoutState.layout, - blocks: layoutState.blocks, - measures: layoutState.measures, - containerPoint: { x, y }, - domContainer: viewportHost, + const { rawHit, hit } = this.#resolveSelectionPointerHit({ + layoutState, + normalized: { x, y }, clientX: event.clientX, clientY: event.clientY, - geometryHelper: pageGeometryHelper ?? undefined, + editor, + useActiveSurfaceHitTest, }); - const doc = editor.state?.doc; - const epochMapper = this.#deps.getEpochMapper(); - const mapped = - rawHit && doc ? epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) : null; - - if (mapped && !mapped.ok) { - debugLog('warn', 'pointerdown mapping failed', mapped); - } - - const hit = - rawHit && doc && mapped?.ok - ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch } - : null; this.#debugLastHit = hit ? { source: 'dom', pos: rawHit?.pos ?? null, layoutEpoch: rawHit?.layoutEpoch ?? null, mappedPos: hit.pos } @@ -1191,9 +1478,19 @@ export class EditorInputManager { return; } - // Disallow cursor placement in footnote lines (footnote content is read-only in the layout). - // Keep the current selection unchanged instead of moving caret to document start. - if (isFootnoteBlockId(rawHit.blockId)) { + // Guard against stale note hits after a session switch or partial rerender. + if ( + isNoteEditing && + activeNoteTarget && + parseRenderedNoteTarget(rawHit.blockId)?.noteId !== activeNoteTarget.noteId + ) { + this.#callbacks.exitActiveStorySession?.(); + this.#focusEditor(); + return; + } + + // Disallow entering read-only note content unless it has been activated into a story session. + if (isRenderedNoteBlockId(rawHit.blockId) && !isNoteEditing) { this.#focusEditor(); return; } @@ -1205,11 +1502,16 @@ export class EditorInputManager { } // Check for image/fragment hit - const fragmentHit = getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos); + const fragmentHit = useActiveSurfaceHitTest + ? null + : getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos); // Handle inline image click const targetImg = (event.target as HTMLElement | null)?.closest?.('img') as HTMLImageElement | null; - if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return; + if (!useActiveSurfaceHitTest) { + const epochMapper = this.#deps.getEpochMapper(); + if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return; + } // Handle atomic fragment (image/drawing) click if (this.#handleFragmentClick(event, fragmentHit, hit, doc)) return; @@ -1275,21 +1577,19 @@ export class EditorInputManager { } // Capture pointer for reliable drag tracking + const viewportHost = this.#deps.getViewportHost(); if (typeof viewportHost.setPointerCapture === 'function') { viewportHost.setPointerCapture(event.pointerId); } // Handle double/triple click selection let handledByDepth = false; - const sessionModeForDepth = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - if (sessionModeForDepth === 'body') { - const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos; - - if (clickDepth >= 3) { - handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false; - } else if (clickDepth === 2) { - handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false; - } + const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos; + + if (clickDepth >= 3) { + handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false; + } else if (clickDepth === 2) { + handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false; } const hasFocus = editor.view?.hasFocus?.() ?? false; @@ -1364,6 +1664,10 @@ export class EditorInputManager { // Handle header/footer hover const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; + if (this.#deps.getActiveStorySession?.()?.kind === 'note') { + this.#callbacks.clearHoverRegion?.(); + return; + } this.#handleHover(normalized); } @@ -1448,19 +1752,7 @@ export class EditorInputManager { return; } - // When editing a header/footer, let the ProseMirror editor inside the - // overlay handle double-click word/paragraph selection. Do not re-run - // header/footer hit-testing for double-clicks that occur inside the - // active editor host. const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - if (sessionMode !== 'body') { - const activeEditorHost = this.#deps.getHeaderFooterSession()?.overlayManager?.getActiveEditorHost?.(); - const clickedInsideEditorHost = - activeEditorHost && (activeEditorHost.contains(target as Node) || activeEditorHost === target); - if (clickedInsideEditorHost) { - return; - } - } const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) return; @@ -1468,6 +1760,33 @@ export class EditorInputManager { const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; + const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY); + if (clickedNoteTarget) { + if (isSameRenderedNoteTarget(this.#getActiveRenderedNoteTarget(), clickedNoteTarget)) { + // Pointerdown already updated selection inside the live note session. + // Re-activating the same note here would remount the hidden editor and + // wipe out the word/paragraph selection that the multi-click logic just set. + // + // The activation gesture itself only registers one click inside the live + // note, so its trailing dblclick can leave a stale single-click marker + // behind. Clear only that activation residue and preserve genuine active + // multi-click state for triple-click paragraph selection. + if (this.#clickCount <= 1) { + this.#resetMultiClickTracking(); + } + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalized.pageIndex, + }); + return; + } + const region = this.#callbacks.hitTestHeaderFooterRegion?.( normalized.x, normalized.y, @@ -1475,13 +1794,20 @@ export class EditorInputManager { normalized.pageLocalY, ); if (region) { - event.preventDefault(); - event.stopPropagation(); - - // Materialization (if needed) now happens inside #enterMode via - // ensureExplicitHeaderFooterSlot. The pointer handler only triggers - // activation — it is not responsible for slot creation. - this.#callbacks.activateHeaderFooterRegion?.(region); + if (sessionMode === 'body') { + event.preventDefault(); + event.stopPropagation(); + + // Materialization (if needed) now happens inside #enterMode via + // ensureExplicitHeaderFooterSlot. The pointer handler only triggers + // activation — it is not responsible for slot creation. + this.#callbacks.activateHeaderFooterRegion?.(region, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalized.pageIndex, + source: 'pointerDoubleClick', + }); + } } else if ((this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body') !== 'body') { this.#callbacks.exitHeaderFooterMode?.(); } @@ -1512,11 +1838,17 @@ export class EditorInputManager { if (!this.#deps) return; const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; if (event.key === 'Escape' && sessionMode !== 'body') { event.preventDefault(); this.#callbacks.exitHeaderFooterMode?.(); return; } + if (event.key === 'Escape' && activeStorySession?.kind === 'note') { + event.preventDefault(); + this.#callbacks.exitActiveStorySession?.(); + return; + } // Ctrl+Alt+H/F shortcuts if (event.ctrlKey && event.altKey && !event.shiftKey) { @@ -1807,12 +2139,16 @@ export class EditorInputManager { pageLocalY?: number, ): boolean { const session = this.#deps?.getHeaderFooterSession(); - const activeEditorHost = session?.overlayManager?.getActiveEditorHost?.(); - const clickedInsideEditorHost = - activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target); - - if (clickedInsideEditorHost) { - return true; // Let editor handle it + const activeSurfaceSelector = + session?.session?.mode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header'; + const visiblePointerSurface = resolveVisibleSurfaceAtPointer(event.target, event.clientX, event.clientY); + const clickedInsideVisibleActiveSurface = + visiblePointerSurface?.kind === 'headerFooter' && + visiblePointerSurface.surface.closest(activeSurfaceSelector) != null; + + if (visiblePointerSurface?.kind === 'bodyContent') { + this.#callbacks.exitHeaderFooterMode?.(); + return false; // Continue to body click handling after exiting the active H/F session } const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y, pageIndex, pageLocalY); @@ -1821,9 +2157,18 @@ export class EditorInputManager { return false; // Continue to body click handling } - // Click is in a H/F region on a different page — don't consume the event. - // Let it fall through to the existing footer region check in #handlePointerDown - // which properly calls event.preventDefault() before the dblclick handler activates it. + if (visiblePointerSurface?.kind === 'headerFooter' && !clickedInsideVisibleActiveSurface) { + this.#callbacks.exitHeaderFooterMode?.(); + return false; // Continue to body click handling + } + + this.#syncNonBodyCommentSelection(event, event.target as HTMLElement | null, this.#deps.getEditor(), { + clearOnMiss: true, + }); + + // Click is in the active rendered header/footer surface. Keep the story + // session active, update any tracked-change/comment bubble state, and let + // the normal rendered-surface hit testing place the selection/caret. return false; } @@ -1938,7 +2283,7 @@ export class EditorInputManager { } #handleShiftClick(event: PointerEvent, headPos: number): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); if (!editor) return; const anchor = editor.state.selection.anchor; @@ -1967,26 +2312,26 @@ export class EditorInputManager { this.#pendingMarginClick = null; this.#dragLastPointer = { clientX, clientY, x: normalized.x, y: normalized.y }; - const viewportHost = this.#deps.getViewportHost(); - const pageGeometryHelper = this.#deps.getPageGeometryHelper(); - - const rawHit = resolvePointerPositionHit({ - layout: layoutState.layout, - blocks: layoutState.blocks, - measures: layoutState.measures, - containerPoint: { x: normalized.x, y: normalized.y }, - domContainer: viewportHost, + const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null; + const editor = useActiveSurfaceHitTest + ? this.#deps.getActiveEditor() + : (this.#deps.getEditor() as ReturnType); + const { rawHit, hit } = this.#resolveSelectionPointerHit({ + layoutState, + normalized: { x: normalized.x, y: normalized.y }, clientX, clientY, - geometryHelper: pageGeometryHelper ?? undefined, + editor, + useActiveSurfaceHitTest, }); - if (!rawHit) return; + if (!rawHit || !hit) return; - // Don't extend selection into footnote lines - if (isFootnoteBlockId(rawHit.blockId)) return; + // Don't extend a body selection into read-only footnote content. + if (!useActiveSurfaceHitTest && isRenderedNoteBlockId(rawHit.blockId)) return; - const editor = this.#deps.getEditor(); const doc = editor.state?.doc; if (!doc) return; @@ -1999,21 +2344,8 @@ export class EditorInputManager { this.#callbacks.updateSelectionVirtualizationPins?.({ includeDragBuffer: true, extraPages: [rawHit.pageIndex] }); - const epochMapper = this.#deps.getEpochMapper(); - const mappedHead = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1); - if (!mappedHead.ok) { - debugLog('warn', 'drag mapping failed', mappedHead); - return; - } - - const hit = { - ...rawHit, - pos: Math.max(0, Math.min(mappedHead.pos, doc.content.size)), - layoutEpoch: mappedHead.toEpoch, - }; - this.#debugLastHit = { - source: pageMounted ? 'dom' : 'geometry', + source: useActiveSurfaceHitTest || pageMounted ? 'dom' : 'geometry', pos: rawHit.pos, layoutEpoch: rawHit.layoutEpoch, mappedPos: hit.pos, @@ -2021,7 +2353,7 @@ export class EditorInputManager { this.#callbacks.updateSelectionDebugHud?.(); // Check for cell selection - const currentTableHit = this.#hitTestTable(normalized.x, normalized.y); + const currentTableHit = useActiveSurfaceHitTest ? null : this.#hitTestTable(normalized.x, normalized.y); const shouldUseCellSel = this.#shouldUseCellSelection(currentTableHit); if (shouldUseCellSel && this.#cellAnchor) { @@ -2242,8 +2574,56 @@ export class EditorInputManager { this.#callbacks.activateHeaderFooterRegion?.(region); } + #getActiveRenderedNoteTarget(): RenderedNoteTarget | null { + const activeStorySession = this.#deps?.getActiveStorySession?.() ?? null; + if (activeStorySession?.kind !== 'note') { + return null; + } + + const locator = activeStorySession.locator; + if (locator.storyType !== 'footnote' && locator.storyType !== 'endnote') { + return null; + } + + return { + storyType: locator.storyType, + noteId: locator.noteId, + }; + } + + #resolveRenderedNoteTargetAtPointer( + target: HTMLElement | null, + clientX: number, + clientY: number, + ): RenderedNoteTarget | null { + const blockIdFromTarget = target?.closest?.('[data-block-id]')?.getAttribute?.('data-block-id') ?? ''; + const parsedFromTarget = parseRenderedNoteTarget(blockIdFromTarget); + if (parsedFromTarget) { + return parsedFromTarget; + } + + const doc = this.#deps?.getViewportHost()?.ownerDocument ?? document; + if (typeof doc.elementsFromPoint !== 'function') { + return null; + } + + for (const element of doc.elementsFromPoint(clientX, clientY)) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const blockId = element.closest('[data-block-id]')?.getAttribute('data-block-id') ?? ''; + const parsed = parseRenderedNoteTarget(blockId); + if (parsed) { + return parsed; + } + } + + return null; + } + #focusEditorAtFirstPosition(): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); const editorDom = editor?.view?.dom as HTMLElement | undefined; if (!editorDom) return; @@ -2270,7 +2650,7 @@ export class EditorInputManager { * operations with tracked changes. */ #focusEditor(): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); const view = editor?.view; const editorDom = view?.dom as HTMLElement | undefined; if (!editorDom) return; @@ -2307,6 +2687,65 @@ export class EditorInputManager { return true; } + #syncNonBodyCommentActivation(event: PointerEvent, target: HTMLElement | null, editor: Editor): void { + this.#syncNonBodyCommentSelection(event, target, editor); + } + + #resolveHeaderFooterCommentThreadIdFromGeometry(clientX: number, clientY: number): string | null { + const sessionMode = this.#deps?.getHeaderFooterSession()?.session?.mode ?? 'body'; + if (sessionMode !== 'header' && sessionMode !== 'footer') { + return null; + } + + const viewportHost = this.#deps?.getViewportHost(); + if (!viewportHost) { + return null; + } + + const activeSurfaceSelector = sessionMode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header'; + const annotationSelector = [ + `${activeSurfaceSelector} ${COMMENT_HIGHLIGHT_SELECTOR}`, + `${activeSurfaceSelector} ${TRACK_CHANGE_SELECTOR}`, + `${activeSurfaceSelector} ${PM_TRACK_CHANGE_SELECTOR}`, + ].join(', '); + const annotationElements = Array.from(viewportHost.querySelectorAll(annotationSelector)); + + return resolveCommentThreadIdFromGeometry(annotationElements, clientX, clientY); + } + + #syncNonBodyCommentSelection( + event: PointerEvent, + target: HTMLElement | null, + editor: Editor, + { clearOnMiss = false }: { clearOnMiss?: boolean } = {}, + ): void { + const clickedThreadId = + resolveCommentThreadIdNearPointer(target, event.clientX, event.clientY) ?? + this.#resolveHeaderFooterCommentThreadIdFromGeometry(event.clientX, event.clientY); + const activeThreadId = getActiveCommentThreadId(editor); + + if (!clickedThreadId) { + if (!clearOnMiss || !activeThreadId) { + return; + } + + editor.emit?.('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: null, + }); + return; + } + + if (clickedThreadId === activeThreadId) { + return; + } + + editor.emit?.('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: clickedThreadId, + }); + } + #handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean { // Direct hits on inline annotated text should not be intercepted here. // Let generic click-to-position place the caret at the clicked pixel. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts index b92868328f..1af5bf21ea 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/LocalSelectionOverlayRendering.ts @@ -106,8 +106,8 @@ export type RenderCaretOverlayDeps = { * * @remarks * This function creates a single div element representing the text cursor with: - * - 2px width and height from caretLayout - * - Black color (#000000) + * - Black 2px caret with the global blink animation + * - Subtle white halo for contrast against dark glyphs * - 1px border radius for visual polish * - Absolute positioning in overlay coordinates * - Pointer-events: none to allow interaction with underlying content @@ -144,6 +144,7 @@ export function renderCaretOverlay({ caretEl.style.height = `${finalHeight}px`; caretEl.style.backgroundColor = '#000000'; caretEl.style.borderRadius = '1px'; + caretEl.style.boxShadow = '0 0 0 1px rgba(255, 255, 255, 0.92)'; caretEl.style.pointerEvents = 'none'; localSelectionLayer.appendChild(caretEl); } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts new file mode 100644 index 0000000000..89c5f1f63a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts @@ -0,0 +1,470 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; + +import { deduplicateOverlappingRects, type LayoutRect } from '../../../dom-observer/DomSelectionGeometry.js'; + +type VisibleTextSegment = { + node: Text; + startOffset: number; + endOffset: number; + pageElement: HTMLElement; + lineElement: HTMLElement | null; +}; + +type VisibleTextModel = { + segments: VisibleTextSegment[]; + totalLength: number; +}; + +type ResolvedTextPoint = { + node: Text; + offset: number; + pageElement: HTMLElement; + lineElement: HTMLElement | null; +}; + +export type VisibleTextOffsetGeometryOptions = { + containers: HTMLElement[]; + zoom: number; + pageHeight: number; + pageGap: number; +}; + +/** + * Measures a visible-text offset within a DOM root from a concrete DOM boundary. + * + * This is used for note overlays because `EditorView.domAtPos()` can resolve the + * active note selection to the correct hidden-editor DOM boundary even when the + * ProseMirror position lands inside tracked-change wrapper structure. Measuring the + * boundary as visible text gives us a stable bridge from the hidden editor DOM to + * the painted note DOM. + */ +export function measureVisibleTextOffset(root: HTMLElement, boundaryNode: Node, boundaryOffset: number): number | null { + if (!root || !boundaryNode) { + return null; + } + if (boundaryNode !== root && !root.contains(boundaryNode)) { + return null; + } + + const doc = root.ownerDocument ?? document; + const boundary = doc.createRange(); + + try { + boundary.setStart(boundaryNode, boundaryOffset); + boundary.setEnd(boundaryNode, boundaryOffset); + } catch { + return null; + } + + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let total = 0; + let currentNode = walker.nextNode(); + + while (currentNode) { + const textNode = currentNode as Text; + const textLength = textNode.textContent?.length ?? 0; + if (textLength === 0) { + currentNode = walker.nextNode(); + continue; + } + + const textRange = doc.createRange(); + textRange.selectNodeContents(textNode); + + if (textRange.compareBoundaryPoints(Range.END_TO_END, boundary) <= 0) { + total += textLength; + currentNode = walker.nextNode(); + continue; + } + + if (textNode === boundaryNode) { + return total + Math.max(0, Math.min(boundaryOffset, textLength)); + } + + if (textRange.compareBoundaryPoints(Range.START_TO_START, boundary) >= 0) { + return total; + } + + return total; + } + + return total; +} + +export function computeCaretRectFromVisibleTextOffset( + options: VisibleTextOffsetGeometryOptions, + textOffset: number, +): LayoutRect | null { + const model = collectVisibleTextModel(options.containers); + if (!model.segments.length) { + return null; + } + + const point = resolveTextPoint(model, textOffset, 'forward'); + if (!point) { + return null; + } + + const doc = point.node.ownerDocument ?? document; + const range = doc.createRange(); + range.setStart(point.node, point.offset); + range.setEnd(point.node, point.offset); + + const rangeRect = range.getBoundingClientRect(); + const lineRect = point.lineElement?.getBoundingClientRect() ?? rangeRect; + const pageRect = point.pageElement.getBoundingClientRect(); + const pageIndex = Number(point.pageElement.dataset.pageIndex ?? 'NaN'); + + if (!Number.isFinite(pageIndex)) { + return null; + } + + const localX = (rangeRect.left - pageRect.left) / options.zoom; + const localY = (lineRect.top - pageRect.top) / options.zoom; + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex, + x: localX, + y: pageIndex * (options.pageHeight + options.pageGap) + localY, + width: 1, + height: Math.max(1, lineRect.height / options.zoom), + }; +} + +export function computeSelectionRectsFromVisibleTextOffsets( + options: VisibleTextOffsetGeometryOptions, + fromOffset: number, + toOffset: number, +): LayoutRect[] | null { + if (!Number.isFinite(fromOffset) || !Number.isFinite(toOffset)) { + return null; + } + + const startOffset = Math.min(fromOffset, toOffset); + const endOffset = Math.max(fromOffset, toOffset); + if (startOffset === endOffset) { + return []; + } + + const model = collectVisibleTextModel(options.containers); + if (!model.segments.length) { + return null; + } + + const startPoint = resolveTextPoint(model, startOffset, 'forward'); + const endPoint = resolveTextPoint(model, endOffset, 'backward'); + if (!startPoint || !endPoint) { + return null; + } + + const doc = startPoint.node.ownerDocument ?? document; + const range = doc.createRange(); + + try { + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + } catch { + return null; + } + + const rawRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; + const pageElements = collectUniquePageElements(model.segments); + const rects = deduplicateOverlappingRects(rawRects); + const layoutRects: LayoutRect[] = []; + + for (const rect of rects) { + if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) { + continue; + } + + const pageElement = findPageElementForRect(rect, pageElements); + if (!pageElement) { + continue; + } + + const pageRect = pageElement.getBoundingClientRect(); + const pageIndex = Number(pageElement.dataset.pageIndex ?? 'NaN'); + if (!Number.isFinite(pageIndex)) { + continue; + } + + const localX = (rect.left - pageRect.left) / options.zoom; + const localY = (rect.top - pageRect.top) / options.zoom; + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + continue; + } + + layoutRects.push({ + pageIndex, + x: localX, + y: pageIndex * (options.pageHeight + options.pageGap) + localY, + width: Math.max(1, rect.width / options.zoom), + height: Math.max(1, rect.height / options.zoom), + }); + } + + return layoutRects; +} + +function collectVisibleTextModel(containers: readonly HTMLElement[]): VisibleTextModel { + const lines = collectRenderedLineElements(containers); + if (!lines.length) { + return { + segments: [], + totalLength: 0, + }; + } + + const segments: VisibleTextSegment[] = []; + let totalLength = 0; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const lineElement = lines[lineIndex]!; + const leafElements = collectLeafPmElements(lineElement); + let lineVisibleLength = 0; + + for (const leafElement of leafElements) { + const pageElement = leafElement.closest(`.${DOM_CLASS_NAMES.PAGE}[data-page-index]`); + if (!pageElement) { + continue; + } + + const doc = leafElement.ownerDocument ?? document; + const walker = doc.createTreeWalker(leafElement, NodeFilter.SHOW_TEXT); + let currentNode = walker.nextNode(); + + while (currentNode) { + const textNode = currentNode as Text; + const textLength = textNode.textContent?.length ?? 0; + if (textLength > 0) { + segments.push({ + node: textNode, + startOffset: totalLength + lineVisibleLength, + endOffset: totalLength + lineVisibleLength + textLength, + pageElement, + lineElement, + }); + lineVisibleLength += textLength; + } + + currentNode = walker.nextNode(); + } + } + + const lineTrailingGap = computeLineTrailingGap(lineElement, leafElements); + const nextLineGap = computeGapToNextLine(lineElement, lines[lineIndex + 1] ?? null); + totalLength += lineVisibleLength + lineTrailingGap + nextLineGap; + } + + return { + segments, + totalLength, + }; +} + +function collectRenderedLineElements(containers: readonly HTMLElement[]): HTMLElement[] { + const lines: HTMLElement[] = []; + + for (const container of containers) { + lines.push(...Array.from(container.querySelectorAll('.superdoc-line[data-pm-start][data-pm-end]'))); + } + + return lines; +} + +function computeLineTrailingGap(lineElement: HTMLElement, leafElements: readonly HTMLElement[]): number { + const linePmEnd = getPmEnd(lineElement); + const lastLeafElement = leafElements[leafElements.length - 1]; + const lastLeafPmEnd = lastLeafElement ? getPmEnd(lastLeafElement) : null; + + if (linePmEnd == null || lastLeafPmEnd == null) { + return 0; + } + + return Math.max(0, linePmEnd - lastLeafPmEnd); +} + +function computeGapToNextLine(currentLine: HTMLElement, nextLine: HTMLElement | null): number { + if (!nextLine) { + return 0; + } + + const currentLinePmEnd = getPmEnd(currentLine); + const nextLinePmStart = getPmStart(nextLine); + if (currentLinePmEnd == null || nextLinePmStart == null) { + return 0; + } + + return Math.max(0, nextLinePmStart - currentLinePmEnd); +} + +function collectLeafPmElements(container: HTMLElement): HTMLElement[] { + const pmElements: HTMLElement[] = []; + if (container.matches('[data-pm-start][data-pm-end]')) { + pmElements.push(container); + } + pmElements.push(...Array.from(container.querySelectorAll('[data-pm-start][data-pm-end]'))); + + const pmElementSet = new WeakSet(pmElements); + const nonLeaf = new WeakSet(); + + for (const element of pmElements) { + if (element.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) { + continue; + } + + let parent = element.parentElement; + while (parent) { + if (pmElementSet.has(parent)) { + nonLeaf.add(parent); + } + if (parent === container) { + break; + } + parent = parent.parentElement; + } + } + + return pmElements.filter( + (element) => !element.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER) && !nonLeaf.has(element), + ); +} + +function resolveTextPoint( + model: VisibleTextModel, + targetOffset: number, + affinity: 'forward' | 'backward', +): ResolvedTextPoint | null { + const { segments, totalLength } = model; + if (!segments.length || !Number.isFinite(targetOffset)) { + return null; + } + + if (targetOffset < 0 || targetOffset > totalLength) { + return null; + } + + let previousSegment: VisibleTextSegment | null = null; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]!; + if (targetOffset < segment.startOffset) { + if (affinity === 'forward') { + return { + node: segment.node, + offset: 0, + pageElement: segment.pageElement, + lineElement: segment.lineElement, + }; + } + + if (previousSegment) { + return { + node: previousSegment.node, + offset: previousSegment.node.textContent?.length ?? 0, + pageElement: previousSegment.pageElement, + lineElement: previousSegment.lineElement, + }; + } + + return { + node: segment.node, + offset: 0, + pageElement: segment.pageElement, + lineElement: segment.lineElement, + }; + } + + if (targetOffset >= segment.startOffset && targetOffset < segment.endOffset) { + return { + node: segment.node, + offset: targetOffset - segment.startOffset, + pageElement: segment.pageElement, + lineElement: segment.lineElement, + }; + } + + if (targetOffset !== segment.endOffset) { + previousSegment = segment; + continue; + } + + if (affinity === 'forward' && index + 1 < segments.length) { + previousSegment = segment; + continue; + } + + return { + node: segment.node, + offset: segment.node.textContent?.length ?? 0, + pageElement: segment.pageElement, + lineElement: segment.lineElement, + }; + } + + const lastSegment = segments[segments.length - 1]; + if (!lastSegment) { + return null; + } + + return { + node: lastSegment.node, + offset: lastSegment.node.textContent?.length ?? 0, + pageElement: lastSegment.pageElement, + lineElement: lastSegment.lineElement, + }; +} + +function getPmStart(element: HTMLElement): number | null { + return parsePmValue(element.dataset.pmStart); +} + +function getPmEnd(element: HTMLElement): number | null { + return parsePmValue(element.dataset.pmEnd); +} + +function parsePmValue(value: string | undefined): number | null { + if (!value) { + return null; + } + + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : null; +} + +function collectUniquePageElements(segments: readonly VisibleTextSegment[]): HTMLElement[] { + const seen = new Set(); + const pages: HTMLElement[] = []; + + for (const segment of segments) { + if (seen.has(segment.pageElement)) { + continue; + } + seen.add(segment.pageElement); + pages.push(segment.pageElement); + } + + return pages; +} + +function findPageElementForRect(rect: DOMRect, pageElements: readonly HTMLElement[]): HTMLElement | null { + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + for (const pageElement of pageElements) { + const pageRect = pageElement.getBoundingClientRect(); + if ( + centerX >= pageRect.left && + centerX <= pageRect.right && + centerY >= pageRect.top && + centerY <= pageRect.bottom + ) { + return pageElement; + } + } + + return null; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts new file mode 100644 index 0000000000..c0aaaf3d1c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts @@ -0,0 +1,354 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StoryPresentationSessionManager } from './StoryPresentationSessionManager.js'; +import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; +import type { Editor } from '../../Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; +import { + getLiveStorySessionCount, + resolveLiveStorySessionRuntime, +} from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- +// +// The session manager only interacts with the runtime's commit / dispose +// hooks and with `editor.view.dom` when a DOM target is needed. Everything +// else is delegated to caller-supplied callbacks, so a bare-minimum +// Editor-shaped stub is sufficient. + +type StubEditor = Pick & { + options?: { parentEditor?: StubEditor }; + emitTransaction?: (docChanged?: boolean) => void; +}; + +function makeStubEditor(dom: HTMLElement | null): StubEditor { + const transactionListeners = new Set<(payload: { transaction: { docChanged: boolean } }) => void>(); + return { + view: dom ? ({ dom } as unknown as Editor['view']) : undefined, + on(event, handler) { + if (event === 'transaction') { + transactionListeners.add(handler as (payload: { transaction: { docChanged: boolean } }) => void); + } + }, + off(event, handler) { + if (event === 'transaction' && handler) { + transactionListeners.delete(handler as (payload: { transaction: { docChanged: boolean } }) => void); + } + }, + emitTransaction(docChanged = true) { + transactionListeners.forEach((listener) => listener({ transaction: { docChanged } })); + }, + } as StubEditor; +} + +function makeStubLocator(): StoryLocator { + return { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }; +} + +function makeStubRuntime(editor: StubEditor, overrides: Partial = {}): StoryRuntime { + return { + locator: makeStubLocator(), + storyKey: 'story:headerFooterPart:rId7', + editor: editor as unknown as Editor, + kind: 'headerFooter', + ...overrides, + }; +} + +function makeHostEditor(): Editor { + return { state: { doc: { content: { size: 10 } } } } as unknown as Editor; +} + +describe('StoryPresentationSessionManager', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('refuses to host a body runtime', () => { + const editor = makeStubEditor(document.createElement('div')); + const runtime: StoryRuntime = { + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor: editor as unknown as Editor, + kind: 'body', + }; + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + expect(() => manager.activate({ kind: 'story', storyType: 'body' })).toThrow(/cannot host a body runtime/); + }); + + it('activates a session, tracks its editor DOM, and exits cleanly', () => { + const dom = document.createElement('div'); + const editor = makeStubEditor(dom); + const runtime = makeStubRuntime(editor); + + const onChange = vi.fn(); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + onActiveSessionChanged: onChange, + }); + + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getActiveEditorDomTarget()).toBeNull(); + + const session = manager.activate(makeStubLocator()); + expect(session.kind).toBe('headerFooter'); + expect(session.locator.storyType).toBe('headerFooterPart'); + expect(manager.getActiveSession()).toBe(session); + expect(manager.getActiveEditorDomTarget()).toBe(dom); + expect(onChange).toHaveBeenLastCalledWith(session); + + manager.exit(); + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getActiveEditorDomTarget()).toBeNull(); + expect(session.isDisposed).toBe(true); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('disposes the previous session when a new session activates over it', () => { + const first = makeStubRuntime(makeStubEditor(document.createElement('div')), { + dispose: vi.fn(), + cacheable: false, + }); + const second = makeStubRuntime(makeStubEditor(document.createElement('div')), { + dispose: vi.fn(), + cacheable: false, + }); + + const runtimes = [first, second]; + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtimes.shift()!, + getMountContainer: () => container, + }); + + const s1 = manager.activate(makeStubLocator()); + expect(s1.isDisposed).toBe(false); + + manager.activate(makeStubLocator()); + expect(s1.isDisposed).toBe(true); + expect(first.dispose).toHaveBeenCalledTimes(1); + }); + + it('commits on exit when commitPolicy is onExit (default)', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + expect(commit).not.toHaveBeenCalled(); + + manager.exit(); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not commit on exit when commitPolicy is manual', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + manager.exit(); + expect(commit).not.toHaveBeenCalled(); + }); + + it('manual commit() invokes the runtime.commit callback', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + session.commit(); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('manual commit() prefers runtime.commitEditor with the session editor', () => { + const runtimeEditor = makeStubEditor(document.createElement('div')); + const sessionEditor = makeStubEditor(document.createElement('div')); + const commitEditor = vi.fn(); + const runtime = makeStubRuntime(runtimeEditor, { commitEditor }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: () => ({ editor: sessionEditor as unknown as Editor }), + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + session.commit(); + expect(commitEditor).toHaveBeenCalledWith(expect.anything(), sessionEditor); + }); + + it('does not dispose cacheable runtimes on exit', () => { + const editor = makeStubEditor(document.createElement('div')); + const dispose = vi.fn(); + const runtime = makeStubRuntime(editor, { dispose, cacheable: true }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + manager.exit(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('disposes non-cacheable runtimes on exit', () => { + const editor = makeStubEditor(document.createElement('div')); + const dispose = vi.fn(); + const runtime = makeStubRuntime(editor, { dispose, cacheable: false }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + manager.exit(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('commits on doc-changing transactions when commitPolicy is continuous', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'continuous' }); + editor.emitTransaction?.(true); + editor.emitTransaction?.(false); + + expect(commit).toHaveBeenCalledTimes(1); + manager.exit(); + expect(session.isDisposed).toBe(true); + }); + + it('appends a hidden-host wrapper and tears it down on exit when an editorFactory is supplied', () => { + const dom = document.createElement('div'); + const freshEditor = makeStubEditor(dom); + const runtime = makeStubRuntime(makeStubEditor(null)); + + const factory = vi.fn((input) => { + // The factory should be handed a hidden host element to mount into. + expect(input.hostElement).toBeInstanceOf(HTMLElement); + expect(input.hostElement.classList.contains('presentation-editor__story-hidden-host')).toBe(true); + return { editor: freshEditor as unknown as Editor }; + }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: factory, + }); + + const session = manager.activate(makeStubLocator()); + expect(factory).toHaveBeenCalledTimes(1); + expect(session.hostWrapper).not.toBeNull(); + expect(session.hostWrapper?.parentNode).toBe(container); + expect(session.domTarget).toBe(dom); + + manager.exit(); + expect(session.hostWrapper?.parentNode).toBeNull(); + }); + + it('destroy() deactivates any active session', () => { + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator()); + manager.destroy(); + expect(session.isDisposed).toBe(true); + expect(manager.getActiveSession()).toBeNull(); + }); + + it('throws a clear error when hidden-host activation has no mount container', () => { + const runtime = makeStubRuntime(makeStubEditor(document.createElement('div'))); + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => null, + editorFactory: () => ({ editor: makeStubEditor(document.createElement('div')) as unknown as Editor }), + }); + expect(() => manager.activate(makeStubLocator())).toThrow(/no mount container/); + }); + + it('allows runtime reuse without a mount container when preferHiddenHost is false', () => { + const dom = document.createElement('div'); + const runtime = makeStubRuntime(makeStubEditor(dom)); + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => null, + }); + + const session = manager.activate(makeStubLocator(), { preferHiddenHost: false }); + expect(session.editor).toBe(runtime.editor); + expect(session.hostWrapper).toBeNull(); + expect(session.domTarget).toBe(dom); + }); + + it('registers the active session editor as the live story runtime and unregisters it on exit', () => { + const hostEditor = makeHostEditor(); + const runtimeEditor = makeStubEditor(document.createElement('div')); + runtimeEditor.options = { parentEditor: hostEditor as unknown as StubEditor }; + + const sessionEditor = makeStubEditor(document.createElement('div')); + sessionEditor.options = { parentEditor: hostEditor as unknown as StubEditor }; + + const runtime = makeStubRuntime(runtimeEditor, { + locator: { kind: 'story', storyType: 'footnote', noteId: '8' }, + storyKey: 'fn:8', + kind: 'note', + }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: () => ({ editor: sessionEditor as unknown as Editor }), + }); + + manager.activate(runtime.locator); + + const liveRuntime = resolveLiveStorySessionRuntime(hostEditor, 'fn:8'); + expect(liveRuntime?.editor).toBe(sessionEditor); + expect(getLiveStorySessionCount(hostEditor)).toBe(1); + + manager.exit(); + + expect(resolveLiveStorySessionRuntime(hostEditor, 'fn:8')).toBeNull(); + expect(getLiveStorySessionCount(hostEditor)).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts new file mode 100644 index 0000000000..6548c5eeae --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts @@ -0,0 +1,337 @@ +/** + * StoryPresentationSessionManager + * + * Owns the active interactive editing session for a story-backed part + * (header, footer, or future note/endnote). This is the generalization of + * `HeaderFooterSessionManager`'s session-lifecycle responsibilities, split + * out from the header/footer region/layout code so future story kinds can + * reuse it. + * + * Responsibilities: + * - Resolve a {@link StoryLocator} to a {@link StoryRuntime} through the + * caller-supplied resolver (so the manager doesn't reach across the + * document-api-adapters package boundary directly). + * - Create a hidden off-screen host and mount a story editor into it when + * the runtime does not already have a visible editor we can reuse. + * - Expose the active editor's DOM as the target for + * `PresentationInputBridge`. + * - Commit and dispose on exit. + * + * What it deliberately does NOT do (left to callers / future phases): + * - Region discovery or section-aware slot materialization (lives in the + * header/footer-specific adapter). + * - Caret/selection rendering (Phase 3 of the plan). + * - Pointer hit-testing (lives in EditorInputManager / region providers). + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../Editor.js'; +import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; +import type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js'; +import { createStoryHiddenHost } from './createStoryHiddenHost.js'; +import { registerLiveStorySessionRuntime } from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; + +/** + * Creates (or returns) the ProseMirror editor that should back an active + * session for a given runtime. May return a fresh editor mounted into a + * freshly-created hidden host, or the runtime's existing editor. + */ +export interface StorySessionEditorFactoryInput { + /** The resolved story runtime. */ + runtime: StoryRuntime; + /** The element the story editor should be mounted into, if headless. */ + hostElement: HTMLElement; + /** Activation-time options for the session being created. */ + activationOptions: ActivateStorySessionOptions; +} + +export interface StorySessionEditorFactoryResult { + /** The editor that should be used for the session. */ + editor: Editor; + /** + * Optional teardown to run when the session is disposed. Only set when + * the factory created a fresh editor; reused editors are owned elsewhere. + */ + dispose?: () => void; +} + +/** Factory used by the manager to obtain a mountable story editor. */ +export type StorySessionEditorFactory = (input: StorySessionEditorFactoryInput) => StorySessionEditorFactoryResult; + +/** + * Constructor options for {@link StoryPresentationSessionManager}. + */ +export interface StoryPresentationSessionManagerOptions { + /** + * Resolve a locator to a {@link StoryRuntime}. In production this wraps + * `resolveStoryRuntime(hostEditor, locator, { intent: 'write' })`; in + * tests it can be any mock. + */ + resolveRuntime: (locator: StoryLocator) => StoryRuntime; + + /** + * Returns the host element the session will mount into. Defaults to the + * container the session manager was given on construction, but may be + * overridden per session (e.g., a page-local overlay). + */ + getMountContainer: () => HTMLElement | null; + + /** + * Optional factory for creating the session editor. When omitted the + * manager uses the runtime's existing editor (appending the hidden host + * is still performed, but ProseMirror's DOM lives wherever the runtime + * originally placed it). Most callers will pass a factory that invokes + * `createStoryEditor` to mount a fresh editor into the hidden host. + */ + editorFactory?: StorySessionEditorFactory; + + /** + * Called after the active session changes (activate, exit, dispose). + * Consumers use this to notify `PresentationInputBridge`. + */ + onActiveSessionChanged?: (session: StoryPresentationSession | null) => void; +} + +/** + * Manages the lifecycle of a single active story-backed editing session. + * + * The first rollout assumes only one session is active at a time; if two + * activations overlap, the current session is disposed before the new one + * is activated. + */ +export class StoryPresentationSessionManager { + #options: StoryPresentationSessionManagerOptions; + #active: MutableStorySession | null = null; + + constructor(options: StoryPresentationSessionManagerOptions) { + this.#options = options; + } + + /** Returns the active session, or `null` if none is active. */ + getActiveSession(): StoryPresentationSession | null { + return this.#active; + } + + /** + * Returns the DOM element that should receive forwarded input events + * while a session is active, or `null` if there is no active session. + */ + getActiveEditorDomTarget(): HTMLElement | null { + return this.#active?.domTarget ?? null; + } + + /** + * Activate a session for the given locator. If a session is already + * active, it is disposed first. + */ + activate(locator: StoryLocator, options: ActivateStorySessionOptions = {}): StoryPresentationSession { + if (this.#active) this.exit(); + + const runtime = this.#options.resolveRuntime(locator); + if (runtime.kind === 'body') { + throw new Error('StoryPresentationSessionManager cannot host a body runtime.'); + } + + const preferHiddenHost = options.preferHiddenHost !== false; + const commitPolicy: StoryCommitPolicy = options.commitPolicy ?? 'onExit'; + + let hostWrapper: HTMLElement | null = null; + let editor = runtime.editor; + let factoryDispose: (() => void) | undefined; + let sessionBeforeDispose: (() => void) | undefined; + + if (preferHiddenHost && this.#options.editorFactory) { + const mountContainer = this.#options.getMountContainer(); + if (!mountContainer) { + throw new Error('StoryPresentationSessionManager: no mount container available for hidden host.'); + } + const doc = mountContainer.ownerDocument ?? document; + const width = options.hostWidthPx ?? mountContainer.clientWidth ?? 1; + const hidden = createStoryHiddenHost(doc, width, { + storyKey: runtime.storyKey, + storyKind: runtime.kind, + }); + mountContainer.appendChild(hidden.wrapper); + const factoryResult = this.#options.editorFactory({ + runtime, + hostElement: hidden.host, + activationOptions: options, + }); + editor = factoryResult.editor; + factoryDispose = factoryResult.dispose; + hostWrapper = hidden.wrapper; + } + + if (commitPolicy === 'continuous' && typeof editor.on === 'function') { + const handleTransaction = ({ transaction }: { transaction?: { docChanged?: boolean } }) => { + if (transaction?.docChanged) { + session.commit(); + } + }; + editor.on('transaction', handleTransaction); + sessionBeforeDispose = () => { + editor.off?.('transaction', handleTransaction); + }; + } + + const domTarget = (editor.view?.dom as HTMLElement | undefined) ?? hostWrapper ?? null; + const hostEditor = resolveSessionHostEditor(editor, runtime); + const unregisterRuntime = registerLiveStorySessionRuntime(hostEditor, runtime, editor); + + const session = new MutableStorySession({ + locator, + runtime, + editor, + kind: runtime.kind as Exclude, + hostWrapper, + domTarget, + commitPolicy, + shouldDisposeRuntime: runtime.cacheable === false, + beforeDispose: sessionBeforeDispose, + unregisterRuntime, + teardown: () => { + try { + factoryDispose?.(); + } finally { + if (hostWrapper && hostWrapper.parentNode) { + hostWrapper.parentNode.removeChild(hostWrapper); + } + } + }, + }); + + this.#active = session; + this.#options.onActiveSessionChanged?.(session); + return session; + } + + /** + * Deactivate the current session. Safe to call when no session is active. + * Commits (if policy says so) and disposes the hidden host. + */ + exit(): void { + const active = this.#active; + if (!active) return; + this.#active = null; + try { + active.dispose(); + } finally { + this.#options.onActiveSessionChanged?.(null); + } + } + + /** + * Dispose the manager and any active session. + */ + destroy(): void { + this.exit(); + } +} + +// --------------------------------------------------------------------------- +// Mutable session record — the concrete object that implements the +// StoryPresentationSession contract exposed to callers. +// --------------------------------------------------------------------------- + +interface MutableStorySessionInit { + locator: StoryLocator; + runtime: StoryRuntime; + editor: Editor; + kind: Exclude; + hostWrapper: HTMLElement | null; + domTarget: HTMLElement | null; + commitPolicy: StoryCommitPolicy; + shouldDisposeRuntime: boolean; + afterActivate?: () => void; + beforeDispose?: () => void; + unregisterRuntime: () => void; + teardown: () => void; +} + +class MutableStorySession implements StoryPresentationSession { + readonly locator: StoryLocator; + readonly runtime: StoryRuntime; + readonly editor: Editor; + readonly kind: Exclude; + readonly hostWrapper: HTMLElement | null; + readonly domTarget: HTMLElement | null; + readonly commitPolicy: StoryCommitPolicy; + + #disposed = false; + #shouldDisposeRuntime: boolean; + #beforeDispose?: () => void; + #unregisterRuntime: () => void; + #teardown: () => void; + + constructor(init: MutableStorySessionInit) { + this.locator = init.locator; + this.runtime = init.runtime; + this.editor = init.editor; + this.kind = init.kind; + this.hostWrapper = init.hostWrapper; + this.domTarget = init.domTarget; + this.commitPolicy = init.commitPolicy; + this.#shouldDisposeRuntime = init.shouldDisposeRuntime; + this.#beforeDispose = init.beforeDispose; + this.#unregisterRuntime = init.unregisterRuntime; + this.#teardown = init.teardown; + init.afterActivate?.(); + } + + get isDisposed(): boolean { + return this.#disposed; + } + + commit(): void { + if (this.#disposed) return; + const hostEditor = getHostEditor(this.editor) ?? getHostEditor(this.runtime.editor) ?? this.runtime.editor; + if (this.runtime.commitEditor) { + this.runtime.commitEditor(hostEditor, this.editor); + return; + } + this.runtime.commit?.(hostEditor); + } + + dispose(): void { + if (this.#disposed) return; + try { + if (this.commitPolicy === 'onExit') this.commit(); + } finally { + this.#disposed = true; + try { + this.#beforeDispose?.(); + } finally { + try { + this.#unregisterRuntime(); + } finally { + try { + if (this.#shouldDisposeRuntime) { + this.runtime.dispose?.(); + } + } finally { + this.#teardown(); + } + } + } + } + } +} + +/** + * Retrieve the parent/host editor from a story editor when present. + * + * `createStoryEditor` stores the parent editor as a non-enumerable + * `parentEditor` getter on `options`. When present we prefer it so the + * commit callback runs against the body editor the runtime was resolved + * for. + */ +function getHostEditor(editor: Editor): Editor | null { + const options = editor.options as Partial<{ parentEditor: Editor }>; + return options?.parentEditor ?? null; +} + +function resolveSessionHostEditor(editor: Editor, runtime: StoryRuntime): Editor { + return getHostEditor(editor) ?? getHostEditor(runtime.editor) ?? runtime.editor; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts new file mode 100644 index 0000000000..ff52c55c61 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + createStoryHiddenHost, + STORY_HIDDEN_HOST_CLASS, + STORY_HIDDEN_HOST_WRAPPER_CLASS, +} from './createStoryHiddenHost.js'; + +describe('createStoryHiddenHost', () => { + let doc: Document; + + beforeEach(() => { + doc = document.implementation.createHTMLDocument('test'); + }); + + it('returns wrapper + host with body-hidden-host invariants', () => { + const { wrapper, host } = createStoryHiddenHost(doc, 800); + + // Wrapper keeps scroll-isolation invariants from createHiddenHost + expect(wrapper.style.position).toBe('fixed'); + expect(wrapper.style.overflow).toBe('hidden'); + expect(wrapper.style.width).toBe('1px'); + expect(wrapper.style.height).toBe('1px'); + + // Host must remain focusable + in the a11y tree + expect(host.style.visibility).not.toBe('hidden'); + expect(host.hasAttribute('aria-hidden')).toBe(false); + }); + + it('adds the story-specific class markers', () => { + const { wrapper, host } = createStoryHiddenHost(doc, 800); + expect(wrapper.classList.contains(STORY_HIDDEN_HOST_WRAPPER_CLASS)).toBe(true); + expect(host.classList.contains(STORY_HIDDEN_HOST_CLASS)).toBe(true); + }); + + it('propagates storyKey/storyKind as data attributes when provided', () => { + const { host } = createStoryHiddenHost(doc, 800, { + storyKey: 'story:headerFooterPart:rId7', + storyKind: 'headerFooter', + }); + expect(host.getAttribute('data-story-key')).toBe('story:headerFooterPart:rId7'); + expect(host.getAttribute('data-story-kind')).toBe('headerFooter'); + }); + + it('omits data attributes when options are not supplied', () => { + const { host } = createStoryHiddenHost(doc, 800); + expect(host.hasAttribute('data-story-key')).toBe(false); + expect(host.hasAttribute('data-story-kind')).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts new file mode 100644 index 0000000000..d733ff815e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts @@ -0,0 +1,55 @@ +/** + * Hidden-host factory for story-backed presentation editing sessions. + * + * Story editors need the same scroll-isolated, off-screen, focusable host + * as the body editor. Rather than re-implementing that contract, this helper + * delegates to {@link createHiddenHost} and adds a story-specific className + * so the two hosts are easy to tell apart in DevTools and in tests. + * + * The returned wrapper must be appended to the DOM before the story editor + * is created, and removed (or left for disposal) when the session exits. + */ + +import { createHiddenHost, type HiddenHostElements } from '../dom/HiddenHost.js'; + +/** Class name added to the story hidden host for introspection/testing. */ +export const STORY_HIDDEN_HOST_CLASS = 'presentation-editor__story-hidden-host'; + +/** Class name added to the story wrapper for introspection/testing. */ +export const STORY_HIDDEN_HOST_WRAPPER_CLASS = 'presentation-editor__story-hidden-host-wrapper'; + +/** + * Options for creating a story hidden host. + */ +export interface CreateStoryHiddenHostOptions { + /** + * Identifier used as `data-story-key` on the host. Purely informational — + * makes it trivial to see in DevTools which story a hidden host belongs to. + */ + storyKey?: string; + /** + * Identifier used as `data-story-kind` on the host (e.g., `"headerFooter"`, + * `"note"`). + */ + storyKind?: string; +} + +/** + * Creates an off-screen hidden host for a story editor. + * + * The host preserves the same accessibility invariants as the body hidden + * host (focusable, present in a11y tree, not `aria-hidden`, + * not `visibility: hidden`). + */ +export function createStoryHiddenHost( + doc: Document, + widthPx: number, + options: CreateStoryHiddenHostOptions = {}, +): HiddenHostElements { + const { wrapper, host } = createHiddenHost(doc, widthPx); + wrapper.classList.add(STORY_HIDDEN_HOST_WRAPPER_CLASS); + host.classList.add(STORY_HIDDEN_HOST_CLASS); + if (options.storyKey) host.setAttribute('data-story-key', options.storyKey); + if (options.storyKind) host.setAttribute('data-story-kind', options.storyKind); + return { wrapper, host }; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts new file mode 100644 index 0000000000..b9a5164604 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts @@ -0,0 +1,22 @@ +/** + * Public entry point for the story-session module. + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +export type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js'; + +export { + StoryPresentationSessionManager, + type StoryPresentationSessionManagerOptions, + type StorySessionEditorFactory, + type StorySessionEditorFactoryInput, + type StorySessionEditorFactoryResult, +} from './StoryPresentationSessionManager.js'; + +export { + createStoryHiddenHost, + STORY_HIDDEN_HOST_CLASS, + STORY_HIDDEN_HOST_WRAPPER_CLASS, + type CreateStoryHiddenHostOptions, +} from './createStoryHiddenHost.js'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts new file mode 100644 index 0000000000..37f7ad0ab6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts @@ -0,0 +1,137 @@ +/** + * Types for story-backed presentation editing sessions. + * + * A "story presentation session" is an interactive layout-mode editing + * context for a non-body story (header, footer, footnote, endnote, or a + * future content part). It holds: + * + * - the resolved {@link StoryLocator} + {@link StoryRuntime} for the story + * - the hidden off-screen DOM host that backs the story's ProseMirror editor + * - the presentation-editor side metadata needed to render caret/selection + * overlays and commit back through the parts system on exit + * + * This is the generalization of what `HeaderFooterSessionManager` does today + * for headers/footers, but intentionally story-kind agnostic so future + * callers (e.g. notes) can reuse the same lifecycle. + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +import type { Editor } from '../../Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; +import type { StoryRuntime, StoryKind } from '../../../document-api-adapters/story-runtime/story-types.js'; + +/** + * How the session's edits should be persisted back to the canonical part. + * + * - `'onExit'` — commit once when the session ends (default). + * - `'continuous'` — commit on every PM transaction. Reserved for future + * collaborative or autosave-style behaviors; not required for the initial + * header/footer rollout. + * - `'manual'` — caller invokes {@link StoryPresentationSession.commit}. + */ +export type StoryCommitPolicy = 'onExit' | 'continuous' | 'manual'; + +/** + * A single active interactive editing session for a story-backed part. + * + * Sessions are created by {@link StoryPresentationSessionManager.activate} + * and disposed by {@link StoryPresentationSessionManager.exit}. While active, + * the session's editor DOM is the target of `PresentationInputBridge` and + * rendered content is still painted by the layout engine. + */ +export interface StoryPresentationSession { + /** The locator that was resolved to produce this session. */ + readonly locator: StoryLocator; + + /** The resolved story runtime (owns the editor, commit callback, dispose). */ + readonly runtime: StoryRuntime; + + /** + * The ProseMirror editor that backs this story while the session is + * active. For most non-body stories this is a freshly-created headless + * editor; for live PresentationEditor sub-editors it may be reused. + */ + readonly editor: Editor; + + /** Broad category of the story (headerFooter, note, body is not valid here). */ + readonly kind: Exclude; + + /** + * Off-screen wrapper element appended to the DOM. Removed on exit. + * May be `null` if the session reuses a pre-existing mounted editor + * whose DOM lifecycle is managed elsewhere. + */ + readonly hostWrapper: HTMLElement | null; + + /** + * The element that ProseMirror writes its visible DOM into — this is what + * `PresentationInputBridge` forwards input events to. For sessions that + * own a hidden host, this is the inner host element. For reused live + * sub-editors, it is `editor.view.dom` at activation time. + */ + readonly domTarget: HTMLElement | null; + + /** Commit policy — how changes persist back to the canonical part. */ + readonly commitPolicy: StoryCommitPolicy; + + /** Whether the session has been deactivated. Set to `true` by the manager on exit. */ + readonly isDisposed: boolean; + + /** + * Commit the session's changes back through the story runtime's commit + * callback. No-op if the runtime has no commit hook (e.g., body runtime). + */ + commit(): void; + + /** + * Tear down the session: commit if policy says so, dispose the hidden + * host (if owned), and invoke {@link StoryRuntime.dispose} when present. + * After calling this, the session's `isDisposed` is `true` and no further + * commits are performed. + */ + dispose(): void; +} + +/** + * Options passed when activating a session. + */ +export interface ActivateStorySessionOptions { + /** Override commit policy. Defaults to `'onExit'`. */ + commitPolicy?: StoryCommitPolicy; + + /** + * Explicit hidden-host width in layout pixels. + * + * When omitted, the session manager falls back to the mount container width. + */ + hostWidthPx?: number; + + /** + * Optional session-scoped editor context consumed by the editor factory. + * + * This is how visible story context such as page number, visible region size, + * and surface kind flows into a hidden-host editor instance without baking it + * into the runtime cache key. + */ + editorContext?: { + availableWidth?: number; + availableHeight?: number; + currentPageNumber?: number; + totalPageCount?: number; + surfaceKind?: 'header' | 'footer' | 'note' | 'endnote'; + }; + + /** + * When `true`, the manager must create its own hidden host and story + * editor instead of reusing any live sub-editor that the runtime might + * already have mounted visibly. PresentationEditor uses this as the + * canonical editing mode for all story-backed parts. + * + * When `false`, the manager may reuse whatever editor the runtime + * resolves (legacy behavior). + * + * @default true + */ + preferHiddenHost?: boolean; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts index 13ffa0ac7c..95ff73bc77 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts @@ -88,6 +88,26 @@ describe('DomPositionIndex', () => { expect(index.findElementAtPosition(10)).toBe(null); }); + it('skips footnote descendants when building the body DOM index', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+
+ Simple +
+
+ This +
+
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.size).toBe(1); + expect(index.findElementAtPosition(1)?.textContent).toBe('Simple'); + }); + it('correctly distributes elements across header, body, and footer sections', () => { const container = document.createElement('div'); container.innerHTML = ` diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 57183e6c67..35e40ce300 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -4,6 +4,10 @@ import { clickToPosition } from '@superdoc/layout-bridge'; import { resolvePointerPositionHit } from '../input/PositionHitResolver.js'; import { TextSelection } from 'prosemirror-state'; +const { mockCommentsPluginState } = vi.hoisted(() => ({ + mockCommentsPluginState: { activeThreadId: null as string | null }, +})); + import { EditorInputManager, type EditorInputDependencies, @@ -28,22 +32,28 @@ vi.mock('@superdoc/layout-bridge', () => ({ vi.mock('prosemirror-state', async (importOriginal) => { const original = await importOriginal(); + class MockTextSelection { + empty = true; + $from = { parent: { inlineContent: true } }; + static create = vi.fn(() => new MockTextSelection()); + } return { ...original, - TextSelection: { - ...original.TextSelection, - create: vi.fn(() => ({ - empty: true, - $from: { parent: { inlineContent: true } }, - })), - }, + TextSelection: MockTextSelection, }; }); +vi.mock('@extensions/comment/comments-plugin.js', () => ({ + CommentsPluginKey: { + getState: vi.fn(() => mockCommentsPluginState), + }, +})); + describe('EditorInputManager - Footnote click selection behavior', () => { let manager: EditorInputManager; let viewportHost: HTMLElement; let visibleHost: HTMLElement; + let originalElementFromPoint: typeof document.elementFromPoint | undefined; let mockEditor: { isEditable: boolean; state: { @@ -64,8 +74,11 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; let mockDeps: EditorInputDependencies; let mockCallbacks: EditorInputCallbacks; + let activateRenderedNoteSession: Mock; beforeEach(() => { + originalElementFromPoint = document.elementFromPoint?.bind(document); + mockCommentsPluginState.activeThreadId = null; viewportHost = document.createElement('div'); viewportHost.className = 'presentation-editor__viewport'; visibleHost = document.createElement('div'); @@ -92,6 +105,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }, selection: { $anchor: null }, storedMarks: null, + comments$: { activeThreadId: null }, }, view: { dispatch: vi.fn(), @@ -106,6 +120,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { mockDeps = { getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getActiveStorySession: vi.fn(() => null), getEditor: vi.fn(() => mockEditor as unknown as ReturnType), getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), getEpochMapper: vi.fn(() => ({ @@ -124,10 +139,12 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; mockCallbacks = { + activateRenderedNoteSession: vi.fn(() => true), normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), scheduleSelectionUpdate: vi.fn(), updateSelectionDebugHud: vi.fn(), }; + activateRenderedNoteSession = mockCallbacks.activateRenderedNoteSession as Mock; manager = new EditorInputManager(); manager.setDependencies(mockDeps); @@ -138,6 +155,14 @@ describe('EditorInputManager - Footnote click selection behavior', () => { afterEach(() => { manager.destroy(); document.body.innerHTML = ''; + if (originalElementFromPoint) { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: originalElementFromPoint, + }); + } else { + Reflect.deleteProperty(document, 'elementFromPoint'); + } vi.clearAllMocks(); }); @@ -148,7 +173,70 @@ describe('EditorInputManager - Footnote click selection behavior', () => { ); } - it('does not change editor selection on direct footnote fragment click', () => { + function createActiveSessionEditor(docSize = 50) { + return { + ...mockEditor, + state: { + ...mockEditor.state, + doc: { ...mockEditor.state.doc, content: { size: docSize } }, + tr: { + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), + }, + }, + view: { + ...mockEditor.view, + dispatch: vi.fn(), + }, + }; + } + + function stubElementFromPoint(element: Element | null): Mock { + const elementFromPoint = vi.fn(() => element); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPoint, + }); + return elementFromPoint; + } + + function stubElementsFromPoint(elements: Array): Mock { + const elementsFromPoint = vi.fn(() => elements.filter((element): element is Element => !!element)); + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: elementsFromPoint, + }); + return elementsFromPoint; + } + + function stubBoundingRect( + element: Element, + { + left, + top, + width, + height, + }: { + left: number; + top: number; + width: number; + height: number; + }, + ) { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: left, + y: top, + left, + top, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + } as DOMRect); + } + + it('activates a note session on direct footnote fragment click', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); const nestedEl = document.createElement('span'); @@ -167,12 +255,75 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - // Expected behavior: footnote click should not relocate caret to start of the document. + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '1' }, + expect.objectContaining({ clientX: 10, clientY: 10 }), + ); + expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + }); + + it('activates a note session on direct endnote fragment click', () => { + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'endnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + nestedEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 16, + clientY: 12, + } as PointerEventInit), + ); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'endnote', noteId: '1' }, + expect.objectContaining({ clientX: 16, clientY: 12 }), + ); expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); - it('does not change editor selection when hit-test resolves to a footnote block', () => { + it('activates the note session and syncs the tracked-change bubble on footnote clicks', () => { + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.setAttribute('data-track-change-id', 'tc-1'); + fragmentEl.appendChild(trackedChangeEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 12, + clientY: 10, + } as PointerEventInit), + ); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '1' }, + expect.objectContaining({ clientX: 12, clientY: 10 }), + ); + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: 'tc-1', + }), + ); + }); + + it('keeps legacy read-only behavior for stale footnote hits without a rendered footnote target', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue({ pos: 22, layoutEpoch: 1, @@ -197,26 +348,47 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - // Expected behavior: block edits in footnotes without resetting user selection. + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); - it('does not change editor selection when hit-test resolves to a semantic footnote block', () => { + it('does not reactivate the same note session when clicking inside the active note', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue({ pos: 22, layoutEpoch: 1, pageIndex: 0, - blockId: '__sd_semantic_footnote-1-1', + blockId: 'footnote-1-1', column: 0, lineIndex: -1, }); - const target = document.createElement('span'); - viewportHost.appendChild(target); + const activeNoteEditor = { + ...mockEditor, + state: { + ...mockEditor.state, + doc: { ...mockEditor.state.doc, content: { size: 50 } }, + }, + view: { + ...mockEditor.view, + dispatch: vi.fn(), + }, + }; + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); const PointerEventImpl = getPointerEventImpl(); - target.dispatchEvent( + nestedEl.dispatchEvent( new PointerEventImpl('pointerdown', { bubbles: true, cancelable: true, @@ -227,11 +399,37 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); - expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + expect(mockEditor.view.focus).toHaveBeenCalled(); + }); + + it('does not reactivate the same note session on double-click inside the active note', () => { + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: createActiveSessionEditor(), + }); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + nestedEl.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 12, + clientY: 14, + }), + ); + + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); }); - it('does not change editor selection on semantic footnotes heading click', () => { + it('does not activate a note session on semantic footnotes heading click', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue(null); const headingEl = document.createElement('div'); @@ -252,7 +450,406 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); - expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + expect(mockEditor.view.focus).toHaveBeenCalled(); + }); + + it('uses story-surface hit testing for active note clicks', () => { + const activeNoteEditor = createActiveSessionEditor(); + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 41, + layoutEpoch: 7, + pageIndex: 0, + blockId: 'footnote-1-0', + column: 0, + lineIndex: -1, + })); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + nestedEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 16, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(activeNoteEditor.view.focus).toHaveBeenCalled(); + }); + + it('keeps note hit testing while syncing the tracked-change bubble during active note editing', () => { + const activeNoteEditor = createActiveSessionEditor(); + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 21, + layoutEpoch: 7, + pageIndex: 0, + blockId: 'footnote-1-0', + column: 0, + lineIndex: -1, + })); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.setAttribute('data-track-change-id', 'tc-1'); + fragmentEl.appendChild(trackedChangeEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 16, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: 'tc-1', + }), + ); + }); + + it('uses story-surface hit testing for active header clicks', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const pageContainer = document.createElement('div'); + pageContainer.className = 'superdoc-page'; + viewportHost.appendChild(pageContainer); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + }); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 18, + layoutEpoch: 3, + pageIndex: 0, + blockId: 'header-1', + column: 0, + lineIndex: -1, + })); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 200, + height: 40, + })); + stubElementsFromPoint([pageContainer]); + + const target = document.createElement('span'); + viewportHost.appendChild(target); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 24, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(24, 12); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(activeHeaderEditor.view.focus).toHaveBeenCalled(); + }); + + it('keeps active header editing when the pointer stack only exposes the page container', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const exitHeaderFooterMode = vi.fn(); + + const pageContainer = document.createElement('div'); + pageContainer.className = 'superdoc-page'; + viewportHost.appendChild(pageContainer); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + }); + mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode; + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 18, + layoutEpoch: 3, + pageIndex: 0, + blockId: 'header-1', + column: 0, + lineIndex: -1, + })); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 200, + height: 40, + })); + manager.setCallbacks(mockCallbacks); + stubElementsFromPoint([pageContainer]); + + const PointerEventImpl = getPointerEventImpl(); + pageContainer.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 24, + clientY: 12, + } as PointerEventInit), + ); + + expect(exitHeaderFooterMode).not.toHaveBeenCalled(); + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(24, 12); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + }); + + it('exits active header editing when the topmost visible target is body content even if region hit-testing still says header', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const exitHeaderFooterMode = vi.fn(); + + const visibleHeader = document.createElement('div'); + visibleHeader.className = 'superdoc-page-header'; + viewportHost.appendChild(visibleHeader); + + const bodyLine = document.createElement('div'); + bodyLine.className = 'superdoc-line'; + const bodyText = document.createElement('span'); + bodyText.textContent = 'Visible body text'; + bodyLine.appendChild(bodyText); + viewportHost.appendChild(bodyLine); + stubElementFromPoint(bodyText); + stubElementsFromPoint([bodyText, bodyLine, visibleHeader]); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + }); + mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode; + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 24, + layoutEpoch: 3, + pageIndex: 0, + blockId: 'body-1', + column: 0, + lineIndex: -1, + })); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 300, + height: 220, + })); + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + bodyText.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 30, + clientY: 220, + } as PointerEventInit), + ); + + expect(exitHeaderFooterMode).toHaveBeenCalledTimes(1); + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(30, 220); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + }); + + it('syncs the tracked-change bubble for real clicks inside the active rendered header surface', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const pageEl = document.createElement('div'); + pageEl.className = 'superdoc-page'; + const activeHeaderSurface = document.createElement('div'); + activeHeaderSurface.className = 'superdoc-page-header'; + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.className = 'track-insert'; + trackedChangeEl.setAttribute('data-id', 'tc-header-1'); + activeHeaderSurface.appendChild(trackedChangeEl); + pageEl.appendChild(activeHeaderSurface); + viewportHost.appendChild(pageEl); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + }); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 300, + height: 220, + })); + stubElementFromPoint(pageEl); + stubElementsFromPoint([pageEl]); + stubBoundingRect(trackedChangeEl, { left: 16, top: 8, width: 52, height: 18 }); + + const PointerEventImpl = getPointerEventImpl(); + pageEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 20, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: 'tc-header-1', + }), + ); + expect(resolvePointerPositionHit).toHaveBeenCalled(); + }); + + it('clears the active tracked-change bubble for plain clicks inside the active rendered header surface', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const activeHeaderSurface = document.createElement('div'); + activeHeaderSurface.className = 'superdoc-page-header'; + const plainTextEl = document.createElement('span'); + plainTextEl.textContent = 'Generic content header'; + activeHeaderSurface.appendChild(plainTextEl); + viewportHost.appendChild(activeHeaderSurface); + + mockCommentsPluginState.activeThreadId = 'tc-header-1'; + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + }); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 300, + height: 220, + })); + stubElementFromPoint(plainTextEl); + stubElementsFromPoint([activeHeaderSurface]); + + const PointerEventImpl = getPointerEventImpl(); + plainTextEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 28, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: null, + }), + ); + expect(resolvePointerPositionHit).toHaveBeenCalled(); + }); + + it('resets multi-click state when the active editing target changes', () => { + const target = document.createElement('span'); + viewportHost.appendChild(target); + + const selectWordAt = vi.fn(() => true); + mockCallbacks.selectWordAt = selectWordAt; + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 22, + pointerId: 1, + } as PointerEventInit), + ); + viewportHost.dispatchEvent( + new PointerEventImpl('pointerup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + clientX: 18, + clientY: 22, + pointerId: 1, + } as PointerEventInit), + ); + + manager.notifyTargetChanged(); + + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 22, + pointerId: 2, + } as PointerEventInit), + ); + + expect(selectWordAt).not.toHaveBeenCalled(); + expect(TextSelection.create as unknown as Mock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index d4d0a7da6c..aa9d2e7cfc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -3,6 +3,7 @@ import type { EditorState } from 'prosemirror-state'; import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js'; import type { ConverterContext } from '@superdoc/pm-adapter'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import { toFlowBlocks } from '@superdoc/pm-adapter'; // Mock toFlowBlocks vi.mock('@superdoc/pm-adapter', async (importOriginal) => { @@ -147,6 +148,20 @@ describe('buildFootnotesInput', () => { expect(result?.dividerHeight).toBe(1); }); + it('stamps converted footnote blocks with the footnote story key', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const [, options] = + (toFlowBlocks as unknown as { mock: { calls: Array<[unknown, Record]> } }).mock.calls.at(-1) ?? + []; + expect(options?.storyKey).toBe('fn:1'); + }); + it('only includes footnotes that are referenced in the document', () => { const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc const converter = createMockConverter([ @@ -182,6 +197,112 @@ describe('buildFootnotesInput', () => { ?.runs?.[0]; expect(firstRun?.text).toBe('1'); expect(firstRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); + expect(firstRun).not.toHaveProperty('pmStart'); + expect(firstRun).not.toHaveProperty('pmEnd'); + }); + + it('normalizes away empty note reference runs before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ]); + }); + + it('normalizes away note separator tabs before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Note' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ]); + }); + + it('normalizes away hidden passthrough field-code nodes before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'passthroughInline', attrs: { originalName: 'w:fldChar' } }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ]); }); it('builds the marker as a scaled superscript run instead of a Unicode superscript glyph', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 525cb6d327..9eb4e2d7a4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -38,11 +38,18 @@ function createMainEditorStub(): Editor { } function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { + const textNode = editorDom.ownerDocument.createTextNode('abcdefghij'); + editorDom.appendChild(textNode); + return { setEditable: vi.fn(), setOptions: vi.fn(), commands: { setTextSelection: vi.fn(), + enableTrackChanges: vi.fn(), + disableTrackChanges: vi.fn(), + enableTrackChangesShowOriginal: vi.fn(), + disableTrackChangesShowOriginal: vi.fn(), }, state: { doc: { @@ -54,6 +61,17 @@ function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { view: { dom: editorDom, focus: vi.fn(), + state: { + doc: { + content: { + size: 10, + }, + }, + }, + domAtPos: vi.fn((pos: number) => ({ + node: textNode, + offset: Math.max(0, Math.min(textNode.length, pos - 1)), + })), }, on: vi.fn(), off: vi.fn(), @@ -88,11 +106,14 @@ describe('HeaderFooterSessionManager', () => { * Sets up a full manager with an active header region and returns the manager * ready for `computeSelectionRects` assertions. * - * The DOM selection mock returns a single rect at (120, 90) with size 200x32, + * The DOM range mock returns a single rect at (120, 90) with size 200x32, * and the editor host is at (100, 50) with size 600x120. The header region is * at localX=40, localY=30 on page 1 with bodyPageHeight=800. */ - async function setupWithZoom(zoom: number | undefined): Promise { + async function setupWithZoom( + zoom: number | undefined, + documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing', + ): Promise { const pageElement = document.createElement('div'); pageElement.dataset.pageIndex = '1'; painterHost.appendChild(pageElement); @@ -112,22 +133,7 @@ describe('HeaderFooterSessionManager', () => { destroy: vi.fn(), }; - const overlayManager = { - showEditingOverlay: vi.fn(() => ({ - success: true, - editorHost, - reason: null, - })), - hideEditingOverlay: vi.fn(), - showSelectionOverlay: vi.fn(), - hideSelectionOverlay: vi.fn(), - setOnDimmingClick: vi.fn(), - getActiveEditorHost: vi.fn(() => editorHost), - destroy: vi.fn(), - }; - mockInitHeaderFooterRegistry.mockReturnValue({ - overlayManager, headerFooterIdentifier: null, headerFooterManager, headerFooterAdapter: null, @@ -167,10 +173,15 @@ describe('HeaderFooterSessionManager', () => { scheduleRerender: vi.fn(), setPendingDocChange: vi.fn(), getBodyPageCount: vi.fn(() => 2), + getStorySessionManager: vi.fn(() => ({ + activate: vi.fn(() => ({ editor: headerFooterEditor })), + exit: vi.fn(), + })), }; manager.setDependencies(deps); manager.initialize(); + manager.setDocumentMode(documentMode); manager.setLayoutResults( [ { @@ -203,12 +214,11 @@ describe('HeaderFooterSessionManager', () => { manager.headerRegions.set(headerRegion.pageIndex, headerRegion); vi.spyOn(editorDom, 'getBoundingClientRect').mockReturnValue(createRect(100, 50, 600, 120)); - vi.spyOn(document, 'getSelection').mockReturnValue({ - rangeCount: 1, - getRangeAt: vi.fn(() => ({ - getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]), - })), - } as unknown as Selection); + vi.spyOn(document, 'createRange').mockReturnValue({ + setStart: vi.fn(), + setEnd: vi.fn(), + getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]), + } as unknown as Range); manager.activateRegion(headerRegion); await vi.waitFor(() => expect(manager.activeEditor).toBe(headerFooterEditor)); @@ -250,6 +260,28 @@ describe('HeaderFooterSessionManager', () => { expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); }); + it('falls back to concrete per-rId layouts when variant layout results are unavailable', async () => { + await setupWithZoom(1); + + manager.headerLayoutResults = null; + manager.headerLayoutsByRId.set('rId-header-default', { + kind: 'header', + type: 'default', + layout: { + height: 47, + pages: [{ number: 2, fragments: [] }], + }, + blocks: [{ id: 'blank-header-block' }] as never[], + measures: [{ id: 'blank-header-measure' }] as never[], + }); + + const context = manager.getContext(); + expect(context).toBeTruthy(); + expect(context?.layout.pageSize?.h).toBe(47); + expect(context?.blocks).toEqual([{ id: 'blank-header-block' }]); + expect(context?.measures).toEqual([{ id: 'blank-header-measure' }]); + }); + it('falls back to zoom=1 when zoom is negative', async () => { await setupWithZoom(-1); @@ -261,4 +293,224 @@ describe('HeaderFooterSessionManager', () => { expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); }); + + it('uses the requested PM range instead of the live DOM selection', async () => { + await setupWithZoom(1); + + vi.spyOn(document, 'getSelection').mockReturnValue(null); + + expect(manager.computeSelectionRects(3, 7)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + + it('activates header editing through the story-session manager without creating an overlay host', async () => { + const pageElement = document.createElement('div'); + pageElement.dataset.pageIndex = '0'; + painterHost.appendChild(pageElement); + + const storyEditor = createHeaderFooterEditorStub(document.createElement('div')); + const activate = vi.fn(() => ({ editor: storyEditor })); + const exit = vi.fn(); + const descriptor = { id: 'rId-header-default', variant: 'default' }; + + mockInitHeaderFooterRegistry.mockReturnValue({ + headerFooterIdentifier: null, + headerFooterManager: { + getDescriptorById: vi.fn(() => descriptor), + getDescriptors: vi.fn(() => [descriptor]), + ensureEditor: vi.fn(), + refresh: vi.fn(), + destroy: vi.fn(), + }, + headerFooterAdapter: null, + cleanups: [], + }); + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({ zoom: 1 })), + getPageElement: vi.fn(() => pageElement), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 3), + getStorySessionManager: vi.fn(() => ({ activate, exit })), + }); + + manager.initialize(); + manager.setDocumentMode('suggesting'); + + const region = { + kind: 'header' as const, + headerFooterRefId: 'rId-header-default', + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + pageIndex: 0, + pageNumber: 1, + localX: 36, + localY: 24, + width: 480, + height: 72, + }; + manager.headerRegions.set(region.pageIndex, region); + + manager.activateRegion(region); + await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor)); + + expect(storyEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(storyEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(storyEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(activate).toHaveBeenCalledWith( + { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId-header-default', + }, + expect.objectContaining({ + commitPolicy: 'continuous', + preferHiddenHost: true, + hostWidthPx: 480, + editorContext: expect.objectContaining({ + availableWidth: 480, + availableHeight: 72, + currentPageNumber: 1, + totalPageCount: 3, + surfaceKind: 'header', + }), + }), + ); + }); + + it('enters header edit mode in suggesting mode and enables tracked changes', async () => { + await setupWithZoom(1, 'suggesting'); + + const activeEditor = manager.activeEditor as unknown as { + commands: { + disableTrackChangesShowOriginal: ReturnType; + enableTrackChanges: ReturnType; + }; + setOptions: ReturnType; + setEditable: ReturnType; + view: { dom: HTMLElement }; + }; + + expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(activeEditor.setEditable).toHaveBeenCalledWith(true); + expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + expect(activeEditor.view.dom.getAttribute('aria-readonly')).toBe('false'); + }); + + it('updates the active header editor when the document mode changes to suggesting', async () => { + await setupWithZoom(1); + + const activeEditor = manager.activeEditor as unknown as { + commands: { + disableTrackChangesShowOriginal: ReturnType; + enableTrackChanges: ReturnType; + }; + setOptions: ReturnType; + setEditable: ReturnType; + view: { dom: HTMLElement }; + }; + + activeEditor.commands.disableTrackChangesShowOriginal.mockClear(); + activeEditor.commands.enableTrackChanges.mockClear(); + activeEditor.setOptions.mockClear(); + activeEditor.setEditable.mockClear(); + + manager.setDocumentMode('suggesting'); + + expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(activeEditor.setEditable).toHaveBeenCalledWith(true); + expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + }); + + it('exits the active story session when leaving header/footer mode', async () => { + const pageElement = document.createElement('div'); + pageElement.dataset.pageIndex = '0'; + painterHost.appendChild(pageElement); + + const storyEditor = createHeaderFooterEditorStub(document.createElement('div')); + const activate = vi.fn(() => ({ editor: storyEditor })); + const exit = vi.fn(); + const descriptor = { id: 'rId-header-default', variant: 'default' }; + + mockInitHeaderFooterRegistry.mockReturnValue({ + headerFooterIdentifier: null, + headerFooterManager: { + getDescriptorById: vi.fn(() => descriptor), + getDescriptors: vi.fn(() => [descriptor]), + ensureEditor: vi.fn(), + refresh: vi.fn(), + destroy: vi.fn(), + }, + headerFooterAdapter: null, + cleanups: [], + }); + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({ zoom: 1 })), + getPageElement: vi.fn(() => pageElement), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + getStorySessionManager: vi.fn(() => ({ activate, exit })), + }); + + manager.initialize(); + + const region = { + kind: 'header' as const, + headerFooterRefId: 'rId-header-default', + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + pageIndex: 0, + pageNumber: 1, + localX: 36, + localY: 24, + width: 480, + height: 72, + }; + manager.headerRegions.set(region.pageIndex, region); + + manager.activateRegion(region); + await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor)); + + manager.exitMode(); + expect(exit).toHaveBeenCalledTimes(1); + expect(manager.session.mode).toBe('body'); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts index 15fc627a23..c637a76978 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/LocalSelectionOverlayRendering.test.ts @@ -349,6 +349,7 @@ describe('renderCaretOverlay', () => { expect(caret.style.width).toBe('2px'); expect(caret.style.backgroundColor).toMatch(/#000000|rgb\(0,\s*0,\s*0\)/); expect(caret.style.borderRadius).toBe('1px'); + expect(caret.style.boxShadow).toBe('0 0 0 1px rgba(255, 255, 255, 0.92)'); expect(caret.style.pointerEvents).toBe('none'); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts index 6556b41d39..8c9a822b35 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts @@ -152,6 +152,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({ clear: vi.fn(), getBatch: vi.fn(() => []), getBlocksByRId: vi.fn(() => new Map()), + setTrackedChangesRenderConfig: vi.fn(), })), })); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index f2fcdad938..27803874fc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -128,6 +128,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({ clear: vi.fn(), getBatch: vi.fn(() => []), getBlocksByRId: vi.fn(() => new Map()), + setTrackedChangesRenderConfig: vi.fn(), })), })); @@ -174,7 +175,7 @@ describe('PresentationEditor - footnote number marker PM position', () => { vi.clearAllMocks(); }); - it('adds pmStart/pmEnd to the data-sd-footnote-number marker run', async () => { + it('keeps the synthetic footnote number marker out of the editable PM range', async () => { editor = new PresentationEditor({ element: container }); await new Promise((r) => setTimeout(r, 100)); @@ -185,8 +186,8 @@ describe('PresentationEditor - footnote number marker PM position', () => { const markerRun = blocks?.[0]?.runs?.[0]; expect(markerRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); - expect(markerRun?.pmStart).toBe(5); - expect(markerRun?.pmEnd).toBe(6); + expect(markerRun?.pmStart).toBeUndefined(); + expect(markerRun?.pmEnd).toBeUndefined(); }); it('appends semantic footnotes as end-of-document blocks in semantic flow mode', async () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts index f39000cec9..166d368be1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -105,6 +105,7 @@ vi.mock('../../header-footer/HeaderFooterRegistry', () => ({ clear: vi.fn(), getBatch: vi.fn(() => []), getBlocksByRId: vi.fn(() => new Map()), + setTrackedChangesRenderConfig: vi.fn(), })), })); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 70546b08bf..cc92107300 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -23,7 +23,9 @@ const { mockMeasureBlock, mockEditorConverterStore, mockCreateHeaderFooterEditor, + mockCreateStoryEditor, createdSectionEditors, + createdStoryEditors, mockOnHeaderFooterDataUpdate, mockUpdateYdocDocxData, mockEditorOverlayManager, @@ -89,18 +91,24 @@ const { once: emitter.once, emit: emitter.emit, destroy: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })), + getUpdatedJson: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })), setEditable: vi.fn(), + setDocumentMode: vi.fn(), setOptions: vi.fn(), commands: { setTextSelection: vi.fn(), + setCursorById: vi.fn(() => true), }, state: { doc: { content: { size: 10, }, + textBetween: vi.fn(() => 'Lazy note session'), }, }, + options: {}, view: { dom: document.createElement('div'), focus: vi.fn(), @@ -111,6 +119,7 @@ const { }; const editors: Array<{ editor: ReturnType }> = []; + const storyEditors: Array<{ editor: ReturnType }> = []; const mockFlowBlockCacheInstances: Array<{ clear: ReturnType; setHasExternalChanges: ReturnType; @@ -150,7 +159,14 @@ const { editors.push({ editor }); return editor; }), + mockCreateStoryEditor: vi.fn((parentEditor?: EditorInstance) => { + const editor = createSectionEditor(); + editor.options = { ...editor.options, parentEditor }; + storyEditors.push({ editor }); + return editor; + }), createdSectionEditors: editors, + createdStoryEditors: storyEditors, mockOnHeaderFooterDataUpdate: vi.fn(), mockUpdateYdocDocxData: vi.fn(() => Promise.resolve()), mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ @@ -324,6 +340,10 @@ vi.mock('@extensions/pagination/pagination-helpers.js', () => ({ onHeaderFooterDataUpdate: mockOnHeaderFooterDataUpdate, })); +vi.mock('../../story-editor-factory.js', () => ({ + createStoryEditor: mockCreateStoryEditor, +})); + vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); @@ -350,6 +370,7 @@ describe('PresentationEditor', () => { }; mockEditorConverterStore.mediaFiles = {}; createdSectionEditors.length = 0; + createdStoryEditors.length = 0; mockFlowBlockCacheInstances.length = 0; // Reset static instances @@ -2446,8 +2467,8 @@ describe('PresentationEditor', () => { viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 })); - await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0)); - await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdStoryEditors.at(-1)?.editor)); const sourceEditor = editor.getActiveEditor(); expect(sourceEditor).toBeDefined(); @@ -2514,8 +2535,8 @@ describe('PresentationEditor', () => { viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 })); - await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0)); - await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdStoryEditors.at(-1)?.editor)); const sourceEditor = editor.getActiveEditor(); const transaction = { docChanged: true }; @@ -2572,8 +2593,8 @@ describe('PresentationEditor', () => { viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 })); - await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0)); - await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdStoryEditors.at(-1)?.editor)); const sourceEditor = editor.getActiveEditor(); expect(sourceEditor).toBeDefined(); @@ -2604,64 +2625,6 @@ describe('PresentationEditor', () => { ); }); - it('clears leftover footer transform when entering footer editing with non-negative minY', async () => { - mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult()); - - const editorContainer = document.createElement('div'); - editorContainer.className = 'super-editor'; - editorContainer.style.transform = 'translateY(24px)'; - const editorHost = document.createElement('div'); - editorHost.appendChild(editorContainer); - - const showEditingOverlay = vi.fn(() => ({ - success: true, - editorHost, - reason: null, - })); - - mockEditorOverlayManager.mockImplementationOnce(() => ({ - showEditingOverlay, - hideEditingOverlay: vi.fn(), - showSelectionOverlay: vi.fn(), - hideSelectionOverlay: vi.fn(), - setOnDimmingClick: vi.fn(), - getActiveEditorHost: vi.fn(() => editorHost), - destroy: vi.fn(), - })); - - editor = new PresentationEditor({ - element: container, - documentId: 'test-doc', - }); - - await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement; - const mockPage = document.createElement('div'); - mockPage.setAttribute('data-page-index', '0'); - pagesHost.appendChild(mockPage); - - const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement; - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ - left: 0, - top: 0, - width: 800, - height: 1000, - right: 800, - bottom: 1000, - x: 0, - y: 0, - toJSON: () => ({}), - } as DOMRect); - - // Click inside the footer hitbox (y between footer margin 36 and bottom margin 72) - viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 })); - - await vi.waitFor(() => expect(showEditingOverlay).toHaveBeenCalled()); - await vi.waitFor(() => expect(editorContainer.style.transform).toBe('')); - }); - it('exits header mode on Escape and announces the transition', async () => { mockIncrementalLayout.mockResolvedValue(buildLayoutResult()); @@ -2734,6 +2697,272 @@ describe('PresentationEditor', () => { }); }); + describe('footnote interactions', () => { + const prepareFootnoteEditor = async () => { + mockIncrementalLayout.mockResolvedValueOnce({ + layout: { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + numberText: '1', + size: { w: 612, h: 792 }, + fragments: [], + margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, + }, + ], + }, + measures: [], + }); + + mockEditorConverterStore.current = { + ...mockEditorConverterStore.current, + footnotes: [ + { + id: '1', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy note session' }] }], + }, + ], + convertedXml: { + 'word/footnotes.xml': { + elements: [ + { + name: 'w:footnotes', + elements: [ + { + name: 'w:footnote', + attributes: { 'w:id': '1' }, + elements: [{ name: 'w:p', elements: [] }], + }, + ], + }, + ], + }, + }, + }; + + editor = new PresentationEditor({ + element: container, + documentId: 'footnote-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement; + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 1000, + right: 800, + bottom: 1000, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const footnoteFragment = document.createElement('span'); + footnoteFragment.setAttribute('data-block-id', 'footnote-1-0'); + viewport.appendChild(footnoteFragment); + + return { viewport, footnoteFragment }; + }; + + const activateFootnoteSession = async () => { + const { viewport, footnoteFragment } = await prepareFootnoteEditor(); + + expect(editor.getStorySessionManager()).not.toBeNull(); + expect(editor.getStorySessionManager()?.getActiveSession()).toBeNull(); + + footnoteFragment.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }), + ); + + await vi.waitFor(() => expect(mockCreateStoryEditor.mock.calls.length).toBeGreaterThanOrEqual(1)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(1)); + + return { + viewport, + footnoteFragment, + sessionEditor: createdStoryEditors.at(-1)?.editor, + }; + }; + + const prepareEndnoteEditor = async () => { + mockIncrementalLayout.mockResolvedValueOnce({ + layout: { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + numberText: '1', + size: { w: 612, h: 792 }, + fragments: [], + margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, + }, + ], + }, + measures: [], + }); + + mockEditorConverterStore.current = { + ...mockEditorConverterStore.current, + endnotes: [ + { + id: '1', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy endnote session' }] }], + }, + ], + convertedXml: { + 'word/endnotes.xml': { + elements: [ + { + name: 'w:endnotes', + elements: [ + { + name: 'w:endnote', + attributes: { 'w:id': '1' }, + elements: [{ name: 'w:p', elements: [] }], + }, + ], + }, + ], + }, + }, + }; + + editor = new PresentationEditor({ + element: container, + documentId: 'endnote-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement; + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 1000, + right: 800, + bottom: 1000, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const endnoteFragment = document.createElement('span'); + endnoteFragment.setAttribute('data-block-id', 'endnote-1-0'); + viewport.appendChild(endnoteFragment); + + return { viewport, endnoteFragment }; + }; + + it('activates a note editing session through the shared story-session manager', async () => { + const { sessionEditor } = await activateFootnoteSession(); + + expect(editor.getStorySessionManager()).not.toBeNull(); + expect(editor.getStorySessionManager()?.getActiveSession()?.commitPolicy).toBe('continuous'); + expect(editor.getActiveEditor()).toBe(sessionEditor); + expect(sessionEditor?.setDocumentMode).toHaveBeenCalledWith('editing'); + + editor.setDocumentMode('viewing'); + expect(sessionEditor?.setDocumentMode).toHaveBeenLastCalledWith('viewing'); + expect(createdSectionEditors.length).toBe(0); + }); + + it('routes tracked-change navigation to the active note session editor', async () => { + const { sessionEditor } = await activateFootnoteSession(); + const setCursorById = vi.fn(() => true); + if (sessionEditor?.commands) { + sessionEditor.commands.setCursorById = setCursorById; + } + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-note-1', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + expect(setCursorById).toHaveBeenCalledWith('tc-note-1', { preferredActiveThreadId: 'tc-note-1' }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + }); + + it('falls back to rendered tracked-change stamps for inactive non-body stories', async () => { + const { viewport } = await prepareFootnoteEditor(); + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + + const renderedChange = document.createElement('span'); + renderedChange.dataset.trackChangeId = 'tc-footnote-2'; + renderedChange.dataset.storyKey = 'fn:2'; + renderedChange.scrollIntoView = vi.fn(); + page.appendChild(renderedChange); + viewport.appendChild(page); + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-footnote-2', + story: { kind: 'story', storyType: 'footnote', noteId: '2' }, + }); + + expect(didNavigate).toBe(true); + expect(renderedChange.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'center', + inline: 'nearest', + }); + }); + + it('activates an inactive endnote story before routing tracked-change navigation', async () => { + const { viewport } = await prepareEndnoteEditor(); + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + + const renderedChange = document.createElement('span'); + renderedChange.dataset.trackChangeId = 'tc-endnote-1'; + renderedChange.dataset.storyKey = 'en:1'; + renderedChange.scrollIntoView = vi.fn(); + vi.spyOn(renderedChange, 'getBoundingClientRect').mockReturnValue({ + left: 140, + top: 720, + width: 20, + height: 12, + right: 160, + bottom: 732, + x: 140, + y: 720, + toJSON: () => ({}), + } as DOMRect); + page.appendChild(renderedChange); + viewport.appendChild(page); + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-endnote-1', + story: { kind: 'story', storyType: 'endnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(2)); + + const sessionEditor = createdStoryEditors.at(-1)?.editor; + expect(sessionEditor?.commands.setCursorById).toHaveBeenCalledWith('tc-endnote-1', { + preferredActiveThreadId: 'tc-endnote-1', + }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + expect(renderedChange.scrollIntoView).not.toHaveBeenCalled(); + }); + }); + describe('pageStyleUpdate event listener', () => { const buildLayoutResult = () => ({ layout: { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts index c056ee07ea..4e9abb5450 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts @@ -237,4 +237,131 @@ describe('PresentationInputBridge - Context Menu Handling', () => { expect(dispatchSpy).not.toHaveBeenCalled(); }); }); + + describe('stale hidden-editor rerouting', () => { + it('does not double-forward layout-surface composing beforeinput when window fallback is enabled', () => { + const event = new InputEvent('beforeinput', { + data: 'e', + inputType: 'insertCompositionText', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'isComposing', { value: true, writable: false }); + + const forwardedEvents: string[] = []; + targetDom.addEventListener('beforeinput', () => { + forwardedEvents.push('beforeinput'); + }); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + layoutSurface.dispatchEvent(event); + + expect(forwardedEvents).toEqual(['beforeinput']); + }); + + it('reroutes beforeinput from a stale hidden editor to the active target when window fallback is enabled', () => { + const staleBodyEditor = document.createElement('div'); + staleBodyEditor.className = 'ProseMirror'; + staleBodyEditor.setAttribute('contenteditable', 'true'); + document.body.appendChild(staleBodyEditor); + + const staleEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + staleBodyEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).toHaveBeenCalled(); + expect(targetDispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'beforeinput', + data: 'a', + inputType: 'insertText', + }), + ); + expect(staleEvent.defaultPrevented).toBe(true); + }); + + it('reroutes non-text keyboard commands from a stale hidden editor to the active target', () => { + const staleBodyEditor = document.createElement('div'); + staleBodyEditor.className = 'ProseMirror'; + staleBodyEditor.setAttribute('contenteditable', 'true'); + document.body.appendChild(staleBodyEditor); + + const staleEvent = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + staleBodyEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).toHaveBeenCalled(); + expect(targetDispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'keydown', + key: 'Backspace', + }), + ); + expect(staleEvent.defaultPrevented).toBe(true); + }); + + it('does not reroute keyboard input from a registered UI surface editor', () => { + const commentEditor = document.createElement('div'); + commentEditor.className = 'ProseMirror'; + commentEditor.setAttribute('contenteditable', 'true'); + + const commentDialog = document.createElement('div'); + commentDialog.setAttribute('data-editor-ui-surface', ''); + commentDialog.appendChild(commentEditor); + document.body.appendChild(commentDialog); + + const staleEvent = new KeyboardEvent('keydown', { + key: 'U', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + commentEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).not.toHaveBeenCalled(); + expect(targetDispatchSpy).not.toHaveBeenCalled(); + expect(staleEvent.defaultPrevented).toBe(false); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts new file mode 100644 index 0000000000..822be59436 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + computeCaretRectFromVisibleTextOffset, + computeSelectionRectsFromVisibleTextOffsets, + measureVisibleTextOffset, + type VisibleTextOffsetGeometryOptions, +} from '../selection/VisibleTextOffsetGeometry.js'; + +function createRect(x: number, y: number, width: number, height: number): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON: () => ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height }), + } as DOMRect; +} + +function createGeometryOptions(containers: HTMLElement[]): VisibleTextOffsetGeometryOptions { + return { + containers, + zoom: 1, + pageHeight: 792, + pageGap: 16, + }; +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('measureVisibleTextOffset', () => { + it('measures an element boundary after a tracked-insert wrapper as visible text', () => { + const root = document.createElement('div'); + root.innerHTML = + '

refXYZerences

'; + document.body.appendChild(root); + + const inlineRoot = root.querySelector('[data-run="1"] > span') as HTMLElement; + const offset = measureVisibleTextOffset(root, inlineRoot, 2); + + expect(offset).toBe(6); + }); +}); + +describe('computeCaretRectFromVisibleTextOffset', () => { + it('skips PM-less marker text and places the caret after inserted visible text', () => { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ 1 + ref + XYZ + erences +
+
+ `; + document.body.appendChild(page); + + const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement; + const line = page.querySelector('.superdoc-line') as HTMLElement; + const suffixTextNode = Array.from(page.querySelectorAll('span')).find( + (element) => element.textContent === 'erences', + )?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + line.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 16)); + + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.startContainer === suffixTextNode && this.startOffset === 0) { + return createRect(70, 20, 0, 16); + } + return createRect(0, 0, 0, 0); + }); + + const rect = computeCaretRectFromVisibleTextOffset(createGeometryOptions([fragment]), 6); + + expect(rect).toMatchObject({ + pageIndex: 0, + x: 70, + y: 20, + width: 1, + height: 16, + }); + }); +}); + +describe('computeSelectionRectsFromVisibleTextOffsets', () => { + it('maps later-word selection offsets after an inserted run to the correct painted range', () => { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ 1 + ref + XYZ + erences + Closing +
+
+ `; + document.body.appendChild(page); + + const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement; + const closingTextNode = Array.from(page.querySelectorAll('span')).find( + (element) => element.textContent === 'Closing', + )?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + + vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () { + if (this.startContainer === closingTextNode && this.startOffset === 0 && this.endContainer === closingTextNode) { + return [createRect(120, 40, 52, 16)] as unknown as DOMRectList; + } + return [] as unknown as DOMRectList; + }); + + const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 14, 21); + + expect(rects).toEqual([ + { + pageIndex: 0, + x: 120, + y: 40, + width: 52, + height: 16, + }, + ]); + }); + + it('collapses same-line PM gaps that come from tracked-change wrapper structure', () => { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ abc + word +
+
+ `; + document.body.appendChild(page); + + const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement; + const wordTextNode = Array.from(page.querySelectorAll('span')).find((element) => element.textContent === 'word') + ?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + + vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () { + if (this.startContainer === wordTextNode && this.startOffset === 0 && this.endContainer === wordTextNode) { + return [createRect(140, 48, 36, 16)] as unknown as DOMRectList; + } + return [] as unknown as DOMRectList; + }); + + const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 3, 7); + + expect(rects).toEqual([ + { + pageIndex: 0, + x: 140, + y: 48, + width: 36, + height: 16, + }, + ]); + }); + + it('preserves logical spaces that are trimmed from painted line text at line breaks', () => { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + page.innerHTML = ` +
+
+ abc +
+
+ word +
+
+ `; + document.body.appendChild(page); + + const fragment = page.querySelector('[data-block-id="footnote-1-0"]') as HTMLElement; + const wordTextNode = Array.from(page.querySelectorAll('span')).find((element) => element.textContent === 'word') + ?.firstChild as Text; + + page.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + + vi.spyOn(Range.prototype, 'getClientRects').mockImplementation(function () { + if (this.startContainer === wordTextNode && this.startOffset === 0 && this.endContainer === wordTextNode) { + return [createRect(180, 60, 40, 16)] as unknown as DOMRectList; + } + return [] as unknown as DOMRectList; + }); + + const rects = computeSelectionRectsFromVisibleTextOffsets(createGeometryOptions([fragment]), 4, 8); + + expect(rects).toEqual([ + { + pageIndex: 0, + x: 180, + y: 60, + width: 40, + height: 16, + }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index a77100a932..46bfa4fe34 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -352,6 +352,10 @@ export interface EditorWithConverter extends Editor { id: string; content?: unknown[]; }>; + endnotes?: Array<{ + id: string; + content?: unknown[]; + }>; }; } @@ -434,7 +438,7 @@ export type PendingMarginClick = * to prevent unwanted scroll behavior when the hidden editor receives focus. * * @remarks - * This flag is set by {@link PresentationEditor#wrapHiddenEditorFocus} to ensure + * This flag is set by {@link PresentationEditor#wrapOffscreenEditorFocus} to ensure * the wrapping is idempotent (applied only once per view instance). */ export interface EditorViewWithScrollFlag { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts index 7fa546f0db..b8ee13f9c8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts @@ -1,48 +1,87 @@ import type { Mark, Node as ProseMirrorNode } from 'prosemirror-model'; +import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js'; +import { + makeCommentAnchorKey, + makeTrackedChangeAnchorKey, +} from '../../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; -export type CommentPosition = { threadId: string; start: number; end: number }; +export type CommentPosition = { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange' | 'comment'; + start: number; + end: number; +}; + +export interface CollectCommentPositionsOptions { + commentMarkName: string; + trackChangeMarkNames: string[]; + storyKey?: string; +} export function collectCommentPositions( doc: ProseMirrorNode | null, - options: { commentMarkName: string; trackChangeMarkNames: string[] }, + options: CollectCommentPositionsOptions, ): Record { if (!doc) { return {}; } - const pmPositions: Record = {}; + const storyKey = options.storyKey ?? BODY_STORY_KEY; + const positions: Record = {}; doc.descendants((node, pos) => { const marks = node.marks || []; for (const mark of marks) { - const threadId = getThreadIdFromMark(mark, options); - if (!threadId) continue; + const descriptor = describeThreadMark(mark, options); + if (!descriptor) continue; + const canonicalKey = + descriptor.kind === 'trackedChange' + ? makeTrackedChangeAnchorKey({ storyKey, rawId: descriptor.rawId }) + : makeCommentAnchorKey(descriptor.rawId); + const storageKey = descriptor.kind === 'trackedChange' ? canonicalKey : descriptor.rawId; const nodeEnd = pos + node.nodeSize; + const existing = positions[storageKey]; - if (!pmPositions[threadId]) { - pmPositions[threadId] = { threadId, start: pos, end: nodeEnd }; - } else { - pmPositions[threadId].start = Math.min(pmPositions[threadId].start, pos); - pmPositions[threadId].end = Math.max(pmPositions[threadId].end, nodeEnd); + if (!existing) { + positions[storageKey] = { + threadId: descriptor.rawId, + key: canonicalKey, + storyKey, + kind: descriptor.kind, + start: pos, + end: nodeEnd, + }; + continue; } + + existing.start = Math.min(existing.start, pos); + existing.end = Math.max(existing.end, nodeEnd); } }); - return pmPositions; + return positions; +} + +interface ThreadMarkDescriptor { + rawId: string; + kind: 'trackedChange' | 'comment'; } -function getThreadIdFromMark( - mark: Mark, - options: { commentMarkName: string; trackChangeMarkNames: string[] }, -): string | undefined { +function describeThreadMark(mark: Mark, options: CollectCommentPositionsOptions): ThreadMarkDescriptor | undefined { if (mark.type.name === options.commentMarkName) { - return mark.attrs.commentId || mark.attrs.importedId; + const commentId = (mark.attrs.commentId as string | undefined) ?? (mark.attrs.importedId as string | undefined); + if (!commentId) return undefined; + return { rawId: commentId, kind: 'comment' }; } if (options.trackChangeMarkNames.includes(mark.type.name)) { - return mark.attrs.id; + const rawId = mark.attrs.id as string | undefined; + if (!rawId) return undefined; + return { rawId, kind: 'trackedChange' }; } return undefined; diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts new file mode 100644 index 0000000000..f688e776ee --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Editor } from './Editor.js'; +import { createStoryEditor } from './story-editor-factory.ts'; +import { initTestEditor } from '../tests/helpers/helpers.js'; + +const createdEditors: Editor[] = []; + +function trackEditor(editor: Editor): Editor { + createdEditors.push(editor); + return editor; +} + +afterEach(() => { + while (createdEditors.length > 0) { + const editor = createdEditors.pop(); + try { + editor?.destroy?.(); + } catch { + // best-effort cleanup for test editors + } + } +}); + +describe('createStoryEditor', () => { + it('inherits tracked changes configuration from the parent editor', () => { + const parent = trackEditor( + initTestEditor({ + mode: 'text', + content: '

Hello world

', + trackedChanges: { + visible: true, + mode: 'review', + enabled: true, + replacements: 'independent', + }, + }).editor as Editor, + ); + + const child = trackEditor( + createStoryEditor( + parent, + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }], + }, + { + documentId: 'hf:part:rId9', + isHeaderOrFooter: true, + headless: true, + }, + ), + ); + + expect(child.options.trackedChanges).toEqual({ + visible: true, + mode: 'review', + enabled: true, + replacements: 'independent', + }); + + child.options.trackedChanges!.replacements = 'paired'; + expect(parent.options.trackedChanges?.replacements).toBe('independent'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index ffc7b8fe08..d271bbd31e 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -129,6 +129,9 @@ export function createStoryEditor( const inheritedExtensions = parentEditor.options.extensions?.length ? [...parentEditor.options.extensions] : undefined; + const inheritedTrackedChanges = parentEditor.options.trackedChanges + ? { ...parentEditor.options.trackedChanges } + : undefined; const StoryEditorClass = parentEditor.constructor as new (options: Partial) => Editor; const storyEditor = new StoryEditorClass({ @@ -144,6 +147,8 @@ export function createStoryEditor( media, mediaFiles: media, fonts: parentEditor.options.fonts, + user: parentEditor.options.user, + trackedChanges: inheritedTrackedChanges, isHeaderOrFooter, isHeadless, pagination: false, @@ -156,7 +161,9 @@ export function createStoryEditor( // Only set element when not headless ...(isHeadless ? {} : { element }), - // Disable collaboration, comments, and tracked changes for story editors + // Disable collaboration and comment threading for story editors. + // Tracked-change configuration is inherited from the parent editor so + // suggesting-mode story sessions honor the same replacement model. ydoc: null, collaborationProvider: null, isCommentsEnabled: false, @@ -169,17 +176,21 @@ export function createStoryEditor( // Store parent editor reference as a non-enumerable property to avoid // circular reference issues during serialization while still allowing // access when needed. - Object.defineProperty(storyEditor.options, 'parentEditor', { - enumerable: false, - configurable: true, - get() { - return parentEditor; - }, - }); + if (storyEditor.options && typeof storyEditor.options === 'object') { + Object.defineProperty(storyEditor.options, 'parentEditor', { + enumerable: false, + configurable: true, + get() { + return parentEditor; + }, + }); + } // Start non-editable; the caller (e.g. PresentationEditor) will enable // editing when entering edit mode. - storyEditor.setEditable(false, false); + if (typeof storyEditor.setEditable === 'function') { + storyEditor.setEditable(false, false); + } return storyEditor; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js index 75b7f2e94f..1d8bcb1913 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -2,21 +2,24 @@ import { defaultNodeListHandler } from './docxImporter'; import { carbonCopy } from '../../../utilities/carbonCopy.js'; /** - * Remove w:footnoteRef placeholders from converted footnote content. - * In OOXML footnotes, the first run often includes a w:footnoteRef marker which - * Word uses to render the footnote number. We render numbering ourselves. + * Remove w:footnoteRef / w:endnoteRef placeholders from converted note content. + * In OOXML notes, the first run often includes a reference marker which Word + * uses to render the display number. We render numbering ourselves. * * @param {Array} nodes * @returns {Array} */ -const stripFootnoteMarkerNodes = (nodes) => { +const stripNoteMarkerNodes = (nodes) => { if (!Array.isArray(nodes) || nodes.length === 0) return nodes; const walk = (list) => { if (!Array.isArray(list) || list.length === 0) return; for (let i = list.length - 1; i >= 0; i--) { const node = list[i]; if (!node) continue; - if (node.type === 'passthroughInline' && node.attrs?.originalName === 'w:footnoteRef') { + if ( + node.type === 'passthroughInline' && + (node.attrs?.originalName === 'w:footnoteRef' || node.attrs?.originalName === 'w:endnoteRef') + ) { list.splice(i, 1); continue; } @@ -109,7 +112,7 @@ function importNoteEntries({ path: [el], }); - const stripped = stripFootnoteMarkerNodes(converted); + const stripped = stripNoteMarkerNodes(converted); results.push({ id, type, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index b99eefff88..8de6cd9b36 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -20,12 +20,13 @@ import { pageReferenceEntity } from './pageReferenceImporter.js'; import { crossReferenceEntity } from './crossReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; -import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; +import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js'; import { importFootnoteData, importEndnoteData } from './documentFootnotesImporter.js'; import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; import { tabNodeEntityHandler } from './tabImporter.js'; import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js'; +import { endnoteReferenceHandlerEntity } from './endnoteReferenceImporter.js'; import { tableNodeHandlerEntity } from './tableImporter.js'; import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js'; import { indexHandlerEntity, indexEntryHandlerEntity } from './indexImporter.js'; @@ -153,9 +154,11 @@ export const createDocumentJson = (docx, converter, editor) => { patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); - converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, { + const trackedChangeIdMapOptions = { replacements: converter.trackedChangesOptions?.replacements ?? 'paired', - }); + }; + converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions); + converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering }); @@ -241,6 +244,7 @@ export const defaultNodeListHandler = () => { trackChangeNodeHandlerEntity, tableNodeHandlerEntity, footnoteReferenceHandlerEntity, + endnoteReferenceHandlerEntity, tabNodeEntityHandler, tableOfContentsHandlerEntity, indexHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js new file mode 100644 index 0000000000..bd254029d4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js @@ -0,0 +1,7 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/w/endnoteReference/endnoteReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const endnoteReferenceHandlerEntity = generateV2HandlerEntity('endnoteReferenceHandler', translator); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index 0a9d6637eb..2710d1b6c0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -134,6 +134,26 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { } } +/** + * Scan a single OOXML part and return a fresh `w:id → internal UUID` map. + * + * The scan assumes the top-level element is a document / hdr / ftr / footnotes + * / endnotes root. Returns an empty map when the part is absent or malformed. + * + * @param {object | undefined} part Parsed OOXML part (from SuperConverter). + * @param {{ replacements?: TrackChangesReplacements }} [options] + * @returns {Map} + */ +function buildTrackedChangeIdMapForPart(part, options = {}) { + const root = part?.elements?.[0]; + if (!root?.elements) return new Map(); + + const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; + const idMap = new Map(); + walkElements(root.elements, idMap, { lastTrackedChange: null, replacements }); + return idMap; +} + /** * Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning * `word/document.xml`. @@ -153,12 +173,41 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * @returns {Map} Word `w:id` → internal UUID */ export function buildTrackedChangeIdMap(docx, options = {}) { - const body = docx?.['word/document.xml']?.elements?.[0]; - if (!body?.elements) return new Map(); + return buildTrackedChangeIdMapForPart(docx?.['word/document.xml'], options); +} - const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; - const idMap = new Map(); - walkElements(body.elements, idMap, { lastTrackedChange: null, replacements }); +/** + * Builds per-part `w:id → internal UUID` maps for every revision-capable + * content part in the DOCX package. + * + * Word revision IDs are not globally unique across parts, so each part keeps + * its own isolated `w:id` namespace. + * + * @param {Record | null | undefined} docx + * @param {{ replacements?: TrackChangesReplacements }} [options] + * @returns {Map>} + */ +export function buildTrackedChangeIdMapsByPart(docx, options = {}) { + /** @type {Map>} */ + const mapsByPart = new Map(); + if (!docx || typeof docx !== 'object') return mapsByPart; - return idMap; + /** @type {Record} */ + const parts = /** @type {Record} */ (docx); + + mapsByPart.set('word/document.xml', buildTrackedChangeIdMapForPart(parts['word/document.xml'], options)); + + for (const partPath of Object.keys(parts)) { + if (!/^word\/(?:header|footer)\d+\.xml$/.test(partPath)) continue; + mapsByPart.set(partPath, buildTrackedChangeIdMapForPart(parts[partPath], options)); + } + + if (parts['word/footnotes.xml']) { + mapsByPart.set('word/footnotes.xml', buildTrackedChangeIdMapForPart(parts['word/footnotes.xml'], options)); + } + if (parts['word/endnotes.xml']) { + mapsByPart.set('word/endnotes.xml', buildTrackedChangeIdMapForPart(parts['word/endnotes.xml'], options)); + } + + return mapsByPart; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js index 806ee8de63..6842cfcfd8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; +import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js'; // --------------------------------------------------------------------------- // Test helpers @@ -291,3 +291,93 @@ describe('buildTrackedChangeIdMap', () => { }); }); }); + +function createDocxWithParts(partMap) { + const docx = {}; + for (const [path, bodyChildren] of Object.entries(partMap)) { + const rootName = path.includes('/footnotes.xml') + ? 'w:footnotes' + : path.includes('/endnotes.xml') + ? 'w:endnotes' + : path.includes('/header') + ? 'w:hdr' + : path.includes('/footer') + ? 'w:ftr' + : 'w:document'; + docx[path] = { + elements: [{ name: rootName, elements: bodyChildren }], + }; + } + return docx; +} + +describe('buildTrackedChangeIdMapsByPart', () => { + it('returns an empty Map when docx is missing or empty', () => { + expect(buildTrackedChangeIdMapsByPart(null).size).toBe(0); + expect(buildTrackedChangeIdMapsByPart(undefined).size).toBe(0); + }); + + it('always includes a body map at `word/document.xml`', () => { + const docx = createDocxWithParts({ 'word/document.xml': [paragraph(trackedChange('w:ins', '1'))] }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.has('word/document.xml')).toBe(true); + expect(maps.get('word/document.xml').get('1')).toBeTruthy(); + }); + + it('scans every header and footer part present in the package', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/header1.xml': [paragraph(wordDelete('100', 'gone'), wordInsert('101', 'new'))], + 'word/footer2.xml': [paragraph(trackedChange('w:ins', '200'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + + const headerMap = maps.get('word/header1.xml'); + expect(headerMap).toBeDefined(); + expect(headerMap.get('100')).toBeTruthy(); + expect(headerMap.get('100')).toBe(headerMap.get('101')); + + const footerMap = maps.get('word/footer2.xml'); + expect(footerMap).toBeDefined(); + expect(footerMap.get('200')).toBeTruthy(); + }); + + it('keeps per-part id spaces isolated when the same w:id appears in multiple parts', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [paragraph(trackedChange('w:ins', 'shared'))], + 'word/header1.xml': [paragraph(trackedChange('w:ins', 'shared'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.get('word/document.xml').get('shared')).not.toBe(maps.get('word/header1.xml').get('shared')); + }); + + it('includes footnotes and endnotes parts when present', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/footnotes.xml': [paragraph(wordDelete('300', 'x'), wordInsert('301', 'y'))], + 'word/endnotes.xml': [paragraph(trackedChange('w:ins', '400'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.get('word/footnotes.xml').get('300')).toBe(maps.get('word/footnotes.xml').get('301')); + expect(maps.get('word/endnotes.xml').get('400')).toBeTruthy(); + }); + + it('passes replacement mode options through to each part scan', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/header1.xml': [paragraph(wordDelete('500', 'gone'), wordInsert('501', 'new'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx, { replacements: 'independent' }); + + expect(maps.get('word/header1.xml').get('500')).not.toBe(maps.get('word/header1.xml').get('501')); + }); + + it('does not introduce unrelated parts into the map', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/styles.xml': [], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.has('word/styles.xml')).toBe(false); + }); +}); 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 e2e8a32421..137d60aad8 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 @@ -23,14 +23,17 @@ const validXmlAttributes = [ * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter } = params; + const { nodeListHandler, extraParams = {}, converter, filename } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. const originalWordId = encodedAttrs.id; - if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId); + const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; + const trackedChangeIdMap = + converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; + if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { + encodedAttrs.id = trackedChangeIdMap.get(originalWordId); } encodedAttrs.sourceId = originalWordId || ''; 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 12cbcc97df..964d99d75a 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 @@ -30,7 +30,7 @@ describe('w:del translator', () => { describe('encode', () => { const mockNode = { elements: [{ text: 'deleted text' }] }; - function encodeWith({ converter, id = '123' } = {}) { + function encodeWith({ converter, id = '123', filename } = {}) { const mockSubNodes = [{ content: [{ type: 'text', text: 'deleted text' }] }]; const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; @@ -46,6 +46,7 @@ describe('w:del translator', () => { nodeListHandler: mockNodeListHandler, extraParams: { node: mockNode }, converter, + filename, path: [], }, { ...encodedAttrs }, @@ -89,6 +90,19 @@ describe('w:del translator', () => { expect(attrs.id).toBe('shared-uuid-abc'); expect(attrs.sourceId).toBe('123'); }); + + it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => { + const converter = { + trackedChangeIdMap: new Map([['123', 'body-uuid']]), + trackedChangeIdMapsByPart: new Map([['word/footnotes.xml', new Map([['123', 'footnote-uuid']])]]), + }; + + const result = encodeWith({ converter, filename: 'footnotes.xml' }); + const attrs = getMarkAttrs(result); + + expect(attrs.id).toBe('footnote-uuid'); + expect(attrs.sourceId).toBe('123'); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 0ed46c4834..9ececf73e7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -23,14 +23,17 @@ const validXmlAttributes = [ * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter } = params; + const { nodeListHandler, extraParams = {}, converter, filename } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. const originalWordId = encodedAttrs.id; - if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId); + const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; + const trackedChangeIdMap = + converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; + if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { + encodedAttrs.id = trackedChangeIdMap.get(originalWordId); } encodedAttrs.sourceId = originalWordId || ''; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index 113d0680b6..be99a7c505 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -29,7 +29,7 @@ describe('w:ins translator', () => { describe('encode', () => { const mockNode = { elements: [{ text: 'added text' }] }; - function encodeWith({ converter, id = '123' } = {}) { + function encodeWith({ converter, id = '123', filename } = {}) { const mockSubNodes = [{ content: [{ type: 'text', text: 'added text' }] }]; const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; @@ -45,6 +45,7 @@ describe('w:ins translator', () => { nodeListHandler: mockNodeListHandler, extraParams: { node: mockNode }, converter, + filename, path: [], }, { ...encodedAttrs }, @@ -97,6 +98,19 @@ describe('w:ins translator', () => { expect(attrs.id).toBe('shared-uuid-abc'); expect(attrs.sourceId).toBe('123'); }); + + it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => { + const converter = { + trackedChangeIdMap: new Map([['123', 'body-uuid']]), + trackedChangeIdMapsByPart: new Map([['word/header1.xml', new Map([['123', 'header-uuid']])]]), + }; + + const { result } = encodeWith({ converter, filename: 'header1.xml' }); + const attrs = getMarkAttrs(result); + + expect(attrs.id).toBe('header-uuid'); + expect(attrs.sourceId).toBe('123'); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index b07139b2c9..16062847f6 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -2,7 +2,7 @@ import type { Transaction } from 'prosemirror-state'; import type { Editor } from '../Editor.js'; import type { DefaultEventMap } from '../EventEmitter.js'; import type { PartChangedEvent } from '../parts/types.js'; -import type { DocumentProtectionState } from '@superdoc/document-api'; +import type { DocumentProtectionState, StoryLocator } from '@superdoc/document-api'; /** Source of a protection state change. */ export type ProtectionChangeSource = 'init' | 'local-mutation' | 'remote-part-sync'; @@ -121,6 +121,15 @@ export interface ListDefinitionsPayload { editor?: unknown; } +/** Payload emitted with the `tracked-changes-changed` event. */ +export interface TrackedChangesChangedPayload { + editor: Editor; + /** Stories whose tracked-change snapshot has changed. `undefined` means full rebuild. */ + stories?: StoryLocator[]; + /** Optional origin hint. */ + source?: string; +} + /** * Event map for the Editor class */ @@ -204,4 +213,12 @@ export interface EditorEventMap extends DefaultEventMap { /** Called when document protection state changes (init, local mutation, or remote sync). */ protectionChanged: [{ editor: Editor; state: DocumentProtectionState; source: ProtectionChangeSource }]; + + /** + * Story-aware tracked-change invalidation signal. + * + * Emitted by the host-level `TrackedChangeIndex` service whenever one or + * more story caches are invalidated. + */ + 'tracked-changes-changed': [TrackedChangesChangedPayload]; } diff --git a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue index e61acfc20b..5834e158c3 100644 --- a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue +++ b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue @@ -1,5 +1,7 @@ diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index d83167e1db..6639e26425 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -5,16 +5,43 @@ type SuperDocConfig = ConstructorParameters[0]; type SuperDocInstance = InstanceType; type SuperDocReadyPayload = Parameters>[0]; type OverrideType = 'markdown' | 'html' | 'text'; +type StoryLocator = + | { kind: 'story'; storyType: 'body' } + | { kind: 'story'; storyType: 'headerFooterPart'; refId: string } + | { kind: 'story'; storyType: 'footnote' | 'endnote'; noteId: string }; type ContentOverrideInput = { contentOverride?: string; overrideType?: OverrideType; }; +type BehaviorHarnessCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; +type BehaviorHarnessApi = { + getActiveStorySession: () => StoryLocator | null; + getActiveStoryText: () => string | null; + getBodyStoryText: () => string | null; + getCommentsSnapshot: () => BehaviorHarnessCommentSnapshot[]; + getEditorCommentPositions: () => Record; + getActiveCommentId: () => string | null; +}; type HarnessWindow = Window & typeof globalThis & { superdocReady?: boolean; superdoc?: SuperDocInstance; editor?: unknown; + behaviorHarness?: BehaviorHarnessApi; behaviorHarnessInit?: (input?: ContentOverrideInput) => void; }; @@ -40,6 +67,63 @@ if (!showCaret) { } let instance: SuperDocInstance | null = null; +const commentsPanel = document.querySelector('#comments-panel'); + +function getEditorText(editor: any): string | null { + const state = editor?.state; + const doc = state?.doc; + if (!doc || typeof doc.textBetween !== 'function' || typeof doc.content?.size !== 'number') return null; + return doc.textBetween(0, doc.content.size, '\n', '\n'); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function buildBehaviorHarnessApi(): BehaviorHarnessApi { + return { + getActiveStorySession: () => { + const session = (harnessWindow.editor as any)?.presentationEditor + ?.getStorySessionManager?.() + ?.getActiveSession?.(); + return session?.locator ?? null; + }, + getActiveStoryText: () => { + const activeEditor = (harnessWindow.editor as any)?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor || activeEditor === harnessWindow.editor) return null; + return getEditorText(activeEditor); + }, + getBodyStoryText: () => getEditorText(harnessWindow.editor), + getCommentsSnapshot: () => { + const comments = (harnessWindow.superdoc as any)?.commentsStore?.commentsList ?? []; + return comments.map((comment: any) => { + const raw = typeof comment?.getValues === 'function' ? comment.getValues() : comment; + return cloneJson({ + commentId: raw?.commentId, + importedId: raw?.importedId, + trackedChange: raw?.trackedChange === true, + trackedChangeText: raw?.trackedChangeText ?? null, + trackedChangeType: raw?.trackedChangeType ?? null, + trackedChangeDisplayType: raw?.trackedChangeDisplayType ?? null, + trackedChangeStory: raw?.trackedChangeStory ?? null, + trackedChangeStoryKind: raw?.trackedChangeStoryKind ?? null, + trackedChangeStoryLabel: raw?.trackedChangeStoryLabel ?? '', + trackedChangeAnchorKey: raw?.trackedChangeAnchorKey ?? null, + deletedText: raw?.deletedText ?? null, + resolvedTime: raw?.resolvedTime ?? null, + }); + }); + }, + getEditorCommentPositions: () => { + const positions = (harnessWindow.superdoc as any)?.commentsStore?.editorCommentPositions ?? {}; + return cloneJson(positions); + }, + getActiveCommentId: () => { + const activeComment = (harnessWindow.superdoc as any)?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }, + }; +} function applyContentOverride(config: SuperDocConfig, input?: ContentOverrideInput) { if (!input?.contentOverride || !input?.overrideType) return; @@ -75,10 +159,15 @@ function init(file?: File, content?: ContentOverrideInput) { telemetry: { enabled: false }, onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; + if (comments === 'panel' && commentsPanel) { + commentsPanel.replaceChildren(); + superdoc.addCommentsList(commentsPanel); + } superdoc.activeEditor.on('create', (payload: unknown) => { if (!payload || typeof payload !== 'object' || !('editor' in payload)) return; harnessWindow.editor = (payload as { editor: unknown }).editor; }); + harnessWindow.behaviorHarness = buildBehaviorHarnessApi(); harnessWindow.superdocReady = true; }, }; @@ -105,6 +194,14 @@ function init(file?: File, content?: ContentOverrideInput) { // Comments if (comments === 'on' || comments === 'panel') { config.comments = { visible: true }; + if (comments === 'panel') { + config.modules = { + ...(config.modules ?? {}), + comments: { + ...((config.modules as Record | undefined)?.comments as Record | undefined), + }, + }; + } } else if (comments === 'readonly') { config.comments = { visible: true, readOnly: true }; } else if (comments === 'disabled') { @@ -136,6 +233,10 @@ function init(file?: File, content?: ContentOverrideInput) { } instance = new SuperDoc(config); + if (commentsPanel) { + commentsPanel.classList.toggle('is-visible', comments === 'panel'); + if (comments !== 'panel') commentsPanel.replaceChildren(); + } if (!showSelection) { const style = document.createElement('style'); diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index b6e79f1746..0c6f60ac42 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -1,6 +1,20 @@ +import { createRequire } from 'node:module'; import { defineConfig } from 'vite'; +import { getAliases } from '../../../packages/superdoc/vite.config.js'; + +const superdocRequire = createRequire(new URL('../../../packages/superdoc/package.json', import.meta.url)); +const vue = superdocRequire('@vitejs/plugin-vue').default; export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify('behavior-harness'), + __IS_DEBUG__: true, + }, + plugins: [vue()], + resolve: { + alias: getAliases(true), + conditions: ['source'], + }, server: { port: 9990, strictPort: true, diff --git a/tests/behavior/helpers/comments.ts b/tests/behavior/helpers/comments.ts index 0c8a9d7765..435c953bdc 100644 --- a/tests/behavior/helpers/comments.ts +++ b/tests/behavior/helpers/comments.ts @@ -8,7 +8,17 @@ import { listComments } from './document-api.js'; /** Locator for the active (clicked/focused) floating comment dialog. */ export const activeCommentDialog = (page: Page): Locator => - page.locator('.comment-placeholder .comments-dialog.is-active, .comment-placeholder .comments-dialog').last(); + page + .locator( + '.comment-placeholder .comments-dialog.is-active, #comments-panel .comments-dialog.is-active, .comment-placeholder .comments-dialog, #comments-panel .comments-dialog', + ) + .last(); + +const commentDialogLocator = (page: Page): Locator => + page.locator('.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'); + +const activeCommentDialogLocator = (page: Page): Locator => + page.locator('.comment-placeholder .comments-dialog.is-active, #comments-panel .comments-dialog.is-active'); const locatorTop = async (locator: Locator): Promise => { const target = locator.first(); @@ -83,17 +93,17 @@ export async function activateCommentDialog( await superdoc.waitForStable(); } - const activeDialog = superdoc.page.locator('.comment-placeholder .comments-dialog.is-active').last(); + const activeDialog = activeCommentDialogLocator(superdoc.page).last(); const dialog = activeCommentDialog(superdoc.page); const hasActiveDialog = (await activeDialog.count()) > 0; if (!hasActiveDialog) { // Fallback: click the floating dialog directly to trigger setFocus → is-active - const floatingDialog = superdoc.page.locator('.comment-placeholder .comments-dialog').last(); - await expect(floatingDialog).toBeVisible({ timeout: timeoutMs }); + const visibleDialog = commentDialogLocator(superdoc.page).last(); + await expect(visibleDialog).toBeVisible({ timeout: timeoutMs }); // Click near the top-left to avoid accidentally hitting interactive controls // such as the "N more replies" collapse/expand pill in the middle of the card. - await floatingDialog.click({ position: { x: 12, y: 12 } }); + await visibleDialog.click({ position: { x: 12, y: 12 }, force: true }); await superdoc.waitForStable(); const hasActiveDialogNow = (await activeDialog.count()) > 0; diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index 278f972128..6b24facabb 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -3,7 +3,11 @@ import type { TextAddress, SelectionTarget, MatchContext, + StoryLocator, TrackChangeType, + TrackChangesAcceptInput, + TrackChangesListInput, + TrackChangesRejectInput, CommentsListResult, TrackChangesListResult, TextMutationReceipt, @@ -320,10 +324,7 @@ export async function deleteText( }); } -export async function listTrackChanges( - page: Page, - query: { limit?: number; offset?: number; type?: TrackChangeType } = {}, -): Promise { +export async function listTrackChanges(page: Page, query: TrackChangesListInput = {}): Promise { return page.evaluate((input) => { const result = (window as any).editor.doc.trackChanges.list(input); if (Array.isArray(result?.changes)) { @@ -376,16 +377,24 @@ export async function listSeparate( return invokeListMutation(page, 'separate', input, options) as Promise; } -export async function acceptTrackChange(page: Page, input: { id: string }): Promise { +export async function acceptTrackChange(page: Page, input: TrackChangesAcceptInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'accept', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'accept', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } -export async function rejectTrackChange(page: Page, input: { id: string }): Promise { +export async function rejectTrackChange(page: Page, input: TrackChangesRejectInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'reject', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'reject', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts new file mode 100644 index 0000000000..4ee42fdd37 --- /dev/null +++ b/tests/behavior/helpers/story-fixtures.ts @@ -0,0 +1,368 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { StoryLocator } from '@superdoc/document-api'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../..'); +const editorFixtureRoot = path.resolve(repoRoot, 'packages/super-editor/src/editors/v1/tests/data'); +const generatedFixtureRoot = path.resolve(os.tmpdir(), `superdoc-behavior-story-fixtures-${process.pid}`); + +const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + +function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(targetPath: string, contents: string): void { + ensureDir(path.dirname(targetPath)); + fs.writeFileSync(targetPath, contents); +} + +function run(command: string, args: string[], cwd?: string): void { + execFileSync(command, args, { + cwd, + stdio: 'ignore', + }); +} + +function rebuildDocx(sourceName: string, targetPath: string, replacements: Record): void { + const sourcePath = path.resolve(editorFixtureRoot, sourceName); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superdoc-behavior-story-fixture-build-')); + try { + run('unzip', ['-qq', sourcePath, '-d', tempRoot]); + for (const [relativePath, contents] of Object.entries(replacements)) { + writeFile(path.resolve(tempRoot, relativePath), contents); + } + + ensureDir(path.dirname(targetPath)); + fs.rmSync(targetPath, { force: true }); + run('zip', ['-q', '-X', '-r', targetPath, '.'], tempRoot); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function ensureGeneratedFixture(filename: string, sourceName: string, replacements: Record): string { + const targetPath = path.resolve(generatedFixtureRoot, filename); + if (!fs.existsSync(targetPath)) { + rebuildDocx(sourceName, targetPath, replacements); + } + return targetPath; +} + +function documentXmlWithEndnotes(): string { + return ` + + + + Simple endnote text + + with longer content + + + + + + + + + + + + +`; +} + +function complexFootnoteMappingDocumentXml(): string { + return ` + + + + Complex mapped note + + and field-coded note + + . + + + + + + + + + + + +`; +} + +function multiPageHeaderFooterDocumentXml(): string { + const paragraphs = Array.from({ length: 48 }, (_, index) => { + const number = index + 1; + return ` + + Multipage footer coverage paragraph ${number}. This filler text keeps the same default header and footer story flowing onto later pages. + `; + }).join(''); + + return ` + + + ${paragraphs} + + + + + + + + + + +`; +} + +function complexFootnotesXml(): string { + return ` + + + + + + + + + + + + + If only one closing is contemplated, references to “Initial Closing” should be modified. + + + + + + + + STYLEREF 1 \\s + + 1.2(b) + + The Company may have tax reporting and/or withholding obligations in connection with the conversion of Convertible Securities into Company stock. + + + +`; +} + +function endnotesXml(): string { + return ` + + + + + + + + + + + + This is a simple endnote + + + + + + + A longer endnote + + + + And more endnote content + + + +`; +} + +function storyOnlyTrackedChangeDocumentXml(): string { + return ` + + + + Body review anchor + with footnote + + and endnote + + . + + + + + + + + + + + +`; +} + +function trackedHeaderXml(): string { + return ` + + + + Header base + + HDR_TC_ALPHA + + + +`; +} + +function trackedFooterXml(): string { + return ` + + + + Footer base + + FTR_TC_BRAVO + + + +`; +} + +function trackedFootnotesXml(): string { + return ` + + + + + + + + + + + + Footnote base + + FN_TC_CHARLIE + + + + +`; +} + +function trackedEndnotesXml(): string { + return ` + + + + + + + + + + + + Endnote base + + EN_TC_DELTA + + + + +`; +} + +export const H_F_NORMAL_DOC_PATH = path.resolve(editorFixtureRoot, 'h_f-normal.docx'); +export const H_F_NORMAL_ODD_EVEN_FIRSTPG_DOC_PATH = path.resolve(editorFixtureRoot, 'h_f-normal-odd-even-firstpg.docx'); +export const LONGER_HEADER_SIGN_AREA_DOC_PATH = path.resolve(editorFixtureRoot, 'longer-header-sign-area.docx'); +export const BASIC_FOOTNOTES_DOC_PATH = path.resolve(editorFixtureRoot, 'basic-footnotes.docx'); +export const COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH = ensureGeneratedFixture( + 'complex-imported-footnotes.docx', + 'h_f-normal.docx', + { + 'word/document.xml': complexFootnoteMappingDocumentXml(), + 'word/footnotes.xml': complexFootnotesXml(), + }, +); +export const BASIC_ENDNOTES_DOC_PATH = ensureGeneratedFixture('basic-endnotes.docx', 'h_f-normal.docx', { + 'word/document.xml': documentXmlWithEndnotes(), + 'word/endnotes.xml': endnotesXml(), +}); +export const MULTI_PAGE_HEADER_FOOTER_DOC_PATH = ensureGeneratedFixture( + 'multi-page-header-footer.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + }, +); +export const STORY_ONLY_TRACKED_CHANGES_DOC_PATH = ensureGeneratedFixture( + 'story-only-tracked-changes.docx', + 'h_f-normal.docx', + { + 'word/document.xml': storyOnlyTrackedChangeDocumentXml(), + 'word/header2.xml': trackedHeaderXml(), + 'word/footer2.xml': trackedFooterXml(), + 'word/footnotes.xml': trackedFootnotesXml(), + 'word/endnotes.xml': trackedEndnotesXml(), + }, +); + +export type StoryTrackedChangeFixtureEntry = { + surface: 'header' | 'footer' | 'footnote' | 'endnote'; + story: StoryLocator; + storyKind: 'headerFooter' | 'footnote' | 'endnote'; + storyLabel?: string; + storyLabelPrefix?: string; + excerpt: string; +}; + +export function readStoryOnlyTrackedChangesManifest(): StoryTrackedChangeFixtureEntry[] { + return [ + { + surface: 'header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId8' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'HDR_TC_ALPHA', + }, + { + surface: 'footer', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId10' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'FTR_TC_BRAVO', + }, + { + surface: 'footnote', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + excerpt: 'FN_TC_CHARLIE', + }, + { + surface: 'endnote', + story: { kind: 'story', storyType: 'endnote', noteId: '1' }, + storyKind: 'endnote', + storyLabel: 'Endnote 1', + excerpt: 'EN_TC_DELTA', + }, + ]; +} diff --git a/tests/behavior/helpers/story-replacements.ts b/tests/behavior/helpers/story-replacements.ts new file mode 100644 index 0000000000..3092c62563 --- /dev/null +++ b/tests/behavior/helpers/story-replacements.ts @@ -0,0 +1,55 @@ +import type { Page } from '@playwright/test'; + +export type StoryReplacementResult = { + success: boolean; + activeDocumentId: string | null; + deletedText: string; + insertedText: string; +}; + +export async function replaceFirstLettersInActiveStory( + page: Page, + insertedText: string, + letterCount = 2, +): Promise { + return page.evaluate( + ({ nextText, count }) => { + const presentationEditor = (window as any).editor?.presentationEditor; + const bodyEditor = (window as any).editor; + const activeEditor = presentationEditor?.getActiveEditor?.(); + + if (!activeEditor || activeEditor === bodyEditor) { + throw new Error('Expected an active story editor.'); + } + + const storyText = activeEditor.state.doc.textBetween(0, activeEditor.state.doc.content.size, '\n', '\n') ?? ''; + const firstWordMatch = storyText.match(/[A-Za-z]{2,}/); + if (!firstWordMatch || firstWordMatch.index == null) { + throw new Error(`No replaceable word found in active story text: "${storyText}"`); + } + + const replaceCount = Math.max(1, Math.min(count, firstWordMatch[0].length)); + const deletedText = storyText.slice(firstWordMatch.index, firstWordMatch.index + replaceCount); + const characterPositions: number[] = []; + + activeEditor.state.doc.descendants((node: any, pos: number) => { + if (!node?.isText || !node.text) return; + for (let offset = 0; offset < node.text.length; offset += 1) { + characterPositions.push(pos + offset); + } + }); + + const from = characterPositions[firstWordMatch.index]; + const to = characterPositions[firstWordMatch.index + replaceCount - 1] + 1; + const success = activeEditor.commands.insertTrackedChange({ from, to, text: nextText }); + + return { + success, + activeDocumentId: activeEditor.options.documentId ?? null, + deletedText, + insertedText: nextText, + }; + }, + { nextText: insertedText, count: letterCount }, + ); +} diff --git a/tests/behavior/helpers/story-surfaces.ts b/tests/behavior/helpers/story-surfaces.ts new file mode 100644 index 0000000000..141e0fc170 --- /dev/null +++ b/tests/behavior/helpers/story-surfaces.ts @@ -0,0 +1,273 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; + +type NoteStoryType = 'footnote' | 'endnote'; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextPointInternal( + locator: Locator, + { + searchText, + offsetWithinMatch = 0, + align = 'center', + }: { + searchText: string; + offsetWithinMatch?: number; + align?: 'center' | 'boundary'; + }, +) { + const point = await locator.evaluate( + ( + element, + params: { + searchText: string; + offsetWithinMatch: number; + align: 'center' | 'boundary'; + }, + ) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) return null; + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) return null; + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd( + currentNode, + params.align === 'center' ? Math.min(textLength, clampedOffset + params.searchText.length) : clampedOffset, + ); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) return null; + return { + x: + params.align === 'center' + ? fallbackRect.left + fallbackRect.width / 2 + : fallbackRect.left + Math.min(2, fallbackRect.width / 2), + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: params.align === 'center' ? rect.left + rect.width / 2 : rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch, align }, + ); + + expect(point).toBeTruthy(); + return point!; +} + +export async function getRenderedTextPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'center' }); +} + +export async function getTextBoundaryPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'boundary' }); +} + +export async function clickTextBoundary( + page: Page, + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + const point = await getTextBoundaryPoint(locator, searchText, offsetWithinMatch); + await page.mouse.click(point.x, point.y); + return point; +} + +export async function doubleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.dblclick(point.x, point.y); +} + +export async function tripleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.click(point.x, point.y, { clickCount: 3 }); +} + +export function getHeaderSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-header').nth(pageIndex); +} + +export function getFooterSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-footer').nth(pageIndex); +} + +export function getHeaderEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getFooterEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getNoteSurfaceLocator(page: Page, input: { storyType: NoteStoryType; noteId: string }): Locator { + const prefix = input.storyType === 'endnote' ? 'endnote' : 'footnote'; + return page + .locator( + `[data-block-id^="${prefix}-${input.noteId}-"], [data-block-id^="__sd_semantic_${prefix}-${input.noteId}-"]`, + ) + .first(); +} + +export function getActiveNoteEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"] .ProseMirror').first(); +} + +export async function getActiveStorySession(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStorySession === 'function') { + return harness.getActiveStorySession(); + } + + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +export async function waitForActiveStory( + page: Page, + expected: + | null + | Partial + | { + match: (story: StoryLocator | null) => boolean; + description: string; + }, +): Promise { + if (expected === null) { + await expect.poll(() => getActiveStorySession(page)).toBeNull(); + return; + } + + if ('match' in expected) { + await expect + .poll(async () => expected.match(await getActiveStorySession(page)), { message: expected.description }) + .toBe(true); + return; + } + + await expect.poll(() => getActiveStorySession(page)).toEqual(expect.objectContaining(expected)); +} + +export async function exitActiveStory(page: Page): Promise { + await page.evaluate(() => { + (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.exit?.(); + }); + await waitForActiveStory(page, null); +} + +export async function getActiveStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStoryText === 'function') { + return harness.getActiveStoryText(); + } + + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor) return null; + return activeEditor.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function getBodyStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getBodyStoryText === 'function') { + return harness.getBodyStoryText(); + } + + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function activateHeader(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const header = getHeaderSurfaceLocator(superdoc.page, pageIndex); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await header.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return header; +} + +export async function activateFooter(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const footer = getFooterSurfaceLocator(superdoc.page, pageIndex); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await footer.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return footer; +} + +export async function activateNote( + superdoc: SuperDocFixture, + input: { storyType: NoteStoryType; noteId: string; expectedText?: string }, +): Promise { + const note = getNoteSurfaceLocator(superdoc.page, input); + await note.scrollIntoViewIfNeeded(); + await note.waitFor({ state: 'visible', timeout: 15_000 }); + if (input.expectedText) { + await expect(note).toContainText(input.expectedText); + } + + const box = await note.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: input.storyType, + noteId: input.noteId, + }); + return note; +} + +export async function expectActiveStoryText(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +export async function expectActiveStoryTextToContain(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} diff --git a/tests/behavior/helpers/story-tracked-changes.ts b/tests/behavior/helpers/story-tracked-changes.ts new file mode 100644 index 0000000000..2513b16035 --- /dev/null +++ b/tests/behavior/helpers/story-tracked-changes.ts @@ -0,0 +1,244 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator, TrackChangeInfo, TrackChangeType } from '@superdoc/document-api'; +import { storyLocatorToKey } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; +import { listTrackChanges } from './document-api.js'; + +type TrackedChangeCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; + +function normalizeTrackedChangeExcerpt(change: TrackChangeInfo): string { + return String(change.excerpt ?? '').trim(); +} + +function mapTrackChangeTypeToCommentType(type: TrackChangeType | undefined): string | null { + if (!type) return null; + if (type === 'insert') return 'trackInsert'; + if (type === 'delete') return 'trackDelete'; + return 'trackFormat'; +} + +function sameStory(left: StoryLocator | null | undefined, right: StoryLocator | null | undefined): boolean { + if (!left || !right) return false; + return storyLocatorToKey(left) === storyLocatorToKey(right); +} + +function trackedChangeIdMatches(comment: TrackedChangeCommentSnapshot, id: string): boolean { + const canonicalId = String(id); + if (comment.commentId != null && String(comment.commentId) === canonicalId) return true; + if (comment.importedId != null && String(comment.importedId) === canonicalId) return true; + return comment.trackedChangeAnchorKey?.endsWith(`::${canonicalId}`) === true; +} + +export async function getCommentsSnapshot(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getCommentsSnapshot !== 'function') { + throw new Error('behaviorHarness.getCommentsSnapshot is unavailable.'); + } + + return harness.getCommentsSnapshot(); + }); +} + +export async function getEditorCommentPositions(page: Page): Promise> { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getEditorCommentPositions !== 'function') { + throw new Error('behaviorHarness.getEditorCommentPositions is unavailable.'); + } + + return harness.getEditorCommentPositions(); + }); +} + +export async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveCommentId === 'function') { + return harness.getActiveCommentId(); + } + + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +export async function findTrackedChange( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const result = await listTrackChanges(page, { in: input.story, ...(input.type ? { type: input.type } : {}) }); + const matched = result.changes.find((change) => { + if (input.id && change.id !== input.id) return false; + if (input.excerpt && !normalizeTrackedChangeExcerpt(change).includes(input.excerpt)) return false; + return true; + }); + + if (!matched) { + throw new Error( + `No tracked change found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export async function findTrackedChangeComment( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const commentType = mapTrackChangeTypeToCommentType(input.type); + const comments = await getCommentsSnapshot(page); + const matched = comments.find((comment) => { + if (comment.trackedChange !== true) return false; + if (!sameStory(comment.trackedChangeStory ?? null, input.story)) return false; + if (input.id && !trackedChangeIdMatches(comment, input.id)) return false; + if (commentType && comment.trackedChangeType !== commentType) return false; + if (input.excerpt) { + const haystack = [comment.trackedChangeText, comment.deletedText].filter(Boolean).join(' '); + if (!haystack.includes(input.excerpt)) return false; + } + return true; + }); + + if (!matched) { + throw new Error( + `No tracked-change comment found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export function getTrackedChangeDialogLocator( + page: Page, + input: { excerpt?: string | null; activeOnly?: boolean }, +): Locator { + const selector = input.activeOnly ? '.comments-dialog.is-active' : '.comments-dialog'; + if (input.excerpt) { + return page.locator(selector, { hasText: input.excerpt }).first(); + } + + return page.locator(selector).first(); +} + +async function setActiveTrackedChangeComment(page: Page, comment: TrackedChangeCommentSnapshot): Promise { + const preferredId = comment.commentId ?? comment.importedId; + if (preferredId == null) { + throw new Error('Tracked-change comment is missing commentId/importedId.'); + } + + const activeId = String(preferredId); + await page.evaluate((commentId) => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: commentId }); + }, activeId); + + await expect.poll(() => getActiveCommentId(page)).toBe(activeId); + return activeId; +} + +export async function activateTrackedChangeDialog( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ change: TrackChangeInfo; comment: TrackedChangeCommentSnapshot; dialog: Locator }> { + const change = await findTrackedChange(superdoc.page, input); + const comment = await findTrackedChangeComment(superdoc.page, { + story: input.story, + ...(input.id ? { id: change.id } : {}), + ...(input.excerpt ? { excerpt: input.excerpt } : {}), + ...(input.type ? { type: input.type } : {}), + }); + await setActiveTrackedChangeComment(superdoc.page, comment); + const dialog = getTrackedChangeDialogLocator(superdoc.page, { + excerpt: input.excerpt ?? change.excerpt ?? comment.trackedChangeText ?? comment.deletedText ?? null, + activeOnly: true, + }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.click({ position: { x: 12, y: 12 } }); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + return { change, comment, dialog }; +} + +export async function acceptTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').first().click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function rejectTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').nth(1).click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function getTrackedChangeAnchorPosition( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ key: string; bounds: Record; pageIndex: number | null } | null> { + const comment = await findTrackedChangeComment(page, input); + const key = comment.trackedChangeAnchorKey ?? comment.commentId ?? comment.importedId; + if (!key) return null; + + const positions = await getEditorCommentPositions(page); + const entry = positions[key]; + if (!entry?.bounds) return null; + + return { + key: String(key), + bounds: entry.bounds, + pageIndex: Number.isFinite(entry.pageIndex) ? Number(entry.pageIndex) : null, + }; +} diff --git a/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts new file mode 100644 index 0000000000..9656d1321a --- /dev/null +++ b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts @@ -0,0 +1,137 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +type TrackedChangePosition = { + key: string; + top: number; + left: number; + pageIndex: number | null; +}; + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +async function getBodyTrackedChangePosition(page: Page): Promise { + return page.evaluate(() => { + const positions = (window as any).superdoc?.commentsStore?.editorCommentPositions ?? {}; + for (const [key, entry] of Object.entries(positions)) { + if (!key.startsWith('tc::body::')) { + continue; + } + + const bounds = (entry as { bounds?: { top?: unknown; left?: unknown } }).bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + continue; + } + + const pageIndex = (entry as { pageIndex?: unknown }).pageIndex; + return { + key, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + } + + return null; + }); +} + +async function getTrackedChangePositionByKey(page: Page, key: string): Promise { + return page.evaluate((targetKey: string) => { + const entry = (window as any).superdoc?.commentsStore?.editorCommentPositions?.[targetKey]; + const bounds = entry?.bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + return null; + } + + const pageIndex = entry?.pageIndex; + return { + key: targetKey, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + }, key); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = getFootnoteLocator(superdoc.page, noteId); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('body tracked-change anchors stay in body space while editing a footnote in suggesting mode', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const lineBox = await bodyLine.boundingBox(); + expect(lineBox).toBeTruthy(); + + await superdoc.page.mouse.click(lineBox!.x + 12, lineBox!.y + lineBox!.height / 2); + await superdoc.page.keyboard.insertText('BODYFIX '); + await superdoc.waitForStable(); + + await expect.poll(() => getBodyTrackedChangePosition(superdoc.page)).not.toBeNull(); + const before = await getBodyTrackedChangePosition(superdoc.page); + expect(before).toBeTruthy(); + + const footnote = await activateFootnote(superdoc, '1'); + await expect(footnote).toContainText('This is a simple footnote'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText('NOTEFIX'); + await superdoc.waitForStable(); + + await expect.poll(() => getTrackedChangePositionByKey(superdoc.page, before!.key)).not.toBeNull(); + const after = await getTrackedChangePositionByKey(superdoc.page, before!.key); + expect(after).toBeTruthy(); + + expect(after!.pageIndex).toBe(before!.pageIndex); + expect(Math.abs(after!.top - before!.top)).toBeLessThanOrEqual(40); + expect(Math.abs(after!.left - before!.left)).toBeLessThanOrEqual(40); +}); diff --git a/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts b/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts new file mode 100644 index 0000000000..ed5d81043c --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts @@ -0,0 +1,229 @@ +import { expect, test, type Page } from '../../fixtures/superdoc.js'; +import { + H_F_NORMAL_ODD_EVEN_FIRSTPG_DOC_PATH as FIRST_PAGE_HEADER_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, + MULTI_PAGE_HEADER_FOOTER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + exitActiveStory, + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +async function insertTrackedTextInActiveStory(page: Page, insertedText: string): Promise { + await page.keyboard.press('End'); + await page.keyboard.insertText(insertedText); +} + +async function readTrackedChangeState(page: Page, insertedText: string) { + return page.evaluate((text) => { + const harness = (window as any).behaviorHarness; + const comments = harness?.getCommentsSnapshot?.() ?? []; + const positions = harness?.getEditorCommentPositions?.() ?? {}; + const floating = (window as any).superdoc?.commentsStore?.getFloatingComments ?? []; + + const match = comments.find( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ); + + const anchorKey = match?.trackedChangeAnchorKey ?? null; + const position = anchorKey ? (positions[anchorKey] ?? null) : null; + + return { + anchorKey, + hasComment: Boolean(match), + hasBounds: Boolean(position?.bounds), + floatingMatchCount: floating.filter( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeText === text && + comment?.trackedChangeStory?.storyType === 'headerFooterPart', + ).length, + storyRefId: match?.trackedChangeStory?.refId ?? null, + }; + }, insertedText); +} + +async function readFirstPageHeaderIdentity(page: Page) { + return page.evaluate(() => { + const presentationEditor = (window as any).editor?.presentationEditor; + const layoutSnapshot = presentationEditor?.getLayoutSnapshot?.(); + const page0 = layoutSnapshot?.layout?.pages?.[0] ?? null; + const expectedRefId = page0?.sectionRefs?.headerRefs?.first ?? null; + const fragment = document.querySelector('.superdoc-page-header [data-block-id]'); + const blockId = fragment?.getAttribute('data-block-id') ?? null; + const renderedRefId = typeof blockId === 'string' ? (blockId.match(/^hf-header-([^:-]+)-/)?.[1] ?? null) : null; + return { expectedRefId, renderedRefId }; + }); +} + +async function expectRenderedHeaderTrackChange( + page: Page, + insertedText: string, + storyRefId?: string | null, +): Promise { + const selector = storyRefId + ? `[data-story-key="hf:part:${storyRefId}"][data-track-change-id]` + : '[data-track-change-id]'; + + await expect( + getHeaderSurfaceLocator(page) + .locator(selector, { + hasText: insertedText, + }) + .first(), + ).toBeVisible(); +} + +async function expectRenderedFooterTrackChange(page: Page, insertedText: string, pageIndex = 0): Promise { + await expect( + getFooterSurfaceLocator(page, pageIndex) + .locator('[data-track-change-id]', { + hasText: insertedText, + }) + .first(), + ).toBeVisible(); +} + +test('header tracked changes get immediate bounds while editing and stay rendered after exit', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'HDRLIVE'; + await activateHeader(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + }), + ); + + await expect(getHeaderEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedHeaderTrackChange(superdoc.page, insertedText); +}); + +test('footer tracked changes get immediate bounds while editing and stay rendered after exit', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'FTRLIVE'; + await activateFooter(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + }), + ); + + await expect(getFooterEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedFooterTrackChange(superdoc.page, insertedText); +}); + +test('repeated footer tracked changes render on later pages without activating that footer', async ({ superdoc }) => { + await superdoc.loadDocument(MULTI_PAGE_HEADER_FOOTER_DOC_PATH); + await superdoc.waitForStable(); + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const insertedText = 'FTRMULTIPAGE'; + await activateFooter(superdoc, 0); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + storyRefId: expect.any(String), + }), + ); + + await expect(getFooterEditorLocator(superdoc.page)).toContainText(insertedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expectRenderedFooterTrackChange(superdoc.page, insertedText, 0); + + const secondPageFooter = getFooterSurfaceLocator(superdoc.page, 1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + await expectRenderedFooterTrackChange(superdoc.page, insertedText, 1); +}); + +test('first-page header tracked changes stay bound to the first-page story', async ({ superdoc }) => { + await superdoc.loadDocument(FIRST_PAGE_HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(() => readFirstPageHeaderIdentity(superdoc.page), { timeout: 10_000 }) + .toEqual({ + expectedRefId: expect.any(String), + renderedRefId: expect.any(String), + }); + + const initialIdentity = await readFirstPageHeaderIdentity(superdoc.page); + expect(initialIdentity.renderedRefId).toBe(initialIdentity.expectedRefId); + + const insertedText = 'FIRSTPGTC'; + await activateHeader(superdoc); + await insertTrackedTextInActiveStory(superdoc.page, insertedText); + await superdoc.waitForStable(); + + await expect + .poll(() => readTrackedChangeState(superdoc.page, insertedText), { timeout: 10_000 }) + .toEqual( + expect.objectContaining({ + hasComment: true, + hasBounds: true, + floatingMatchCount: 1, + storyRefId: initialIdentity.expectedRefId, + }), + ); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => window.scrollTo(0, 0)); + await expect(getHeaderSurfaceLocator(superdoc.page, 0)).toBeVisible(); + + await expectRenderedHeaderTrackChange(superdoc.page, insertedText, initialIdentity.expectedRefId); +}); diff --git a/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts new file mode 100644 index 0000000000..77413d5def --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { getActiveCommentId, findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; +import { activateFooter, activateHeader } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest().filter( + (entry) => entry.surface === 'header' || entry.surface === 'footer', +); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + showCaret: true, + showSelection: true, + }, +}); + +async function clearActiveComment(page: Page) { + await page.evaluate(() => { + (window as any).superdoc?.commentsStore?.$patch?.({ activeComment: null }); + }); +} + +async function clickRenderedTrackedChange(page: Page, locator: import('@playwright/test').Locator): Promise { + await locator.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await locator.boundingBox(); + if (!box) { + throw new Error('Tracked-change marker is not clickable: no bounding box available.'); + } + + await page.mouse.click( + box.x + Math.min(8, Math.max(box.width / 2, 1)), + box.y + Math.min(8, Math.max(box.height / 2, 1)), + ); +} + +for (const entry of STORY_CASES) { + test(`${entry.surface} tracked-change text activates its bubble and a body click clears it`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const surface = entry.surface === 'header' ? await activateHeader(superdoc) : await activateFooter(superdoc); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await clickRenderedTrackedChange( + superdoc.page, + surface.locator('[data-track-change-id]', { hasText: entry.excerpt }).first(), + ); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + }); +} diff --git a/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts new file mode 100644 index 0000000000..17cf0692e4 --- /dev/null +++ b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts @@ -0,0 +1,161 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH } from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'on', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, +}); + +function getInsertedTrackChangeLocator(container: Locator, insertedText: string): Locator { + return container + .locator('[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]') + .filter({ hasText: insertedText }) + .first(); +} + +async function getTextClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + return null; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getTrackChangeThreadIdAtPoint(page: Page, x: number, y: number): Promise { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const target = document.elementFromPoint(clientX, clientY); + const trackedChangeElement = target?.closest?.( + '[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]', + ); + + return ( + trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ?? + trackedChangeElement?.getAttribute('data-id')?.trim() ?? + null + ); + }, + { x, y }, + ); +} + +async function clearActiveComment(page: Page): Promise { + await page.evaluate(() => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: null }); + }); +} + +async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = superdoc.page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('clicking tracked-change text inside an active footnote activates its floating bubble', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'NOTEFIX'; + const footnote = await activateFootnote(superdoc, '1'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(insertedText); + await superdoc.waitForStable(); + + const insertedChange = getInsertedTrackChangeLocator(footnote, insertedText); + await expect(insertedChange).toBeVisible(); + + const clickPoint = await getTextClickPoint(footnote, insertedText); + const threadId = await getTrackChangeThreadIdAtPoint(superdoc.page, clickPoint.x, clickPoint.y); + expect(threadId).toBeTruthy(); + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await superdoc.page.mouse.click(clickPoint.x, clickPoint.y); + await superdoc.waitForStable(); + + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(threadId); +}); diff --git a/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts b/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts index 3c77ba17b1..5262e8dd2e 100644 --- a/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts +++ b/tests/behavior/tests/comments/reject-format-suggestion-selection.spec.ts @@ -4,6 +4,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); const TEXT = 'Agreement signed by both parties'; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; type EditorCommand = [name: string, ...args: unknown[]]; @@ -45,7 +46,7 @@ test('reject tracked mixed marks + textStyle on selection restores original form await superdoc.waitForStable(); await superdoc.assertTrackedChangeExists('format'); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text'), }); await expect(trackedDialog).toHaveCount(1); diff --git a/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts b/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts index 3347cee1c3..c75f881262 100644 --- a/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts +++ b/tests/behavior/tests/comments/sd-1960-word-replacement-no-comments.spec.ts @@ -6,6 +6,7 @@ import { assertDocumentApiReady, getDocumentText, listTrackChanges } from '../.. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve(__dirname, 'fixtures/sd-1960-word-replacement-no-comments.docx'); +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); @@ -101,7 +102,7 @@ async function loadImportedReplacement(page: Page, loadDocument: (filePath: stri const deleteSegment = deleteSegments.length > 0 ? combineSegments(deleteSegments) : null; const insertSegment = insertSegments.length > 0 ? combineSegments(insertSegments) : null; - const replacementDialog = page.locator('.comment-placeholder .comments-dialog', { + const replacementDialog = page.locator(TRACKED_CHANGE_DIALOGS, { has: page.locator('.change-type', { hasText: 'Replaced' }), }); diff --git a/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts new file mode 100644 index 0000000000..db8c0710f8 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { findTrackedChangeComment, getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +test('imported story-only tracked changes bootstrap sidebar threads for every non-body story', async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await assertDocumentApiReady(superdoc.page); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total).toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); + + const comments = await getCommentsSnapshot(superdoc.page); + expect(comments.filter((comment) => comment.trackedChange)).toHaveLength(STORY_CASES.length); + + for (const entry of STORY_CASES) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + expect(comment.trackedChangeStoryKind).toBe(entry.storyKind); + if (entry.storyLabel) { + expect(comment.trackedChangeStoryLabel).toBe(entry.storyLabel); + } else if (entry.storyLabelPrefix) { + expect(comment.trackedChangeStoryLabel ?? '').toContain(entry.storyLabelPrefix); + } + expect(comment.trackedChangeAnchorKey).toMatch(/^tc::/); + expect(comment.resolvedTime ?? null).toBeNull(); + } +}); diff --git a/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts b/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts new file mode 100644 index 0000000000..41cfb10f14 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts @@ -0,0 +1,173 @@ +import type { Locator, Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import { expect, test } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { replaceFirstLettersInActiveStory } from '../../helpers/story-replacements.js'; +import { + activateFooter, + activateHeader, + activateNote, + exitActiveStory, + getActiveNoteEditorLocator, + getActiveStorySession, + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, +} from '../../helpers/story-surfaces.js'; +import { findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; + +test.use({ + config: { + toolbar: 'full', + comments: 'on', + trackChanges: true, + documentMode: 'suggesting', + replacements: 'independent', + showCaret: true, + showSelection: true, + }, +}); + +type HeaderFooterStory = Extract; + +async function getActiveHeaderFooterStory(page: Page): Promise { + const story = await getActiveStorySession(page); + if (!story || story.kind !== 'story' || story.storyType !== 'headerFooterPart' || typeof story.refId !== 'string') { + throw new Error(`Expected an active header/footer story, received: ${JSON.stringify(story)}`); + } + return story; +} + +async function expectIndependentReplacementBubbles( + page: Page, + insertedText: string, + deletedText: string, +): Promise { + await expect( + page + .locator('.comment-placeholder .comments-dialog', { + hasText: `Deleted "${deletedText}"`, + }) + .first(), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page + .locator('.comment-placeholder .comments-dialog', { + hasText: `Added "${insertedText}"`, + }) + .first(), + ).toBeVisible({ timeout: 10_000 }); +} + +async function expectReplacementTrackedChangeComments( + page: Page, + story: StoryLocator, + insertedText: string, + deletedText: string, +): Promise { + await findTrackedChangeComment(page, { + story, + excerpt: insertedText, + type: 'insert', + }); + await findTrackedChangeComment(page, { + story, + excerpt: deletedText, + type: 'delete', + }); +} + +async function expectActiveStoryEditorText(editor: Locator, insertedText: string): Promise { + await expect(editor).toContainText(insertedText); +} + +test('header replacement shows a visible tracked-change bubble and stays rendered after exiting the header', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + const story = await getActiveHeaderFooterStory(superdoc.page); + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'HDRREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getHeaderEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect( + getHeaderSurfaceLocator(superdoc.page) + .locator(`[data-story-key="hf:part:${story.refId}"][data-track-change-id]`, { + hasText: result.insertedText, + }) + .first(), + ).toBeVisible(); +}); + +test('footer replacement shows a visible tracked-change bubble and stays rendered after exiting the footer', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + const story = await getActiveHeaderFooterStory(superdoc.page); + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'FTRREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getFooterEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); + + await exitActiveStory(superdoc.page); + await superdoc.waitForStable(); + + await expect( + getFooterSurfaceLocator(superdoc.page) + .locator(`[data-story-key="hf:part:${story.refId}"][data-track-change-id]`, { + hasText: result.insertedText, + }) + .first(), + ).toBeVisible(); +}); + +test('footnote replacement shows a visible tracked-change bubble inside the active note', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(BASIC_FOOTNOTES_DOC_PATH); + await superdoc.waitForStable(); + + await activateNote(superdoc, { + storyType: 'footnote', + noteId: '1', + expectedText: 'This is a simple footnote', + }); + const story: StoryLocator = { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }; + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'FNREP'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectReplacementTrackedChangeComments(superdoc.page, story, result.insertedText, result.deletedText); + await expectActiveStoryEditorText(getActiveNoteEditorLocator(superdoc.page), result.insertedText); + await expectIndependentReplacementBubbles(superdoc.page, result.insertedText, result.deletedText); +}); diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts new file mode 100644 index 0000000000..ee87ad538a --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts @@ -0,0 +1,150 @@ +import { test, expect, type Locator, type Page, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { acceptTrackedChangeFromSidebar, rejectTrackedChangeFromSidebar } from '../../helpers/story-tracked-changes.js'; +import { + activateFooter, + activateHeader, + expectActiveStoryTextToContain, + getFooterSurfaceLocator, + getHeaderSurfaceLocator, + getNoteSurfaceLocator, +} from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +function getSurfaceLocator(page: Page, surface: (typeof STORY_CASES)[number]['surface']): Locator { + if (surface === 'header') return getHeaderSurfaceLocator(page); + if (surface === 'footer') return getFooterSurfaceLocator(page); + return getNoteSurfaceLocator(page, { + storyType: surface, + noteId: '1', + }); +} + +async function expectSurfaceExcerpt( + superdoc: SuperDocFixture, + entry: (typeof STORY_CASES)[number], + visible: boolean, +): Promise { + const surface = getSurfaceLocator(superdoc.page, entry.surface); + await surface.scrollIntoViewIfNeeded(); + if (visible) { + if (entry.surface === 'header') { + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + if (entry.surface === 'footer') { + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + await expect(surface).toContainText(entry.excerpt); + return; + } + + await expect(surface).not.toContainText(entry.excerpt); +} + +for (const entry of STORY_CASES) { + test(`accept from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await acceptTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + }); + + test(`reject from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await rejectTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + }); +} diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts new file mode 100644 index 0000000000..b7dddef566 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { activateTrackedChangeDialog } from '../../helpers/story-tracked-changes.js'; +import { getActiveStoryText, getBodyStoryText, waitForActiveStory } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + }, +}); + +for (const entry of STORY_CASES) { + test(`sidebar tracked-change dialog navigates into the ${entry.surface} story`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const { dialog } = await activateTrackedChangeDialog(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await waitForActiveStory(superdoc.page, entry.story); + await expect(dialog).toContainText(entry.excerpt); + await expect.poll(() => getActiveStoryText(superdoc.page)).toContain(entry.excerpt); + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); + }); +} diff --git a/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts new file mode 100644 index 0000000000..bb7b83a1b2 --- /dev/null +++ b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { replaceFirstLettersInActiveStory } from '../../helpers/story-replacements.js'; +import { activateFooter, activateHeader, activateNote } from '../../helpers/story-surfaces.js'; + +const FOOTNOTE_DOC_PATH = BASIC_FOOTNOTES_DOC_PATH; + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + replacements: 'independent', + }, +}); + +async function expectIndependentStoryThreads(page: Page, deletedText: string, insertedText: string) { + await expect + .poll( + () => + page.evaluate( + ({ deleted, inserted }) => { + const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; + const trackedChangeComments = comments.filter((comment: any) => comment?.trackedChange); + const matchingComments = trackedChangeComments.filter( + (comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted, + ); + const floatingComments = (window as any).superdoc?.commentsStore?.getFloatingComments ?? []; + const hasFloatingMatch = floatingComments.some( + (comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted, + ); + const panelText = Array.from(document.querySelectorAll('#comments-panel .comments-dialog')) + .map((node) => node.textContent ?? '') + .filter(Boolean); + + return { + hasFloatingMatch, + matchingTypes: matchingComments.map((comment: any) => comment?.trackedChangeType).sort(), + matchingDeletedTexts: matchingComments.map((comment: any) => comment?.deletedText).filter(Boolean), + matchingInsertedTexts: matchingComments.map((comment: any) => comment?.trackedChangeText).filter(Boolean), + panelHasDeletedText: panelText.some((text) => text.includes(deleted)), + panelHasInsertedText: panelText.some((text) => text.includes(inserted)), + }; + }, + { deleted: deletedText, inserted: insertedText }, + ), + { timeout: 10_000 }, + ) + .toEqual( + expect.objectContaining({ + hasFloatingMatch: true, + matchingTypes: ['trackDelete', 'trackInsert'], + matchingDeletedTexts: [deletedText], + matchingInsertedTexts: [insertedText], + panelHasDeletedText: true, + panelHasInsertedText: true, + }), + ); +} + +async function expectActiveStoryReplacementMode(page: Page) { + await expect + .poll(() => + page.evaluate(() => (window as any).editor?.presentationEditor?.getActiveEditor?.()?.options?.trackedChanges), + ) + .toEqual( + expect.objectContaining({ + replacements: 'independent', + }), + ); +} + +test('header replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + expect(result.activeDocumentId).not.toBe( + (await superdoc.page.evaluate(() => (window as any).editor?.options?.documentId)) ?? null, + ); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); + +test('footer replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateFooter(superdoc); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); + +test('footnote replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateNote(superdoc, { storyType: 'footnote', noteId: '1' }); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryThreads(superdoc.page, result.deletedText, result.insertedText); +}); diff --git a/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts index 729eb48154..c94b7f9779 100644 --- a/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-independent-replacement.spec.ts @@ -28,6 +28,8 @@ type TrackedSegment = { type: TrackChangeType; }; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; + async function listTrackedSegments(page: Page): Promise { return page.evaluate(() => { const segments: Array<{ from: number; id: string; text: string; to: number; type: TrackChangeType }> = []; @@ -95,18 +97,18 @@ test.describe("trackedChanges.replacements='independent'", () => { await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(2); - const dialogs = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const dialogs = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text'), }); await expect(dialogs).toHaveCount(2); - await expect( - superdoc.page.locator('.comment-placeholder .comments-dialog .change-type', { hasText: 'Replaced' }), - ).toHaveCount(0); + await expect(superdoc.page.locator(`${TRACKED_CHANGE_DIALOGS} .change-type`, { hasText: 'Replaced' })).toHaveCount( + 0, + ); - const deletedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const deletedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-deleted', { hasText: 'ME' }), }); - const insertedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const insertedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: 'it' }), }); diff --git a/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts b/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts index fc7c64d928..e1d8bd138b 100644 --- a/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-partial-resolution.spec.ts @@ -8,6 +8,8 @@ test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true, sho const TRACK_TEXT = 'ABCDE'; const PARTIAL_TEXT = 'BC'; const ACCEPT_TRACKED_CHANGES_BUTTON = 'Accept tracked changes'; +const TRACKED_CHANGE_DIALOGS = '.comment-placeholder .comments-dialog, #comments-panel .comments-dialog'; +const TRACKED_CHANGE_TEXT = `${TRACKED_CHANGE_DIALOGS} .tracked-change-text`; test('toolbar accept partially resolves a tracked insertion and updates the bubble text', async ({ superdoc }) => { await insertTrackedChange(superdoc.page, { from: 1, to: 1, text: TRACK_TEXT }); @@ -17,7 +19,7 @@ test('toolbar accept partially resolves a tracked insertion and updates the bubb await superdoc.setTextSelection(selectionStart, selectionStart + PARTIAL_TEXT.length); await superdoc.waitForStable(); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: TRACK_TEXT }), }); await expect(trackedDialog).toBeVisible(); @@ -29,7 +31,7 @@ test('toolbar accept partially resolves a tracked insertion and updates the bubb await expect.poll(() => getDocumentText(superdoc.page)).toBe(TRACK_TEXT); await expect.poll(() => getMarkedText(superdoc.page, 'trackInsert')).toBe('ADE'); - await expect(superdoc.page.locator('.comment-placeholder .comments-dialog .tracked-change-text')).toBeVisible(); + await expect(superdoc.page.locator(TRACKED_CHANGE_TEXT)).toBeVisible(); await superdoc.snapshot('tracked-change-partial-insert-after-accept'); }); @@ -49,7 +51,7 @@ test('context menu reject partially resolves a tracked insertion and updates the await expect.poll(() => getSelectedText(superdoc.page)).toBe(PARTIAL_TEXT); - const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + const trackedDialog = superdoc.page.locator(TRACKED_CHANGE_DIALOGS, { has: superdoc.page.locator('.tracked-change-text.is-inserted', { hasText: TRACK_TEXT }), }); await expect(trackedDialog).toBeVisible(); @@ -66,7 +68,7 @@ test('context menu reject partially resolves a tracked insertion and updates the await expect.poll(() => getDocumentText(superdoc.page)).toBe('ADE'); await expect.poll(() => getMarkedText(superdoc.page, 'trackInsert')).toBe('ADE'); - await expect(superdoc.page.locator('.comment-placeholder .comments-dialog .tracked-change-text')).toBeVisible(); + await expect(superdoc.page.locator(TRACKED_CHANGE_TEXT)).toBeVisible(); await superdoc.snapshot('tracked-change-partial-insert-after-context-reject'); }); diff --git a/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts b/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts index 65f0612be2..04642dffb9 100644 --- a/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts +++ b/tests/behavior/tests/comments/undo-redo-tracked-change-sidebar.spec.ts @@ -13,10 +13,11 @@ type ChangeType = 'addition' | 'deletion' | 'replacement'; type Decision = 'accept' | 'reject'; const CHANGE_TYPES: ChangeType[] = ['addition', 'deletion', 'replacement']; +const trackedChangePanelSelector = '#comments-panel .comment-item .comments-dialog:not(.is-resolved)'; const getUnresolvedTrackedBubbleCount = async (page: Page): Promise => page - .locator('.superdoc__right-sidebar .comment-placeholder .comments-dialog:not(.is-resolved)', { + .locator(trackedChangePanelSelector, { has: page.locator('.tracked-change-text'), }) .count(); @@ -134,8 +135,8 @@ for (const changeType of CHANGE_TYPES) { } test('partial undo updates tracked-change bubble text to match the document (SD-2277)', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const bubbleText = sidebar.locator('.tracked-change-text.is-inserted'); + const commentsPanel = superdoc.page.locator('#comments-panel'); + const bubbleText = commentsPanel.locator('.tracked-change-text.is-inserted'); await assertDocumentApiReady(superdoc.page); await superdoc.setDocumentMode('suggesting'); @@ -148,6 +149,7 @@ test('partial undo updates tracked-change bubble text to match the document (SD- await superdoc.waitForStable(); await expectTrackedState(superdoc.page, { changes: 1, bubbles: 1 }); + await expect(commentsPanel).toContainText('hello world'); await expect(bubbleText).toContainText('hello world'); await superdoc.undo(); diff --git a/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts b/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts index e58a39eb5d..091f6449f6 100644 --- a/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts +++ b/tests/behavior/tests/comments/undo-tracked-insert-removes-sidebar.spec.ts @@ -12,9 +12,14 @@ async function historyRedo(superdoc: Pick) { return superdoc.page.evaluate(() => (window as any).editor.doc.history.redo()); } +const trackedChangePanel = (superdoc: SuperDocFixture) => superdoc.page.locator('#comments-panel'); + +const trackedChangePanelEntries = (superdoc: SuperDocFixture) => + trackedChangePanel(superdoc).locator('.tracked-change-text'); + test('undo tracked insertion removes suggestion bubble and sidebar entry', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const commentsPanel = trackedChangePanel(superdoc); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -23,15 +28,15 @@ test('undo tracked insertion removes suggestion bubble and sidebar entry', async await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect(sidebar).toBeVisible(); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect(commentsPanel).toBeVisible(); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); const result = await historyUndo(superdoc); await superdoc.waitForStable(); expect(result.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); - await expect(sidebarTrackedChange).toHaveCount(0); + await expect(panelTrackedChange).toHaveCount(0); await expect( superdoc.page.locator('.floating-comment > .comments-dialog', { has: superdoc.page.locator('.tracked-change-text'), @@ -40,8 +45,7 @@ test('undo tracked insertion removes suggestion bubble and sidebar entry', async }); test('redo restores tracked insertion bubble and sidebar entry after undo', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -50,7 +54,7 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); const undoResult = await historyUndo(superdoc); @@ -58,7 +62,7 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn expect(undoResult.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); - await expect(sidebarTrackedChange).toHaveCount(0); + await expect(panelTrackedChange).toHaveCount(0); await expect( superdoc.page.locator('.floating-comment > .comments-dialog', { has: superdoc.page.locator('.tracked-change-text'), @@ -70,13 +74,12 @@ test('redo restores tracked insertion bubble and sidebar entry after undo', asyn expect(redoResult.noop).toBe(false); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); }); test('redo is a no-op when the document did not change', async ({ superdoc }) => { - const sidebar = superdoc.page.locator('.superdoc__right-sidebar'); - const sidebarTrackedChange = sidebar.locator('.tracked-change-text'); + const panelTrackedChange = trackedChangePanelEntries(superdoc); await superdoc.setDocumentMode('suggesting'); await superdoc.waitForStable(); @@ -85,7 +88,7 @@ test('redo is a no-op when the document did not change', async ({ superdoc }) => await superdoc.waitForStable(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); const textBeforeRedo = await getDocumentText(superdoc.page); @@ -95,6 +98,6 @@ test('redo is a no-op when the document did not change', async ({ superdoc }) => expect(result.noop).toBe(true); await expect(await activateCommentDialog(superdoc, 'Tracked insertion')).toBeVisible(); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); - await expect.poll(async () => sidebarTrackedChange.count()).toBeGreaterThan(0); + await expect.poll(async () => panelTrackedChange.count()).toBeGreaterThan(0); expect(await getDocumentText(superdoc.page)).toBe(textBeforeRedo); }); diff --git a/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts new file mode 100644 index 0000000000..3f6bd073fa --- /dev/null +++ b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_ENDNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateNote, + expectActiveStoryTextToContain, + getBodyStoryText, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + showCaret: true, + showSelection: true, + }, +}); + +test('double-click rendered endnote to edit it through the presentation surface', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(BASIC_ENDNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const endnote = await activateNote(superdoc, { + storyType: 'endnote', + noteId: '1', + expectedText: 'This is a simple endnote', + }); + + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'endnote', + noteId: '1', + }); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + await superdoc.waitForStable(); + await expect(endnote).toContainText('This is a simple endnote edited'); + + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'simple endnote edite'); + await expect(endnote).toContainText('This is a simple endnote edite'); + + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); +}); diff --git a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts new file mode 100644 index 0000000000..60b16978be --- /dev/null +++ b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts @@ -0,0 +1,868 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH as DOC_PATH, + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test.use({ config: { showCaret: true, showSelection: true } }); + +type FootnoteBehaviorHarness = { + page: Page; + loadDocument: (docPath: string) => Promise; + waitForStable: (ms?: number) => Promise; +}; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) { + return null; + } + return { + x: fallbackRect.left + 2, + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: rect.left + 1, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getBoundaryClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + x: rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getWordRect(locator: Locator, searchText: string) { + return locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, remaining); + range.setEnd(currentNode, endOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); +} + +async function getSelectionOverlayRect(page: Page) { + const selectionRect = page.locator('.presentation-editor__selection-rect').first(); + await expect(selectionRect).toBeVisible(); + const box = await selectionRect.boundingBox(); + expect(box).toBeTruthy(); + return box!; +} + +async function expectVisibleCaret(page: Page) { + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + const box = await caret.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.y).toBeGreaterThanOrEqual(0); + return box!; +} + +async function expectCaretAlignedToVisibleBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, + tolerancePx = 3, +) { + const boundaryPoint = await getBoundaryClickPoint(footnote, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + const caretBox = await expectVisibleCaret(page); + expect(Math.abs(caretBox.x - boundaryPoint!.x)).toBeLessThanOrEqual(tolerancePx); +} + +async function getActiveSelectionPosition(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.selection?.from ?? null; + }); +} + +async function getHitTestPosition(page: Page, x: number, y: number) { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const hit = (window as any).editor?.presentationEditor?.hitTest?.(clientX, clientY); + return hit?.pos ?? null; + }, + { x, y }, + ); +} + +async function getActiveStorySession(page: Page) { + return page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +async function expectInsertedMarkerBeforeEdited(footnote: Locator) { + const text = await footnote.textContent(); + expect(text).toBeTruthy(); + + const insertedIndex = text!.indexOf('X'); + const editedIndex = text!.indexOf('edited'); + + expect(insertedIndex).toBeGreaterThanOrEqual(0); + expect(editedIndex).toBeGreaterThan(insertedIndex); +} + +async function getActiveStoryText(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +async function getBodyStoryText(page: Page) { + return page.evaluate(() => { + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +function getBodyFragmentLocator(page: Page, text: string): Locator { + return page + .locator('[data-block-id]:not([data-block-id^="footnote-"]):not([data-block-id^="__sd_semantic_footnote-"])') + .filter({ hasText: text }) + .first(); +} + +async function insertTextIntoBodyAtVisibleBoundary( + page: Page, + bodySurface: Locator, + searchText: string, + offsetWithinMatch: number, + insertedText: string, +): Promise { + const boundaryPoint = await getBoundaryClickPoint(bodySurface, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + const hitPosition = await getHitTestPosition(page, boundaryPoint!.x, boundaryPoint!.y); + expect(hitPosition).not.toBeNull(); + + await page.evaluate( + ({ position, text }) => { + const editor = (window as any).editor; + if (!editor?.view) { + throw new Error('Body editor view is unavailable.'); + } + + editor.view.dispatch(editor.state.tr.insertText(text, position, position)); + }, + { position: hitPosition, text: insertedText }, + ); + + return hitPosition!; +} + +async function loadAndActivateFootnote( + superdoc: FootnoteBehaviorHarness, + noteId: string, + expectedText: string, + docPath = DOC_PATH, +): Promise { + await superdoc.loadDocument(docPath); + await superdoc.waitForStable(); + + const footnote = getFootnoteLocator(superdoc.page, noteId); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText(expectedText); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId, + }); + + return footnote; +} + +async function clickFootnoteBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise<{ x: number; y: number }> { + const boundaryPoint = await getBoundaryClickPoint(footnote, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + await page.mouse.click(boundaryPoint!.x, boundaryPoint!.y); + return boundaryPoint!; +} + +async function expectCaretAtClickBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise { + const boundaryPoint = await clickFootnoteBoundary(page, footnote, searchText, offsetWithinMatch); + await expect(page.locator('.presentation-editor__selection-caret').first()).toBeVisible(); + await expect.poll(() => getActiveSelectionPosition(page)).not.toBeNull(); + + const selectionAfterClick = await getActiveSelectionPosition(page); + const hitAfterClick = await getHitTestPosition(page, boundaryPoint.x, boundaryPoint.y); + const domSelectionAfterClick = await getActiveDomSelection(page); + + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick).toBe(hitAfterClick); + expect(domSelectionAfterClick?.anchorPos).toBe(selectionAfterClick); + + return selectionAfterClick!; +} + +async function expectStoryText(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +async function expectStoryTextToContain(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} + +async function getActiveDomSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const view = activeEditor?.view; + const selection = view?.dom?.ownerDocument?.getSelection?.(); + if (!view || !selection || !selection.anchorNode) { + return null; + } + + const anchorInside = view.dom.contains(selection.anchorNode); + const focusInside = selection.focusNode ? view.dom.contains(selection.focusNode) : false; + + let anchorPos = null; + let focusPos = null; + try { + if (anchorInside) { + anchorPos = view.posAtDOM(selection.anchorNode, selection.anchorOffset, -1); + } + if (focusInside && selection.focusNode) { + focusPos = view.posAtDOM(selection.focusNode, selection.focusOffset, -1); + } + } catch {} + + return { + anchorInside, + focusInside, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + anchorPos, + focusPos, + text: selection.toString(), + }; + }); +} + +test('double-click rendered footnote to edit it through the presentation surface', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + const storyHost = superdoc.page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"]').first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + if (browserName === 'firefox') { + await superdoc.page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + activeEditor?.commands?.insertContent?.(' edited'); + }); + } else { + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + } + await superdoc.waitForStable(); + if (browserName !== 'firefox') { + await expect(footnote).toContainText('This is a simple footnote edited', { timeout: 10_000 }); + const selectionAtEnd = await getActiveSelectionPosition(superdoc.page); + expect(selectionAtEnd).not.toBeNull(); + + const startPoint = await getTextClickPoint(footnote, 'This', 0); + expect(startPoint).toBeTruthy(); + await superdoc.page.mouse.click(startPoint!.x, startPoint!.y); + await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + const selectionAfterClick = await getActiveSelectionPosition(superdoc.page); + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick!).toBeLessThan(selectionAtEnd!); + + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectInsertedMarkerBeforeEdited(footnote); + } + + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + await expectInsertedMarkerBeforeEdited(footnote); +}); + +test('clicking inside footnote text inserts at the exact requested character boundary', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'footanote'); + await expect(footnote).toContainText('footanote'); +}); + +test('footnote caret placement supports inserts at the note start, inside a word, and at the note end', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'This', 0); + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footnote'); + await expect(footnote).toContainText('XThis is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote'); + await expect(footnote).toContainText('XThis is a simple footanote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footanote', 'footanote'.length); + await superdoc.page.keyboard.insertText('!'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote!'); + await expect(footnote).toContainText('XThis is a simple footanote!'); +}); + +test('footnote caret placement stays correct on later note lines above table content', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '2', 'A longer one with a table'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'with', 1); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'A longer one waith a table'); + await expectStoryTextToContain(superdoc.page, 'And multi-paragraph content'); + await expect(footnote).toContainText('A longer one waith a table'); +}); + +test('footnote backspace deletes the character immediately before the visible caret', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a siple footnote'); + await expect(footnote).toContainText('This is a siple footnote'); +}); + +test('double-click word selection stays horizontally aligned with rendered footnote text', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + const simplePoint = await getTextClickPoint(footnote, 'simple', 2); + const simpleRect = await getWordRect(footnote, 'simple'); + expect(simplePoint).toBeTruthy(); + expect(simpleRect).toBeTruthy(); + + await superdoc.page.mouse.dblclick(simplePoint!.x, simplePoint!.y); + await superdoc.waitForStable(); + + const domSelectionAfterClick = await getActiveDomSelection(superdoc.page); + expect(domSelectionAfterClick?.text).toBe('simple'); + + const overlayRect = await getSelectionOverlayRect(superdoc.page); + expect(Math.abs(overlayRect.x - simpleRect!.left)).toBeLessThanOrEqual(2.5); + expect(Math.abs(overlayRect.width - simpleRect!.width)).toBeLessThanOrEqual(3); +}); + +test.describe('suggesting mode routing', () => { + test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + }, + }); + + test('typing stays in the active footnote even if body focus is restored underneath the session', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + + const originalBodyText = await getBodyStoryText(superdoc.page); + expect(originalBodyText).toContain('Simple text'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.view?.focus?.(); + }); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const bodyEditor = (window as any).editor; + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + + return { + bodyHasFocus: bodyEditor?.view?.hasFocus?.() ?? false, + activeIsBody: activeEditor === bodyEditor, + sessionLocator: session?.locator ?? null, + }; + }), + ) + .toEqual({ + bodyHasFocus: true, + activeIsBody: false, + sessionLocator: { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }, + }); + + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(originalBodyText); + }); + + test('tracked inserts keep the active footnote caret aligned with the rendered insertion point', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'references', 3); + + let insertedText = ''; + for (const nextChar of ['X', 'Y', 'Z']) { + insertedText += nextChar; + await superdoc.page.keyboard.insertText(nextChar); + await superdoc.waitForStable(300); + + await expectStoryTextToContain(superdoc.page, `ref${insertedText}erences`); + await expect(footnote).toContainText(`ref${insertedText}erences`); + await expectCaretAlignedToVisibleBoundary( + superdoc.page, + footnote, + `ref${insertedText}erences`, + 3 + insertedText.length, + ); + } + }); + + test('word selection overlay stays aligned after a tracked insert splits the note text', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'references', 3); + await superdoc.page.keyboard.insertText('XYZ'); + await superdoc.waitForStable(300); + await expect(footnote).toContainText('refXYZerences'); + + const selectedWord = 'Closing'; + const selectedWordPoint = await getTextClickPoint(footnote, selectedWord, 2); + const selectedWordRect = await getWordRect(footnote, selectedWord); + expect(selectedWordPoint).toBeTruthy(); + expect(selectedWordRect).toBeTruthy(); + + await superdoc.page.mouse.dblclick(selectedWordPoint!.x, selectedWordPoint!.y); + await superdoc.waitForStable(); + + const domSelectionAfterClick = await getActiveDomSelection(superdoc.page); + expect(domSelectionAfterClick?.text).toBe(selectedWord); + + const overlayRect = await getSelectionOverlayRect(superdoc.page); + expect(Math.abs(overlayRect.x - selectedWordRect!.left)).toBeLessThanOrEqual(2.5); + expect(Math.abs(overlayRect.width - selectedWordRect!.width)).toBeLessThanOrEqual(3); + }); + + test('footnote clicks stay accurately mapped after returning to the body in suggesting mode', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(1000); + + const footnote = getFootnoteLocator(superdoc.page, '1'); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText('This is a simple footnote'); + + const noteBox = await footnote.boundingBox(); + expect(noteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(noteBox!.x + noteBox!.width / 2, noteBox!.y + noteBox!.height / 2); + await superdoc.waitForStable(300); + + const initialBoundary = await getBoundaryClickPoint(footnote, 'simple', 3); + expect(initialBoundary).toBeTruthy(); + await superdoc.page.mouse.click(initialBoundary!.x, initialBoundary!.y); + await superdoc.waitForStable(200); + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(300); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + const bodyBox = await bodySurface.boundingBox(); + expect(bodyBox).toBeTruthy(); + await superdoc.page.mouse.click(bodyBox!.x + bodyBox!.width / 2, bodyBox!.y + bodyBox!.height / 2); + await superdoc.waitForStable(300); + + const bodyTextAfterReturn = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterReturn).toContain('Simple text'); + + // First click re-enters the note. + const reentryActivationBoundary = await getBoundaryClickPoint(footnote, 'footnote', 2); + expect(reentryActivationBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryActivationBoundary!.x, reentryActivationBoundary!.y); + await superdoc.waitForStable(300); + + // Second click inside the now-active note must still map to the exact + // requested boundary after the tracked insert. + const reentryBoundary = await getBoundaryClickPoint(footnote, 'simZple', 4); + expect(reentryBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryBoundary!.x, reentryBoundary!.y); + await superdoc.waitForStable(300); + + const reentryState = await superdoc.page.evaluate(({ x, y }) => { + const editor = (window as any).editor; + const presentation = editor?.presentationEditor; + const activeEditor = presentation?.getActiveEditor?.(); + const session = presentation?.getStorySessionManager?.()?.getActiveSession?.(); + const view = activeEditor?.view; + const selection = activeEditor?.state?.selection?.from ?? null; + const hit = presentation?.hitTest?.(x, y)?.pos ?? null; + const domSelection = view?.dom?.ownerDocument?.getSelection?.(); + + let anchorPos = null; + try { + if (view && domSelection?.anchorNode && view.dom.contains(domSelection.anchorNode)) { + anchorPos = view.posAtDOM(domSelection.anchorNode, domSelection.anchorOffset, -1); + } + } catch {} + + return { + session: session?.locator ?? null, + selection, + hit, + anchorPos, + }; + }, reentryBoundary!); + + expect(reentryState.session).toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + expect(reentryState.selection).toBe(reentryState.hit); + expect(reentryState.anchorPos).toBe(reentryState.selection); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterReturn); + }); + + test('body edits do not corrupt footnote click mapping after a footnote edit', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 1); + await superdoc.page.keyboard.insertText('X0'); + await superdoc.waitForStable(300); + await expect(footnote).toContainText('fX0ootnote'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.exit?.(); + }); + await superdoc.waitForStable(300); + await expect.poll(() => getActiveStorySession(superdoc.page)).toBeNull(); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + await insertTextIntoBodyAtVisibleBoundary(superdoc.page, bodySurface, 'footnotes', 1, 'X0'); + await superdoc.waitForStable(300); + + const bodyTextAfterBodyEdit = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterBodyEdit).toContain('fX0ootnotes'); + await expect(footnote).toContainText('fX0ootnote'); + + await clickFootnoteBoundary(superdoc.page, footnote, 'fX0ootnote', 4); + await superdoc.waitForStable(300); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'fX0ootnote', 6); + + await superdoc.page.keyboard.insertText('Z'); + await superdoc.waitForStable(300); + + await expect(footnote).toContainText('fX0ootZnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterBodyEdit); + }); + + test('complex imported footnotes stay aligned when the note starts with hidden separator content', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + let footnote = await loadAndActivateFootnote( + superdoc, + '1', + 'If only one closing is contemplated', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'contemplated', 1); + await superdoc.page.keyboard.insertText('x'); + await superdoc.waitForStable(300); + await expectStoryTextToContain(superdoc.page, 'cxontemplated'); + await expect(footnote).toContainText('cxontemplated'); + }); + + test('complex imported footnotes stay aligned when the note contains hidden field-code passthrough nodes', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + let footnote = await loadAndActivateFootnote( + superdoc, + '2', + 'The Company may have tax reporting', + COMPLEX_IMPORTED_FOOTNOTES_DOC_PATH, + ); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'reporting', 1); + await superdoc.page.keyboard.insertText('x'); + await superdoc.waitForStable(300); + await expectStoryTextToContain(superdoc.page, 'rxeporting'); + await expect(footnote).toContainText('rxeporting'); + }); +}); diff --git a/tests/behavior/tests/headers/double-click-edit-header.spec.ts b/tests/behavior/tests/headers/double-click-edit-header.spec.ts index 6a2808dd6a..edd6ca2652 100644 --- a/tests/behavior/tests/headers/double-click-edit-header.spec.ts +++ b/tests/behavior/tests/headers/double-click-edit-header.spec.ts @@ -1,89 +1,279 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, expect } from '../../fixtures/superdoc.js'; +import { test, expect, type Locator, type Page, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + getRenderedTextPoint, + clickTextBoundary, + expectActiveStoryTextToContain, + getActiveStorySession, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); +test.use({ config: { showCaret: true, showSelection: true } }); -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +async function measureRenderedWordRect(locator: Locator, searchText: string) { + const rect = await locator.evaluate((element, expectedText) => { + const doc = element.ownerDocument; + if (!doc) { + return null; + } -test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { - await superdoc.loadDocument(DOC_PATH); - await superdoc.waitForStable(); + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + const matchIndex = text.indexOf(expectedText); + if (matchIndex >= 0) { + const range = doc.createRange(); + range.setStart(node, matchIndex); + range.setEnd(node, matchIndex + expectedText.length); + const bounds = range.getBoundingClientRect(); + const containerBounds = element.getBoundingClientRect(); + return { + left: bounds.left - containerBounds.left, + top: bounds.top - containerBounds.top, + width: bounds.width, + height: bounds.height, + }; + } + node = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(rect).toBeTruthy(); + return rect!; +} + +async function expectRenderedSurfaceStable( + page: Page, + surface: Locator, + word: string, + activate: () => Promise, +): Promise { + const before = await measureRenderedWordRect(surface, word); + await activate(); + + await expect(page.locator('.superdoc-header-editor-host, .superdoc-footer-editor-host')).toHaveCount(0); + + const after = await measureRenderedWordRect(surface, word); + expect(Math.abs(after.left - before.left)).toBeLessThan(1); + expect(Math.abs(after.top - before.top)).toBeLessThan(1); + expect(Math.abs(after.width - before.width)).toBeLessThan(1); + expect(Math.abs(after.height - before.height)).toBeLessThan(1); +} + +async function expectVisibleCaretNearClickedBoundary( + page: Page, + surface: Locator, + word: string, + offsetWithinWord = 0, +): Promise { + const point = await clickTextBoundary(page, surface, word, offsetWithinWord); + await page.waitForTimeout(100); - // Header should be visible - const header = superdoc.page.locator('.superdoc-page-header').first(); - await header.waitFor({ state: 'visible', timeout: 15_000 }); + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + await page.waitForTimeout(950); + const opacity = await caret.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity || '0')); + expect(opacity).toBeGreaterThan(0.2); - // Double-click at the header's coordinates (header has pointer-events:none, - // so we must use raw mouse to reach the viewport host's dblclick handler) - const box = await header.boundingBox(); + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - point.x)).toBeLessThanOrEqual(8); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - point.y)).toBeLessThanOrEqual(3); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function expectVisibleCaretAfterActivationDoubleClick(page: Page, surface: Locator, word: string): Promise { + const point = await getRenderedTextPoint(surface, word); + await page.mouse.dblclick(point.x, point.y); + await waitForActiveStory(page, { storyType: 'headerFooterPart' }); + await page.waitForTimeout(150); + + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - point.x)).toBeLessThanOrEqual(8); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - point.y)).toBeLessThanOrEqual(3); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function expectBlankDocumentHeaderCaretAfterActivation(superdoc: SuperDocFixture): Promise { + const pageSurface = superdoc.page.locator('.superdoc-page').first(); + await pageSurface.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await pageSurface.boundingBox(); expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + + const activationPoint = { + x: box!.x + 120, + y: box!.y + 60, + }; + + await superdoc.page.mouse.dblclick(activationPoint.x, activationPoint.y); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the header - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + const caret = superdoc.page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); + const caretMetrics = await caret.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + height: rect.height, + }; + }); + + expect(Math.abs(caretMetrics.left - activationPoint.x)).toBeLessThanOrEqual(80); + expect(Math.abs(caretMetrics.top + caretMetrics.height / 2 - activationPoint.y)).toBeLessThanOrEqual(40); + expect(caretMetrics.height).toBeGreaterThan(8); +} + +async function exitToBody(superdoc: SuperDocFixture) { + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + if (await getActiveStorySession(superdoc.page)) { + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + } + + await waitForActiveStory(superdoc.page, null); +} + +test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await activateHeader(superdoc); + + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + // Editing runs through the hidden-host PM while the visible header remains painted. await superdoc.page.keyboard.press('End'); - // Use insertText instead of type() to avoid character-by-character key events - // which may trigger PM shortcuts await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await exitToBody(superdoc); - // Press Escape to exit header edit mode - await superdoc.page.keyboard.press('Escape'); + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); + + await superdoc.snapshot('header-edited'); +}); + +test('activating a header keeps the painted header stable and does not show a visible editor host', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After exiting, the static header is re-rendered with the edited content - await expect(header).toContainText('Edited'); + const headerSurface = superdoc.page.locator('.superdoc-page-header').first(); + await expectRenderedSurfaceStable(superdoc.page, headerSurface, 'Generic', async () => { + await activateHeader(superdoc); + }); +}); - await superdoc.snapshot('header-edited'); +test('header editing shows a visible caret at the clicked boundary', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const headerSurface = await activateHeader(superdoc); + await expectVisibleCaretNearClickedBoundary(superdoc.page, headerSurface, 'Generic', 3); }); -test('double-click footer to enter edit mode, type, and exit', async ({ superdoc }) => { +test('double-clicking into an inactive header places the initial caret at the clicked word', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // Footer should be visible — scroll into view first since it's at page bottom - const footer = superdoc.page.locator('.superdoc-page-footer').first(); - await footer.scrollIntoViewIfNeeded(); - await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const headerSurface = superdoc.page.locator('.superdoc-page-header').first(); + await expectVisibleCaretAfterActivationDoubleClick(superdoc.page, headerSurface, 'Generic'); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); +}); - // Double-click at the footer's coordinates - const box = await footer.boundingBox(); - expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); +test('double-click footer to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the footer - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + await activateFooter(superdoc); + + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); await superdoc.page.keyboard.press('End'); await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await exitToBody(superdoc); - // Press Escape to exit footer edit mode - await superdoc.page.keyboard.press('Escape'); + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); + + await superdoc.snapshot('footer-edited'); +}); + +test('activating a footer keeps the painted footer stable and does not show a visible editor host', async ({ + superdoc, +}) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // After exiting, the static footer is re-rendered with the edited content - await expect(footer).toContainText('Edited'); + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await expectRenderedSurfaceStable(superdoc.page, footerSurface, 'Footer', async () => { + await activateFooter(superdoc); + }); +}); + +test('footer editing shows a visible caret at the clicked boundary', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); - await superdoc.snapshot('footer-edited'); + const footerSurface = await activateFooter(superdoc); + await expectVisibleCaretNearClickedBoundary(superdoc.page, footerSurface, 'Footer', 2); +}); + +test('double-clicking into an inactive footer places the initial caret at the clicked word', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const footerSurface = superdoc.page.locator('.superdoc-page-footer').first(); + await footerSurface.scrollIntoViewIfNeeded(); + await expectVisibleCaretAfterActivationDoubleClick(superdoc.page, footerSurface, 'Footer'); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); +}); + +test('blank document header activation shows a visible caret', async ({ superdoc }) => { + await superdoc.waitForStable(); + await expectBlankDocumentHeaderCaretAfterActivation(superdoc); }); diff --git a/tests/behavior/tests/headers/header-footer-line-height.spec.ts b/tests/behavior/tests/headers/header-footer-line-height.spec.ts index 89bbb5a138..f6061a8387 100644 --- a/tests/behavior/tests/headers/header-footer-line-height.spec.ts +++ b/tests/behavior/tests/headers/header-footer-line-height.spec.ts @@ -1,12 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +test.use({ config: { showCaret: true, showSelection: true } }); test('header editor uses line-height 1, not the default 1.2', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -21,15 +16,10 @@ test('header editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror element inside the header editor should have lineHeight: 1 - // (matching OOXML Header style w:line="240" w:lineRule="auto" = 240/240 = 1.0) - const pm = editorHost.locator('.ProseMirror'); - await expect(pm).toHaveCSS('line-height', /^\d+(\.\d+)?px$/); - - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -47,11 +37,10 @@ test('footer editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - const pm = editorHost.locator('.ProseMirror'); - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -68,12 +57,14 @@ test('body editor still uses default line-height 1.2', async ({ superdoc }) => { expect(lineHeight).toBe('1.2'); }); -test('header content is not clipped when entering edit mode', async ({ superdoc }) => { +test('header content remains visible while hidden-host editing is active', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); const header = superdoc.page.locator('.superdoc-page-header').first(); await header.waitFor({ state: 'visible', timeout: 15_000 }); + const beforeBox = await header.boundingBox(); + expect(beforeBox).toBeTruthy(); // Double-click to enter header edit mode const box = await header.boundingBox(); @@ -81,20 +72,13 @@ test('header content is not clipped when entering edit mode', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror content should not overflow the editor host container - const overflow = await editorHost.evaluate((host) => { - const pm = host.querySelector('.ProseMirror') as HTMLElement; - if (!pm) return { error: 'no PM' }; - return { - pmScrollHeight: pm.scrollHeight, - pmOffsetHeight: pm.offsetHeight, - hostHeight: host.offsetHeight, - isOverflowing: pm.scrollHeight > host.offsetHeight, - }; - }); - expect(overflow).not.toHaveProperty('error'); - expect(overflow.isOverflowing).toBe(false); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + const afterBox = await header.boundingBox(); + expect(afterBox).toBeTruthy(); + expect(afterBox!.height).toBeGreaterThan(0); + expect(Math.abs((afterBox?.height ?? 0) - (beforeBox?.height ?? 0))).toBeLessThan(1); }); diff --git a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts index 49ea916249..d46590be68 100644 --- a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts +++ b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts @@ -1,49 +1,49 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { expect, test } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + getFooterEditorLocator, + getFooterSurfaceLocator, + getHeaderEditorLocator, + getHeaderSurfaceLocator, +} from '../../helpers/story-surfaces.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; test.use({ config: { showSelection: true } }); -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); - -async function enterHeaderFooterEditMode( - page: Page, - surfaceSelector: string, - editorHostSelector: string, -): Promise { - const surface = page.locator(surfaceSelector).first(); +async function enterHeaderFooterEditMode(surface: Locator, editor: Locator): Promise { await surface.scrollIntoViewIfNeeded(); await surface.waitFor({ state: 'visible', timeout: 15_000 }); const box = await surface.boundingBox(); expect(box).toBeTruthy(); - await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); - - const editorHost = page.locator(editorHostSelector).first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + await surface.page().mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); - - return pm; + await editor.waitFor({ state: 'visible', timeout: 10_000 }); + return editor; } async function assertSelectionOverlayRenders( page: Page, - editor: Locator, + _editor: Locator, expectedSelectionText: string, ): Promise { - await editor.click(); await page.keyboard.press(`${MOD_KEY}+A`); await expect - .poll(async () => page.evaluate(() => document.getSelection()?.toString().trim() ?? '')) + .poll(async () => + page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const selection = activeEditor?.state?.selection; + const doc = activeEditor?.state?.doc; + if (!selection || !doc) { + return ''; + } + return doc.textBetween(selection.from, selection.to, '\n', '\n').trim(); + }), + ) .toBe(expectedSelectionText); await expect.poll(async () => page.locator('.presentation-editor__selection-rect').count()).toBeGreaterThan(0); @@ -52,14 +52,73 @@ async function assertSelectionOverlayRenders( await expect(selectionRect.first()).toBeVisible(); } +async function getRenderedWordRect(surface: Locator, word: string) { + const rect = await surface.evaluate((element, targetWord) => { + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + const matchIndex = text.indexOf(targetWord); + if (matchIndex >= 0) { + const range = doc.createRange(); + range.setStart(node, matchIndex); + range.setEnd(node, matchIndex + targetWord.length); + const bounds = range.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + node = walker.nextNode() as Text | null; + } + + return null; + }, word); + + expect(rect).toBeTruthy(); + return rect!; +} + +async function assertWordSelectionOverlayAlignment(page: Page, surface: Locator, word: string): Promise { + const wordRect = await getRenderedWordRect(surface, word); + expect(wordRect).toBeTruthy(); + + await page.mouse.dblclick(wordRect.left + wordRect.width / 2, wordRect.top + wordRect.height / 2); + await page.waitForTimeout(100); + + const selectionRect = page.locator('.presentation-editor__selection-rect').first(); + await expect(selectionRect).toBeVisible(); + + const overlayRect = await selectionRect.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + }); + + expect(Math.abs(overlayRect.left - wordRect.left)).toBeLessThan(2); + expect(Math.abs(overlayRect.top - wordRect.top)).toBeLessThan(2); + expect(Math.abs(overlayRect.width - wordRect.width)).toBeLessThan(2); + expect(Math.abs(overlayRect.height - wordRect.height)).toBeLessThan(2); +} + test('layout engine renders selection rectangles while editing a header', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); const editor = await enterHeaderFooterEditMode( - superdoc.page, - '.superdoc-page-header', - '.superdoc-header-editor-host', + getHeaderSurfaceLocator(superdoc.page), + getHeaderEditorLocator(superdoc.page), ); await assertSelectionOverlayRenders(superdoc.page, editor, 'Generic content header'); @@ -70,10 +129,27 @@ test('layout engine renders selection rectangles while editing a footer', async await superdoc.waitForStable(); const editor = await enterHeaderFooterEditMode( - superdoc.page, - '.superdoc-page-footer', - '.superdoc-footer-editor-host', + getFooterSurfaceLocator(superdoc.page), + getFooterEditorLocator(superdoc.page), ); await assertSelectionOverlayRenders(superdoc.page, editor, 'Footer'); }); + +test('header word selection overlay aligns with the rendered word bounds', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const surface = getHeaderSurfaceLocator(superdoc.page); + await enterHeaderFooterEditMode(surface, getHeaderEditorLocator(superdoc.page)); + await assertWordSelectionOverlayAlignment(superdoc.page, surface, 'Generic'); +}); + +test('footer word selection overlay aligns with the rendered word bounds', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const surface = getFooterSurfaceLocator(superdoc.page); + await enterHeaderFooterEditMode(surface, getFooterEditorLocator(superdoc.page)); + await assertWordSelectionOverlayAlignment(superdoc.page, surface, 'Footer'); +}); diff --git a/tests/behavior/tests/search/search-and-navigate.spec.ts b/tests/behavior/tests/search/search-and-navigate.spec.ts index f890e746e9..84e6d3aecd 100644 --- a/tests/behavior/tests/search/search-and-navigate.spec.ts +++ b/tests/behavior/tests/search/search-and-navigate.spec.ts @@ -1,19 +1,12 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; test('search and navigate to results in document', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); // Search for text that spans across content - const query = 'works of the Licensed Material'; + const query = 'NetHack'; const matches = await superdoc.page.evaluate((q: string) => { return (window as any).editor?.commands?.search?.(q) ?? []; }, query); diff --git a/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts new file mode 100644 index 0000000000..cb9a59d83d --- /dev/null +++ b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts @@ -0,0 +1,330 @@ +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + BASIC_ENDNOTES_DOC_PATH as ENDNOTE_DOC_PATH, + BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test.use({ + config: { + showCaret: true, + showSelection: true, + }, +}); + +const MULTI_CLICK_RESET_MS = 450; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getWordClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const length = currentNode.textContent?.length ?? 0; + if (remaining < length) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(length, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallback = currentNode.parentElement?.getBoundingClientRect(); + if (!fallback) { + return null; + } + return { + x: fallback.left + Math.min(8, fallback.width / 2), + y: fallback.top + fallback.height / 2, + }; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= length; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getFirstWord(locator: Locator) { + const word = await locator.evaluate((element) => { + const text = element.textContent ?? ''; + const match = text.match(/\p{L}[\p{L}\p{N}]*/u); + return match?.[0] ?? null; + }); + + expect(word).toBeTruthy(); + return word!; +} + +async function getSelectionOverlayRects(page: Page) { + return page.evaluate(() => + Array.from(document.querySelectorAll('.presentation-editor__selection-rect')) + .map((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }) + .filter(Boolean), + ); +} + +async function getActiveSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + const selection = state?.selection; + if (!state?.doc || !selection) { + return null; + } + + return { + from: selection.from, + to: selection.to, + empty: selection.empty, + text: state.doc.textBetween(selection.from, selection.to, '\n', '\n'), + }; + }); +} + +async function getActiveEditorText(page: Page) { + const text = await page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + if (!state?.doc) { + return null; + } + + return state.doc.textBetween(0, state.doc.content.size, '\n', '\n'); + }); + + return normalizeText(text); +} + +async function expectWordSelection(page: Page, expectedWord: string) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(normalizeText(selection?.text)).toBe(expectedWord); +} + +async function expectParagraphSelection(page: Page, expectedText: string, minWordLength: number) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(selection?.empty).toBe(false); + expect(normalizeText(selection?.text)).toBe(expectedText); + expect(normalizeText(selection?.text).length).toBeGreaterThanOrEqual(minWordLength); +} + +test('body surface supports double-click word selection and triple-click paragraph selection', async ({ superdoc }) => { + await superdoc.type('alpha beta gamma'); + await superdoc.waitForStable(); + + const line = superdoc.page.locator('.superdoc-line').first(); + const point = await getWordClickPoint(line, 'beta'); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'beta'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, 'alpha beta gamma', 'beta'.length); +}); + +test('body surface selection does not leak into visible footnotes', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const point = await getWordClickPoint(bodyLine, 'Simple'); + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + + await expectWordSelection(superdoc.page, 'Simple'); + + const selectionRects = await getSelectionOverlayRects(superdoc.page); + expect(selectionRects).toHaveLength(1); +}); + +test('active header supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const header = superdoc.page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + + const headerBox = await header.boundingBox(); + expect(headerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(headerBox!.x + headerBox!.width / 2, headerBox!.y + headerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(header); + const point = await getWordClickPoint(header, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test('active footer supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const footer = superdoc.page.locator('.superdoc-page-footer').first(); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + + const footerBox = await footer.boundingBox(); + expect(footerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footerBox!.x + footerBox!.width / 2, footerBox!.y + footerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(footer); + const point = await getWordClickPoint(footer, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test('active footnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const footnote = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const footnoteBox = await footnote.boundingBox(); + expect(footnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footnoteBox!.x + footnoteBox!.width / 2, footnoteBox!.y + footnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple footnote'); + + const point = await getWordClickPoint(footnote, 'footnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'footnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'footnote'.length); +}); + +test('active endnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(ENDNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const endnote = superdoc.page.locator('[data-block-id^="endnote-1-"]').first(); + await endnote.scrollIntoViewIfNeeded(); + await endnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const endnoteBox = await endnote.boundingBox(); + expect(endnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(endnoteBox!.x + endnoteBox!.width / 2, endnoteBox!.y + endnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple endnote'); + + const point = await getWordClickPoint(endnote, 'endnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'endnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'endnote'.length); +}); From 5f951145d76a5003d72aa67295cbb00c49b7f82b Mon Sep 17 00:00:00 2001 From: VladaHarbour <114763039+VladaHarbour@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:36:46 +0300 Subject: [PATCH 31/43] fix: image rendering (#2796) * fix: image rendering * chore: update test case --- .../pm-adapter/src/utilities.test.ts | 30 ++++++++++++++++ .../layout-engine/pm-adapter/src/utilities.ts | 13 +++++-- .../alternate-content-translator.js | 9 ++--- .../alternate-content-translator.test.js | 20 +++++++++-- .../import/alternateChoiceImporter.test.js | 34 ++++++++++++++++++- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/utilities.test.ts b/packages/layout-engine/pm-adapter/src/utilities.test.ts index 1218404f3b..f7cb642cf4 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.test.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.test.ts @@ -752,6 +752,36 @@ describe('Media Utilities', () => { expect(result[0].src).toBe('data:image/png;base64,base64data'); }); + it('hydrates word/media src from media storage key', () => { + const blocks: FlowBlock[] = [ + { + kind: 'image', + id: '1', + src: 'word/media/image.png', + runs: [], + }, + ]; + const mediaFiles = { 'media/image.png': 'base64data' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + expect(result[0].src).toBe('data:image/png;base64,base64data'); + }); + + it('hydrates media src from word/media storage key', () => { + const blocks: FlowBlock[] = [ + { + kind: 'image', + id: '1', + src: 'media/image.png', + runs: [], + }, + ]; + const mediaFiles = { 'word/media/image.png': 'base64data' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + expect(result[0].src).toBe('data:image/png;base64,base64data'); + }); + it('uses rId fallback when direct path does not match', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index 6db6d38956..d09d3245ad 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -1001,9 +1001,18 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { + if (!value) return [] as string[]; + const normalized = normalizeMediaKey(value); + if (!normalized) return [value]; + const withoutWordPrefix = normalized.startsWith('word/') ? normalized.slice(5) : normalized; + const withWordPrefix = normalized.startsWith('word/') ? normalized : `word/${normalized}`; + return [value, normalized, withoutWordPrefix, withWordPrefix]; + }; + const candidates = new Set(); - candidates.add(src); - if (attrSrc) candidates.add(attrSrc); + addPathCandidates(src).forEach((candidate) => candidates.add(candidate)); + if (attrSrc) addPathCandidates(attrSrc).forEach((candidate) => candidates.add(candidate)); if (relId) { const inferredExt = extension ?? inferExtensionFromPath(src) ?? 'jpeg'; candidates.add(`word/media/${relId}.${inferredExt}`); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js index 3556625b47..6f255708cd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.js @@ -116,10 +116,11 @@ export function selectAlternateContentElements(node) { const requiresAttr = choice?.attributes?.Requires || choice?.attributes?.requires; if (!requiresAttr) return false; - return requiresAttr - .split(/\s+/) - .filter(Boolean) - .some((namespace) => SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has(namespace)); + const requiredNamespaces = requiresAttr.split(/\s+/).filter(Boolean); + if (requiredNamespaces.length === 0) return false; + + // ECMA-376 mc:Choice requires ALL listed namespaces to be understood. + return requiredNamespaces.every((namespace) => SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has(namespace)); }); const branch = supportedChoice || fallback || choices[0] || null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js index 04a59c4337..f4312d88f5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/mc/altermateContent/alternate-content-translator.test.js @@ -178,10 +178,10 @@ describe('selectAlternateContentElements', () => { expect(SUPPORTED_ALTERNATE_CONTENT_REQUIRES.has('w16sdtfl')).toBe(true); }); - it('selects supported choice when namespace matches set', () => { + it('selects supported choice when all required namespaces are supported', () => { const choice = { name: 'mc:Choice', - attributes: { Requires: 'foo wps bar' }, + attributes: { Requires: 'wps w14' }, elements: [{ name: 'w:r' }], }; const node = { @@ -193,6 +193,22 @@ describe('selectAlternateContentElements', () => { expect(elements).toEqual(choice.elements); }); + it('falls back when mc:Choice requires an unsupported namespace', () => { + const fallback = { name: 'mc:Fallback', elements: [{ name: 'w:p' }] }; + const mixedChoice = { + name: 'mc:Choice', + attributes: { Requires: 'wps unknownNs' }, + elements: [{ name: 'w:r' }], + }; + const node = { + elements: [mixedChoice, fallback], + }; + + const { branch, elements } = selectAlternateContentElements(node); + expect(branch).toBe(fallback); + expect(elements).toEqual(fallback.elements); + }); + it('returns fallback when no choice is supported', () => { const fallback = { name: 'mc:Fallback', elements: [{ name: 'w:p' }] }; const node = { diff --git a/packages/super-editor/src/editors/v1/tests/import/alternateChoiceImporter.test.js b/packages/super-editor/src/editors/v1/tests/import/alternateChoiceImporter.test.js index 7409c72656..8be472584b 100644 --- a/packages/super-editor/src/editors/v1/tests/import/alternateChoiceImporter.test.js +++ b/packages/super-editor/src/editors/v1/tests/import/alternateChoiceImporter.test.js @@ -194,7 +194,7 @@ describe('alternateChoiceHandler', () => { expect(text).toBe('cell choice'); }); - it('selects a supported choice when Requires contains multiple namespaces', () => { + it('falls back when Requires lists any unsupported namespace (all must be supported)', () => { const altNode = { type: 'element', name: 'mc:AlternateContent', @@ -203,6 +203,38 @@ describe('alternateChoiceHandler', () => { type: 'element', name: 'mc:Choice', attributes: { Requires: 'foo wps bar' }, + elements: [createTextRun('choice run')], + }, + { + type: 'element', + name: 'mc:Fallback', + elements: [createTextRun('fallback run')], + }, + ], + }; + + const { handlerSpy, result } = callHandler([altNode]); + + expect(result.consumed).toBe(1); + expect(handlerSpy).toHaveBeenCalledTimes(1); + const handledCall = handlerSpy.mock.calls[0][0]; + const handledNodes = handledCall?.nodes ?? []; + expect(handledNodes).toHaveLength(1); + const run = handledNodes[0]; + const textElement = run.elements?.find((el) => el.name === 'w:t'); + const textNode = textElement?.elements?.find((el) => el.type === 'text'); + expect(textNode?.text).toBe('fallback run'); + }); + + it('selects choice when every Requires namespace is supported', () => { + const altNode = { + type: 'element', + name: 'mc:AlternateContent', + elements: [ + { + type: 'element', + name: 'mc:Choice', + attributes: { Requires: 'wps w14' }, elements: [createTextRun('supported choice')], }, { From 9c6ccb0bc55b55f8bcf6608a55fa12339b495dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 23 Apr 2026 14:05:27 -0300 Subject: [PATCH 32/43] [10/16] refactor(painter): deliver resolved items to decoration provider callback (#2827) * refactor(painter): deliver resolved items to decoration provider callback * fix: normalize header/footer resolved item coordinates before paint --- .../painters/dom/src/renderer.ts | 48 ++-- .../HeaderFooterSessionManager.ts | 121 +++++++++- .../tests/HeaderFooterSessionManager.test.ts | 223 +++++++++++++++++- .../PresentationEditor.collaboration.test.ts | 1 + .../PresentationEditor.decorationSync.test.ts | 1 + .../PresentationEditor.draggableFocus.test.ts | 1 + .../PresentationEditor.focusWrapping.test.ts | 1 + ...sentationEditor.footnotesPmMarkers.test.ts | 1 + ...entationEditor.getCurrentPageIndex.test.ts | 1 + ...PresentationEditor.getElementAtPos.test.ts | 1 + .../PresentationEditor.goToAnchor.test.ts | 1 + .../tests/PresentationEditor.media.test.ts | 1 + ...resentationEditor.scrollToPosition.test.ts | 1 + ...esentationEditor.sectionPageStyles.test.ts | 1 + .../tests/PresentationEditor.test.ts | 1 + .../tests/PresentationEditor.zoom.test.ts | 1 + 16 files changed, 383 insertions(+), 22 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 76b5dac508..acbc957ed1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -275,6 +275,9 @@ type OptionalBlockMeasurePair = { type PageDecorationPayload = { fragments: Fragment[]; + /** Resolved items aligned 1:1 with `fragments`. Same length, same order. + * Absent when provider has no resolved data (painter falls back to blockLookup). */ + items?: ResolvedPaintItem[]; height: number; /** Optional measured content height to aid bottom alignment in footers. */ contentHeight?: number; @@ -2451,16 +2454,13 @@ export class DomPainter { * Used to determine special Y positioning for page-relative anchored media * in header/footer decoration sections. */ - private isPageRelativeAnchoredFragment(fragment: Fragment): boolean { + private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem?: ResolvedPaintItem): boolean { if (fragment.kind !== 'image' && fragment.kind !== 'drawing') { return false; } - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup) { - return false; - } - const block = lookup.block; - if (block.kind !== 'image' && block.kind !== 'drawing') { + const resolvedBlock = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined; + const block = resolvedBlock ?? this.blockLookup.get(fragment.blockId)?.block; + if (!block || (block.kind !== 'image' && block.kind !== 'drawing')) { return false; } return block.anchor?.vRelativeFrom === 'page'; @@ -2580,9 +2580,10 @@ export class DomPainter { const contentHeight = typeof data.contentHeight === 'number' ? data.contentHeight - : data.fragments.reduce((max, f) => { + : data.fragments.reduce((max, f, fi) => { + const resolvedItem = data.items?.[fi]; const fragHeight = - 'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f); + 'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f, resolvedItem); return Math.max(max, f.y + Math.max(0, fragHeight)); }, 0); // Offset to push content to bottom of container @@ -2599,7 +2600,7 @@ export class DomPainter { }; // Compute between-border flags for header/footer paragraph fragments - const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup); + const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup, data.items); // Separate behindDoc fragments from normal fragments. // Prefer explicit fragment.behindDoc when present. Keep zIndex===0 as a @@ -2636,8 +2637,15 @@ export class DomPainter { // By inserting at the beginning and using z-index: 0, they render below body content // which also has z-index values but comes later in DOM order. behindDocFragments.forEach(({ fragment, originalIndex }) => { - const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex)); - const isPageRelative = this.isPageRelativeAnchoredFragment(fragment); + const resolvedItem = data.items?.[originalIndex]; + const fragEl = this.renderFragment( + fragment, + context, + undefined, + betweenBorderFlags.get(originalIndex), + resolvedItem, + ); + const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem); let pageY: number; if (isPageRelative && kind === 'footer') { @@ -2660,8 +2668,15 @@ export class DomPainter { // Render normal fragments in the header/footer container normalFragments.forEach(({ fragment, originalIndex }) => { - const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex)); - const isPageRelative = this.isPageRelativeAnchoredFragment(fragment); + const resolvedItem = data.items?.[originalIndex]; + const fragEl = this.renderFragment( + fragment, + context, + undefined, + betweenBorderFlags.get(originalIndex), + resolvedItem, + ); + const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem); if (isPageRelative && kind === 'footer') { // Footer page-relative: fragment.y is normalized to band-local coords @@ -7043,7 +7058,10 @@ export class DomPainter { * @param fragment - The fragment to estimate height for * @returns Estimated height in pixels, or 0 if height cannot be determined */ - private estimateFragmentHeight(fragment: Fragment): number { + private estimateFragmentHeight(fragment: Fragment, resolvedItem?: ResolvedPaintItem): number { + if (resolvedItem && 'height' in resolvedItem && typeof resolvedItem.height === 'number') { + return resolvedItem.height; + } const lookup = this.blockLookup.get(fragment.blockId); const measure = lookup?.measure; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index eceab88aa0..29d7abbd9c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -11,8 +11,18 @@ * @module presentation-editor/header-footer/HeaderFooterSessionManager */ -import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts'; +import type { + Layout, + FlowBlock, + Measure, + Page, + SectionMetadata, + Fragment, + ResolvedHeaderFooterLayout, + ResolvedPaintItem, +} from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; +import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; @@ -339,6 +349,55 @@ type HeaderFooterActivationOptions = { initialSelection?: 'end' | 'defer'; }; +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Resolve a `HeaderFooterLayoutResult` into a `ResolvedHeaderFooterLayout`. + * Paired with the originals so the decoration provider can deliver aligned + * `items` alongside `fragments`. + */ +function resolveResult(result: HeaderFooterLayoutResult): ResolvedHeaderFooterLayout { + return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures); +} + +function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): ResolvedPaintItem { + if (item.kind === 'group') { + return { + ...item, + y: item.y + yOffset, + children: item.children.map((child) => shiftResolvedPaintItemY(child, yOffset)), + }; + } + + return { + ...item, + y: item.y + yOffset, + }; +} + +function normalizeDecorationFragments(fragments: Fragment[], layoutMinY: number): Fragment[] { + if (layoutMinY >= 0) { + return fragments; + } + + const yOffset = -layoutMinY; + return fragments.map((fragment) => ({ ...fragment, y: fragment.y + yOffset })); +} + +function normalizeDecorationItems( + items: ResolvedPaintItem[] | undefined, + layoutMinY: number, +): ResolvedPaintItem[] | undefined { + if (!items || layoutMinY >= 0) { + return items; + } + + const yOffset = -layoutMinY; + return items.map((item) => shiftResolvedPaintItemY(item, yOffset)); +} + // ============================================================================= // HeaderFooterSessionManager // ============================================================================= @@ -365,6 +424,12 @@ export class HeaderFooterSessionManager { #headerLayoutsByRId: Map = new Map(); #footerLayoutsByRId: Map = new Map(); + // Resolved layouts (aligned 1:1 with the results above) + #resolvedHeaderLayouts: ResolvedHeaderFooterLayout[] | null = null; + #resolvedFooterLayouts: ResolvedHeaderFooterLayout[] | null = null; + #resolvedHeaderByRId: Map = new Map(); + #resolvedFooterByRId: Map = new Map(); + // Decoration providers #headerDecorationProvider: PageDecorationProvider | undefined; #footerDecorationProvider: PageDecorationProvider | undefined; @@ -492,6 +557,7 @@ export class HeaderFooterSessionManager { /** Set header layout results */ set headerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#headerLayoutResults = results; + this.#resolvedHeaderLayouts = results ? results.map(resolveResult) : null; } /** Footer layout results */ @@ -502,6 +568,7 @@ export class HeaderFooterSessionManager { /** Set footer layout results */ set footerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#footerLayoutResults = results; + this.#resolvedFooterLayouts = results ? results.map(resolveResult) : null; } /** Header layouts by rId */ @@ -612,6 +679,8 @@ export class HeaderFooterSessionManager { ): void { this.#headerLayoutResults = headerResults; this.#footerLayoutResults = footerResults; + this.#resolvedHeaderLayouts = headerResults ? headerResults.map(resolveResult) : null; + this.#resolvedFooterLayouts = footerResults ? footerResults.map(resolveResult) : null; } /** @@ -1449,10 +1518,20 @@ export class HeaderFooterSessionManager { layout: Layout, sectionMetadata: SectionMetadata[], ): Promise { - return await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, { + await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, { headerLayoutsByRId: this.#headerLayoutsByRId, footerLayoutsByRId: this.#footerLayoutsByRId, }); + + // Rebuild resolved maps aligned 1:1 with the raw rId maps. + this.#resolvedHeaderByRId.clear(); + for (const [key, result] of this.#headerLayoutsByRId) { + this.#resolvedHeaderByRId.set(key, resolveResult(result)); + } + this.#resolvedFooterByRId.clear(); + for (const [key, result] of this.#footerLayoutsByRId) { + this.#resolvedFooterByRId.set(key, resolveResult(result)); + } } #computeMetrics( @@ -2092,6 +2171,8 @@ export class HeaderFooterSessionManager { createDecorationProvider(kind: 'header' | 'footer', layout: Layout): PageDecorationProvider | undefined { const results = kind === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults; const layoutsByRId = kind === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId; + const resolvedResults = kind === 'header' ? this.#resolvedHeaderLayouts : this.#resolvedFooterLayouts; + const resolvedByRId = kind === 'header' ? this.#resolvedHeaderByRId : this.#resolvedFooterByRId; if ((!results || results.length === 0) && (!layoutsByRId || layoutsByRId.size === 0)) { return undefined; @@ -2166,6 +2247,15 @@ export class HeaderFooterSessionManager { const slotPage = this.#findPageForNumber(rIdLayout.layout.pages, pageNumber); if (slotPage) { const fragments = slotPage.fragments ?? []; + const resolvedLayout = resolvedByRId.get(rIdLayoutKey); + const resolvedSlotPage = resolvedLayout?.pages.find((p) => p.number === slotPage.number); + const resolvedItems = resolvedSlotPage?.items; + if (resolvedItems && resolvedItems.length !== fragments.length) { + console.warn( + `[HeaderFooterSessionManager] Resolved items length (${resolvedItems.length}) does not match fragments length (${fragments.length}) for rId '${rIdLayoutKey}' page ${pageNumber}. Dropping items.`, + ); + } + const alignedItems = resolvedItems && resolvedItems.length === fragments.length ? resolvedItems : undefined; const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins; const decorationMargins = @@ -2180,11 +2270,12 @@ export class HeaderFooterSessionManager { const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); const layoutMinY = rIdLayout.layout.minY ?? 0; - const normalizedFragments = - layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; + const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); + const normalizedItems = normalizeDecorationItems(alignedItems, layoutMinY); return { fragments: normalizedFragments, + items: normalizedItems, height: metrics.containerHeight, contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, offset: metrics.offset, @@ -2205,7 +2296,8 @@ export class HeaderFooterSessionManager { return null; } - const variant = results.find((entry) => entry.type === headerFooterType); + const variantIndex = results.findIndex((entry) => entry.type === headerFooterType); + const variant = variantIndex >= 0 ? results[variantIndex] : undefined; if (!variant || !variant.layout?.pages?.length) { return null; } @@ -2216,6 +2308,17 @@ export class HeaderFooterSessionManager { } const fragments = slotPage.fragments ?? []; + const resolvedVariant = resolvedResults?.[variantIndex]; + const resolvedVariantPage = resolvedVariant?.pages.find((p) => p.number === slotPage.number); + const resolvedVariantItems = resolvedVariantPage?.items; + if (resolvedVariantItems && resolvedVariantItems.length !== fragments.length) { + console.warn( + `[HeaderFooterSessionManager] Resolved items length (${resolvedVariantItems.length}) does not match fragments length (${fragments.length}) for variant '${headerFooterType}' page ${pageNumber}. Dropping items.`, + ); + } + const alignedVariantItems = + resolvedVariantItems && resolvedVariantItems.length === fragments.length ? resolvedVariantItems : undefined; + const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins; const decorationMargins = @@ -2228,10 +2331,12 @@ export class HeaderFooterSessionManager { const finalHeaderId = sectionRId ?? fallbackId ?? undefined; const layoutMinY = variant.layout.minY ?? 0; - const normalizedFragments = layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; + const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); + const normalizedItems = normalizeDecorationItems(alignedVariantItems, layoutMinY); return { fragments: normalizedFragments, + items: normalizedItems, height: metrics.containerHeight, contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, offset: metrics.offset, @@ -2312,6 +2417,10 @@ export class HeaderFooterSessionManager { this.#footerLayoutResults = null; this.#headerLayoutsByRId.clear(); this.#footerLayoutsByRId.clear(); + this.#resolvedHeaderLayouts = null; + this.#resolvedFooterLayouts = null; + this.#resolvedHeaderByRId.clear(); + this.#resolvedFooterByRId.clear(); // Clear decoration providers this.#headerDecorationProvider = undefined; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 9eb4e2d7a4..d9259bcaf3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -1,14 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockInitHeaderFooterRegistry } = vi.hoisted(() => ({ +const { mockInitHeaderFooterRegistry, mockLayoutPerRIdHeaderFooters } = vi.hoisted(() => ({ mockInitHeaderFooterRegistry: vi.fn(), + mockLayoutPerRIdHeaderFooters: vi.fn(), })); vi.mock('../../header-footer/HeaderFooterRegistryInit.js', () => ({ initHeaderFooterRegistry: mockInitHeaderFooterRegistry, })); +vi.mock('../../header-footer/HeaderFooterPerRidLayout.js', () => ({ + layoutPerRIdHeaderFooters: mockLayoutPerRIdHeaderFooters, +})); + import type { Editor } from '../../Editor.js'; +import type { FlowBlock, HeaderFooterLayout, Layout, Measure, ParaFragment } from '@superdoc/contracts'; +import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { HeaderFooterSessionManager, type SessionManagerDependencies, @@ -86,6 +93,7 @@ describe('HeaderFooterSessionManager', () => { beforeEach(() => { vi.clearAllMocks(); + mockLayoutPerRIdHeaderFooters.mockReset(); painterHost = document.createElement('div'); visibleHost = document.createElement('div'); @@ -513,4 +521,217 @@ describe('HeaderFooterSessionManager', () => { expect(exit).toHaveBeenCalledTimes(1); expect(manager.session.mode).toBe('body'); }); + + describe('createDecorationProvider — resolved items', () => { + function buildHeaderResult(options?: { y?: number; minY?: number }): HeaderFooterLayoutResult { + const y = options?.y ?? 10; + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y, + width: 468, + }; + const layout: HeaderFooterLayout = { + height: 50, + ...(options?.minY != null ? { minY: options.minY } : {}), + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + return { kind: 'header', type: 'default', layout, blocks, measures }; + } + + it('delivers items aligned 1:1 with fragments when variant layout is used', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.setLayoutResults([buildHeaderResult()], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout); + expect(provider).toBeDefined(); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]); + expect(payload).not.toBeNull(); + expect(payload!.fragments).toHaveLength(1); + expect(payload!.items).toBeDefined(); + expect(payload!.items!.length).toBe(payload!.fragments.length); + expect(payload!.items![0]!.blockId).toBe('p1'); + }); + + it('normalizes resolved items when variant layout minY is negative', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.setLayoutResults([buildHeaderResult({ y: -12, minY: -12 })], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]); + + expect(payload).not.toBeNull(); + expect(payload!.fragments[0]!.y).toBe(0); + expect(payload!.items).toBeDefined(); + expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 }); + }); + + it('normalizes resolved items when per-rId layout minY is negative', async () => { + mockLayoutPerRIdHeaderFooters.mockImplementation( + async ( + _input: unknown, + _layout: unknown, + _sectionMetadata: unknown, + deps: { headerLayoutsByRId: Map }, + ) => { + deps.headerLayoutsByRId.set('rId-header-default', buildHeaderResult({ y: -12, minY: -12 })); + }, + ); + + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { default: 'rId-header-default', first: undefined, even: undefined }, + footerRefs: {}, + }, + } as never, + ], + } as unknown as Layout; + + await manager.layoutPerRId( + { + headerBlocksByRId: new Map(), + footerBlocksByRId: new Map(), + constraints: { + width: 468, + height: 648, + pageWidth: 612, + pageHeight: 792, + margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 }, + overflowBaseHeight: 36, + }, + }, + layout, + [{ sectionIndex: 0 } as never], + ); + + const provider = manager.createDecorationProvider('header', layout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]); + + expect(mockLayoutPerRIdHeaderFooters).toHaveBeenCalledTimes(1); + expect(payload).not.toBeNull(); + expect(payload!.fragments[0]!.y).toBe(0); + expect(payload!.items).toBeDefined(); + expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 }); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts index 8c9a822b35..2d5fbe5ea1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts @@ -187,6 +187,7 @@ vi.mock('y-prosemirror', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts index da0f0fc702..37b37bfa45 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts @@ -280,6 +280,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 2dcff9abe3..3b70896003 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -202,6 +202,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor - Draggable Annotation Focus Suppression (SD-1179)', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index 49e81ea373..edc977cfd5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -217,6 +217,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor - Focus Wrapping (#wrapHiddenEditorFocus)', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index 27803874fc..107ccddc1e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -113,6 +113,7 @@ vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: vi.fn(() => ({ width: vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: mockResolveLayout, + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); vi.mock('../../header-footer/HeaderFooterRegistry', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts index da8016ae70..43952f86e3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts @@ -270,6 +270,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts index f3753dd292..44b8833b16 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts @@ -184,6 +184,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor.getElementAtPos', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index cf12ec716b..b75b0eb138 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -258,6 +258,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor - goToAnchor', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts index 166d368be1..71169dbb0d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -129,6 +129,7 @@ vi.mock('y-prosemirror', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('SD-1313: toFlowBlocks receives media from storage.image.media', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts index d10f93e3ec..08aea2d32f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts @@ -275,6 +275,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor - scrollToPosition', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts index 2066d959f4..28a2da6a98 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts @@ -270,6 +270,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index cc92107300..dced8833fd 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -333,6 +333,7 @@ vi.mock('@superdoc/measuring-dom', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: mockResolveLayout, + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); vi.mock('@extensions/pagination/pagination-helpers.js', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts index 221aaf00a0..4020a94542 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts @@ -289,6 +289,7 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), + resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), })); describe('PresentationEditor - Zoom Functionality', () => { From d0a36c2b1f48d4e50f15904fb522db898f03a564 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:28:16 -0300 Subject: [PATCH 33/43] fix(extract): return tables as paragraph-granular blocks (SD-2672) (#2925) * fix(extract): return tables as paragraph-granular blocks (SD-2672) doc.extract() was flattening tables into one joined string, which broke RAG chunking and made table citations unreachable via scrollToElement. Walk tables directly and emit one block per paragraph-like descendant of each origin cell, tagged with tableContext so consumers can group back to cell, row, or whole table. - gridBefore/gridAfter placeholder cells are skipped via the __placeholder attr; they are layout artifacts with no user content. - Block SDTs (structuredContentBlock) are transparent, so tables wrapped in content controls are not re-flattened through the wrapper's textContent. - Cell paths use physical row-and-cell child indexes so deterministic fallback nodeIds agree with buildBlockIndex, keeping the scrollToElement round-trip stable for paragraphs that lack paraId and sdBlockId inside horizontally merged tables. Tested: 13 behavior tests (7 existing SD-2525 + 6 new SD-2672), 5 new adapter unit tests, plus the full document-api-adapters suite (3105 tests) and document-api bun suite (1362 tests). * fix(extract): recurse through unrecognized block wrappers (SD-2672) The new table walker only emitted blocks for recognized types and silently dropped anything else, including their block children. That regressed coverage versus the old textContent walk for `documentSection`, `documentPartObject`, and `shapeContainer`, which all declare block-level content but aren't in EMITTABLE_BLOCK_TYPES. Treat any unrecognized block with block-level children as transparent and recurse into it, so paragraphs nested inside these wrappers still surface with their enclosing tableContext. Adds a unit test covering a `documentSection` inside a table cell. * test(extract): add DOCX-import-driven coverage for table edge cases (SD-2672) The adapter unit tests hit the algorithm via schema-constructed PM docs, which skips the importer entirely. This adds a second layer of tests that load real Word-authored .docx files, run them through the full import pipeline, and assert extract output. Closes the gap the code review flagged for a customer-facing legal RAG contract. Fixtures authored via Word COM + local OOXML patching: - sd-2672-plain-3x3.docx: baseline table, no merges or placeholders - sd-2672-merged-table.docx: colspan=2 and rowspan=2 anchors - sd-2672-rtl-table.docx: bidiVisual RTL table - sd-2672-gridbefore-vmerge.docx: w:gridBefore + w:vMerge=restart/continue - sd-2672-sdt-table.docx: table wrapped in a w:sdt block (content control) - sd-2672-nested-table.docx: 2x2 table inside cell (1,1) of outer table - sd-2672-multipara-cell.docx: cell (0,0) with two paragraphs The build-sd-2672-fixtures.mjs script regenerates the patched variants from the Word-authored base, using JSZip + regex/XmlDocument surgery. Tests assert: per-cell content lands at correct logical grid coords, merged anchors carry rowspan/colspan, RTL tables still report columns 0..N-1, gridBefore placeholders don't emit phantom blocks, SDT wrappers are transparent, nested tables get a fresh tableOrdinal with parent coordinates, multi-paragraph cells emit one block per paragraph with shared tableContext, and scrollToElement round-trips a merged-cell paragraph nodeId. * chore(tests): drop SD-2672 fixture build script The script was added alongside the fixtures to regenerate the OOXML-patched variants from a Word-authored base. It isn't carrying its weight: fixtures are committed as static binaries, the regex-based XML patching is fragile to Word COM output changes, and the commit history already documents how each fixture was constructed. If we need a new edge-case fixture later, hand-authoring it once is simpler than maintaining a generator. * chore(tests): drop stale script reference in extract-docx error --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/extract.mdx | 56 +++- packages/document-api/src/contract/schemas.ts | 33 +- .../document-api/src/types/extract.types.ts | 50 ++- .../extract-adapter.test.ts | 289 +++++++++++++++++ .../document-api-adapters/extract-adapter.ts | 294 ++++++++++++++++-- .../tests/navigation/extract-docx.spec.ts | 259 +++++++++++++++ .../behavior/tests/navigation/extract.spec.ts | 168 +++++++++- .../fixtures/sd-2672-gridbefore-vmerge.docx | Bin 0 -> 69602 bytes .../fixtures/sd-2672-merged-table.docx | Bin 0 -> 13867 bytes .../fixtures/sd-2672-multipara-cell.docx | Bin 0 -> 69471 bytes .../fixtures/sd-2672-nested-table.docx | Bin 0 -> 70071 bytes .../fixtures/sd-2672-plain-3x3.docx | Bin 0 -> 13877 bytes .../fixtures/sd-2672-rtl-table.docx | Bin 0 -> 13784 bytes .../fixtures/sd-2672-sdt-table.docx | Bin 0 -> 69668 bytes 15 files changed, 1125 insertions(+), 26 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts create mode 100644 tests/behavior/tests/navigation/extract-docx.spec.ts create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-merged-table.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-nested-table.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx create mode 100644 tests/behavior/tests/navigation/fixtures/sd-2672-sdt-table.docx diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index b886c7b66d..6d4e645b77 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1018,5 +1018,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "e74a36833ec8587b67447a79517de348cfc9b4bba1c564729c184f6d5464a018" + "sourceHash": "c8670fb494b56c19fbd09a7bada35974fbb3c22d938f6a5e01eee6e8467961c0" } diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx index 0eb276f66a..4294047ed3 100644 --- a/apps/docs/document-api/reference/extract.mdx +++ b/apps/docs/document-api/reference/extract.mdx @@ -49,6 +49,15 @@ _No fields._ { "headingLevel": 1, "nodeId": "node-def456", + "tableContext": { + "colspan": 1, + "columnIndex": 1, + "parentRowIndex": 1, + "parentTableOrdinal": 1, + "rowIndex": 1, + "rowspan": 1, + "tableOrdinal": 1 + }, "text": "Hello, world.", "type": "example" } @@ -110,12 +119,57 @@ _No fields._ "description": "Stable block ID — pass to scrollToElement() for navigation.", "type": "string" }, + "tableContext": { + "additionalProperties": false, + "properties": { + "colspan": { + "description": "Number of columns the cell spans.", + "type": "integer" + }, + "columnIndex": { + "description": "0-based logical grid column, not the row child order.", + "type": "integer" + }, + "parentColumnIndex": { + "description": "Column index in the parent table. Set with parentTableOrdinal.", + "type": "integer" + }, + "parentRowIndex": { + "description": "Row index in the parent table. Set with parentTableOrdinal.", + "type": "integer" + }, + "parentTableOrdinal": { + "description": "Ordinal of the parent table when the containing table is nested.", + "type": "integer" + }, + "rowIndex": { + "description": "0-based row index of the containing cell.", + "type": "integer" + }, + "rowspan": { + "description": "Number of rows the cell spans.", + "type": "integer" + }, + "tableOrdinal": { + "description": "0-based table ordinal, unique within one extract() result.", + "type": "integer" + } + }, + "required": [ + "tableOrdinal", + "rowIndex", + "columnIndex", + "rowspan", + "colspan" + ], + "type": "object" + }, "text": { "description": "Full plain text content of the block.", "type": "string" }, "type": { - "description": "Block type: paragraph, heading, listItem, table, image, etc.", + "description": "Block type: paragraph, heading, listItem, image, tableOfContents.", "type": "string" } }, diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 4e2e73d1ff..55c8b91181 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2963,9 +2963,40 @@ const operationSchemas: Record = { items: objectSchema( { nodeId: { type: 'string', description: 'Stable block ID — pass to scrollToElement() for navigation.' }, - type: { type: 'string', description: 'Block type: paragraph, heading, listItem, table, image, etc.' }, + type: { + type: 'string', + description: 'Block type: paragraph, heading, listItem, image, tableOfContents.', + }, text: { type: 'string', description: 'Full plain text content of the block.' }, headingLevel: { type: 'integer', description: 'Heading level (1–6). Only present for headings.' }, + tableContext: objectSchema( + { + tableOrdinal: { + type: 'integer', + description: '0-based table ordinal, unique within one extract() result.', + }, + parentTableOrdinal: { + type: 'integer', + description: 'Ordinal of the parent table when the containing table is nested.', + }, + parentRowIndex: { + type: 'integer', + description: 'Row index in the parent table. Set with parentTableOrdinal.', + }, + parentColumnIndex: { + type: 'integer', + description: 'Column index in the parent table. Set with parentTableOrdinal.', + }, + rowIndex: { type: 'integer', description: '0-based row index of the containing cell.' }, + columnIndex: { + type: 'integer', + description: '0-based logical grid column, not the row child order.', + }, + rowspan: { type: 'integer', description: 'Number of rows the cell spans.' }, + colspan: { type: 'integer', description: 'Number of columns the cell spans.' }, + }, + ['tableOrdinal', 'rowIndex', 'columnIndex', 'rowspan', 'colspan'], + ), }, ['nodeId', 'type', 'text'], ), diff --git a/packages/document-api/src/types/extract.types.ts b/packages/document-api/src/types/extract.types.ts index 00f4033156..708b2bc0d9 100644 --- a/packages/document-api/src/types/extract.types.ts +++ b/packages/document-api/src/types/extract.types.ts @@ -4,15 +4,59 @@ import type { CommentStatus, TrackChangeType } from './index.js'; // extract // --------------------------------------------------------------------------- +/** + * Table coordinates for an {@link ExtractBlock} that lives inside a table cell. + * + * Blocks inside tables are extracted at paragraph granularity (one entry per + * paragraph/heading/listItem/image/sdt/tableOfContents in each cell). Group + * by these fields to reconstruct cells, rows, or whole tables: + * + * - cell: group by `tableOrdinal + rowIndex + columnIndex` + * - row: group by `tableOrdinal + rowIndex` + * - table: group by `tableOrdinal` + */ +export interface ExtractTableContext { + /** 0-based table ordinal, unique within one `extract()` result. */ + tableOrdinal: number; + /** Ordinal of the parent table when this block is inside a nested table. */ + parentTableOrdinal?: number; + /** Row index within the parent table. Only set with `parentTableOrdinal`. */ + parentRowIndex?: number; + /** Column index within the parent table. Only set with `parentTableOrdinal`. */ + parentColumnIndex?: number; + /** 0-based row index of the containing cell. */ + rowIndex: number; + /** 0-based logical grid column of the containing cell, not the row's child order. */ + columnIndex: number; + /** Number of rows the containing cell spans. 1 for unmerged cells. */ + rowspan: number; + /** Number of columns the containing cell spans. 1 for unmerged cells. */ + colspan: number; +} + +/** + * One addressable unit of document content. + * + * Extraction is paragraph-granular: tables are NOT returned as a single block. + * Paragraph-like descendants of table cells are emitted individually with + * `tableContext` attached. + * + * Block SDTs (structured document tags / content controls) are transparent: + * their children emit individually as if they were direct children of the + * enclosing container. No wrapper `sdt` block is emitted. This prevents + * SDT-wrapped tables from re-flattening through the wrapper's textContent. + */ export interface ExtractBlock { - /** Stable block ID — pass to `scrollToElement()` for navigation. */ + /** Stable block ID. Pass to `scrollToElement()` for navigation. */ nodeId: string; - /** Block type: paragraph, heading, listItem, table, image, etc. */ + /** Block type: paragraph, heading, listItem, image, tableOfContents. */ type: string; /** Full plain text content of the block. */ text: string; - /** Heading level (1–6). Only present for headings. */ + /** Heading level (1-6). Only present for headings. */ headingLevel?: number; + /** Table coordinates. Only present for blocks inside a table cell. */ + tableContext?: ExtractTableContext; } export interface ExtractComment { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts new file mode 100644 index 0000000000..ccc3a8de0a --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts @@ -0,0 +1,289 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import type { Editor } from '../core/Editor.js'; +import { extractAdapter } from './extract-adapter.js'; +import { buildBlockIndex } from './helpers/node-address-resolver.js'; + +// --------------------------------------------------------------------------- +// Doc builders +// +// These use initTestEditor's schema content mode so the PM schema normalizes +// the JSON into real nodes. That gives us a realistic Editor instance while +// still letting us shape the doc to hit specific extract edge cases. +// --------------------------------------------------------------------------- + +type SchemaDoc = { + type: 'doc'; + content: unknown[]; +}; + +function paragraph(text: string, attrs: Record = {}): unknown { + return { + type: 'paragraph', + attrs, + content: text ? [{ type: 'text', text }] : [], + }; +} + +function cell(content: unknown[], attrs: Record = {}): unknown { + return { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [100], ...attrs }, + content, + }; +} + +function row(cells: unknown[]): unknown { + return { type: 'tableRow', content: cells }; +} + +function table(rows: unknown[]): unknown { + return { type: 'table', content: rows }; +} + +function sdt(content: unknown[], attrs: Record = {}): unknown { + return { + type: 'structuredContentBlock', + attrs: { id: 'sdt-1', tag: null, alias: null, sdtPr: null, ...attrs }, + content, + }; +} + +function makeEditor(doc: SchemaDoc): Promise<{ editor: Editor }> { + return initTestEditor({ content: doc, loadFromSchema: true }) as Promise<{ editor: Editor }>; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('extract-adapter table handling', () => { + let editor: Editor | undefined; + + afterEach(() => { + editor?.destroy?.(); + editor = undefined; + }); + + it('skips gridBefore/gridAfter placeholder cells', async () => { + // Row 0 starts with a gridBefore placeholder followed by two real cells. + // Row 1 is two real cells plus a gridAfter placeholder. + const doc: SchemaDoc = { + type: 'doc', + content: [ + table([ + row([ + cell([paragraph('')], { __placeholder: 'gridBefore' }), + cell([paragraph('r0c1')]), + cell([paragraph('r0c2')]), + ]), + row([ + cell([paragraph('r1c0')]), + cell([paragraph('r1c1')]), + cell([paragraph('')], { __placeholder: 'gridAfter' }), + ]), + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const tableBlocks = result.blocks.filter((b) => b.tableContext); + const byCoord = (r: number, c: number) => + tableBlocks.find((b) => b.tableContext!.rowIndex === r && b.tableContext!.columnIndex === c); + + // Placeholder slots do not emit blocks. + expect(byCoord(0, 0)).toBeUndefined(); + expect(byCoord(1, 2)).toBeUndefined(); + + // Real cells still emit at their logical grid columns. + expect(byCoord(0, 1)?.text).toBe('r0c1'); + expect(byCoord(0, 2)?.text).toBe('r0c2'); + expect(byCoord(1, 0)?.text).toBe('r1c0'); + expect(byCoord(1, 1)?.text).toBe('r1c1'); + + // No phantom cell from placeholder text. + expect( + tableBlocks.some((b) => b.text === '' && b.tableContext!.rowIndex === 0 && b.tableContext!.columnIndex === 0), + ).toBe(false); + }); + + it('reports grid coordinates from TableMap, not cell child order, across merges', async () => { + // Row 0: one cell with colspan=2, then a regular cell. + // Row 1: three regular cells. + // TableMap should place row-0 cells at columns 0 and 2; row-1 cells at 0, 1, 2. + const doc: SchemaDoc = { + type: 'doc', + content: [ + table([ + row([cell([paragraph('A')], { colspan: 2 }), cell([paragraph('B')])]), + row([cell([paragraph('C')]), cell([paragraph('D')]), cell([paragraph('E')])]), + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const tableBlocks = result.blocks.filter((b) => b.tableContext); + + const a = tableBlocks.find((b) => b.text === 'A')!; + const b = tableBlocks.find((b) => b.text === 'B')!; + const e = tableBlocks.find((b) => b.text === 'E')!; + + expect(a.tableContext!.columnIndex).toBe(0); + expect(a.tableContext!.colspan).toBe(2); + expect(b.tableContext!.columnIndex).toBe(2); // grid column, not cellChildIndex=1 + expect(e.tableContext!.columnIndex).toBe(2); + }); +}); + +describe('extract-adapter SDT transparency', () => { + let editor: Editor | undefined; + + afterEach(() => { + editor?.destroy?.(); + editor = undefined; + }); + + it('does not emit a wrapper block for a top-level structuredContentBlock', async () => { + const doc: SchemaDoc = { + type: 'doc', + content: [sdt([paragraph('inside sdt')]), paragraph('outside')], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + + expect(result.blocks.some((b) => b.type === 'sdt')).toBe(false); + expect(result.blocks.find((b) => b.text === 'inside sdt')?.type).toBe('paragraph'); + expect(result.blocks.find((b) => b.text === 'outside')?.type).toBe('paragraph'); + }); + + it('recurses transparently into unrecognized block containers inside a cell', async () => { + // documentSection is a block wrapper (`content: 'block*'`) that neither + // mapBlockNodeType nor EMITTABLE_BLOCK_TYPES recognize. The walker must + // step through it so paragraphs inside still emit with the cell's + // tableContext attached. The pre-SD-2672 textContent walk included + // this text, so skipping it would be a coverage regression. + const doc: SchemaDoc = { + type: 'doc', + content: [ + table([ + row([ + cell([ + { + type: 'documentSection', + attrs: {}, + content: [paragraph('inside section')], + }, + ]), + cell([paragraph('normal cell')]), + ]), + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const wrapped = result.blocks.find((b) => b.text === 'inside section'); + const normal = result.blocks.find((b) => b.text === 'normal cell'); + + expect(wrapped).toBeDefined(); + expect(wrapped!.type).toBe('paragraph'); + expect(wrapped!.tableContext).toBeDefined(); + expect(wrapped!.tableContext!.rowIndex).toBe(0); + expect(wrapped!.tableContext!.columnIndex).toBe(0); + + expect(normal?.tableContext?.columnIndex).toBe(1); + }); + + it('does not flatten tables wrapped in an SDT', async () => { + const doc: SchemaDoc = { + type: 'doc', + content: [ + sdt([ + table([ + row([cell([paragraph('x1')]), cell([paragraph('x2')])]), + row([cell([paragraph('y1')]), cell([paragraph('y2')])]), + ]), + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + + expect(result.blocks.some((b) => b.type === 'sdt')).toBe(false); + expect(result.blocks.some((b) => b.type === 'table')).toBe(false); + + // Per-cell blocks land with correct grid coordinates. + for (const [label, r, c] of [ + ['x1', 0, 0], + ['x2', 0, 1], + ['y1', 1, 0], + ['y2', 1, 1], + ] as const) { + const block = result.blocks.find((b) => b.text === label); + expect(block).toBeDefined(); + expect(block!.tableContext).toBeDefined(); + expect(block!.tableContext!.rowIndex).toBe(r); + expect(block!.tableContext!.columnIndex).toBe(c); + } + }); +}); + +describe('extract-adapter fallback path consistency with buildBlockIndex', () => { + let editor: Editor | undefined; + + afterEach(() => { + editor?.destroy?.(); + editor = undefined; + }); + + it('produces nodeIds that resolve through buildBlockIndex for paragraphs in merged tables', async () => { + // Paragraphs get paraId / sdBlockId from the schema / plugins. We don't + // try to strip them here - the assertion is that whatever ID strategy + // the resolver picks, extract and buildBlockIndex agree on the result. + // If they diverge, the scrollToElement-from-extract path breaks. + const doc: SchemaDoc = { + type: 'doc', + content: [ + table([ + // Row 0: one colspan=2 cell followed by a regular cell. Physical + // cell indexes 0 and 1 but logical grid columns 0 and 2 - exactly + // the case where logical-vs-physical path divergence used to break + // fallback ID hashing. + row([cell([paragraph('merged')], { colspan: 2 }), cell([paragraph('right')])]), + row([cell([paragraph('a')]), cell([paragraph('b')]), cell([paragraph('c')])]), + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const index = buildBlockIndex(editor); + const byKey = new Map(index.candidates.map((c) => [`${c.nodeType}:${c.nodeId}`, c])); + + const cellBlocks = result.blocks.filter((b) => b.tableContext); + expect(cellBlocks.length).toBe(5); + + for (const block of cellBlocks) { + const key = `${block.type}:${block.nodeId}`; + expect(byKey.has(key), `extract nodeId ${key} should resolve through buildBlockIndex`).toBe(true); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts index 4e04775ecd..66dbd168e6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts @@ -1,42 +1,298 @@ /** - * Extract adapter — produces a flat, RAG-friendly extraction of the entire + * Extract adapter - produces a flat, RAG-friendly extraction of the entire * document: blocks with full text, comments, and tracked changes. * + * Tables are NOT returned as a single flattened block. Instead, the + * paragraph-like descendants inside each cell are emitted in document order + * with `tableContext` attached. Consumers group by `tableContext.tableOrdinal` + * (whole table), `+ rowIndex` (row), or `+ columnIndex` (cell). + * + * Block SDTs (structured document tags / content controls) are transparent: + * their children emit individually as if they were direct children of the + * enclosing container. No wrapper `sdt` block is emitted. This prevents + * SDT-wrapped tables from re-flattening through the wrapper's textContent. + * * Follows the same read-only adapter pattern as info-adapter.ts. */ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { TableMap } from 'prosemirror-tables'; + import type { Editor } from '../core/Editor.js'; import type { ExtractInput, ExtractResult, ExtractBlock, + ExtractTableContext, ExtractComment, ExtractTrackedChange, CommentsListQuery, + BlockNodeType, } from '@superdoc/document-api'; -import { getHeadingLevel } from './helpers/node-address-resolver.js'; +import { getHeadingLevel, mapBlockNodeType, resolveBlockNodeId } from './helpers/node-address-resolver.js'; import { getRevision } from './plan-engine/revision-tracker.js'; -import { collectTopLevelBlocks } from './plan-engine/blocks-wrappers.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { trackChangesListWrapper } from './plan-engine/track-changes-wrappers.js'; -function collectBlocks(editor: Editor): ExtractBlock[] { - const candidates = collectTopLevelBlocks(editor); - - return candidates.map((candidate) => { - const pProps = (candidate.node.attrs as Record).paragraphProperties as - | { styleId?: string } - | undefined; - const headingLevel = getHeadingLevel(pProps?.styleId); - - const block: ExtractBlock = { - nodeId: candidate.nodeId, - type: candidate.nodeType, - text: candidate.node.textContent, +/** + * Block types we emit individually (paragraph-granular). + * + * Keep in sync with the superset in `helpers/node-address-resolver.ts` + * (`SUPPORTED_BLOCK_NODE_TYPES`). Deliberately excluded: + * - `table`, `tableRow`, `tableCell`: structural containers, handled by the + * table walk. + * - `sdt`: block SDTs are transparent (see module doc). Their children are + * walked as if they were direct children of the enclosing container. + */ +const EMITTABLE_BLOCK_TYPES: ReadonlySet = new Set([ + 'paragraph', + 'heading', + 'listItem', + 'image', + 'tableOfContents', +]); + +/** PM node type names that the walker treats as transparent block SDTs. */ +const SDT_BLOCK_NODE_NAMES: ReadonlySet = new Set(['structuredContentBlock', 'sdt']); + +interface CellAnchor { + cellNode: ProseMirrorNode; + /** Position offset relative to the table content start (tablePos + 1). */ + cellOffset: number; + /** 0-based physical row index within `tableNode.childCount`. */ + rowChildIndex: number; + /** 0-based physical cell index within `row.childCount`. */ + cellChildIndexInRow: number; + /** 0-based logical grid row from `TableMap`. */ + gridRowIndex: number; + /** + * 0-based logical grid column from `TableMap`. Can differ from + * `cellChildIndexInRow` when earlier cells in the row carry `colspan > 1` + * or when the row above spans down into this row's grid. + */ + gridColumnIndex: number; + rowspan: number; + colspan: number; +} + +/** + * Walks a PM table and returns one entry per origin cell. + * + * Origin cells are the unique addressable cells: + * - Merged cells (rowspan/colspan > 1) appear once at their anchor, not + * once per grid slot they cover. + * - Vertical `vMerge="continue"` cells are folded into their origin during + * DOCX import (see super-converter/v3/handlers/w/tc/helpers), so they + * don't exist in the PM tree and don't need special handling here. + * - `gridBefore` / `gridAfter` placeholder cells (see + * super-converter/v3/handlers/w/tr/tr-helpers.js) are skipped via the + * `__placeholder` attr. They're synthetic layout artifacts with empty + * content and aren't user-addressable. + * + * Both physical child indexes and logical grid coordinates are returned: + * - Physical (`rowChildIndex`, `cellChildIndexInRow`) go into the traversal + * path so deterministic fallback IDs match `buildBlockIndex`. + * - Logical (`gridRowIndex`, `gridColumnIndex`) are exposed to consumers + * via `tableContext` so row/column grouping maps to the visible grid. + */ +function indexCellsForTable(tableNode: ProseMirrorNode): CellAnchor[] { + const map = TableMap.get(tableNode); + const anchors: CellAnchor[] = []; + + let rowOffset = 0; + for (let rowChildIndex = 0; rowChildIndex < tableNode.childCount; rowChildIndex++) { + const row = tableNode.child(rowChildIndex); + let cellOffsetInRow = 0; + + for (let cellChildIndexInRow = 0; cellChildIndexInRow < row.childCount; cellChildIndexInRow++) { + const cellNode = row.child(cellChildIndexInRow); + // cellOffset is the offset from the table content start (tablePos + 1) + // to this cell node. +1 skips the row's open token. + const cellOffset = rowOffset + 1 + cellOffsetInRow; + cellOffsetInRow += cellNode.nodeSize; + + const cellAttrs = cellNode.attrs as { + rowspan?: number; + colspan?: number; + __placeholder?: string; + }; + + // Skip gridBefore/gridAfter placeholders - synthetic cells added at + // import to preserve OOXML column layout. They contain an empty + // paragraph but no user content. + if (cellAttrs.__placeholder != null) continue; + + // Look up grid coordinates from TableMap. For a merged cell, indexOf + // returns the first grid slot the cell occupies - its anchor position. + const mapIndex = map.map.indexOf(cellOffset); + if (mapIndex < 0) continue; + + anchors.push({ + cellNode, + cellOffset, + rowChildIndex, + cellChildIndexInRow, + gridRowIndex: Math.floor(mapIndex / map.width), + gridColumnIndex: mapIndex % map.width, + rowspan: cellAttrs.rowspan ?? 1, + colspan: cellAttrs.colspan ?? 1, + }); + } + + rowOffset += row.nodeSize; + } + + return anchors; +} + +/** Builds an `ExtractBlock` for a paragraph-like node. */ +function buildBlock( + node: ProseMirrorNode, + pos: number, + nodeType: BlockNodeType, + path: readonly number[], + tableContext?: ExtractTableContext, +): ExtractBlock | undefined { + const nodeId = resolveBlockNodeId(node, pos, nodeType, path); + if (!nodeId) return undefined; + + const pProps = (node.attrs as Record).paragraphProperties as { styleId?: string } | undefined; + const headingLevel = getHeadingLevel(pProps?.styleId); + + const block: ExtractBlock = { + nodeId, + type: nodeType, + text: node.textContent, + }; + if (headingLevel !== undefined) block.headingLevel = headingLevel; + if (tableContext) block.tableContext = tableContext; + return block; +} + +interface OrdinalCounter { + next: number; +} + +interface NestedTableParent { + tableOrdinal: number; + rowIndex: number; + columnIndex: number; +} + +/** + * Walks the immediate children of a block container (doc root, table cell, + * or block SDT) and emits extract blocks for paragraph-like descendants. + * + * Three special cases: + * - A child `table` recurses into `collectTableExtractBlocks` with the + * current `nestedParent` attached. + * - A child block SDT is transparent: we recurse into its children with + * the same `tableContext` and `nestedParent`. No wrapper block emits. + * - Paragraph-like children emit a block and inherit `tableContext`. + */ +function collectContainerBlocks( + container: ProseMirrorNode, + contentStart: number, + containerPath: readonly number[], + ordinals: OrdinalCounter, + tableContext?: ExtractTableContext, + nestedParent?: NestedTableParent, +): ExtractBlock[] { + const blocks: ExtractBlock[] = []; + let childOffset = 0; + + for (let i = 0; i < container.childCount; i++) { + const child = container.child(i); + const childPos = contentStart + childOffset; + childOffset += child.nodeSize; + const childPath = [...containerPath, i]; + + if (child.type.name === 'table') { + blocks.push(...collectTableExtractBlocks(child, childPos, childPath, ordinals, nestedParent)); + continue; + } + + if (SDT_BLOCK_NODE_NAMES.has(child.type.name)) { + // Transparent descent: +1 skips the SDT's opening token so `contentStart` + // points at the SDT's first child. + blocks.push(...collectContainerBlocks(child, childPos + 1, childPath, ordinals, tableContext, nestedParent)); + continue; + } + + const childType = mapBlockNodeType(child); + if (childType && EMITTABLE_BLOCK_TYPES.has(childType)) { + const block = buildBlock(child, childPos, childType, childPath, tableContext); + if (block) blocks.push(block); + continue; + } + + // Unrecognized block wrapper with block-level children (e.g. + // `documentSection`, `documentPartObject`, `shapeContainer`). Recurse + // transparently so paragraphs inside these wrappers still surface in + // extract output. Without this, content inside them would be silently + // dropped: the pre-SD-2672 `textContent` walk included that text, and + // the new walker must not regress coverage. + if (!child.isLeaf && child.firstChild?.isBlock === true) { + blocks.push(...collectContainerBlocks(child, childPos + 1, childPath, ordinals, tableContext, nestedParent)); + } + } + + return blocks; +} + +/** + * Walks one table and emits an `ExtractBlock` for every paragraph-like + * descendant of every origin cell, in document order. Recurses into nested + * tables with a fresh `tableOrdinal` and a `parent*` reference. + */ +function collectTableExtractBlocks( + tableNode: ProseMirrorNode, + tablePos: number, + tablePath: readonly number[], + ordinals: OrdinalCounter, + parent?: NestedTableParent, +): ExtractBlock[] { + const tableOrdinal = ordinals.next++; + const anchors = indexCellsForTable(tableNode); + const blocks: ExtractBlock[] = []; + + for (const anchor of anchors) { + // Cell content starts at tablePos + 1 (table open) + cellOffset (offset + // to the cell node) + 1 (cell open). + const cellContentStart = tablePos + 1 + anchor.cellOffset + 1; + // Path uses physical indexes so deterministic fallback IDs match the + // walk in buildBlockIndex (which uses the `index` arg of doc.descendants). + const cellPath: readonly number[] = [...tablePath, anchor.rowChildIndex, anchor.cellChildIndexInRow]; + + const tableContext: ExtractTableContext = { + tableOrdinal, + rowIndex: anchor.gridRowIndex, + columnIndex: anchor.gridColumnIndex, + rowspan: anchor.rowspan, + colspan: anchor.colspan, }; - if (headingLevel !== undefined) block.headingLevel = headingLevel; - return block; - }); + if (parent) { + tableContext.parentTableOrdinal = parent.tableOrdinal; + tableContext.parentRowIndex = parent.rowIndex; + tableContext.parentColumnIndex = parent.columnIndex; + } + + blocks.push( + ...collectContainerBlocks(anchor.cellNode, cellContentStart, cellPath, ordinals, tableContext, { + tableOrdinal, + rowIndex: anchor.gridRowIndex, + columnIndex: anchor.gridColumnIndex, + }), + ); + } + + return blocks; +} + +function collectBlocks(editor: Editor): ExtractBlock[] { + // doc is root - no opening token in the PM position model, content starts at 0. + const ordinals: OrdinalCounter = { next: 0 }; + return collectContainerBlocks(editor.state.doc, 0, [], ordinals); } function collectComments(editor: Editor): ExtractComment[] { diff --git a/tests/behavior/tests/navigation/extract-docx.spec.ts b/tests/behavior/tests/navigation/extract-docx.spec.ts new file mode 100644 index 0000000000..96db6b2233 --- /dev/null +++ b/tests/behavior/tests/navigation/extract-docx.spec.ts @@ -0,0 +1,259 @@ +/** + * Import-driven extract tests for SD-2672. + * + * Each test loads a real .docx fixture via `superdoc.loadDocument()`, which + * exercises the full DOCX import path (super-converter, normalization, + * paraId synthesis, placeholder cell injection, vMerge folding) before + * calling `doc.extract()`. This is the layer our schema-driven adapter unit + * tests do not cover and where real OOXML weirdness manifests. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.resolve(__dirname, 'fixtures'); + +const fixture = (name: string) => path.join(FIXTURES_DIR, name); + +function requireFixture(name: string): string { + const p = fixture(name); + if (!fs.existsSync(p)) { + throw new Error(`Missing SD-2672 fixture "${name}" in ${FIXTURES_DIR}.`); + } + return p; +} + +async function loadAndExtract( + superdoc: { loadDocument: (p: string) => Promise; page: { evaluate: (fn: () => T) => Promise } }, + fixtureName: string, +): Promise<{ + blocks: Array>; + comments: unknown[]; + trackedChanges: unknown[]; + revision: string; +}> { + await superdoc.loadDocument(requireFixture(fixtureName)); + return superdoc.page.evaluate(() => (window as any).editor.doc.extract({}) as any); +} + +// --------------------------------------------------------------------------- +// Baseline: plain 3x3 table authored by Word COM +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: plain 3x3 table emits one block per cell paragraph', async ({ superdoc }) => { + // NOTE: Word's COM API inserts a leading empty paragraph into every cell. + // That's real document state, so extraction correctly surfaces it as a + // separate block. The test verifies the 9 authored cells are reachable + // at their correct grid coordinates, not the exact block count. + const result = await loadAndExtract(superdoc, 'sd-2672-plain-3x3.docx'); + + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `r${r}c${c}`).toBeDefined(); + const tc = (block as any).tableContext; + expect(tc.rowIndex).toBe(r); + expect(tc.columnIndex).toBe(c); + expect(tc.rowspan).toBe(1); + expect(tc.colspan).toBe(1); + expect(tc.tableOrdinal).toBe(0); + } + } + + // No flattened 'type: table' block. + expect(result.blocks.find((b: any) => b.type === 'table')).toBeUndefined(); +}); + +// --------------------------------------------------------------------------- +// Merged cells: colspan + rowspan authored by Word +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: merged cells report rowspan/colspan on anchors only', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-merged-table.docx'); + + const top = result.blocks.find((b: any) => b.text === 'top-span'); + const left = result.blocks.find((b: any) => b.text === 'left-span'); + expect(top, 'top-span anchor').toBeDefined(); + expect(left, 'left-span anchor').toBeDefined(); + + const topCtx = (top as any).tableContext; + expect(topCtx.rowIndex).toBe(0); + expect(topCtx.columnIndex).toBe(0); + expect(topCtx.colspan).toBe(2); + expect(topCtx.rowspan).toBe(1); + + const leftCtx = (left as any).tableContext; + expect(leftCtx.rowIndex).toBe(1); + expect(leftCtx.columnIndex).toBe(0); + expect(leftCtx.rowspan).toBe(2); + expect(leftCtx.colspan).toBe(1); + + // No continuation cell at (0,1), (2,0), etc. + const blocksAt = (r: number, c: number) => + result.blocks.filter((b: any) => b.tableContext?.rowIndex === r && b.tableContext?.columnIndex === c); + expect(blocksAt(0, 1)).toHaveLength(0); + expect(blocksAt(2, 0)).toHaveLength(0); +}); + +// --------------------------------------------------------------------------- +// RTL table: bidiVisual should not affect logical grid coords +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: RTL table reports logical grid columns', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-rtl-table.docx'); + + // Every cell we wrote lands somewhere with a tableContext. + for (let r = 0; r < 2; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `rtl-r${r}c${c}`); + expect(block, `rtl-r${r}c${c}`).toBeDefined(); + expect((block as any).tableContext).toBeDefined(); + } + } + + // Each row's non-empty cells cover grid columns {0, 1, 2}. Empty blocks + // from Word's leading-paragraph padding are ignored; what we assert is that + // each authored cell lands at a distinct logical column. + for (let r = 0; r < 2; r++) { + const rowCols = new Set( + result.blocks + .filter((b: any) => b.tableContext?.rowIndex === r && b.text.length > 0) + .map((b: any) => b.tableContext.columnIndex), + ); + expect(rowCols).toEqual(new Set([0, 1, 2])); + } +}); + +// --------------------------------------------------------------------------- +// gridBefore + vMerge: no phantom blocks, correct grid coords on the anchor +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: gridBefore + vMerge does not emit phantom cells', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-gridbefore-vmerge.docx'); + + // The fixture injects `` on row 0 (shifting its cells + // one column right) and `` on row 1's first cell (so it's a + // continuation of row 0's vertically-merged anchor, which the importer + // folds into row 0 as rowspan>=2). + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + expect(tableBlocks.length).toBeGreaterThan(0); + + // No phantom empty-text blocks from the placeholder column. + const phantoms = tableBlocks.filter( + (b: any) => b.text === '' && b.tableContext.rowIndex === 0 && b.tableContext.columnIndex === 0, + ); + expect(phantoms).toHaveLength(0); + + // Every emitted block's authored text comes from a real cell in the + // base 3x3 fixture ("rNcN"). A continuation cell (vMerge="continue") + // must not surface as its own block with authored text. + const realCells = tableBlocks.filter((b: any) => /^r\dc\d$/.test(b.text)); + expect(realCells.length).toBeGreaterThan(0); +}); + +// --------------------------------------------------------------------------- +// SDT-wrapped table: transparency +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: SDT-wrapped table does not emit a wrapper block', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-sdt-table.docx'); + + // No wrapper 'sdt' block emitted. + expect(result.blocks.some((b: any) => b.type === 'sdt')).toBe(false); + + // The inner table's cells still come through with tableContext intact. + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `sdt-wrapped r${r}c${c}`).toBeDefined(); + expect((block as any).tableContext?.rowIndex).toBe(r); + expect((block as any).tableContext?.columnIndex).toBe(c); + } + } +}); + +// --------------------------------------------------------------------------- +// Nested table: fresh ordinal + parent context +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: nested table gets its own ordinal and parent coords', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-nested-table.docx'); + + // Inner table cells carry text "nested-a" through "nested-d". + const inner = result.blocks.filter((b: any) => /^nested-[a-d]$/.test(b.text)); + expect(inner.length).toBe(4); + + const outerOrdinals = new Set( + result.blocks + .filter((b: any) => /^r\dc\d$/.test(b.text)) + .map((b: any) => b.tableContext?.tableOrdinal) + .filter((v: unknown) => typeof v === 'number'), + ); + const innerOrdinals = new Set(inner.map((b: any) => b.tableContext?.tableOrdinal)); + + expect(outerOrdinals.size).toBe(1); + expect(innerOrdinals.size).toBe(1); + // Inner table MUST have a different ordinal from the outer. + const [outer] = outerOrdinals; + const [innerO] = innerOrdinals; + expect(innerO).not.toBe(outer); + + // Every inner cell has parent context pointing at the outer cell (1,1). + for (const block of inner) { + const tc = (block as any).tableContext; + expect(tc.parentTableOrdinal).toBe(outer); + expect(tc.parentRowIndex).toBe(1); + expect(tc.parentColumnIndex).toBe(1); + } + + // The outer cell's "before-nested" and "after-nested" paragraphs should + // emit alongside the nested table, all with the outer cell's tableContext. + const before = result.blocks.find((b: any) => b.text === 'before-nested'); + const after = result.blocks.find((b: any) => b.text === 'after-nested'); + expect(before, 'before-nested').toBeDefined(); + expect(after, 'after-nested').toBeDefined(); + expect((before as any).tableContext.rowIndex).toBe(1); + expect((before as any).tableContext.columnIndex).toBe(1); + expect((before as any).tableContext.tableOrdinal).toBe(outer); +}); + +// --------------------------------------------------------------------------- +// Multi-paragraph cell: one block per paragraph, shared tableContext +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: multi-paragraph cells emit one block per paragraph', async ({ superdoc }) => { + const result = await loadAndExtract(superdoc, 'sd-2672-multipara-cell.docx'); + + const p1 = result.blocks.find((b: any) => b.text === 'cell-00-line-1'); + const p2 = result.blocks.find((b: any) => b.text === 'cell-00-line-2'); + expect(p1, 'line 1').toBeDefined(); + expect(p2, 'line 2').toBeDefined(); + + // Distinct nodeIds, but they share the same tableContext (both in (0,0)). + expect((p1 as any).nodeId).not.toBe((p2 as any).nodeId); + expect((p1 as any).tableContext.rowIndex).toBe(0); + expect((p1 as any).tableContext.columnIndex).toBe(0); + expect((p2 as any).tableContext.rowIndex).toBe(0); + expect((p2 as any).tableContext.columnIndex).toBe(0); +}); + +// --------------------------------------------------------------------------- +// scrollToElement round-trip: extract's nodeId resolves in the browser +// --------------------------------------------------------------------------- + +test('@behavior SD-2672 docx: merged-cell paragraph nodeId works with scrollToElement', async ({ superdoc }) => { + await superdoc.loadDocument(requireFixture('sd-2672-merged-table.docx')); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const anchor = result.blocks.find((b: any) => b.text === 'left-span'); + expect(anchor).toBeDefined(); + + const navResult = await superdoc.page.evaluate( + (id) => (window as any).superdoc.scrollToElement(id), + (anchor as any).nodeId, + ); + expect(navResult).toBe(true); +}); diff --git a/tests/behavior/tests/navigation/extract.spec.ts b/tests/behavior/tests/navigation/extract.spec.ts index 6a66600758..83e58eb6b9 100644 --- a/tests/behavior/tests/navigation/extract.spec.ts +++ b/tests/behavior/tests/navigation/extract.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/superdoc.js'; +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; import { addCommentByText, replaceText, findFirstSelectionTarget } from '../../helpers/document-api.js'; test('@behavior SD-2525: doc.extract returns blocks with nodeIds and full text', async ({ superdoc }) => { @@ -122,3 +122,169 @@ test('@behavior SD-2525: extract nodeIds work with scrollToElement', async ({ su ); expect(navResult).toBe(true); }); + +// --------------------------------------------------------------------------- +// SD-2672: Table-aware extraction +// --------------------------------------------------------------------------- + +/** + * Inserts a table at the current selection, then types a unique label + * `r{row}c{col}` into each cell so we can assert which block came from + * which (row, column). + */ +async function insertLabeledTable(superdoc: SuperDocFixture, rows: number, cols: number): Promise { + await superdoc.executeCommand('insertTable', { rows, cols }); + await superdoc.waitForStable(); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + await superdoc.type(`r${r}c${c}`); + const isLastCell = r === rows - 1 && c === cols - 1; + if (!isLastCell) await superdoc.press('Tab'); + } + } + await superdoc.waitForStable(); +} + +test('@behavior SD-2672: extract emits a block per cell paragraph with tableContext', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 3); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + // No flattened "type: table" block should be returned. + expect(result.blocks.find((b: any) => b.type === 'table')).toBeUndefined(); + + // Every cell's paragraph should appear as its own block with tableContext. + for (let r = 0; r < 2; r++) { + for (let c = 0; c < 3; c++) { + const block = result.blocks.find((b: any) => b.text === `r${r}c${c}`); + expect(block, `block for r${r}c${c}`).toBeDefined(); + expect(block.tableContext).toBeDefined(); + expect(block.tableContext.tableOrdinal).toBe(0); + expect(block.tableContext.rowIndex).toBe(r); + expect(block.tableContext.columnIndex).toBe(c); + expect(block.tableContext.rowspan).toBe(1); + expect(block.tableContext.colspan).toBe(1); + expect(block.nodeId).toBeTruthy(); + expect(block.type).toBe('paragraph'); + } + } +}); + +test('@behavior SD-2672: nodeIds inside a table cell work with scrollToElement', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 2); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const cellBlock = result.blocks.find((b: any) => b.text === 'r1c1'); + expect(cellBlock).toBeDefined(); + + const navResult = await superdoc.page.evaluate( + (id) => (window as any).superdoc.scrollToElement(id), + cellBlock.nodeId, + ); + expect(navResult).toBe(true); +}); + +test('@behavior SD-2672: empty cells emit a block with empty text', async ({ superdoc }) => { + // Insert a table without filling any cells; every cell holds one empty paragraph. + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2 }); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + + expect(tableBlocks).toHaveLength(4); + for (const block of tableBlocks) { + expect(block.text).toBe(''); + expect(block.nodeId).toBeTruthy(); + expect(block.type).toBe('paragraph'); + } + + const coords = tableBlocks.map((b: any) => `${b.tableContext.rowIndex},${b.tableContext.columnIndex}`).sort(); + expect(coords).toEqual(['0,0', '0,1', '1,0', '1,1']); +}); + +test('@behavior SD-2672: blocks outside tables have no tableContext', async ({ superdoc }) => { + await superdoc.type('Before the table'); + await superdoc.press('Enter'); + await insertLabeledTable(superdoc, 1, 2); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + const before = result.blocks.find((b: any) => b.text === 'Before the table'); + expect(before).toBeDefined(); + expect(before.tableContext).toBeUndefined(); + + const insideCell = result.blocks.find((b: any) => b.text === 'r0c1'); + expect(insideCell).toBeDefined(); + expect(insideCell.tableContext).toBeDefined(); +}); + +test('@behavior SD-2672: nested tables get a fresh ordinal and parent context', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 1, cols: 1 }); + await superdoc.waitForStable(); + // Cursor lands inside the only cell. Insert a nested 1x2 table here. + await superdoc.executeCommand('insertTable', { rows: 1, cols: 2 }); + await superdoc.waitForStable(); + // Type into the inner cells. + await superdoc.type('inner-a'); + await superdoc.press('Tab'); + await superdoc.type('inner-b'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + + const innerA = result.blocks.find((b: any) => b.text === 'inner-a'); + const innerB = result.blocks.find((b: any) => b.text === 'inner-b'); + expect(innerA).toBeDefined(); + expect(innerB).toBeDefined(); + + // Both inner cells share the inner table's ordinal and reference the outer + // table as parent. + expect(innerA.tableContext.tableOrdinal).toBe(innerB.tableContext.tableOrdinal); + expect(innerA.tableContext.parentTableOrdinal).toBeDefined(); + expect(innerA.tableContext.parentTableOrdinal).not.toBe(innerA.tableContext.tableOrdinal); + expect(innerA.tableContext.parentRowIndex).toBe(0); + expect(innerA.tableContext.parentColumnIndex).toBe(0); + expect(innerA.tableContext.rowIndex).toBe(0); + expect(innerA.tableContext.columnIndex).toBe(0); + expect(innerB.tableContext.columnIndex).toBe(1); +}); + +test('@behavior SD-2672: merged cells carry rowspan/colspan on the anchor', async ({ superdoc }) => { + await insertLabeledTable(superdoc, 2, 3); + + // Merge cells (0,0) through (1,1): a 2-row x 2-column block in the top-left. + await superdoc.page.evaluate(() => { + const docApi = (window as any).editor.doc; + const tableResult = docApi.find({ select: { type: 'node', nodeType: 'table' }, limit: 1 }); + const tableAddress = tableResult.items[0].address; + docApi.tables.mergeCells({ + target: tableAddress, + start: { rowIndex: 0, columnIndex: 0 }, + end: { rowIndex: 1, columnIndex: 1 }, + }); + }); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => (window as any).editor.doc.extract({})); + const tableBlocks = result.blocks.filter((b: any) => b.tableContext); + + // Anchor cell (0,0) carries the merged content and the spans. + const anchorBlocks = tableBlocks.filter( + (b: any) => b.tableContext.rowIndex === 0 && b.tableContext.columnIndex === 0, + ); + expect(anchorBlocks.length).toBeGreaterThan(0); + for (const block of anchorBlocks) { + expect(block.tableContext.rowspan).toBe(2); + expect(block.tableContext.colspan).toBe(2); + } + + // Continuation cells (0,1), (1,0), (1,1) emit nothing; the anchor absorbed them. + const continuationCoords = ['0,1', '1,0', '1,1']; + for (const coord of continuationCoords) { + const [r, c] = coord.split(',').map(Number); + const found = tableBlocks.find((b: any) => b.tableContext.rowIndex === r && b.tableContext.columnIndex === c); + expect(found, `no anchor expected at (${r},${c}); should be folded into (0,0)`).toBeUndefined(); + } +}); diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-gridbefore-vmerge.docx new file mode 100644 index 0000000000000000000000000000000000000000..bb62cb02558eed114ed060b1b9ad6a56e10114cd GIT binary patch literal 69602 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

RLb3soBJ-B&gcX>QE_kRt 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 NJ)qF4&i>oc{{#8x-75e9 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-multipara-cell.docx new file mode 100644 index 0000000000000000000000000000000000000000..f5821b2ecd159c8a5bcdacee86f9a00b060f81bc GIT binary patch literal 69471 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 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-plain-3x3.docx new file mode 100644 index 0000000000000000000000000000000000000000..b3a2ea76d9c18a2b585587dfa806fa7e8160dd64 GIT binary patch literal 13877 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 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx b/tests/behavior/tests/navigation/fixtures/sd-2672-rtl-table.docx new file mode 100644 index 0000000000000000000000000000000000000000..2c70655f5fad8e72f1ec73ce71b5d1870116e290 GIT binary patch literal 13784 zcmeHuWmFt%wsr%--QC^YA-Dy1CrEIY;O+!>cXxMp3+@iVHNhReK4)g`Idf)aeQVvn zcWSMEySjEgySl2j)w`ckkOupJ27mxS0RRAEz*fPml@p}%pR0WBJu_7U7z29V28w!|NO|ybVZge3(#pF-&i`O&fq+MCs zqxboOO|g@}v?(;o;cLa#(TOd?0m4Mu*dI5c3?rYBpAmqr@c@XTNsLw_bB|;t2zw;q;))50fi zxd9z(2A1n~H+8VQn(?h*(*6d-JbzbUXtUa?a?*SozyAKXS14 zZ2bTR0KC700TlkpB?)42ntuZAOb+OAut1mm>|ku=$jI>9_+MB2KkR>hI`xXUUW(_qna0Mg=brdqYF53NoOFy_Xc^xB_q8#pPyx~x^$o7&6<ecrYx84muY)jPcpiLbN8^b4O>I} zv^8N9;)BE4)Zv2;Q|D8i{Kye@Jzvprqlq5QDpWCX@-(SX>4Ib94rOYO<}(yGH93`(kQnIADT8C8$QlBk(gOZK6+ z_{Om|*hR!1*!jehj$F1;v!)4_A>t<%Y;Q!^*9*R?G|pi z3{8>nEs5vNgtJUnK6w2^=q(1}$L9pM&%wYnW)M^}|Y{AVM zT-L$uktw^&hthP!r}7mNx6 zUzDdMTrDC5A?|6*=By?|6zMxx7oxBIkmA57iX%o2ADB!5ZrB;=8DF<&rFVq}Iz?L__A__e^d^{~MpQ*eJr5 z-LRWk+#&ClpWoxLS4K<2>Y3oS+)B^-@SZ2nA!(k8HH};^*0a+YVFtcqrFZc^52U|8 zfEqjo?|dcX14-~QqNdq1O?`R>%~h(f^gm@Zkv1iHVPTTLSPe3a(XA8Q#(~KYSeFL9 z1*CY<^Li-=?^%5!UR!(xh!F^s&_Pe!>~vR4N}W#YxBYUcN^zX>_cDsMvl%ry`-qBI{-gUN@G2!f*-5M0pDldp5;m8Sp(eH1}q)OnZ1P+r@0&(}uWUu~fZ5xxoX4_}vENQh@o zZ+WuE4MwVG(8wKZgf!uT&gyCim|PidhYBv*ZQ2P$GPsERYE?XJYkM3&$R&O{JP>~j z9PtU;w$PVb4pWMRgN}qx%5vYe{73?qAvEi<#{zKt_f|MG z6lNuEUFCh>p;fTvrV$U;G$@DnvR$@%x*A0EY?ng!mgdGBkTJC{$I4(_v1HM%D=?fS zs5zI{zI2I0(N)l0Tiv}x@FWXXEu>lB+C6M;%VLA=3O|-s$^X)G8`2rZy#8vVA;4~Wtnya;A_XdPanAn1LEu?hKq~o|3HHc>rO%f=H&tp-LM@z(y$9gF&6~?aBz|<^gw1v>qp`tMF*cU!>7CNDRCiTq%p-4gU4L>CS9;JYJP%mtajLisZ{QAIa=KlPENb;KU5M zFh;El6UUi*=wXCI8`3sDTriThtiv}`aW?B9n;nfOV{1~XSL@}2Um#dYhROosrW8t9 z@RC+E$jsj#D0HB)w6XhB5@I10L^Rm}qLK!iVJ^#h3Q-sy3(ih?Ja z3v4A=%GZTC*4BefD$2b5TPhM&?2YW{4#Y>Ya$?keaqIjf%VL)?94TYl(oG{~O=Wh& zvcCLuAWfQRScFiSIlxbV`&>TnjwVD}p_09}`*i~D{WYGI!kFL<7W-zMg{Ai0Xe~q7 zkgf(kMLD$BkACA5a{sF~^4E-p%BB&&<@;s>T3Csv(7lPEmKi zCO){PAxeoXv(J*HN4(d15)+W%pviS?P;SF6q;^RRe?{q9);BB9UI;*2vu{?77$=ea zl7BvVhisOscQ5>!@>N7|^dn-}tRY2MaFD1e>jNB$yJ>P?o~gl%>ItppQS&

    ?{( zKawpwt2oR07fHx5`P7%8_T-CW8l$weJNmU#>Z;^j^8iumbs3aGrii`$T3*l5t_y2j zi)rPe<>Hy+F1XL-WOebll7@FZtv}`!-4B?~d0`*Jq&Rj}X!dASg-|HGOc@-c-_z&g zSQv(4B(P=VjUhs71>unJ#`PJW{m952_}$F(R2{k;`pU)Sx)VDiFksm*(Mi{;GR6}$ zOg|%4@Hml6WsCN5r^KT32G1%~MiqD9>NCKoL#8C=c~}l+dM0Lz?T%-kV+WB%>U$R^ z)Vo0}o%_p~6Q&(sRDJjEO4TgMxanKEs1KSKrM6UCCpuYepo~d&8Bf-*M(ysU8Q*9h zt!kEXR$Uqh--05ySQ~`1_*NUV>O5cI9EL7r-b%t&&JZ@=l%B%&z(-}!HXO(*!w2vK#st2Fn zSB285w17Eq4H`$VBF+GR*n+`CaW~fV!0c`=%^9P?jT*NQ*SEvxDwqv zb6~kamw7TH`>hK}f6#8rZOIxwjkKG+taW3tV9|O^R?P}v7eM} zXYtxmGI)QGO!chK}eg$VOgKziv)H6zH6`|UDgLxP2&lj$I zo1;HB1sph*kqb-F!VO$G2oOR?$cqfyvAkP9Lmu|~aY*V96cB~)_#N#$k8k;SG|RIw zL(Ja*r~>6&yRedmR7TATuH)u zkS4|K*9t2rgyNb=oUdbAB#RXja-99x5#%1ZHMA#wz)so66-_FJH9!=^U|z_N`>x3W zke07|o}qBgaO?Eq@*Z@)SK7%RwCt;gNQ~p2!9fURB_u-6+EXj` zR~5KAHvcfg>aQtBAiv9H%Pp?Ia+v$TmbDTa;v5EVDu5{y4OpU76h$Qr@zS^^NFIVn zE}nU0#nIIMq7ZgdOM*M(pE-nekChH;#Nu=07&jNk!H+`V>Jt3qropK46>MWhhIrv7 zqN%_wYuzSDg~|vCA>j^C=D3|#-)%Q?x0AFtGiq(KCf0ifs^kMre_o#8StC!A=g_h= zjd-}bGXk1|R$_8MU|brDUUG&sz!$B>#TYBxN=#F!fS`V%1(Q#9?XK}gKEd9=2hLg7 zXa`@TT`0>64(rEt_CqRQT@r4?CVT!{*T;>;9yi2dujDP>xak-4!M#lrMs`T?MI_Hp z-Ep*j0*k8(`cBqYm?RI108x#p?Cp-9i0GQAUo0f&aJd~RYfTzf6$%U5(6J^TCo*R8 zh+yy(zKlSOh0wIRxV;G?1*blXdt1uBff%>g#CuSUhZM4jUm=E+1?s%7O0~HY-i~8- z;nm!QPbsD4)7&(hc{AIRG@2n|%lF+5hL60je&6NFlG77ayWY2WL~*Fc`8=qI>xfG& ze^&6O{g_C94`NGKg*=5O77&@aPg)*Z8bXdvFa^a%HY+j9bFGiW=6euY%TBfl#$e9Z ztD??XU}xDRWU+SHgmWTBeUlX}!LXJb`lLMCW-;9wUasfm17Y^=j-uFg?7S=swT9eP?C>P5}R#nqFo|O#}`J z0Dvk601*FC(>pr3SsDKxa?W(u9M;59ytc|-ArrEk3Gt-J#6Fc#QA;%~RW;%d#&J%G z=u?q^f-nHI)VjP6A?!wme2;{}6?L?$>Lh)=Oqh!0joR!}34Pw0B>k)T7=O@pa&f)w z91(4sr-<3R``G{5zc??S3&^3;#Hl5dejMyYWkm3PJO1hOe0kYHm5rkJ0hEY-5_4y; zw}USEFeF5b>kwA$C5GOO#(A>Nv-i^_d$)j2&5J>!C%Q>EIqj}_#=!pE&i0X+r@sCb z1J#NZM##%oj4BL1`ovd*NmRd-!a+Vg4D?ka%!XmQ=p;9ct2`vgPD!>+|3PzjM?O%& zaIWw5Bgvc9wh`j|9LQr8OssvYtJ#fT``fN() zYZl~>1y}ApA~3t&N0Y~rw!2Leg-}ROyOM!^Ejys;FM_8Ty4o8@=UKzdZ8I%r(Ni3> zXNR2C-7R0AA&crq*1>`lg-)ga*u5?Brbzh6*{F*U{>UV~2gz(90DUa3`H(iXOhXO( z$lg6Vp?)p<>Xu$;jLer+PZF`qJM($why>%jyIm&yLGWy+f{HObh5aI995K?PFRy_E z=#idwRN9`~z)R+1Y`6{Su6!xcX`o`=nv}V^oK$nuwK_DX{@LbyQZv0MuQVJisk^OH zC3rahnykxop9a%j##{9%$>BNuWIryb_5>|GQAf0K z_cHcO2xXjzCnZDe^T%qkWGhvZB4qHK_r;nJUuNVp(WCYSWR0`^dc?jZS10Xn_?VDo z^tG4c&*^-V4#>ug2$PfFhqSvww+0mllutvB`Jx_d9kaZtT^lyvPPO+(Ohi&Dl`h@5 zuxS}TeLI?Hj$t%jNB2;s;C>~8M|nI|?Xb~FeQyTk#JAt}x%3CfTp(v&Aj~xkNMlloqnx2fxS)2HW$Ld*MqzP)M zP+S9jxiO_ujYK^?c@9~#uG#hyqGTaySbK?d>5D#ewSKw(qQMs#8d5Dx1`X3F68_@G zAclgz5B9SZu>J&9HoVwzz)zAMp8;2_8E%mn*ER76B z_*fxQ9O?jZHhqXlv-JiarO0&P22S7x8;HvHmW@HaUPaV3D76 z*a>1gEci(L7F=J+?-XU?_dlHD%YJlhPfLo-Q*Tzw+`-3be zU8tk|`GL4)Jc(r@0ZatUH8Ih+iKw3~vB&3SsQA6$c)L*2e~vOQzmtpxk-FwIWt%KN zwk{M(N``fpjeLHC47 z!*vXzAi0 z57_WM^{K(NQ-SoztjP<_OjhW;EJ0}Q%m29fHk#c#9!Hm5iP8pA0T>mp0VTujqtm3H z^}h;5C)fd@54@eIMHmjer9e9}EP|&lB=fw9o00gg){Ki2`4I=fH=p zc<IiksUay-lMsJxpwc z(f=EjbQ~RNS!Ja>*OK*2yqSQ+svWOzGJp zaaVS7?|Dbn-5S$werA$tUzb5sdv6glVYVLm>CUjGr=nU<4g(autdBYY`aZADF~YR#l>vUA~M#gGQaKzo1re7zOg5n zI?GI}oG-Y@`?ge5%w&=rR+q~xopUm9ef23h?M&e+1V_ZN58hzZu@=R?=W<5iv;X?(~z|)@(fdOiXIZ}2MbEjx{CLR(V9hvt4(iGY6+obn=Ke&YzH6l z;oFJi1i=tb3!gJ916KoKzIO(~C{7CpA(`b0Lp3Pue1M}=F^fi}X}CwG5xoR{gkaHy z{|5QTKKzGwa*m9-!e|W>UukU;5G*gu~U|NmgbA3l}ztV@?5fzzu%`C-rm3c{)i zSR66_Z*aPF|BK_9UCTR$&3;XPW$rpT?l9_pE~K3+ox00k=a{eIRav0rRJ%7tuBtmD zHuL;tS8^3;Lv+a&#;TgJE940 z2dY`jWE=^>PMfa@P979Hqq$O^Cu**xs~e4|p*25<$)2>Km3*%ZW>^7BSIUvp$#^Pq zI#HYx^qV{-T$TnWTABf80^x#!iALFL(201D5|OiTyDb07KapQ2Vm%ffo5%s)`&X;>=d5yA%dV#20~9*gyR1jgMmK zH5ap1;)1qQ>4mCjChb?^WYchSX+3NLtJvC*6+C$iehswcPQ$h5zLX3ZOUtS}!!AVn z7UCq+bC&{tSE}sR6{?j1Vd)k+P4lAc4B8fXs{&l@z%%_%Vjz}XapOAhEby`zxZbD~ zIi9rsn;r_F9wpR;7OT=Qpt8M!e+adHJCe;m34cG5!*9sng}?ue=sq$ESMd3B|9xY@ z`>qz3ij?Gv_i^a%0&d2-J=$o{RgqU;p0WPn$o*=vCarG@FI{Lx6kEPF{8ebjoIN;# zxBd5uoid4RN&L{?PYg`zPik6q%C%L3dDF^zCmMek0lM|pKQ%HL|0wZ}%?H7e-w#K| zZITWtX;PoinbNGfuhMONibURh(jb_+CW$KBkdO%2(RVQE3)bv7^Sj=soatJn=!Wuk z&F!jOVox7)`4)zZbrLl3oyD|RyfM|=axSD=Y9lUpvSFre1n;@iIF`HhI&y{L-nN3Z z*`QU=RL-WWY$+ACaO~8(6JK^{jB`F(^+OAn{-^iTiK`+s5T{!;(u(B5at4mI@!4bR zMSuH`ldN_;(a=I{ldMPEe3qIgTh^ghsT<=J>CJPUWh1{bTbHI|tlN_}7w0;I(Ve?a+X+I<9FDDO;Z&(~uUIr#yR@=@{Si>F&?pCD5FS$SYpu z-Y00h`&4=?E!n!e4}{H;e)rO5Be1{nat%}(9S_In_Y!0TS=$4n2rs>i#-RE z!&InSwp8Haj(=~$7p`h+a1LqLs5KVuy8VIP<(b#)&SVP2+VV>EG@ zpZN|deT(ZqfAwjtYg;2|v^SqR>ioyd#!n?RNA0JIvd&6yL0u$g9xYRO+TySxEl=0% zPZAypdP?_fPeW`8%RUqFd!%#SlSvoZ6rL3OBzFXarT6wW=h-KP%t+1VR3v_bPs;7 z0QH?v%l<(eN3VP6Sj$dgIG#Z98A1D*VVhWUrjY}amQu7Lyv_i+gKJHjH7Yg^HUr zZy@3Yc44q<%<2god(Eh_nFF4=sjDbvF_0b2Sf($yEYlA(wabtq+{p0AsaD7Pu_I1y zNX=Lc-kv7JZV=_V&zs+YaeFiB7P&OvTNQpBZ0fZ3^HgCuJ>`L?$&I(#a-8$)-^59!!Pm_aZa zS{Af!YrrJ{1@qAhyzN-jX7cmHz-20Mva?7}Sw9`SGf_Amrc^ctj5YO=uYJ{}zV!18 zfm#xa%YK<|&O}n{{*mO{X&=s4oDPCAah|8Lfgke|eR5%EU-!~<)%4gk8OLk+)lPDQ zZ!dH|!i5-`otRREL@b~)xOsi*GMs8LT+^w_Z=Z*>-sDy9hO>RH>0P_Bi<;d=EDOOUyS)H~t#ZF(~Ja^hUlL zrj(82JXPoF1%8{V9mm2X(RA7h^C;V%SXet(dlK1}Q{G*geFOjIV}XtKpjdu-$B%Cn z`Xywzi)*8KXymq>TNkY5O>B!wQ};wfY_!y7AwrGV|C7T+5}OeuMeM?BvNoxf7mfE5 zA#hsYUz>pxn@^Iqz@{)gaK;kt9}@>I#)is&ny}26+py^)LIZ3+lY4{&dld4pOB=Ck zVkB?SNjLEW`bx<`i$Qe0$_tb%EVvrEC?iZ?@BiZbe9`!+tCuH?f->LW6ABYv5Lw&$j;oLAx4&7)sC&?nwN^2p6$n`3)~`->5C^O0_uGj)v?W2hj_v?!@v6f88G zv1=)6?rrO6M5{^2vzkwSH*)|tAEoD^dtst0sw~MBexkA_seJP_7tr?v|IT&xj2&Gm zBx14@o0rYX%d6y+@s2(RGEjxd?9H`+oSw2-oAE2}Y&df=bV%nwb5zHT8lxo{~6 zwkXE7o#JM#;TW6m$Tc|4v>G=csZuqjuTrLO@DmP~cmLx+5wyWp#IUt_fbbyE*(yoL z=ZcQ`D@Zl7Y`R4Ry2ySR>WZx`J>d{NzN^81-{T)sAKAqOa{D%b9b1%t+#nMm?N-IW z(8~CC0#p2&#Tsx5@F%PnWbJ*e+7)qB7=9W>@a9b(8DfoIWd*qh60haB_w!`c7m3~k z@gU()xY;AZsp0Q08O!E3^4=W%=jmDLAiAX75E3G9YaQJ^Cm zC#T74CW>0S#J%oduoKAzS6zAq)W1HNL6B<028_<~Oz?HJS(l}HtSqRnpQO39#(sJ- zN>k<`#UJVnF&tNNxYU1?gWWaqItdicV^sAb{;*`eI{-7(}=YOV-v+#SSt}o_&uMi@vs zAdwJeKS4z+Y#Isegj--;>?z_{q?)1!oY2>*^oPb1)bi_(T_t~1hSOfXf#W;b&OHbx z)HcFjqaxcWmHDMlj*NG4B=0()fR{(GI163~17c&n;MFQs&m=>7qn{aEFee%(!gy zRqEYlnCOg$w2cbh{Y2DfnYCJm;r=ESylRsn|B=bv{hO@Gqrl*~={E}N>vwa+Xao4@ z(`BFbke?+wQ^og~9xad01X`26{2$WeA)y-$ho>y>FH&cRh&Qz_;N%VWvl$GV+u$Qa} z^bv+9FCCP9#B5YYQ^;aT)3AXZQTBw_{c`ttOVC>T0u@ske&AE)6jbtNT=rC*SxS{2 zZ&!daIXkP3dklAkUv3)h>1OQ$0UDkzk`d;G!4lr`oVn}wEoc7Rot{&lSPA%<;^VUfvB8)=ueU3+*(}->a2N!v0o@1W6(pHr0h}L9W zNlHi4&1_oqF2-0nxHz<^$8gWRw?R1rf7eG0mR8%xAUPZV*nXteUQ2Y3@Vp3O5jAFw z%Bc!x4||5+x3Prj7j#XPZ|5zz0nQ1H#$q2VHZ|`@a%aHK3U_5eC>o8A`s|wic#5Z9 z2E{o~%??z|GyGV9`sI2XmawaG(e(`a-gx*JtorAPTPO}CGo9Iu4m2L38bZ*dCi}h^ zyi?lQXBh3|HP!U~ultq7p|Ap85SNK1Z1^)m80MW32hyMra;9oWUl|c4~ zu0iNmqTCyy}-Z87db{=gYGGN%S+4WyV63t$aMRK}ln%$fxd&aUPERt18 zGKX1}<;*Ppj3C2U9Ryzt>_WB-$O3c_um`PS0V`kyUkt2$kbM$#&~2^50K2ec9~LrT zK!y!}??q&0WJYGPvNF5XYBd{dR%J$f@gm+&ym;|<^#14GdV5X$e0l9pf9H=KECnb)(mgXTyJUy z`0AZE=jQm*Jhz&iR%_543p&;VqNDNP{+@MaE}X#FzX5rI63=q{h9L>2vbG!MeD2s| zGXSN{tJx$~%DOD!9&&OT|H7X8{}5y}nwcfgoBRS~zNcs!&z@Muk?93b%qhrdUc26; zIdR8}DcX`6u>dB6PWSB09$QgJ#(SvFhhW1brtF0W{ER^8fA1bTCX&k&x9R z&z<|wXm5pThbl|C*P#S+%L{BPQraw`k4l6!s9Zmb?9IfQWR%@x`}W`{VNieW>tFiL zlh3ZL(eIDKpnjFCRFf=@zq%oPrrN6&4Qj&X!Jw*`#-qu0!+SL8HVk1$?>5%LMD0lv zCv0}2h-OC10p9e^tzf&M@dySui=xi*6eYTkBq>uXFeheUvJ5Jm%%CTd(j0=?%M)~W z6D3o`)acy@sA;P0<11qKD?zAcDC3?Y5tKH&HaSt{!XICb=ZuE;Ye`|cR9NNS)uhEY{)Y`q~ z_yV1+brWN>OM~>Ao6QX!sIvkPYS?M(P~Fl{gCRj#)Z5UA3S`l}&jRBo^uQb(t0 zxEfMdN7`5oNfW23F5nsd>eTcu7xQ%p{2boosqNUoEgQm6D&d@6u0RoTMF`U_EOW{l zXX=`w9mD3-omftH5~8@dMTw6M|o5#a4q;o%&yhcYl} zALL!nUCg7#o7>gU9>pg6nnEBYC_}7+s;Y?jT#uF%$%N)!9bcGpt6@yXTaV6Xu1CYo z?Z$PxXI!`Y_yq^(f2aWc;g8l0KPUN-AI=>5Fj*L65;0o1-a2(BxAb?87rm{y>6tWW z=mee!XQnw&{oO{0U>PBe!@PnCCg~P!LiHaD0$~ zDnblS9r;JR#rKZ@a*di(D^Sb}v8+Yl$|6pk$L6iO2%>jq_Kh`(U%KvPNJ9%hgmGqj ze(=l(XncYlBy<7p^c z?r7L+?WIgd_3E@=e04gVLA$d@+N6kLZ@0U-(Qj?m%$-htb%;rVjdyBKd-Gs(IAWWu zko=lzPa8eeW@ktpP(fu^)*kLDlKMIr?hkw2jlE=vgHg9N+&!SD8MbXjeQm!4eeLYG zhlBP3b?Qn3_73`+{r%xV&DDAF`r7Zb`-4Fn@jSIU-Ok2ttH0T+xjOae>tMe(=#0_= z^}6b7x6>N6_EVl^duPzz8}7tzq|&p83RSwd>%0W*8SHHK;VF+&;x^da>GXGYY8AK6 z_{D3_us0gC`v<$p>hwE1-JQ`vy}n3ie7E)pZ;O3<)zijfd?jBcX_;G_y;f&$k9uXr zl%*9Zt1mcGUCB}c#~p-kjWHky^JU0x0-p&#KmG*i;Ze6cogVw@7~{A58~7~xoOAfD zND;VmJnxAlo_&6SS(fmt@Lc2Xj&-KIy}($?LcNCY*a39rx`Fcbd=cuTjB!OoB1Qgk$4u)Dkw72vuI%sn$-K7IHRV_G%IsOskv>iL!BrJr}j!rq}>^tZTfODJ*Faj!t<>; zB-A=}CKewk1jLIRa*D@5qrS;ap7xGQ*QV5SXn!=A3eYN z#ygF*HTwOjP}R~^S$*Qr++r*lTQ{|+SjMzKDiSW@&of?A^OV=b7$>xN0RbrqzVG1c zEb!^U*zt~Ob&+MLIXLa)vP~)cTYuzu21k%EmpE~+PY|(c7##g@*tB!}1l((yTQZXq zp(n3T^FoHhLmxMfjY+3G{UP8}u*2;{@h-jG78sR7|+B zt&NUMt0WR1Gq5*@?QSx?g5T9>Zf_2DJG}%xCldHQEq&`?bAPX&NY9Bx`o5Nar`6fT zcwe-gl1O|UXz6?VJHyU?B0VR%K+kijrydL`WFpX`BxkPkFf;k)V9*-w$>eb=a5a)H zDuVPZPu8a9$F6sP1eB20navD}@0@Xmxib%3-`KN##3pYW#@w8_6cBH9+HFW4yprf2 zx45}wnM!V=3zYcUBcjLf$DTb$zy2Oj8%mPz|G^)8`nBKx^lRVw^jClN)35z|<1sl0 z0xFU3p*cHO^ZofZzy3dd>z^Bc_U+&P^I!j0+37KfAJ^>%|L$M>;6MDwB6z4;oc#O$ z`uBeD`@i@7fAcT@`|tjG2Ko*H*J|<;dusW{6YJV|=1x&TZi*!R)~Q!6$H|3ht2uVC zOwF8`R7wW^esGaU{p8kkI5a5aA!`3!E7^`8dN4Iv3Ey3OoFMr4!dnD(M#=AAOcMzY zUDw$~WIwAF-lwvZW+ULEBT?D)DZd(KA5vyc7JGF33lIY4W7fwsU*b$LT?scMlIg8+=Tod z-#TZem8E1m-ZWuicRV{&I(8T53DJIRVI|)+hFg~L{80u4yY4*E<}uOlUBIe5w6X;2 zJu@k*^nGSwmXDnq-A+EX{e;LKTjy>LJ%_g`!*grSuV&i%) zRSpchny0XBPE03JKrApPrfodVGPlT*V1Jy2I#{nkzzI`T($C2=B@a{HOe0{hi>Y4*&;?8rhdqT8!C z9?ct%rt#W)GQw>|532$HcA+yc!BGh%gTaeWtlFpEKKWczxPq zpTj;rMl+KGJfwWQ?%IlCfb$D{q;3ucB+{LGCg(9cH~7d7E_fXuCT%!^dG1T-bKjV| zSRvTr$C=n1cv?&k-H8Zwa%_pGVo;(P#LruOS}n#sF2YoSNIe+JheRL8Q$a6Nd(L6j zkO0bCh6tswBoW;VB8K6^JBXmDSv^#2W>B#~@FA> zFbI~?Nl)`zIVPhluQy|sCdi|vkZqxmG2AGkVpU!vU)nY}Gu2W!GlS=Lp>z#nY+?@g z3^8MToX#OUJ`K4{=N!v4$AKp_eihR)$70YutnC&p&Q?e8XKG_|*KllXv!MD%d~jxL zLnmG8OUbw-0KO(J{3({t)!)kaNpr%~S}Xvpn$@6JRm7x#}?8XlPswKJt|Guwnl*J4_Voc9@K|e3~RJtl%A_ zEz?UVW`7XErrIs)Br=P`!}55tP!IJ+saXdxHH_oh;8$%(jM8)0~>JQRfw9%=Gq>8D)CP zDik)#EZo>gsDx4`Ge^xTBLs5P2awiG_;t%#|A&xCJ# z%$}XFy&(H0koEUw7s)qwi0y(#G?E^p8);~z2DnwA+hX!ZZL{Ln}uzGk4#r;sn&PD^~1Nm_rBB9 zvI%S2l3RW&EPBG!Zmx`VD|arL3U`{l-0iGy_KK!hm#K2$PQ!3}=9|5uS*JL3{@U&p z_4O^R87j;@llj;_^-ND}q$=kD9o4-1GyDA`9^L_%s?Ar3vr*a^x;(NPFm618cA_~(uq~k1*Yr1vnUG5^V~tdu>giMB#hwk!S>rcIOt@Oy?&;FWfDDAo~yBxFR=yyJ1aigdP34J(-#q zB(_@}UNtLIV?iD5KUdQd5+$A|o*HzF1Wj6ism(-YmiQDc)Qyd4cKi5tdK&jm_XOb% zVp7Kz9W@ZRw8e${j(^1Oux$ssK_1Z&20ih--|Y=|+8bOj4m#N4qmS6wfbSR>?wYZp0+su4z!^$9J-Laei$|yJ+5%0V~Z`$4EAtl2pou; zU?En}l3PR#BmOGQg-~bm8?_-1@VL?OC;K;JYfeKRlqZH` zPbdQxlw%BL$AvRN|1<;>o#9qye%kd(Eys?J3c=);f?)s1S8AJ$xk%NWQbg48>j;fT#$?t$IkyaRGT{lEOc!evK+nS52G65ZA~2F^&*J%o zF;5Ovf>Z>%K2RDK8neaplr}17xv3@EaE_hLbz$L(2{sA!)igw`#mGbgjq&pZ4Rdh= zN9FdLnz%BcGD_f@MqTy+8%nAhWCAgE7mh<4mMe-FV?C)yTp26J3o9Y@43pz@m4(Ui zvC6{axKw3f1kdsu)fLfdYAH!uO${c|YHBwjpLMmHM60RYBw9`FCef<5+ld{}w(Vr- zuDyKhZsfRh6If;Ya%`Vt`W-GxWh5Cwr903}tX7#{7&J+n4Xb!sA~sY)w|244RqO|> zYFi_uy8L>!j17x6_cWuRp-Y#EHO(30`{la0_EF2QIddI zzN_C0?_R?SrpgthmBTo*X_lPOy>{aTA)qtXx?J6blV& z!<3Q0zyFbS`VZHw{A~elkUo@qxp{zzTs*70GG4Wf_RMc*2GAmdR=KP^EF~OHH5yH7ay8fmx@4V z<(f*R;$_H{!IZEeR+rMoxyq1FT%2iHLuBabBH;KVyY|&*dlPWgy_f`Cbr*rTiSB{q zH*Zq0#JDd3SG}?cxVkDk!ZE_WojZkEy0#%+UDXZo>aK6~!Jl9U?v8T3vxQTexI}=p zwO~kLZB58`aQSuC70~KJ*KQKHZWLS zJYcX|8IZGg!?@$hu@sfvijND3WYV=Qv8&yRAF`5Nd`aInHsOc}-|Xo_mH6 zHAb>x5qG8Wogk26)O9-B0~a%wd6SN@_W$UMAmkvqYJ!r-^zN>BAnj?naRtHCK4- zLT2kkgT@@;8x~1XlsQZOaQA4w(nLA4L~vLnMF~|gc6fJkh9L5W?wmG`btXT5QMF^` z$Xj8;#iZBUn;=%wbIK3HI$ zG@ZZ0%c7Uc{|JuL43|e_;&hjvxKiy?0V|d^H~Fks+V12-ENy`DA(pa4x!FsZq?JX(BRfj08s5(R`Mb)yeq^LSdk$gs4rYfoq zQ6!=fgsP}IM3H=EDk@KCR8Ukse^Eo|imK-?YK4@FMT)AU6g5N&B~nx!rL>~z5TzAW zhbXP6Iz(wj)gekNst!>~QMK$VDXNZAB%hI%sfwyY6p3gAp(?5lQ6!(4iprCP6%_9APoWY?Rx$VMv}^%gF&%t}VRfs5?1lCf{l zoMXD8xLzq= zg(=V2e5leK&WBo!)w-IMWwnk_meo2!Syt-^E3sOGS)J87)XJ>Zp;lwHZWzn5T1P0$ zY8|00t968xSgpaV&T1WMWmfA@tFc<|%F41@M<~l`9ic3%b%d2zt--9$Y8`52R_jo! zv08WT%CcHVD9dUcp)9L)gq2vW!K}_|9cpD(>rkt)S{o(z%CcHVD9dUcp)9L)gq2vW z!K}_|9cpD(>rh3k<|v97YOOL_QnG74La&IiN=B{6=@s!)$*9?{UJ)CWj4OrgART8CPV)p}P}meo2!Syt-^Wm&Bwti);!W_4EUP%E=qhgyx*nk!!D zLT4Fp9ic3%b%e64))7`>wFa{~t97WAS*=5@#%j$KFU@Klp)9L)gtDyG5msWg2D3V= zb*PnDtwR;D`j9R#&Dvf=yCqBP#XrWKx`9#O33Lxl{~|D}AItiDhC|+6=gOKG^;Y#U zu92j@LBK!CHPwi|RaBhLjBCN8|93nuC6M=F65KEef8@|r5j zO!rpd72h%@AM?DJoUX-Zl*k$HC@KP{!#y!q+~!-dRCswJIJUuY38!@TdgDZmxLA=k5siGpDRTPz8uBgcM zJ@tr6?-dsnbZzJ)q#+8;zTVHV`qVEeHrrWyB)Z|MJy=DF=;um=T(?z^MD$+|iC`_M zUn2gBWk6g3o8{cf*i=0-FnGmf0EQs83c1cNt3~}HP+%1LoAfX3*3zv@u~U0u(`~*x z%&&?TVVG+XWJIDKX&Am1(hxSd9B!qDLbW&(FPZ-vx%`)2Nj?1Ect!ayy@Fi*XC-*4 z`rjGSzh(4)k2zFP|2K2_F9Q(u@PG5KDgVPJevukqKgY+2E16??FndlMO#CJ{}D_D+Nm85tbAE9wA=&55f7;qmV|H1mR%!T_MbNpfRqQl{^s5Fw>g z=g6bhzz1BMf*M|LH`rqkk;=)9DGI^+k>xmtrbvzJCUTQH))_?x0n~2sd7LSzQ#T0Q zX?}i>Ha+A6pjlO7#8;|BetXaOCP2A4Ca2AxxHMO#S&8rr`y5f&T(YI}X-mlPc;TaI zjwuG5jt3`VscHF@FO9Yl+n1|cWdIXjEmOK;adFtR<&u2 zD#62&+!ArVJZ|-B;+70ezB=407^|9!V6 zd6b#z4azPhVx-uR>8*`hz`2}KD?Q@XLh`8@v6RX*_C{m zucWmMa?5%lh!D@sF#?mJBDUoKBwoK4E9;1sA+lC4yJ}`xKQh6RJmyzb(6Era&|+Ki ze0d#x9a)yGnc_}?G-t%qI&_keiWCy}AXMpH!?n1ySdwu*9*43##+r28tc$0bZWj2K z2sz{5GP8$bMQnZDSlm>Z?5L_G3QzTJEabwSD)LfWE?Z=2HsvT<=bmmW-WL?VzDtJn zrohD>kvYYV>Bn z@p>hiK~ByTNXH9_^koZ(Yzraqc@3qgL&X7vT3nH(!%ZzET2y-c^WBLuFUozik6vBg z@%_nmgLmIiUr+OTY`1unhPl>N!s5{k>I*K3EV8Uw?5BjbJh`uezHz&$I<_(3dh>+t zc7wiglB&jkg*L0t{%dknD7sa<1GO~Kc)AS;CALbh@Zy^^Z6o8OIrADYPQg->(*H76 zGMwsLZa5*WodpA?({#Wpyab-a)+!?ISJ(wM9Hg;*_*LLIi z$-z1nXZV3Ro0yJE@s3-|Z+zv!J8xq(hyrB*W#(^9$J>pIAee78oBsI1nwtJScW%w_ z^33(7W`M8Wc@z5rFBkLmu{)icfqiN_c5vJ5v|8AvC}6y0P~5XKdu;8wvm5An%ZN}_1&}J9xBrm z6g9b-JFRw$KTVw&pkEH4qX7)Y)=fE#4T;f2&X|zF*v96;RI3zLW)R`f6^>zx49bCo z#3%rhMJb3*H>87FxjmRH6fA!dOfJg7g*<9fUNbN+u*Wosc;=Kt?1cmZQ3$=`j6RHo(o5#a5VcsLzzJvyJ^>fKX{HbN?m>vkW%1KcWy|G`=M!ym01eooCIKb&DB zBexovQ+FQNQ~ML^0J{o3_u98SHfD6!>h;l+cku<5MN#sUdHJs&{o$wI`&(;k^!s;0 z^KyomC$BoIYFr4&m#y~3tc)#|0$8OtW<@i!Z_Em7|EpqFNOKXlp*=O&mN5@ppVv#$ zSlGAd4Ub1!awKqOPHpEleCl7@zAs$ZxgCsQwnXYH)3bRwEL$kwrcmg@KW$$w{|HZp8~t6X5uU{DAdYq~YzKi; zq8*6M#-8oZ9rO0}W%r3awS41=b!|Lzr{*k&@f|R}+vaaqcjRDhSv zc&Q2LvBt)?{?Gh>es*n*et#U!z{DLNc`gP*W7o5)`2yql7J(3)_9vgX4yOs9-$#pITDRn3 z`^cU6hU^o|AA2@@M|UQ$6AzHu!Lb&H7(t%d)@1iKS&aUgO7rBE&JWsLT=W%na)jyxSDeDhIvw0CmQcmO*)Sa0>#JKdA^V5>jeYPJ3e)hdyn8cRHwO8S;Z+(;sZ z0HT4C0-AD^AN=lr`Op6edO^Q`Th$G7KCh-4X`P4!r(pD*HEzZ#1#333CK)Pmg(!Gx zxRRk|S)ic_`0xa>Pd>njHAVLjSP!01FaR(evw;r>S~ zlkL|1rucN9g0#H3$A8e@xZhO1-rt$e9ed32fCq=+K;3xCVFQD*-*2i(?jw|NY>gKl zboBuWP`}=POh?BM9pVG%l4tWs;-YW&kH@BCjbK?GoSBYq-EYPZ?vE(qF}sDxM4y0n z=|7*JxO>zvGE4HIQpb<%;NqC#!~TQ8P_3=<;yw;}@vI5R58>hm_oLI8e6-Z;+?s@G z^cU2AABwopgZAJa`A6Jf;WO|gvY(2;Kl!!a_(jNg^z(UqB1_UddjB(@{mHdBI`yZ& z^T&>XxAglHQS$J^FO1-B!RCIIh3A(@P0|2A_w_G*=LuAbet$H~$A3QnEeK_vE{O(f!SzSzDuD>Z{_PKg3X$iG}QPSShRAKbd^{gZAgv*68V4i*68=g5)?c-4ztrfBaRiOGpFN=asGeymol>wJ;}kUsXaB<6N_J_c)!bQ9E^?EB|KDHy YOepi8{{lV!BL4j^i0PN!`3%1Oe}c9Nxc~qF literal 0 HcmV?d00001 From 3eb4571181429e2dd2c3a2f8243bb5323e88ad45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Thu, 23 Apr 2026 17:12:34 -0300 Subject: [PATCH 34/43] =?UTF-8?q?refactor(painter):=20remove=20blockLookup?= =?UTF-8?q?=20=E2=80=94=20painter=20reads=20only=20resolved=20data=20(#282?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../painters/dom/src/between-borders.test.ts | 167 +++---- .../paragraph-borders/group-analysis.ts | 131 +---- .../src/features/paragraph-borders/index.ts | 5 +- .../src/features/paragraph-borders/types.ts | 15 - .../painters/dom/src/index.test.ts | 246 +++------- .../layout-engine/painters/dom/src/index.ts | 152 +----- .../painters/dom/src/renderer.ts | 448 ++++-------------- .../painters/dom/src/virtualization.test.ts | 6 - .../presentation-editor/PresentationEditor.ts | 41 -- .../tests/PresentationEditor.test.ts | 31 -- 10 files changed, 268 insertions(+), 974 deletions(-) delete mode 100644 packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index f9ae6037fa..2218a18b0d 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -1,14 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { applyParagraphBorderStyles, - getFragmentParagraphBorders, computeBetweenBorderFlags, createParagraphDecorationLayers, getParagraphBorderBox, computeBorderSpaceExpansion, - type BlockLookup, type BetweenBorderInfo, } from './features/paragraph-borders/index.js'; +import { hashParagraphBorders } from './paragraph-hash-utils.js'; /** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */ const betweenOn: BetweenBorderInfo = { @@ -36,6 +35,8 @@ import type { ParaFragment, ListItemFragment, ImageFragment, + ResolvedPaintItem, + ResolvedFragmentItem, } from '@superdoc/contracts'; // --------------------------------------------------------------------------- @@ -65,24 +66,54 @@ const makeListBlock = (id: string, items: { itemId: string; borders?: ParagraphB })), }); -const stubMeasure = { kind: 'paragraph' as const, lines: [], totalHeight: 0 }; -const stubListMeasure = { - kind: 'list' as const, - items: [], - totalHeight: 0, +/** + * Test surrogate for the old BlockLookup — a list of blocks keyed by id that + * `buildResolvedItems` consumes to synthesize per-fragment ResolvedPaintItems. + */ +type TestBlockList = ReadonlyArray; + +const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): TestBlockList => + entries.map((e) => e.block); + +/** + * Build resolved items aligned 1:1 with the given fragments. + * Looks up each fragment's block (+ list item) to extract paragraph borders, + * then produces a ResolvedFragmentItem carrying the borders and a border hash. + */ +const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockList): ResolvedPaintItem[] => { + const byId = new Map(blocks.map((b) => [b.id, b])); + return fragments.map((fragment, index): ResolvedPaintItem => { + const block = byId.get(fragment.blockId); + let borders: ParagraphBorders | undefined; + + if (fragment.kind === 'para' && block?.kind === 'paragraph') { + borders = block.attrs?.borders; + } else if (fragment.kind === 'list-item' && block?.kind === 'list') { + const item = block.items.find((listItem) => listItem.id === fragment.itemId); + borders = item?.paragraph.attrs?.borders; + } + + const item: ResolvedFragmentItem = { + kind: 'fragment', + id: `item:${index}`, + pageIndex: 0, + x: fragment.x, + y: fragment.y, + width: fragment.width, + height: 'height' in fragment && typeof fragment.height === 'number' ? fragment.height : 0, + fragmentKind: fragment.kind, + blockId: fragment.blockId, + fragmentIndex: index, + paragraphBorders: borders, + paragraphBorderHash: borders ? hashParagraphBorders(borders) : undefined, + }; + return item; + }); }; -const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): BlockLookup => { - const map: BlockLookup = new Map(); - for (const e of entries) { - map.set(e.block.id, { - block: e.block, - measure: (e.measure ?? (e.block.kind === 'list' ? stubListMeasure : stubMeasure)) as never, - version: '1', - }); - } - return map; -}; +/** Test helper: run computeBetweenBorderFlags given fragments and the underlying blocks. */ +const runFlags = (fragments: readonly Fragment[], blocks: TestBlockList) => + computeBetweenBorderFlags(fragments, buildResolvedItems(fragments, blocks)); const paraFragment = (blockId: string, overrides?: Partial): ParaFragment => ({ kind: 'para', @@ -398,56 +429,6 @@ describe('createParagraphDecorationLayers — gap extension', () => { }); }); -// --------------------------------------------------------------------------- -// getFragmentParagraphBorders -// --------------------------------------------------------------------------- - -describe('getFragmentParagraphBorders', () => { - it('returns borders from a paragraph block', () => { - const borders: ParagraphBorders = { top: { style: 'solid', width: 1 } }; - const block = makeParagraphBlock('b1', borders); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toEqual(borders); - }); - - it('returns undefined for paragraph block without borders', () => { - const block = makeParagraphBlock('b1'); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toBeUndefined(); - }); - - it('returns borders from a list-item block', () => { - const borders: ParagraphBorders = { between: { style: 'solid', width: 1 } }; - const block = makeListBlock('l1', [{ itemId: 'i1', borders }]); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(listItemFragment('l1', 'i1'), lookup)).toEqual(borders); - }); - - it('returns undefined when list item is not found', () => { - const block = makeListBlock('l1', [{ itemId: 'i1' }]); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(listItemFragment('l1', 'missing'), lookup)).toBeUndefined(); - }); - - it('returns undefined when blockId is not in lookup', () => { - const lookup = buildLookup([]); - expect(getFragmentParagraphBorders(paraFragment('missing'), lookup)).toBeUndefined(); - }); - - it('returns undefined for image fragment', () => { - const block = makeParagraphBlock('b1', { top: { style: 'solid', width: 1 } }); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(imageFragment('b1'), lookup)).toBeUndefined(); - }); - - it('returns undefined for kind/block mismatch (para fragment with list block)', () => { - const block = makeListBlock('l1', [{ itemId: 'i1' }]); - const lookup = buildLookup([{ block }]); - // para fragment referencing a list block - expect(getFragmentParagraphBorders(paraFragment('l1'), lookup)).toBeUndefined(); - }); -}); - // --------------------------------------------------------------------------- // computeBetweenBorderFlags // --------------------------------------------------------------------------- @@ -460,7 +441,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); expect(flags.get(0)?.showBetweenBorder).toBe(true); // Fragment 1 also gets an entry (suppressTopBorder) @@ -478,7 +459,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); // First fragment: bottom border suppressed (no between separator, single box) expect(flags.get(0)?.suppressBottomBorder).toBe(true); @@ -501,7 +482,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- page-split handling --- @@ -511,7 +492,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { continuesOnNext: true }), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag when next fragment continuesFromPrev (page split continuation)', () => { @@ -520,7 +501,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2', { continuesFromPrev: true })]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- same-block deduplication --- @@ -532,7 +513,7 @@ describe('computeBetweenBorderFlags', () => { paraFragment('b1', { fromLine: 3, toLine: 6 }), ]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag same blockId + same itemId list-item fragments', () => { @@ -543,7 +524,7 @@ describe('computeBetweenBorderFlags', () => { listItemFragment('l1', 'i1', { fromLine: 2, toLine: 4 }), ]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('flags different itemIds in same list block', () => { @@ -554,7 +535,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block }]); const fragments: Fragment[] = [listItemFragment('l1', 'i1'), listItemFragment('l1', 'i2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); }); @@ -567,7 +548,7 @@ describe('computeBetweenBorderFlags', () => { const fragments: Fragment[] = [paraFragment('b1'), imageFragment('img1'), paraFragment('b2')]; // Index 0 can't pair with index 1 (image), index 1 is image (skip) - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(false); // Index 1 is image, skipped — but index 1→2 is image→para, image is skipped expect(flags.size).toBe(0); @@ -580,7 +561,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block }]); const fragments: Fragment[] = [paraFragment('b1'), listItemFragment('l1', 'i1')]; - expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true); + expect(runFlags(fragments, lookup).has(0)).toBe(true); }); it('flags list-item followed by para with matching borders', () => { @@ -589,7 +570,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block }, { block: b2 }]); const fragments: Fragment[] = [listItemFragment('l1', 'i1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true); + expect(runFlags(fragments, lookup).has(0)).toBe(true); }); // --- multiple consecutive --- @@ -600,7 +581,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); expect(flags.get(0)?.showBetweenBorder).toBe(true); expect(flags.has(1)).toBe(true); @@ -623,7 +604,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(0); }); @@ -635,7 +616,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag when only second fragment has between border', () => { @@ -645,19 +626,19 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: empty / single fragment --- it('returns empty set for empty fragment list', () => { const lookup = buildLookup([]); - expect(computeBetweenBorderFlags([], lookup).size).toBe(0); + expect(runFlags([], lookup).size).toBe(0); }); it('returns empty set for single fragment', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); const lookup = buildLookup([{ block: b1 }]); - expect(computeBetweenBorderFlags([paraFragment('b1')], lookup).size).toBe(0); + expect(runFlags([paraFragment('b1')], lookup).size).toBe(0); }); // --- edge: missing block in lookup --- @@ -667,7 +648,7 @@ describe('computeBetweenBorderFlags', () => { // b1 is not in lookup const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: between borders match but other sides differ --- @@ -686,7 +667,7 @@ describe('computeBetweenBorderFlags', () => { const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; // Full border hash differs (top is different), so not same border group - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: last fragment on page --- @@ -695,7 +676,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }]); const fragments: Fragment[] = [paraFragment('b1')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(false); }); @@ -709,7 +690,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 100, x: 0 }), paraFragment('b2', { y: 0, x: 300 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(0); }); @@ -723,7 +704,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 0, x: 50 }), paraFragment('b2', { y: 16, x: 50 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); }); @@ -741,7 +722,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 0 }), paraFragment('b2', { y: 20 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); // First fragment: suppressBottomBorder (not showBetweenBorder) expect(flags.get(0)?.showBetweenBorder).toBe(false); @@ -769,7 +750,7 @@ describe('computeBetweenBorderFlags', () => { paraFragment('b3', { y: 40 }), ]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(3); // First: suppress bottom, keep top expect(flags.get(0)?.suppressBottomBorder).toBe(true); @@ -796,7 +777,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); }); diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts index c225a92810..caae556b0d 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts @@ -8,17 +8,7 @@ * @ooxml w:pPr/w:pBdr/w:between — between border for grouped paragraphs * @spec ECMA-376 §17.3.1.24 (pBdr) */ -import type { - Fragment, - ListItemFragment, - ListBlock, - ListMeasure, - ParagraphBlock, - ParagraphAttrs, - ResolvedPaintItem, - ResolvedFragmentItem, -} from '@superdoc/contracts'; -import type { BlockLookup } from './types.js'; +import type { Fragment, ListItemFragment, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; /** @@ -35,74 +25,26 @@ export type BetweenBorderInfo = { gapBelow: number; }; -/** - * Extracts the paragraph borders for a fragment, looking up the block data. - * Handles both paragraph and list-item fragments. - */ -export const getFragmentParagraphBorders = ( - fragment: Fragment, - blockLookup: BlockLookup, -): ParagraphAttrs['borders'] | undefined => { - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return undefined; - - if (fragment.kind === 'para' && lookup.block.kind === 'paragraph') { - return (lookup.block as ParagraphBlock).attrs?.borders; - } - - if (fragment.kind === 'list-item' && lookup.block.kind === 'list') { - const block = lookup.block as ListBlock; - const item = block.items.find((entry) => entry.id === fragment.itemId); - return item?.paragraph.attrs?.borders; - } - - return undefined; -}; - -/** - * Computes the height of a fragment from its measured line heights. - * Used to calculate the spacing gap between consecutive fragments. - */ -export const getFragmentHeight = (fragment: Fragment, blockLookup: BlockLookup): number => { - if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') { - return fragment.height; - } - - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return 0; - - if (fragment.kind === 'para' && lookup.measure.kind === 'paragraph') { - const lines = fragment.lines ?? lookup.measure.lines.slice(fragment.fromLine, fragment.toLine); - let totalHeight = 0; - for (const line of lines) { - totalHeight += line.lineHeight ?? 0; - } - return totalHeight; - } - - if (fragment.kind === 'list-item' && lookup.measure.kind === 'list') { - const listMeasure = lookup.measure as ListMeasure; - const item = listMeasure.items.find((it) => it.itemId === fragment.itemId); - if (!item) return 0; - const lines = item.paragraph.lines.slice(fragment.fromLine, fragment.toLine); - let totalHeight = 0; - for (const line of lines) { - totalHeight += line.lineHeight ?? 0; - } - return totalHeight; - } - - return 0; -}; - /** * Whether a between border is effectively absent (nil/none or missing). */ -const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => { +const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): boolean => { if (!borders?.between) return true; return borders.between.style === 'none'; }; +/** + * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) + * with pre-computed paragraph border data. + */ +function isResolvedFragmentWithBorders( + item: ResolvedPaintItem | undefined, +): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { + return ( + item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined + ); +} + /** * Pre-computes per-fragment between-border rendering info for a page. * @@ -126,23 +68,9 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => { * * Middle fragments in a chain of 3+ get both flags. */ - -/** - * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) - * with pre-computed paragraph border data. - */ -function isResolvedFragmentWithBorders( - item: ResolvedPaintItem | undefined, -): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { - return ( - item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined - ); -} - export const computeBetweenBorderFlags = ( fragments: readonly Fragment[], - blockLookup: BlockLookup, - resolvedItems?: readonly ResolvedPaintItem[], + resolvedItems: readonly ResolvedPaintItem[], ): Map => { // Phase 1: determine which consecutive pairs form between-border groups const pairFlags = new Set(); @@ -153,11 +81,9 @@ export const computeBetweenBorderFlags = ( if (frag.kind !== 'para' && frag.kind !== 'list-item') continue; if (frag.continuesOnNext) continue; - const resolvedCur = resolvedItems?.[i]; - const borders = isResolvedFragmentWithBorders(resolvedCur) - ? resolvedCur.paragraphBorders - : getFragmentParagraphBorders(frag, blockLookup); - if (!borders) continue; + const resolvedCur = resolvedItems[i]; + if (!isResolvedFragmentWithBorders(resolvedCur)) continue; + const borders = resolvedCur.paragraphBorders; const next = fragments[i + 1]; if (next.kind !== 'para' && next.kind !== 'list-item') continue; @@ -171,21 +97,17 @@ export const computeBetweenBorderFlags = ( ) continue; - const resolvedNext = resolvedItems?.[i + 1]; - const nextBorders = isResolvedFragmentWithBorders(resolvedNext) - ? resolvedNext.paragraphBorders - : getFragmentParagraphBorders(next, blockLookup); - if (!nextBorders) continue; + const resolvedNext = resolvedItems[i + 1]; + if (!isResolvedFragmentWithBorders(resolvedNext)) continue; + const nextBorders = resolvedNext.paragraphBorders; // Compare using pre-computed hashes when available, falling back to computing on-the-fly. const curHash = - resolvedCur && 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash + 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash! : hashParagraphBorders(borders); const nextHash = - resolvedNext && - 'paragraphBorderHash' in resolvedNext && - (resolvedNext as ResolvedFragmentItem).paragraphBorderHash + 'paragraphBorderHash' in resolvedNext && (resolvedNext as ResolvedFragmentItem).paragraphBorderHash ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash! : hashParagraphBorders(nextBorders); if (curHash !== nextHash) continue; @@ -209,11 +131,8 @@ export const computeBetweenBorderFlags = ( for (const i of pairFlags) { const frag = fragments[i]; const next = fragments[i + 1]; - const resolvedCur = resolvedItems?.[i]; - const fragHeight = - resolvedCur && 'height' in resolvedCur && resolvedCur.height != null - ? resolvedCur.height - : getFragmentHeight(frag, blockLookup); + const resolvedCur = resolvedItems[i]; + const fragHeight = resolvedCur && 'height' in resolvedCur && resolvedCur.height != null ? resolvedCur.height : 0; const gapBelow = Math.max(0, next.y - (frag.y + fragHeight)); const isNoBetween = noBetweenPairs.has(i); diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts index 16dbf581f1..79084b6abe 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts @@ -15,7 +15,7 @@ */ // Group analysis -export { computeBetweenBorderFlags, getFragmentParagraphBorders, getFragmentHeight } from './group-analysis.js'; +export { computeBetweenBorderFlags } from './group-analysis.js'; export type { BetweenBorderInfo } from './group-analysis.js'; // DOM layers and CSS @@ -27,6 +27,3 @@ export { stampBetweenBorderDataset, computeBorderSpaceExpansion, } from './border-layer.js'; - -// Shared types -export type { BlockLookup, BlockLookupEntry } from './types.js'; diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts deleted file mode 100644 index 12cdf624c8..0000000000 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Shared types for the DomPainter rendering pipeline. - * - * BlockLookup is the canonical definition — renderer.ts and feature modules - * both import from here to avoid circular dependencies. - */ -import type { FlowBlock, Measure } from '@superdoc/contracts'; - -export type BlockLookupEntry = { - block: FlowBlock; - measure: Measure; - version: string; -}; - -export type BlockLookup = Map; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b52913da70..75fc7c10d5 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -27,14 +27,9 @@ const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageG * rewriting every call site. */ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) { - const { blocks: initBlocks, measures: initMeasures, ...painterOpts } = opts; + const { blocks: initBlocks, measures: initMeasures, headerProvider, footerProvider, ...painterOpts } = opts; let lastPaintSnapshot: PaintSnapshot | null = null; - const painter = createDomPainter({ - ...painterOpts, - onPaintSnapshot: (snapshot) => { - lastPaintSnapshot = snapshot; - }, - }); + let currentBlocks: FlowBlock[] = initBlocks ?? []; let currentMeasures: Measure[] = initMeasures ?? []; let currentResolved: ResolvedLayout = emptyResolved; @@ -42,9 +37,62 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } let headerMeasures: Measure[] | undefined; let footerBlocks: FlowBlock[] | undefined; let footerMeasures: Measure[] | undefined; - let resolvedLayoutOverridden = false; + /** + * Resolve decoration items from the currently-registered decoration blocks/measures + * (plus body blocks, which historically also carry decoration block ids in tests). + * This lets tests keep using providers that return `{ fragments, height }` without items: + * the wrapper synthesizes `items` by running the fragments through `resolveLayout`. + */ + const resolveDecorationItems = ( + fragments: readonly import('@superdoc/contracts').Fragment[], + kind: 'header' | 'footer', + ): import('@superdoc/contracts').ResolvedPaintItem[] | undefined => { + const decorationBlocks = kind === 'header' ? headerBlocks : footerBlocks; + const decorationMeasures = kind === 'header' ? headerMeasures : footerMeasures; + const mergedBlocks = [...(currentBlocks ?? []), ...(decorationBlocks ?? [])]; + const mergedMeasures = [...(currentMeasures ?? []), ...(decorationMeasures ?? [])]; + if (mergedBlocks.length !== mergedMeasures.length || mergedBlocks.length === 0) { + return undefined; + } + const fakeLayout: Layout = { pageSize: { w: 400, h: 500 }, pages: [{ number: 1, fragments: [...fragments] }] }; + try { + const resolved = resolveLayout({ + layout: fakeLayout, + flowMode: opts.flowMode ?? 'paginated', + blocks: mergedBlocks, + measures: mergedMeasures, + }); + return resolved.pages[0]?.items; + } catch { + return undefined; + } + }; + + const wrapProvider = ( + provider: import('./renderer.js').PageDecorationProvider | undefined, + kind: 'header' | 'footer', + ): import('./renderer.js').PageDecorationProvider | undefined => { + if (!provider) return undefined; + return (pageNumber, pageMargins, page) => { + const payload = provider(pageNumber, pageMargins, page); + if (!payload) return payload; + if (payload.items) return payload; + const items = resolveDecorationItems(payload.fragments, kind); + return items ? { ...payload, items } : payload; + }; + }; + + const painter = createDomPainter({ + ...painterOpts, + headerProvider: wrapProvider(headerProvider, 'header'), + footerProvider: wrapProvider(footerProvider, 'footer'), + onPaintSnapshot: (snapshot) => { + lastPaintSnapshot = snapshot; + }, + }); + return { paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { const effectiveResolved = resolvedLayoutOverridden @@ -55,26 +103,9 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } blocks: currentBlocks, measures: currentMeasures, }); - // Tests historically pass header/footer blocks via the main `blocks` array and - // rely on the blockLookup containing them. Merge body blocks into headerBlocks - // so header/footer fragments from providers can resolve their block data. - const mergedHeaderBlocks = - headerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(headerBlocks ?? [])] : undefined; - const mergedHeaderMeasures = - headerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(headerMeasures ?? [])] : undefined; - const mergedFooterBlocks = - footerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(footerBlocks ?? [])] : undefined; - const mergedFooterMeasures = - footerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(footerMeasures ?? [])] : undefined; const input: DomPainterInput = { resolvedLayout: effectiveResolved, sourceLayout: layout, - blocks: currentBlocks, - measures: currentMeasures, - headerBlocks: mergedHeaderBlocks, - headerMeasures: mergedHeaderMeasures, - footerBlocks: mergedFooterBlocks, - footerMeasures: mergedFooterMeasures, }; painter.paint(input, mount, mapping as any); }, @@ -1700,20 +1731,6 @@ describe('DomPainter', () => { expect(() => painter.paint(layout, mount)).toThrow(); }); - it('rejects resolved-layout-only paint input until body lookups are removed', () => { - const painter = createDomPainter({}); - - expect(() => - painter.paint( - { - resolvedLayout: emptyResolved, - sourceLayout: layout, - } as DomPainterInput, - mount, - ), - ).toThrow('DomPainterInput requires body blocks and measures'); - }); - it('renders placeholder content for empty lines', () => { const blockWithEmptyRun: FlowBlock = { kind: 'paragraph', @@ -5000,6 +5017,8 @@ describe('DomPainter', () => { fragmentKind: 'list-item', blockId: 'list-1', fragmentIndex: 0, + block: listBlock as import('@superdoc/contracts').ListBlock, + measure: listMeasure as import('@superdoc/contracts').ListMeasure, }, ], }, @@ -5029,6 +5048,8 @@ describe('DomPainter', () => { fragmentKind: 'list-item', blockId: 'list-1', fragmentIndex: 0, + block: listBlock as import('@superdoc/contracts').ListBlock, + measure: listMeasure as import('@superdoc/contracts').ListMeasure, }, ], }, @@ -5058,75 +5079,6 @@ describe('DomPainter', () => { expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch); }); - it('uses resolved block versions for block change tracking', () => { - const blockId = 'resolved-version-block'; - const paragraphBlock: FlowBlock = { - kind: 'paragraph', - id: blockId, - runs: [{ text: 'Stable content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 14 }], - }; - const paragraphMeasure: Measure = { - kind: 'paragraph', - lines: [ - { - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 14, - width: 100, - ascent: 12, - descent: 4, - lineHeight: 20, - }, - ], - totalHeight: 20, - }; - const paragraphLayout: Layout = { - pageSize: { w: 400, h: 500 }, - pages: [ - { - number: 1, - fragments: [ - { kind: 'para', blockId, fromLine: 0, toLine: 1, x: 24, y: 24, width: 300, pmStart: 0, pmEnd: 14 }, - ], - }, - ], - }; - - const item = { - kind: 'fragment' as const, - id: `para:${blockId}:0:1`, - pageIndex: 0, - x: 24, - y: 24, - width: 300, - height: 20, - fragmentKind: 'para' as const, - blockId, - fragmentIndex: 0, - version: 'stable-fragment-version', - }; - - const painter = createTestPainter({ blocks: [paragraphBlock], measures: [paragraphMeasure] }); - - painter.setResolvedLayout({ - ...createSinglePageResolvedLayout(item), - blockVersions: { [blockId]: 'resolved-block-version-1' }, - }); - painter.paint(paragraphLayout, mount); - - const initialWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement; - - painter.setResolvedLayout({ - ...createSinglePageResolvedLayout(item), - blockVersions: { [blockId]: 'resolved-block-version-2' }, - }); - painter.paint(paragraphLayout, mount); - - const updatedWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement; - expect(updatedWrapper).not.toBe(initialWrapper); - }); - it('applies resolved zIndex only to anchored media fragments', () => { const anchoredDrawingBlock: FlowBlock = { kind: 'drawing', @@ -5215,6 +5167,7 @@ describe('DomPainter', () => { fragmentKind: 'drawing', blockId: 'drawing-anchored', fragmentIndex: 0, + block: anchoredDrawingBlock as import('@superdoc/contracts').DrawingBlock, }, { kind: 'fragment', @@ -5228,6 +5181,7 @@ describe('DomPainter', () => { fragmentKind: 'drawing', blockId: 'drawing-inline', fragmentIndex: 1, + block: inlineDrawingBlock as import('@superdoc/contracts').DrawingBlock, }, ], }, @@ -5366,6 +5320,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-indent', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { @@ -5459,6 +5415,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-marker', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { @@ -5552,6 +5510,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-drop-cap', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { @@ -6464,76 +6424,6 @@ describe('DomPainter', () => { expect(page2Line.style.textIndent).toBe('0px'); }); - it('uses resolved continuesFromPrev for first-line width calculations', () => { - const continuedBlock: FlowBlock = { - kind: 'paragraph', - id: 'resolved-continued-block', - runs: [{ text: 'alpha beta gamma', fontFamily: 'Arial', fontSize: 16 }], - attrs: { alignment: 'justify', indent: { left: 20, hanging: 40 } }, - }; - - const continuedMeasure: Measure = { - kind: 'paragraph', - lines: [ - { - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 16, - width: 120, - ascent: 12, - descent: 4, - lineHeight: 20, - }, - ], - totalHeight: 20, - }; - - const continuedLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'para', - blockId: 'resolved-continued-block', - fromLine: 0, - toLine: 1, - x: 0, - y: 0, - width: 200, - continuesOnNext: true, - }, - ], - }, - ], - }; - - const resolvedLayout = createSinglePageResolvedLayout({ - kind: 'fragment', - id: 'resolved-continued-item', - pageIndex: 0, - x: 0, - y: 0, - width: 200, - height: 20, - fragmentKind: 'para', - blockId: 'resolved-continued-block', - fragmentIndex: 0, - continuesFromPrev: true, - continuesOnNext: true, - }); - - const painter = createTestPainter({ blocks: [continuedBlock], measures: [continuedMeasure] }); - painter.setResolvedLayout(resolvedLayout); - painter.paint(continuedLayout, mount); - - const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; - expect(lineEl.style.textIndent).toBe('0px'); - expect(lineEl.style.wordSpacing).toBe('30px'); - }); - it('removes fragment-level indent styles to prevent double-application', () => { const doubleIndentBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 8acc396001..a7a701be12 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -1,8 +1,16 @@ -import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts'; +import type { FlowBlock, Layout, Measure, PageMargins, ResolvedLayout, Page } from '@superdoc/contracts'; import { DomPainter } from './renderer.js'; import { resolveLayout } from '@superdoc/layout-resolved'; import type { PageStyles } from './styles.js'; -import type { DomPainterInput, PaintSnapshot, PositionMapping, RulerOptions, FlowMode } from './renderer.js'; +import type { + DomPainterInput, + PageDecorationPayload, + PageDecorationProvider, + PaintSnapshot, + PositionMapping, + RulerOptions, + FlowMode, +} from './renderer.js'; // Re-export constants export { DOM_CLASS_NAMES } from './constants.js'; @@ -56,32 +64,7 @@ export type { PmPositionValidationStats } from './pm-position-validation.js'; export type LayoutMode = 'vertical' | 'horizontal' | 'book'; export type { FlowMode } from './renderer.js'; -export type PageDecorationPayload = { - fragments: Fragment[]; - height: number; - /** - * Decoration fragments are expressed in header/footer-local coordinates. - * Header/footer layout normalizes page- and margin-relative anchors before - * they reach the painter. - */ - /** Optional measured content height; when provided, footer content will be bottom-aligned within its box. */ - contentHeight?: number; - offset?: number; - marginLeft?: number; - contentWidth?: number; - headerFooterRefId?: string; - sectionType?: string; - /** Minimum Y coordinate from layout; negative when content extends above y=0 */ - minY?: number; - box?: { x: number; y: number; width: number; height: number }; - hitRegion?: { x: number; y: number; width: number; height: number }; -}; - -export type PageDecorationProvider = ( - pageNumber: number, - pageMargins?: PageMargins, - page?: Page, -) => PageDecorationPayload | null; +export type { PageDecorationPayload, PageDecorationProvider } from './renderer.js'; export type DomPainterOptions = { /** @@ -133,37 +116,16 @@ export type DomPainterOptions = { type LegacyDomPainterState = { blocks: FlowBlock[]; measures: Measure[]; - headerBlocks?: FlowBlock[]; - headerMeasures?: Measure[]; - footerBlocks?: FlowBlock[]; - footerMeasures?: Measure[]; resolvedLayout: ResolvedLayout | null; }; -type BlockMeasurePair = { - blocks: FlowBlock[]; - measures: Measure[]; -}; - -type DomPainterInputCandidate = Partial & { - resolvedLayout?: ResolvedLayout; - sourceLayout?: Layout; -}; - export type DomPainterHandle = { paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void; /** * Legacy compatibility API. * New callers should pass block/measure data via `paint(input, mount)`. */ - setData( - blocks: FlowBlock[], - measures: Measure[], - headerBlocks?: FlowBlock[], - headerMeasures?: Measure[], - footerBlocks?: FlowBlock[], - footerMeasures?: Measure[], - ): void; + setData(blocks: FlowBlock[], measures: Measure[]): void; /** * Legacy compatibility API. * New callers should pass resolved data via `paint(input, mount)`. @@ -183,43 +145,6 @@ function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], meas } } -function normalizeRequiredBlockMeasurePair( - label: 'body', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, -): BlockMeasurePair { - if (!Array.isArray(blocks) || !Array.isArray(measures)) { - throw new Error('DomPainterInput requires body blocks and measures; resolved-layout-only input is not supported.'); - } - - assertRequiredBlockMeasurePair(label, blocks, measures); - return { blocks, measures }; -} - -function normalizeOptionalBlockMeasurePair( - label: 'header' | 'footer', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, -): BlockMeasurePair | undefined { - const hasBlocks = blocks !== undefined; - const hasMeasures = measures !== undefined; - - if (hasBlocks !== hasMeasures) { - throw new Error(`${label}Blocks and ${label}Measures must both be provided or both be omitted.`); - } - - if (!hasBlocks || !hasMeasures) { - return undefined; - } - - if (!Array.isArray(blocks) || !Array.isArray(measures)) { - throw new Error(`${label}Blocks and ${label}Measures must be arrays when provided.`); - } - - assertRequiredBlockMeasurePair(label, blocks, measures); - return { blocks, measures }; -} - function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: number | undefined): ResolvedLayout { return { version: 1, @@ -229,29 +154,8 @@ function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: numb }; } -function isLegacyLayoutInput(value: DomPainterInput | Layout): value is Layout { - return 'pages' in value; -} - -function normalizeDomPainterInput(input: DomPainterInputCandidate): DomPainterInput { - if (!input.resolvedLayout || !input.sourceLayout) { - throw new Error('DomPainterInput requires resolvedLayout and sourceLayout.'); - } - - const body = normalizeRequiredBlockMeasurePair('body', input.blocks, input.measures); - const header = normalizeOptionalBlockMeasurePair('header', input.headerBlocks, input.headerMeasures); - const footer = normalizeOptionalBlockMeasurePair('footer', input.footerBlocks, input.footerMeasures); - - return { - resolvedLayout: input.resolvedLayout, - sourceLayout: input.sourceLayout, - blocks: body.blocks, - measures: body.measures, - headerBlocks: header?.blocks, - headerMeasures: header?.measures, - footerBlocks: footer?.blocks, - footerMeasures: footer?.measures, - }; +function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput { + return 'resolvedLayout' in value && 'sourceLayout' in value; } function buildLegacyPaintInput( @@ -280,12 +184,6 @@ function buildLegacyPaintInput( return { resolvedLayout, sourceLayout: layout, - blocks: legacyState.blocks, - measures: legacyState.measures, - headerBlocks: legacyState.headerBlocks, - headerMeasures: legacyState.headerMeasures, - footerBlocks: legacyState.footerBlocks, - footerMeasures: legacyState.footerMeasures, }; } @@ -314,29 +212,15 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle = return { paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping) { - const normalizedInput = isLegacyLayoutInput(input) - ? buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap) - : normalizeDomPainterInput(input); + const normalizedInput = isDomPainterInput(input) + ? input + : buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap); painter.paint(normalizedInput, mount, mapping); }, - setData( - blocks: FlowBlock[], - measures: Measure[], - headerBlocks?: FlowBlock[], - headerMeasures?: Measure[], - footerBlocks?: FlowBlock[], - footerMeasures?: Measure[], - ) { + setData(blocks: FlowBlock[], measures: Measure[]) { assertRequiredBlockMeasurePair('body', blocks, measures); - const normalizedHeader = normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); - const normalizedFooter = normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures); - legacyState.blocks = blocks; legacyState.measures = measures; - legacyState.headerBlocks = normalizedHeader?.blocks; - legacyState.headerMeasures = normalizedHeader?.measures; - legacyState.footerBlocks = normalizedFooter?.blocks; - legacyState.footerMeasures = normalizedFooter?.measures; }, setResolvedLayout(resolvedLayout: ResolvedLayout | null) { legacyState.resolvedLayout = resolvedLayout; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index acbc957ed1..72762ca504 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -120,8 +120,6 @@ import { } from './utils/sdt-helpers.js'; import { computeBetweenBorderFlags, - getFragmentParagraphBorders, - getFragmentHeight, createParagraphDecorationLayers, applyParagraphBorderStyles, applyParagraphShadingStyles, @@ -249,35 +247,25 @@ export type RenderedLineInfo = { /** * Input to `DomPainter.paint()`. * - * `resolvedLayout` is the canonical resolved data. The remaining fields are - * still required bridge data until the painter can render solely from resolved - * items for lookups, change tracking, and non-paragraph fragment rendering. + * `resolvedLayout` is the canonical resolved data the painter reads from. + * `sourceLayout` is the raw Layout retained for legacy internal access paths. */ export type DomPainterInput = { resolvedLayout: ResolvedLayout; - /** Raw Layout for internal fragment access (bridge, will be removed once render loops iterate resolved items). */ + /** Raw Layout for internal fragment access. */ sourceLayout: Layout; - /** Main document blocks/measures used for lookups and version tracking. */ - blocks: FlowBlock[]; - measures: Measure[]; - /** Header block data (still needed for decoration rendering, no resolved path yet). */ - headerBlocks?: FlowBlock[]; - headerMeasures?: Measure[]; - /** Footer block data (still needed for decoration rendering, no resolved path yet). */ - footerBlocks?: FlowBlock[]; - footerMeasures?: Measure[]; }; -type OptionalBlockMeasurePair = { - blocks: FlowBlock[]; - measures: Measure[]; -}; - -type PageDecorationPayload = { +export type PageDecorationPayload = { fragments: Fragment[]; - /** Resolved items aligned 1:1 with `fragments`. Same length, same order. - * Absent when provider has no resolved data (painter falls back to blockLookup). */ + /** + * Resolved items aligned 1:1 with `fragments`. Same length, same order. + * When omitted, the painter treats fragments as having no resolved metadata + * (no paragraph borders, no SDT container keys). + */ items?: ResolvedPaintItem[]; + /** Minimum Y coordinate from layout; negative when content extends above y=0. */ + minY?: number; height: number; /** Optional measured content height to aid bottom alignment in footers. */ contentHeight?: number; @@ -340,10 +328,6 @@ type PainterOptions = { onPaintSnapshot?: (snapshot: PaintSnapshot) => void; }; -// BlockLookup lives in the shared types module (single source of truth) -import type { BlockLookupEntry, BlockLookup } from './features/paragraph-borders/types.js'; -export type { BlockLookup, BlockLookupEntry }; - type FragmentDomState = { key: string; signature: string; @@ -1235,7 +1219,6 @@ const applyLinkDataset = (element: HTMLElement, dataset?: Record * ``` */ export class DomPainter { - private blockLookup: BlockLookup; private readonly options: PainterOptions; private mount: HTMLElement | null = null; private doc: Document | null = null; @@ -1311,7 +1294,6 @@ export class DomPainter { this.options = options; this.layoutMode = options.layoutMode ?? 'vertical'; this.isSemanticFlow = (options.flowMode ?? 'paginated') === 'semantic'; - this.blockLookup = new Map(); this.headerProvider = options.headerProvider; this.footerProvider = options.footerProvider; @@ -1590,80 +1572,10 @@ export class DomPainter { }; } - /** - * Builds a new block lookup from the input data, merging header/footer blocks, - * and tracks which blocks changed since the last paint cycle. - */ - private normalizeOptionalBlockMeasurePair( - label: 'header' | 'footer', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, - ): OptionalBlockMeasurePair | undefined { - const hasBlocks = blocks !== undefined; - const hasMeasures = measures !== undefined; - - if (hasBlocks !== hasMeasures) { - throw new Error( - `DomPainter.paint requires ${label}Blocks and ${label}Measures to both be provided or both be omitted`, - ); - } - - if (!hasBlocks || !hasMeasures) { - return undefined; - } - - return { blocks, measures }; - } - - private updateBlockLookup(input: DomPainterInput): void { - const { blocks, measures, headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input; - const resolvedBlockVersions = this.resolvedLayout?.blockVersions; - - // Build lookup for main document blocks - const nextLookup = this.buildBlockLookup(blocks, measures, resolvedBlockVersions); - - const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); - if (normalizedHeader) { - const headerLookup = this.buildBlockLookup( - normalizedHeader.blocks, - normalizedHeader.measures, - resolvedBlockVersions, - ); - headerLookup.forEach((entry, id) => { - nextLookup.set(id, entry); - }); - } - - const normalizedFooter = this.normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures); - if (normalizedFooter) { - const footerLookup = this.buildBlockLookup( - normalizedFooter.blocks, - normalizedFooter.measures, - resolvedBlockVersions, - ); - footerLookup.forEach((entry, id) => { - nextLookup.set(id, entry); - }); - } - - // Track changed blocks (decoration only now, body change detection uses resolved version) - const changed = new Set(); - nextLookup.forEach((entry, id) => { - const previous = this.blockLookup.get(id); - if (!previous || previous.version !== entry.version) { - changed.add(id); - } - }); - this.blockLookup = nextLookup; - this.changedBlocks = changed; - } - public paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping): void { const layout = input.sourceLayout; this.resolvedLayout = input.resolvedLayout; - - // Update block lookup and change tracking (absorbs former setData logic) - this.updateBlockLookup(input); + this.changedBlocks.clear(); if (!(mount instanceof HTMLElement)) { throw new Error('DomPainter.paint requires a valid HTMLElement mount'); @@ -1686,8 +1598,6 @@ export class DomPainter { if ('blockId' in item) this.changedBlocks.add(item.blockId); } } - // Also mark all header/footer blocks as changed. - this.blockLookup.forEach((_, id) => this.changedBlocks.add(id)); this.currentMapping = null; } else { this.currentMapping = mapping ?? null; @@ -2246,13 +2156,9 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); page.fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -2454,12 +2360,11 @@ export class DomPainter { * Used to determine special Y positioning for page-relative anchored media * in header/footer decoration sections. */ - private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem?: ResolvedPaintItem): boolean { + private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem: ResolvedPaintItem | undefined): boolean { if (fragment.kind !== 'image' && fragment.kind !== 'drawing') { return false; } - const resolvedBlock = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined; - const block = resolvedBlock ?? this.blockLookup.get(fragment.blockId)?.block; + const block = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined; if (!block || (block.kind !== 'image' && block.kind !== 'drawing')) { return false; } @@ -2600,7 +2505,8 @@ export class DomPainter { }; // Compute between-border flags for header/footer paragraph fragments - const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup, data.items); + const decorationItems = data.items ?? []; + const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, decorationItems); // Separate behindDoc fragments from normal fragments. // Prefer explicit fragment.behindDoc when present. Keep zIndex===0 as a @@ -2613,10 +2519,11 @@ export class DomPainter { const fragment = data.fragments[fi]; let isBehindDoc = false; if (fragment.kind === 'image' || fragment.kind === 'drawing') { + const resolvedItem = decorationItems[fi] as ResolvedDrawingItem | undefined; isBehindDoc = fragment.behindDoc === true || (fragment.behindDoc == null && 'zIndex' in fragment && fragment.zIndex === 0) || - this.shouldRenderBehindPageContent(fragment, kind); + this.shouldRenderBehindPageContent(fragment, kind, resolvedItem); } if (isBehindDoc) { behindDocFragments.push({ fragment, originalIndex: fi }); @@ -2791,13 +2698,9 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -2813,6 +2716,7 @@ export class DomPainter { const sdtBoundary = sdtBoundaries.get(index); const betweenInfo = betweenBorderFlags.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index); + const resolvedSig = (resolvedItem as { version?: string } | undefined)?.version ?? ''; if (current) { existing.delete(key); @@ -2832,12 +2736,10 @@ export class DomPainter { newPmStart != null && current.element.dataset.pmStart != null && this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; - const resolvedSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; const needsRebuild = geometryChanged || this.changedBlocks.has(fragment.blockId) || - current.signature !== (resolvedSig ?? fragmentSignature(fragment, this.blockLookup)) || + current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || mappingUnreliable; @@ -2846,7 +2748,7 @@ export class DomPainter { const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.replaceChild(replacement, current.element); current.element = replacement; - current.signature = resolvedSig ?? fragmentSignature(fragment, this.blockLookup); + current.signature = resolvedSig; } else if (this.currentMapping) { // Fragment NOT rebuilt - update position attributes to reflect document changes this.updatePositionAttributes(current.element, this.currentMapping); @@ -2866,13 +2768,11 @@ export class DomPainter { const fresh = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.insertBefore(fresh, pageEl.children[index] ?? null); - const freshSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; nextFragments.push({ key, fragment, element: fresh, - signature: freshSig ?? fragmentSignature(fragment, this.blockLookup), + signature: resolvedSig, context: contextBase, }); }); @@ -2967,13 +2867,9 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index); @@ -2985,11 +2881,10 @@ export class DomPainter { resolvedItem, ); el.appendChild(fragmentEl); - const initSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; + const initSig = (resolvedItem as { version?: string } | undefined)?.version ?? ''; return { key: fragmentKey(fragment), - signature: initSig ?? fragmentSignature(fragment, this.blockLookup), + signature: initSig, fragment, element: fragmentEl, context: contextBase, @@ -3086,16 +2981,12 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const { block, measure } = this.resolveBlockAndMeasure( - fragment, - resolvedItem?.block, - resolvedItem?.measure, - 'paragraph', - 'paragraph', - 'paragraph block/measure', - ); + // Pre-extracted block/measure from the resolved item. + if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') { + throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ParagraphBlock; + const measure = resolvedItem.measure as ParagraphMeasure; const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; @@ -3631,16 +3522,12 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const { block, measure } = this.resolveBlockAndMeasure( - fragment, - resolvedItem?.block, - resolvedItem?.measure, - 'list', - 'list', - 'list block/measure', - ); + // Pre-extracted block/measure from the resolved item. + if (resolvedItem?.block?.kind !== 'list' || resolvedItem?.measure?.kind !== 'list') { + throw new Error(`DomPainter: missing resolved list block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ListBlock; + const measure = resolvedItem.measure as ListMeasure; const item = block.items.find((entry) => entry.id === fragment.itemId); const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId); if (!item || !itemMeasure) { @@ -3775,9 +3662,11 @@ export class DomPainter { resolvedItem?: ResolvedImageItem, ): HTMLElement { try { - // Prefer pre-extracted block from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const block = this.resolveBlock(fragment, resolvedItem?.block, 'image', 'image block'); + // Pre-extracted block from the resolved item. + if (resolvedItem?.block?.kind !== 'image') { + throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ImageBlock; if (!this.doc) { throw new Error('DomPainter: document is not available'); @@ -3975,9 +3864,11 @@ export class DomPainter { resolvedItem?: ResolvedDrawingItem, ): HTMLElement { try { - // Prefer pre-extracted block from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const block = this.resolveBlock(fragment, resolvedItem?.block, 'drawing', 'drawing block'); + // Pre-extracted block from the resolved item. + if (resolvedItem?.block?.kind !== 'drawing') { + throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as DrawingBlock; if (!this.doc) { throw new Error('DomPainter: document is not available'); } @@ -4978,28 +4869,14 @@ export class DomPainter { cellSpacingPx: number; effectiveColumnWidths: number[]; } { - if (resolvedItem) { - return { - block: resolvedItem.block, - measure: resolvedItem.measure, - cellSpacingPx: resolvedItem.cellSpacingPx, - effectiveColumnWidths: resolvedItem.effectiveColumnWidths, - }; - } - - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'table' || lookup.measure.kind !== 'table') { - throw new Error(`DomPainter: missing table block for fragment ${fragment.blockId}`); + if (!resolvedItem) { + throw new Error(`DomPainter: missing resolved table item for fragment ${fragment.blockId}`); } - - const block = lookup.block as TableBlock; - const measure = lookup.measure as TableMeasure; - return { - block, - measure, - cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), - effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, + block: resolvedItem.block, + measure: resolvedItem.measure, + cellSpacingPx: resolvedItem.cellSpacingPx, + effectiveColumnWidths: resolvedItem.effectiveColumnWidths, }; } @@ -6961,21 +6838,24 @@ export class DomPainter { private shouldRenderBehindPageContent( fragment: ImageFragment | DrawingFragment, section: 'header' | 'footer', + resolvedItem?: ResolvedDrawingItem, ): boolean { if (fragment.behindDoc === true || (fragment.behindDoc == null && 'zIndex' in fragment && fragment.zIndex === 0)) { return true; } - return section === 'header' && fragment.kind === 'drawing' && this.isHeaderWordArtWatermark(fragment); + return ( + section === 'header' && + fragment.kind === 'drawing' && + this.isHeaderWordArtWatermark(resolvedItem?.block) + ); } - private isHeaderWordArtWatermark(fragment: DrawingFragment): boolean { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'drawing' || lookup.block.drawingKind !== 'vectorShape') { + private isHeaderWordArtWatermark(block: DrawingBlock | undefined): boolean { + if (!block || block.kind !== 'drawing' || block.drawingKind !== 'vectorShape') { return false; } - const block = lookup.block; const attrs = (block.attrs as Record | undefined) ?? {}; const hasTextContent = Array.isArray(block.textContent?.parts) && block.textContent.parts.length > 0; @@ -7062,91 +6942,13 @@ export class DomPainter { if (resolvedItem && 'height' in resolvedItem && typeof resolvedItem.height === 'number') { return resolvedItem.height; } - const lookup = this.blockLookup.get(fragment.blockId); - const measure = lookup?.measure; - - if (fragment.kind === 'para' && measure?.kind === 'paragraph') { - return measure.totalHeight; - } - - if (fragment.kind === 'list-item' && measure?.kind === 'list') { - return measure.totalHeight; - } - - if (fragment.kind === 'table') { - return fragment.height; - } - - if (fragment.kind === 'image' || fragment.kind === 'drawing') { + // Atomic fragment kinds carry their own height on the fragment. + if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') { return fragment.height; } - return 0; } - /** - * Resolves the block + measure pair for a fragment. Body fragments get these from the - * ResolvedFragmentItem; header/footer fragments fall back to the blockLookup map. - */ - private resolveBlockAndMeasure( - fragment: { blockId: string }, - resolvedBlock: FlowBlock | undefined, - resolvedMeasure: Measure | undefined, - blockKind: B['kind'], - measureKind: M['kind'], - errorLabel: string, - ): { block: B; measure: M } { - if (resolvedBlock?.kind === blockKind && resolvedMeasure?.kind === measureKind) { - return { block: resolvedBlock as B, measure: resolvedMeasure as M }; - } - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== blockKind || lookup.measure.kind !== measureKind) { - throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); - } - return { block: lookup.block as B, measure: lookup.measure as M }; - } - - /** - * Resolves only the block for a fragment (image/drawing rendering doesn't consume the measure). - * Body fragments get this from the ResolvedImageItem/ResolvedDrawingItem; header/footer - * fragments fall back to the blockLookup map. - */ - private resolveBlock( - fragment: { blockId: string }, - resolvedBlock: B | undefined, - blockKind: B['kind'], - errorLabel: string, - ): B { - if (resolvedBlock?.kind === blockKind) { - return resolvedBlock; - } - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== blockKind) { - throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); - } - return lookup.block as B; - } - - private buildBlockLookup( - blocks: FlowBlock[], - measures: Measure[], - precomputedVersions?: Record, - ): BlockLookup { - if (blocks.length !== measures.length) { - throw new Error('DomPainter requires the same number of blocks and measures'); - } - - const lookup: BlockLookup = new Map(); - blocks.forEach((block, index) => { - lookup.set(block.id, { - block, - measure: measures[index], - version: precomputedVersions?.[block.id] ?? deriveBlockVersion(block), - }); - }); - return lookup; - } - /** * All dataset keys used for SDT metadata. * Shared between applySdtDataset and clearSdtDataset to ensure consistency. @@ -7313,46 +7115,20 @@ export class DomPainter { } } -const getFragmentSdtContainerKey = (fragment: Fragment, blockLookup: BlockLookup): string | null => { - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return null; - const block = lookup.block; - - if (fragment.kind === 'para' && block.kind === 'paragraph') { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - if (fragment.kind === 'list-item' && block.kind === 'list') { - const item = block.items.find((listItem) => listItem.id === fragment.itemId); - const attrs = item?.paragraph.attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - if (fragment.kind === 'table' && block.kind === 'table') { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - return null; -}; - const computeSdtBoundaries = ( fragments: readonly Fragment[], - blockLookup: BlockLookup, + resolvedItems: readonly ResolvedPaintItem[], sdtLabelsRendered: Set, - resolvedItems?: readonly ResolvedPaintItem[], ): Map => { const boundaries = new Map(); - const containerKeys: (string | null)[] = resolvedItems - ? resolvedItems.map((item) => { - if ('sdtContainerKey' in item) { - const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; - return key ?? null; - } - return null; - }) - : fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); + const containerKeys: (string | null)[] = fragments.map((_frag, idx) => { + const item = resolvedItems[idx]; + if (item && 'sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }); let i = 0; while (i < fragments.length) { @@ -7381,7 +7157,7 @@ const computeSdtBoundaries = ( let paddingBottomOverride: number | undefined; if (!isEnd) { const nextFragment = fragments[k + 1]; - const currentHeight = resolvedItems?.[k]?.height ?? getFragmentHeight(fragment, blockLookup); + const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0; const currentBottom = fragment.y + currentHeight; const gapToNext = nextFragment.y - currentBottom; if (gapToNext > 0) { @@ -7409,7 +7185,7 @@ const computeSdtBoundaries = ( return boundaries; }; -// getFragmentParagraphBorders, computeBetweenBorderFlags — moved to features/paragraph-borders/ +// computeBetweenBorderFlags — moved to features/paragraph-borders/ const fragmentKey = (fragment: Fragment): string => { if (fragment.kind === 'para') { @@ -7437,66 +7213,6 @@ const fragmentKey = (fragment: Fragment): string => { return _exhaustiveCheck; }; -const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => { - const base = lookup.get(fragment.blockId)?.version ?? 'missing'; - if (fragment.kind === 'para') { - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - return [ - base, - fragment.fromLine, - fragment.toLine, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - fragment.markerWidth ?? '', // Include markerWidth to trigger re-render when list status changes - ].join('|'); - } - if (fragment.kind === 'list-item') { - return [ - base, - fragment.itemId, - fragment.fromLine, - fragment.toLine, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - ].join('|'); - } - if (fragment.kind === 'image') { - return [base, fragment.width, fragment.height].join('|'); - } - if (fragment.kind === 'drawing') { - return [ - base, - fragment.drawingKind, - fragment.drawingContentId ?? '', - fragment.width, - fragment.height, - fragment.geometry.width, - fragment.geometry.height, - fragment.geometry.rotation ?? 0, - fragment.scale ?? 1, - fragment.zIndex ?? '', - ].join('|'); - } - if (fragment.kind === 'table') { - // Include all properties that affect table fragment rendering - const partialSig = fragment.partialRow - ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}` - : ''; - return [ - base, - fragment.fromRow, - fragment.toRow, - fragment.width, - fragment.height, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - fragment.repeatHeaderCount ?? 0, - partialSig, - ].join('|'); - } - return base; -}; - const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean => previous.x !== next.x || previous.y !== next.y || diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 68c96d6542..b14f510638 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -34,12 +34,6 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } const input: DomPainterInput = { resolvedLayout: effectiveResolved, sourceLayout: layout, - blocks: currentBlocks, - measures: currentMeasures, - headerBlocks: undefined, - headerMeasures: undefined, - footerBlocks: undefined, - footerMeasures: undefined, }; painter.paint(input, mount, mapping as any); }, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index e893e79419..9a065bd2ac 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -5168,41 +5168,6 @@ export class PresentationEditor extends EventEmitter { ); } - // Extract header/footer blocks and measures from layout results - const headerBlocks: FlowBlock[] = []; - const headerMeasures: Measure[] = []; - if (headerLayouts) { - for (const headerResult of headerLayouts) { - headerBlocks.push(...headerResult.blocks); - headerMeasures.push(...headerResult.measures); - } - } - // Also include per-rId header blocks for multi-section support - const headerLayoutsByRId = this.#headerFooterSession?.headerLayoutsByRId; - if (headerLayoutsByRId) { - for (const rIdResult of headerLayoutsByRId.values()) { - headerBlocks.push(...rIdResult.blocks); - headerMeasures.push(...rIdResult.measures); - } - } - - const footerBlocks: FlowBlock[] = []; - const footerMeasures: Measure[] = []; - if (footerLayouts) { - for (const footerResult of footerLayouts) { - footerBlocks.push(...footerResult.blocks); - footerMeasures.push(...footerResult.measures); - } - } - // Also include per-rId footer blocks for multi-section support - const footerLayoutsByRId = this.#headerFooterSession?.footerLayoutsByRId; - if (footerLayoutsByRId) { - for (const rIdResult of footerLayoutsByRId.values()) { - footerBlocks.push(...rIdResult.blocks); - footerMeasures.push(...rIdResult.measures); - } - } - // Avoid MutationObserver overhead while repainting large DOM trees. this.#domIndexObserverManager?.pause(); // Pass the transaction mapping for efficient position attribute updates. @@ -5213,12 +5178,6 @@ export class PresentationEditor extends EventEmitter { const paintInput: DomPainterInput = { resolvedLayout, sourceLayout: layout, - blocks: bodyBlocksForPaint, - measures: bodyMeasuresForPaint, - headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined, - headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined, - footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined, - footerMeasures: footerMeasures.length > 0 ? footerMeasures : undefined, }; this.#painterAdapter.paint(paintInput, this.#painterHost, mapping ?? undefined); const painterPaintEnd = perfNow(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index dced8833fd..d2370ad967 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -445,37 +445,6 @@ describe('PresentationEditor', () => { }); describe('semantic flow mode configuration', () => { - it('passes body blocks and measures to the painter on blank-document render', async () => { - editor = new PresentationEditor({ - element: container, - documentId: 'blank-render-contract-doc', - }); - - await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); - - const painterInstance = (mockCreateDomPainter as unknown as Mock).mock.results[ - (mockCreateDomPainter as unknown as Mock).mock.results.length - 1 - ].value as { - paint: Mock; - }; - - await vi.waitFor(() => expect(painterInstance.paint).toHaveBeenCalled()); - - const [paintInput] = painterInstance.paint.mock.calls[painterInstance.paint.mock.calls.length - 1] as [ - { - blocks: unknown[]; - measures: unknown[]; - resolvedLayout: unknown; - sourceLayout: unknown; - }, - ]; - - expect(paintInput.blocks).toEqual([]); - expect(paintInput.measures).toEqual([]); - expect(paintInput.resolvedLayout).toBeTruthy(); - expect(paintInput.sourceLayout).toBeTruthy(); - }); - it('forces vertical layout and disables virtualization when flowMode is semantic', async () => { editor = new PresentationEditor({ element: container, From ffef210dcdf8723ab9312d6e37f103058fe85173 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:32:05 -0700 Subject: [PATCH 35/43] fix: footnote editing lag, other tcs headers and footers (#2926) * fix: footnote editing lag * fix: flush active note sessions before DOCX export * fix: context menu accept/reject in headers/footers * fix: enable hover effect on header/footer regardless of active editing part * feat: create duplicate-comment display for header/footer tcs --- packages/document-api/src/types/address.ts | 2 + .../src/editors/v1/components/SuperEditor.vue | 11 +- .../components/context-menu/ContextMenu.vue | 57 +++- .../v1/components/context-menu/menuItems.js | 10 +- .../context-menu/tests/ContextMenu.test.js | 157 +++++++--- .../context-menu/tests/menuItems.test.js | 30 ++ .../context-menu/tests/testHelpers.js | 22 +- .../v1/components/context-menu/utils.js | 8 +- .../src/editors/v1/core/Editor.ts | 5 + .../editors/v1/core/helpers/editorSurface.js | 6 +- .../presentation-editor/PresentationEditor.ts | 292 ++++++++++++++++-- .../HeaderFooterSessionManager.ts | 123 +++++++- .../layout/EndnotesBuilder.ts | 41 ++- .../layout/FootnotesBuilder.ts | 46 ++- .../pointer-events/EditorInputManager.ts | 84 ++++- .../EditorInputManager.footnoteClick.test.ts | 240 ++++++++++++++ .../tests/FootnotesBuilder.test.ts | 24 ++ .../tests/HeaderFooterSessionManager.test.ts | 29 ++ ...sentationEditor.footnotesPmMarkers.test.ts | 65 ++-- .../tests/PresentationEditor.test.ts | 2 +- .../v1/core/story-editor-factory.test.ts | 30 ++ .../editors/v1/core/story-editor-factory.ts | 11 + .../helpers/note-pm-json.ts | 10 +- .../story-runtime/index.ts | 1 + .../live-story-session-runtime-registry.ts | 26 ++ .../resolve-story-runtime.test.ts | 19 ++ .../extensions/context-menu/context-menu.js | 6 +- .../context-menu/context-menu.test.js | 57 ++++ .../v1/extensions/page-number/page-number.js | 5 +- .../page-number/page-number.test.js | 5 +- .../import-export/footnotes-roundtrip.test.js | 45 +++ .../CommentsLayer/CommentDialog.test.js | 177 +++++++++++ .../CommentsLayer/CommentDialog.vue | 102 ++++-- .../CommentsLayer/FloatingComments.vue | 167 ++++++---- .../commentsList/commentsList.vue | 94 +++++- .../superdoc/src/stores/comments-store.js | 76 ++++- .../src/stores/comments-store.test.js | 158 ++++++++++ .../helpers/floating-comment-instances.js | 152 +++++++++ .../floating-comment-instances.test.js | 205 ++++++++++++ tests/behavior/helpers/story-surfaces.ts | 19 +- ...-footer-live-tracked-change-bounds.spec.ts | 197 ++++++++++++ .../story-surface-context-menu.spec.ts | 84 +++++ .../headers/double-click-edit-header.spec.ts | 87 ++++++ .../footer-page-number-activation.spec.ts | 33 ++ 44 files changed, 2756 insertions(+), 264 deletions(-) create mode 100644 packages/superdoc/src/stores/helpers/floating-comment-instances.js create mode 100644 packages/superdoc/src/stores/helpers/floating-comment-instances.test.js create mode 100644 tests/behavior/tests/comments/story-surface-context-menu.spec.ts create mode 100644 tests/behavior/tests/headers/footer-page-number-activation.spec.ts diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 7414740445..1de125be74 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -127,6 +127,8 @@ export type TrackedChangeAddress = { entityId: string; /** Story containing this tracked change. Omit for body (backward compatible). */ story?: StoryLocator; + /** Preferred rendered page instance for repeated stories such as headers and footers. */ + pageIndex?: number; }; export type EntityAddress = CommentAddress | TrackedChangeAddress; diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.vue b/packages/super-editor/src/editors/v1/components/SuperEditor.vue index 91e6c8ccf7..08fcc9bd0b 100644 --- a/packages/super-editor/src/editors/v1/components/SuperEditor.vue +++ b/packages/super-editor/src/editors/v1/components/SuperEditor.vue @@ -59,6 +59,13 @@ const activeEditor = computed(() => { return editor.value; }); +const contextMenuEditor = computed(() => { + if (editor.value instanceof PresentationEditor) { + return editor.value; + } + return activeEditor.value; +}); + const contextMenuDisabled = computed(() => { const active = activeEditor.value; return active?.options ? Boolean(active.options.disableContextMenu) : Boolean(props.options.disableContextMenu); @@ -1298,8 +1305,8 @@ onBeforeUnmount(() => { { * * @param {MouseEvent} event - The context menu event in capture phase */ +const getContextMenuTargets = () => { + const targets = new Set(); + const surface = getEditorSurfaceElement(props.editor); + if (surface) { + targets.add(surface); + } + + const activeEditor = resolveContextMenuCommandEditor(props.editor); + const activeDom = activeEditor?.view?.dom; + if (activeDom instanceof HTMLElement) { + targets.add(activeDom); + } + + return [...targets]; +}; + +const isEventWithinContextMenuTargets = (event) => { + const target = event?.target; + if (!(target instanceof Node)) { + return false; + } + + return getContextMenuTargets().some( + (surface) => surface === target || (typeof surface?.contains === 'function' && surface.contains(target)), + ); +}; + const handleRightClickCapture = (event) => { try { - if (shouldHandleContextMenu(event)) { + if (isEventWithinContextMenuTargets(event) && shouldHandleContextMenu(event)) { event[CONTEXT_MENU_HANDLED_FLAG] = true; } } catch (error) { @@ -325,6 +352,10 @@ const handleRightClickCapture = (event) => { }; const handleRightClick = async (event) => { + if (!isEventWithinContextMenuTargets(event)) { + return; + } + if (!shouldHandleContextMenu(event)) { return; } @@ -393,6 +424,7 @@ const handleRightClick = async (event) => { const executeCommand = async (item) => { if (props.editor) { + const commandEditor = resolveContextMenuCommandEditor(props.editor); const currentPos = currentContext.value?.pos; const shouldReseatTableSelection = currentContext.value?.event?.type === 'contextmenu' && @@ -406,11 +438,14 @@ const executeCommand = async (item) => { } // First call the action if needed on the item - item.action ? await item.action(props.editor, currentContext.value) : null; + item.action ? await item.action(commandEditor, currentContext.value) : null; if (item.component) { const menuElement = menuRef.value; - const componentProps = getPropsByItemId(item.id, props); + const componentProps = getPropsByItemId(item.id, { + ...props, + editor: commandEditor, + }); // Convert viewport-relative coordinates (used by fixed-position ContextMenu) // to container-relative coordinates (used by absolute-position GenericPopover) @@ -469,7 +504,6 @@ const closeMenu = (options = { restoreCursor: true }) => { /** * Lifecycle hooks on mount and onBeforeUnmount */ -let contextMenuTarget = null; let contextMenuOpenHandler = null; let contextMenuCloseHandler = null; @@ -481,6 +515,8 @@ onMounted(() => { // call event.preventDefault() which suppresses mousedown events document.addEventListener('keydown', handleGlobalKeyDown); document.addEventListener('pointerdown', handleGlobalOutsideClick); + document.addEventListener('contextmenu', handleRightClickCapture, true); + document.addEventListener('contextmenu', handleRightClick); // Close menu if the editor becomes read-only while it's open props.editor.on('update', handleEditorUpdate); @@ -507,13 +543,6 @@ onMounted(() => { }; props.editor.on('contextMenu:open', contextMenuOpenHandler); - // Attach context menu to the active surface (flow view.dom or presentation host) - contextMenuTarget = getEditorSurfaceElement(props.editor); - if (contextMenuTarget) { - contextMenuTarget.addEventListener('contextmenu', handleRightClickCapture, true); - contextMenuTarget.addEventListener('contextmenu', handleRightClick); - } - contextMenuCloseHandler = () => { cleanupCustomItems(); isOpen.value = false; @@ -527,6 +556,8 @@ onMounted(() => { onBeforeUnmount(() => { document.removeEventListener('keydown', handleGlobalKeyDown); document.removeEventListener('pointerdown', handleGlobalOutsideClick); + document.removeEventListener('contextmenu', handleRightClickCapture, true); + document.removeEventListener('contextmenu', handleRightClick); cleanupCustomItems(); @@ -540,8 +571,6 @@ onBeforeUnmount(() => { props.editor.off('contextMenu:close', contextMenuCloseHandler); } props.editor.off('update', handleEditorUpdate); - contextMenuTarget?.removeEventListener('contextmenu', handleRightClickCapture, true); - contextMenuTarget?.removeEventListener('contextmenu', handleRightClick); } catch (error) { console.warn('[ContextMenu] Error during cleanup:', error); } diff --git a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js index 37d506ae29..59bf0533a0 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js @@ -4,6 +4,7 @@ import TableActions from '../toolbar/TableActions.vue'; import LinkInput from '../toolbar/LinkInput.vue'; import CellBackgroundPicker from './CellBackgroundPicker.vue'; import { TEXTS, ICONS, TRIGGERS } from './constants.js'; +import { resolveContextMenuCommandEditor } from './utils.js'; import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js'; import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js'; import { handleClipboardPaste } from '../../core/InputRule.js'; @@ -377,7 +378,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true) icon: ICONS.paste, isDefault: true, action: async (editor) => { - const { view } = editor ?? {}; + const targetEditor = resolveContextMenuCommandEditor(editor); + const { view } = targetEditor ?? {}; if (!view) return; // Save the current selection before focusing. When the context menu // is open, its hidden search input holds focus, so the PM editor's @@ -404,7 +406,7 @@ export function getItems(context, customItems = [], includeDefaultItems = true) view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo))); } } - const handled = handleClipboardPaste({ editor, view }, html, text); + const handled = handleClipboardPaste({ editor: targetEditor, view }, html, text); if (!handled) { const pasteEvent = createPasteEventShim({ html, text }); @@ -418,8 +420,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true) return; } - if (text && editor.commands?.insertContent) { - editor.commands.insertContent(text, { contentType: 'text' }); + if (text && targetEditor.commands?.insertContent) { + targetEditor.commands.insertContent(text, { contentType: 'text' }); } } }, diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js index 3c298e56a3..90f339f1c8 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js @@ -19,10 +19,16 @@ vi.mock('@extensions/context-menu', () => ({ }, })); -vi.mock('../utils.js', () => ({ - getPropsByItemId: vi.fn(() => ({ editor: {} })), - getEditorContext: vi.fn(), -})); +vi.mock('../utils.js', async () => { + const actual = await vi.importActual('../utils.js'); + + return { + ...actual, + getPropsByItemId: vi.fn(() => ({ editor: {} })), + getEditorContext: vi.fn(), + resolveContextMenuCommandEditor: vi.fn((editor) => editor), + }; +}); vi.mock('../menuItems.js', () => ({ getItems: vi.fn(), @@ -50,6 +56,7 @@ describe('ContextMenu.vue', () => { let mockProps; let mockGetItems; let mockGetEditorContext; + let mockResolveContextMenuCommandEditor; let commonMocks; beforeEach(async () => { @@ -77,10 +84,13 @@ describe('ContextMenu.vue', () => { }; const { getItems } = await import('../menuItems.js'); - const { getEditorContext } = await import('../utils.js'); + const { getEditorContext, resolveContextMenuCommandEditor } = await import('../utils.js'); mockGetItems = getItems; mockGetEditorContext = getEditorContext; + mockResolveContextMenuCommandEditor = resolveContextMenuCommandEditor; + mockResolveContextMenuCommandEditor.mockReset(); + mockResolveContextMenuCommandEditor.mockImplementation((editor) => editor); mockGetItems.mockReturnValue( createMockMenuItems(1, [ @@ -101,6 +111,21 @@ describe('ContextMenu.vue', () => { }); }); + const getDocumentContextMenuHandler = (capture = false) => { + const match = commonMocks.spies.docAddEventListener.mock.calls.find( + (call) => call[0] === 'contextmenu' && (capture ? call[2] === true : call[2] !== true), + ); + return match?.[1]; + }; + + const setEventTarget = (event, target = surfaceElementMock) => { + Object.defineProperty(event, 'target', { + value: target, + configurable: true, + }); + return event; + }; + describe('component mounting and lifecycle', () => { it('should mount without errors', () => { const wrapper = mount(ContextMenu, { props: mockProps }); @@ -130,11 +155,15 @@ describe('ContextMenu.vue', () => { surfaceElementMock = presentationHost; const wrapper = mount(ContextMenu, { props: mockProps }); - expect(presentationHost.addEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + expect(commonMocks.spies.docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true); + expect(commonMocks.spies.docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + expect(presentationHost.addEventListener).not.toHaveBeenCalled(); expect(mockEditor.view.dom.addEventListener).not.toHaveBeenCalledWith('contextmenu', expect.any(Function)); wrapper.unmount(); - expect(presentationHost.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + expect(commonMocks.spies.docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true); + expect(commonMocks.spies.docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + expect(presentationHost.removeEventListener).not.toHaveBeenCalled(); }); }); @@ -225,7 +254,7 @@ describe('ContextMenu.vue', () => { }); it('should pass right-click context (including event) to custom renderers', async () => { - const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }); + const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 })); const contextFromEvent = { selectedText: '', @@ -259,9 +288,7 @@ describe('ContextMenu.vue', () => { mount(ContextMenu, { props: mockProps }); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu', - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); await contextMenuHandler(rightClickEvent); @@ -283,11 +310,9 @@ describe('ContextMenu.vue', () => { mockEditor.state.selection.to = 15; mockEditor.posAtCoords = vi.fn(() => ({ pos: 10 })); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu', - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); - const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }); + const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 })); await contextMenuHandler(rightClickEvent); @@ -305,11 +330,9 @@ describe('ContextMenu.vue', () => { mockEditor.posAtCoords = vi.fn(() => ({ pos: 25 })); // Find the bubble phase handler (not capture phase which has `true` as third arg) - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu' && call[2] !== true, - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); - const rightClickEvent = new MouseEvent('contextmenu', { clientX: 120, clientY: 160 }); + const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 120, clientY: 160 })); await contextMenuHandler(rightClickEvent); @@ -339,10 +362,9 @@ describe('ContextMenu.vue', () => { const target = document.createElement('span'); target.dataset.pmStart = '10'; tableFragment.appendChild(target); + surfaceElementMock.appendChild(tableFragment); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu' && call[2] !== true, - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); await contextMenuHandler({ type: 'contextmenu', @@ -379,10 +401,9 @@ describe('ContextMenu.vue', () => { const target = document.createElement('span'); target.dataset.pmStart = '10'; tableFragment.appendChild(target); + surfaceElementMock.appendChild(tableFragment); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu' && call[2] !== true, - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); await contextMenuHandler({ type: 'contextmenu', @@ -401,9 +422,7 @@ describe('ContextMenu.vue', () => { mockGetEditorContext.mockClear(); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu', - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); const event = { ctrlKey: true, @@ -413,6 +432,7 @@ describe('ContextMenu.vue', () => { type: 'contextmenu', detail: 0, button: 2, + target: surfaceElementMock, }; await contextMenuHandler(event); @@ -426,9 +446,7 @@ describe('ContextMenu.vue', () => { mockGetEditorContext.mockClear(); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu', - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); const keyboardEvent = { preventDefault: vi.fn(), @@ -437,6 +455,7 @@ describe('ContextMenu.vue', () => { detail: 0, button: 0, type: 'contextmenu', + target: surfaceElementMock, }; await contextMenuHandler(keyboardEvent); @@ -446,7 +465,7 @@ describe('ContextMenu.vue', () => { }); it('should reuse the computed context instead of re-reading clipboard for custom renders', async () => { - const rightClickEvent = new MouseEvent('contextmenu', { clientX: 200, clientY: 240 }); + const rightClickEvent = setEventTarget(new MouseEvent('contextmenu', { clientX: 200, clientY: 240 })); mockGetEditorContext.mockReset(); mockGetEditorContext.mockResolvedValue({ @@ -478,9 +497,7 @@ describe('ContextMenu.vue', () => { mount(ContextMenu, { props: mockProps }); - const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find( - (call) => call[0] === 'contextmenu', - )[1]; + const contextMenuHandler = getDocumentContextMenuHandler(); await contextMenuHandler(rightClickEvent); @@ -799,6 +816,56 @@ describe('ContextMenu.vue', () => { ); }); + it('should execute item action with the active editor resolved from a wrapper', async () => { + const activeEditor = createMockEditor(); + const editorWrapper = { + ...mockEditor, + getActiveEditor: vi.fn(() => activeEditor), + }; + const mockAction = vi.fn(); + + mockResolveContextMenuCommandEditor.mockImplementation((editor) => { + return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; + }); + + mockGetItems.mockReturnValue([ + { + id: 'test-section', + items: [ + { + id: 'test-item', + label: 'Test Item', + showWhen: (context) => context.trigger === TRIGGERS.slash, + action: mockAction, + }, + ], + }, + ]); + + const wrapper = mount(ContextMenu, { + props: { + ...mockProps, + editor: editorWrapper, + }, + }); + + const onContextMenuOpen = editorWrapper.on.mock.calls.find((call) => call[0] === 'contextMenu:open')[1]; + await onContextMenuOpen({ menuPosition: { left: '100px', top: '200px' } }); + await nextTick(); + + await wrapper.find('.context-menu-item').trigger('click'); + + expect(editorWrapper.getActiveEditor).toHaveBeenCalled(); + expect(mockAction).toHaveBeenCalledWith( + activeEditor, + expect.objectContaining({ + hasSelection: expect.any(Boolean), + selectedText: expect.any(String), + trigger: expect.any(String), + }), + ); + }); + it('should open popover for component items', async () => { const MockComponent = { template: '
    Mock Component
    ' }; mockGetItems.mockReturnValue([ @@ -837,12 +904,7 @@ describe('ContextMenu.vue', () => { beforeEach(() => { mount(ContextMenu, { props: mockProps }); // Find the capture phase contextmenu listener - const captureCall = surfaceElementMock.addEventListener.mock.calls.find( - (call) => - call[0] === 'contextmenu' && - (call[2] === true || call[2]?.capture === true || call[1]?.name === 'handleRightClickCapture'), - ); - captureHandler = captureCall?.[1]; + captureHandler = getDocumentContextMenuHandler(true); }); it('should set __sdHandledByContextMenu flag when editor is editable', () => { @@ -855,6 +917,7 @@ describe('ContextMenu.vue', () => { clientX: 120, clientY: 150, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -873,6 +936,7 @@ describe('ContextMenu.vue', () => { clientX: 120, clientY: 150, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -891,6 +955,7 @@ describe('ContextMenu.vue', () => { clientX: 120, clientY: 150, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -908,6 +973,7 @@ describe('ContextMenu.vue', () => { clientX: 120, clientY: 150, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -925,6 +991,7 @@ describe('ContextMenu.vue', () => { clientX: 120, clientY: 150, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -942,6 +1009,7 @@ describe('ContextMenu.vue', () => { clientX: 0, clientY: 0, preventDefault: vi.fn(), + target: surfaceElementMock, }; captureHandler(event); @@ -958,6 +1026,7 @@ describe('ContextMenu.vue', () => { throw new Error('Test error'); }, preventDefault: vi.fn(), + target: surfaceElementMock, }; // Should not throw, error should be caught @@ -976,10 +1045,8 @@ describe('ContextMenu.vue', () => { wrapper.unmount(); // Verify the capture listener was removed (check for contextmenu with capture flag) - const removeCall = surfaceElementMock.removeEventListener.mock.calls.find( - (call) => - call[0] === 'contextmenu' && - (call[2] === true || call[2]?.capture === true || call[1]?.name === 'handleRightClickCapture'), + const removeCall = commonMocks.spies.docRemoveEventListener.mock.calls.find( + (call) => call[0] === 'contextmenu' && call[2] === true, ); expect(removeCall).toBeDefined(); }); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js index eda40d1836..a5e5e4e9c8 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/menuItems.test.js @@ -781,6 +781,36 @@ describe('menuItems.js', () => { expect(insertContent).toHaveBeenCalledWith('fallback text', { contentType: 'text' }); }); + + it('should resolve PresentationEditor wrappers to the active editor for paste', async () => { + const editor = createMockEditor(); + editor.view.dom.focus = vi.fn(); + editor.view.pasteText = vi.fn(); + + const presentationEditor = { + getActiveEditor: vi.fn(() => editor), + }; + + clipboardMocks.readClipboardRaw.mockResolvedValue({ + html: '', + text: 'wrapped paste', + }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getItems( + createMockContext({ + editor: presentationEditor, + trigger: TRIGGERS.click, + }), + ) + .find((section) => section.id === 'clipboard') + ?.items.find((item) => item.id === 'paste')?.action; + + await pasteAction(presentationEditor); + + expect(presentationEditor.getActiveEditor).toHaveBeenCalled(); + expect(editor.view.pasteText).toHaveBeenCalledWith('wrapped paste', expect.any(Object)); + }); }); describe('getItems - cell selection context', () => { diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js index ded839ef1b..3d65bc42c4 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/testHelpers.js @@ -99,6 +99,10 @@ export function createMockView(options = {}) { const mockState = createMockState(options.state || {}); const coordsAtPos = options.coordsAtPos || vi.fn(() => ({ left: 100, top: 200 })); const posAtCoords = options.posAtCoords || vi.fn(() => ({ pos: 12 })); + const dom = document.createElement('div'); + dom.addEventListener = vi.fn(); + dom.removeEventListener = vi.fn(); + dom.getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0 })); return { state: mockState, @@ -106,11 +110,7 @@ export function createMockView(options = {}) { posAtCoords, dispatch: vi.fn(), focus: vi.fn(), - dom: { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0 })), - }, + dom, }; } @@ -405,15 +405,13 @@ export function assertEventListenersSetup(editor, documentSpies) { // call event.preventDefault() which suppresses mousedown events expect(docAddEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); expect(docAddEventListener).toHaveBeenCalledWith('pointerdown', expect.any(Function)); + expect(docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true); + expect(docAddEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); // Check editor listeners expect(editor.on).toHaveBeenCalledWith('update', expect.any(Function)); expect(editor.on).toHaveBeenCalledWith('contextMenu:open', expect.any(Function)); expect(editor.on).toHaveBeenCalledWith('contextMenu:close', expect.any(Function)); - - // Check DOM listeners - const domTarget = editor.presentationEditor?.element || editor.view.dom; - expect(domTarget.addEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); } /** @@ -427,15 +425,13 @@ export function assertEventListenersCleanup(editor, documentSpies) { // call event.preventDefault() which suppresses mousedown events expect(docRemoveEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); expect(docRemoveEventListener).toHaveBeenCalledWith('pointerdown', expect.any(Function)); + expect(docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function), true); + expect(docRemoveEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); // Check editor listeners cleanup (now with specific handlers to prevent leaks) expect(editor.off).toHaveBeenCalledWith('contextMenu:open', expect.any(Function)); expect(editor.off).toHaveBeenCalledWith('contextMenu:close', expect.any(Function)); expect(editor.off).toHaveBeenCalledWith('update', expect.any(Function)); - - // Check DOM listeners cleanup - const domTarget = editor.presentationEditor?.element || editor.view.dom; - expect(domTarget.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); } /** diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index b5b3e9b82b..04cdc09228 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -11,6 +11,10 @@ import { isList } from '@core/commands/list-helpers'; import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; import { selectedRect } from 'prosemirror-tables'; + +export const resolveContextMenuCommandEditor = (editor) => { + return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; +}; /** * Get props by item id * @@ -22,10 +26,10 @@ import { selectedRect } from 'prosemirror-tables'; */ export const getPropsByItemId = (itemId, props) => { // Common props that are needed regardless of trigger type - const editor = props.editor; + const editor = resolveContextMenuCommandEditor(props.editor); const baseProps = { - editor: markRaw(props.editor), + editor: markRaw(editor), }; switch (itemId) { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index d43ec9f6ba..2f4c28e9a8 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -84,6 +84,8 @@ import { syncPackageMetadata } from './opc/sync-package-metadata.js'; import { readSettingsRoot, parseProtectionState } from '../document-api-adapters/document-settings.js'; import { applyEffectiveEditability, getProtectionStorage } from '../extensions/protection/editability.js'; import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewModeSelectionWithoutStructuredContent.js'; +import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; +import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -3149,6 +3151,9 @@ export class Editor extends EventEmitter { compression, }: ExportDocxParams = {}): Promise | string | undefined> { try { + const exportHostEditor = resolveMainBodyEditor(this); + commitLiveStorySessionRuntimes(exportHostEditor); + // Use provided comments, or fall back to imported comments from converter const effectiveComments = comments ?? this.converter.comments ?? []; diff --git a/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js b/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js index 25fbd51a9a..78894e1639 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js +++ b/packages/super-editor/src/editors/v1/core/helpers/editorSurface.js @@ -18,8 +18,12 @@ export function getEditorSurfaceElement(editor) { return editor.element; } + const parentEditor = editor.options?.parentEditor ?? null; + const presentationEditor = + editor.presentationEditor ?? parentEditor?.presentationEditor ?? parentEditor?._presentationEditor ?? null; + // For flow Editor: check for attached PresentationEditor, then fall back to view.dom or options.element - return editor.presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element ?? null; + return presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element ?? null; } /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9a065bd2ac..f7e7aa6795 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -19,6 +19,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; import { Editor } from '../Editor.js'; import { EventEmitter } from '../EventEmitter.js'; +import type { ProseMirrorJSON } from '../types/EditorTypes.js'; import { EpochPositionMapper } from './layout/EpochPositionMapper.js'; import { DomPositionIndex } from '../../dom-observer/DomPositionIndex.js'; import { DomPositionIndexObserverManager } from '../../dom-observer/DomPositionIndexObserverManager.js'; @@ -42,7 +43,7 @@ import { import { getPageElementByIndex } from '../../dom-observer/PageDom.js'; import { inchesToPx, parseColumns } from './layout/LayoutOptionParsing.js'; import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/PresentationLayoutMetrics.js'; -import { buildFootnotesInput } from './layout/FootnotesBuilder.js'; +import { buildFootnotesInput, type NoteRenderOverride } from './layout/FootnotesBuilder.js'; import { safeCleanup } from './utils/SafeCleanup.js'; import { createHiddenHost } from './dom/HiddenHost.js'; import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js'; @@ -472,6 +473,10 @@ export class PresentationEditor extends EventEmitter { #storySessionSelectionHandler: ((...args: unknown[]) => void) | null = null; #storySessionTransactionHandler: ((...args: unknown[]) => void) | null = null; #storySessionEditor: Editor | null = null; + #activeSurfaceUiEventEditor: Editor | null = null; + #activeSurfaceUiUpdateHandler: ((...args: unknown[]) => void) | null = null; + #activeSurfaceUiContextMenuOpenHandler: ((...args: unknown[]) => void) | null = null; + #activeSurfaceUiContextMenuCloseHandler: ((...args: unknown[]) => void) | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; pmStart: number; @@ -1230,6 +1235,33 @@ export class PresentationEditor extends EventEmitter { return session as NoteStorySession; } + #buildActiveNoteRenderOverride(storyType: 'footnote' | 'endnote'): NoteRenderOverride | null { + const session = this.#getActiveNoteStorySession(); + if (!session || session.locator.storyType !== storyType) { + return null; + } + + const storyEditor = session.editor as Editor & { + getJSON?: () => ProseMirrorJSON; + getUpdatedJson?: () => ProseMirrorJSON; + }; + const docJson = + typeof storyEditor.getUpdatedJson === 'function' + ? storyEditor.getUpdatedJson() + : typeof storyEditor.getJSON === 'function' + ? storyEditor.getJSON() + : null; + + if (!docJson || typeof docJson !== 'object') { + return null; + } + + return { + noteId: session.locator.noteId, + docJson, + }; + } + #getActiveTrackedChangeStorySurface(): { storyKey: string; editor: Editor } | null { const storySession = this.#getActiveStorySession(); if (storySession) { @@ -1269,6 +1301,24 @@ export class PresentationEditor extends EventEmitter { return this.#storySessionManager; } + /** + * Exit any active non-body editing surface and restore the body editor. + * + * This gives tests and editor-integrated helpers a single public entry point + * that does not need to know whether the current surface is managed by the + * generic story-session bridge, the header/footer session manager, or both. + */ + exitActiveStorySurface(): void { + const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; + if (sessionMode !== 'body') { + this.#exitHeaderFooterMode(); + } + + if (this.#getActiveStorySession()) { + this.#exitActiveStorySession(); + } + } + // ------------------------------------------------------------------- // Selection bridge — tracked handles + snapshot convenience // ------------------------------------------------------------------- @@ -2268,7 +2318,10 @@ export class PresentationEditor extends EventEmitter { return null; } - const bounds = this.#aggregateLayoutBounds(rects); + const groupedRects = this.#groupRangeRectsByPage(rects); + const preferredPageIndex = this.#getPreferredRenderedTrackedChangePageIndex(storyKey, groupedRects, relativeTo); + const anchorRects = groupedRects.get(preferredPageIndex) ?? rects; + const bounds = this.#aggregateLayoutBounds(anchorRects); if (!bounds) { return null; } @@ -2276,7 +2329,7 @@ export class PresentationEditor extends EventEmitter { return { bounds, rects, - pageIndex: rects[0]?.pageIndex ?? 0, + pageIndex: preferredPageIndex, }; } @@ -2287,8 +2340,117 @@ export class PresentationEditor extends EventEmitter { } const baseSelector = `[data-track-change-id="${escapeAttrValue(rawId)}"]`; - const selector = storyKey ? `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]` : baseSelector; - return Array.from(host.querySelectorAll(selector)); + if (!storyKey) { + return Array.from(host.querySelectorAll(baseSelector)); + } + + const storySelector = `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]`; + const exactMatches = Array.from(host.querySelectorAll(storySelector)); + const allMatches = Array.from(host.querySelectorAll(baseSelector)); + + if (exactMatches.length > 1 || exactMatches.length === allMatches.length || allMatches.length === 0) { + return exactMatches; + } + + return allMatches; + } + + #groupRangeRectsByPage(rects: RangeRect[]): Map { + const grouped = new Map(); + + rects.forEach((rect) => { + const pageIndex = Number.isFinite(rect.pageIndex) ? rect.pageIndex : 0; + const pageRects = grouped.get(pageIndex); + if (pageRects) { + pageRects.push(rect); + return; + } + grouped.set(pageIndex, [rect]); + }); + + return grouped; + } + + #getPreferredRenderedTrackedChangePageIndex( + storyKey: string, + groupedRects: Map, + relativeTo?: HTMLElement, + ): number { + const activeHeaderFooterSession = this.#headerFooterSession?.session; + const activeHeaderFooterStoryKey = + activeHeaderFooterSession?.mode !== 'body' && activeHeaderFooterSession?.headerFooterRefId + ? buildStoryKey({ + kind: 'story', + storyType: 'headerFooterPart', + refId: activeHeaderFooterSession.headerFooterRefId, + }) + : null; + + const activePageIndex = + activeHeaderFooterStoryKey === storyKey && Number.isFinite(activeHeaderFooterSession?.pageIndex) + ? Number(activeHeaderFooterSession?.pageIndex) + : null; + if (activePageIndex != null && groupedRects.has(activePageIndex)) { + return activePageIndex; + } + + const scrollViewport = + this.#scrollContainer instanceof Window + ? { + top: 0, + bottom: this.#scrollContainer.innerHeight, + } + : this.#scrollContainer instanceof Element + ? this.#scrollContainer.getBoundingClientRect() + : this.#visibleHost?.ownerDocument?.defaultView + ? { + top: 0, + bottom: this.#visibleHost.ownerDocument.defaultView.innerHeight, + } + : this.#visibleHost?.getBoundingClientRect?.(); + const viewportRect = scrollViewport ?? null; + if (viewportRect) { + const relativeRect = relativeTo?.getBoundingClientRect?.(); + const visibleTop = viewportRect.top - (relativeRect?.top ?? 0); + const visibleBottom = viewportRect.bottom - (relativeRect?.top ?? 0); + const viewportCenter = visibleTop + (visibleBottom - visibleTop) / 2; + + let bestPageIndex: number | null = null; + let bestIntersection = -1; + let bestDistance = Number.POSITIVE_INFINITY; + + groupedRects.forEach((pageRects, pageIndex) => { + const pageBounds = this.#aggregateLayoutBounds(pageRects); + if (!pageBounds) { + return; + } + + const intersection = Math.max( + 0, + Math.min(pageBounds.bottom, visibleBottom) - Math.max(pageBounds.top, visibleTop), + ); + const pageCenter = pageBounds.top + pageBounds.height / 2; + const distance = Math.abs(pageCenter - viewportCenter); + + if ( + intersection > bestIntersection || + (intersection === bestIntersection && distance < bestDistance) || + (intersection === bestIntersection && + distance === bestDistance && + (bestPageIndex == null || pageIndex < bestPageIndex)) + ) { + bestPageIndex = pageIndex; + bestIntersection = intersection; + bestDistance = distance; + } + }); + + if (bestPageIndex != null) { + return bestPageIndex; + } + } + + return [...groupedRects.keys()].sort((left, right) => left - right)[0] ?? 0; } /** @@ -3532,6 +3694,7 @@ export class PresentationEditor extends EventEmitter { } this.#teardownStorySessionEventBridge(); + this.#teardownActiveSurfaceUiEventBridge(); // Unregister from static registry if (this.#registryKey) { @@ -3944,6 +4107,7 @@ export class PresentationEditor extends EventEmitter { event: 'stylesDefaultsChanged', handler: handleStylesDefaultsChanged as (...args: unknown[]) => void, }); + this.#syncActiveSurfaceUiEventBridge(this.#editor); // Listen for footnote/endnote part mutations (e.g., insert via document API). // These modify the OOXML part and derived cache but don't change the PM document, @@ -4471,6 +4635,8 @@ export class PresentationEditor extends EventEmitter { this.#scheduleSelectionUpdate({ immediate: true }); this.#scheduleA11ySelectionAnnouncement({ immediate: true }); } + + this.#syncActiveSurfaceUiEventBridge(); }, onEditBlocked: (reason) => { this.emit('headerFooterEditBlocked', { reason }); @@ -4539,6 +4705,58 @@ export class PresentationEditor extends EventEmitter { this.#storySessionTransactionHandler = null; } + #teardownActiveSurfaceUiEventBridge(): void { + if (this.#activeSurfaceUiEventEditor) { + if (this.#activeSurfaceUiUpdateHandler) { + this.#activeSurfaceUiEventEditor.off?.('update', this.#activeSurfaceUiUpdateHandler); + } + if (this.#activeSurfaceUiContextMenuOpenHandler) { + this.#activeSurfaceUiEventEditor.off?.('contextMenu:open', this.#activeSurfaceUiContextMenuOpenHandler); + } + if (this.#activeSurfaceUiContextMenuCloseHandler) { + this.#activeSurfaceUiEventEditor.off?.('contextMenu:close', this.#activeSurfaceUiContextMenuCloseHandler); + } + } + + this.#activeSurfaceUiEventEditor = null; + this.#activeSurfaceUiUpdateHandler = null; + this.#activeSurfaceUiContextMenuOpenHandler = null; + this.#activeSurfaceUiContextMenuCloseHandler = null; + } + + #syncActiveSurfaceUiEventBridge(editor: Editor | null = this.getActiveEditor()): void { + const nextEditor = editor ?? null; + if (nextEditor === this.#activeSurfaceUiEventEditor) { + return; + } + + this.#teardownActiveSurfaceUiEventBridge(); + if (!nextEditor) { + return; + } + + const updateHandler = (event?: { transaction?: Transaction }) => { + this.emit('update', { + ...(event ?? {}), + editor: this, + }); + }; + const contextMenuOpenHandler = (event?: { menuPosition?: { left?: string; top?: string } }) => { + this.emit('contextMenu:open', event ?? {}); + }; + const contextMenuCloseHandler = () => { + this.emit('contextMenu:close'); + }; + + nextEditor.on?.('update', updateHandler); + nextEditor.on?.('contextMenu:open', contextMenuOpenHandler); + nextEditor.on?.('contextMenu:close', contextMenuCloseHandler); + this.#activeSurfaceUiEventEditor = nextEditor; + this.#activeSurfaceUiUpdateHandler = updateHandler; + this.#activeSurfaceUiContextMenuOpenHandler = contextMenuOpenHandler; + this.#activeSurfaceUiContextMenuCloseHandler = contextMenuCloseHandler; + } + #syncStorySessionEventBridge(session: StoryPresentationSession | null): void { this.#teardownStorySessionEventBridge(); @@ -4558,7 +4776,6 @@ export class PresentationEditor extends EventEmitter { if (session.kind === 'note') { this.#invalidateTrackedChangesForStory(session.locator); - this.#flowBlockCache.setHasExternalChanges(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); @@ -4572,6 +4789,7 @@ export class PresentationEditor extends EventEmitter { this.#storySessionTransactionHandler = transactionHandler; this.#scheduleSelectionUpdate({ immediate: true }); this.#scheduleA11ySelectionAnnouncement({ immediate: true }); + this.#syncActiveSurfaceUiEventBridge(); } #syncActiveStorySessionDocumentMode(session: StoryPresentationSession | null): void { @@ -4642,6 +4860,7 @@ export class PresentationEditor extends EventEmitter { } this.#syncActiveStorySessionDocumentMode(activeSession); this.#syncStorySessionEventBridge(activeSession); + this.#syncActiveSurfaceUiEventBridge(); this.#inputBridge?.notifyTargetChanged(); }, }); @@ -5002,20 +5221,24 @@ export class PresentationEditor extends EventEmitter { const isSemanticFlow = this.#isSemanticFlowMode(); const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); + const activeFootnoteOverride = this.#buildActiveNoteRenderOverride('footnote'); const footnotesLayoutInput = buildFootnotesInput( this.#editor?.state, (this.#editor as EditorWithConverter)?.converter, converterContext, this.#editor?.converter?.themeColors ?? undefined, + activeFootnoteOverride, ); const semanticFootnoteBlocks = isSemanticFlow ? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode) : []; + const activeEndnoteOverride = this.#buildActiveNoteRenderOverride('endnote'); const endnoteBlocks = buildEndnoteBlocks( this.#editor?.state, (this.#editor as EditorWithConverter)?.converter, converterContext, this.#editor?.converter?.themeColors ?? undefined, + activeEndnoteOverride, ); const blocksForLayout = semanticFootnoteBlocks.length > 0 || endnoteBlocks.length > 0 @@ -6621,9 +6844,9 @@ export class PresentationEditor extends EventEmitter { noteId: target.noteId, }, { - // Notes need to repaint while the user types; otherwise the hidden-host - // editor is active but the rendered footnote appears frozen until exit. - commitPolicy: 'continuous', + // Render from the active note session locally while typing, then persist + // the canonical notes part once when the session exits. + commitPolicy: 'onExit', preferHiddenHost: true, hostWidthPx: targetContext?.hostWidthPx ?? this.#visibleHost.clientWidth ?? 1, editorContext: { @@ -6973,7 +7196,11 @@ export class PresentationEditor extends EventEmitter { return await this.#navigateToComment(target.entityId); } if (target.entityType === 'trackedChange') { - return await this.#navigateToTrackedChange(target.entityId, resolveStoryKeyFromAddress(target.story)); + return await this.#navigateToTrackedChange( + target.entityId, + resolveStoryKeyFromAddress(target.story), + target.pageIndex, + ); } } @@ -7055,7 +7282,7 @@ export class PresentationEditor extends EventEmitter { return true; } - async #navigateToTrackedChange(entityId: string, storyKey?: string): Promise { + async #navigateToTrackedChange(entityId: string, storyKey?: string, preferredPageIndex?: number): Promise { const editor = this.#editor; if (!editor) return false; @@ -7064,13 +7291,13 @@ export class PresentationEditor extends EventEmitter { return true; } - if (await this.#activateTrackedChangeStorySurface(entityId, storyKey)) { + if (await this.#activateTrackedChangeStorySurface(entityId, storyKey, preferredPageIndex)) { if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { return true; } } - return this.#scrollToRenderedTrackedChange(entityId, storyKey); + return this.#scrollToRenderedTrackedChange(entityId, storyKey, preferredPageIndex); } const setCursorById = editor.commands?.setCursorById; @@ -7084,7 +7311,7 @@ export class PresentationEditor extends EventEmitter { // Fall back to resolving the tracked change position and scrolling. const resolved = resolveTrackedChange(editor, entityId); if (!resolved) { - return this.#scrollToRenderedTrackedChange(entityId); + return this.#scrollToRenderedTrackedChange(entityId, undefined, preferredPageIndex); } // Try with the raw ID (tracked changes may use a different internal ID). @@ -7107,7 +7334,11 @@ export class PresentationEditor extends EventEmitter { return true; } - async #activateTrackedChangeStorySurface(entityId: string, storyKey: string): Promise { + async #activateTrackedChangeStorySurface( + entityId: string, + storyKey: string, + preferredPageIndex?: number, + ): Promise { let locator: StoryLocator | null = null; try { locator = parseStoryKey(storyKey); @@ -7119,7 +7350,7 @@ export class PresentationEditor extends EventEmitter { return false; } - const candidate = this.#findRenderedTrackedChangeElements(entityId, storyKey)[0] ?? null; + const candidate = this.#findRenderedTrackedChangeElement(entityId, storyKey, preferredPageIndex); if (!candidate) { return false; } @@ -7220,14 +7451,39 @@ export class PresentationEditor extends EventEmitter { this.#scheduleSelectionUpdate({ immediate: true }); } - async #scrollToRenderedTrackedChange(entityId: string, storyKey?: string): Promise { + #findRenderedTrackedChangeElement( + entityId: string, + storyKey?: string, + preferredPageIndex?: number, + ): HTMLElement | null { const candidates = this.#findRenderedTrackedChangeElements(entityId, storyKey); if (!candidates.length) { + return null; + } + + if (!Number.isFinite(preferredPageIndex)) { + return candidates[0] ?? null; + } + + return ( + candidates.find((candidate) => this.#resolveRenderedPageIndexForElement(candidate) === preferredPageIndex) ?? + candidates[0] ?? + null + ); + } + + async #scrollToRenderedTrackedChange( + entityId: string, + storyKey?: string, + preferredPageIndex?: number, + ): Promise { + const candidate = this.#findRenderedTrackedChangeElement(entityId, storyKey, preferredPageIndex); + if (!candidate) { return false; } try { - candidates[0]?.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + candidate.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); return true; } catch { return false; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 29d7abbd9c..e2368466fb 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -447,6 +447,7 @@ export class HeaderFooterSessionManager { #hoverOverlay: HTMLElement | null = null; #hoverTooltip: HTMLElement | null = null; #modeBanner: HTMLElement | null = null; + #activeBorderLine: HTMLElement | null = null; #hoverRegion: HeaderFooterRegion | null = null; // Document mode @@ -817,6 +818,8 @@ export class HeaderFooterSessionManager { if (!region.sectionId) console.error('[HeaderFooterSessionManager] Footer region missing sectionId', region); } } + + this.#syncActiveBorder(); } /** @@ -1154,20 +1157,13 @@ export class HeaderFooterSessionManager { return null; } + const shouldRestoreInitialSelection = options?.initialSelection !== 'defer'; + try { this.#applyChildEditorDocumentMode(editor, this.#documentMode); - if (options?.initialSelection !== 'defer') { - try { - const doc = editor.state?.doc; - if (doc) { - const endPos = doc.content.size - 1; - const pos = Math.max(1, endPos); - editor.commands?.setTextSelection?.({ from: pos, to: pos }); - } - } catch (cursorError) { - console.warn('[HeaderFooterSessionManager] Could not set cursor to end:', cursorError); - } + if (shouldRestoreInitialSelection) { + this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not set cursor to end'); } } catch (editableError) { console.error('[HeaderFooterSessionManager] Error setting editor editable:', editableError); @@ -1198,6 +1194,19 @@ export class HeaderFooterSessionManager { console.warn('[HeaderFooterSessionManager] Could not focus editor:', focusError); } + if (shouldRestoreInitialSelection) { + // WebKit can keep a stale DOM selection when the hidden story editor + // receives focus. Re-applying the PM selection after focus keeps the + // first keyboard event aligned with the intended caret position. + this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not restore cursor after focus'); + try { + editor.view?.focus(); + } catch (focusError) { + console.warn('[HeaderFooterSessionManager] Could not refocus editor after restoring selection:', focusError); + } + this.#scheduleSelectionRestoreAfterFocus(editor); + } + this.#emitModeChanged(); this.#emitEditingContext(editor); this.#deps?.notifyInputBridgeTargetChanged(); @@ -1250,6 +1259,47 @@ export class HeaderFooterSessionManager { } } + #getDefaultSelectionAtStoryEnd(editor: Editor): { from: number; to: number } | null { + const doc = editor.state?.doc; + if (!doc) return null; + + const endPos = doc.content.size - 1; + const pos = Math.max(1, endPos); + return { from: pos, to: pos }; + } + + #applyEditorTextSelection(editor: Editor, selection: { from: number; to: number }, warningMessage: string): void { + try { + editor.commands?.setTextSelection?.(selection); + } catch (error) { + console.warn(`[HeaderFooterSessionManager] ${warningMessage}:`, error); + } + } + + #applyDefaultSelectionAtStoryEnd(editor: Editor, warningMessage: string): void { + const selection = this.#getDefaultSelectionAtStoryEnd(editor); + if (!selection) return; + this.#applyEditorTextSelection(editor, selection, warningMessage); + } + + #scheduleSelectionRestoreAfterFocus(editor: Editor): void { + const win = editor.view?.dom?.ownerDocument?.defaultView; + if (!win) return; + + win.requestAnimationFrame(() => { + if (this.#activeEditor !== editor || this.#session.mode === 'body') { + return; + } + + this.#applyDefaultSelectionAtStoryEnd(editor, 'Could not restore cursor on the next frame'); + try { + editor.view?.focus(); + } catch (focusError) { + console.warn('[HeaderFooterSessionManager] Could not refocus editor on the next frame:', focusError); + } + }); + } + #validateEditPermission(): { allowed: boolean; reason?: string } { if (this.#deps?.isViewLocked()) { return { allowed: false, reason: 'documentMode' }; @@ -1286,6 +1336,7 @@ export class HeaderFooterSessionManager { this.#callbacks.onModeChanged?.(this.#session); this.#callbacks.onUpdateAwarenessSession?.(this.#session); this.#updateModeBanner(); + this.#syncActiveBorder(); } #emitEditingContext(editor: Editor): void { @@ -1419,6 +1470,55 @@ export class HeaderFooterSessionManager { return this.#hoverRegion; } + #getActiveRegion(): HeaderFooterRegion | null { + if (this.#session.mode === 'header') { + return this.#headerRegions.get(this.#session.pageIndex ?? -1) ?? null; + } + + if (this.#session.mode === 'footer') { + return this.#footerRegions.get(this.#session.pageIndex ?? -1) ?? null; + } + + return null; + } + + #hideActiveBorder(): void { + if (this.#activeBorderLine) { + this.#activeBorderLine.remove(); + this.#activeBorderLine = null; + } + } + + #syncActiveBorder(): void { + this.#hideActiveBorder(); + + const region = this.#getActiveRegion(); + if (!region || this.#session.mode === 'body') { + return; + } + + const pageElement = this.#deps?.getPageElement(region.pageIndex); + if (!pageElement) { + return; + } + + const borderLine = pageElement.ownerDocument.createElement('div'); + borderLine.className = 'superdoc-header-footer-border'; + Object.assign(borderLine.style, { + position: 'absolute', + left: '0', + right: '0', + top: `${region.kind === 'header' ? region.localY + region.height : region.localY}px`, + height: '1px', + backgroundColor: '#4472c4', + pointerEvents: 'none', + zIndex: '8', + }); + + pageElement.appendChild(borderLine); + this.#activeBorderLine = borderLine; + } + // =========================================================================== // Layout // =========================================================================== @@ -2435,6 +2535,7 @@ export class HeaderFooterSessionManager { this.#activeEditor = null; // Clear UI references + this.#hideActiveBorder(); this.#hoverOverlay = null; this.#hoverTooltip = null; this.#modeBanner = null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts index c4ec3c5b3b..4927353733 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts @@ -3,9 +3,11 @@ import type { FlowBlock, Run as LayoutRun, TextRun } from '@superdoc/contracts'; import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; +import type { NoteRenderOverride } from './FootnotesBuilder.js'; export type EndnoteConverterLike = { endnotes?: Array<{ id?: unknown; content?: unknown[] }>; @@ -25,6 +27,7 @@ export function buildEndnoteBlocks( converter: EndnoteConverterLike | null | undefined, converterContext: ConverterContext | undefined, themeColors: unknown, + renderOverride: NoteRenderOverride | null = null, ): FlowBlock[] { if (!editorState) return []; @@ -50,13 +53,10 @@ export function buildEndnoteBlocks( const blocks: FlowBlock[] = []; orderedEndnoteIds.forEach((id) => { - const entry = findNoteEntryById(importedEndnotes, id); - const content = entry?.content; - if (!Array.isArray(content) || content.length === 0) return; - try { - const clonedContent = JSON.parse(JSON.stringify(content)); - const endnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); + const endnoteDoc = resolveEndnoteDocJson(id, importedEndnotes, renderOverride); + if (!endnoteDoc) return; + const result = toFlowBlocks(endnoteDoc, { blockIdPrefix: `endnote-${id}-`, storyKey: buildStoryKey({ kind: 'story', storyType: 'endnote', noteId: id }), @@ -142,6 +142,35 @@ function syncMarkerRun(target: TextRun, source: TextRun): void { delete target.pmEnd; } +function cloneJsonValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneNoteContentJson(content: unknown[]): ProseMirrorJSON[] { + return cloneJsonValue(content) as ProseMirrorJSON[]; +} + +function resolveEndnoteDocJson( + id: string, + importedEndnotes: Array<{ id?: unknown; content?: unknown[] }>, + renderOverride: NoteRenderOverride | null, +): ProseMirrorJSON | null { + if (renderOverride && renderOverride.noteId === id) { + return normalizeNotePmJson(cloneJsonValue(renderOverride.docJson)); + } + + const entry = findNoteEntryById(importedEndnotes, id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) { + return null; + } + + return normalizeNotePmJson({ + type: 'doc', + content: cloneNoteContentJson(content), + }); +} + function ensureEndnoteMarker( blocks: FlowBlock[], id: string, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 9f323f53b2..07171fec78 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -23,6 +23,7 @@ import type { FlowBlock } from '@superdoc/contracts'; import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; @@ -40,6 +41,11 @@ export type ConverterLike = { footnotes?: Array<{ id?: unknown; content?: unknown[] }>; }; +export type NoteRenderOverride = { + noteId: string; + docJson: ProseMirrorJSON; +}; + /** A text run within a paragraph block. */ type Run = { kind?: string; @@ -91,6 +97,7 @@ export function buildFootnotesInput( converter: ConverterLike | null | undefined, converterContext: ConverterContext | undefined, themeColors: unknown, + renderOverride: NoteRenderOverride | null = null, ): FootnotesLayoutInput | null { if (!editorState) return null; @@ -121,14 +128,10 @@ export function buildFootnotesInput( const blocksById = new Map(); idsInUse.forEach((id) => { - const entry = findNoteEntryById(importedFootnotes, id); - const content = entry?.content; - if (!Array.isArray(content) || content.length === 0) return; - try { - // Deep clone to prevent mutation of the original converter data - const clonedContent = JSON.parse(JSON.stringify(content)); - const footnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); + const footnoteDoc = resolveNoteDocJson(id, importedFootnotes, renderOverride); + if (!footnoteDoc) return; + const result = toFlowBlocks(footnoteDoc, { blockIdPrefix: `footnote-${id}-`, storyKey: buildStoryKey({ kind: 'story', storyType: 'footnote', noteId: id }), @@ -232,6 +235,35 @@ function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run return markerRun; } +function cloneJsonValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneNoteContentJson(content: unknown[]): ProseMirrorJSON[] { + return cloneJsonValue(content) as ProseMirrorJSON[]; +} + +function resolveNoteDocJson( + id: string, + importedFootnotes: Array<{ id?: unknown; content?: unknown[] }>, + renderOverride: NoteRenderOverride | null, +): ProseMirrorJSON | null { + if (renderOverride && renderOverride.noteId === id) { + return normalizeNotePmJson(cloneJsonValue(renderOverride.docJson)); + } + + const entry = findNoteEntryById(importedFootnotes, id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) { + return null; + } + + return normalizeNotePmJson({ + type: 'doc', + content: cloneNoteContentJson(content), + }); +} + function syncMarkerRun(target: Run, source: Run): void { target.kind = source.kind; target.text = source.text; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 2d64ad536e..1a207a2eb9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1794,7 +1794,7 @@ export class EditorInputManager { normalized.pageLocalY, ); if (region) { - if (sessionMode === 'body') { + if (sessionMode === 'body' || this.#isDifferentHeaderFooterRegionFromActiveSession(region)) { event.preventDefault(); event.stopPropagation(); @@ -2158,6 +2158,11 @@ export class EditorInputManager { } if (visiblePointerSurface?.kind === 'headerFooter' && !clickedInsideVisibleActiveSurface) { + if (this.#isDifferentHeaderFooterRegionFromActiveSession(headerFooterRegion)) { + event.preventDefault(); + return true; + } + this.#callbacks.exitHeaderFooterMode?.(); return false; // Continue to body click handling } @@ -2172,6 +2177,64 @@ export class EditorInputManager { return false; } + #isDifferentHeaderFooterRegionFromActiveSession(region: HeaderFooterRegion): boolean { + const session = this.#deps?.getHeaderFooterSession()?.session; + if (!session || session.mode === 'body') { + return true; + } + + if (session.mode !== region.kind) { + return true; + } + + if ( + session.headerFooterRefId && + region.headerFooterRefId && + session.headerFooterRefId !== region.headerFooterRefId + ) { + return true; + } + + if ( + Number.isFinite(session.pageIndex) && + Number.isFinite(region.pageIndex) && + session.pageIndex !== region.pageIndex + ) { + return true; + } + + return (session.sectionType ?? null) !== (region.sectionType ?? null); + } + + #isSameHeaderFooterRegion( + left: HeaderFooterRegion | null | undefined, + right: HeaderFooterRegion | null | undefined, + ): boolean { + if (!left || !right) { + return false; + } + + if (left.kind !== right.kind || left.pageIndex !== right.pageIndex) { + return false; + } + + if ((left.sectionId ?? null) !== (right.sectionId ?? null)) { + return false; + } + + if ((left.sectionType ?? null) !== (right.sectionType ?? null)) { + return false; + } + + const leftRefId = left.headerFooterRefId ?? null; + const rightRefId = right.headerFooterRefId ?? null; + if (leftRefId && rightRefId && leftRefId !== rightRefId) { + return false; + } + + return true; + } + #handleInlineImageClick( event: PointerEvent, targetImg: HTMLImageElement | null, @@ -2422,17 +2485,12 @@ export class EditorInputManager { #handleHover(normalized: { x: number; y: number; pageIndex?: number; pageLocalY?: number }): void { if (!this.#deps) return; - const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - if (sessionMode !== 'body') { - this.#callbacks.clearHoverRegion?.(); - return; - } - if (this.#deps.getDocumentMode() === 'viewing') { this.#callbacks.clearHoverRegion?.(); return; } + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; const region = this.#callbacks.hitTestHeaderFooterRegion?.( normalized.x, normalized.y, @@ -2444,13 +2502,13 @@ export class EditorInputManager { return; } + if (sessionMode !== 'body' && !this.#isDifferentHeaderFooterRegionFromActiveSession(region)) { + this.#callbacks.clearHoverRegion?.(); + return; + } + const currentHover = this.#deps.getHeaderFooterSession()?.hoverRegion; - if ( - currentHover && - currentHover.kind === region.kind && - currentHover.pageIndex === region.pageIndex && - currentHover.sectionType === region.sectionType - ) { + if (this.#isSameHeaderFooterRegion(currentHover, region)) { return; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 35e40ce300..0cceb3def7 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -702,6 +702,246 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); }); + it('keeps the current session alive on the first click into a different header/footer surface', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const exitHeaderFooterMode = vi.fn(); + + const pageEl = document.createElement('div'); + pageEl.className = 'superdoc-page'; + const footerSurface = document.createElement('div'); + footerSurface.className = 'superdoc-page-footer'; + const footerText = document.createElement('span'); + footerText.textContent = 'Footer'; + footerSurface.appendChild(footerText); + pageEl.appendChild(footerSurface); + viewportHost.appendChild(pageEl); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { + mode: 'header', + headerFooterRefId: 'rId6', + sectionType: 'default', + pageIndex: 0, + }, + }); + mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode; + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'footer', + headerFooterRefId: 'rId7', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + localX: 0, + localY: 180, + width: 300, + height: 40, + })); + manager.setCallbacks(mockCallbacks); + stubElementFromPoint(footerText); + stubElementsFromPoint([footerText, footerSurface, pageEl]); + + const PointerEventImpl = getPointerEventImpl(); + footerText.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 30, + clientY: 210, + } as PointerEventInit), + ); + + expect(exitHeaderFooterMode).not.toHaveBeenCalled(); + }); + + it('activates a different header/footer region on double-click without requiring a body round-trip', () => { + const activateHeaderFooterRegion = vi.fn(); + const footerSurface = document.createElement('div'); + footerSurface.className = 'superdoc-page-footer'; + viewportHost.appendChild(footerSurface); + + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { + mode: 'header', + headerFooterRefId: 'rId6', + sectionType: 'default', + pageIndex: 0, + }, + }); + mockCallbacks.normalizeClientPoint = vi.fn((clientX: number, clientY: number) => ({ + x: clientX, + y: clientY, + pageIndex: 0, + pageLocalY: clientY, + })); + mockCallbacks.activateHeaderFooterRegion = activateHeaderFooterRegion; + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'footer', + headerFooterRefId: 'rId7', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + localX: 0, + localY: 180, + width: 300, + height: 40, + })); + manager.setCallbacks(mockCallbacks); + stubElementFromPoint(footerSurface); + stubElementsFromPoint([footerSurface]); + + footerSurface.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 30, + clientY: 210, + }), + ); + + expect(activateHeaderFooterRegion).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'footer', + headerFooterRefId: 'rId7', + }), + expect.objectContaining({ + clientX: 30, + clientY: 210, + pageIndex: 0, + source: 'pointerDoubleClick', + }), + ); + }); + + it('renders the hover affordance for a different header/footer region while another region is active', () => { + const renderHover = vi.fn(); + const renderHoverRegion = vi.fn(); + const clearHoverRegion = vi.fn(); + const footerSurface = document.createElement('div'); + footerSurface.className = 'superdoc-page-footer'; + viewportHost.appendChild(footerSurface); + + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { + mode: 'header', + headerFooterRefId: 'rId6', + sectionType: 'default', + pageIndex: 0, + }, + hoverRegion: null, + renderHover, + }); + mockCallbacks.renderHoverRegion = renderHoverRegion; + mockCallbacks.clearHoverRegion = clearHoverRegion; + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'footer', + headerFooterRefId: 'rId7', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + localX: 0, + localY: 180, + width: 300, + height: 40, + })); + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + footerSurface.dispatchEvent( + new PointerEventImpl('pointermove', { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: 210, + } as PointerEventInit), + ); + + expect(renderHover).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'footer', + headerFooterRefId: 'rId7', + }), + ); + expect(renderHoverRegion).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'footer', + headerFooterRefId: 'rId7', + }), + ); + expect(clearHoverRegion).not.toHaveBeenCalled(); + }); + + it('keeps the hover affordance hidden for the currently active header/footer region', () => { + const renderHover = vi.fn(); + const renderHoverRegion = vi.fn(); + const clearHoverRegion = vi.fn(); + const headerSurface = document.createElement('div'); + headerSurface.className = 'superdoc-page-header'; + viewportHost.appendChild(headerSurface); + + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { + mode: 'header', + headerFooterRefId: 'rId6', + sectionType: 'default', + pageIndex: 0, + }, + hoverRegion: { + kind: 'footer', + headerFooterRefId: 'rId7', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + localX: 0, + localY: 180, + width: 300, + height: 40, + }, + renderHover, + }); + mockCallbacks.renderHoverRegion = renderHoverRegion; + mockCallbacks.clearHoverRegion = clearHoverRegion; + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + headerFooterRefId: 'rId6', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + localX: 0, + localY: 0, + width: 300, + height: 40, + })); + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + headerSurface.dispatchEvent( + new PointerEventImpl('pointermove', { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: 20, + } as PointerEventInit), + ); + + expect(clearHoverRegion).toHaveBeenCalledTimes(1); + expect(renderHover).not.toHaveBeenCalled(); + expect(renderHoverRegion).not.toHaveBeenCalled(); + }); + it('syncs the tracked-change bubble for real clicks inside the active rendered header surface', () => { const activeHeaderEditor = createActiveSessionEditor(); const pageEl = document.createElement('div'); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index aa9d2e7cfc..572e3fb8f6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -162,6 +162,30 @@ describe('buildFootnotesInput', () => { expect(options?.storyKey).toBe('fn:1'); }); + it('prefers the active note render override over stale converter content', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Stale note' }] }], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined, { + noteId: '1', + docJson: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Live note' }] }], + }, + }); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Live note' }] }], + }); + }); + it('only includes footnotes that are referenced in the document', () => { const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc const converter = createMockConverter([ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index d9259bcaf3..63a65b797e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -423,6 +423,35 @@ describe('HeaderFooterSessionManager', () => { expect(activeEditor.view.dom.getAttribute('aria-readonly')).toBe('false'); }); + it('renders and clears the active header/footer divider while editing', async () => { + await setupWithZoom(1, 'suggesting'); + + const border = painterHost.querySelector('.superdoc-header-footer-border') as HTMLElement | null; + expect(border).toBeTruthy(); + expect(border?.style.top).toBe('90px'); + + manager.exitMode(); + expect(painterHost.querySelector('.superdoc-header-footer-border')).toBeNull(); + }); + + it('reapplies the initial story selection after focus when entering edit mode', async () => { + await setupWithZoom(1, 'suggesting'); + + const activeEditor = manager.activeEditor as unknown as { + commands: { + setTextSelection: ReturnType; + }; + view: { + focus: ReturnType; + }; + }; + + expect(activeEditor.commands.setTextSelection.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(activeEditor.commands.setTextSelection).toHaveBeenNthCalledWith(1, { from: 9, to: 9 }); + expect(activeEditor.commands.setTextSelection).toHaveBeenNthCalledWith(2, { from: 9, to: 9 }); + expect(activeEditor.view.focus.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + it('updates the active header editor when the document mode changes to suggesting', async () => { await setupWithZoom(1); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index 107ccddc1e..eb8b69045e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -64,35 +64,42 @@ vi.mock('@superdoc/pm-adapter', async (importOriginal) => { }; }); -vi.mock('@superdoc/layout-bridge', () => ({ - incrementalLayout: mockIncrementalLayout, - normalizeMargin: (value: number | undefined, fallback: number) => - Number.isFinite(value) ? (value as number) : fallback, - selectionToRects: vi.fn(() => []), - clickToPosition: vi.fn(), - getFragmentAtPosition: vi.fn(), - computeLinePmRange: vi.fn(), - measureCharacterX: vi.fn(), - extractIdentifierFromConverter: vi.fn(), - getHeaderFooterType: vi.fn(), - getBucketForPageNumber: vi.fn(), - getBucketRepresentative: vi.fn(), - buildMultiSectionIdentifier: vi.fn(), - getHeaderFooterTypeForSection: vi.fn(), - layoutHeaderFooterWithCache: vi.fn(), - computeDisplayPageNumber: vi.fn(), - findWordBoundaries: vi.fn(), - findParagraphBoundaries: vi.fn(), - createDragHandler: vi.fn(), - PageGeometryHelper: vi.fn(() => ({ - updateLayout: vi.fn(), - getPageIndexAtY: vi.fn(() => 0), - getNearestPageIndex: vi.fn(() => 0), - getPageTop: vi.fn(() => 0), - getPageGap: vi.fn(() => 0), - getLayout: vi.fn(() => ({ pages: [] })), - })), -})); +vi.mock('@superdoc/layout-bridge', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + incrementalLayout: mockIncrementalLayout, + normalizeMargin: (value: number | undefined, fallback: number) => + Number.isFinite(value) ? (value as number) : fallback, + selectionToRects: vi.fn(() => []), + clickToPosition: vi.fn(), + getFragmentAtPosition: vi.fn(), + computeLinePmRange: vi.fn(), + measureCharacterX: vi.fn(), + extractIdentifierFromConverter: vi.fn(), + getHeaderFooterType: vi.fn(), + getBucketForPageNumber: vi.fn(), + getBucketRepresentative: vi.fn(), + buildMultiSectionIdentifier: vi.fn(), + buildEffectiveHeaderFooterRefsBySection: vi.fn(() => new Map()), + collectReferencedHeaderFooterRIds: vi.fn(() => new Set()), + getHeaderFooterTypeForSection: vi.fn(), + layoutHeaderFooterWithCache: vi.fn(), + computeDisplayPageNumber: vi.fn(), + findWordBoundaries: vi.fn(), + findParagraphBoundaries: vi.fn(), + createDragHandler: vi.fn(), + PageGeometryHelper: vi.fn(() => ({ + updateLayout: vi.fn(), + getPageIndexAtY: vi.fn(() => 0), + getNearestPageIndex: vi.fn(() => 0), + getPageTop: vi.fn(() => 0), + getPageGap: vi.fn(() => 0), + getLayout: vi.fn(() => ({ pages: [] })), + })), + }; +}); vi.mock('@superdoc/painter-dom', () => ({ createDomPainter: vi.fn(() => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d2370ad967..bb532f01e9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -2834,7 +2834,7 @@ describe('PresentationEditor', () => { const { sessionEditor } = await activateFootnoteSession(); expect(editor.getStorySessionManager()).not.toBeNull(); - expect(editor.getStorySessionManager()?.getActiveSession()?.commitPolicy).toBe('continuous'); + expect(editor.getStorySessionManager()?.getActiveSession()?.commitPolicy).toBe('onExit'); expect(editor.getActiveEditor()).toBe(sessionEditor); expect(sessionEditor?.setDocumentMode).toHaveBeenCalledWith('editing'); diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts index f688e776ee..dade8ad145 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts @@ -61,4 +61,34 @@ describe('createStoryEditor', () => { child.options.trackedChanges!.replacements = 'paired'; expect(parent.options.trackedChanges?.replacements).toBe('independent'); }); + + it('inherits presentation editor references from the parent editor', () => { + const parent = trackEditor( + initTestEditor({ + mode: 'text', + content: '

    Hello world

    ', + }).editor as Editor, + ); + const presentationEditor = { element: document.createElement('div') } as unknown as Editor['presentationEditor']; + parent.presentationEditor = presentationEditor; + (parent as Editor & { _presentationEditor?: typeof presentationEditor })._presentationEditor = presentationEditor; + + const child = trackEditor( + createStoryEditor( + parent, + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }], + }, + { + documentId: 'hf:part:rId9', + isHeaderOrFooter: true, + headless: true, + }, + ), + ); + + expect(child.presentationEditor).toBe(presentationEditor); + expect((child as Editor & { _presentationEditor?: unknown })._presentationEditor).toBe(presentationEditor); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index d271bbd31e..817668f0e6 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -1,5 +1,6 @@ import type { Editor } from './Editor.js'; import type { EditorOptions } from './types/EditorConfig.js'; +import type { PresentationEditor } from './presentation-editor/index.js'; /** * Options for creating a story editor (header, footer, footnote, endnote, etc.). @@ -173,6 +174,16 @@ export function createStoryEditor( ...editorOptions, } as Partial); + const inheritedPresentationEditor = + parentEditor.presentationEditor ?? + (parentEditor as Editor & { _presentationEditor?: PresentationEditor | null })._presentationEditor ?? + null; + if (inheritedPresentationEditor) { + storyEditor.presentationEditor = inheritedPresentationEditor; + (storyEditor as Editor & { _presentationEditor?: PresentationEditor | null })._presentationEditor = + inheritedPresentationEditor; + } + // Store parent editor reference as a non-enumerable property to avoid // circular reference issues during serialization while still allowing // access when needed. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts index fe8650f871..fc4d402025 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts @@ -4,6 +4,14 @@ type PmJsonNode = { [key: string]: unknown; }; +type NotePmJsonRoot = { + type?: unknown; + content?: unknown; + attrs?: unknown; + marks?: unknown; + text?: unknown; +}; + function isPmJsonNode(value: unknown): value is PmJsonNode { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -161,7 +169,7 @@ function normalizeNotePmNode(value: unknown): unknown { * Keeping both paths on the same normalized PM JSON fixes the mismatch at the * source. */ -export function normalizeNotePmJson>(docJson: T): T { +export function normalizeNotePmJson(docJson: T): T { const normalized = normalizeNotePmNode(docJson); return (isPmJsonNode(normalized) ? normalized : docJson) as T; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts index 3e9a55fba4..d5de66d282 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts @@ -30,6 +30,7 @@ export { // Runtime cache export { StoryRuntimeCache } from './runtime-cache.js'; export { + commitLiveStorySessionRuntimes, registerLiveStorySessionRuntime, resolveLiveStorySessionRuntime, unregisterLiveStorySessionRuntime, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts index cfada76ea9..95c56c91f2 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts @@ -71,6 +71,32 @@ export function resolveLiveStorySessionRuntime(hostEditor: Editor, storyKey: str return buildLiveSessionRuntime(registration); } +/** + * Commits all currently active live story sessions for a host editor. + * + * Export paths operate on the host editor's canonical converter / OOXML + * state. When a user is mid-edit in a hidden-host story session, that live + * editor may contain newer content than the converter cache. Flush the + * registered session editors first so host-level exports include those edits + * without requiring the UI session to exit. + * + * @returns The number of live sessions that were committed. + */ +export function commitLiveStorySessionRuntimes(hostEditor: Editor): number { + const sessions = liveSessionsByHost.get(hostEditor); + if (!sessions || sessions.size === 0) { + return 0; + } + + let committedCount = 0; + for (const registration of [...sessions.values()]) { + buildLiveSessionRuntime(registration).commit?.(hostEditor); + committedCount += 1; + } + + return committedCount; +} + /** * Remove a registered interactive runtime. * diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts index 0180186a06..7b0b2b6965 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts @@ -67,6 +67,7 @@ vi.mock('./story-revision-store.js', () => ({ import { resolveStoryRuntime, invalidateStoryRuntime } from './resolve-story-runtime.js'; import { + commitLiveStorySessionRuntimes, registerLiveStorySessionRuntime, unregisterLiveStorySessionRuntime, } from './live-story-session-runtime-registry.js'; @@ -436,4 +437,22 @@ describe('resolveStoryRuntime — active story sessions', () => { unregisterLiveStorySessionRuntime(hostEditor, 'hf:part:rId11', secondSessionEditor); }); + + it('commits registered live sessions through their active editors', () => { + const hostEditor = makeHostEditor(); + const commitEditor = vi.fn(); + const runtime = { + locator: { kind: 'story', storyType: 'footnote', noteId: '9' }, + storyKey: 'fn:9', + editor: { id: 'cached-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any, + kind: 'note' as const, + commitEditor, + }; + const sessionEditor = { id: 'session-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + + registerLiveStorySessionRuntime(hostEditor, runtime, sessionEditor); + + expect(commitLiveStorySessionRuntimes(hostEditor)).toBe(1); + expect(commitEditor).toHaveBeenCalledWith(hostEditor, sessionEditor); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js index 86d4978059..77c1f214c9 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js @@ -1,6 +1,6 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Extension } from '@core/Extension.js'; -import { getSurfaceRelativePoint } from '../../core/helpers/editorSurface.js'; +import { getEditorSurfaceElement, getSurfaceRelativePoint } from '../../core/helpers/editorSurface.js'; /** * Find the nearest ancestor element that creates a containing block for `position: fixed`. @@ -226,7 +226,7 @@ export const ContextMenu = Extension.create({ } else { // Fallback to selection-based positioning (slash trigger) const relativePoint = getSurfaceRelativePoint(editor, meta); - const surface = editor.presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element; + const surface = getEditorSurfaceElement(editor); if (relativePoint && surface) { try { const rect = surface.getBoundingClientRect(); @@ -263,7 +263,7 @@ export const ContextMenu = Extension.create({ // // Example: If viewport coords are (200, 150) and containing block is at (50, 30), // we need (150, 120) in containing-block-relative coordinates. - const menuSurface = editor.presentationEditor?.element ?? editor.view?.dom ?? editor.options?.element; + const menuSurface = getEditorSurfaceElement(editor); const containingBlock = findContainingBlockAncestor(menuSurface); if (containingBlock) { try { diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js index a1b90d4d7f..659ecd0833 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js @@ -8,6 +8,7 @@ vi.mock('@core/commands/list-helpers', () => ({ })); vi.mock('../../core/helpers/editorSurface.js', () => ({ + getEditorSurfaceElement: vi.fn((editor) => editor?.presentationEditor?.element ?? editor?.view?.dom ?? null), getSurfaceRelativePoint: vi.fn(() => ({ left: 20, top: 30 })), })); @@ -900,6 +901,62 @@ describe('ContextMenu extension', () => { expect(pluginState.menuPosition.left).toBe('110px'); }); + it('prefers the visible presentation surface when correcting right-click position', () => { + const baseDoc = doc(p()); + const initialSelection = TextSelection.create(baseDoc, 1); + let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); + + const containingBlock = document.createElement('div'); + containingBlock.style.transform = 'translateX(0)'; + containingBlock.style.position = 'relative'; + testContainer.appendChild(containingBlock); + + const presentationSurface = document.createElement('div'); + containingBlock.appendChild(presentationSurface); + + const hiddenSurface = document.createElement('div'); + testContainer.appendChild(hiddenSurface); + + vi.spyOn(containingBlock, 'getBoundingClientRect').mockReturnValue({ left: 100, top: 100 }); + + const editor = { + options: {}, + emit: vi.fn(), + view: null, + presentationEditor: { element: presentationSurface }, + }; + + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); + + const view = { + state, + dispatch: vi.fn((tr) => { + state = state.apply(tr); + view.state = state; + }), + dom: hiddenSurface, + }; + + editor.view = view; + + view.dispatch( + view.state.tr.setMeta(ContextMenuPluginKey, { + type: 'open', + clientX: 150, + clientY: 200, + pos: 1, + }), + ); + + const pluginState = ContextMenuPluginKey.getState(view.state); + expect(pluginState.open).toBe(true); + expect(pluginState.menuPosition).toEqual({ + left: '60px', + top: '110px', + }); + }); + it('should adjust menu position for scrollTop offset in containing block', () => { const baseDoc = doc(p()); const initialSelection = TextSelection.create(baseDoc, 1); diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 10f403f04c..8242a8d6ee 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -309,7 +309,10 @@ export class AutoPageNumberNodeView { marksAsAttrs: newMarks, }; - const tr = state.tr.setNodeMarkup(pos, undefined, newAttrs); + const tr = state.tr + .setNodeMarkup(pos, undefined, newAttrs) + .setMeta('skipTrackChanges', true) + .setMeta('addToHistory', false); dispatch(tr); }, 0); } diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js index 2e8ac82426..6ad214e92e 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js @@ -91,8 +91,9 @@ describe('AutoPageNumberNodeView', () => { resolve: vi.fn().mockReturnValue({ nodeBefore: { marks: marksBefore }, nodeAfter: { marks: marksAfter } }), nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), }; - const tr = { setNodeMarkup: vi.fn() }; + const tr = { setNodeMarkup: vi.fn(), setMeta: vi.fn() }; tr.setNodeMarkup.mockImplementation(() => tr); + tr.setMeta.mockImplementation(() => tr); const dispatch = vi.fn(); state.doc = doc; @@ -125,6 +126,8 @@ describe('AutoPageNumberNodeView', () => { { type: 'underline', attrs: {} }, ], }); + expect(tr.setMeta).toHaveBeenNthCalledWith(1, 'skipTrackChanges', true); + expect(tr.setMeta).toHaveBeenNthCalledWith(2, 'addToHistory', false); expect(dispatch).toHaveBeenCalledWith(tr); }); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/footnotes-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/footnotes-roundtrip.test.js index 8445399ef1..a46a996fcd 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/footnotes-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/footnotes-roundtrip.test.js @@ -9,6 +9,8 @@ import { initTestEditor } from '../helpers/helpers.js'; import { createFootnoteElement, prepareFootnotesXmlForExport } from '@converter/v2/exporter/footnotesExporter.js'; import { importFootnoteData } from '@converter/v2/importer/documentFootnotesImporter.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { resolveNoteRuntime } from '../../document-api-adapters/story-runtime/note-story-runtime.ts'; +import { registerLiveStorySessionRuntime } from '../../document-api-adapters/story-runtime/live-story-session-runtime-registry.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DOCX_FIXTURE_NAME = 'basic-footnotes.docx'; @@ -215,6 +217,49 @@ describe('footnotes import/export roundtrip', () => { // Verify footnoteReference elements exist in exported XML expect(documentXml).toContain('w:footnoteReference'); }); + + it('flushes active live footnote sessions before host-level DOCX export', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const liveFootnote = editor.converter.footnotes.find((entry) => { + const type = entry?.type; + return type !== 'separator' && type !== 'continuationSeparator'; + }); + expect(liveFootnote).toBeDefined(); + + const noteId = String(liveFootnote.id); + const runtime = resolveNoteRuntime(editor, { + kind: 'story', + storyType: 'footnote', + noteId, + }); + const liveText = 'Live export session regression text'; + + runtime.editor.commands.selectAll(); + runtime.editor.commands.insertContent(liveText); + + const unregister = registerLiveStorySessionRuntime(editor, runtime, runtime.editor); + + try { + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + const exportedFootnotesXml = updatedDocs['word/footnotes.xml']; + expect(exportedFootnotesXml).toBeTruthy(); + + const exportedFootnotesJson = parseXmlToJson(exportedFootnotesXml); + const exportedRoot = findFootnotesRoot(exportedFootnotesJson); + const exportedFn = findFootnoteById(exportedRoot, noteId); + + expect(exportedFn).toBeDefined(); + expect(extractTextContent(exportedFn)).toContain(liveText); + } finally { + unregister(); + } + }); }); // ------------------------------------------ diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index a4398daa76..99117c1210 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -146,6 +146,7 @@ const mountDialog = async ({ removePendingComment: vi.fn(), requestInstantSidebarAlignment: vi.fn(), clearInstantSidebarAlignment: vi.fn(), + setActiveFloatingCommentInstance: vi.fn(), decideTrackedChangeFromSidebar: vi.fn(() => ({ ok: true, success: true })), getCommentDocumentId: vi.fn( (comment) => comment?.fileId ?? comment?.documentId ?? comment?.selection?.documentId ?? null, @@ -183,6 +184,7 @@ const mountDialog = async ({ suppressInternalExternal: ref(false), getConfig: ref({ readOnly: false }), activeComment: ref(null), + activeFloatingCommentInstanceId: ref(null), floatingCommentsOffset: ref(0), pendingComment: ref(null), currentCommentText: ref('

    Pending

    '), @@ -346,6 +348,100 @@ describe('CommentDialog.vue', () => { }); }); + it('navigates repeated header/footer tracked changes to the clicked floating page instance', async () => { + const presentation = { + navigateTo: vi.fn().mockResolvedValue(true), + }; + PresentationEditor.getInstance.mockReturnValue(presentation); + + const trackedChangeStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId-repeat' }; + const floatingInstanceId = 'tc::hf:part:rId-repeat::tracked-change-story-repeat::page:2'; + + const { wrapper } = await mountDialog({ + props: { + autoFocus: false, + floatingInstanceId, + floatingPageIndex: 2, + floatingPositionEntry: { + pageIndex: 2, + bounds: { top: 240, left: 12, right: 64, bottom: 264, width: 52, height: 24 }, + }, + }, + baseCommentOverrides: { + commentId: 'tracked-change-story-repeat', + importedId: 'imported-tracked-change-story-repeat', + trackedChange: true, + trackedChangeStory, + }, + }); + + await wrapper.trigger('click'); + + expect(presentation.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'imported-tracked-change-story-repeat', + story: trackedChangeStory, + pageIndex: 2, + }); + expect(commentsStoreStub.requestInstantSidebarAlignment).toHaveBeenCalledWith( + expect.any(Number), + 'tracked-change-story-repeat', + floatingInstanceId, + ); + expect(commentsStoreStub.setActiveFloatingCommentInstance).toHaveBeenCalledWith(floatingInstanceId); + }); + + it('honors explicit floating instance active overrides', async () => { + const inactiveMount = await mountDialog({ + props: { + autoFocus: false, + floatingInstanceId: 'thread-1::page:0', + isFloatingInstanceActive: false, + }, + commentsStoreOverrides: { + activeComment: ref('comment-1'), + activeFloatingCommentInstanceId: ref('thread-1::page:0'), + }, + }); + + expect(inactiveMount.wrapper.classes()).not.toContain('is-active'); + + const activeMount = await mountDialog({ + props: { + autoFocus: false, + floatingInstanceId: 'thread-1::page:0', + isFloatingInstanceActive: true, + }, + commentsStoreOverrides: { + activeComment: ref('comment-1'), + activeFloatingCommentInstanceId: ref('thread-1::page:0'), + }, + }); + + expect(activeMount.wrapper.classes()).toContain('is-active'); + }); + + it('clears instant alignment instead of re-requesting it when the active dialog is clicked again', async () => { + const { wrapper } = await mountDialog({ + props: { + autoFocus: false, + floatingInstanceId: 'thread-1::page:2', + }, + commentsStoreOverrides: { + activeComment: ref('comment-1'), + activeFloatingCommentInstanceId: ref('thread-1::page:2'), + }, + }); + + commentsStoreStub.requestInstantSidebarAlignment.mockClear(); + commentsStoreStub.clearInstantSidebarAlignment.mockClear(); + await wrapper.trigger('click'); + + expect(commentsStoreStub.requestInstantSidebarAlignment).not.toHaveBeenCalled(); + expect(commentsStoreStub.clearInstantSidebarAlignment).toHaveBeenCalled(); + }); + it('falls back to setCursorById for resolved tracked changes when PresentationEditor navigation is unavailable', async () => { PresentationEditor.getInstance.mockReturnValue({}); @@ -944,6 +1040,49 @@ describe('CommentDialog.vue', () => { expect(baseComment.setIsInternal).toHaveBeenCalledWith({ isInternal: false, superdoc: superdocStub }); }); + it('marks the active floating instance when edit mode opens from a repeated instance bubble', async () => { + const floatingInstanceId = 'tc::hf:part:rId-repeat::comment-1::page:2'; + const { wrapper, superdocStub } = await mountDialog({ + props: { + autoFocus: false, + floatingInstanceId, + floatingPageIndex: 2, + }, + }); + + const header = wrapper.findComponent(CommentHeaderStub); + header.vm.$emit('overflow-select', 'edit'); + await nextTick(); + + expect(commentsStoreStub.setActiveFloatingCommentInstance).toHaveBeenCalledWith(floatingInstanceId); + expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, 'comment-1'); + }); + + it('updates pending-comment internal state without mutating the persisted comment', async () => { + const { wrapper, baseComment } = await mountDialog({ + baseCommentOverrides: { + isInternal: true, + }, + commentsStoreOverrides: { + pendingComment: ref({ + commentId: 'comment-1', + selection: { + getValues: () => ({ selectionBounds: { top: 110, bottom: 130, left: 15, right: 30 } }), + selectionBounds: { top: 110, bottom: 130, left: 15, right: 30 }, + }, + isInternal: true, + }), + }, + }); + + const dropdown = wrapper.findComponent(InternalDropdownStub); + dropdown.vm.$emit('select', 'external'); + await nextTick(); + + expect(commentsStoreStub.pendingComment.value.isInternal).toBe(false); + expect(baseComment.setIsInternal).not.toHaveBeenCalled(); + }); + it('prepopulates edit text from a ref-based commentText value', async () => { const baseCommentWithRef = { commentText: { value: '

    Ref text

    ' }, @@ -986,6 +1125,44 @@ describe('CommentDialog.vue', () => { expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled(); }); + it('auto-focuses the pending comment input on mount', async () => { + commentInputFocusSpies = []; + + await mountDialog({ + commentsStoreOverrides: { + pendingComment: ref({ + commentId: 'comment-1', + selection: { + getValues: () => ({ selectionBounds: { top: 110, bottom: 130, left: 15, right: 30 } }), + selectionBounds: { top: 110, bottom: 130, left: 15, right: 30 }, + }, + isInternal: true, + }), + }, + }); + await nextTick(); + + expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled(); + }); + + it('filters reply suggestions to internal users for internal comments', async () => { + const { wrapper, baseComment } = await mountDialog({ + baseCommentOverrides: { + isInternal: true, + }, + }); + commentsStoreStub.activeComment.value = baseComment.commentId; + await nextTick(); + + await wrapper.find('.reply-pill').trigger('click'); + await nextTick(); + + const input = wrapper.findComponent(CommentInputStub); + expect(input.props('users')).toEqual([ + { name: 'Internal', email: 'internal@example.com', access: { role: 'internal' } }, + ]); + }); + it('emits dialog-exit when clicking outside active comment and no track changes highlighted', async () => { const { wrapper, baseComment } = await mountDialog(); commentsStoreStub.activeComment.value = baseComment.commentId; diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 1be407baba..74a5e4d922 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -30,6 +30,22 @@ const props = defineProps({ type: Object, required: false, }, + floatingInstanceId: { + type: String, + default: null, + }, + floatingPageIndex: { + type: Number, + default: null, + }, + floatingPositionEntry: { + type: Object, + default: null, + }, + isFloatingInstanceActive: { + type: Boolean, + default: undefined, + }, }); const { proxy } = getCurrentInstance(); @@ -47,11 +63,13 @@ const { requestInstantSidebarAlignment, resolveCommentPositionEntry, clearInstantSidebarAlignment, + setActiveFloatingCommentInstance, } = commentsStore; const { suppressInternalExternal, getConfig, activeComment, + activeFloatingCommentInstanceId, floatingCommentsOffset, pendingComment, currentCommentText, @@ -233,7 +251,25 @@ const isDialogAlreadyAlignedWithTarget = (dialogElement, targetClientY, toleranc return Number.isFinite(dialogTop) && Math.abs(dialogTop - targetClientY) <= tolerancePx; }; -const isActiveComment = computed(() => activeComment.value === props.comment.commentId); +const currentFloatingInstanceId = computed(() => { + return props.floatingInstanceId ?? props.comment.commentId ?? null; +}); + +const isDialogActive = computed(() => { + if (typeof props.isFloatingInstanceActive === 'boolean') { + return props.isFloatingInstanceActive; + } + + if (activeComment.value !== props.comment.commentId) { + return false; + } + + if (props.floatingInstanceId == null) { + return true; + } + + return activeFloatingCommentInstanceId.value === props.floatingInstanceId; +}); /* ── Step 1: Resolved badge ── */ const resolvedBadgeLabel = computed(() => { @@ -253,7 +289,7 @@ const showSeparator = computed(() => (index) => { }); const showInputSection = computed(() => { - return !getConfig.readOnly && isActiveComment.value && !props.comment.resolvedTime && !isEditingAnyComment.value; + return !getConfig.readOnly && isDialogActive.value && !props.comment.resolvedTime && !isEditingAnyComment.value; }); // Reply pill → expanded editor toggle @@ -349,7 +385,7 @@ watch(parentBodyRef, () => { nextTick(checkOverflow); }); // Reset truncation, thread collapse, and reply state when card becomes inactive -watch(isActiveComment, (active) => { +watch(isDialogActive, (active) => { if (!active) { textExpanded.value = false; threadExpanded.value = false; @@ -417,10 +453,12 @@ const isInternalDropdownDisabled = computed(() => { return getConfig.value.readOnly; }); -const isEditingThisComment = computed(() => (comment) => editingCommentId.value === comment.commentId); +const isEditingThisComment = computed(() => (comment) => { + return isDialogActive.value && editingCommentId.value === comment.commentId; +}); const isEditingAnyComment = computed(() => { - if (!editingCommentId.value) return false; + if (!editingCommentId.value || !isDialogActive.value) return false; return comments.value.some((c) => c.commentId === editingCommentId.value); }); @@ -437,16 +475,25 @@ const setFocus = () => { const editor = proxy.$superdoc.activeEditor; const isTrackedChange = Boolean(props.comment?.trackedChange); const targetClientY = getPreferredCommentFocusTargetClientY(); - const willChangeActiveThread = !props.comment.resolvedTime && activeComment.value !== props.comment.commentId; + const isInstanceScopedDialog = props.floatingInstanceId != null; + const willChangeActiveDialog = + !props.comment.resolvedTime && + (activeComment.value !== props.comment.commentId || + (isInstanceScopedDialog && currentFloatingInstanceId.value !== activeFloatingCommentInstanceId.value)); let instantAlignmentTargetY = targetClientY; // Move cursor to the comment location and set active comment in a single PM // transaction. This prevents a race where position-based comment detection in the // plugin clears the activeThreadId before the setActiveComment meta is processed. if (editor) { - const { entry: focusEntry } = resolveCommentPositionEntry(props.comment); + const { entry: resolvedFocusEntry } = resolveCommentPositionEntry(props.comment); + const focusEntry = props.floatingPositionEntry ?? resolvedFocusEntry; + const usePageScopedAnchorOnly = + Number.isFinite(props.floatingPageIndex) && props.comment?.trackedChangeStory?.storyType === 'headerFooterPart'; const visibleAnchorTargetY = getVisibleThreadAnchorClientY(props.parent, focusEntry); - const visibleHighlightTargetY = getVisibleThreadHighlightClientY(getThreadHighlightLookupIds(props.comment)); + const visibleHighlightTargetY = usePageScopedAnchorOnly + ? null + : getVisibleThreadHighlightClientY(getThreadHighlightLookupIds(props.comment)); const visibleThreadTargetY = Number.isFinite(visibleHighlightTargetY) ? visibleHighlightTargetY : visibleAnchorTargetY; @@ -463,6 +510,7 @@ const setFocus = () => { entityType: 'trackedChange', entityId: cursorId, story: props.comment.trackedChangeStory, + ...(Number.isFinite(props.floatingPageIndex) ? { pageIndex: props.floatingPageIndex } : {}), } : { kind: 'entity', @@ -510,8 +558,12 @@ const setFocus = () => { // actually reach. Near scroll boundaries the preferred focus Y may be impossible // to achieve, and using that impossible target would visibly separate the bubble // from its highlight. - if (willChangeActiveThread) { - requestInstantSidebarAlignment(instantAlignmentTargetY, props.comment.commentId); + if (willChangeActiveDialog) { + if (props.floatingInstanceId) { + requestInstantSidebarAlignment(instantAlignmentTargetY, props.comment.commentId, props.floatingInstanceId); + } else { + requestInstantSidebarAlignment(instantAlignmentTargetY, props.comment.commentId); + } } else { clearInstantSidebarAlignment(); } @@ -520,6 +572,9 @@ const setFocus = () => { // floating sidebar can react to both state changes in the same flush. if (!props.comment.resolvedTime) { activeComment.value = props.comment.commentId; + if (props.floatingInstanceId) { + setActiveFloatingCommentInstance(props.floatingInstanceId); + } } }; @@ -546,10 +601,10 @@ const handleClickOutside = (e) => { return; } - if (activeComment.value === props.comment.commentId) { - floatingCommentsOffset.value = 0; - emit('dialog-exit'); - } + if (!isDialogActive.value) return; + + floatingCommentsOffset.value = 0; + emit('dialog-exit'); activeComment.value = null; commentsStore.setActiveComment(proxy.$superdoc, activeComment.value); isCommentHighlighted.value = false; @@ -644,6 +699,9 @@ const handleOverflowSelect = (value, comment) => { case 'edit': currentCommentText.value = comment?.commentText?.value ?? comment?.commentText ?? ''; activeComment.value = props.comment.commentId; + if (props.floatingInstanceId) { + setActiveFloatingCommentInstance(props.floatingInstanceId); + } editingCommentId.value = comment.commentId; commentsStore.setActiveComment(proxy.$superdoc, activeComment.value); nextTick(() => { @@ -673,7 +731,7 @@ const handleInternalExternalSelect = (value) => { const getSidebarCommentStyle = computed(() => { const style = {}; - if (isActiveComment.value || isPendingNewComment.value || isEditingAnyComment.value) { + if (isDialogActive.value || isPendingNewComment.value || isEditingAnyComment.value) { style.zIndex = 50; } @@ -714,7 +772,9 @@ onMounted(() => { } nextTick(() => { - const commentId = props.comment.importedId !== undefined ? props.comment.importedId : props.comment.commentId; + const commentId = + props.floatingInstanceId ?? + (props.comment.importedId !== undefined ? props.comment.importedId : props.comment.commentId); emit('ready', { commentId, elementRef: commentDialogElement }); checkOverflow(); }); @@ -732,7 +792,7 @@ watch( ); watch(editingCommentId, (commentId) => { - if (!commentId) return; + if (!commentId || !isDialogActive.value) return; const entry = comments.value.find((comment) => comment.commentId === commentId); if (!entry || entry.trackedChange) return; nextTick(() => { @@ -744,13 +804,17 @@ watch(editingCommentId, (commentId) => {