diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index 15007c6c94..55fe624f3d 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -1808,8 +1808,8 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-dKJSGVcDAofGOzCV883PoCD6NBzIGzQ84c1dqhcZP+XhG+oUOgORNIBxc4PxwsYD+u5eIqe9ikQ0MMWOrvtnCw==, tarball: file:../../packages/superdoc/superdoc.tgz} - version: 1.32.0 + resolution: {integrity: sha512-P41Bcy6pkBTD9QPEp2paqppGL2bQ9BnBIVaTftT0KGnHwHEJ/ca7Hpxqlm8j87ZznLxXfId9RXQSCqtgLyCBvA==, tarball: file:../../packages/superdoc/superdoc.tgz} + version: 1.33.1 peerDependencies: '@hocuspocus/provider': ^2.13.6 pdfjs-dist: ^5.4.296 diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 4ee7c1d164..165b379d95 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -78,6 +78,27 @@ export { } from './vertical-text.js'; export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; + +// Editor-neutral layout identity primitives (prep-001). +// Additive only — `pmStart`/`pmEnd` and PM-shaped fields remain available +// alongside these on every fragment/run. +export { + LAYOUT_BOUNDARY_SCHEMA, + bodyStoryLocator, + namedStoryLocator, + computeLayoutFragmentId, + buildLayoutSourceIdentity, + buildLayoutSourceIdentityForFragment, +} from './layout-identity.js'; +export type { + LayoutBlockRef, + LayoutFragmentId, + LayoutPartialRowIdentity, + LayoutSourceIdentity, + LayoutStoryKind, + LayoutStoryLocator, +} from './layout-identity.js'; +import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ @@ -1937,6 +1958,15 @@ export type ParaFragment = { pmStart?: number; pmEnd?: number; sourceAnchor?: SourceAnchor; + /** + * Optional editor-neutral identity for this fragment. + * + * Additive (prep-001). PM-facing `pmStart`/`pmEnd` and `blockId` remain + * authoritative for v1 consumers; this field exists so downstream surfaces + * can address rendered output without requiring `pmStart`/`pmEnd`. See + * `layout-identity.ts`. + */ + layoutSourceIdentity?: LayoutSourceIdentity; }; export type TableColumnBoundary = { @@ -2003,6 +2033,8 @@ export type TableFragment = { * When set, the renderer uses these instead of measure.columnWidths. */ columnWidths?: number[]; sourceAnchor?: SourceAnchor; + /** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */ + layoutSourceIdentity?: LayoutSourceIdentity; }; export type ImageFragment = { @@ -2019,6 +2051,8 @@ export type ImageFragment = { pmEnd?: number; metadata?: ImageFragmentMetadata; sourceAnchor?: SourceAnchor; + /** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */ + layoutSourceIdentity?: LayoutSourceIdentity; }; export type DrawingFragment = { @@ -2038,6 +2072,8 @@ export type DrawingFragment = { pmStart?: number; pmEnd?: number; sourceAnchor?: SourceAnchor; + /** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */ + layoutSourceIdentity?: LayoutSourceIdentity; }; export type ListItemFragment = { @@ -2053,6 +2089,8 @@ export type ListItemFragment = { continuesFromPrev?: boolean; continuesOnNext?: boolean; sourceAnchor?: SourceAnchor; + /** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */ + layoutSourceIdentity?: LayoutSourceIdentity; }; export type Fragment = ParaFragment | ImageFragment | DrawingFragment | ListItemFragment | TableFragment; diff --git a/packages/layout-engine/contracts/src/layout-identity.test.ts b/packages/layout-engine/contracts/src/layout-identity.test.ts new file mode 100644 index 0000000000..882f12bd80 --- /dev/null +++ b/packages/layout-engine/contracts/src/layout-identity.test.ts @@ -0,0 +1,171 @@ +/** + * Editor-neutral layout identity primitives (prep-001) — contract tests. + * + * These tests pin the additive substrate so v1 fixtures cannot quietly + * lose `pmStart`/`pmEnd` fields and future producers cannot break the + * editor-neutral shape used by downstream surfaces. + */ +import { describe, expect, it } from 'vitest'; +import { + LAYOUT_BOUNDARY_SCHEMA, + bodyStoryLocator, + buildLayoutSourceIdentity, + buildLayoutSourceIdentityForFragment, + computeLayoutFragmentId, + namedStoryLocator, +} from './layout-identity.js'; +import type { ParaFragment, TableFragment } from './index.js'; + +describe('layout-identity (prep-001)', () => { + it('exposes a versioned schema constant', () => { + expect(LAYOUT_BOUNDARY_SCHEMA).toBe('layout-identity/1'); + }); + + it('builds story locators for body and named stories', () => { + expect(bodyStoryLocator()).toEqual({ kind: 'body' }); + expect(namedStoryLocator('header', 'rId4')).toEqual({ kind: 'header', id: 'rId4' }); + expect(namedStoryLocator('footer', 'rId7')).toEqual({ kind: 'footer', id: 'rId7' }); + }); + + it('falls back to unknown when a named story has no id', () => { + // Empty id is not a useful story handle; reject rather than silently + // shipping `{kind:'header', id:''}` to consumers. + expect(namedStoryLocator('header', '')).toEqual({ kind: 'unknown' }); + }); + + it('computes a stable opaque fragment id for paragraph fragments', () => { + const a = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + toLine: 2, + }); + const b = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + toLine: 2, + }); + const c = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 7, + toLine: 8, + }); + + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('differentiates by story when story changes', () => { + const body = computeLayoutFragmentId({ blockId: '5-paragraph', kind: 'para', fromLine: 0 }); + const header = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + story: namedStoryLocator('header', 'rId4'), + }); + expect(body).not.toBe(header); + }); + + it('differentiates paragraph and table split fragments by their full rendered slice', () => { + const paragraphA = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + toLine: 1, + }); + const paragraphB = computeLayoutFragmentId({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + toLine: 2, + }); + const a = computeLayoutFragmentId({ + blockId: '12-table', + kind: 'table', + fromRow: 3, + toRow: 4, + partialRow: { rowIndex: 3, fromLineByCell: [0, 1], toLineByCell: [2, -1] }, + }); + const b = computeLayoutFragmentId({ + blockId: '12-table', + kind: 'table', + fromRow: 3, + toRow: 4, + partialRow: { rowIndex: 3, fromLineByCell: [2, 1], toLineByCell: [4, -1] }, + }); + expect(paragraphA).not.toBe(paragraphB); + expect(a).not.toBe(b); + }); + + it('derives fragment identity from the same discriminators used by rendered fragment keys', () => { + const para = buildLayoutSourceIdentityForFragment({ + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 20, + y: 40, + }); + const imageA = buildLayoutSourceIdentityForFragment({ + kind: 'image', + blockId: 'img1', + x: 20, + y: 40, + }); + const imageB = buildLayoutSourceIdentityForFragment({ + kind: 'image', + blockId: 'img1', + x: 20, + y: 80, + }); + + expect(para.fragmentId).toContain('para:0:1'); + expect(imageA.fragmentId).not.toBe(imageB.fragmentId); + }); + + it('builds a composite identity with schema, story, blockRef, and fragmentId', () => { + const identity = buildLayoutSourceIdentity({ + blockId: '5-paragraph', + kind: 'para', + fromLine: 0, + }); + expect(identity.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(identity.story).toEqual({ kind: 'body' }); + expect(identity.blockRef).toBe('5-paragraph'); + expect(typeof identity.fragmentId).toBe('string'); + expect(identity.fragmentId.length).toBeGreaterThan(0); + }); + + it('keeps neutral identity optional on Fragment shapes', () => { + // Compile-time intent: ParaFragment / TableFragment should still type-check + // without `layoutSourceIdentity` so v1 fixtures (and existing tests) stay + // valid. + const para: ParaFragment = { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }; + const table: TableFragment = { + kind: 'table', + blockId: '12-table', + fromRow: 0, + toRow: 1, + x: 0, + y: 0, + width: 200, + height: 50, + }; + + expect(para.layoutSourceIdentity).toBeUndefined(); + expect(table.layoutSourceIdentity).toBeUndefined(); + // pmStart / pmEnd remain available alongside the neutral substrate. + expect('pmStart' in para).toBe(false); + expect('pmEnd' in para).toBe(false); + }); +}); diff --git a/packages/layout-engine/contracts/src/layout-identity.ts b/packages/layout-engine/contracts/src/layout-identity.ts new file mode 100644 index 0000000000..40cb97ae39 --- /dev/null +++ b/packages/layout-engine/contracts/src/layout-identity.ts @@ -0,0 +1,221 @@ +/** + * Editor-neutral layout identity primitives. + * + * These types describe rendered fragments and supported objects in terms that + * do not require ProseMirror. Legacy PM-shaped fields on `Fragment`, `Run`, and + * `Resolved*Item` (`pmStart`, `pmEnd`, `blockId`) remain available; this + * module is strictly additive and exists so future editor surfaces can map + * rendered output back to source state without re-deriving identity through + * `pmStart`/`pmEnd`. + * + * Versioned via `LAYOUT_BOUNDARY_SCHEMA` so downstream consumers (DOM datasets, + * adapters, paint snapshots) can negotiate a single shape over time. + */ +import type { SourceAnchor } from './index.js'; + +/** + * Schema version for the editor-neutral layout boundary substrate. + * + * Bump when an additive field becomes load-bearing or a field changes + * semantics. Pure additive growth (adding optional fields) does not require a + * bump, but a renamed/typed-change field does. + */ +export const LAYOUT_BOUNDARY_SCHEMA = 'layout-identity/1'; + +/** + * Stable opaque identifier for a rendered fragment. + * + * The painter writes this as `data-layout-fragment-id` and the layout-bridge + * neutral hit-test entry points return it. Format is an opaque string; callers + * MUST NOT parse it. Today it is derived from `blockId` plus the fragment's + * local position (`fromLine`/`fromRow` / atomic anchor) so it stays stable + * across re-layouts that preserve a fragment's identity. Future producers may + * mint stronger ids without invalidating this contract. + */ +export type LayoutFragmentId = string; + +/** + * Story kind for the surface a rendered fragment belongs to. + * + * The set mirrors the surfaces SuperDoc currently renders. `'unknown'` is + * used when the producer cannot classify a fragment yet (for example + * standalone footnote/endnote stories that have not been wired up to the + * neutral boundary). Consumers MUST treat `'unknown'` as a diagnostic + * fallback, not as a default value. + */ +export type LayoutStoryKind = 'body' | 'header' | 'footer' | 'footnote' | 'endnote' | 'unknown'; + +/** + * Story locator carried alongside every neutral identity. + * + * `id` is set when the producer can name the story (a header/footer part + * relationship id, a footnote/endnote story id). For body content it is + * omitted. + */ +export type LayoutStoryLocator = { + kind: LayoutStoryKind; + id?: string; +}; + +/** + * Reference to the source block a fragment belongs to. + * + * Today this is the producer's existing `blockId`. The type is intentionally + * opaque so a future v2 source provider can substitute a richer reference + * (e.g. a part-uri / xpath tuple) without reopening the layout boundary + * contract. + */ +export type LayoutBlockRef = string; + +/** + * Composite editor-neutral identity for a rendered fragment. + * + * Carries the minimum information needed to address a rendered fragment + * without consulting ProseMirror: the story, the source block, the stable + * fragment id, and (when available) a layout-side cross-reference to the + * DOCX source anchor that produced the fragment. + */ +export type LayoutSourceIdentity = { + schema: typeof LAYOUT_BOUNDARY_SCHEMA; + story: LayoutStoryLocator; + blockRef: LayoutBlockRef; + fragmentId: LayoutFragmentId; + sourceAnchor?: SourceAnchor; +}; + +export type LayoutPartialRowIdentity = { + rowIndex?: number; + fromLineByCell?: readonly number[]; + toLineByCell?: readonly number[]; +}; + +/** + * Build a `LayoutStoryLocator` for body content. + */ +export const bodyStoryLocator = (): LayoutStoryLocator => ({ kind: 'body' }); + +/** + * Build a `LayoutStoryLocator` for an HF / footnote / endnote story. + */ +export const namedStoryLocator = ( + kind: Exclude, + id: string, +): LayoutStoryLocator => (id ? { kind, id } : { kind: 'unknown' }); + +/** + * Compute a stable `LayoutFragmentId` for a fragment. + * + * Inputs are the values the producer already has on hand. The shape of the + * output is intentionally opaque; consumers compare ids for equality and + * round-trip them through DOM datasets, but never parse them. + */ +export const computeLayoutFragmentId = (input: { + blockId: string; + story?: LayoutStoryLocator; + kind: string; + fromLine?: number; + toLine?: number; + fromRow?: number; + toRow?: number; + itemId?: string; + x?: number; + y?: number; + partialRow?: LayoutPartialRowIdentity; +}): LayoutFragmentId => { + const story = input.story ?? bodyStoryLocator(); + const storySegment = story.kind === 'body' ? 'body' : `${story.kind}:${story.id ?? ''}`; + let variant: string; + if (input.kind === 'para') { + variant = `para:${input.fromLine ?? 0}:${input.toLine ?? ''}`; + } else if (input.kind === 'list-item') { + variant = `list-item:${input.itemId ?? ''}:${input.fromLine ?? 0}:${input.toLine ?? ''}`; + } else if (input.kind === 'table') { + const partialKey = input.partialRow + ? `:${input.partialRow.rowIndex ?? ''}:${input.partialRow.fromLineByCell?.join(',') ?? ''}-${input.partialRow.toLineByCell?.join(',') ?? ''}` + : ''; + variant = `table:${input.fromRow ?? 0}:${input.toRow ?? ''}${partialKey}`; + } else if (input.kind === 'image' || input.kind === 'drawing') { + variant = `${input.kind}:${input.x ?? ''}:${input.y ?? ''}`; + } else { + variant = input.kind; + } + return `${storySegment}|${input.blockId}|${variant}`; +}; + +/** + * Build a `LayoutSourceIdentity` from producer-known fields. + */ +export const buildLayoutSourceIdentity = (input: { + blockId: string; + story?: LayoutStoryLocator; + kind: string; + fromLine?: number; + toLine?: number; + fromRow?: number; + toRow?: number; + itemId?: string; + x?: number; + y?: number; + partialRow?: LayoutPartialRowIdentity; + sourceAnchor?: SourceAnchor; +}): LayoutSourceIdentity => ({ + schema: LAYOUT_BOUNDARY_SCHEMA, + story: input.story ?? bodyStoryLocator(), + blockRef: input.blockId, + fragmentId: computeLayoutFragmentId(input), + sourceAnchor: input.sourceAnchor, +}); + +type LayoutIdentityFragmentLike = { + kind: string; + blockId: string; + layoutSourceIdentity?: LayoutSourceIdentity; + sourceAnchor?: SourceAnchor; + fromLine?: number; + toLine?: number; + fromRow?: number; + toRow?: number; + itemId?: string; + x?: number; + y?: number; + partialRow?: LayoutPartialRowIdentity; +}; + +const sameStoryLocator = (left: LayoutStoryLocator, right: LayoutStoryLocator): boolean => + left.kind === right.kind && (left.id ?? '') === (right.id ?? ''); + +const shouldKeepExistingIdentity = (existing: LayoutSourceIdentity, story: LayoutStoryLocator | undefined): boolean => { + if (!story) return true; + if (sameStoryLocator(existing.story, story)) return true; + // A body default is not authoritative enough to downgrade an identity that + // was already produced for a named non-body story. + return story.kind === 'body' && existing.story.kind !== 'body'; +}; + +/** + * Build neutral identity from the fragment fields already used by renderer and + * resolved-layout fragment keys. Keeping this in the contract package prevents + * drift between DOM datasets, neutral hit tests, and resolved paint items. + */ +export const buildLayoutSourceIdentityForFragment = ( + fragment: LayoutIdentityFragmentLike, + story?: LayoutStoryLocator, +): LayoutSourceIdentity => { + const existing = fragment.layoutSourceIdentity; + if (existing && shouldKeepExistingIdentity(existing, story)) return existing; + + return buildLayoutSourceIdentity({ + blockId: fragment.blockId, + story: story ?? existing?.story, + kind: fragment.kind, + fromLine: fragment.kind === 'para' || fragment.kind === 'list-item' ? fragment.fromLine : undefined, + toLine: fragment.kind === 'para' || fragment.kind === 'list-item' ? fragment.toLine : undefined, + fromRow: fragment.kind === 'table' ? fragment.fromRow : undefined, + toRow: fragment.kind === 'table' ? fragment.toRow : undefined, + itemId: fragment.kind === 'list-item' ? fragment.itemId : undefined, + x: fragment.kind === 'image' || fragment.kind === 'drawing' ? fragment.x : undefined, + y: fragment.kind === 'image' || fragment.kind === 'drawing' ? fragment.y : undefined, + partialRow: fragment.kind === 'table' ? fragment.partialRow : undefined, + sourceAnchor: fragment.sourceAnchor ?? existing?.sourceAnchor, + }); +}; diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index fb564a57e3..08fe933979 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -18,6 +18,7 @@ import type { TableBlock, TableMeasure, } from './index.js'; +import type { LayoutSourceIdentity } from './layout-identity.js'; /** A fully resolved layout ready for the next-generation paint pipeline. */ export type ResolvedLayout = { @@ -162,6 +163,12 @@ export type ResolvedFragmentItem = { measure?: ParagraphMeasure | ListMeasure; /** Optional DOCX source evidence preserved for intelligence adapters and paint snapshots. */ sourceAnchor?: SourceAnchor; + /** + * Optional editor-neutral identity (prep-001). Mirrors the field on the + * underlying `Fragment`; carried through resolve so the painter can stamp + * neutral `data-layout-*` datasets without re-deriving from PM positions. + */ + layoutSourceIdentity?: LayoutSourceIdentity; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ @@ -295,6 +302,12 @@ export type ResolvedTableItem = { paintCacheVersion?: string; /** Optional DOCX source evidence preserved for intelligence adapters and paint snapshots. */ sourceAnchor?: SourceAnchor; + /** + * Optional editor-neutral identity (prep-001). Mirrors the field on the + * underlying `Fragment`; carried through resolve so the painter can stamp + * neutral `data-layout-*` datasets without re-deriving from PM positions. + */ + layoutSourceIdentity?: LayoutSourceIdentity; }; /** @@ -350,6 +363,12 @@ export type ResolvedImageItem = { paintCacheVersion?: string; /** Optional DOCX source evidence preserved for intelligence adapters and paint snapshots. */ sourceAnchor?: SourceAnchor; + /** + * Optional editor-neutral identity (prep-001). Mirrors the field on the + * underlying `Fragment`; carried through resolve so the painter can stamp + * neutral `data-layout-*` datasets without re-deriving from PM positions. + */ + layoutSourceIdentity?: LayoutSourceIdentity; }; /** @@ -403,6 +422,12 @@ export type ResolvedDrawingItem = { paintCacheVersion?: string; /** Optional DOCX source evidence preserved for intelligence adapters and paint snapshots. */ sourceAnchor?: SourceAnchor; + /** + * Optional editor-neutral identity (prep-001). Mirrors the field on the + * underlying `Fragment`; carried through resolve so the painter can stamp + * neutral `data-layout-*` datasets without re-deriving from PM positions. + */ + layoutSourceIdentity?: LayoutSourceIdentity; }; /** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */ diff --git a/packages/layout-engine/dom-contract/src/data-attrs.ts b/packages/layout-engine/dom-contract/src/data-attrs.ts index 819545aa80..facc7f9490 100644 --- a/packages/layout-engine/dom-contract/src/data-attrs.ts +++ b/packages/layout-engine/dom-contract/src/data-attrs.ts @@ -10,6 +10,11 @@ * * The `DATASET_KEYS` mirror provides the camelCase equivalents used with * `element.dataset.*` access. + * + * Editor-neutral (prep-001) attributes live alongside the legacy `data-pm-*` + * attributes; both surfaces are emitted so that v1 consumers continue to work + * unmodified while future editor-neutral consumers (hit-test / range mapping) + * can address rendered output without consulting ProseMirror positions. */ /** @@ -51,6 +56,24 @@ export const DATA_ATTRS = { /** Element type discriminator (annotation variant, etc.). */ TYPE: 'data-type', + + // --- Editor-neutral layout boundary (prep-001) --- + // Additive only — `pmStart`/`pmEnd` and `data-pm-*` remain the + // authoritative click-to-position surface for v1. These attributes give + // future editor-neutral consumers a way to address rendered output without + // consulting ProseMirror positions. + + /** Schema version for the editor-neutral layout boundary attributes. */ + LAYOUT_BOUNDARY_SCHEMA: 'data-layout-boundary-schema', + + /** Stable opaque id of the rendered fragment (see `LayoutFragmentId`). */ + LAYOUT_FRAGMENT_ID: 'data-layout-fragment-id', + + /** Encoded story locator (e.g. `body`, `header:rId4`, `footer:rId7`). */ + LAYOUT_STORY: 'data-layout-story', + + /** Source block reference (today: producer's `blockId`). */ + LAYOUT_BLOCK_REF: 'data-layout-block-ref', } as const; /** @@ -71,4 +94,52 @@ export const DATASET_KEYS = { DISPLAY_LABEL: 'displayLabel', VARIANT: 'variant', TYPE: 'type', + + // Editor-neutral layout boundary (prep-001). + LAYOUT_BOUNDARY_SCHEMA: 'layoutBoundarySchema', + LAYOUT_FRAGMENT_ID: 'layoutFragmentId', + LAYOUT_STORY: 'layoutStory', + LAYOUT_BLOCK_REF: 'layoutBlockRef', } as const; + +/** + * Encode a `LayoutStoryLocator`-shaped object into its dataset string form. + * + * Format is `"body"` for body content and `":"` otherwise. The id + * is passed through verbatim (no escaping is applied) because the producers + * pass opaque part-relationship ids that do not contain the colon delimiter. + * + * Kept here so emitters and readers agree on the wire shape without taking a + * dependency on the contracts package from dom-contract. + */ +export const encodeLayoutStoryDataset = (story: { + kind: 'body' | 'header' | 'footer' | 'footnote' | 'endnote' | 'unknown'; + id?: string; +}): string => (story.kind === 'body' ? 'body' : story.id ? `${story.kind}:${story.id}` : story.kind); + +/** + * Decode the dataset string back into a `LayoutStoryLocator`-shaped object. + * + * Used by editor-side DOM observers. Unknown kinds fall back to + * `{ kind: 'unknown' }` so downstream code can treat the value as a + * diagnostic, not as a default body. + */ +export const decodeLayoutStoryDataset = ( + raw: string | undefined | null, +): { kind: 'body' | 'header' | 'footer' | 'footnote' | 'endnote' | 'unknown'; id?: string } => { + if (!raw) return { kind: 'unknown' }; + if (raw === 'body') return { kind: 'body' }; + const idx = raw.indexOf(':'); + const kind = idx === -1 ? raw : raw.slice(0, idx); + const id = idx === -1 ? undefined : raw.slice(idx + 1); + switch (kind) { + case 'body': + case 'header': + case 'footer': + case 'footnote': + case 'endnote': + return id ? { kind, id } : { kind }; + default: + return { kind: 'unknown' }; + } +}; diff --git a/packages/layout-engine/dom-contract/src/index.test.ts b/packages/layout-engine/dom-contract/src/index.test.ts index 5cab9112d5..2b95e56e38 100644 --- a/packages/layout-engine/dom-contract/src/index.test.ts +++ b/packages/layout-engine/dom-contract/src/index.test.ts @@ -13,6 +13,8 @@ import { buildAnnotationPmSelector, SDT_BLOCK_WITH_ID_SELECTOR, DRAGGABLE_SELECTOR, + encodeLayoutStoryDataset, + decodeLayoutStoryDataset, } from './index.js'; describe('@superdoc/dom-contract', () => { @@ -50,6 +52,10 @@ describe('@superdoc/dom-contract', () => { DISPLAY_LABEL: 'data-display-label', VARIANT: 'data-variant', TYPE: 'data-type', + LAYOUT_BOUNDARY_SCHEMA: 'data-layout-boundary-schema', + LAYOUT_FRAGMENT_ID: 'data-layout-fragment-id', + LAYOUT_STORY: 'data-layout-story', + LAYOUT_BLOCK_REF: 'data-layout-block-ref', }); expect(DATASET_KEYS).toEqual({ @@ -65,9 +71,25 @@ describe('@superdoc/dom-contract', () => { DISPLAY_LABEL: 'displayLabel', VARIANT: 'variant', TYPE: 'type', + LAYOUT_BOUNDARY_SCHEMA: 'layoutBoundarySchema', + LAYOUT_FRAGMENT_ID: 'layoutFragmentId', + LAYOUT_STORY: 'layoutStory', + LAYOUT_BLOCK_REF: 'layoutBlockRef', }); }); + it('encodes and decodes the editor-neutral story locator dataset', () => { + expect(encodeLayoutStoryDataset({ kind: 'body' })).toBe('body'); + expect(encodeLayoutStoryDataset({ kind: 'header', id: 'rId4' })).toBe('header:rId4'); + expect(encodeLayoutStoryDataset({ kind: 'footer' })).toBe('footer'); + + expect(decodeLayoutStoryDataset('body')).toEqual({ kind: 'body' }); + expect(decodeLayoutStoryDataset('header:rId4')).toEqual({ kind: 'header', id: 'rId4' }); + expect(decodeLayoutStoryDataset('footnote:1')).toEqual({ kind: 'footnote', id: '1' }); + expect(decodeLayoutStoryDataset(undefined)).toEqual({ kind: 'unknown' }); + expect(decodeLayoutStoryDataset('garbage:xyz')).toEqual({ kind: 'unknown' }); + }); + it('builds the full image selector for a rendered pm-start value', () => { expect(buildImagePmSelector(42)).toBe( '.superdoc-image-fragment[data-pm-start="42"], .superdoc-inline-image-clip-wrapper[data-pm-start="42"], .superdoc-inline-image[data-pm-start="42"]', diff --git a/packages/layout-engine/dom-contract/src/index.ts b/packages/layout-engine/dom-contract/src/index.ts index 07606917be..0ae8cd80b1 100644 --- a/packages/layout-engine/dom-contract/src/index.ts +++ b/packages/layout-engine/dom-contract/src/index.ts @@ -16,7 +16,7 @@ export { DOM_CLASS_NAMES } from './class-names.js'; export type { DomClassName } from './class-names.js'; -export { DATA_ATTRS, DATASET_KEYS } from './data-attrs.js'; +export { DATA_ATTRS, DATASET_KEYS, encodeLayoutStoryDataset, decodeLayoutStoryDataset } from './data-attrs.js'; export { buildImagePmSelector, diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index a1820b6e9b..08e2e752b3 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -162,6 +162,24 @@ export { getRunBooleanProp, } from './paragraph-hash-utils'; +// Editor-neutral hit-test substrate (prep-001). +// +// Additive only — `pmStart`/`pmEnd` and the existing `clickToPosition` / +// `selectionToRects` entry points remain available for v1 callers. The +// neutral entry points project the same producer state onto an +// editor-neutral shape so future surfaces can address rendered output +// without consulting ProseMirror positions. +export type { + LayoutHit, + LayoutHitDiagnostic, + LayoutFragmentOpaqueRange, + LayoutFragmentSubrange, + LayoutRangeMapping, + LayoutRect, + PmOpaqueRange, +} from './neutral-hit.js'; +export { hitTestNeutral, mapRangeToFragmentsNeutral } from './neutral-hit.js'; + // Position-hit types and helpers (re-exported from position-hit.ts) export type { Point, diff --git a/packages/layout-engine/layout-bridge/src/neutral-hit.ts b/packages/layout-engine/layout-bridge/src/neutral-hit.ts new file mode 100644 index 0000000000..6354147357 --- /dev/null +++ b/packages/layout-engine/layout-bridge/src/neutral-hit.ts @@ -0,0 +1,495 @@ +/** + * Editor-neutral hit-test and range-mapping substrate (prep-001). + * + * Wraps the existing PM-shaped hit-test / selection-rect functions in a + * neutral surface that does not require `pmStart` / `pmEnd` in its result + * types. Today the implementation is a thin adapter over `clickToPosition` + * and `selectionToRects` — that preserves current v1 behavior — but the + * shape of the result is owned by the layout boundary, not by ProseMirror. + * + * Hard rules from `prep-001-layout-boundary-and-identity.md`: + * + * - `pmStart` / `pmEnd` may remain as legacy/diagnostic fields, but the + * neutral types here MUST NOT require them. + * - No Phase 3/v2 package may be imported from this module. + * - This module is additive; nothing here may be load-bearing for v1. + */ + +import type { + FlowBlock, + Fragment, + Layout, + LayoutBlockRef, + LayoutFragmentId, + LayoutSourceIdentity, + LayoutStoryLocator, + Measure, + SourceAnchor, +} from '@superdoc/contracts'; +import { + LAYOUT_BOUNDARY_SCHEMA, + bodyStoryLocator, + buildLayoutSourceIdentity, + buildLayoutSourceIdentityForFragment, +} from '@superdoc/contracts'; +import { + calculatePageTopFallback, + clickToPositionGeometry, + type ClickToPositionGeometryOptions, + findBlockIndexByFragmentId, + hitTestAtomicFragment, + hitTestFragment, + hitTestPage, + hitTestTableFragment, + snapToNearestFragment, + type PositionHit, + type Point, +} from './position-hit.js'; + +/** Container-space rectangle (mirrors `Rect` from this package's `index.ts`). */ +export type LayoutRect = { + x: number; + y: number; + width: number; + height: number; + pageIndex: number; +}; + +// --------------------------------------------------------------------------- +// Public neutral types +// --------------------------------------------------------------------------- + +/** + * Layout-side representation of a hit-tested point. + * + * Carries everything an editor-neutral consumer needs to address the + * rendered fragment under a click. Legacy PM fields (`pmPosition`, + * `layoutEpoch`, `lineIndex`, `column`) are surfaced under `legacyPm` for + * v1 callers that still need them. + */ +export type LayoutHit = { + /** Schema version for this hit-test result. */ + schema: typeof LAYOUT_BOUNDARY_SCHEMA; + /** Layout revision (today: `layout.layoutEpoch`). */ + layoutRevision: number; + /** Composite editor-neutral identity for the hit fragment. */ + identity: LayoutSourceIdentity; + /** Story locator for the hit fragment. */ + story: LayoutStoryLocator; + /** Source block reference (today: producer's `blockId`). */ + blockRef: LayoutBlockRef; + /** Stable opaque fragment id. */ + fragmentId: LayoutFragmentId; + /** 0-based page index containing the hit. */ + pageIndex: number; + /** Optional cross-reference to the DOCX source anchor. */ + sourceAnchor?: SourceAnchor; + /** Diagnostics for partially supported or rejected hits. */ + diagnostics?: LayoutHitDiagnostic[]; + /** + * Legacy PM-shaped position hit. + * + * AIDEV-NOTE: compat-fallback - v1 callers (PresentationEditor, + * super-editor selection) still consume `pmPosition`. Retire once the v2 + * provider stops relying on PM positions to map this hit back to a v2 + * ref. Do not gate new editor-neutral behavior on this field. + */ + legacyPm?: PositionHit; +}; + +export type LayoutHitDiagnostic = + | { code: 'no-page-hit' } + | { code: 'no-fragment-hit' } + | { code: 'pm-position-unavailable' } + | { code: 'unsupported-fragment-kind'; fragmentKind: string }; + +/** + * Subrange of a single fragment, in editor-neutral terms. + * + * `inlineFromOpaque` / `inlineToOpaque` are placeholders for the offsets a + * future neutral text-offset model will carry. They are intentionally + * optional because the current producer can only describe character offsets + * via PM positions, and PM-required fields are not allowed on this contract. + * v1 consumers that need pixel rects should use `selectionToRects` directly; + * they should not gate behavior on this neutral surface yet. + */ +export type LayoutFragmentSubrange = { + identity: LayoutSourceIdentity; + story: LayoutStoryLocator; + blockRef: LayoutBlockRef; + fragmentId: LayoutFragmentId; + pageIndex: number; + /** Container-space rectangle covered by the fragment slice. */ + rect: LayoutRect; + /** Optional opaque inline-offset start (may be omitted). */ + inlineFromOpaque?: string; + /** Optional opaque inline-offset end (may be omitted). */ + inlineToOpaque?: string; +}; + +/** + * PM-free rendered range for the current neutral subset. + * + * This maps one or more already-known rendered fragment ids to their full + * fragment rectangles. It intentionally does not model text offsets; v1 PM + * range slicing remains available through `PmOpaqueRange`. + */ +export type LayoutFragmentOpaqueRange = { + fragmentIds: readonly LayoutFragmentId[]; +}; + +/** + * Result of mapping an opaque source range to rendered fragments. + * + * The opaque range type is intentionally `unknown` here; today the only + * concrete instantiation is `{ pmFrom: number; pmTo: number }`, but the + * contract names neither, so a future v2 source range can substitute + * without reopening the layout boundary. + */ +export type LayoutRangeMapping = { + schema: typeof LAYOUT_BOUNDARY_SCHEMA; + layoutRevision: number; + fragments: LayoutFragmentSubrange[]; + diagnostics?: LayoutHitDiagnostic[]; +}; + +/** + * Concrete opaque-range instantiation for v1 consumers using PM positions. + * + * Kept here (and not in `@superdoc/contracts`) because nothing about it is + * editor-neutral. v2 consumers should not import this type; they should + * define their own opaque-range shape and the same `mapRangeToFragmentsNeutral` + * entry point will accept it (via the function overload defined below). + */ +export type PmOpaqueRange = { pmFrom: number; pmTo: number }; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const buildIdentityForFragment = (fragment: Fragment, story: LayoutStoryLocator): LayoutSourceIdentity => { + return buildLayoutSourceIdentityForFragment(fragment, story); +}; + +const resolveBodyStoryForHit = (): LayoutStoryLocator => bodyStoryLocator(); + +const resolvePageHitForPoint = (layout: Layout, containerPoint: Point, options?: ClickToPositionGeometryOptions) => { + const pageHint = options?.pageHint; + if (pageHint != null && pageHint.pageIndex >= 0 && pageHint.pageIndex < layout.pages.length) { + return { pageIndex: pageHint.pageIndex, page: layout.pages[pageHint.pageIndex] }; + } + return hitTestPage(layout, containerPoint, options?.geometryHelper); +}; + +const resolvePageRelativePoint = ( + layout: Layout, + pageIndex: number, + containerPoint: Point, + options?: ClickToPositionGeometryOptions, +): Point => { + const pageTopY = options?.geometryHelper + ? options.geometryHelper.getPageTop(pageIndex) + : calculatePageTopFallback(layout, pageIndex); + return { + x: containerPoint.x, + y: options?.pageHint?.pageRelativeY ?? containerPoint.y - pageTopY, + }; +}; + +const isWithinTableFragment = (fragment: Fragment, point: Point): boolean => { + if (fragment.kind !== 'table') return false; + return ( + point.x >= fragment.x && + point.x <= fragment.x + fragment.width && + point.y >= fragment.y && + point.y <= fragment.y + fragment.height + ); +}; + +const findNeutralHitFragment = ( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + pageHit: { pageIndex: number; page: Layout['pages'][number] }, + pageRelativePoint: Point, +): Fragment | undefined => { + const textHit = hitTestFragment(layout, pageHit, blocks, measures, pageRelativePoint); + if (textHit) return textHit.fragment; + + const tableHit = hitTestTableFragment(pageHit, blocks, measures, pageRelativePoint); + if (tableHit) return tableHit.fragment; + + const atomicHit = hitTestAtomicFragment(pageHit, blocks, measures, pageRelativePoint); + if (atomicHit) return atomicHit.fragment; + + if (!pageHit.page.fragments.some((fragment) => isWithinTableFragment(fragment, pageRelativePoint))) { + const snapped = snapToNearestFragment(pageHit, blocks, measures, pageRelativePoint); + if (snapped) return snapped.fragment; + } + + return undefined; +}; + +const isPmOpaqueRange = (range: PmOpaqueRange | LayoutFragmentOpaqueRange): range is PmOpaqueRange => + typeof (range as PmOpaqueRange).pmFrom === 'number' && typeof (range as PmOpaqueRange).pmTo === 'number'; + +const pageTopForRectMapping = (layout: Layout, pageIndex: number): number => + calculatePageTopFallback(layout, pageIndex); + +const sumLineHeights = (lines: { lineHeight: number }[] | undefined, fromLine: number, toLine: number): number => { + if (!lines) return 0; + let height = 0; + for (let i = fromLine; i < toLine && i < lines.length; i += 1) { + height += lines[i]?.lineHeight ?? 0; + } + return height; +}; + +const fragmentHeight = (fragment: Fragment, blocks: FlowBlock[], measures: Measure[]): number => { + if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') return fragment.height; + const blockIndex = findBlockIndexByFragmentId(blocks, fragment.blockId); + if (blockIndex === -1) return 0; + const measure = measures[blockIndex]; + if (fragment.kind === 'para' && measure?.kind === 'paragraph') { + return sumLineHeights(measure.lines, fragment.fromLine, fragment.toLine); + } + if (fragment.kind === 'list-item' && measure?.kind === 'list') { + const item = measure.items.find((candidate) => candidate.itemId === fragment.itemId); + return sumLineHeights(item?.paragraph.lines, fragment.fromLine, fragment.toLine); + } + return 0; +}; + +const fragmentRect = ( + layout: Layout, + fragment: Fragment, + pageIndex: number, + blocks: FlowBlock[], + measures: Measure[], +): LayoutRect => ({ + x: fragment.x, + y: fragment.y + pageTopForRectMapping(layout, pageIndex), + width: fragment.width, + height: fragmentHeight(fragment, blocks, measures), + pageIndex, +}); + +const rectOverlapArea = (a: LayoutRect, b: LayoutRect): number => { + const left = Math.max(a.x, b.x); + const right = Math.min(a.x + a.width, b.x + b.width); + const top = Math.max(a.y, b.y); + const bottom = Math.min(a.y + a.height, b.y + b.height); + return Math.max(0, right - left) * Math.max(0, bottom - top); +}; + +const findFragmentForRect = ( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + rect: LayoutRect, +): Fragment | undefined => { + const page = layout.pages[rect.pageIndex]; + if (!page) return undefined; + let best: { fragment: Fragment; overlap: number } | undefined; + for (const fragment of page.fragments) { + const candidateRect = fragmentRect(layout, fragment, rect.pageIndex, blocks, measures); + const overlap = rectOverlapArea(candidateRect, rect); + if (overlap > 0 && (!best || overlap > best.overlap)) { + best = { fragment, overlap }; + } + } + return best?.fragment; +}; + +const findFragmentsByNeutralRange = ( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: LayoutFragmentOpaqueRange, + story: LayoutStoryLocator, +): LayoutFragmentSubrange[] => { + const wanted = new Set(range.fragmentIds); + const fragments: LayoutFragmentSubrange[] = []; + layout.pages.forEach((page, pageIndex) => { + for (const fragment of page.fragments) { + const identity = buildIdentityForFragment(fragment, story); + if (!wanted.has(identity.fragmentId)) continue; + fragments.push({ + identity, + story, + blockRef: identity.blockRef, + fragmentId: identity.fragmentId, + pageIndex, + rect: fragmentRect(layout, fragment, pageIndex, blocks, measures), + }); + } + }); + return fragments; +}; + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * Hit-test a container-space coordinate and return an editor-neutral + * `LayoutHit`. + * + * Today the implementation derives the underlying mapping by calling + * `clickToPositionGeometry` and projecting the result onto the neutral + * shape. The `legacyPm` field is populated so v1 callers continue to work. + * + * Returns `null` only when no page can be hit at all; partial hits emit + * diagnostics rather than failing closed so consumers can distinguish + * "outside content" from "fragment unrecognized". + */ +export function hitTestNeutral( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + containerPoint: Point, + options?: ClickToPositionGeometryOptions, +): LayoutHit | null { + const layoutRevision = layout.layoutEpoch ?? 0; + const story = resolveBodyStoryForHit(); + const pageHit = resolvePageHitForPoint(layout, containerPoint, options); + if (!pageHit) { + return null; + } + + const pageRelativePoint = resolvePageRelativePoint(layout, pageHit.pageIndex, containerPoint, options); + const fragment = findNeutralHitFragment(layout, blocks, measures, pageHit, pageRelativePoint); + const legacy = clickToPositionGeometry(layout, blocks, measures, containerPoint, options); + + if (!fragment) { + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + story, + blockRef: legacy?.blockId ?? '', + fragmentId: legacy?.blockId ?? '', + identity: buildLayoutSourceIdentity({ blockId: legacy?.blockId ?? '', story, kind: 'unknown' }), + pageIndex: pageHit.pageIndex, + diagnostics: [{ code: 'no-fragment-hit' }], + legacyPm: legacy ?? undefined, + }; + } + + const identity = buildIdentityForFragment(fragment, story); + const diagnostics = legacy ? undefined : [{ code: 'pm-position-unavailable' } satisfies LayoutHitDiagnostic]; + + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + story, + blockRef: identity.blockRef, + fragmentId: identity.fragmentId, + identity, + pageIndex: pageHit.pageIndex, + sourceAnchor: identity.sourceAnchor, + diagnostics, + legacyPm: legacy ?? undefined, + }; +} + +/** + * Project rendered selection rectangles into editor-neutral fragment + * subranges. + * + * Accepts a `PmOpaqueRange` today; future neutral range shapes can be + * accepted via overloads without reopening this contract. + * + * The `selectionToRects` produces one `Rect` per visual line; this helper + * groups them by page and surfaces the underlying fragment identity for + * each rect. v1 callers that need raw pixel rects should continue to call + * `selectionToRects` directly — this is the editor-neutral adapter. + */ +export function mapRangeToFragmentsNeutral( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: PmOpaqueRange, + selectionToRectsFn: ( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + from: number, + to: number, + ) => LayoutRect[], +): LayoutRangeMapping; +export function mapRangeToFragmentsNeutral( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: LayoutFragmentOpaqueRange, +): LayoutRangeMapping; +export function mapRangeToFragmentsNeutral( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: PmOpaqueRange | LayoutFragmentOpaqueRange, + selectionToRectsFn?: ( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + from: number, + to: number, + ) => LayoutRect[], +): LayoutRangeMapping { + const layoutRevision = layout.layoutEpoch ?? 0; + const story = resolveBodyStoryForHit(); + + if (!isPmOpaqueRange(range)) { + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + fragments: findFragmentsByNeutralRange(layout, blocks, measures, range, story), + }; + } + + if (range.pmFrom === range.pmTo) { + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + fragments: [], + }; + } + + if (!selectionToRectsFn) { + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + fragments: [], + diagnostics: [{ code: 'pm-position-unavailable' }], + }; + } + + const rects = selectionToRectsFn(layout, blocks, measures, range.pmFrom, range.pmTo); + const fragments: LayoutFragmentSubrange[] = []; + const diagnostics: LayoutHitDiagnostic[] = []; + + for (const rect of rects) { + const fragment = findFragmentForRect(layout, blocks, measures, rect); + if (!fragment) { + diagnostics.push({ code: 'no-fragment-hit' }); + continue; + } + + const identity = buildIdentityForFragment(fragment, story); + fragments.push({ + identity, + story, + blockRef: identity.blockRef, + fragmentId: identity.fragmentId, + pageIndex: rect.pageIndex, + rect, + }); + } + + return { + schema: LAYOUT_BOUNDARY_SCHEMA, + layoutRevision, + fragments, + diagnostics: diagnostics.length > 0 ? diagnostics : undefined, + }; +} diff --git a/packages/layout-engine/layout-bridge/test/neutral-hit.test.ts b/packages/layout-engine/layout-bridge/test/neutral-hit.test.ts new file mode 100644 index 0000000000..992d975584 --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/neutral-hit.test.ts @@ -0,0 +1,255 @@ +/** + * Editor-neutral hit-test and range-mapping substrate (prep-001). + * + * Verifies that: + * - `hitTestNeutral` returns a `LayoutHit` with a stable, opaque + * `fragmentId`, `blockRef`, and `LAYOUT_BOUNDARY_SCHEMA`, even when the + * target fragment does not carry `pmStart`/`pmEnd`. + * - `legacyPm` mirrors the existing PM-shaped `clickToPositionGeometry` + * result so v1 callers can keep working. + * - `mapRangeToFragmentsNeutral` returns neutral subranges that surface the + * same identity the painter would stamp in the DOM. + * - The neutral substrate works on fixtures that omit `pmStart`/`pmEnd` + * entirely. + */ +import { describe, it, expect } from 'vitest'; +import { + LAYOUT_BOUNDARY_SCHEMA, + buildLayoutSourceIdentityForFragment, + type FlowBlock, + type Layout, + type Measure, +} from '@superdoc/contracts'; +import { hitTestNeutral, mapRangeToFragmentsNeutral, selectionToRects, type LayoutRect } from '../src/index.ts'; + +const block: FlowBlock = { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 }, + ], +}; + +const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: 5, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, +}; + +const layout: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 17, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + pmStart: 1, + pmEnd: 12, + }, + ], + }, + ], +}; + +// Same layout, but with PM positions stripped from the paragraph fragment. +// Used to prove the neutral substrate does not require `pmStart`/`pmEnd`. +const layoutNoPm: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 18, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + }, + ], + }, + ], +}; + +const blockNoPm: FlowBlock = { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16 }, + ], +}; + +const secondBlock: FlowBlock = { + kind: 'paragraph', + id: '1-paragraph', + runs: [{ text: 'Second line', fontFamily: 'Arial', fontSize: 16, pmStart: 20, pmEnd: 31 }], +}; + +const secondMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 11, + width: 110, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, +}; + +const stackedLayout: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 19, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + pmStart: 1, + pmEnd: 12, + }, + { + kind: 'para', + blockId: '1-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 90, + width: 300, + pmStart: 20, + pmEnd: 31, + }, + ], + }, + ], +}; + +describe('hitTestNeutral (prep-001)', () => { + it('returns a LayoutHit with neutral identity and revision', () => { + const hit = hitTestNeutral(layout, [block], [measure], { x: 40, y: 60 }); + expect(hit).not.toBeNull(); + expect(hit!.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(hit!.layoutRevision).toBe(17); + expect(hit!.story).toEqual({ kind: 'body' }); + expect(hit!.blockRef).toBe('0-paragraph'); + expect(typeof hit!.fragmentId).toBe('string'); + expect(hit!.fragmentId.length).toBeGreaterThan(0); + expect(hit!.identity.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(hit!.identity.blockRef).toBe('0-paragraph'); + }); + + it('keeps the legacy PM-shaped hit available for v1 callers', () => { + const hit = hitTestNeutral(layout, [block], [measure], { x: 40, y: 60 }); + expect(hit?.legacyPm).toBeDefined(); + expect(hit?.legacyPm?.blockId).toBe('0-paragraph'); + expect(hit?.legacyPm?.pageIndex).toBe(0); + expect(typeof hit?.legacyPm?.pos).toBe('number'); + }); + + it('returns identical fragment identity for two clicks on the same fragment', () => { + const a = hitTestNeutral(layout, [block], [measure], { x: 40, y: 60 }); + const b = hitTestNeutral(layout, [block], [measure], { x: 80, y: 60 }); + expect(a?.fragmentId).toBe(b?.fragmentId); + expect(a?.blockRef).toBe(b?.blockRef); + }); + + it('resolves a fragment without consulting pmStart/pmEnd on the fragment or runs', () => { + const hit = hitTestNeutral(layoutNoPm, [blockNoPm], [measure], { x: 40, y: 60 }); + expect(hit).not.toBeNull(); + expect(hit!.blockRef).toBe('0-paragraph'); + expect(typeof hit!.fragmentId).toBe('string'); + expect(hit!.fragmentId.length).toBeGreaterThan(0); + expect(hit!.legacyPm).toBeUndefined(); + expect(hit!.diagnostics).toContainEqual({ code: 'pm-position-unavailable' }); + }); +}); + +describe('mapRangeToFragmentsNeutral (prep-001)', () => { + it('returns no fragments for an empty PM range', () => { + const mapping = mapRangeToFragmentsNeutral(layout, [block], [measure], { pmFrom: 3, pmTo: 3 }, selectionToRects); + expect(mapping.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(mapping.fragments).toEqual([]); + }); + + it('returns neutral subranges with identity for a non-empty range', () => { + const mapping = mapRangeToFragmentsNeutral(layout, [block], [measure], { pmFrom: 1, pmTo: 7 }, selectionToRects); + expect(mapping.layoutRevision).toBe(17); + expect(mapping.fragments.length).toBeGreaterThan(0); + const first = mapping.fragments[0]; + expect(first.blockRef).toBe('0-paragraph'); + expect(first.story).toEqual({ kind: 'body' }); + expect(first.identity.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + const rect: LayoutRect = first.rect; + expect(rect.pageIndex).toBe(0); + expect(rect.width).toBeGreaterThan(0); + }); + + it('groups multiple rects under the matching fragment identity', () => { + const mapping = mapRangeToFragmentsNeutral(layout, [block], [measure], { pmFrom: 1, pmTo: 12 }, selectionToRects); + for (const slice of mapping.fragments) { + expect(slice.blockRef).toBe('0-paragraph'); + expect(slice.identity.blockRef).toBe('0-paragraph'); + } + }); + + it('maps selection rects to the fragment that vertically overlaps the rect', () => { + const mapping = mapRangeToFragmentsNeutral( + stackedLayout, + [block, secondBlock], + [measure, secondMeasure], + { pmFrom: 20, pmTo: 31 }, + selectionToRects, + ); + expect(mapping.fragments.length).toBeGreaterThan(0); + expect(mapping.fragments.every((slice) => slice.blockRef === '1-paragraph')).toBe(true); + }); + + it('maps known fragment ids without PM ranges or pmStart/pmEnd fields', () => { + const fragment = layoutNoPm.pages[0].fragments[0]; + const identity = buildLayoutSourceIdentityForFragment(fragment); + const mapping = mapRangeToFragmentsNeutral(layoutNoPm, [blockNoPm], [measure], { + fragmentIds: [identity.fragmentId], + }); + expect(mapping.layoutRevision).toBe(18); + expect(mapping.fragments).toHaveLength(1); + expect(mapping.fragments[0].fragmentId).toBe(identity.fragmentId); + expect(mapping.fragments[0].rect.pageIndex).toBe(0); + expect(mapping.fragments[0].rect.height).toBe(20); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index c6d5e91903..2d4d1d3c62 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -29,7 +29,7 @@ import type { FlowMode, NormalizedColumnLayout, } from '@superdoc/contracts'; -import { normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; +import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -3159,6 +3159,16 @@ export function layoutHeaderFooter( normalizeFragmentsForRegion(layout.pages, blocks, measures, kind, constraints); } + const story = kind ? { kind } : undefined; + if (story) { + for (const page of layout.pages) { + page.fragments = page.fragments.map((fragment) => ({ + ...fragment, + layoutSourceIdentity: buildLayoutSourceIdentityForFragment(fragment, story), + })); + } + } + // Compute bounds using an index map to avoid building multiple Maps const idToIndex = new Map(); for (let i = 0; i < blocks.length; i += 1) { diff --git a/packages/layout-engine/layout-resolved/src/index.ts b/packages/layout-engine/layout-resolved/src/index.ts index c504917f6f..4434b7c7f5 100644 --- a/packages/layout-engine/layout-resolved/src/index.ts +++ b/packages/layout-engine/layout-resolved/src/index.ts @@ -1,3 +1,9 @@ export { resolveLayout } from './resolveLayout.js'; export type { ResolveLayoutInput } from './resolveLayout.js'; export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; +export { + deriveBlockVersion, + fragmentSignature, + sourceAnchorSignature, + resolveFragmentLayoutIdentity, +} from './versionSignature.js'; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts index 7862da9026..27629ad7d4 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; +import { namedStoryLocator } from '@superdoc/contracts'; import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; describe('resolveHeaderFooterLayout', () => { @@ -34,6 +35,36 @@ describe('resolveHeaderFooterLayout', () => { expect(item.measure?.kind).toBe('paragraph'); }); + it('stamps resolved fragment identities with the supplied header/footer story', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'header-p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'header-p1', runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + + const result = resolveHeaderFooterLayout(layout, blocks, measures, namedStoryLocator('header', 'rIdHeader1')); + const item = result.pages[0].items[0] as ResolvedFragmentItem; + + expect(item.layoutSourceIdentity?.story).toEqual({ kind: 'header', id: 'rIdHeader1' }); + expect(item.layoutSourceIdentity?.fragmentId).toContain('header:rIdHeader1'); + }); + it('preserves height, minY, maxY, renderHeight from input', () => { const layout: HeaderFooterLayout = { height: 100, diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 9988a337c3..944c0b138a 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -4,6 +4,7 @@ import type { Measure, ResolvedHeaderFooterLayout, ResolvedHeaderFooterPage, + LayoutStoryLocator, } from '@superdoc/contracts'; import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js'; @@ -18,6 +19,7 @@ export function resolveHeaderFooterLayout( layout: HeaderFooterLayout, blocks: FlowBlock[], measures: Measure[], + story?: LayoutStoryLocator, ): ResolvedHeaderFooterLayout { const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => { const pageBlocks = page.blocks ?? blocks; @@ -29,7 +31,7 @@ export function resolveHeaderFooterLayout( number: page.number, numberText: page.numberText, items: page.fragments.map((fragment, fragmentIndex) => - resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache), + resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache, story), ), }; }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index dc8f46a19e..d817fd0d5f 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -106,6 +106,31 @@ describe('resolveLayout', () => { expect(a).toEqual(b); }); + it('derives neutral layout identity for resolved fragments even when input fragments do not precompute it', () => { + const layout: Layout = { + pageSize: { w: 800, h: 1000 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 2, x: 72, y: 0, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'visible', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [ + { kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }], totalHeight: 40 } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0]; + + expect(item?.kind).toBe('fragment'); + expect(item?.layoutSourceIdentity?.blockRef).toBe('p1'); + expect(item?.layoutSourceIdentity?.fragmentId).toContain('para:0:2'); + }); + it('includes precomputed block versions for every supplied block', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 28f63bb75b..900d38ae13 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -20,6 +20,7 @@ import type { ListBlock, ParagraphBlock, ParagraphMeasure, + LayoutStoryLocator, } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; import { resolveTableItem } from './resolveTable.js'; @@ -28,7 +29,12 @@ import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; -import { deriveBlockVersion, fragmentSignature, sourceAnchorSignature } from './versionSignature.js'; +import { + deriveBlockVersion, + fragmentSignature, + resolveFragmentLayoutIdentity, + sourceAnchorSignature, +} from './versionSignature.js'; export type ResolveLayoutInput = { layout: Layout; @@ -207,10 +213,12 @@ export function resolveFragmentItem( pageIndex: number, blockMap: Map, blockVersionCache: Map, + story?: LayoutStoryLocator, ): ResolvedPaintItem { const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache); const version = fragmentSignature(fragment, blockVer); + const layoutSourceIdentity = resolveFragmentLayoutIdentity(fragment, story); // Route to kind-specific resolvers for types that carry extracted block/measure data. switch (fragment.kind) { @@ -218,6 +226,7 @@ export function resolveFragmentItem( const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; + item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions(item, version); return item; } @@ -225,6 +234,7 @@ export function resolveFragmentItem( const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; + item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions(item, version); return item; } @@ -232,6 +242,7 @@ export function resolveFragmentItem( const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; + item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions(item, version); return item; } @@ -251,6 +262,7 @@ export function resolveFragmentItem( blockId: fragment.blockId, fragmentIndex, content: resolveParagraphContentIfApplicable(fragment, blockMap), + layoutSourceIdentity, }; if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 911d7c984d..7d1f223147 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -1,4 +1,5 @@ import { + buildLayoutSourceIdentityForFragment, getParagraphInlineDirection, type DrawingBlock, type FieldAnnotationRun, @@ -7,6 +8,8 @@ import { type ImageBlock, type ImageDrawing, type ImageRun, + type LayoutSourceIdentity, + type LayoutStoryLocator, type ParagraphAttrs, type ParagraphBlock, type SdtMetadata, @@ -177,6 +180,19 @@ const stableSerializeEvidenceValue = (value: unknown): string => { export const sourceAnchorSignature = (sourceAnchor: SourceAnchor | undefined): string => sourceAnchor ? stableSerializeEvidenceValue(sourceAnchor) : ''; +/** + * Resolve the editor-neutral identity for a fragment (prep-001). + * + * Prefers `fragment.layoutSourceIdentity` when present; otherwise constructs + * one from the producer's existing fields (`blockId`, `kind`, fragment-local + * line/row indices, optional `sourceAnchor`). Pure helper — does not mutate + * the fragment, and remains safe to call for v1 layouts that never populate + * `layoutSourceIdentity` upstream. + */ +export const resolveFragmentLayoutIdentity = (fragment: Fragment, story?: LayoutStoryLocator): LayoutSourceIdentity => { + return buildLayoutSourceIdentityForFragment(fragment, story); +}; + // --------------------------------------------------------------------------- // deriveBlockVersion // --------------------------------------------------------------------------- diff --git a/packages/layout-engine/painters/dom/src/renderer-neutral-identity.test.ts b/packages/layout-engine/painters/dom/src/renderer-neutral-identity.test.ts new file mode 100644 index 0000000000..9a30adab32 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-neutral-identity.test.ts @@ -0,0 +1,177 @@ +/** + * Painter-side coverage for the editor-neutral layout identity dataset + * (prep-001). + * + * Asserts that the painter stamps: + * - `data-layout-boundary-schema` on each page + * - `data-layout-fragment-id`, `data-layout-block-ref`, `data-layout-story` + * on each rendered fragment wrapper + * + * And that the legacy `data-pm-start` / `data-pm-end` / `data-block-id` + * attributes remain available alongside the new ones (additive only). + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + LAYOUT_BOUNDARY_SCHEMA, + type FlowBlock, + type HeaderFooterLayout, + type Layout, + type Measure, + type ResolvedLayout, +} from '@superdoc/contracts'; +import { resolveHeaderFooterLayout, resolveLayout } from '@superdoc/layout-resolved'; +import { createDomPainter, type DomPainterInput, type PaintSnapshot } from './index.js'; + +const block: FlowBlock = { + kind: 'paragraph', + id: 'block-1', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 }, + ], +}; + +const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: 5, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, +}; + +const layout: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 42, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-1', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + pmStart: 1, + pmEnd: 12, + }, + ], + }, + ], +}; + +const paintLayout = (mount: HTMLElement, layoutInput: Layout = layout): void => { + const resolved: ResolvedLayout = resolveLayout({ + layout: layoutInput, + flowMode: 'paginated', + blocks: [block], + measures: [measure], + }); + const painter = createDomPainter({}); + const input: DomPainterInput = { resolvedLayout: resolved }; + painter.paint(input, mount); +}; + +describe('DomPainter — editor-neutral layout identity (prep-001)', () => { + let mount: HTMLElement; + + beforeEach(() => { + mount = document.createElement('div'); + }); + + it('stamps the layout boundary schema on each page', () => { + paintLayout(mount); + const page = mount.querySelector('.superdoc-page') as HTMLElement; + expect(page).toBeTruthy(); + expect(page.dataset.layoutBoundarySchema).toBe(LAYOUT_BOUNDARY_SCHEMA); + }); + + it('stamps neutral identity attributes on each rendered fragment', () => { + paintLayout(mount); + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment).toBeTruthy(); + + // Neutral identity (additive). + expect(fragment.dataset.layoutFragmentId).toBeTruthy(); + expect(fragment.dataset.layoutBlockRef).toBe('block-1'); + expect(fragment.dataset.layoutStory).toBe('body'); + + // Legacy PM-shaped surface remains. + expect(fragment.dataset.blockId).toBe('block-1'); + expect(fragment.dataset.pmStart).toBe('1'); + expect(fragment.dataset.pmEnd).toBe('12'); + }); + + it('emits the same fragment id across repaints when the producer state is unchanged', () => { + paintLayout(mount); + const firstId = (mount.querySelector('.superdoc-fragment') as HTMLElement).dataset.layoutFragmentId; + // Repaint into a fresh mount with the same layout and expect a stable id. + const mount2 = document.createElement('div'); + paintLayout(mount2); + const secondId = (mount2.querySelector('.superdoc-fragment') as HTMLElement).dataset.layoutFragmentId; + expect(secondId).toBe(firstId); + }); + + it('carries neutral fragment identity into paint snapshots', () => { + let snapshot: PaintSnapshot | null = null; + const resolved: ResolvedLayout = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [block], + measures: [measure], + }); + const painter = createDomPainter({ + onPaintSnapshot: (nextSnapshot) => { + snapshot = nextSnapshot; + }, + }); + + painter.paint({ resolvedLayout: resolved }, mount); + + expect(snapshot?.pages[0]?.lines[0]?.layoutSourceIdentity?.blockRef).toBe('block-1'); + expect(snapshot?.pages[0]?.lines[0]?.layoutSourceIdentity?.fragmentId).toBe( + (mount.querySelector('.superdoc-fragment') as HTMLElement).dataset.layoutFragmentId, + ); + }); + + it('uses the decoration story when stamping header/footer fragment identity', () => { + const headerLayout: HeaderFooterLayout = { + height: 80, + pages: [{ number: 1, fragments: layout.pages[0].fragments }], + }; + const resolvedHeader = resolveHeaderFooterLayout(headerLayout, [block], [measure]); + const resolvedBody = resolveLayout({ + layout: { pageSize: { w: 400, h: 500 }, pages: [{ number: 1, fragments: [] }] }, + flowMode: 'paginated', + blocks: [], + measures: [], + }); + const painter = createDomPainter({ + headerProvider: () => ({ + fragments: headerLayout.pages[0].fragments, + items: resolvedHeader.pages[0].items, + height: 80, + offset: 0, + headerFooterRefId: 'rIdHeader1', + sectionType: 'default', + }), + }); + + painter.paint({ resolvedLayout: resolvedBody }, mount); + + const headerFragment = mount.querySelector('.superdoc-page-header .superdoc-fragment') as HTMLElement; + expect(headerFragment.dataset.layoutStory).toBe('header:rIdHeader1'); + expect(headerFragment.dataset.layoutFragmentId).toContain('header:rIdHeader1'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 44ed6f6c4a..f89b43b9d1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -54,9 +54,13 @@ import type { ResolvedImageItem, ResolvedDrawingItem, ResolvedListMarkerItem, + LayoutSourceIdentity, + LayoutStoryLocator, } from '@superdoc/contracts'; import { + LAYOUT_BOUNDARY_SCHEMA, adjustAvailableWidthForTextIndent, + buildLayoutSourceIdentityForFragment, calculateJustifySpacing, computeLinePmRange, expandRunsForInlineNewlines, @@ -69,6 +73,7 @@ import { sliceRunsForLine, SPACE_CHARS, } from '@superdoc/contracts'; +import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; @@ -356,6 +361,7 @@ export type FragmentRenderContext = { pageNumber: number; totalPages: number; section: 'body' | 'header' | 'footer'; + story?: LayoutStoryLocator; pageNumberText?: string; pageIndex?: number; }; @@ -405,6 +411,7 @@ export type PaintSnapshotAnnotationEntity = { fieldId?: string; fieldType?: string; type?: string; + layoutSourceIdentity?: LayoutSourceIdentity; }; export type PaintSnapshotStructuredContentBlockEntity = { @@ -413,6 +420,7 @@ export type PaintSnapshotStructuredContentBlockEntity = { sdtId: string; pmStart?: number; pmEnd?: number; + layoutSourceIdentity?: LayoutSourceIdentity; }; export type PaintSnapshotStructuredContentInlineEntity = { @@ -421,6 +429,7 @@ export type PaintSnapshotStructuredContentInlineEntity = { sdtId: string; pmStart?: number; pmEnd?: number; + layoutSourceIdentity?: LayoutSourceIdentity; }; export type PaintSnapshotImageEntity = { @@ -431,6 +440,7 @@ export type PaintSnapshotImageEntity = { pmEnd?: number; blockId?: string; sourceAnchor?: SourceAnchor; + layoutSourceIdentity?: LayoutSourceIdentity; }; export type PaintSnapshotEntities = { @@ -448,6 +458,7 @@ export type PaintSnapshotLine = { markers?: PaintSnapshotMarkerStyle[]; tabs?: PaintSnapshotTabStyle[]; sourceAnchor?: SourceAnchor; + layoutSourceIdentity?: LayoutSourceIdentity; }; export type PaintSnapshotPage = { @@ -536,6 +547,53 @@ function compactSnapshotObject>(input: T): T { return out; } +/** + * Stamp the editor-neutral layout-identity dataset (prep-001). + * + * Additive only — runs alongside the legacy `data-pm-*` / `data-block-id` + * writes in `applyFragmentFrame` and `applyResolvedFragmentFrame`. v1 + * consumers still read PM-shaped datasets; future editor-neutral consumers + * read `data-layout-fragment-id` / `data-layout-story` / `data-layout-block-ref` + * here. + */ +export function applyLayoutIdentityDataset(element: HTMLElement, identity: LayoutSourceIdentity | undefined): void { + if (!identity) { + delete element.dataset[DATASET_KEYS.LAYOUT_FRAGMENT_ID]; + delete element.dataset[DATASET_KEYS.LAYOUT_BLOCK_REF]; + delete element.dataset[DATASET_KEYS.LAYOUT_STORY]; + return; + } + element.dataset[DATASET_KEYS.LAYOUT_FRAGMENT_ID] = identity.fragmentId; + element.dataset[DATASET_KEYS.LAYOUT_BLOCK_REF] = identity.blockRef; + element.dataset[DATASET_KEYS.LAYOUT_STORY] = encodeLayoutStoryDataset(identity.story); +} + +const resolveOrBuildFragmentIdentity = ( + fragment: Fragment, + story?: LayoutStoryLocator, + existing?: LayoutSourceIdentity, +): LayoutSourceIdentity => + buildLayoutSourceIdentityForFragment( + existing + ? { + ...fragment, + layoutSourceIdentity: existing, + sourceAnchor: fragment.sourceAnchor ?? existing.sourceAnchor, + } + : fragment, + story, + ); + +const resolveSectionStory = (section?: 'body' | 'header' | 'footer'): LayoutStoryLocator | undefined => { + if (!section || section === 'body') return undefined; + return { kind: section }; +}; + +const resolveDecorationStory = (kind: 'header' | 'footer', data: PageDecorationPayload): LayoutStoryLocator => { + const id = data.headerFooterRefId ?? data.sectionType; + return typeof id === 'string' && id.length > 0 ? { kind, id } : { kind }; +}; + export function applySourceAnchorDataset(element: HTMLElement, sourceAnchor?: SourceAnchor): void { if (!sourceAnchor) { delete element.dataset.sourceAnchor; @@ -582,6 +640,29 @@ function readNearestSourceAnchor(element: HTMLElement | null | undefined): Sourc ); } +function readLayoutIdentityDataset(element: HTMLElement | null | undefined): LayoutSourceIdentity | undefined { + if (!element) return undefined; + const fragmentId = element.dataset?.[DATASET_KEYS.LAYOUT_FRAGMENT_ID]; + const blockRef = element.dataset?.[DATASET_KEYS.LAYOUT_BLOCK_REF]; + const story = decodeLayoutStoryDataset(element.dataset?.[DATASET_KEYS.LAYOUT_STORY]); + if (!fragmentId || !blockRef || story.kind === 'unknown') return undefined; + return compactSnapshotObject({ + schema: LAYOUT_BOUNDARY_SCHEMA, + story, + blockRef, + fragmentId, + sourceAnchor: readNearestSourceAnchor(element), + }) as LayoutSourceIdentity; +} + +function readNearestLayoutSourceIdentity(element: HTMLElement | null | undefined): LayoutSourceIdentity | undefined { + if (!element) return undefined; + return ( + readLayoutIdentityDataset(element) ?? + readLayoutIdentityDataset(element.closest(`.${CLASS_NAMES.fragment}`) as HTMLElement | null) + ); +} + function shouldIncludeInlineImageSnapshotElement(element: HTMLElement): boolean { if (element.classList.contains(DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER)) { return true; @@ -622,6 +703,7 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap fieldId: element.dataset.fieldId || null, fieldType: element.dataset.fieldType || null, type: element.dataset.type || null, + layoutSourceIdentity: readNearestLayoutSourceIdentity(element), }) as PaintSnapshotAnnotationEntity, ); } @@ -641,6 +723,7 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap sdtId, pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), + layoutSourceIdentity: readNearestLayoutSourceIdentity(element), }) as PaintSnapshotStructuredContentBlockEntity, ); } @@ -660,6 +743,7 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap sdtId, pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), + layoutSourceIdentity: readNearestLayoutSourceIdentity(element), }) as PaintSnapshotStructuredContentInlineEntity, ); } @@ -683,6 +767,7 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), sourceAnchor: readNearestSourceAnchor(element), + layoutSourceIdentity: readNearestLayoutSourceIdentity(element), }) as PaintSnapshotImageEntity, ); } @@ -703,6 +788,7 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), blockId: element.getAttribute('data-sd-block-id'), sourceAnchor: readNearestSourceAnchor(element), + layoutSourceIdentity: readNearestLayoutSourceIdentity(element), }) as PaintSnapshotImageEntity, ); } @@ -1585,6 +1671,8 @@ export class DomPainter { tabs, sourceAnchor: readNearestSourceAnchor(lineEl) ?? readNearestSourceAnchor(options.wrapperEl) ?? options.sourceAnchor, + layoutSourceIdentity: + readNearestLayoutSourceIdentity(lineEl) ?? readNearestLayoutSourceIdentity(options.wrapperEl), }) as PaintSnapshotLine, ); @@ -1629,6 +1717,7 @@ export class DomPainter { markers, tabs, sourceAnchor: readNearestSourceAnchor(lineEl), + layoutSourceIdentity: readNearestLayoutSourceIdentity(lineEl), }) as PaintSnapshotLine, ); } @@ -2208,6 +2297,10 @@ export class DomPainter { el.dataset.layoutEpoch = String(this.layoutEpoch); el.dataset.pageNumber = String(page.number); el.dataset.pageIndex = String(pageIndex); + // Editor-neutral layout boundary stamp (prep-001). Lets DOM observers + // negotiate the additive identity contract version without reading + // package metadata. + el.dataset[DATASET_KEYS.LAYOUT_BOUNDARY_SCHEMA] = LAYOUT_BOUNDARY_SCHEMA; // Render per-page ruler if enabled (suppressed in semantic flow mode) if (!this.isSemanticFlow && this.options.ruler?.enabled) { @@ -2574,6 +2667,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: kind, + story: resolveDecorationStory(kind, data), pageNumberText: page.numberText, pageIndex, }; @@ -2927,6 +3021,9 @@ export class DomPainter { applyStyles(el, pageStyles(page.width, page.height, this.getEffectivePageStyles())); this.applySemanticPageOverrides(el); el.dataset.layoutEpoch = String(this.layoutEpoch); + // Editor-neutral layout boundary stamp (prep-001). See `renderPage` for + // the spread/horizontal flow that stamps the same attribute. + el.dataset[DATASET_KEYS.LAYOUT_BOUNDARY_SCHEMA] = LAYOUT_BOUNDARY_SCHEMA; const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -3090,9 +3187,9 @@ export class DomPainter { : fragmentStyles; applyStyles(fragmentEl, styles); if (resolvedItem) { - this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section, context.story); } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section); + this.applyFragmentFrame(fragmentEl, fragment, context.section, context.story); } // Add TOC-specific styling class @@ -3282,6 +3379,7 @@ export class DomPainter { this.capturePaintSnapshotLine(lineEl, context, { inTableFragment: false, inTableParagraph: false, + wrapperEl: fragmentEl, sourceAnchor: resolvedItem?.sourceAnchor, }); fragmentEl.appendChild(lineEl); @@ -3507,6 +3605,7 @@ export class DomPainter { this.capturePaintSnapshotLine(lineEl, context, { inTableFragment: false, inTableParagraph: false, + wrapperEl: fragmentEl, sourceAnchor: resolvedItem?.sourceAnchor, }); fragmentEl.appendChild(lineEl); @@ -3642,13 +3741,14 @@ export class DomPainter { fragmentEl.classList.add(CLASS_NAMES.fragment, `${CLASS_NAMES.fragment}-list-item`); applyStyles(fragmentEl, fragmentStyles); if (resolvedItem) { - this.applyResolvedListItemWrapperFrame(fragmentEl, fragment, resolvedItem, context.section); + this.applyResolvedListItemWrapperFrame(fragmentEl, fragment, resolvedItem, context.section, context.story); } else { fragmentEl.style.left = `${fragment.x - fragment.markerWidth}px`; fragmentEl.style.top = `${fragment.y}px`; fragmentEl.style.width = `${fragment.markerWidth + fragment.width}px`; fragmentEl.dataset.blockId = fragment.blockId; applySourceAnchorDataset(fragmentEl, fragment.sourceAnchor); + applyLayoutIdentityDataset(fragmentEl, resolveOrBuildFragmentIdentity(fragment, context.story)); } fragmentEl.dataset.itemId = fragment.itemId; @@ -3754,6 +3854,7 @@ export class DomPainter { this.capturePaintSnapshotLine(lineEl, context, { inTableFragment: false, inTableParagraph: false, + wrapperEl: fragmentEl, sourceAnchor: resolvedItem?.sourceAnchor, }); contentEl.appendChild(lineEl); @@ -3787,9 +3888,9 @@ export class DomPainter { fragmentEl.classList.add(CLASS_NAMES.fragment, DOM_CLASS_NAMES.IMAGE_FRAGMENT); applyStyles(fragmentEl, fragmentStyles); if (resolvedItem) { - this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section, context.story); } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section); + this.applyFragmentFrame(fragmentEl, fragment, context.section, context.story); fragmentEl.style.height = `${fragment.height}px`; this.applyFragmentWrapperZIndex(fragmentEl, fragment); } @@ -3987,9 +4088,9 @@ export class DomPainter { fragmentEl.classList.add(CLASS_NAMES.fragment, 'superdoc-drawing-fragment'); applyStyles(fragmentEl, fragmentStyles); if (resolvedItem) { - this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section, context.story); } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section); + this.applyFragmentFrame(fragmentEl, fragment, context.section, context.story); fragmentEl.style.height = `${fragment.height}px`; this.applyFragmentWrapperZIndex(fragmentEl, fragment); } @@ -5020,7 +5121,7 @@ export class DomPainter { // Wrap applyFragmentFrame to capture section from context. // Table cell inner fragments always stay on the legacy frame path for now. const applyFragmentFrameWithSection = (el: HTMLElement, frag: Fragment): void => { - this.applyFragmentFrame(el, frag, context.section); + this.applyFragmentFrame(el, frag, context.section, context.story); }; // Word justifies text inside table cells, but not the final line unless the @@ -5105,7 +5206,7 @@ export class DomPainter { // Override outer wrapper positioning with resolved data when available. // Inner cell fragments still use legacy applyFragmentFrame via deps closure. if (resolvedItem) { - this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section); + this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section, context.story); // Re-apply the SDT group width override after the resolved frame, so block-SDT // containers can stretch table fragments to match sibling paragraph widths. if (sdtBoundary?.widthOverride != null) { @@ -7016,16 +7117,17 @@ export class DomPainter { ): void { // Narrow to fragment-kind resolved items (excludes ResolvedGroupItem) const fragmentItem = resolvedItem?.kind === 'fragment' ? resolvedItem : undefined; + const story = resolveSectionStory(section); if (fragment.kind === 'list-item' && fragmentItem) { - this.applyResolvedListItemWrapperFrame(el, fragment, fragmentItem as ResolvedFragmentItem, section); + this.applyResolvedListItemWrapperFrame(el, fragment, fragmentItem as ResolvedFragmentItem, section, story); return; } if (fragmentItem) { - this.applyResolvedFragmentFrame(el, fragmentItem, fragment, section); + this.applyResolvedFragmentFrame(el, fragmentItem, fragment, section, story); } else { - this.applyFragmentFrame(el, fragment, section); + this.applyFragmentFrame(el, fragment, section, story); if (fragment.kind === 'image' || fragment.kind === 'drawing') { el.style.height = `${fragment.height}px`; this.applyFragmentWrapperZIndex(el, fragment); @@ -7045,13 +7147,19 @@ export class DomPainter { * - 'header' or 'footer': PM position validation is skipped (these sections have separate PM coordinate spaces) * When undefined, defaults to 'body' section behavior (validation enabled). */ - private applyFragmentFrame(el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer'): void { + private applyFragmentFrame( + el: HTMLElement, + fragment: Fragment, + section?: 'body' | 'header' | 'footer', + story?: LayoutStoryLocator, + ): void { el.style.left = `${fragment.x}px`; el.style.top = `${fragment.y}px`; el.style.width = `${fragment.width}px`; el.dataset.blockId = fragment.blockId; el.dataset.layoutEpoch = String(this.layoutEpoch); applySourceAnchorDataset(el, fragment.sourceAnchor); + applyLayoutIdentityDataset(el, resolveOrBuildFragmentIdentity(fragment, story ?? resolveSectionStory(section))); // Footnote content is read-only: prevent cursor placement and typing (blockId prefix from FootnotesBuilder) if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { @@ -7202,6 +7310,7 @@ export class DomPainter { item: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem, fragment: Fragment, section?: 'body' | 'header' | 'footer', + story?: LayoutStoryLocator, ): void { el.style.left = `${item.x}px`; el.style.top = `${item.y}px`; @@ -7209,6 +7318,16 @@ export class DomPainter { el.dataset.blockId = item.blockId; el.dataset.layoutEpoch = String(this.layoutEpoch); applySourceAnchorDataset(el, item.sourceAnchor); + applyLayoutIdentityDataset( + el, + resolveOrBuildFragmentIdentity( + fragment, + story ?? resolveSectionStory(section), + item.layoutSourceIdentity + ? { ...item.layoutSourceIdentity, sourceAnchor: item.sourceAnchor ?? item.layoutSourceIdentity.sourceAnchor } + : undefined, + ), + ); this.applyFragmentWrapperZIndex(el, fragment, item.zIndex); if (item.fragmentKind === 'image' || item.fragmentKind === 'drawing' || item.fragmentKind === 'table') { @@ -7230,8 +7349,9 @@ export class DomPainter { fragment: ListItemFragment, item: ResolvedFragmentItem, section?: 'body' | 'header' | 'footer', + story?: LayoutStoryLocator, ): void { - this.applyResolvedFragmentFrame(el, item, fragment, section); + this.applyResolvedFragmentFrame(el, item, fragment, section, story); // Default to 0 (no marker gutter expansion) when markerWidth is absent — the resolve // stage populates this for list items that have a measured marker (SD-2957). const mw = item.markerWidth ?? 0; diff --git a/packages/layout-engine/tests/src/architecture-boundaries.test.ts b/packages/layout-engine/tests/src/architecture-boundaries.test.ts index 0cd3fd52b5..c5d797fcf7 100644 --- a/packages/layout-engine/tests/src/architecture-boundaries.test.ts +++ b/packages/layout-engine/tests/src/architecture-boundaries.test.ts @@ -285,6 +285,34 @@ describe('architecture boundaries', () => { }); }); + describe('Guard G: prep-001 layout boundary does not depend on v2 runtime packages', () => { + // Editor-neutral substrate added by `prep-001-layout-boundary-and-identity.md`. + // It must not silently pick up a dependency on any v2 runtime package — if a + // future Phase 3 plan needs to ship a v2 type through this boundary, that + // belongs in the v2 layer, not in the shared layout-engine packages. + const FORBIDDEN_V2_PACKAGES = [ + '@superdoc/super-editor-v2', + '@superdoc/document-api-v2', + '@superdoc/document-api-v2-adapter', + ]; + const PREP_001_RUNTIME_DIRS = [ + 'contracts/src', + 'dom-contract/src', + 'layout-bridge/src', + 'layout-resolved/src', + 'painters/dom/src', + ]; + + for (const dir of PREP_001_RUNTIME_DIRS) { + for (const pkg of FORBIDDEN_V2_PACKAGES) { + it(`${dir} does not import ${pkg}`, () => { + const srcDir = path.join(LAYOUT_ENGINE_ROOT, dir); + expectNoViolations(findImportViolations(srcDir, pkg)); + }); + } + } + }); + describe('Guard F: painter-dom render path does not coalesce resolved fields with the legacy fragment back-pointer (SD-2957)', () => { // Lines exempt because the LHS reads from a different stage entirely (e.g. // ImageBlock.width is the OOXML natural width, fragment.width is the 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 d2d4923453..85c963a92a 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 @@ -22,7 +22,9 @@ import type { ResolvedPaintItem, ResolvedLayout, ResolvedPage, + LayoutStoryLocator, } from '@superdoc/contracts'; +import { namedStoryLocator } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; @@ -74,6 +76,11 @@ type SurfacePmEntry = { el: HTMLElement; }; +// AIDEV-NOTE: compat-fallback - header/footer session interaction still keys +// off `data-pm-*` (prep-002). DomPainter also stamps the parallel neutral +// dataset (`data-layout-fragment-id` etc.) which a future v2 consumer can +// pick up via `LayoutHitV1Compat.readRenderedElementIdentity`. Do not strip +// the PM reads here without an explicit migration plan. function buildSurfacePmEntries(surface: HTMLElement): SurfacePmEntry[] { const nodes = Array.from(surface.querySelectorAll('[data-pm-start][data-pm-end]')); const nonLeaf = new WeakSet(); @@ -358,8 +365,18 @@ type HeaderFooterActivationOptions = { * Paired with the originals so the decoration provider can deliver aligned * `items` alongside `fragments`. */ -function resolveResult(result: HeaderFooterLayoutResult): ResolvedHeaderFooterLayout { - return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures); +function buildHeaderFooterStory(kind: 'header' | 'footer', id: string | null | undefined): LayoutStoryLocator { + const normalizedId = typeof id === 'string' && id.length > 0 ? id : undefined; + return normalizedId ? namedStoryLocator(kind, normalizedId) : { kind }; +} + +function storyIdFromHeaderFooterLayoutKey(key: string): string { + return key.replace(/::s\d+$/, ''); +} + +function resolveResult(result: HeaderFooterLayoutResult, storyId?: string | null): ResolvedHeaderFooterLayout { + const story = buildHeaderFooterStory(result.kind, storyId ?? String(result.type)); + return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures, story); } function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): ResolvedPaintItem { @@ -559,7 +576,7 @@ export class HeaderFooterSessionManager { /** Set header layout results */ set headerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#headerLayoutResults = results; - this.#resolvedHeaderLayouts = results ? results.map(resolveResult) : null; + this.#resolvedHeaderLayouts = results ? results.map((result) => resolveResult(result)) : null; } /** Footer layout results */ @@ -570,7 +587,7 @@ export class HeaderFooterSessionManager { /** Set footer layout results */ set footerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#footerLayoutResults = results; - this.#resolvedFooterLayouts = results ? results.map(resolveResult) : null; + this.#resolvedFooterLayouts = results ? results.map((result) => resolveResult(result)) : null; } /** Header layouts by rId */ @@ -681,8 +698,8 @@ export class HeaderFooterSessionManager { ): void { this.#headerLayoutResults = headerResults; this.#footerLayoutResults = footerResults; - this.#resolvedHeaderLayouts = headerResults ? headerResults.map(resolveResult) : null; - this.#resolvedFooterLayouts = footerResults ? footerResults.map(resolveResult) : null; + this.#resolvedHeaderLayouts = headerResults ? headerResults.map((result) => resolveResult(result)) : null; + this.#resolvedFooterLayouts = footerResults ? footerResults.map((result) => resolveResult(result)) : null; } /** @@ -1633,11 +1650,11 @@ export class HeaderFooterSessionManager { // Rebuild resolved maps aligned 1:1 with the raw rId maps. this.#resolvedHeaderByRId.clear(); for (const [key, result] of this.#headerLayoutsByRId) { - this.#resolvedHeaderByRId.set(key, resolveResult(result)); + this.#resolvedHeaderByRId.set(key, resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); } this.#resolvedFooterByRId.clear(); for (const [key, result] of this.#footerLayoutsByRId) { - this.#resolvedFooterByRId.set(key, resolveResult(result)); + this.#resolvedFooterByRId.set(key, resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); } } @@ -2278,6 +2295,7 @@ export class HeaderFooterSessionManager { result: HeaderFooterLayoutResult, cachedResolvedLayout: ResolvedHeaderFooterLayout | undefined, contextLabel: string, + storyId?: string | null, ): ResolvedPaintItem[] | undefined { const cachedPage = cachedResolvedLayout?.pages.find((page) => page.number === slotPageNumber); const cachedItems = cachedPage?.items; @@ -2290,7 +2308,7 @@ export class HeaderFooterSessionManager { ); } - const freshResolvedLayout = resolveHeaderFooterLayout(result.layout, result.blocks, result.measures); + const freshResolvedLayout = resolveResult(result, storyId); const freshPage = freshResolvedLayout.pages.find((page) => page.number === slotPageNumber); const freshItems = freshPage?.items; if (freshItems && freshItems.length === fragments.length) { @@ -2404,6 +2422,7 @@ export class HeaderFooterSessionManager { rIdLayout, rIdResolvedLayout, `rId '${rIdLayoutKey}' page ${pageNumber}`, + sectionRId, ); if (!alignedItems) { return null; @@ -2460,6 +2479,8 @@ export class HeaderFooterSessionManager { return null; } const fragments = slotPage.fragments ?? []; + const fallbackId = this.#headerFooterManager?.getVariantId(kind, headerFooterType); + const finalHeaderId = sectionRId ?? fallbackId ?? undefined; const resolvedVariant = resolvedResults?.[variantIndex]; const alignedVariantItems = this.resolveAlignedDecorationItems( @@ -2468,6 +2489,7 @@ export class HeaderFooterSessionManager { variant, resolvedVariant, `variant '${headerFooterType}' page ${pageNumber}`, + finalHeaderId ?? headerFooterType, ); if (!alignedVariantItems) { return null; @@ -2482,8 +2504,6 @@ export class HeaderFooterSessionManager { const rawLayoutHeight = variant.layout.height ?? 0; const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); - const fallbackId = this.#headerFooterManager?.getVariantId(kind, headerFooterType); - const finalHeaderId = sectionRId ?? fallbackId ?? undefined; const layoutMinY = variant.layout.minY ?? 0; const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.test.ts new file mode 100644 index 0000000000..9d96db1cc6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment jsdom + * + * Regression tests for the editor-side pointer-hit seam (prep-002). + * + * The PM-shaped path `resolvePointerPositionHit` is exercised extensively by + * `DomPointerMapping.test.ts` and the PresentationEditor input tests. These + * tests focus on the prep-002 contract: + * + * - `resolvePointerLayoutHit` returns a `LayoutHit` whose `legacyPm` is + * identical to the v1 path's `PositionHit`. + * - `PositionHit` shape stays exact (`pos`, `layoutEpoch`, `blockId`, + * `pageIndex`, `column`, `lineIndex`). + * - Geometry-only mode (no DOM container) still returns the same v1 hit. + */ + +import { describe, expect, it } from 'vitest'; +import { LAYOUT_BOUNDARY_SCHEMA, type FlowBlock, type Layout, type Measure } from '@superdoc/contracts'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { clickToPositionGeometry } from '@superdoc/layout-bridge'; + +import { resolvePointerPositionHit, resolvePointerLayoutHit } from './PositionHitResolver.ts'; + +const block: FlowBlock = { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 }, + ], +}; + +const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: 5, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, +}; + +const layout: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 7, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + pmStart: 1, + pmEnd: 12, + }, + ], + }, + ], +}; + +const POSITION_HIT_KEYS = ['pos', 'layoutEpoch', 'blockId', 'pageIndex', 'column', 'lineIndex'].sort(); + +describe('PositionHitResolver — v1 surface (prep-002)', () => { + it('returns the v1 PositionHit shape exactly in geometry-only mode', () => { + const hit = resolvePointerPositionHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + expect(hit).not.toBeNull(); + expect(Object.keys(hit!).sort()).toEqual(POSITION_HIT_KEYS); + expect(hit!.blockId).toBe('0-paragraph'); + expect(hit!.pageIndex).toBe(0); + expect(typeof hit!.pos).toBe('number'); + expect(typeof hit!.layoutEpoch).toBe('number'); + }); +}); + +describe('PositionHitResolver — neutral surface (prep-002)', () => { + it('returns a LayoutHit with neutral identity', () => { + const hit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + expect(hit).not.toBeNull(); + expect(hit!.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(hit!.blockRef).toBe('0-paragraph'); + expect(hit!.layoutRevision).toBe(7); + expect(typeof hit!.fragmentId).toBe('string'); + }); + + it('legacyPm matches the v1 path exactly for the same coordinate', () => { + const v1Hit = resolvePointerPositionHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + const neutralHit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + expect(neutralHit?.legacyPm).toEqual(v1Hit); + }); + + it('preserves DOM-first legacyPm parity when DOM mapping differs from geometry', () => { + const domContainer = document.createElement('div'); + const page = document.createElement('div'); + page.className = DOM_CLASS_NAMES.PAGE; + page.dataset.pageIndex = '0'; + + const fragment = document.createElement('div'); + fragment.className = DOM_CLASS_NAMES.FRAGMENT; + + const line = document.createElement('div'); + line.className = DOM_CLASS_NAMES.LINE; + line.dataset.pmStart = '20'; + line.dataset.pmEnd = '30'; + + const span = document.createElement('span'); + span.dataset.pmStart = '20'; + span.dataset.pmEnd = '30'; + span.textContent = 'DOM text'; + + line.appendChild(span); + fragment.appendChild(line); + page.appendChild(fragment); + domContainer.appendChild(page); + document.body.appendChild(domContainer); + + try { + const pointer = { x: 40, y: 60 }; + const v1Hit = resolvePointerPositionHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: pointer, + domContainer, + clientX: 40, + clientY: 60, + }); + const geometryHit = clickToPositionGeometry(layout, [block], [measure], pointer); + const neutralHit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: pointer, + domContainer, + clientX: 40, + clientY: 60, + }); + + expect(v1Hit?.pos).toBe(30); + expect(geometryHit?.pos).not.toBe(v1Hit?.pos); + expect(neutralHit?.legacyPm).toEqual(v1Hit); + } finally { + domContainer.remove(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.ts index c2de0339e6..89df8cc79a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PositionHitResolver.ts @@ -7,11 +7,18 @@ * This module does NOT perform epoch mapping or position clamping — those * remain at the existing call sites in PresentationEditor / EditorInputManager. * + * AIDEV-NOTE: prep-002 — this seam keeps DOM-first browser-specific pointer + * logic editor-owned. v1 callers see `PositionHit`; the additive + * {@link resolvePointerLayoutHit} entry point returns the editor-neutral + * `LayoutHit` for future v2 consumers. Both paths share the same DOM-first / + * geometry-fallback strategy so behavior stays in lock-step. + * * @module input/PositionHitResolver */ import type { Layout, FlowBlock, Measure } from '@superdoc/contracts'; import { + type LayoutHit, type Point, type PositionHit, type PageGeometryHelper, @@ -19,6 +26,7 @@ import { clickToPositionGeometry, } from '@superdoc/layout-bridge'; import { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from '../../../dom-observer/index.js'; +import { resolvePointerLayoutHit as resolvePointerLayoutHitCompat } from '../../../dom-observer/LayoutHitV1Compat.js'; /** * Full pointer-hit resolution: DOM-first with geometry fallback. @@ -73,3 +81,32 @@ export function resolvePointerPositionHit(options: { // Pure geometry path return clickToPositionGeometry(layout, blocks, measures, containerPoint, { geometryHelper }); } + +/** + * Editor-neutral pointer hit resolution (prep-002). + * + * Returns a `LayoutHit` whose `legacyPm` mirrors what + * {@link resolvePointerPositionHit} would have produced for the same inputs. + * Additive — v1 call sites continue to use `resolvePointerPositionHit`. This + * entry point exists so a future editor-neutral consumer can adopt the + * neutral substrate without forking the DOM-first orchestration that lives + * in this module. + * + * The DOM-first branch reuses the same `clickToPositionDom` / + * `findPageElement` helpers as the PM-shaped path, then enriches the + * resulting position into a `LayoutHit` by routing through `hitTestNeutral` + * with a `pageHint`. This keeps a single browser-coupled path inside the + * editor. + */ +export function resolvePointerLayoutHit(options: { + layout: Layout; + blocks: FlowBlock[]; + measures: Measure[]; + containerPoint: Point; + domContainer?: HTMLElement | null; + clientX?: number; + clientY?: number; + geometryHelper?: PageGeometryHelper; +}): LayoutHit | null { + return resolvePointerLayoutHitCompat(options); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/proofing/dom/decoration-pass.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/proofing/dom/decoration-pass.ts index 1e36c74b40..1f4cc490f0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/proofing/dom/decoration-pass.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/proofing/dom/decoration-pass.ts @@ -3,6 +3,13 @@ * * This is a post-paint compatibility layer that walks rendered PM-mapped spans * and applies proofing classes without involving the painter package. + * + * AIDEV-NOTE: compat-fallback - proofing decoration still keys off + * `data-pm-*` (prep-002). The painter additionally stamps the editor-neutral + * identity dataset (prep-001); editor-side consumers that need it should go + * through `LayoutHitV1Compat`. Annotations are emitted with PM ranges today, + * so this DOM walk stays PM-coupled until a future v2 consumer brings + * neutral range mapping end-to-end. */ import { PROOFING_CSS, cssClassForKind, type ProofingAnnotation } from '../types.js'; 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 9d96041338..f2e4a2fad3 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -7,6 +7,12 @@ * browser's actual rendering and correctly handles PM position gaps that occur * after document edits (e.g. paragraph joins). * + * AIDEV-NOTE: compat-fallback - DOM-first browser-coupled pointer logic stays + * editor-owned (prep-002). This module reads `data-pm-*` directly because v1 + * still consumes PM positions. The editor-neutral neighbour for future v2 + * consumers is `LayoutHitV1Compat.resolvePointerLayoutHit`, which reuses the + * same DOM-first / geometry-fallback strategy but returns a `LayoutHit`. + * * @module dom-observer/DomPointerMapping */ 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 924f883f8e..386340a327 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -2,6 +2,17 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { sortedIndexBy } from 'lodash'; import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js'; +// AIDEV-NOTE: compat-fallback - this index is the v1 PM-shape path. It reads +// `data-pm-start`/`data-pm-end` directly because v1 selection painting and +// header/footer interaction still address rendered elements through PM +// positions. The parallel neutral identity (`data-layout-fragment-id` / +// `data-layout-block-ref` / `data-layout-story`) is stamped by DomPainter +// (prep-001); editor-side consumers that want it should go through +// `LayoutHitV1Compat.findElementByLayoutFragmentId` / +// `readRenderedElementIdentity`. Retire this PM-keyed index once a future +// neutral consumer takes ownership of selection geometry; until then the +// PM keys remain load-bearing. + /** * Represents a single entry in the DOM position index. * diff --git a/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.test.ts b/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.test.ts new file mode 100644 index 0000000000..634a0a101f --- /dev/null +++ b/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.test.ts @@ -0,0 +1,354 @@ +/** + * @vitest-environment jsdom + * + * Regression tests for the editor-side prep-002 compatibility adapter. + * + * Covers: + * - `layoutHitToPositionHit` projects neutral hits onto the v1 PositionHit + * shape and preserves every required field. + * - `resolvePointerLayoutHit` returns a `LayoutHit` whose `legacyPm` is + * identical to what the existing v1 path would have produced. + * - `mapPmRangeToLayoutFragments` returns fragment subranges for the + * existing PM-shaped selection. + * - DOM identity helpers read `data-layout-*` and fall back to `data-pm-*` + * correctly. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LAYOUT_BOUNDARY_SCHEMA, type FlowBlock, type Layout, type Measure } from '@superdoc/contracts'; +import { DATA_ATTRS, DATASET_KEYS, DOM_CLASS_NAMES, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; +import { clickToPositionGeometry, hitTestNeutral, type PositionHit } from '@superdoc/layout-bridge'; + +import { + findElementByLayoutFragmentId, + findNearestRenderedElementIdentity, + layoutHitToPositionHit, + mapPmRangeToLayoutFragments, + readRenderedElementIdentity, + resolvePointerLayoutHit, +} from './LayoutHitV1Compat.ts'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const block: FlowBlock = { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 }, + ], +}; + +const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: 5, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, +}; + +const layout: Layout = { + pageSize: { w: 400, h: 500 }, + layoutEpoch: 42, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: '0-paragraph', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 300, + pmStart: 1, + pmEnd: 12, + }, + ], + }, + ], +}; + +// --------------------------------------------------------------------------- +// PositionHit projection +// --------------------------------------------------------------------------- + +describe('layoutHitToPositionHit (prep-002)', () => { + const POSITION_HIT_KEYS = ['pos', 'layoutEpoch', 'blockId', 'pageIndex', 'column', 'lineIndex'].sort(); + + it('returns null for null input', () => { + expect(layoutHitToPositionHit(null)).toBeNull(); + expect(layoutHitToPositionHit(undefined)).toBeNull(); + }); + + it('extracts legacyPm and preserves the v1 PositionHit shape exactly', () => { + const hit = hitTestNeutral(layout, [block], [measure], { x: 40, y: 60 }); + expect(hit).not.toBeNull(); + const positionHit = layoutHitToPositionHit(hit); + expect(positionHit).not.toBeNull(); + + const keys = Object.keys(positionHit as PositionHit).sort(); + expect(keys).toEqual(POSITION_HIT_KEYS); + + expect(typeof positionHit!.pos).toBe('number'); + expect(typeof positionHit!.layoutEpoch).toBe('number'); + expect(typeof positionHit!.blockId).toBe('string'); + expect(typeof positionHit!.pageIndex).toBe('number'); + expect(typeof positionHit!.column).toBe('number'); + expect(typeof positionHit!.lineIndex).toBe('number'); + }); + + it('matches the PM-shaped geometry path 1:1 for the same coordinate', () => { + const containerPoint = { x: 40, y: 60 }; + const v1Hit = clickToPositionGeometry(layout, [block], [measure], containerPoint); + const neutralHit = hitTestNeutral(layout, [block], [measure], containerPoint); + const projected = layoutHitToPositionHit(neutralHit); + expect(projected).toEqual(v1Hit); + }); +}); + +// --------------------------------------------------------------------------- +// resolvePointerLayoutHit +// --------------------------------------------------------------------------- + +describe('resolvePointerLayoutHit (prep-002)', () => { + it('returns a LayoutHit with the neutral schema and identity', () => { + const hit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + expect(hit).not.toBeNull(); + expect(hit!.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(hit!.blockRef).toBe('0-paragraph'); + expect(typeof hit!.fragmentId).toBe('string'); + expect(hit!.legacyPm).toBeDefined(); + }); + + it('legacyPm mirrors the existing v1 pointer path', () => { + const v1Hit = clickToPositionGeometry(layout, [block], [measure], { x: 40, y: 60 }); + const layoutHit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: { x: 40, y: 60 }, + }); + expect(layoutHit?.legacyPm).toEqual(v1Hit); + }); + + it('uses DOM-first PM mapping for legacyPm when DOM and geometry disagree', () => { + const container = document.createElement('div'); + const page = document.createElement('div'); + page.className = DOM_CLASS_NAMES.PAGE; + page.dataset.pageIndex = '0'; + + const fragment = document.createElement('div'); + fragment.className = DOM_CLASS_NAMES.FRAGMENT; + fragment.setAttribute(DATA_ATTRS.LAYOUT_FRAGMENT_ID, 'header:rIdHeader1|header-p|para:0:1'); + fragment.setAttribute(DATA_ATTRS.LAYOUT_BLOCK_REF, 'header-p'); + fragment.setAttribute(DATA_ATTRS.LAYOUT_STORY, encodeLayoutStoryDataset({ kind: 'header', id: 'rIdHeader1' })); + fragment.dataset.sourceAnchor = JSON.stringify({ sourceNodeId: 'src-header-p', anchorConfidence: 'high' }); + + const line = document.createElement('div'); + line.className = DOM_CLASS_NAMES.LINE; + line.dataset.pmStart = '20'; + line.dataset.pmEnd = '30'; + + const span = document.createElement('span'); + span.dataset.pmStart = '20'; + span.dataset.pmEnd = '30'; + span.textContent = 'DOM text'; + + line.appendChild(span); + fragment.appendChild(line); + page.appendChild(fragment); + container.appendChild(page); + document.body.appendChild(container); + const previousElementsFromPoint = document.elementsFromPoint; + document.elementsFromPoint = () => [span, line, fragment, page, container]; + + try { + const pointer = { x: 40, y: 60 }; + const geometryHit = clickToPositionGeometry(layout, [block], [measure], pointer); + const layoutHit = resolvePointerLayoutHit({ + layout, + blocks: [block], + measures: [measure], + containerPoint: pointer, + domContainer: container, + clientX: 40, + clientY: 60, + }); + + expect(layoutHit?.legacyPm?.pos).toBe(30); + expect(layoutHit?.legacyPm?.pos).not.toBe(geometryHit?.pos); + expect(layoutHit?.story).toEqual({ kind: 'header', id: 'rIdHeader1' }); + expect(layoutHit?.fragmentId).toBe('header:rIdHeader1|header-p|para:0:1'); + expect(layoutHit?.sourceAnchor).toEqual({ sourceNodeId: 'src-header-p', anchorConfidence: 'high' }); + } finally { + document.elementsFromPoint = previousElementsFromPoint; + container.remove(); + } + }); +}); + +// --------------------------------------------------------------------------- +// mapPmRangeToLayoutFragments +// --------------------------------------------------------------------------- + +describe('mapPmRangeToLayoutFragments (prep-002)', () => { + it('returns the v1 PM range as neutral fragment subranges', () => { + const mapping = mapPmRangeToLayoutFragments(layout, [block], [measure], { + pmFrom: 1, + pmTo: 7, + }); + expect(mapping.schema).toBe(LAYOUT_BOUNDARY_SCHEMA); + expect(mapping.layoutRevision).toBe(42); + expect(mapping.fragments.length).toBeGreaterThan(0); + for (const frag of mapping.fragments) { + expect(frag.blockRef).toBe('0-paragraph'); + expect(frag.pageIndex).toBe(0); + expect(typeof frag.fragmentId).toBe('string'); + } + }); + + it('returns no fragments for a collapsed PM range', () => { + const mapping = mapPmRangeToLayoutFragments(layout, [block], [measure], { + pmFrom: 3, + pmTo: 3, + }); + expect(mapping.fragments).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// DOM identity helpers +// --------------------------------------------------------------------------- + +describe('readRenderedElementIdentity / findNearestRenderedElementIdentity (prep-002)', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it('reads neutral identity datasets alongside the legacy PM range', () => { + const span = document.createElement('span'); + span.setAttribute(DATA_ATTRS.LAYOUT_FRAGMENT_ID, 'body|0-paragraph|para:0:1'); + span.setAttribute(DATA_ATTRS.LAYOUT_BLOCK_REF, '0-paragraph'); + span.setAttribute(DATA_ATTRS.LAYOUT_STORY, encodeLayoutStoryDataset({ kind: 'body' })); + span.dataset.sourceAnchor = JSON.stringify({ sourceNodeId: 'src-body-p', anchorConfidence: 'high' }); + span.setAttribute(DATA_ATTRS.PM_START, '1'); + span.setAttribute(DATA_ATTRS.PM_END, '12'); + container.appendChild(span); + + const identity = readRenderedElementIdentity(span); + expect(identity.fragmentId).toBe('body|0-paragraph|para:0:1'); + expect(identity.blockRef).toBe('0-paragraph'); + expect(identity.story).toEqual({ kind: 'body' }); + expect(identity.sourceAnchor).toEqual({ sourceNodeId: 'src-body-p', anchorConfidence: 'high' }); + expect(identity.pm).toEqual({ start: 1, end: 12 }); + }); + + it('still returns PM range when neutral datasets are absent (compat-fallback path)', () => { + const span = document.createElement('span'); + span.setAttribute(DATA_ATTRS.PM_START, '4'); + span.setAttribute(DATA_ATTRS.PM_END, '8'); + container.appendChild(span); + + const identity = readRenderedElementIdentity(span); + expect(identity.fragmentId).toBeUndefined(); + expect(identity.blockRef).toBeUndefined(); + expect(identity.story).toBeUndefined(); + expect(identity.pm).toEqual({ start: 4, end: 8 }); + }); + + it('returns empty result for null element', () => { + expect(readRenderedElementIdentity(null)).toEqual({}); + expect(readRenderedElementIdentity(undefined)).toEqual({}); + }); + + it('finds the nearest neutral identity by walking ancestors', () => { + const fragmentEl = document.createElement('div'); + fragmentEl.setAttribute(DATA_ATTRS.LAYOUT_FRAGMENT_ID, 'body|0-paragraph|para:0:1'); + fragmentEl.setAttribute(DATA_ATTRS.LAYOUT_BLOCK_REF, '0-paragraph'); + fragmentEl.setAttribute(DATA_ATTRS.LAYOUT_STORY, 'body'); + fragmentEl.dataset.sourceAnchor = JSON.stringify({ sourceNodeId: 'src-ancestor', anchorConfidence: 'medium' }); + + const inner = document.createElement('span'); + inner.setAttribute(DATA_ATTRS.PM_START, '2'); + inner.setAttribute(DATA_ATTRS.PM_END, '5'); + fragmentEl.appendChild(inner); + container.appendChild(fragmentEl); + + const nearest = findNearestRenderedElementIdentity(inner, container); + expect(nearest).toBeDefined(); + expect(nearest!.fragmentId).toBe('body|0-paragraph|para:0:1'); + expect(nearest!.blockRef).toBe('0-paragraph'); + expect(nearest!.story).toEqual({ kind: 'body' }); + expect(nearest!.sourceAnchor).toEqual({ sourceNodeId: 'src-ancestor', anchorConfidence: 'medium' }); + }); + + it('returns undefined when no ancestor carries neutral identity', () => { + const span = document.createElement('span'); + container.appendChild(span); + expect(findNearestRenderedElementIdentity(span, container)).toBeUndefined(); + }); +}); + +describe('findElementByLayoutFragmentId (prep-002)', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it('finds the element whose data-layout-fragment-id matches', () => { + const el = document.createElement('div'); + el.setAttribute(DATA_ATTRS.LAYOUT_FRAGMENT_ID, 'body|0-paragraph|para:0:1'); + container.appendChild(el); + + const found = findElementByLayoutFragmentId(container, 'body|0-paragraph|para:0:1'); + expect(found).toBe(el); + }); + + it('escapes special characters in the fragment id', () => { + const id = 'body|table:fancy,id|para:0:1'; + const el = document.createElement('div'); + el.setAttribute(DATA_ATTRS.LAYOUT_FRAGMENT_ID, id); + container.appendChild(el); + + const found = findElementByLayoutFragmentId(container, id); + expect(found).toBe(el); + }); + + it('returns null for unknown / empty inputs', () => { + expect(findElementByLayoutFragmentId(container, null)).toBeNull(); + expect(findElementByLayoutFragmentId(container, '')).toBeNull(); + expect(findElementByLayoutFragmentId(null, 'x')).toBeNull(); + expect(findElementByLayoutFragmentId(container, 'no-such-fragment')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.ts b/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.ts new file mode 100644 index 0000000000..37cb39ae20 --- /dev/null +++ b/packages/super-editor/src/editors/v1/dom-observer/LayoutHitV1Compat.ts @@ -0,0 +1,409 @@ +/** + * Editor-side v1 compatibility adapter for the editor-neutral layout + * hit-test / range-mapping substrate (prep-002). + * + * The neutral substrate landed in `prep-001` as `hitTestNeutral` / + * `mapRangeToFragmentsNeutral` and the editor-neutral `data-layout-*` + * datasets emitted by DomPainter. v1 consumers continue to address rendered + * output through the PM-shaped `PositionHit` and `data-pm-*` datasets. This + * module is the single editor-side place where the two surfaces meet. + * + * AIDEV-NOTE: compat-fallback - v1 callers consume `PositionHit` / + * `data-pm-*`. Retire once a future v2 consumer takes ownership of the + * neutral surface end-to-end. Adding behavior here is additive only — + * nothing in this module may break the v1 contract. + * + * Hard rules (prep-002): + * - Public `PositionHit` shape MUST NOT change. + * - `data-pm-*` datasets MUST remain available for current v1 consumers. + * - Browser/editor pointer logic stays editor-owned (this file is editor-side). + * - No v2 runtime dependency is introduced. + * + * @module dom-observer/LayoutHitV1Compat + */ + +import type { + FlowBlock, + Layout, + LayoutSourceIdentity, + LayoutStoryLocator, + Measure, + SourceAnchor, +} from '@superdoc/contracts'; +import { + type ClickToPositionGeometryOptions, + type LayoutFragmentOpaqueRange, + type LayoutHit, + type LayoutRangeMapping, + type LayoutRect, + type PmOpaqueRange, + type PageGeometryHelper, + type Point, + type PositionHit, + hitTestNeutral, + mapRangeToFragmentsNeutral, + resolvePositionHitFromDomPosition, + selectionToRects, +} from '@superdoc/layout-bridge'; +import { DATA_ATTRS, DATASET_KEYS, decodeLayoutStoryDataset } from '@superdoc/dom-contract'; +import { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from './DomPointerMapping.js'; + +// --------------------------------------------------------------------------- +// LayoutHit ↔ PositionHit projection +// --------------------------------------------------------------------------- + +/** + * Project an editor-neutral `LayoutHit` into the v1 `PositionHit` shape. + * + * Today the neutral substrate carries `legacyPm` for every hit that + * resolved against a PM-aware fragment. v1 callers can therefore continue + * to consume `PositionHit` while interacting with the neutral entry points. + * + * Returns `null` when the neutral hit could not be projected — typically + * because the underlying fragment did not carry `pmStart`/`pmEnd` (which + * surfaces as a `pm-position-unavailable` diagnostic on the neutral hit). + * Callers should treat `null` as the same "no v1 mapping" signal they get + * from the existing `clickToPositionGeometry` path. + */ +export function layoutHitToPositionHit(hit: LayoutHit | null | undefined): PositionHit | null { + if (!hit) return null; + return hit.legacyPm ?? null; +} + +/** + * Editor-neutral pointer hit resolution. + * + * Mirrors {@link resolvePointerPositionHit} from + * `presentation-editor/input/PositionHitResolver` but returns a + * `LayoutHit` (with `legacyPm` populated for v1 callers). This is additive + * — `resolvePointerPositionHit` keeps the historical signature and v1 call + * sites are not switched today. + */ +export function resolvePointerLayoutHit(options: { + layout: Layout; + blocks: FlowBlock[]; + measures: Measure[]; + containerPoint: Point; + domContainer?: HTMLElement | null; + clientX?: number; + clientY?: number; + geometryHelper?: PageGeometryHelper; +}): LayoutHit | null { + const { layout, blocks, measures, containerPoint, domContainer, clientX, clientY, geometryHelper } = options; + + if (domContainer != null && clientX != null && clientY != null) { + const domPos = clickToPositionDom(domContainer, clientX, clientY); + const domLayoutEpoch = readLayoutEpochFromDom(domContainer, clientX, clientY) ?? layout.layoutEpoch ?? 0; + const pageHint = resolveDomPageHint(layout, domContainer, clientX, clientY); + const neutralOptions: ClickToPositionGeometryOptions = { + ...(geometryHelper ? { geometryHelper } : {}), + ...(pageHint ? { pageHint } : {}), + }; + const neutralHit = hitTestNeutral(layout, blocks, measures, containerPoint, neutralOptions); + + if (domPos != null) { + const legacyPm = resolvePositionHitFromDomPosition(layout, blocks, measures, domPos, domLayoutEpoch); + return mergeDomFirstHit({ + neutralHit, + identity: resolveDomLayoutIdentity(domContainer, clientX, clientY), + legacyPm, + layoutRevision: layout.layoutEpoch ?? 0, + pageIndex: pageHint?.pageIndex, + }); + } + + if (neutralHit) { + return neutralHit; + } + } + + const neutralOptions: ClickToPositionGeometryOptions | undefined = geometryHelper ? { geometryHelper } : undefined; + return hitTestNeutral(layout, blocks, measures, containerPoint, neutralOptions); +} + +function mergeDomFirstHit(options: { + neutralHit: LayoutHit | null; + identity: LayoutSourceIdentity | undefined; + legacyPm: PositionHit | null; + layoutRevision: number; + pageIndex?: number; +}): LayoutHit | null { + const { neutralHit, identity, legacyPm, layoutRevision, pageIndex } = options; + const legacyFields = legacyPm ? { legacyPm } : {}; + const sourceAnchor = identity?.sourceAnchor ?? neutralHit?.sourceAnchor; + const sourceAnchorFields = sourceAnchor !== undefined ? { sourceAnchor } : {}; + + if (!identity) { + return neutralHit ? { ...neutralHit, ...sourceAnchorFields, ...legacyFields } : null; + } + + return { + ...(neutralHit ?? { + schema: identity.schema, + layoutRevision, + story: identity.story, + blockRef: identity.blockRef, + fragmentId: identity.fragmentId, + identity, + pageIndex: pageIndex ?? legacyPm?.pageIndex ?? 0, + }), + identity, + story: identity.story, + blockRef: identity.blockRef, + fragmentId: identity.fragmentId, + ...sourceAnchorFields, + ...legacyFields, + }; +} + +function resolveDomLayoutIdentity( + domContainer: HTMLElement, + clientX: number, + clientY: number, +): LayoutSourceIdentity | undefined { + const doc = domContainer.ownerDocument as + | (Document & { elementsFromPoint?: (x: number, y: number) => Element[] }) + | null; + if (typeof doc?.elementsFromPoint !== 'function') { + return undefined; + } + + let hitChain: Element[] = []; + try { + hitChain = doc.elementsFromPoint(clientX, clientY) ?? []; + } catch { + return undefined; + } + + for (const element of hitChain) { + if (!(element instanceof HTMLElement)) continue; + const identity = findNearestRenderedElementIdentity(element, domContainer); + if (identity) return identity; + } + + return undefined; +} + +function resolveDomPageHint( + layout: Layout, + domContainer: HTMLElement, + clientX: number, + clientY: number, +): ClickToPositionGeometryOptions['pageHint'] | undefined { + const pageEl = findPageElement(domContainer, clientX, clientY); + if (!pageEl) return undefined; + + const pageIndex = Number(pageEl.dataset.pageIndex ?? 'NaN'); + if (!Number.isFinite(pageIndex) || pageIndex < 0 || pageIndex >= layout.pages.length) { + return undefined; + } + + const page = layout.pages[pageIndex]; + const pageRect = pageEl.getBoundingClientRect(); + const layoutPageHeight = page.size?.h ?? layout.pageSize.h; + const domPageHeight = pageRect.height; + const effectiveZoom = domPageHeight > 0 && layoutPageHeight > 0 ? domPageHeight / layoutPageHeight : 1; + + return { + pageIndex, + pageRelativeY: (clientY - pageRect.top) / effectiveZoom, + }; +} + +// --------------------------------------------------------------------------- +// Selection-range neutral mapping (PM range → fragment subranges) +// --------------------------------------------------------------------------- + +/** + * Map a v1 PM selection range onto editor-neutral fragment subranges. + * + * Wraps `mapRangeToFragmentsNeutral` and supplies `selectionToRects` as the + * PM-rect producer so callers do not need to know about that internal + * dependency. Existing v1 selection painting continues to call + * `selectionToRects` directly; this entry point is additive and used by + * future neutral consumers and by tests proving parity. + */ +export function mapPmRangeToLayoutFragments( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: PmOpaqueRange, +): LayoutRangeMapping { + // Cast — `selectionToRects` returns the layout-bridge `Rect` shape which + // is structurally compatible with `LayoutRect` (same fields). Both types + // exist for historical reasons and the cast keeps this adapter's surface + // typed against the neutral contract. + return mapRangeToFragmentsNeutral( + layout, + blocks, + measures, + range, + selectionToRects as unknown as (l: Layout, b: FlowBlock[], m: Measure[], from: number, to: number) => LayoutRect[], + ); +} + +/** + * Variant overload for already-known fragment ids — useful when a future + * neutral consumer holds opaque fragment identifiers and wants their + * page-positioned rectangles back. + */ +export function mapFragmentIdsToLayoutFragments( + layout: Layout, + blocks: FlowBlock[], + measures: Measure[], + range: LayoutFragmentOpaqueRange, +): LayoutRangeMapping { + return mapRangeToFragmentsNeutral(layout, blocks, measures, range); +} + +// --------------------------------------------------------------------------- +// DOM dataset compatibility helpers (data-layout-* ↔ data-pm-*) +// --------------------------------------------------------------------------- + +/** + * Result of reading a rendered element's identity through the explicit + * compatibility helper. + * + * The neutral fields (`fragmentId`, `blockRef`, `story`) come from the + * `data-layout-*` datasets stamped by DomPainter (prep-001). The legacy + * `pm` field is the existing `data-pm-start`/`data-pm-end` pair, preserved + * so v1 consumers do not need to fork their reads. + */ +export type RenderedElementIdentity = { + fragmentId?: string; + blockRef?: string; + story?: LayoutStoryLocator; + sourceAnchor?: SourceAnchor; + pm?: { start: number; end: number }; +}; + +/** + * Read both the neutral identity datasets and the legacy PM datasets from a + * rendered element. + * + * v1 consumers that today reach for `dataset.pmStart` / `dataset.pmEnd` + * directly can call this helper to pick up the parallel neutral identity + * without rewriting their hot paths. The function is intentionally cheap — + * just dataset reads, no DOM walks. + * + * `data-pm-*` reads remain available regardless of whether neutral datasets + * are present. + */ +export function readRenderedElementIdentity(element: HTMLElement | null | undefined): RenderedElementIdentity { + if (!element) return {}; + + const result: RenderedElementIdentity = {}; + + const fragmentId = element.dataset[DATASET_KEYS.LAYOUT_FRAGMENT_ID]; + if (fragmentId) result.fragmentId = fragmentId; + + const blockRef = element.dataset[DATASET_KEYS.LAYOUT_BLOCK_REF]; + if (blockRef) result.blockRef = blockRef; + + const rawStory = element.dataset[DATASET_KEYS.LAYOUT_STORY]; + if (typeof rawStory === 'string' && rawStory.length > 0) { + const story = decodeLayoutStoryDataset(rawStory); + if (story.kind !== 'unknown') { + result.story = story; + } + } + + const sourceAnchor = readSourceAnchorDataset(element); + if (sourceAnchor) { + result.sourceAnchor = sourceAnchor; + } + + const pmStartRaw = element.dataset[DATASET_KEYS.PM_START]; + const pmEndRaw = element.dataset[DATASET_KEYS.PM_END]; + if (pmStartRaw != null && pmEndRaw != null) { + const pmStart = Number(pmStartRaw); + const pmEnd = Number(pmEndRaw); + if (Number.isFinite(pmStart) && Number.isFinite(pmEnd)) { + result.pm = { start: pmStart, end: pmEnd }; + } + } + + return result; +} + +/** + * Read the nearest neutral identity from `element` or any of its ancestors + * up to (and including) `container`. + * + * Returns `undefined` when no element in the chain carries a usable neutral + * identity. Callers that still need PM positions should fall back to + * `data-pm-*` (which {@link readRenderedElementIdentity} continues to + * expose). + */ +export function findNearestRenderedElementIdentity( + element: HTMLElement | null | undefined, + container?: HTMLElement | null, +): LayoutSourceIdentity | undefined { + let cursor: HTMLElement | null = element ?? null; + let nearestSourceAnchor: SourceAnchor | undefined; + while (cursor) { + nearestSourceAnchor ??= readSourceAnchorDataset(cursor); + const fragmentId = cursor.dataset?.[DATASET_KEYS.LAYOUT_FRAGMENT_ID]; + const blockRef = cursor.dataset?.[DATASET_KEYS.LAYOUT_BLOCK_REF]; + const rawStory = cursor.dataset?.[DATASET_KEYS.LAYOUT_STORY]; + if (fragmentId && blockRef) { + const story = decodeLayoutStoryDataset(rawStory); + if (story.kind !== 'unknown') { + return { + schema: 'layout-identity/1', + story, + blockRef, + fragmentId, + sourceAnchor: nearestSourceAnchor, + }; + } + } + if (container && cursor === container) break; + cursor = cursor.parentElement; + } + return undefined; +} + +function readSourceAnchorDataset(element: HTMLElement): SourceAnchor | undefined { + const raw = element.dataset.sourceAnchor; + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + return parsed as SourceAnchor; + } catch { + return undefined; + } +} + +/** + * Find a rendered element by neutral `LayoutFragmentId`. + * + * v1 today locates elements by PM range via {@link DomPositionIndex}. + * Neutral consumers should locate elements by fragment id; this helper is + * the explicit adapter they call. Returns the first element whose + * `data-layout-fragment-id` matches, or `null`. + */ +export function findElementByLayoutFragmentId( + container: HTMLElement | null | undefined, + fragmentId: string | null | undefined, +): HTMLElement | null { + if (!container || !fragmentId) return null; + return container.querySelector(`[${DATA_ATTRS.LAYOUT_FRAGMENT_ID}="${cssEscape(fragmentId)}"]`); +} + +/** + * Minimal CSS.escape polyfill for the attribute selector built in + * {@link findElementByLayoutFragmentId}. `LayoutFragmentId` values today + * include `:` and `,` characters which need escaping inside attribute + * selectors. We avoid pulling in a runtime polyfill by hand-escaping the + * smallest set of characters the fragment-id format actually uses. + */ +function cssEscape(value: string): string { + if (typeof (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape === 'function') { + return (globalThis as { CSS: { escape: (v: string) => string } }).CSS.escape(value); + } + return value.replace(/["\\\n\r\t]/g, (ch) => `\\${ch}`); +} 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 a7cebfbe6c..7d21930b90 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/index.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/index.ts @@ -27,3 +27,13 @@ export { resolvePositionWithinFragmentDom, resolveTextBoundaryWithinFragmentDom, } from './DomPointerMapping.js'; +export { + type RenderedElementIdentity, + findElementByLayoutFragmentId, + findNearestRenderedElementIdentity, + layoutHitToPositionHit, + mapFragmentIdsToLayoutFragments, + mapPmRangeToLayoutFragments, + readRenderedElementIdentity, + resolvePointerLayoutHit, +} from './LayoutHitV1Compat.js';