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 4cc339e09c..15add517c7 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 ce6869b26d..f988dd2caa 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2826,6 +2826,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 { @@ -6693,6 +6697,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; } @@ -7322,6 +7327,13 @@ const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => { return base; }; +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) { @@ -7489,6 +7501,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, @@ -7506,8 +7531,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 0bc5a4d59b..23ba18bfe0 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -249,7 +249,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) @@ -271,7 +274,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 => { @@ -509,6 +512,7 @@ export function paragraphToFlowBlocks({ para, nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, @@ -572,7 +576,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 = { @@ -619,6 +623,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); // Ghost list artifact suppression only applies in markup/review modes. @@ -726,6 +731,7 @@ export function paragraphToFlowBlocks({ const inlineConverterParams = { node: node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks: inheritedMarks ?? [], @@ -748,6 +754,7 @@ export function paragraphToFlowBlocks({ nextBlockId: stableNextBlockId, nextId, positions, + storyKey, trackedChangesConfig, defaultFont, defaultSize, @@ -862,6 +869,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); if (trackedChangesConfig.enabled && filteredRuns.length === 0) { return; @@ -1082,6 +1090,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): para: node, nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1110,6 +1119,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 4ffd9da91d..dd689a7719 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -189,6 +189,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 5a98b205ed..46d0cfda19 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. @@ -279,6 +286,7 @@ export interface NodeHandlerContext { // ID generation & positions nextBlockId: BlockIdGenerator; blockIdPrefix?: string; + storyKey?: string; positions: PositionMap; // Style & defaults @@ -333,6 +341,7 @@ export type ParagraphToFlowBlocksParams = { para: PMNode; nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; themeColors?: ThemeColorPalette; @@ -348,6 +357,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/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/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts index 0ece308cb5..cbe9996dfb 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,14 @@ * - 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 (transitional) + * This visible child-PM overlay is the legacy header/footer editing model. + * When {@link PresentationEditorOptions.useHiddenHostForStoryParts} is enabled, + * header/footer editing runs through the story-session/hidden-host path + * (see `presentation-editor/story-session/`) and this overlay is bypassed. + * It will be retired once the story-session path has shipped and the flag + * defaults to `true`. See `plans/story-backed-parts-presentation-editing.md`. */ import type { HeaderFooterRegion } from './types.js'; @@ -184,6 +192,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..9b1dda41b0 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 @@ -129,4 +129,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/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 5a9783e3e9..108b5d19ab 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, @@ -73,6 +77,13 @@ 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 { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.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 +134,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 +199,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 +391,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 +449,15 @@ export class PresentationEditor extends EventEmitter { #trackedChangesOverrides: TrackedChangesOverrides | undefined; // Header/footer session management #headerFooterSession: HeaderFooterSessionManager | null = null; + /** + * Generic story-backed presentation-session manager. + * + * Header/footer editing uses this manager only when + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} is enabled. + * Note/endnote editing creates it lazily on first activation because notes + * have no legacy visible-PM overlay fallback. + */ + #storySessionManager: StoryPresentationSessionManager | null = null; #hoverOverlay: HTMLElement | null = null; #hoverTooltip: HTMLElement | null = null; #modeBanner: HTMLElement | null = null; @@ -377,6 +466,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; @@ -630,6 +722,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'; @@ -673,7 +769,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; @@ -685,6 +781,7 @@ export class PresentationEditor extends EventEmitter { } this.#setupHeaderFooterSession(); + this.#setupStorySessionManager(); this.#applyZoom(); this.#setupEditorListeners(); this.#initializeEditorInputManager(); @@ -692,6 +789,7 @@ export class PresentationEditor extends EventEmitter { this.#setupDragHandlers(); this.#setupInputBridge(); this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); this.#setupSemanticResizeObserver(); this.#initializeProofing(); @@ -720,25 +818,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. + * + * 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: * - * 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. + * 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 @@ -748,8 +854,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; } @@ -786,54 +892,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); @@ -1086,6 +1198,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) { @@ -1094,6 +1211,68 @@ 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. + * + * Header/footer consumers should still treat + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} as the + * rollout gate for hidden-host header/footer editing. Note sessions may + * create the manager lazily even when that flag is off. + * + * This is a transitional surface exposed so tests and opt-in callers + * can drive activation while the full Phase 3/4 geometry/pointer + * plumbing is landed incrementally. Do not rely on it from product + * code yet. + * + * @experimental + */ + getStorySessionManager(): StoryPresentationSessionManager | null { + return this.#storySessionManager; + } + // ------------------------------------------------------------------- // Selection bridge — tracked handles + snapshot convenience // ------------------------------------------------------------------- @@ -1374,9 +1553,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) { @@ -1423,6 +1604,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(); @@ -1537,22 +1719,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 []; @@ -1569,10 +1746,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; @@ -1597,7 +1780,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); @@ -1617,12 +1800,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; @@ -1666,8 +1846,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); } /** @@ -1702,6 +1894,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. @@ -1754,6 +1982,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)) { @@ -1761,7 +2002,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; @@ -1779,6 +2020,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. * @@ -1790,98 +2048,338 @@ 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(); + 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(); }); @@ -2052,7 +2550,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) { @@ -2288,11 +2820,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; @@ -2313,6 +2848,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) { @@ -2411,7 +2976,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); @@ -2501,12 +3066,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; @@ -2522,6 +3092,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, @@ -2937,6 +3517,8 @@ export class PresentationEditor extends EventEmitter { this.#a11ySelectionAnnounceTimeout = null; } + this.#teardownStorySessionEventBridge(); + // Unregister from static registry if (this.#registryKey) { PresentationEditor.#instances.delete(this.#registryKey); @@ -2949,8 +3531,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; @@ -3199,6 +3789,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()); @@ -3344,6 +3935,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(); @@ -3537,6 +4129,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 @@ -3562,6 +4155,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), @@ -3576,6 +4170,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(), }); } @@ -3775,6 +4371,11 @@ export class PresentationEditor extends EventEmitter { this.#visibleHost, () => this.#getActiveDomTarget(), () => !this.#isViewLocked(), + () => this.#editorInputManager?.notifyTargetChanged(), + { + useWindowFallback: true, + getTargetEditor: () => this.getActiveEditor(), + }, ); this.#inputBridge.bind(); } @@ -3803,6 +4404,8 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, + getStorySessionManager: () => + this.#options.useHiddenHostForStoryParts ? this.#ensureStorySessionManager() : null, }); // Set up callbacks @@ -3878,6 +4481,16 @@ 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.#emitCommentPositions(); + } this.emit('headerFooterTransaction', { editor: this.#editor, sourceEditor, @@ -3894,48 +4507,205 @@ 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 - */ - #selectWordAt(pos: number): boolean { - const state = this.#editor.state; + #syncStorySessionEventBridge(session: StoryPresentationSession | null): void { + this.#teardownStorySessionEventBridge(); + + if (!session || session.kind !== 'note') { + this.#scheduleSelectionUpdate({ immediate: true }); + return; + } + + const handler = () => { + this.#scheduleSelectionUpdate(); + this.#scheduleA11ySelectionAnnouncement(); + }; + const transactionHandler = ({ transaction }: { transaction?: { docChanged?: boolean } }) => { + if (!transaction?.docChanged) { + return; + } + 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 surfaceKind = editorContext.surfaceKind; + + let fresh: Editor; + if ( + runtime.kind === 'headerFooter' && + (surfaceKind === 'header' || surfaceKind === 'footer') && + runtime.locator.storyType === 'headerFooterPart' + ) { + const editorContainer = hostElement.ownerDocument.createElement('div'); + fresh = createHeaderFooterEditor({ + editor: this.#editor, + data: pmJson, + editorContainer, + editorHost: hostElement, + headerFooterRefId: runtime.locator.refId, + type: surfaceKind, + availableWidth: editorContext.availableWidth, + availableHeight: editorContext.availableHeight, + currentPageNumber: editorContext.currentPageNumber, + totalPageCount: editorContext.totalPageCount, + }); + } else { + 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. + * + * Header/footer hidden-host editing still rolls out behind + * {@link PresentationEditorOptions.useHiddenHostForStoryParts}. Note + * sessions call {@link #ensureStorySessionManager} lazily when activated. + */ + #setupStorySessionManager() { + if (!this.#options.useHiddenHostForStoryParts) return; + 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 activeEditor = this.getActiveEditor(); + const state = activeEditor.state; if (!state?.doc) { return false; } @@ -3947,7 +4717,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') { @@ -3973,7 +4743,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; } @@ -3983,7 +4754,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') { @@ -4246,7 +5017,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 } @@ -4262,6 +5042,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(); @@ -4301,8 +5083,8 @@ 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; + resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; + resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures; resolvedLayout = resolveLayout({ layout, @@ -4331,6 +5113,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 @@ -4478,11 +5262,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 }); @@ -5005,7 +5785,11 @@ export class PresentationEditor extends EventEmitter { const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - this.#updateHeaderFooterSelection(); + this.#updateHeaderFooterSelection(shouldScrollIntoView); + return; + } + if (this.#getActiveNoteStorySession()) { + this.#updateNoteSelection(shouldScrollIntoView); return; } @@ -5620,7 +6404,277 @@ 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; + } + + #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; + } + } + } + + const renderedFragments = Array.from(this.#viewportHost.querySelectorAll('[data-block-id]')); + for (const fragmentElement of renderedFragments) { + const blockId = fragmentElement.getAttribute('data-block-id') ?? ''; + if (!noteBlockIds.has(blockId)) { + continue; + } + + 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; @@ -5908,7 +6962,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)); } } @@ -5990,10 +7044,24 @@ 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; + 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. @@ -6004,7 +7072,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) { @@ -6026,6 +7096,128 @@ 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); + 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). * @@ -6223,6 +7415,142 @@ export class PresentationEditor extends EventEmitter { return this.#headerFooterSession?.computeSelectionRects(from, to) ?? []; } + #computeHeaderFooterCaretRect(pos: number): LayoutRect | null { + return this.#headerFooterSession?.computeCaretRect(pos) ?? null; + } + + #computeNoteSelectionRects(from: number, to: number): LayoutRect[] { + const context = this.#buildActiveNoteLayoutContext(); + const layout = this.#layoutState.layout; + if (!context || !layout) { + return []; + } + + return ( + selectionToRects(layout, context.blocks, context.measures, from, to, this.#pageGeometryHelper ?? undefined) ?? [] + ); + } + + #computeNoteDomCaretRect(context: NoteLayoutContext, pos: number): LayoutRect | null { + const layout = this.#layoutState.layout; + const pageCount = layout?.pages?.length ?? 0; + if (pageCount === 0) { + return null; + } + + const noteBlockIds = this.#collectNoteBlockIds(context); + if (noteBlockIds.size === 0) { + return null; + } + + const zoom = + typeof this.#layoutOptions.zoom === 'number' && + Number.isFinite(this.#layoutOptions.zoom) && + this.#layoutOptions.zoom > 0 + ? this.#layoutOptions.zoom + : 1; + const pageStride = this.#getBodyPageHeight() + (layout?.pageGap ?? 0); + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const pageElement = this.#getPageElement(pageIndex); + if (!pageElement) { + continue; + } + + const pageRect = pageElement.getBoundingClientRect(); + const noteFragments = Array.from(pageElement.querySelectorAll('[data-block-id]')).filter((element) => + noteBlockIds.has(element.getAttribute('data-block-id') ?? ''), + ); + + for (const fragmentElement of noteFragments) { + const textRuns = fragmentElement.querySelectorAll('[data-pm-start][data-pm-end]'); + for (const runElement of Array.from(textRuns)) { + const pmStart = Number(runElement.dataset.pmStart); + const pmEnd = Number(runElement.dataset.pmEnd); + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || pos < pmStart || pos > pmEnd) { + continue; + } + + const textNode = Array.from(runElement.childNodes).find( + (node): node is Text => node.nodeType === Node.TEXT_NODE, + ); + if (!textNode) { + continue; + } + + const charIndex = Math.max(0, Math.min(textNode.length, pos - pmStart)); + const range = runElement.ownerDocument?.createRange(); + if (!range) { + continue; + } + + range.setStart(textNode, charIndex); + range.setEnd(textNode, charIndex); + const rect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + continue; + } + + const localX = (rect.left - pageRect.left) / zoom; + const localY = (rect.top - pageRect.top) / zoom; + const height = Math.max(1, rect.height / zoom); + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + continue; + } + + return { + pageIndex, + x: localX, + y: pageIndex * pageStride + localY, + width: 1, + height, + }; + } + } + } + + return null; + } + + #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, + true, + ); + 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(); @@ -6234,6 +7562,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) { @@ -6714,6 +8049,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) { @@ -6838,10 +8188,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) { @@ -6859,11 +8209,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; } @@ -6893,6 +8267,97 @@ 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) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + 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.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() { @@ -6930,3 +8395,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/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 698f155d34..aed0792f9f 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 @@ -13,6 +13,7 @@ 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 type { Editor } from '../../Editor.js'; import type { @@ -27,6 +28,7 @@ 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'; @@ -42,6 +44,7 @@ import { 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 { @@ -135,6 +138,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; }; /** @@ -224,6 +232,10 @@ export class HeaderFooterSessionManager { // Document mode #documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing'; + #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: 'review', + enabled: true, + }; constructor(options: HeaderFooterSessionManagerOptions) { this.#options = options; @@ -420,6 +432,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); } /** @@ -483,6 +515,7 @@ export class HeaderFooterSessionManager { this.#headerFooterIdentifier = result.headerFooterIdentifier; this.#headerFooterManager = result.headerFooterManager; this.#headerFooterAdapter = result.headerFooterAdapter; + this.#headerFooterAdapter?.setTrackedChangesRenderConfig(this.#trackedChangesRenderConfig); this.#managerCleanups = result.cleanups; } @@ -725,15 +758,19 @@ export class HeaderFooterSessionManager { // Capture headerFooterRefId before clearing session - needed for cache invalidation const editedHeaderId = this.#session.headerFooterRefId; + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; 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(); + if (storySessionManager) { + storySessionManager.exit(); + } else { + this.#overlayManager?.hideEditingOverlay(); + this.#overlayManager?.showSelectionOverlay(); + } this.#activeEditor = null; this.#session = { mode: 'body' }; @@ -765,9 +802,38 @@ export class HeaderFooterSessionManager { this.activateRegion(region); } + #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: 'onExit', + 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): Promise { try { - if (!this.#headerFooterManager || !this.#overlayManager) { + if (!this.#headerFooterManager) { this.clearHover(); return; } @@ -775,8 +841,7 @@ export class HeaderFooterSessionManager { // 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(); @@ -854,46 +919,70 @@ export class HeaderFooterSessionManager { return; } - 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); - this.clearHover(); - this.#callbacks.onError?.({ - error: new Error(`Failed to create editor host: ${reason}`), - context: 'enterMode.showOverlay', - }); - return; - } - - 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, - }); - } catch (editorError) { - console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); - this.#overlayManager.hideEditingOverlay(); - this.clearHover(); - this.#callbacks.onError?.({ - error: editorError, - context: 'enterMode.ensureEditor', - }); - return; + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; + if (storySessionManager) { + try { + editor = this.#activateStorySessionForRegion(region, descriptor); + } catch (editorError) { + console.error('[HeaderFooterSessionManager] Error creating story session:', editorError); + this.clearHover(); + this.#callbacks.onError?.({ + error: editorError, + context: 'enterMode.storySession', + }); + return; + } + } else { + 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); + this.clearHover(); + this.#callbacks.onError?.({ + error: new Error(`Failed to create editor host: ${reason}`), + context: 'enterMode.showOverlay', + }); + return; + } + + if (region.kind === 'footer') { + const editorContainer = editorHost.querySelector('.super-editor'); + if (editorContainer instanceof HTMLElement) { + editorContainer.style.transform = ''; + } + } + + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; + try { + editor = await this.#headerFooterManager.ensureEditor(descriptor, { + editorHost, + availableWidth: region.width, + availableHeight: region.height, + currentPageNumber: region.pageNumber, + totalPageCount: bodyPageCount, + }); + } catch (editorError) { + console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); + this.#overlayManager.hideEditingOverlay(); + this.clearHover(); + this.#callbacks.onError?.({ + error: editorError, + context: 'enterMode.ensureEditor', + }); + return; + } } if (!editor) { console.warn('[HeaderFooterSessionManager] Failed to ensure editor for descriptor:', descriptor); - this.#overlayManager.hideEditingOverlay(); + if (!storySessionManager) { + this.#overlayManager.hideEditingOverlay(); + } this.clearHover(); this.#callbacks.onError?.({ error: new Error('Failed to create editor instance'), @@ -902,43 +991,8 @@ export class HeaderFooterSessionManager { 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 = ''; - } - } - } - 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)); - } - } catch { - // Best-effort: if we can't wire the focus handler, continue without it. - } + this.#applyChildEditorDocumentMode(editor, this.#documentMode); // Move caret to end of content try { @@ -953,7 +1007,9 @@ export class HeaderFooterSessionManager { } } catch (editableError) { console.error('[HeaderFooterSessionManager] Error setting editor editable:', editableError); - this.#overlayManager.hideEditingOverlay(); + if (!storySessionManager) { + this.#overlayManager.hideEditingOverlay(); + } this.clearHover(); this.#callbacks.onError?.({ error: editableError, @@ -1006,6 +1062,32 @@ export class HeaderFooterSessionManager { } } + #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'); + } + } + #validateEditPermission(): { allowed: boolean; reason?: string } { if (this.#deps?.isViewLocked()) { return { allowed: false, reason: 'documentMode' }; @@ -1377,15 +1459,11 @@ 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 + * In visible overlay-host mode we read the active editor's DOM Range geometry + * directly for tight browser-aligned highlights. In hidden-host story-session + * mode, those DOM rects live off-screen, so we instead project the requested + * PM range through the header/footer layout data and then remap it into the + * active page region. */ computeSelectionRects(from: number, to: number): LayoutRect[] { // Guard: must be in header/footer mode with an active editor and region context. @@ -1411,30 +1489,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 +1510,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 +1549,177 @@ 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 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] : []; + } + + #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)); + if (start === end) { + return null; + } + + 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 = 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 activeEditor = this.#activeEditor; + const view = activeEditor?.view; + if (!view || typeof view.coordsAtPos !== 'function') { + return null; + } + + const context = this.getContext(); + if (!context) { + return null; + } + + const region = context.region; + 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 + ? layoutOptions.zoom + : 1; + + try { + const coords = view.coordsAtPos(pos); + const editorDom = view.dom as HTMLElement; + const editorHostRect = editorDom.getBoundingClientRect(); + const localX = (coords.left - editorHostRect.left) / zoom; + const localY = (coords.top - editorHostRect.top) / zoom; + const height = Math.max(1, (coords.bottom - coords.top) / zoom); + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex: region.pageIndex, + x: region.localX + localX, + y: region.pageIndex * bodyPageHeight + region.localY + localY, + width: 1, + height, + }; + } catch { + return null; + } + } + /** * Get the current header/footer layout context. */ 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 6dd36c0bf2..ebc4a27850 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,8 @@ 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 COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], @@ -71,12 +74,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 +156,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; } @@ -204,6 +251,29 @@ function resolveCommentThreadIdNearPointer( return null; } +function getVisibleHeaderFooterSurfaceAtPointer( + target: EventTarget | null, + clientX: number, + clientY: number, +): HTMLElement | 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 topmostElement = ownerDocument.elementFromPoint(sampleX, sampleY); + + if (!(topmostElement instanceof HTMLElement)) { + return null; + } + + return topmostElement.closest(VISIBLE_HEADER_FOOTER_SELECTOR) as HTMLElement | null; +} + function getActiveCommentThreadId(editor: Editor): string | null { const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null; const activeThreadId = pluginState?.activeThreadId; @@ -288,6 +358,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; }; /** @@ -367,6 +439,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 +686,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 +752,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 +781,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 +1236,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 +1290,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 +1341,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 +1410,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 +1434,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 +1509,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 +1596,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); } @@ -1468,6 +1704,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 +1738,15 @@ export class EditorInputManager { normalized.pageLocalY, ); if (region) { - event.preventDefault(); - event.stopPropagation(); + 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); + // 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); + } } else if ((this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body') !== 'body') { this.#callbacks.exitHeaderFooterMode?.(); } @@ -1512,11 +1777,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,14 +2078,25 @@ export class EditorInputManager { const activeEditorHost = session?.overlayManager?.getActiveEditorHost?.(); const clickedInsideEditorHost = activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target); + const activeSurfaceSelector = + session?.session?.mode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header'; + const visibleSurfaceAtPointer = getVisibleHeaderFooterSurfaceAtPointer(event.target, event.clientX, event.clientY); + const clickedInsideVisibleActiveSurface = visibleSurfaceAtPointer?.closest(activeSurfaceSelector) != null; if (clickedInsideEditorHost) { + this.#syncNonBodyCommentSelection(event, event.target as HTMLElement | null, this.#deps.getEditor(), { + clearOnMiss: true, + }); return true; // Let editor handle it } + if (!clickedInsideVisibleActiveSurface) { + 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); if (!headerFooterRegion) { - this.#callbacks.exitHeaderFooterMode?.(); return false; // Continue to body click handling } @@ -1935,7 +2217,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; @@ -1964,26 +2246,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; @@ -1996,21 +2278,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, @@ -2018,7 +2287,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) { @@ -2239,8 +2508,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; @@ -2267,7 +2584,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; @@ -2304,6 +2621,41 @@ export class EditorInputManager { return true; } + #syncNonBodyCommentActivation(event: PointerEvent, target: HTMLElement | null, editor: Editor): void { + this.#syncNonBodyCommentSelection(event, target, editor); + } + + #syncNonBodyCommentSelection( + event: PointerEvent, + target: HTMLElement | null, + editor: Editor, + { clearOnMiss = false }: { clearOnMiss?: boolean } = {}, + ): void { + const clickedThreadId = resolveCommentThreadIdNearPointer(target, 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/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..62e0de00e7 --- /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. This is the canonical mode under the + * `useHiddenHostForStoryParts` flag. + * + * 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..d2d381fe64 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 @@ -28,15 +28,14 @@ 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, }; }); @@ -44,6 +43,7 @@ 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 +64,10 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; let mockDeps: EditorInputDependencies; let mockCallbacks: EditorInputCallbacks; + let activateRenderedNoteSession: Mock; beforeEach(() => { + originalElementFromPoint = document.elementFromPoint?.bind(document); viewportHost = document.createElement('div'); viewportHost.className = 'presentation-editor__viewport'; visibleHost = document.createElement('div'); @@ -92,6 +94,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }, selection: { $anchor: null }, storedMarks: null, + comments$: { activeThreadId: null }, }, view: { dispatch: vi.fn(), @@ -106,6 +109,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 +128,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 +144,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 +162,34 @@ 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; + } + + 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 +208,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 +301,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 +352,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 +403,324 @@ 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(); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => null) }, + }); + 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, + })); + + 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('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 bodyText = document.createElement('span'); + bodyText.textContent = 'Visible body text'; + viewportHost.appendChild(bodyText); + stubElementFromPoint(bodyText); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => null) }, + }); + 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 clicks inside the active header editor host', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const activeEditorHost = document.createElement('div'); + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.className = 'track-insert'; + trackedChangeEl.setAttribute('data-id', 'tc-header-1'); + activeEditorHost.appendChild(trackedChangeEl); + viewportHost.appendChild(activeEditorHost); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => activeEditorHost) }, + }); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.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(mockCallbacks.scheduleSelectionUpdate as Mock).not.toHaveBeenCalled(); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + }); + + it('clears the active tracked-change bubble for plain clicks inside the active header editor host', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const activeEditorHost = document.createElement('div'); + const plainTextEl = document.createElement('span'); + plainTextEl.textContent = 'Generic content header'; + activeEditorHost.appendChild(plainTextEl); + viewportHost.appendChild(activeEditorHost); + + (mockEditor.state as typeof mockEditor.state & { comments$: { activeThreadId: string | null } }).comments$ = { + activeThreadId: 'tc-header-1', + }; + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => activeEditorHost) }, + }); + + 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(mockCallbacks.scheduleSelectionUpdate as Mock).not.toHaveBeenCalled(); + expect(resolvePointerPositionHit).not.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..06c3b71172 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); @@ -171,6 +192,7 @@ describe('HeaderFooterSessionManager', () => { manager.setDependencies(deps); manager.initialize(); + manager.setDocumentMode(documentMode); manager.setLayoutResults( [ { @@ -203,12 +225,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)); @@ -261,4 +282,251 @@ 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 overlayManager = { + showEditingOverlay: vi.fn(() => ({ + success: true, + editorHost: document.createElement('div'), + reason: null, + })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + }; + + 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({ + overlayManager, + 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(overlayManager.showEditingOverlay).not.toHaveBeenCalled(); + 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: 'onExit', + 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 overlayManager = { + showEditingOverlay: vi.fn(), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + }; + + 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({ + overlayManager, + 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/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index f2fcdad938..3e54517cb7 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 @@ -174,7 +174,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 +185,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.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 40f9fd8b4e..ab237f8bc1 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 @@ -1032,6 +1053,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); // Verify by checking that Editor was called with documentMode: 'editing' @@ -1057,6 +1079,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1073,6 +1096,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1088,6 +1112,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1108,6 +1133,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); // Call with invalid mode should throw @@ -1183,6 +1209,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1197,6 +1224,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1216,6 +1244,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const result = editor.normalizeClientPoint(120, 80); @@ -1226,6 +1255,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1240,6 +1270,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1258,6 +1289,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1280,6 +1312,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -2230,6 +2263,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2288,6 +2322,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2356,6 +2391,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2414,6 +2450,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2504,6 +2541,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2540,6 +2578,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2606,6 +2645,271 @@ 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()).toBeNull(); + + footnoteFragment.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }), + ); + + await vi.waitFor(() => expect(mockCreateStoryEditor.mock.calls.length).toBeGreaterThanOrEqual(2)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(2)); + + 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 without enabling hidden-host header/footer rollout', 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/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index e442a6f34e..c6be222601 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 @@ -201,6 +201,24 @@ export type PresentationEditorOptions = ConstructorParameters[0] * @default false */ allowSelectionInViewMode?: boolean; + /** + * Route interactive header/footer editing through the body-style + * presentation editing architecture: a hidden off-screen ProseMirror host + * plus layout-engine rendering. When `false`, header/footer editing + * continues to mount a visible child PM overlay via + * {@link EditorOverlayManager}. Notes/endnotes still use story-backed + * presentation sessions when activated because they have no legacy overlay + * editing surface. + * + * This is a transitional flag governing the rollout of the story-backed + * parts presentation editing refactor. See + * `plans/story-backed-parts-presentation-editing.md`. + * + * Enabled by default. Pass `false` to opt back into the legacy mounted + * overlay path while it still exists. + * @experimental + */ + useHiddenHostForStoryParts?: boolean; }; /** @@ -343,6 +361,10 @@ export interface EditorWithConverter extends Editor { id: string; content?: unknown[]; }>; + endnotes?: Array<{ + id: string; + content?: unknown[]; + }>; }; } @@ -425,7 +447,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 e1b0b058d4..6f44834272 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 @@ -19,12 +19,13 @@ import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.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'; @@ -152,9 +153,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 }); @@ -240,6 +243,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..04a87dd586 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/document-api-adapters/helpers/note-pm-json.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts new file mode 100644 index 0000000000..23ec01c353 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeNotePmJson } from './note-pm-json.js'; + +describe('normalizeNotePmJson', () => { + it('returns the input unchanged when there is no content array', () => { + const doc = { type: 'doc' }; + expect(normalizeNotePmJson(doc)).toEqual({ type: 'doc' }); + }); + + it('drops empty leading run nodes inside paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [] }, + { type: 'run', content: [{ type: 'text', text: 'hello' }] }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'hello' }] }], + }, + ], + }); + }); + + it('strips a leading tab separator after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('strips a whitespace-only run after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: ' ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('trims a leading space from the first text run after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'EndnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: ' Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('strips hidden passthrough inline nodes from note paragraphs', () => { + const doc = { + type: 'doc', + 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)' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }); + }); + + it('preserves empty run nodes outside paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'custom', + content: [{ type: 'run', content: [] }], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual(doc); + }); + + it('treats runs with no content array as empty and strips them from paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run' }, { type: 'text', text: 'x' }], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x' }], + }, + ], + }); + }); + + it('recurses into nested structures', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'section', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [] }, + { type: 'run', content: [{ type: 'text', text: 'deep' }] }, + ], + }, + ], + }, + ], + }; + + const normalized = normalizeNotePmJson(doc) as { + content: Array<{ content: Array<{ content: unknown[] }> }>; + }; + expect(normalized.content[0].content[0].content).toEqual([ + { type: 'run', content: [{ type: 'text', text: 'deep' }] }, + ]); + }); + + it('does not mutate the input document', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [] }], + }, + ], + }; + const before = JSON.stringify(doc); + normalizeNotePmJson(doc); + expect(JSON.stringify(doc)).toBe(before); + }); +}); 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 new file mode 100644 index 0000000000..358ab858a9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts @@ -0,0 +1,171 @@ +type PmJsonNode = { + type?: unknown; + content?: unknown; + [key: string]: unknown; +}; + +function isPmJsonNode(value: unknown): value is PmJsonNode { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEmptyRunNode(value: unknown): value is PmJsonNode { + if (!isPmJsonNode(value) || value.type !== 'run') { + return false; + } + + return !Array.isArray(value.content) || value.content.length === 0; +} + +function isLeadingNoteReferenceRun(value: unknown): value is PmJsonNode { + if (!isEmptyRunNode(value)) { + return false; + } + + const styleId = (value.attrs as { runProperties?: { styleId?: unknown } } | undefined)?.runProperties?.styleId; + if (styleId === 'FootnoteReference' || styleId === 'EndnoteReference') { + return true; + } + + return true; +} + +function isWhitespaceOnlyTextNode(value: unknown): value is PmJsonNode { + return isPmJsonNode(value) && value.type === 'text' && typeof value.text === 'string' && /^\s*$/.test(value.text); +} + +function isInvisibleNotePassthroughNode(value: unknown): value is PmJsonNode { + return isPmJsonNode(value) && value.type === 'passthroughInline'; +} + +function stripLeadingWhitespaceFromTextNode(value: unknown): unknown { + if (!isPmJsonNode(value) || value.type !== 'text' || typeof value.text !== 'string') { + return value; + } + + const trimmed = value.text.replace(/^\s+/, ''); + if (trimmed.length === 0) { + return null; + } + + return trimmed === value.text ? value : { ...value, text: trimmed }; +} + +function stripLeadingNoteSeparatorFromRun(value: unknown): unknown { + if (!isPmJsonNode(value) || value.type !== 'run' || !Array.isArray(value.content)) { + return value; + } + + const remainingContent = [...value.content]; + while (remainingContent.length > 0) { + const firstChild = remainingContent[0]; + if (isPmJsonNode(firstChild) && firstChild.type === 'tab') { + remainingContent.shift(); + continue; + } + if (isWhitespaceOnlyTextNode(firstChild)) { + remainingContent.shift(); + continue; + } + + const normalizedFirstChild = stripLeadingWhitespaceFromTextNode(firstChild); + if (normalizedFirstChild == null) { + remainingContent.shift(); + continue; + } + + remainingContent[0] = normalizedFirstChild; + break; + } + + if (remainingContent.length === 0) { + return null; + } + + return { + ...value, + content: remainingContent, + }; +} + +function stripLeadingNoteSeparatorChildren(children: unknown[]): unknown[] { + const remainingChildren = [...children]; + + while (remainingChildren.length > 0) { + const firstChild = remainingChildren[0]; + if (!isPmJsonNode(firstChild)) { + break; + } + + if (firstChild.type === 'run') { + const normalizedRun = stripLeadingNoteSeparatorFromRun(firstChild); + if (normalizedRun == null) { + remainingChildren.shift(); + continue; + } + remainingChildren[0] = normalizedRun; + break; + } + + if (firstChild.type === 'tab' || isWhitespaceOnlyTextNode(firstChild)) { + remainingChildren.shift(); + continue; + } + + const normalizedText = stripLeadingWhitespaceFromTextNode(firstChild); + if (normalizedText == null) { + remainingChildren.shift(); + continue; + } + + remainingChildren[0] = normalizedText; + break; + } + + return remainingChildren; +} + +function normalizeNotePmNode(value: unknown): unknown { + if (!isPmJsonNode(value)) { + return value; + } + + const normalized: PmJsonNode = { ...value }; + if (!Array.isArray(value.content)) { + return normalized; + } + + const originalChildren = value.content; + const normalizedChildren = originalChildren + .map((child) => normalizeNotePmNode(child)) + .filter((child) => !isInvisibleNotePassthroughNode(child)) + .filter((child) => !(value.type === 'paragraph' && isEmptyRunNode(child))); + + if (value.type === 'paragraph' && originalChildren[0] && isLeadingNoteReferenceRun(originalChildren[0])) { + normalized.content = stripLeadingNoteSeparatorChildren(normalizedChildren); + return normalized; + } + + normalized.content = normalizedChildren; + return normalized; +} + +/** + * Normalize note PM JSON so interactive layout and story editors share the same + * position space. + * + * The note importer preserves note-only content from OOXML: + * the empty footnote/endnote reference run, the separator Word places + * immediately after it (typically a tab or a whitespace-only run), and any + * hidden passthrough field-code nodes. + * + * The rendered footnote surface does not expose those invisible note-only + * nodes as editable PM positions, so leaving them in the hidden story editor + * shifts the visible click surface and the active editor into different + * coordinate spaces. + * Keeping both paths on the same normalized PM JSON fixes the mismatch at the + * source. + */ +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/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index 6323a078f1..f21bf2bdb2 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -1,6 +1,11 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; -import type { TrackChangeType, TrackChangeWordRevisionIds } from '@superdoc/document-api'; +import type { + StoryLocator, + TrackChangeType, + TrackChangeWordRevisionIds, + TrackedChangeAddress, +} from '@superdoc/document-api'; import { TrackDeleteMarkName, TrackFormatMarkName, @@ -8,6 +13,9 @@ import { } from '../../extensions/track-changes/constants.js'; import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { normalizeExcerpt, toNonEmptyString } from './value-utils.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { buildStoryKey, BODY_STORY_KEY } from '../story-runtime/story-key.js'; +import type { TrackedChangeRuntimeRef } from './tracked-change-runtime-ref.js'; const DERIVED_ID_LENGTH = 24; @@ -213,3 +221,100 @@ export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map void; +} + +type TrackedChangeLookupInput = string | TrackedChangeAddress; + +function toAddress(input: TrackedChangeLookupInput): TrackedChangeAddress { + if (typeof input === 'string') { + return { kind: 'entity', entityType: 'trackedChange', entityId: input }; + } + return input; +} + +/** + * Resolves a tracked-change id/address to the owning story editor and the + * grouped change within it. + * + * For body addresses (no `story` field) this is an O(n) search against the + * host editor's grouped marks — same as the legacy body-only resolver. + * + * For non-body addresses it resolves the correct story runtime, then performs + * the lookup within that editor's state. + * + * Returns `null` if the address resolves to no matching tracked change. + */ +export function resolveTrackedChangeInStory( + hostEditor: Editor, + input: TrackedChangeLookupInput, +): ResolvedStoryTrackedChange | null { + const address = toAddress(input); + const entityId = address.entityId; + + const story: StoryLocator = address.story ?? { kind: 'story', storyType: 'body' }; + const storyKey = address.story ? buildStoryKey(address.story) : BODY_STORY_KEY; + + if (storyKey === BODY_STORY_KEY) { + const match = findMatchingChange(hostEditor, entityId); + if (!match) return null; + return { + editor: hostEditor, + story, + runtimeRef: { storyKey: BODY_STORY_KEY, rawId: match.rawId }, + change: match, + }; + } + + let runtime; + try { + runtime = resolveStoryRuntime(hostEditor, story); + } catch { + return null; + } + + const match = findMatchingChange(runtime.editor, entityId); + if (!match) return null; + return { + editor: runtime.editor, + story: runtime.locator, + runtimeRef: { storyKey: runtime.storyKey, rawId: match.rawId }, + change: match, + commit: runtime.commit, + }; +} + +/** + * Lookup helper — accepts both the canonical id and the raw mark id to + * tolerate callers that stored whichever was convenient at the time. + */ +function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { + const grouped = groupTrackedChanges(editor); + return grouped.find((item) => item.id === id || item.rawId === id) ?? null; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts new file mode 100644 index 0000000000..e4eb051fe5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + isCommentAnchorKey, + isTrackedChangeAnchorKey, + makeCommentAnchorKey, + makeTrackedChangeAnchorKey, + parseTrackedChangeAnchorKey, +} from './tracked-change-runtime-ref.js'; + +describe('anchor key helpers', () => { + it('makeTrackedChangeAnchorKey formats tc::::', () => { + expect(makeTrackedChangeAnchorKey({ storyKey: 'body', rawId: 'rev-1' })).toBe('tc::body::rev-1'); + expect(makeTrackedChangeAnchorKey({ storyKey: 'hf:part:rId4', rawId: 'r7' })).toBe('tc::hf:part:rId4::r7'); + expect(makeTrackedChangeAnchorKey({ storyKey: 'fn:5', rawId: 'rev-123' })).toBe('tc::fn:5::rev-123'); + }); + + it('makeCommentAnchorKey formats comment::', () => { + expect(makeCommentAnchorKey('c-1')).toBe('comment::c-1'); + }); + + it('isTrackedChangeAnchorKey classifies keys', () => { + expect(isTrackedChangeAnchorKey('tc::body::r1')).toBe(true); + expect(isTrackedChangeAnchorKey('comment::c-1')).toBe(false); + expect(isTrackedChangeAnchorKey('r1')).toBe(false); + }); + + it('isCommentAnchorKey classifies keys', () => { + expect(isCommentAnchorKey('comment::c-1')).toBe(true); + expect(isCommentAnchorKey('tc::body::r1')).toBe(false); + }); + + it('parseTrackedChangeAnchorKey round-trips body and non-body', () => { + expect(parseTrackedChangeAnchorKey('tc::body::rev-1')).toEqual({ storyKey: 'body', rawId: 'rev-1' }); + expect(parseTrackedChangeAnchorKey('tc::hf:part:rId4::r7')).toEqual({ + storyKey: 'hf:part:rId4', + rawId: 'r7', + }); + expect(parseTrackedChangeAnchorKey('tc::fn:12::rev-abc')).toEqual({ storyKey: 'fn:12', rawId: 'rev-abc' }); + }); + + it('parseTrackedChangeAnchorKey rejects malformed keys', () => { + expect(parseTrackedChangeAnchorKey('not-an-anchor')).toBeNull(); + expect(parseTrackedChangeAnchorKey('tc::')).toBeNull(); + expect(parseTrackedChangeAnchorKey('comment::c1')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts new file mode 100644 index 0000000000..56939f6b4b --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts @@ -0,0 +1,82 @@ +/** + * Internal helpers for tracked-change runtime refs and shared anchor keys. + * + * Public tracked-change addresses use canonical IDs, while runtime refs use + * story-local raw IDs. This module intentionally stays on the runtime side of + * that boundary; it does not attempt to convert contract addresses. + */ + +/** + * Internal runtime form of a tracked-change identity. + * + * - `storyKey` — compact, cache-friendly story identity (see `story-key.ts`). + * - `rawId` — the raw tracked-change mark ID local to the owning story editor. + * + * Story runtimes are editor-scoped and revision-tracking is per-editor, so + * `rawId` is story-local by construction. This ref captures that scoping + * explicitly so sidebar position maps, accept/reject routers, and the + * TrackedChangeIndex can key on the full (storyKey, rawId) tuple without + * ambiguity. + */ +export interface TrackedChangeRuntimeRef { + storyKey: string; + rawId: string; +} + +/** Prefix for tracked-change anchor keys in shared position maps. */ +export const TRACKED_CHANGE_ANCHOR_KEY_PREFIX = 'tc::'; + +/** Prefix for comment anchor keys in shared position maps. */ +export const COMMENT_ANCHOR_KEY_PREFIX = 'comment::'; + +/** + * Builds the canonical shared-map anchor key for a tracked-change runtime ref. + * + * Format: `tc::::`. + */ +export function makeTrackedChangeAnchorKey(ref: TrackedChangeRuntimeRef): string { + return `${TRACKED_CHANGE_ANCHOR_KEY_PREFIX}${ref.storyKey}::${ref.rawId}`; +} + +/** + * Builds the canonical shared-map anchor key for a comment id. + * + * Format: `comment::`. + */ +export function makeCommentAnchorKey(commentId: string): string { + return `${COMMENT_ANCHOR_KEY_PREFIX}${commentId}`; +} + +/** + * Returns true when the given key is a canonical tracked-change anchor key. + */ +export function isTrackedChangeAnchorKey(key: string): boolean { + return typeof key === 'string' && key.startsWith(TRACKED_CHANGE_ANCHOR_KEY_PREFIX); +} + +/** + * Returns true when the given key is a canonical comment anchor key. + */ +export function isCommentAnchorKey(key: string): boolean { + return typeof key === 'string' && key.startsWith(COMMENT_ANCHOR_KEY_PREFIX); +} + +/** + * Parses a canonical tracked-change anchor key back into a {@link TrackedChangeRuntimeRef}. + * + * Returns `null` when the key is not a tracked-change anchor key or when + * the format is malformed. + */ +export function parseTrackedChangeAnchorKey(key: string): TrackedChangeRuntimeRef | null { + if (!isTrackedChangeAnchorKey(key)) return null; + + const body = key.slice(TRACKED_CHANGE_ANCHOR_KEY_PREFIX.length); + const separatorIndex = body.lastIndexOf('::'); + if (separatorIndex <= 0 || separatorIndex >= body.length - 2) return null; + + const storyKey = body.slice(0, separatorIndex); + const rawId = body.slice(separatorIndex + 2); + if (!storyKey || !rawId) return null; + + return { storyKey, rawId }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts new file mode 100644 index 0000000000..7e684e500f --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; + +const mocks = vi.hoisted(() => ({ + checkRevision: vi.fn(), + getRevision: vi.fn(() => '0'), + executeDomainCommand: vi.fn(), + resolveTrackedChangeInStory: vi.fn(), + getTrackedChangeIndex: vi.fn(), + resolveStoryRuntime: vi.fn(), +})); + +vi.mock('./revision-tracker.js', () => ({ + checkRevision: mocks.checkRevision, + getRevision: mocks.getRevision, +})); + +vi.mock('./plan-wrappers.js', () => ({ + executeDomainCommand: mocks.executeDomainCommand, +})); + +vi.mock('../helpers/tracked-change-resolver.js', () => ({ + resolveTrackedChangeInStory: mocks.resolveTrackedChangeInStory, + resolveTrackedChangeType: vi.fn(() => 'insert'), +})); + +vi.mock('../tracked-changes/tracked-change-index.js', () => ({ + getTrackedChangeIndex: mocks.getTrackedChangeIndex, +})); + +vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: mocks.resolveStoryRuntime, +})); + +import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper } from './track-changes-wrappers.js'; + +const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; + +function makeEditor(commands: Record = {}): Editor { + return { + commands, + state: { doc: { textBetween: vi.fn(() => '') } }, + } as unknown as Editor; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRevision.mockReturnValue('0'); + mocks.executeDomainCommand.mockReturnValue({ steps: [{ effect: 'changed' }] }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); +}); + +describe('track-changes-wrappers revision guard', () => { + it('checks expectedRevision on the host editor before accepting a non-body tracked change', () => { + const hostEditor = makeEditor(); + const storyEditor = makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }); + const commit = vi.fn(); + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + commit, + }); + mocks.getTrackedChangeIndex.mockReturnValue(index); + + const receipt = trackChangesAcceptWrapper( + hostEditor, + { id: 'canon-1', story: footnoteStory }, + { expectedRevision: '12' }, + ); + + expect(receipt).toEqual({ success: true }); + expect(mocks.checkRevision).toHaveBeenCalledWith(hostEditor, '12'); + expect(mocks.executeDomainCommand).toHaveBeenCalledWith(storyEditor, expect.any(Function)); + expect(commit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); + + it('checks expectedRevision once on the host editor for accept-all across multiple stories', () => { + const hostEditor = makeEditor(); + const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const footnoteEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const bodyCommit = vi.fn(); + const footnoteCommit = vi.fn(); + + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const snapshots = [ + { + story: bodyStory, + runtimeRef: { storyKey: 'body', rawId: 'raw-body' }, + }, + { + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-fn' }, + }, + ]; + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => snapshots), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.getTrackedChangeIndex.mockReturnValue(index); + mocks.resolveStoryRuntime.mockImplementation((_host: Editor, story: StoryLocator) => { + if (story.storyType === 'body') { + return { editor: bodyEditor, storyKey: 'body', locator: story, kind: 'body', commit: bodyCommit }; + } + + return { editor: footnoteEditor, storyKey: 'fn:5', locator: story, kind: 'note', commit: footnoteCommit }; + }); + + const receipt = trackChangesAcceptAllWrapper(hostEditor, {}, { expectedRevision: '33' }); + + expect(receipt).toEqual({ success: true }); + expect(mocks.checkRevision).toHaveBeenCalledTimes(1); + expect(mocks.checkRevision).toHaveBeenCalledWith(hostEditor, '33'); + expect(mocks.executeDomainCommand).toHaveBeenNthCalledWith(1, bodyEditor, expect.any(Function)); + expect(mocks.executeDomainCommand).toHaveBeenNthCalledWith(2, footnoteEditor, expect.any(Function)); + expect(bodyCommit).toHaveBeenCalledWith(hostEditor); + expect(footnoteCommit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledWith(bodyStory); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index dbb27f9c54..acd5b7d278 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -2,9 +2,13 @@ * Track-changes convenience wrappers — bridge track-change operations to * the plan engine's revision management and execution path. * - * Read operations (list, get) are pure queries. - * Mutating operations (accept, reject, acceptAll, rejectAll) delegate to - * editor commands with plan-engine revision tracking. + * Discovery (list / get) is a thin passthrough over the host-level + * {@link getTrackedChangeIndex} service, so there is a single owner for + * tracked-change enumeration across every revision-capable story. + * + * Mutating operations (accept, reject, acceptAll, rejectAll) route through + * the story runtime resolver so that non-body tracked changes execute in + * the owning story editor and commit back through `mutatePart(...)`. */ import type { Editor } from '../../core/Editor.js'; @@ -21,19 +25,19 @@ import type { TrackChangesRejectInput, TrackChangeType, TrackChangesListResult, + StoryLocator, } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; -import { requireEditorCommand } from '../helpers/mutation-helpers.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; -import { getRevision } from './revision-tracker.js'; -import { - groupTrackedChanges, - resolveTrackedChange, - resolveTrackedChangeType, - type GroupedTrackedChange, -} from '../helpers/tracked-change-resolver.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; +import { resolveTrackedChangeInStory, resolveTrackedChangeType } from '../helpers/tracked-change-resolver.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; +import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; +import { makeTrackedChangeAnchorKey } from '../helpers/tracked-change-runtime-ref.js'; import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; function normalizeWordRevisionIds( @@ -49,39 +53,26 @@ function normalizeWordRevisionIds( return Object.keys(normalized).length > 0 ? normalized : undefined; } -function buildTrackChangeInfo(editor: Editor, change: GroupedTrackedChange): TrackChangeInfo { - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); - const type = resolveTrackedChangeType(change); - +function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { return { - address: { - kind: 'entity', - entityType: 'trackedChange', - entityId: change.id, - }, - id: change.id, - type, - wordRevisionIds: normalizeWordRevisionIds(change.wordRevisionIds), - author: toNonEmptyString(change.attrs.author), - authorEmail: toNonEmptyString(change.attrs.authorEmail), - authorImage: toNonEmptyString(change.attrs.authorImage), - date: toNonEmptyString(change.attrs.date), - excerpt, + address: snapshot.address, + id: snapshot.address.entityId, + type: snapshot.type, + wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + author: snapshot.author, + authorEmail: snapshot.authorEmail, + authorImage: snapshot.authorImage, + date: snapshot.date, + excerpt: snapshot.excerpt, }; } -function filterByType(changes: GroupedTrackedChange[], requestedType?: TrackChangeType): GroupedTrackedChange[] { - if (!requestedType) return changes; - return changes.filter((change) => resolveTrackedChangeType(change) === requestedType); -} - -function requireTrackChangeById(editor: Editor, id: string): GroupedTrackedChange { - const change = resolveTrackedChange(editor, id); - if (change) return change; - - throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { - id, - }); +function filterByType( + snapshots: ReadonlyArray, + requestedType?: TrackChangeType, +): TrackedChangeSnapshot[] { + if (!requestedType) return [...snapshots]; + return snapshots.filter((snapshot) => snapshot.type === requestedType); } function toNoOpReceipt(message: string, details?: unknown): Receipt { @@ -95,20 +86,37 @@ function toNoOpReceipt(message: string, details?: unknown): Receipt { }; } -// --------------------------------------------------------------------------- -// Read operations (queries) -// --------------------------------------------------------------------------- +function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { + if (!input || input.in === undefined) return 'body'; + if (input.in === 'all') return 'all'; + return { story: input.in }; +} export function trackChangesListWrapper(editor: Editor, input?: TrackChangesListInput): TrackChangesListResult { - const query = input; - validatePaginationInput(query?.offset, query?.limit); - const grouped = filterByType(groupTrackedChanges(editor), query?.type); - const paged = paginate(grouped, query?.offset, query?.limit); + validatePaginationInput(input?.offset, input?.limit); + + const index = getTrackedChangeIndex(editor); + const scope = resolveListScope(input); + + let rawSnapshots: ReadonlyArray; + if (scope === 'all') { + rawSnapshots = index.getAll(); + } else if (scope === 'body') { + rawSnapshots = index.get({ kind: 'story', storyType: 'body' }); + } else { + rawSnapshots = index.get(scope.story); + } + + const filtered = filterByType(rawSnapshots, input?.type); + const paged = paginate(filtered, input?.offset, input?.limit); + // Track-changes discovery uses a document-level revision token across every + // scope. Part commits also advance the host revision, so one shared token + // correctly guards body, story-scoped, and aggregate review flows. const evaluatedRevision = getRevision(editor); - const items = paged.items.map((change) => { - const info = buildTrackChangeInfo(editor, change); - const handle = buildResolvedHandle(`tc:${info.id}`, 'stable', 'trackedChange'); + const items = paged.items.map((snapshot) => { + const info = snapshotToInfo(snapshot); + const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); const { address, type, wordRevisionIds, author, authorEmail, authorImage, date, excerpt } = info; return buildDiscoveryItem(info.id, handle, { address, @@ -126,54 +134,173 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList evaluatedRevision, total: paged.total, items, - page: { limit: query?.limit ?? paged.total, offset: query?.offset ?? 0, returned: items.length }, + page: { limit: input?.limit ?? paged.total, offset: input?.offset ?? 0, returned: items.length }, }); } export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInput): TrackChangeInfo { - const { id } = input; - return buildTrackChangeInfo(editor, requireTrackChangeById(editor, id)); + const { id, story } = input; + const resolved = resolveTrackedChangeInStory(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: id, + ...(story ? { story } : {}), + }); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { id }); + } + + const index = getTrackedChangeIndex(editor); + const storyKey = buildStoryKey(resolved.story); + const anchorKey = makeTrackedChangeAnchorKey(resolved.runtimeRef); + const snapshots = + storyKey === BODY_STORY_KEY ? index.get({ kind: 'story', storyType: 'body' }) : index.get(resolved.story); + const snapshot = snapshots.find((item) => item.anchorKey === anchorKey); + + if (snapshot) return snapshotToInfo(snapshot); + + return { + address: { + kind: 'entity', + entityType: 'trackedChange', + entityId: resolved.change.id, + ...(storyKey === BODY_STORY_KEY ? {} : { story: resolved.story }), + }, + id: resolved.change.id, + type: resolveTrackedChangeType(resolved.change), + wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), + author: toNonEmptyString(resolved.change.attrs.author), + authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), + authorImage: toNonEmptyString(resolved.change.attrs.authorImage), + date: toNonEmptyString(resolved.change.attrs.date), + excerpt: normalizeExcerpt( + resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc'), + ), + }; } -// --------------------------------------------------------------------------- -// Mutating operations (wrappers) -// --------------------------------------------------------------------------- +type ReviewDecision = 'accept' | 'reject'; -export function trackChangesAcceptWrapper( - editor: Editor, - input: TrackChangesAcceptInput, - options?: RevisionGuardOptions, +function decideSingle( + hostEditor: Editor, + decision: ReviewDecision, + id: string, + story: StoryLocator | undefined, + options: RevisionGuardOptions | undefined, ): Receipt { - const { id } = input; - const change = requireTrackChangeById(editor, id); - const acceptById = requireEditorCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); - - const receipt = executeDomainCommand(editor, () => Boolean(acceptById(change.rawId)), { - expectedRevision: options?.expectedRevision, + const resolved = resolveTrackedChangeInStory(hostEditor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: id, + ...(story ? { story } : {}), }); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { id, story }); + } + + const commandName = decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById'; + const command = (resolved.editor.commands as Record boolean) | undefined>)[commandName]; + if (typeof command !== 'function') { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `${decision === 'accept' ? 'Accept' : 'Reject'} tracked change command is not available on the story editor.`, + { reason: 'missing_command' }, + ); + } + + checkRevision(hostEditor, options?.expectedRevision); + + const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(resolved.change.rawId))); + if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`Accept tracked change "${id}" produced no change.`, { id }); + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, { + id, + story, + }); + } + + if (resolved.commit) { + resolved.commit(hostEditor); } + getTrackedChangeIndex(hostEditor).invalidate(resolved.story); + return { success: true }; } +export function trackChangesAcceptWrapper( + editor: Editor, + input: TrackChangesAcceptInput, + options?: RevisionGuardOptions, +): Receipt { + return decideSingle(editor, 'accept', input.id, input.story, options); +} + export function trackChangesRejectWrapper( editor: Editor, input: TrackChangesRejectInput, options?: RevisionGuardOptions, ): Receipt { - const { id } = input; - const change = requireTrackChangeById(editor, id); - const rejectById = requireEditorCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); + return decideSingle(editor, 'reject', input.id, input.story, options); +} - const receipt = executeDomainCommand(editor, () => Boolean(rejectById(change.rawId)), { - expectedRevision: options?.expectedRevision, - }); +function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGuardOptions | undefined): Receipt { + const index = getTrackedChangeIndex(editor); + const allSnapshots = index.getAll(); + if (allSnapshots.length === 0) { + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); + } - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`Reject tracked change "${id}" produced no change.`, { id }); + checkRevision(editor, options?.expectedRevision); + + const byStoryKey = new Map(); + for (const snapshot of allSnapshots) { + const key = snapshot.runtimeRef.storyKey; + const entry = byStoryKey.get(key); + if (entry) { + entry.snapshots.push(snapshot); + continue; + } + byStoryKey.set(key, { story: snapshot.story, snapshots: [snapshot] }); + } + + let anyApplied = false; + + for (const { story, snapshots } of byStoryKey.values()) { + const runtime = resolveStoryRuntime(editor, story); + const commandName = decision === 'accept' ? 'acceptAllTrackedChanges' : 'rejectAllTrackedChanges'; + const bulkCommand = (runtime.editor.commands as Record boolean) | undefined>)[commandName]; + + const receipt = executeDomainCommand(runtime.editor, (): boolean => { + if (typeof bulkCommand === 'function') return Boolean(bulkCommand()); + + const perChangeCommand = (runtime.editor.commands as Record boolean) | undefined>)[ + decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById' + ]; + if (typeof perChangeCommand !== 'function') return false; + + let applied = false; + for (const snapshot of snapshots) { + if (perChangeCommand(snapshot.runtimeRef.rawId)) { + applied = true; + } + } + return applied; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) continue; + + anyApplied = true; + if (runtime.commit) { + runtime.commit(editor); + } + index.invalidate(story); + } + + if (!anyApplied) { + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); } return { success: true }; @@ -184,21 +311,7 @@ export function trackChangesAcceptAllWrapper( _input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions, ): Receipt { - const acceptAll = requireEditorCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); - - if (groupTrackedChanges(editor).length === 0) { - return toNoOpReceipt('Accept all tracked changes produced no change.'); - } - - const receipt = executeDomainCommand(editor, () => Boolean(acceptAll()), { - expectedRevision: options?.expectedRevision, - }); - - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt('Accept all tracked changes produced no change.'); - } - - return { success: true }; + return decideAll(editor, 'accept', options); } export function trackChangesRejectAllWrapper( @@ -206,19 +319,5 @@ export function trackChangesRejectAllWrapper( _input: TrackChangesRejectAllInput, options?: RevisionGuardOptions, ): Receipt { - const rejectAll = requireEditorCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); - - if (groupTrackedChanges(editor).length === 0) { - return toNoOpReceipt('Reject all tracked changes produced no change.'); - } - - const receipt = executeDomainCommand(editor, () => Boolean(rejectAll()), { - expectedRevision: options?.expectedRevision, - }); - - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt('Reject all tracked changes produced no change.'); - } - - return { success: true }; + return decideAll(editor, 'reject', options); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts index f3ebd4cfa1..953d86e870 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts @@ -157,6 +157,8 @@ export function resolveHeaderFooterSlotRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, isolatedEditor, { commit: buildSlotCommit(locator, isolatedEditor, effectiveRefId, true), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, true)(hostEditor), }); } @@ -170,6 +172,8 @@ export function resolveHeaderFooterSlotRuntime( editor: liveEditor, kind: 'headerFooter', commit: buildSlotCommit(locator, liveEditor, effectiveRefId, false), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, false)(hostEditor), }; } @@ -179,6 +183,8 @@ export function resolveHeaderFooterSlotRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, storyEditor, { commit: buildSlotCommit(locator, storyEditor, effectiveRefId, false), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, false)(hostEditor), }); } @@ -293,6 +299,9 @@ export function resolveHeaderFooterPartRuntime( commit: (hostEditor: Editor) => { exportAndSyncCache(hostEditor, liveEditor, locator.refId, hfType); }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + exportAndSyncCache(hostEditor, sessionEditor, locator.refId, hfType); + }, }; } @@ -302,6 +311,9 @@ export function resolveHeaderFooterPartRuntime( commit: (hostEditor: Editor) => { exportAndSyncCache(hostEditor, storyEditor, locator.refId, hfType); }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + exportAndSyncCache(hostEditor, sessionEditor, locator.refId, hfType); + }, }); } @@ -314,10 +326,8 @@ export function resolveHeaderFooterPartRuntime( * converter's PM JSON cache. * * The OOXML write goes through `exportSubEditorToPart` → `mutatePart`. - * The PM cache update is needed because the part descriptor's afterCommit - * hook skips re-import for `SOURCE_HEADER_FOOTER_LOCAL` (it assumes the - * UI blur path already refreshed the cache). The headless document-api - * path bypasses that handler, so we must update the cache explicitly. + * The PM cache update keeps the converter's header/footer collections in sync + * immediately for the in-memory runtime that performed the export. */ function exportAndSyncCache(hostEditor: Editor, subEditor: Editor, refId: string, hfType: 'header' | 'footer'): void { exportSubEditorToPart(hostEditor, subEditor, refId, hfType); @@ -364,6 +374,7 @@ function createOwnedHeaderFooterRuntime( editor: Editor, options: { commit: (hostEditor: Editor) => void; + commitEditor?: (hostEditor: Editor, storyEditor: Editor) => void; }, ): StoryRuntime { return { @@ -374,6 +385,7 @@ function createOwnedHeaderFooterRuntime( cacheable: false, dispose: () => editor.destroy(), commit: options.commit, + commitEditor: options.commitEditor, }; } @@ -408,6 +420,8 @@ function createMissingSlotWriteRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, pendingEditor, { commit: buildSlotCommit(locator, pendingEditor, null, true), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, null, true)(hostEditor), }); } 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 aa9f630cbc..3e9a55fba4 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 @@ -29,6 +29,11 @@ export { // Runtime cache export { StoryRuntimeCache } from './runtime-cache.js'; +export { + registerLiveStorySessionRuntime, + resolveLiveStorySessionRuntime, + unregisterLiveStorySessionRuntime, +} from './live-story-session-runtime-registry.js'; // Resolution export { resolveStoryRuntime, getStoryRuntimeCache } from './resolve-story-runtime.js'; 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 new file mode 100644 index 0000000000..cfada76ea9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts @@ -0,0 +1,101 @@ +import type { Editor } from '../../core/Editor.js'; +import type { StoryRuntime } from './story-types.js'; + +/** + * A registered interactive story session. + * + * While a story session is active, tracked-change resolution and other + * document-api calls must target the session editor the user is typing in, + * not an older cached runtime editor. + */ +interface LiveStorySessionRegistration { + storyKey: string; + editor: Editor; + runtime: StoryRuntime; +} + +const liveSessionsByHost = new WeakMap>(); + +function getOrCreateLiveSessionMap(hostEditor: Editor): Map { + let sessions = liveSessionsByHost.get(hostEditor); + if (!sessions) { + sessions = new Map(); + liveSessionsByHost.set(hostEditor, sessions); + } + return sessions; +} + +function buildLiveSessionRuntime(registration: LiveStorySessionRegistration): StoryRuntime { + const { runtime, editor } = registration; + + return { + ...runtime, + editor, + cacheable: false, + commit: + runtime.commitEditor == null + ? runtime.commit + : (hostEditor: Editor) => { + runtime.commitEditor?.(hostEditor, editor); + }, + }; +} + +/** + * Register the currently active editor for a story session. + * + * Returns a cleanup callback that only unregisters the session if the same + * editor is still registered for that story key. + */ +export function registerLiveStorySessionRuntime(hostEditor: Editor, runtime: StoryRuntime, editor: Editor): () => void { + const sessions = getOrCreateLiveSessionMap(hostEditor); + const storyKey = runtime.storyKey; + + sessions.set(storyKey, { + storyKey, + editor, + runtime, + }); + + return () => { + unregisterLiveStorySessionRuntime(hostEditor, storyKey, editor); + }; +} + +/** + * Resolve the interactive runtime for a story session, if one is active. + */ +export function resolveLiveStorySessionRuntime(hostEditor: Editor, storyKey: string): StoryRuntime | null { + const registration = liveSessionsByHost.get(hostEditor)?.get(storyKey); + if (!registration) return null; + return buildLiveSessionRuntime(registration); +} + +/** + * Remove a registered interactive runtime. + * + * When `editor` is provided, the registration is removed only if it still + * points to that editor. This prevents a stale disposer from clearing a + * newer activation for the same story. + */ +export function unregisterLiveStorySessionRuntime(hostEditor: Editor, storyKey: string, editor?: Editor): void { + const sessions = liveSessionsByHost.get(hostEditor); + if (!sessions) return; + + const registration = sessions.get(storyKey); + if (!registration) return; + if (editor && registration.editor !== editor) return; + + sessions.delete(storyKey); + + if (sessions.size === 0) { + liveSessionsByHost.delete(hostEditor); + } +} + +/** + * Visible for tests. + */ +export function getLiveStorySessionCount(hostEditor: Editor): number { + return liveSessionsByHost.get(hostEditor)?.size ?? 0; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts index 9f86c83fa1..ece7557c3b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts @@ -95,6 +95,140 @@ describe('resolveNoteRuntime — empty note content', () => { ); }); + it('normalizes empty footnote reference runs out of the editable note story', () => { + const hostEditor = makeHostEditor([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + + it('normalizes note separator tabs out of the editable footnote story', () => { + const hostEditor = makeHostEditor([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + + it('strips hidden passthrough field-code nodes out of the editable note story', () => { + const hostEditor = makeHostEditor([ + { + 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)' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + it('resolves an endnote with content: [] as a valid empty story', () => { const hostEditor = makeHostEditor([], [{ id: '1', content: [] }]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index cdbfab8470..488797a3ce 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -19,6 +19,7 @@ import { ensureFootnoteRefRun, updateNoteElement, } from '../../core/parts/adapters/notes-part-descriptor.js'; +import { normalizeNotePmJson } from '../helpers/note-pm-json.js'; type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; @@ -88,63 +89,75 @@ export function resolveNoteRuntime(hostEditor: Editor, locator: NoteStoryLocator kind: 'note', dispose: () => storyEditor.destroy(), commit: (hostEditor: Editor) => { - const noteType = isFootnote ? 'footnote' : 'endnote'; - const notesConfig = getNotesConfig(noteType); - - // Try rich export via converter's exportToXmlJson (preserves formatting) - const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; - const pmJson = - typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); - - if (conv?.exportToXmlJson && pmJson) { - let ooxmlElements: unknown[] | null = null; - try { - const { result } = conv.exportToXmlJson({ - data: pmJson, - editor: storyEditor, - editorSchema: storyEditor.schema, - isHeaderFooter: true, - comments: [], - commentDefinitions: [], - }); - // result.elements[0] is the body wrapper; its children are all - // content elements (paragraphs, tables, etc.). Keep all of them - // so tables and other non-paragraph content survive the commit. - const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; - ooxmlElements = body?.elements ?? null; - } catch { - // Fall through to plain-text fallback - } - - if (ooxmlElements && ooxmlElements.length > 0) { - mutatePart({ - editor: hostEditor, - partId: notesConfig.partId, - operation: 'mutate', - source: `story-runtime:commit:${locator.storyType}`, - mutate({ part }) { - updateNoteContentFromOoxml(part, notesConfig, noteId, ooxmlElements!); - }, - }); - return; - } - } - - // Fallback: plain-text export (loses formatting) - const doc = storyEditor.state.doc; - const text = doc.textBetween(0, doc.content.size, '\n', '\n'); + commitNoteRuntime(hostEditor, storyEditor, locator, isFootnote); + }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + commitNoteRuntime(hostEditor, sessionEditor, locator, isFootnote); + }, + }; +} +function commitNoteRuntime( + hostEditor: Editor, + storyEditor: Editor, + locator: NoteStoryLocator, + isFootnote: boolean, +): void { + const noteType = isFootnote ? 'footnote' : 'endnote'; + const notesConfig = getNotesConfig(noteType); + + // Try rich export via converter's exportToXmlJson (preserves formatting) + const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; + const pmJson = + typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); + + if (conv?.exportToXmlJson && pmJson) { + let ooxmlElements: unknown[] | null = null; + try { + const { result } = conv.exportToXmlJson({ + data: pmJson, + editor: storyEditor, + editorSchema: storyEditor.schema, + isHeaderFooter: true, + comments: [], + commentDefinitions: [], + }); + // result.elements[0] is the body wrapper; its children are all + // content elements (paragraphs, tables, etc.). Keep all of them + // so tables and other non-paragraph content survive the commit. + const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; + ooxmlElements = body?.elements ?? null; + } catch { + // Fall through to plain-text fallback + } + + if (ooxmlElements && ooxmlElements.length > 0) { mutatePart({ editor: hostEditor, partId: notesConfig.partId, operation: 'mutate', source: `story-runtime:commit:${locator.storyType}`, mutate({ part }) { - updateNoteElement(part, notesConfig, noteId, text); + updateNoteContentFromOoxml(part, notesConfig, locator.noteId, ooxmlElements!); }, }); + return; + } + } + + // Fallback: plain-text export (loses formatting) + const doc = storyEditor.state.doc; + const text = doc.textBetween(0, doc.content.size, '\n', '\n'); + + mutatePart({ + editor: hostEditor, + partId: notesConfig.partId, + operation: 'mutate', + source: `story-runtime:commit:${locator.storyType}`, + mutate({ part }) { + updateNoteElement(part, notesConfig, locator.noteId, text); }, - }; + }); } // --------------------------------------------------------------------------- @@ -172,20 +185,20 @@ function extractNotePmJson(converter: any, isFootnote: boolean, noteId: string): // Empty arrays represent blank notes (e.g., after the reference marker is stripped) // and are valid — they produce a minimal doc with an empty paragraph. if (Array.isArray(note.content)) { - return { + return normalizeNotePmJson({ type: 'doc', content: note.content.length > 0 ? note.content : [{ type: 'paragraph' }], - }; + }); } // If the note has a `doc` field (pre-built PM JSON), return it directly if (note.doc && typeof note.doc === 'object') { - return note.doc; + return normalizeNotePmJson(note.doc); } // If the note itself looks like PM JSON (has a `type` field) if (note.type === 'doc' || note.type === 'footnoteBody' || note.type === 'endnoteBody') { - return note; + return normalizeNotePmJson(note); } return null; 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 de7b3fef2a..0180186a06 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 @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ if (locator.storyType === 'footnote') return `fn:${locator.noteId}`; if (locator.storyType === 'endnote') return `en:${locator.noteId}`; if (locator.storyType === 'body') return 'body'; + if (locator.storyType === 'headerFooterPart') return `hf:part:${locator.refId}`; return `unknown:${JSON.stringify(locator)}`; }), resolveNoteRuntime: vi.fn(), @@ -65,6 +66,10 @@ vi.mock('./story-revision-store.js', () => ({ })); import { resolveStoryRuntime, invalidateStoryRuntime } from './resolve-story-runtime.js'; +import { + registerLiveStorySessionRuntime, + unregisterLiveStorySessionRuntime, +} from './live-story-session-runtime-registry.js'; // --------------------------------------------------------------------------- // Helpers @@ -116,6 +121,7 @@ beforeEach(() => { if (locator.storyType === 'footnote') return `fn:${locator.noteId}`; if (locator.storyType === 'endnote') return `en:${locator.noteId}`; if (locator.storyType === 'body') return 'body'; + if (locator.storyType === 'headerFooterPart') return `hf:part:${locator.refId}`; return `unknown:${JSON.stringify(locator)}`; }); mocks.isHeaderFooterPartId.mockImplementation((partId: string) => /^word\/(header|footer)\d+\.xml$/.test(partId)); @@ -358,3 +364,76 @@ describe('invalidateStoryRuntime', () => { expect(result).toBe(false); }); }); + +describe('resolveStoryRuntime — active story sessions', () => { + it('prefers the active session editor over the cached runtime editor', () => { + const hostEditor = makeHostEditor(); + const cachedRuntime = { + locator: { kind: 'story', storyType: 'footnote', noteId: '3' }, + storyKey: 'fn:3', + editor: { id: 'cached-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any, + kind: 'note' as const, + commitEditor: vi.fn(), + }; + const activeSessionEditor = { id: 'session-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + + mocks.resolveNoteRuntime.mockReturnValue(cachedRuntime); + + const cached = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + expect(cached.editor).toBe(cachedRuntime.editor); + + const unregister = registerLiveStorySessionRuntime(hostEditor, cachedRuntime, activeSessionEditor); + + const live = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + + expect(live.editor).toBe(activeSessionEditor); + live.commit?.(hostEditor); + expect(cachedRuntime.commitEditor).toHaveBeenCalledWith(hostEditor, activeSessionEditor); + + unregister(); + + const afterExit = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + + expect(afterExit).toBe(cached); + }); + + it('ignores stale unregister callbacks when a newer session replaces the same story', () => { + const hostEditor = makeHostEditor(); + const runtime = { + locator: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId11' }, + storyKey: 'hf:part:rId11', + editor: { id: 'cached-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any, + kind: 'headerFooter' as const, + commitEditor: vi.fn(), + }; + const firstSessionEditor = { id: 'session-1', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + const secondSessionEditor = { id: 'session-2', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + + const unregisterFirst = registerLiveStorySessionRuntime(hostEditor, runtime, firstSessionEditor); + registerLiveStorySessionRuntime(hostEditor, runtime, secondSessionEditor); + + unregisterFirst(); + + const live = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId11', + } as any); + + expect(live.editor).toBe(secondSessionEditor); + + unregisterLiveStorySessionRuntime(hostEditor, 'hf:part:rId11', secondSessionEditor); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts index 981d1885a7..b368782ea4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts @@ -28,6 +28,7 @@ import { resolveNoteRuntime } from './note-story-runtime.js'; import { isHeaderFooterPartId } from '../../core/parts/adapters/header-footer-part-descriptor.js'; import { initRevision, trackRevisions, restoreRevision } from '../plan-engine/revision-tracker.js'; import { getStoryRevisionStore, getStoryRevision, incrementStoryRevision } from './story-revision-store.js'; +import { resolveLiveStorySessionRuntime } from './live-story-session-runtime-registry.js'; // --------------------------------------------------------------------------- // Cache — one per host editor, attached via WeakMap @@ -191,6 +192,10 @@ export function resolveStoryRuntime( // Non-body stories — validate key and dispatch // ----------------------------------------------------------------------- const storyKey = buildStoryKey(locator); + const liveSessionRuntime = resolveLiveStorySessionRuntime(hostEditor, storyKey); + if (liveSessionRuntime) { + return liveSessionRuntime; + } // Check the cache first. const cache = getOrCreateCache(hostEditor); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts index e2f1276841..f115c2a03c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts @@ -67,4 +67,18 @@ export interface StoryRuntime { * @param hostEditor - The host (body) editor, needed for parts runtime access. */ commit?: (hostEditor: Editor) => void; + + /** + * Persists the provided editor state back to the canonical OOXML part. + * + * This is the session-aware variant of {@link commit}. It is used when the + * interactive editing session mounts a different editor instance than the + * runtime originally resolved (for example, a hidden-host editor created from + * a cached headless runtime). When omitted, callers may fall back to + * {@link commit}, which commits the runtime's own editor. + * + * @param hostEditor - The host (body) editor. + * @param storyEditor - The editor whose state should be exported. + */ + commitEditor?: (hostEditor: Editor, storyEditor: Editor) => void; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts new file mode 100644 index 0000000000..56e23a3d40 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for the host-level TrackedChangeIndex service. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../../core/Editor.js'; + +const mocks = vi.hoisted(() => ({ + resolveStoryRuntime: vi.fn(), + groupTrackedChanges: vi.fn(), + enumerateRevisionCapableStories: vi.fn(), + isHeaderFooterPartId: vi.fn(() => false), + resolveRIdFromRelsData: vi.fn(() => null), +})); + +vi.mock('../../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: mocks.resolveStoryRuntime, +})); + +vi.mock('../../helpers/tracked-change-resolver.js', async (importOriginal) => { + const original = await importOriginal>(); + return { + ...original, + groupTrackedChanges: mocks.groupTrackedChanges, + }; +}); + +vi.mock('../enumerate-stories.js', () => ({ + enumerateRevisionCapableStories: mocks.enumerateRevisionCapableStories, +})); + +vi.mock('../../../core/parts/adapters/header-footer-part-descriptor.js', () => ({ + isHeaderFooterPartId: mocks.isHeaderFooterPartId, +})); + +vi.mock('../../../core/parts/adapters/header-footer-sync.js', () => ({ + resolveRIdFromRelsData: mocks.resolveRIdFromRelsData, +})); + +import { getTrackedChangeIndex } from '../tracked-change-index.js'; + +type EventHandler = (...args: unknown[]) => void; + +interface FakeEditor extends Editor { + _emit: (event: string, payload?: unknown) => void; +} + +function makeEditor(): FakeEditor { + const listeners = new Map(); + return { + state: { doc: { textBetween: () => '' } }, + commands: {}, + on(event: string, handler: EventHandler) { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event)?.push(handler); + }, + off(event: string, handler: EventHandler) { + const list = listeners.get(event); + if (!list) return; + const index = list.indexOf(handler); + if (index >= 0) list.splice(index, 1); + }, + emit: vi.fn(), + _emit(event: string, payload?: unknown) { + for (const handler of listeners.get(event) ?? []) { + handler(payload); + } + }, + } as unknown as FakeEditor; +} + +function makeGroupedChange(rawId: string, from = 0, to = 5, overrides: Record = {}) { + return { + rawId, + id: `canon-${rawId}`, + from, + to, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: { author: 'Ada', date: '2026-01-01', ...overrides }, + wordRevisionIds: undefined, + }; +} + +function makeStoryRuntime(editor: Editor, locator: { storyType: string; [k: string]: unknown }, storyKey: string) { + return { + locator: { kind: 'story', ...locator } as any, + storyKey, + editor, + kind: + locator.storyType === 'body' ? 'body' : locator.storyType.startsWith('headerFooter') ? 'headerFooter' : 'note', + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.enumerateRevisionCapableStories.mockReturnValue([{ kind: 'story', storyType: 'body' }]); + mocks.groupTrackedChanges.mockReturnValue([]); + mocks.resolveStoryRuntime.mockImplementation((host: Editor, locator: any) => { + if (!locator || locator.storyType === 'body') { + return makeStoryRuntime(host, { storyType: 'body' }, 'body'); + } + if (locator.storyType === 'footnote') { + return makeStoryRuntime(makeEditor(), locator, `fn:${locator.noteId}`); + } + if (locator.storyType === 'endnote') { + return makeStoryRuntime(makeEditor(), locator, `en:${locator.noteId}`); + } + if (locator.storyType === 'headerFooterPart') { + return makeStoryRuntime(makeEditor(), locator, `hf:part:${locator.refId}`); + } + throw new Error(`Unexpected locator: ${JSON.stringify(locator)}`); + }); +}); + +describe('TrackedChangeIndex — per-story cache', () => { + it('returns body-only snapshots when no non-body stories exist', () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValueOnce([makeGroupedChange('rev-1')]); + + const index = getTrackedChangeIndex(editor); + const snapshots = index.get({ kind: 'story', storyType: 'body' }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.anchorKey).toBe('tc::body::rev-1'); + expect(snapshots[0]?.storyKind).toBe('body'); + expect(snapshots[0]?.address).toEqual({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canon-rev-1', + }); + }); + + it('returns story-scoped anchor keys for footnote stories', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '5' }, + ]); + mocks.groupTrackedChanges.mockReturnValueOnce([]).mockReturnValueOnce([makeGroupedChange('rev-7')]); + + const index = getTrackedChangeIndex(editor); + const all = index.getAll(); + + expect(all).toHaveLength(1); + expect(all[0]?.anchorKey).toBe('tc::fn:5::rev-7'); + expect(all[0]?.storyLabel).toBe('Footnote 5'); + expect(all[0]?.address).toEqual({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canon-rev-7', + story: { kind: 'story', storyType: 'footnote', noteId: '5' }, + }); + }); + + it('produces distinct snapshots when body and non-body share a rawId', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + ]); + mocks.groupTrackedChanges + .mockReturnValueOnce([makeGroupedChange('shared')]) + .mockReturnValueOnce([makeGroupedChange('shared')]); + + const index = getTrackedChangeIndex(editor); + const all = index.getAll(); + + expect(all).toHaveLength(2); + const keys = all.map((snapshot) => snapshot.anchorKey); + expect(keys).toContain('tc::body::shared'); + expect(keys).toContain('tc::fn:1::shared'); + }); +}); + +describe('TrackedChangeIndex — invalidation', () => { + it('body edits only dirty the body cache', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + ]); + mocks.groupTrackedChanges.mockReturnValueOnce([]).mockReturnValueOnce([makeGroupedChange('fn-1')]); + + const index = getTrackedChangeIndex(editor); + index.getAll(); + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(2); + + editor._emit('transaction', { transaction: { docChanged: true } }); + + mocks.groupTrackedChanges + .mockReturnValueOnce([makeGroupedChange('body-1')]) + .mockReturnValue([makeGroupedChange('fn-1')]); + + index.getAll(); + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(3); + }); + + it('invalidateAll wipes every cache', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([{ kind: 'story', storyType: 'body' }]); + mocks.groupTrackedChanges.mockReturnValue([makeGroupedChange('x')]); + + const index = getTrackedChangeIndex(editor); + index.getAll(); + index.invalidateAll(); + index.getAll(); + + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(2); + }); +}); + +describe('TrackedChangeIndex — broadcast', () => { + it('emits a coalesced tracked-changes-changed event after invalidation', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + + index.invalidate({ kind: 'story', storyType: 'body' }); + index.invalidate({ kind: 'story', storyType: 'body' }); + index.invalidate({ kind: 'story', storyType: 'body' }); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ editor, source: 'invalidate' }), + ); + }); + + it('unions different stories invalidated in the same tick', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '7' } as const; + + index.invalidate(bodyStory); + index.invalidate(footnoteStory); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: 'invalidate', + stories: expect.arrayContaining([bodyStory, footnoteStory]), + }), + ); + }); + + it('drops the coalesced source when the same tick mixes body and non-body invalidations', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '7' } as const; + + editor._emit('transaction', { transaction: { docChanged: true } }); + index.invalidate(footnoteStory); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: undefined, + stories: expect.arrayContaining([{ kind: 'story', storyType: 'body' }, footnoteStory]), + }), + ); + }); + + it('notifies subscribers with the aggregated snapshot list', async () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValue([makeGroupedChange('r1')]); + + const index = getTrackedChangeIndex(editor); + const listener = vi.fn(); + const unsubscribe = index.subscribe(listener); + + index.invalidate({ kind: 'story', storyType: 'body' }); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledTimes(1); + const snapshot = listener.mock.calls[0][0]; + expect(snapshot).toHaveLength(1); + expect(snapshot[0].anchorKey).toBe('tc::body::r1'); + + unsubscribe(); + index.invalidate({ kind: 'story', storyType: 'body' }); + await Promise.resolve(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts new file mode 100644 index 0000000000..3c24cfddb3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { enumerateRevisionCapableStories } from './enumerate-stories.js'; + +function makeEditor(converter?: Record): Editor { + return { converter } as unknown as Editor; +} + +describe('enumerateRevisionCapableStories', () => { + it('returns only the body when the editor has no converter', () => { + expect(enumerateRevisionCapableStories(makeEditor())).toEqual([{ kind: 'story', storyType: 'body' }]); + }); + + it('includes headers and footers as part refs in converter-order', () => { + const editor = makeEditor({ + headers: { rId1: {}, rId2: {} }, + footers: { rId5: {} }, + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId2' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId5' }, + ]); + }); + + it('includes revision-capable footnotes and endnotes', () => { + const editor = makeEditor({ + footnotes: [{ id: 1 }, { id: '7' }], + endnotes: [{ id: 2 }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + { kind: 'story', storyType: 'footnote', noteId: '7' }, + { kind: 'story', storyType: 'endnote', noteId: '2' }, + ]); + }); + + it('skips notes with negative ids (separator / continuationSeparator)', () => { + const editor = makeEditor({ + footnotes: [{ id: -1 }, { id: 0 }, { id: 3 }], + endnotes: [{ id: '-2' }, { id: '4' }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '0' }, + { kind: 'story', storyType: 'footnote', noteId: '3' }, + { kind: 'story', storyType: 'endnote', noteId: '4' }, + ]); + }); + + it('skips notes missing an id rather than emitting "undefined" locators', () => { + const editor = makeEditor({ + footnotes: [{ id: undefined } as unknown as { id: string }, { id: 1 }], + endnotes: [null as unknown as { id: string }, { id: 2 }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + { kind: 'story', storyType: 'endnote', noteId: '2' }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts new file mode 100644 index 0000000000..0bfdff7577 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts @@ -0,0 +1,88 @@ +/** + * Enumerate every revision-capable story in a host document. + * + * Used by {@link TrackedChangeIndex} to drive cross-story tracked-change + * discovery when callers pass `in: 'all'` or need to build a single + * aggregated snapshot. + * + * The enumeration is purely a read over the converter's derived caches — + * it never resolves a story runtime. Runtime resolution is deferred to + * the index so we do not pay editor construction cost for stories that + * hold zero tracked changes. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; + +interface NoteEntry { + id: string | number; +} + +interface ConverterShape { + headers?: Record; + footers?: Record; + footnotes?: NoteEntry[]; + endnotes?: NoteEntry[]; +} + +function getConverter(editor: Editor): ConverterShape | undefined { + return (editor as unknown as { converter?: ConverterShape }).converter; +} + +/** + * Returns the note's revision-capable id as a string, or `null` when the note + * lacks an id or uses a special-purpose negative id (separator, + * continuationSeparator, etc.). + */ +function toRevisionCapableNoteId(note: NoteEntry | undefined | null): string | null { + if (!note || note.id === undefined || note.id === null) return null; + const numeric = Number(note.id); + if (Number.isFinite(numeric) && numeric < 0) return null; + const noteId = String(note.id); + return noteId.length > 0 ? noteId : null; +} + +/** + * Returns every revision-capable story locator for the given host editor. + * + * Body is always first; header/footer parts, footnotes, and endnotes follow + * in insertion-order. Header/footer slots are intentionally NOT enumerated — + * tracked-change identity always addresses the owning part, so slot + * enumeration would produce duplicates against parts. + */ +export function enumerateRevisionCapableStories(editor: Editor): StoryLocator[] { + const stories: StoryLocator[] = [{ kind: 'story', storyType: 'body' }]; + + const converter = getConverter(editor); + if (!converter) return stories; + + if (converter.headers) { + for (const refId of Object.keys(converter.headers)) { + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + } + + if (converter.footers) { + for (const refId of Object.keys(converter.footers)) { + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + } + + if (Array.isArray(converter.footnotes)) { + for (const note of converter.footnotes) { + const noteId = toRevisionCapableNoteId(note); + if (!noteId) continue; + stories.push({ kind: 'story', storyType: 'footnote', noteId }); + } + } + + if (Array.isArray(converter.endnotes)) { + for (const note of converter.endnotes) { + const noteId = toRevisionCapableNoteId(note); + if (!noteId) continue; + stories.push({ kind: 'story', storyType: 'endnote', noteId }); + } + } + + return stories; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts new file mode 100644 index 0000000000..7ad06bc5b6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import type { StoryLocator } from '@superdoc/document-api'; +import { classifyStoryKind, describeStoryLocation } from './story-labels.js'; + +describe('classifyStoryKind', () => { + it('classifies body stories', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'body' })).toBe('body'); + }); + + it('classifies header/footer slot stories as headerFooter', () => { + const locator: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: 's1' }, + headerFooterKind: 'header', + variant: 'default', + }; + expect(classifyStoryKind(locator)).toBe('headerFooter'); + }); + + it('classifies header/footer part stories as headerFooter', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' })).toBe('headerFooter'); + }); + + it('classifies footnote and endnote stories', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'footnote', noteId: '1' })).toBe('footnote'); + expect(classifyStoryKind({ kind: 'story', storyType: 'endnote', noteId: '2' })).toBe('endnote'); + }); +}); + +describe('describeStoryLocation', () => { + it('returns an empty string for body stories', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'body' })).toBe(''); + }); + + it('labels default header/footer slots with kind and section', () => { + const locator: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '3' }, + headerFooterKind: 'header', + variant: 'default', + }; + expect(describeStoryLocation(locator)).toBe('Header · Section 3'); + }); + + it('includes variant when header/footer slot is first or even', () => { + const first: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '1' }, + headerFooterKind: 'footer', + variant: 'first', + }; + expect(describeStoryLocation(first)).toBe('Footer · Section 1 · First page'); + + const even: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '2' }, + headerFooterKind: 'header', + variant: 'even', + }; + expect(describeStoryLocation(even)).toBe('Header · Section 2 · Even pages'); + }); + + it('labels header/footer parts with their refId', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' })).toBe( + 'Header/Footer · rId7', + ); + }); + + it('labels footnotes and endnotes with their noteId', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'footnote', noteId: '12' })).toBe('Footnote 12'); + expect(describeStoryLocation({ kind: 'story', storyType: 'endnote', noteId: '4' })).toBe('Endnote 4'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts new file mode 100644 index 0000000000..408d63b88d --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts @@ -0,0 +1,76 @@ +/** + * Human-readable story labels for sidebar cards and review UI. + * + * Produces strings like: + * - Body tracked changes → `""` (empty — sidebar renders no extra badge) + * - Headers / footers → `"Header"`, `"Footer"`, `"Header · Section 3"`, `"Footer · First page"` + * - Footnotes / endnotes → `"Footnote 12"`, `"Endnote 4"` + * + * Labels are strictly informational — they never drive behavior. Identity + * continues to flow through `storyKey` / `StoryLocator`. + */ + +import type { StoryLocator } from '@superdoc/document-api'; + +export type StoryKind = 'body' | 'headerFooter' | 'footnote' | 'endnote'; + +/** Coarse classifier for UI decisions (icons, labels, sort groups). */ +export function classifyStoryKind(locator: StoryLocator): StoryKind { + switch (locator.storyType) { + case 'body': + return 'body'; + case 'headerFooterSlot': + case 'headerFooterPart': + return 'headerFooter'; + case 'footnote': + return 'footnote'; + case 'endnote': + return 'endnote'; + } +} + +function capitalize(word: string): string { + if (!word) return word; + return word.charAt(0).toUpperCase() + word.slice(1); +} + +function variantLabel(variant: 'default' | 'first' | 'even'): string { + switch (variant) { + case 'first': + return 'First page'; + case 'even': + return 'Even pages'; + case 'default': + return 'Default'; + } +} + +/** + * Returns a human-readable label describing where the tracked change lives. + * + * Body tracked changes return an empty string so the sidebar can render + * them without an extra location badge. + */ +export function describeStoryLocation(locator: StoryLocator): string { + switch (locator.storyType) { + case 'body': + return ''; + + case 'headerFooterSlot': { + const kind = capitalize(locator.headerFooterKind); + const variant = variantLabel(locator.variant); + const section = locator.section.sectionId; + if (variant === 'Default') return `${kind} · Section ${section}`; + return `${kind} · Section ${section} · ${variant}`; + } + + case 'headerFooterPart': + return `Header/Footer · ${locator.refId}`; + + case 'footnote': + return `Footnote ${locator.noteId}`; + + case 'endnote': + return `Endnote ${locator.noteId}`; + } +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts new file mode 100644 index 0000000000..6d143f1640 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -0,0 +1,362 @@ +/** + * Host-level tracked-change index service. + * + * Owns every aspect of tracked-change discovery across revision-capable + * stories: + * + * - Discovery: walks body + headers + footers + footnotes + endnotes. + * - Caching: per-story snapshot array keyed by `storyKey`. + * - Invalidation: targeted — `mutatePart` commits only invalidate the one + * part they touched; body edits only refresh the body cache. + * - Broadcast: emits `tracked-changes-changed` on the host editor so + * comments-store, navigation, and review surfaces can resync. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { PartChangedEvent } from '../../core/parts/types.js'; +import { buildStoryKey, BODY_STORY_KEY, parseStoryKeyType } from '../story-runtime/story-key.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { + groupTrackedChanges, + resolveTrackedChangeType, + type GroupedTrackedChange, +} from '../helpers/tracked-change-resolver.js'; +import { makeTrackedChangeAnchorKey, type TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; +import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; +import { enumerateRevisionCapableStories } from './enumerate-stories.js'; +import { classifyStoryKind, describeStoryLocation } from './story-labels.js'; +import type { TrackedChangeSnapshot } from './tracked-change-snapshot.js'; +import { isHeaderFooterPartId } from '../../core/parts/adapters/header-footer-part-descriptor.js'; +import { resolveRIdFromRelsData } from '../../core/parts/adapters/header-footer-sync.js'; + +export type TrackedChangeIndexListener = (snapshots: ReadonlyArray) => void; + +export interface TrackedChangeIndex { + get(locator: StoryLocator): ReadonlyArray; + getAll(): ReadonlyArray; + invalidate(locator: StoryLocator): void; + invalidateAll(): void; + subscribe(listener: TrackedChangeIndexListener): () => void; + dispose(): void; +} + +const indexByHost = new WeakMap(); + +export function getTrackedChangeIndex(hostEditor: Editor): TrackedChangeIndex { + let index = indexByHost.get(hostEditor); + if (!index) { + index = new TrackedChangeIndexImpl(hostEditor); + indexByHost.set(hostEditor, index); + } + return index; +} + +function buildTrackedChangeAddress( + locator: StoryLocator, + storyKey: string, + canonicalId: string, +): TrackedChangeSnapshot['address'] { + return { + kind: 'entity', + entityType: 'trackedChange', + entityId: canonicalId, + ...(storyKey === BODY_STORY_KEY ? {} : { story: locator }), + }; +} + +class TrackedChangeIndexImpl implements TrackedChangeIndex { + readonly #hostEditor: Editor; + readonly #snapshots = new Map(); + #aggregated: TrackedChangeSnapshot[] | null = null; + readonly #dirtyStoryKeys = new Set(); + readonly #listeners = new Set(); + readonly #teardowns: Array<() => void> = []; + #broadcastScheduled = false; + #pendingBroadcastStories: StoryLocator[] | undefined | null = null; + #pendingBroadcastSource: string | undefined | null = null; + #bodyDirty = true; + + constructor(hostEditor: Editor) { + this.#hostEditor = hostEditor; + this.#attachHostListeners(); + } + + get(locator: StoryLocator): ReadonlyArray { + const storyKey = buildStoryKey(locator); + return this.#getByKey(storyKey, locator); + } + + getAll(): ReadonlyArray { + if (this.#aggregated && this.#dirtyStoryKeys.size === 0) { + return this.#aggregated; + } + + const stories = enumerateRevisionCapableStories(this.#hostEditor); + const flat: TrackedChangeSnapshot[] = []; + for (const story of stories) { + const storyKey = buildStoryKey(story); + const snapshots = this.#getByKey(storyKey, story); + flat.push(...snapshots); + } + + this.#aggregated = flat; + return flat; + } + + invalidate(locator: StoryLocator): void { + const storyKey = buildStoryKey(locator); + this.#invalidateKey(storyKey); + this.#scheduleBroadcast([locator], 'invalidate'); + } + + invalidateAll(): void { + this.#snapshots.clear(); + this.#dirtyStoryKeys.clear(); + this.#aggregated = null; + this.#bodyDirty = true; + this.#scheduleBroadcast(undefined, 'invalidateAll'); + } + + subscribe(listener: TrackedChangeIndexListener): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + dispose(): void { + for (const teardown of this.#teardowns) { + try { + teardown(); + } catch { + // Teardown errors during host disposal are non-fatal. + } + } + this.#teardowns.length = 0; + this.#listeners.clear(); + this.#snapshots.clear(); + this.#dirtyStoryKeys.clear(); + this.#aggregated = null; + indexByHost.delete(this.#hostEditor); + } + + #getByKey(storyKey: string, locator: StoryLocator): TrackedChangeSnapshot[] { + if (storyKey === BODY_STORY_KEY) { + if (this.#bodyDirty || !this.#snapshots.has(storyKey)) { + const bodySnapshots = this.#buildSnapshotsFromEditor(this.#hostEditor, storyKey, locator); + this.#snapshots.set(storyKey, bodySnapshots); + this.#bodyDirty = false; + this.#dirtyStoryKeys.delete(storyKey); + this.#aggregated = null; + } + return this.#snapshots.get(storyKey) ?? []; + } + + if (this.#dirtyStoryKeys.has(storyKey) || !this.#snapshots.has(storyKey)) { + const snapshots = this.#computeStorySnapshots(locator, storyKey); + this.#snapshots.set(storyKey, snapshots); + this.#dirtyStoryKeys.delete(storyKey); + this.#aggregated = null; + } + + return this.#snapshots.get(storyKey) ?? []; + } + + #computeStorySnapshots(locator: StoryLocator, storyKey: string): TrackedChangeSnapshot[] { + let runtime; + try { + runtime = resolveStoryRuntime(this.#hostEditor, locator); + } catch { + return []; + } + + return this.#buildSnapshotsFromEditor(runtime.editor, storyKey, locator); + } + + #buildSnapshotsFromEditor(editor: Editor, storyKey: string, locator: StoryLocator): TrackedChangeSnapshot[] { + const grouped = groupTrackedChanges(editor); + if (grouped.length === 0) return []; + + const storyKind = classifyStoryKind(locator); + const storyLabel = describeStoryLocation(locator); + + return grouped.map((change) => this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel)); + } + + #buildSnapshot( + editor: Editor, + change: GroupedTrackedChange, + storyKey: string, + locator: StoryLocator, + storyKind: TrackedChangeSnapshot['storyKind'], + storyLabel: string, + ): TrackedChangeSnapshot { + const runtimeRef: TrackedChangeRuntimeRef = { storyKey, rawId: change.rawId }; + const address = buildTrackedChangeAddress(locator, storyKey, change.id); + const type = resolveTrackedChangeType(change); + const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); + + return { + address, + runtimeRef, + story: locator, + type, + author: toNonEmptyString(change.attrs.author), + authorEmail: toNonEmptyString(change.attrs.authorEmail), + authorImage: toNonEmptyString(change.attrs.authorImage), + date: toNonEmptyString(change.attrs.date), + excerpt, + wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + storyLabel, + storyKind, + anchorKey: makeTrackedChangeAnchorKey(runtimeRef), + range: { from: change.from, to: change.to }, + }; + } + + #attachHostListeners(): void { + const editor = this.#hostEditor; + if (typeof editor.on !== 'function') return; + + const onTransaction = ({ transaction }: { transaction: { docChanged: boolean } }): void => { + if (!transaction.docChanged) return; + this.#bodyDirty = true; + this.#aggregated = null; + this.#scheduleBroadcast([{ kind: 'story', storyType: 'body' }], 'body-edit'); + }; + editor.on('transaction', onTransaction); + this.#teardowns.push(() => editor.off?.('transaction', onTransaction)); + + const onPartChanged = (event: PartChangedEvent): void => { + const invalidatedStories = this.#storiesFromPartChange(event); + if (invalidatedStories.length === 0) return; + for (const story of invalidatedStories) { + this.#invalidateKey(buildStoryKey(story)); + } + this.#scheduleBroadcast(invalidatedStories, 'partChanged'); + }; + editor.on('partChanged', onPartChanged); + this.#teardowns.push(() => editor.off?.('partChanged', onPartChanged)); + + const onNotesChanged = (): void => { + const wiped: StoryLocator[] = []; + for (const key of Array.from(this.#snapshots.keys())) { + if (!key.startsWith('fn:') && !key.startsWith('en:')) continue; + this.#invalidateKey(key); + const storyType: 'footnote' | 'endnote' = key.startsWith('fn:') ? 'footnote' : 'endnote'; + const noteId = key.slice(storyType === 'footnote' ? 'fn:'.length : 'en:'.length); + wiped.push({ kind: 'story', storyType, noteId }); + } + + if (wiped.length > 0) { + this.#scheduleBroadcast(wiped, 'notes-part-changed'); + return; + } + + this.#aggregated = null; + this.#scheduleBroadcast(undefined, 'notes-part-changed'); + }; + editor.on('notes-part-changed', onNotesChanged); + this.#teardowns.push(() => editor.off?.('notes-part-changed', onNotesChanged)); + + const onDestroy = (): void => { + this.dispose(); + }; + editor.on('destroy', onDestroy); + this.#teardowns.push(() => editor.off?.('destroy', onDestroy)); + } + + #storiesFromPartChange(event: PartChangedEvent): StoryLocator[] { + const stories: StoryLocator[] = []; + const converter = (this.#hostEditor as unknown as { converter?: { convertedXml?: Record } }) + .converter; + const relsData = converter?.convertedXml?.['word/_rels/document.xml.rels']; + + for (const part of event.parts) { + if (!isHeaderFooterPartId(part.partId)) continue; + const refId = resolveRIdFromRelsData(relsData, part.partId); + if (!refId) continue; + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + + return stories; + } + + #invalidateKey(storyKey: string): void { + if (storyKey === BODY_STORY_KEY) { + this.#bodyDirty = true; + } else { + this.#dirtyStoryKeys.add(storyKey); + this.#snapshots.delete(storyKey); + } + this.#aggregated = null; + } + + #scheduleBroadcast(stories: StoryLocator[] | undefined, source: string): void { + this.#pendingBroadcastStories = this.#mergePendingStories(this.#pendingBroadcastStories, stories); + this.#pendingBroadcastSource = this.#mergePendingSource(this.#pendingBroadcastSource, source); + + if (this.#broadcastScheduled) return; + + this.#broadcastScheduled = true; + void Promise.resolve().then(() => { + this.#broadcastScheduled = false; + const pendingStories = this.#pendingBroadcastStories; + const pendingSource = this.#pendingBroadcastSource; + this.#pendingBroadcastStories = null; + this.#pendingBroadcastSource = null; + + this.#emitHostEvent(pendingStories ?? undefined, pendingSource ?? undefined); + this.#notifySubscribers(); + }); + } + + #mergePendingStories( + current: StoryLocator[] | undefined | null, + next: StoryLocator[] | undefined, + ): StoryLocator[] | undefined { + if (current === undefined || next === undefined) return undefined; + + const merged = new Map(); + for (const story of current ?? []) { + merged.set(buildStoryKey(story), story); + } + for (const story of next ?? []) { + merged.set(buildStoryKey(story), story); + } + return Array.from(merged.values()); + } + + #mergePendingSource(current: string | undefined | null, next: string): string | undefined { + if (current === null) return next; + if (current === undefined || current === next) return current; + return undefined; + } + + #emitHostEvent(stories: StoryLocator[] | undefined, source?: string): void { + const editor = this.#hostEditor; + if (typeof editor.emit !== 'function') return; + editor.emit('tracked-changes-changed', { + editor, + stories, + source, + }); + } + + #notifySubscribers(): void { + if (this.#listeners.size === 0) return; + const snapshot = this.getAll(); + for (const listener of Array.from(this.#listeners)) { + try { + listener(snapshot); + } catch { + // Listener failures must not prevent other subscribers from syncing. + } + } + } +} + +export { classifyStoryKind, describeStoryLocation } from './story-labels.js'; +export type { TrackedChangeSnapshot } from './tracked-change-snapshot.js'; +export { parseStoryKeyType as parseStoryKind }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts new file mode 100644 index 0000000000..b9ed36a2e2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -0,0 +1,44 @@ +/** + * Canonical tracked-change snapshot — the single shape every downstream + * consumer (sidebar, navigation, document-api list/get, review tools) reads + * from the {@link TrackedChangeIndex}. + */ + +import type { + StoryLocator, + TrackedChangeAddress, + TrackChangeType, + TrackChangeWordRevisionIds, +} from '@superdoc/document-api'; +import type { TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; + +export interface TrackedChangeSnapshot { + /** Public, story-aware address for contract use. */ + address: TrackedChangeAddress; + /** Internal runtime ref for routing and caching. */ + runtimeRef: TrackedChangeRuntimeRef; + /** Story locator for this snapshot. */ + story: StoryLocator; + /** Tracked-change kind. */ + type: TrackChangeType; + /** Author display name, if captured on the mark. */ + author?: string; + /** Author email, if captured. */ + authorEmail?: string; + /** Author avatar URL, if captured. */ + authorImage?: string; + /** Change creation timestamp, if captured. */ + date?: string; + /** Short textual excerpt for sidebar display. */ + excerpt?: string; + /** Raw imported Word revision IDs, if present. */ + wordRevisionIds?: TrackChangeWordRevisionIds; + /** Human-readable label for sidebar cards ("Footer · Section 3", "Footnote 12"). */ + storyLabel: string; + /** Coarse classifier for UI decisions (icon, label). */ + storyKind: 'body' | 'headerFooter' | 'footnote' | 'endnote'; + /** Canonical shared-map anchor key (`tc::::`). */ + anchorKey: string; + /** Absolute PM position range within the story editor. */ + range: { from: number; to: number }; +} diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts index 7171e6fab8..7e94965c71 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts @@ -10,6 +10,7 @@ import { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from './D type MutableElementsFromPointDocument = Document & { elementsFromPoint?: (x: number, y: number) => Element[]; + caretRangeFromPoint?: (x: number, y: number) => Range | null; }; /** @@ -69,6 +70,27 @@ function buildPageDom( return page; } +function mockRect(element: Element, rect: { left: number; top: number; width: number; height: number }): void { + const value = { + x: rect.left, + y: rect.top, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON() { + return this; + }, + }; + + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => value, + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -189,6 +211,77 @@ describe('DomPointerMapping', () => { expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(10); }); + + it('maps the right half of a tracked-change span to the next rendered span start when PM has hidden gaps', () => { + container.innerHTML = ` +
+
+
+ This is a sim + Z + ple footnote +
+
+
+ `; + + const page = container.querySelector('.superdoc-page') as HTMLElement; + const fragment = container.querySelector('.superdoc-fragment') as HTMLElement; + const line = container.querySelector('.superdoc-line') as HTMLElement; + const spans = Array.from(container.querySelectorAll('span')) as HTMLElement[]; + const insertedSpan = spans[1]; + const insertedTextNode = insertedSpan.firstChild as Text; + + mockRect(page, { left: 100, top: 10, width: 240, height: 30 }); + mockRect(fragment, { left: 100, top: 10, width: 240, height: 30 }); + mockRect(line, { left: 110, top: 10, width: 160, height: 20 }); + mockRect(spans[0], { left: 110, top: 10, width: 77, height: 20 }); + mockRect(spans[1], { left: 187, top: 10, width: 8, height: 20 }); + mockRect(spans[2], { left: 195, top: 10, width: 70, height: 20 }); + + const doc = document as MutableElementsFromPointDocument; + const originalElementsFromPoint = doc.elementsFromPoint; + const originalCaretRangeFromPoint = doc.caretRangeFromPoint; + + doc.elementsFromPoint = () => [ + insertedSpan, + line, + fragment, + page, + container, + document.body, + document.documentElement, + ]; + doc.caretRangeFromPoint = (x: number) => { + if (x < 191) { + return { + startContainer: insertedTextNode, + startOffset: 0, + } as Range; + } + + return { + startContainer: insertedTextNode, + startOffset: 1, + } as Range; + }; + + try { + expect(clickToPositionDom(container, 194, 18)).toBe(21); + } finally { + if (originalElementsFromPoint) { + doc.elementsFromPoint = originalElementsFromPoint; + } else { + delete doc.elementsFromPoint; + } + + if (originalCaretRangeFromPoint) { + doc.caretRangeFromPoint = originalCaretRangeFromPoint; + } else { + delete doc.caretRangeFromPoint; + } + } + }); }); // ----------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts index a645c6dd4c..32fdfb2ff4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -214,6 +214,26 @@ export function clickToPositionDom(domContainer: HTMLElement, clientX: number, c return resolveFragment(fragmentEl, clientX, clientY); } +/** + * Resolves a click within a specific rendered fragment. + * + * Unlike {@link clickToPositionDom}, this helper does not scan the full page + * hit chain to choose a fragment. Callers that already know which rendered + * fragment owns the click can use this to avoid cross-surface ambiguity when + * multiple stories share overlapping PM position ranges. + */ +export function resolvePositionWithinFragmentDom( + fragmentEl: HTMLElement, + clientX: number, + clientY: number, +): number | null { + if (!fragmentEl.classList?.contains?.(CLASS.fragment)) { + return null; + } + + return resolveFragment(fragmentEl, clientX, clientY); +} + /** * Finds the page element containing the given viewport coordinates. * @@ -359,21 +379,52 @@ function resolvePositionInLine( const targetEl = findSpanAtX(spanEls, viewX); if (!targetEl) return lineStart; + const targetIndex = spanEls.indexOf(targetEl); + if (targetIndex < 0) { + return lineStart; + } const { start: spanStart, end: spanEnd } = readPmRange(targetEl); if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return null; + const rightCaretBoundary = resolveRightCaretBoundary(spanEls, targetIndex, spanStart, spanEnd); // Non-text or empty element → snap to nearest edge const firstChild = targetEl.firstChild; if (!firstChild || firstChild.nodeType !== Node.TEXT_NODE || !firstChild.textContent) { const targetRect = targetEl.getBoundingClientRect(); const closerToLeft = Math.abs(viewX - targetRect.left) <= Math.abs(viewX - targetRect.right); - return rtl ? (closerToLeft ? spanEnd : spanStart) : closerToLeft ? spanStart : spanEnd; + return rtl ? (closerToLeft ? rightCaretBoundary : spanStart) : closerToLeft ? spanStart : rightCaretBoundary; } const textNode = firstChild as Text; const charIndex = findCharIndexAtX(textNode, viewX, rtl); - return mapCharIndexToPm(spanStart, spanEnd, textNode.length, charIndex); + return mapCharIndexToPm(spanStart, spanEnd, rightCaretBoundary, textNode.length, charIndex); +} + +/** + * Visible text can be split across adjacent PM wrapper nodes, which creates + * hidden structural gaps between consecutive rendered spans. The caret the user + * sees at the right edge of the current span should land at the next rendered + * span's start, not inside the hidden wrapper gap. + */ +function resolveRightCaretBoundary( + spanEls: readonly HTMLElement[], + targetIndex: number, + spanStart: number, + spanEnd: number, +): number { + for (let index = targetIndex + 1; index < spanEls.length; index += 1) { + const { start: nextStart } = readPmRange(spanEls[index]); + if (!Number.isFinite(nextStart)) { + continue; + } + if (nextStart > spanEnd) { + return nextStart; + } + break; + } + + return spanEnd; } // --------------------------------------------------------------------------- @@ -431,19 +482,45 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null * Otherwise (e.g. ligatures or collapsed content) falls back to a midpoint * heuristic. */ -function mapCharIndexToPm(spanStart: number, spanEnd: number, textLength: number, charIndex: number): number { +function mapCharIndexToPm( + spanStart: number, + spanEnd: number, + rightCaretBoundary: number, + textLength: number, + charIndex: number, +): number { if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return spanStart; if (textLength <= 0) return spanStart; const pmRange = spanEnd - spanStart; if (!Number.isFinite(pmRange) || pmRange <= 0) return spanStart; + const safeRightBoundary = + Number.isFinite(rightCaretBoundary) && rightCaretBoundary >= spanEnd ? rightCaretBoundary : spanEnd; + + const clampedIndex = Math.max(0, Math.min(textLength, charIndex)); + + // When text is split across wrapper nodes (for example tracked-change runs), + // PM exposes hidden boundary positions between visible spans. Preserve the + // normal 1:1 mapping for visible characters and reserve the structural gap + // for the final caret boundary only. + if (safeRightBoundary > spanEnd) { + if (clampedIndex >= textLength) { + return safeRightBoundary; + } + + const directPos = spanStart + clampedIndex; + if (directPos <= spanEnd) { + return directPos; + } + } + if (pmRange === textLength) { - return Math.min(spanEnd, Math.max(spanStart, spanStart + charIndex)); + return Math.min(spanEnd, Math.max(spanStart, spanStart + clampedIndex)); } // PM range ≠ text length — snap to closer half - return charIndex / textLength <= 0.5 ? spanStart : spanEnd; + return clampedIndex / textLength <= 0.5 ? spanStart : safeRightBoundary; } /** diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts index b555ff3a4b..924f883f8e 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -18,6 +18,20 @@ export type DomPositionIndexEntry = { el: HTMLElement; }; +function isExcludedFromBodyDomIndex(node: HTMLElement): boolean { + if (node.closest('.superdoc-page-header, .superdoc-page-footer')) { + return true; + } + + const blockId = node.closest('[data-block-id]')?.dataset.blockId ?? ''; + return ( + blockId.startsWith('footnote-') || + blockId.startsWith('__sd_semantic_footnote-') || + blockId.startsWith('endnote-') || + blockId.startsWith('__sd_semantic_endnote-') + ); +} + /** * Options for controlling how the DOM position index is rebuilt. */ @@ -98,7 +112,7 @@ export class DomPositionIndex { for (const node of pmNodes) { if (node.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) continue; - if (node.closest('.superdoc-page-header, .superdoc-page-footer')) continue; + if (isExcludedFromBodyDomIndex(node)) continue; if (leafOnly && nonLeaf.has(node)) continue; const pmStart = Number(node.dataset.pmStart ?? 'NaN'); diff --git a/packages/super-editor/src/editors/v1/dom-observer/index.ts b/packages/super-editor/src/editors/v1/dom-observer/index.ts index 44155994f1..869c70fbd2 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/index.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/index.ts @@ -20,4 +20,9 @@ export { deduplicateOverlappingRects, } from './DomSelectionGeometry.js'; export { getPageElementByIndex } from './PageDom.js'; -export { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from './DomPointerMapping.js'; +export { + clickToPositionDom, + findPageElement, + readLayoutEpochFromDom, + resolvePositionWithinFragmentDom, +} from './DomPointerMapping.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js index 47112d67d5..030a9b26aa 100644 --- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js @@ -253,19 +253,43 @@ export const broadcastEditorEvents = (editor, sectionEditor) => { }); }; +const applyHeaderFooterEditorDocumentMode = (editor, documentMode) => { + if (!editor) return; + + if (documentMode === 'viewing') { + editor.commands?.enableTrackChangesShowOriginal?.(); + editor.setOptions?.({ documentMode: 'viewing' }); + editor.setEditable(false, false); + } else if (documentMode === 'suggesting') { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.enableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'suggesting' }); + editor.setEditable(true, false); + } else { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.disableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'editing' }); + editor.setEditable(true, false); + } + + if (editor.view?.dom) { + editor.view.dom.setAttribute('aria-readonly', documentMode === 'viewing' ? 'true' : 'false'); + editor.view.dom.setAttribute('documentmode', documentMode); + editor.view.dom.classList.toggle('view-mode', documentMode === 'viewing'); + } +}; + export const toggleHeaderFooterEditMode = ({ editor, focusedSectionEditor, isEditMode, documentMode }) => { if (isHeadless(editor)) return; + const targetMode = isEditMode ? documentMode : 'viewing'; + editor.converter.headerEditors.forEach((item) => { - item.editor.setEditable(isEditMode, false); - item.editor.view.dom.setAttribute('aria-readonly', !isEditMode); - item.editor.view.dom.setAttribute('documentmode', documentMode); + applyHeaderFooterEditorDocumentMode(item.editor, targetMode); }); editor.converter.footerEditors.forEach((item) => { - item.editor.setEditable(isEditMode, false); - item.editor.view.dom.setAttribute('aria-readonly', !isEditMode); - item.editor.view.dom.setAttribute('documentmode', documentMode); + applyHeaderFooterEditorDocumentMode(item.editor, targetMode); }); if (isEditMode) { diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js index 007ec83f32..2161478588 100644 --- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js @@ -9,6 +9,13 @@ const { MockEditor, getStarterExtensions, applyStyleIsolationClass } = vi.hoiste this.once = vi.fn(); this.emit = vi.fn(); this.setEditable = vi.fn(); + this.setOptions = vi.fn(); + this.commands = { + enableTrackChanges: vi.fn(), + disableTrackChanges: vi.fn(), + enableTrackChangesShowOriginal: vi.fn(), + disableTrackChangesShowOriginal: vi.fn(), + }; this.view = { dom: document.createElement('div') }; this.storage = { image: { media: {} } }; } @@ -41,13 +48,17 @@ vi.mock('@core/parts/adapters/header-footer-sync.js', () => ({ exportSubEditorToPart: vi.fn(), })); -import { createHeaderFooterEditor } from './pagination-helpers.js'; +import { createHeaderFooterEditor, toggleHeaderFooterEditMode } from './pagination-helpers.js'; function createParentEditor() { return { constructor: MockEditor, options: { role: 'editor', + user: { + name: 'SuperDoc Test', + email: 'test@superdoc.com', + }, fonts: {}, isHeadless: true, }, @@ -92,7 +103,50 @@ describe('createHeaderFooterEditor', () => { expect.objectContaining({ isHeaderOrFooter: true, headerFooterType: 'footer', + user: { + name: 'SuperDoc Test', + email: 'test@superdoc.com', + }, }), ); }); + + it('applies suggesting mode to header/footer editors when edit mode is enabled', () => { + const headerEditor = new MockEditor({}); + const footerEditor = new MockEditor({}); + const mainPm = document.createElement('div'); + const focusedSectionEditor = { + view: { + focus: vi.fn(), + }, + }; + + toggleHeaderFooterEditMode({ + editor: { + converter: { + headerEditors: [{ editor: headerEditor }], + footerEditors: [{ editor: footerEditor }], + }, + view: { + dom: mainPm, + }, + }, + focusedSectionEditor, + isEditMode: true, + documentMode: 'suggesting', + }); + + expect(headerEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(headerEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(headerEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(headerEditor.setEditable).toHaveBeenCalledWith(true, false); + expect(headerEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + + expect(footerEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(footerEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(footerEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(footerEditor.setEditable).toHaveBeenCalledWith(true, false); + expect(footerEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + expect(focusedSectionEditor.view.focus).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index 4612c9e9de..ddcf8a8f01 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -50,6 +50,17 @@ import { seedEditorStateToYDoc } from './extensions/collaboration/seed-editor-to import { onCollaborationProviderSynced } from './core/helpers/collaboration-provider-sync.js'; import { resolveSelectionTarget } from './document-api-adapters/helpers/selection-target-resolver.js'; import { resolveDefaultInsertTarget } from './document-api-adapters/helpers/adapter-utils.js'; +import { resolveTrackedChangeInStory } from './document-api-adapters/helpers/tracked-change-resolver.js'; +import { getTrackedChangeIndex } from './document-api-adapters/tracked-changes/tracked-change-index.js'; +import { + makeTrackedChangeAnchorKey, + makeCommentAnchorKey, + isTrackedChangeAnchorKey, + isCommentAnchorKey, + parseTrackedChangeAnchorKey, + TRACKED_CHANGE_ANCHOR_KEY_PREFIX, + COMMENT_ANCHOR_KEY_PREFIX, +} from './document-api-adapters/helpers/tracked-change-runtime-ref.js'; const Extensions = { Node, @@ -145,4 +156,24 @@ export { resolveSelectionTarget, /** @internal */ resolveDefaultInsertTarget, + /** @internal */ + resolveTrackedChangeInStory, + + // Story-aware tracked-change service + /** @internal */ + getTrackedChangeIndex, + /** @internal */ + makeTrackedChangeAnchorKey, + /** @internal */ + makeCommentAnchorKey, + /** @internal */ + isTrackedChangeAnchorKey, + /** @internal */ + isCommentAnchorKey, + /** @internal */ + parseTrackedChangeAnchorKey, + /** @internal */ + TRACKED_CHANGE_ANCHOR_KEY_PREFIX, + /** @internal */ + COMMENT_ANCHOR_KEY_PREFIX, }; diff --git a/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js b/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js index 90fffdc7eb..273487ba2c 100644 --- a/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js +++ b/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js @@ -80,4 +80,43 @@ describe('footnotes import', () => { const types = collectNodeTypes(result.pmDoc); expect(types).toContain('footnoteReference'); }); + + it('imports w:endnoteReference and loads matching endnotes.xml entry', () => { + const documentXml = + '' + + '' + + '' + + 'Hello' + + '' + + '' + + '' + + ''; + + const endnotesXml = + '' + + '' + + 'Endnote text' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(documentXml), + 'word/endnotes.xml': parseXmlToJson(endnotesXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), + }; + + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + const result = createDocumentJson(docx, converter, editor); + expect(result).toBeTruthy(); + + expect(Array.isArray(result.endnotes)).toBe(true); + const endnote = result.endnotes.find((note) => note?.id === '1'); + expect(endnote).toBeTruthy(); + expect(Array.isArray(endnote.content)).toBe(true); + expect(extractPlainText(endnote.content)).toBe('Endnote text'); + + const types = collectNodeTypes(result.pmDoc); + expect(types).toContain('endnoteReference'); + }); }); diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 75a484340e..79eb9970ba 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -43,6 +43,7 @@ export type { FontsResolvedPayload, PaginationPayload, ListDefinitionsPayload, + TrackedChangesChangedPayload, ProtectionChangeSource, EditorEventMap, } from './editors/v1/core/types/EditorEvents.js'; diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 2a5d0307c9..b73a8ee999 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -104,10 +104,22 @@ const HrbrFieldsLayerStub = stubComponent('HrbrFieldsLayer'); const AiLayerStub = stubComponent('AiLayer'); const HtmlViewerStub = stubComponent('HtmlViewer'); +const createTrackedChangeIndexStub = () => ({ + subscribe: vi.fn(() => () => {}), + getAll: vi.fn(() => []), + get: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + dispose: vi.fn(), +}); + +const getTrackedChangeIndexMock = vi.fn(() => createTrackedChangeIndexStub()); + // Mock @superdoc/super-editor with stubs and PresentationEditor class vi.mock('@superdoc/super-editor', () => ({ SuperEditor: SuperEditorStub, AIWriter: AIWriterStub, + getTrackedChangeIndex: getTrackedChangeIndexMock, PresentationEditor: class PresentationEditorMock { static getInstance(documentId) { return mockState.instances.get(documentId); @@ -254,9 +266,28 @@ const buildCommentsStore = () => ({ isCommentHighlighted: ref(false), }); -const mountComponent = async (superdocStub, { surfaceManager = null } = {}) => { - superdocStoreStub = buildSuperdocStore(); - commentsStoreStub = buildCommentsStore(); +const createCommentsStoreWithFloatingGetter = () => { + const store = buildCommentsStore(); + const floatingCommentsState = ref([]); + + delete store.getFloatingComments; + Object.defineProperty(store, 'getFloatingComments', { + configurable: true, + enumerable: true, + get() { + return floatingCommentsState.value; + }, + }); + + return { store, floatingCommentsState }; +}; + +const mountComponent = async ( + superdocStub, + { surfaceManager = null, superdocStore = null, commentsStore = null } = {}, +) => { + superdocStoreStub = superdocStore ?? buildSuperdocStore(); + commentsStoreStub = commentsStore ?? buildCommentsStore(); superdocStoreStub.modules.ai = { endpoint: '/ai' }; commentsStoreStub.documentsWithConverations.value = [{ id: 'doc-1' }]; @@ -387,6 +418,8 @@ describe('SuperDoc.vue', () => { useSelectionMock.mockClear(); useAiMock.mockClear(); useSelectedTextMock.mockClear(); + getTrackedChangeIndexMock.mockClear(); + getTrackedChangeIndexMock.mockImplementation(() => createTrackedChangeIndexStub()); mockState.instances.clear(); // Make RAF synchronous in tests — jsdom has no rendering loop, and @@ -680,6 +713,17 @@ describe('SuperDoc.vue', () => { expect(options.layoutEngineOptions.flowMode).toBe('paginated'); }); + it('passes useHiddenHostForStoryParts through to SuperEditor options', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.useHiddenHostForStoryParts = true; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + expect(options.useHiddenHostForStoryParts).toBe(true); + }); + it('handles replay comment update/delete events and triggers tracked-change resync', async () => { const superdocStub = createSuperdocStub(); const wrapper = await mountComponent(superdocStub); @@ -742,6 +786,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -761,6 +807,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -796,6 +844,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -833,6 +883,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -1285,6 +1337,79 @@ describe('SuperDoc.vue', () => { expect(doc.setPresentationEditor).toHaveBeenCalledWith(presentationEditor); expect(presentationEditor.setContextMenuDisabled).toHaveBeenCalledWith(true); expect(presentationEditor.on).toHaveBeenCalledWith('commentPositions', expect.any(Function)); + expect(getTrackedChangeIndexMock).toHaveBeenCalledWith(editor); + }); + + it('resyncs tracked-change comments from non-body tracked-changes-changed events', async () => { + const superdocStub = createSuperdocStub(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn(); + + const listeners = {}; + const presentationEditor = { + setContextMenuDisabled: vi.fn(), + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + getCommentBounds: vi.fn(() => ({})), + }; + const bodyEditor = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[`editor:${event}`] = handler; + }), + }; + const sourceEditor = { options: { documentId: 'header-doc' } }; + + wrapper.findComponent(SuperEditorStub).vm.$emit('editor-ready', { + editor: bodyEditor, + presentationEditor, + }); + await nextTick(); + + listeners['editor:tracked-changes-changed']?.({ editor: sourceEditor, source: 'story-edit' }); + expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ + superdoc: superdocStub, + editor: sourceEditor, + }); + + commentsStoreStub.syncTrackedChangeComments.mockClear(); + listeners['editor:tracked-changes-changed']?.({ editor: sourceEditor, source: 'body-edit' }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + }); + + it('clears tracked-change positions for non-body tracked-change updates when viewing-mode comments are hidden', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.documentMode = 'viewing'; + const wrapper = await mountComponent(superdocStub); + await nextTick(); + superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn(); + + const listeners = {}; + const presentationEditor = { + setContextMenuDisabled: vi.fn(), + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + getCommentBounds: vi.fn(() => ({})), + }; + const bodyEditor = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[`editor:${event}`] = handler; + }), + }; + + wrapper.findComponent(SuperEditorStub).vm.$emit('editor-ready', { + editor: bodyEditor, + presentationEditor, + }); + await nextTick(); + + listeners['editor:tracked-changes-changed']?.({ source: 'story-edit' }); + expect(commentsStoreStub.clearEditorCommentPositions).toHaveBeenCalled(); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); }); it('forwards header/footer presentation events through the public update callbacks', async () => { @@ -1548,6 +1673,20 @@ describe('SuperDoc.vue', () => { expect(wrapper.find('.floating-comments').exists()).toBe(true); }); + it('shows floating comments when the comments store exposes them through a getter', async () => { + const superdocStub = createSuperdocStub(); + const { store, floatingCommentsState } = createCommentsStoreWithFloatingGetter(); + const wrapper = await mountComponent(superdocStub, { commentsStore: store }); + await nextTick(); + + floatingCommentsState.value = [{ commentId: 'tracked-1' }]; + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(wrapper.vm.showCommentsSidebar).toBe(true); + expect(wrapper.find('.floating-comments').exists()).toBe(true); + }); + it('hides floating comments sidebar entirely in viewing mode even with comment positions', async () => { const superdocStub = createSuperdocStub(); superdocStub.config.documentMode = 'viewing'; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 6e75e4aed0..167a16dfda 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -8,6 +8,7 @@ import { getCurrentInstance, inject, ref, + unref, onMounted, onBeforeUnmount, nextTick, @@ -30,7 +31,7 @@ import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import { DOCX, PDF, HTML } from '@superdoc/common'; -import { SuperEditor, AIWriter, PresentationEditor } from '@superdoc/super-editor'; +import { SuperEditor, AIWriter, PresentationEditor, getTrackedChangeIndex } from '@superdoc/super-editor'; import { ySyncPluginKey } from 'y-prosemirror'; import HtmlViewer from './components/HtmlViewer/HtmlViewer.vue'; import useComment from './components/CommentsLayer/use-comment'; @@ -104,7 +105,6 @@ const { isCommentsListVisible, isFloatingCommentsReady, generalCommentIds, - getFloatingComments, hasSyncedCollaborationComments, editorCommentPositions, hasInitializedLocations, @@ -128,6 +128,11 @@ const { const { proxy } = getCurrentInstance(); commentsStore.proxy = proxy; +const floatingComments = computed(() => { + const currentFloatingComments = unref(commentsStore.getFloatingComments); + return Array.isArray(currentFloatingComments) ? currentFloatingComments : []; +}); + const { isHighContrastMode } = useHighContrastMode(); const { uiFontFamily } = useUiFontFamily(); @@ -241,6 +246,36 @@ const flushPendingReplayTrackedChangeSync = () => { syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor: proxy.$superdoc?.activeEditor }); }; +let queuedTrackedChangeCommentResync = null; +let isTrackedChangeCommentResyncQueued = false; + +const flushQueuedTrackedChangeCommentResync = () => { + isTrackedChangeCommentResyncQueued = false; + + const pendingResync = queuedTrackedChangeCommentResync; + queuedTrackedChangeCommentResync = null; + if (!pendingResync?.editor) return; + + syncTrackedChangeComments({ + superdoc: proxy.$superdoc, + editor: pendingResync.editor, + broadcastChanges: pendingResync.broadcastChanges, + }); +}; + +const queueTrackedChangeCommentResync = ({ editor, broadcastChanges = true } = {}) => { + if (!editor) return; + + queuedTrackedChangeCommentResync = { + editor, + broadcastChanges: Boolean(queuedTrackedChangeCommentResync?.broadcastChanges) || Boolean(broadcastChanges), + }; + + if (isTrackedChangeCommentResyncQueued) return; + isTrackedChangeCommentResyncQueued = true; + queueMicrotask(flushQueuedTrackedChangeCommentResync); +}; + const scheduleReplayTrackedChangeSync = () => { pendingReplayTrackedChangeSync.value = true; @@ -357,6 +392,7 @@ const onEditorReady = ({ editor, presentationEditor }) => { if (doc.password) doc.password = undefined; } presentationEditor.setContextMenuDisabled?.(proxy.$superdoc.config.disableContextMenu); + getTrackedChangeIndex(editor); // Listen for fresh comment positions from the layout engine. // PresentationEditor emits this after every layout with PM positions collected @@ -382,6 +418,15 @@ const onEditorReady = ({ editor, presentationEditor }) => { } }); + editor.on?.('tracked-changes-changed', ({ editor: sourceEditor, source }) => { + if (source === 'body-edit') return; + if (!shouldRenderCommentsInViewing.value) { + commentsStore.clearEditorCommentPositions?.(); + return; + } + syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor: sourceEditor ?? editor }); + }); + presentationEditor.on('paginationUpdate', ({ layout }) => { const totalPages = layout.pages.length; proxy.$superdoc.emit('pagination-update', { totalPages, superdoc: proxy.$superdoc }); @@ -719,6 +764,7 @@ const editorOptions = (doc) => { viewOptions: proxy.$superdoc.config.viewOptions, contained: proxy.$superdoc.config.contained, linkPopoverResolver: proxy.$superdoc.config.modules?.links?.popoverResolver, + useHiddenHostForStoryParts: proxy.$superdoc.config.useHiddenHostForStoryParts, layoutEngineOptions: useLayoutEngine ? { ...(proxy.$superdoc.config.layoutEngineOptions || {}), @@ -1120,8 +1166,7 @@ const onEditorTransaction = (payload = {}) => { if (shouldResyncTrackedChangeThreads(transaction, ySyncMeta)) { const documentId = editor?.options?.documentId; syncTrackedChangePositionsWithDocument({ documentId, editor }); - syncTrackedChangeComments({ - superdoc: proxy.$superdoc, + queueTrackedChangeCommentResync({ editor, // Remote replay should rebuild only local sidebar state. The authoritative // collaboration comment update is already shared through the comments ydoc. @@ -1137,7 +1182,7 @@ const showCommentsSidebar = computed(() => { if (!shouldRenderCommentsInViewing.value) return false; return ( pendingComment.value || - (getFloatingComments.value?.length > 0 && + (floatingComments.value.length > 0 && isReady.value && layers.value && isCommentsEnabled.value && @@ -1479,7 +1524,7 @@ watch( // Ensure hasInitializedLocations is set when comments arrive (backup for cases // where handleDocumentReady hasn't fired yet). Never toggle false→true→false — // the virtualized FloatingComments reacts to comment changes via computed properties. -watch(getFloatingComments, () => { +watch(floatingComments, () => { if (!hasInitializedLocations.value) { hasInitializedLocations.value = true; } @@ -1637,7 +1682,7 @@ const getPDFViewer = () => {