From 6aa21643860963102a14bc423ea3889c8b6854ee Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 15:15:17 -0300 Subject: [PATCH 1/5] refactor(painter-dom): extract note story block id helpers Move the footnote/endnote story-block detection out of renderer.ts into a dedicated notes/story.ts module with explicit kind discrimination, and add unit tests covering both the helper and the painter read-only side effect. --- .../painters/dom/src/notes/story.test.ts | 31 +++++++++++++++ .../painters/dom/src/notes/story.ts | 27 +++++++++++++ .../dom/src/renderer-position-mapping.test.ts | 39 +++++++++++++++---- .../painters/dom/src/renderer.ts | 14 ++----- 4 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/notes/story.test.ts create mode 100644 packages/layout-engine/painters/dom/src/notes/story.ts diff --git a/packages/layout-engine/painters/dom/src/notes/story.test.ts b/packages/layout-engine/painters/dom/src/notes/story.test.ts new file mode 100644 index 0000000000..f8dd26fc3d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/story.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getNoteStoryKind, isNonBodyStoryBlockId, shouldApplyPainterReadOnly } from './story.js'; + +describe('note story block ids', () => { + it.each([ + ['footnote-1-abc', 'footnote'], + ['endnote-1-abc', 'endnote'], + ['__sd_semantic_footnote-1-abc', 'semantic-footnote'], + ['__sd_semantic_endnote-1-abc', 'semantic-endnote'], + ] as const)('detects %s as a non-body %s story block', (blockId, kind) => { + expect(getNoteStoryKind(blockId)).toBe(kind); + expect(isNonBodyStoryBlockId(blockId)).toBe(true); + }); + + it.each(['body-paragraph-1', 'footnotes-heading', '__sd_semantic_footnotes_heading', undefined])( + 'does not treat %s as a note body fragment', + (blockId) => { + expect(getNoteStoryKind(blockId)).toBeUndefined(); + expect(isNonBodyStoryBlockId(blockId)).toBe(false); + }, + ); + + it.each([ + ['footnote-1-abc', true], + ['endnote-1-abc', false], + ['__sd_semantic_footnote-1-abc', false], + ['__sd_semantic_endnote-1-abc', false], + ] as const)('applies painter read-only for %s: %s', (blockId, expected) => { + expect(shouldApplyPainterReadOnly(blockId)).toBe(expected); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/notes/story.ts b/packages/layout-engine/painters/dom/src/notes/story.ts new file mode 100644 index 0000000000..9ac7f07657 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/story.ts @@ -0,0 +1,27 @@ +export type NoteStoryKind = 'footnote' | 'endnote' | 'semantic-footnote' | 'semantic-endnote'; + +export const getNoteStoryKind = (blockId: string | undefined): NoteStoryKind | undefined => { + if (typeof blockId !== 'string') { + return undefined; + } + + if (blockId.startsWith('footnote-')) { + return 'footnote'; + } + if (blockId.startsWith('endnote-')) { + return 'endnote'; + } + if (blockId.startsWith('__sd_semantic_footnote-')) { + return 'semantic-footnote'; + } + if (blockId.startsWith('__sd_semantic_endnote-')) { + return 'semantic-endnote'; + } + + return undefined; +}; + +export const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => getNoteStoryKind(blockId) !== undefined; + +export const shouldApplyPainterReadOnly = (blockId: string | undefined): boolean => + getNoteStoryKind(blockId) === 'footnote'; diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts index 21dea44686..138ced6e90 100644 --- a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts @@ -23,16 +23,41 @@ const shiftByTwo = { }; describe('DomPainter.updatePositionAttributes', () => { - it('does not remap footnote fragments with body transaction mappings', () => { + it.each(['footnote-1-abc', 'endnote-1-abc', '__sd_semantic_footnote-1-abc', '__sd_semantic_endnote-1-abc'])( + 'does not remap %s fragments with body transaction mappings', + (blockId) => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment(blockId, 2, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('2'); + expect(fragment.dataset.pmEnd).toBe('30'); + expect(span.dataset.pmStart).toBe('2'); + expect(span.dataset.pmEnd).toBe('30'); + }, + ); + + it.each([ + ['footnote-1-abc', 'false'], + ['endnote-1-abc', null], + ['__sd_semantic_footnote-1-abc', null], + ['__sd_semantic_endnote-1-abc', null], + ] as const)('preserves painter read-only behavior for %s', (blockId, expected) => { const painter = new DomPainter(); - const { fragment, span } = makeFragment('footnote-1-abc', 2, 30); + const fragment = { + kind: 'image', + blockId, + x: 0, + y: 0, + width: 100, + height: 20, + }; + const el = document.createElement('div'); - (painter as any).updatePositionAttributes(fragment, shiftByTwo); + (painter as any).applyFragmentFrame(el, fragment); - expect(fragment.dataset.pmStart).toBe('2'); - expect(fragment.dataset.pmEnd).toBe('30'); - expect(span.dataset.pmStart).toBe('2'); - expect(span.dataset.pmEnd).toBe('30'); + expect(el.getAttribute('contenteditable')).toBe(expected); }); it('still remaps body fragments when the mapping applies', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 3fd6db52e6..f1bdda8da4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -70,6 +70,7 @@ import { isHeaderWordArtWatermark, renderDrawingFragment as renderDrawingFragmentElement, } from './drawings/renderDrawingFragment.js'; +import { isNonBodyStoryBlockId, shouldApplyPainterReadOnly } from './notes/story.js'; export type { PaintSnapshotStructuredContentBlockEntity, @@ -2663,8 +2664,7 @@ export class DomPainter { el.dataset.layoutEpoch = String(this.layoutEpoch); applySourceAnchorDataset(el, fragment.sourceAnchor); - // Footnote content is read-only: prevent cursor placement and typing (blockId prefix from FootnotesBuilder) - if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { + if (shouldApplyPainterReadOnly(fragment.blockId)) { el.setAttribute('contenteditable', 'false'); } @@ -2684,8 +2684,7 @@ export class DomPainter { section?: 'body' | 'header' | 'footer', resolvedItem?: ResolvedFragmentItem | ResolvedTablePaintItem | ResolvedImageItem | ResolvedDrawingItem, ): void { - // Footnote content is read-only: prevent cursor placement and typing - if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { + if (shouldApplyPainterReadOnly(fragment.blockId)) { el.setAttribute('contenteditable', 'false'); } @@ -2802,10 +2801,3 @@ const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean typeof previous.height === 'number' && typeof next.height === 'number' && previous.height !== next.height); - -const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => - typeof blockId === 'string' && - (blockId.startsWith('footnote-') || - blockId.startsWith('endnote-') || - blockId.startsWith('__sd_semantic_footnote-') || - blockId.startsWith('__sd_semantic_endnote-')); From 536704491d308d0623c9b0439efb826882c13dfe Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 15:23:20 -0300 Subject: [PATCH 2/5] refactor(painter-dom): extract note frame attribute helper Wrap the painter read-only contenteditable assignment into applyNoteStoryFrameAttributes so the two fragment-frame call sites share one helper, and move its coverage into a focused test alongside the new module. --- .../painters/dom/src/notes/frame.test.ts | 17 ++++++++++++++ .../painters/dom/src/notes/frame.ts | 7 ++++++ .../dom/src/renderer-position-mapping.test.ts | 22 ------------------- .../painters/dom/src/renderer.ts | 11 ++++------ 4 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/notes/frame.test.ts create mode 100644 packages/layout-engine/painters/dom/src/notes/frame.ts diff --git a/packages/layout-engine/painters/dom/src/notes/frame.test.ts b/packages/layout-engine/painters/dom/src/notes/frame.test.ts new file mode 100644 index 0000000000..b1314c4708 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/frame.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { applyNoteStoryFrameAttributes } from './frame.js'; + +describe('applyNoteStoryFrameAttributes', () => { + it.each([ + ['footnote-1-abc', 'false'], + ['endnote-1-abc', null], + ['__sd_semantic_footnote-1-abc', null], + ['__sd_semantic_endnote-1-abc', null], + ] as const)('applies painter frame attributes for %s', (blockId, expected) => { + const el = document.createElement('div'); + + applyNoteStoryFrameAttributes(el, blockId); + + expect(el.getAttribute('contenteditable')).toBe(expected); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/notes/frame.ts b/packages/layout-engine/painters/dom/src/notes/frame.ts new file mode 100644 index 0000000000..ed71e9510c --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/frame.ts @@ -0,0 +1,7 @@ +import { shouldApplyPainterReadOnly } from './story.js'; + +export const applyNoteStoryFrameAttributes = (el: HTMLElement, blockId: string | undefined): void => { + if (shouldApplyPainterReadOnly(blockId)) { + el.setAttribute('contenteditable', 'false'); + } +}; diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts index 138ced6e90..5004f0adca 100644 --- a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts @@ -38,28 +38,6 @@ describe('DomPainter.updatePositionAttributes', () => { }, ); - it.each([ - ['footnote-1-abc', 'false'], - ['endnote-1-abc', null], - ['__sd_semantic_footnote-1-abc', null], - ['__sd_semantic_endnote-1-abc', null], - ] as const)('preserves painter read-only behavior for %s', (blockId, expected) => { - const painter = new DomPainter(); - const fragment = { - kind: 'image', - blockId, - x: 0, - y: 0, - width: 100, - height: 20, - }; - const el = document.createElement('div'); - - (painter as any).applyFragmentFrame(el, fragment); - - expect(el.getAttribute('contenteditable')).toBe(expected); - }); - it('still remaps body fragments when the mapping applies', () => { const painter = new DomPainter(); const { fragment, span } = makeFragment('body-paragraph-1', 25, 30); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f1bdda8da4..27079b1d20 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -70,7 +70,8 @@ import { isHeaderWordArtWatermark, renderDrawingFragment as renderDrawingFragmentElement, } from './drawings/renderDrawingFragment.js'; -import { isNonBodyStoryBlockId, shouldApplyPainterReadOnly } from './notes/story.js'; +import { applyNoteStoryFrameAttributes } from './notes/frame.js'; +import { isNonBodyStoryBlockId } from './notes/story.js'; export type { PaintSnapshotStructuredContentBlockEntity, @@ -2664,9 +2665,7 @@ export class DomPainter { el.dataset.layoutEpoch = String(this.layoutEpoch); applySourceAnchorDataset(el, fragment.sourceAnchor); - if (shouldApplyPainterReadOnly(fragment.blockId)) { - el.setAttribute('contenteditable', 'false'); - } + applyNoteStoryFrameAttributes(el, fragment.blockId); if (fragment.kind === 'para') { applyParagraphFragmentPmAttributes(el, fragment, section); @@ -2684,9 +2683,7 @@ export class DomPainter { section?: 'body' | 'header' | 'footer', resolvedItem?: ResolvedFragmentItem | ResolvedTablePaintItem | ResolvedImageItem | ResolvedDrawingItem, ): void { - if (shouldApplyPainterReadOnly(fragment.blockId)) { - el.setAttribute('contenteditable', 'false'); - } + applyNoteStoryFrameAttributes(el, fragment.blockId); if (fragment.kind === 'para') { applyParagraphFragmentPmAttributes(el, fragment, section, resolvedItem as ResolvedFragmentItem | undefined); From 20f4425e87eda141b6604a7468b17f7844c7cefc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 15:50:15 -0300 Subject: [PATCH 3/5] refactor(painter-dom): clarify footnote read-only helper --- packages/layout-engine/painters/dom/src/notes/frame.ts | 4 ++-- packages/layout-engine/painters/dom/src/notes/story.test.ts | 4 ++-- packages/layout-engine/painters/dom/src/notes/story.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/notes/frame.ts b/packages/layout-engine/painters/dom/src/notes/frame.ts index ed71e9510c..2dbc5cd8b9 100644 --- a/packages/layout-engine/painters/dom/src/notes/frame.ts +++ b/packages/layout-engine/painters/dom/src/notes/frame.ts @@ -1,7 +1,7 @@ -import { shouldApplyPainterReadOnly } from './story.js'; +import { shouldApplyPlainFootnotePainterReadOnly } from './story.js'; export const applyNoteStoryFrameAttributes = (el: HTMLElement, blockId: string | undefined): void => { - if (shouldApplyPainterReadOnly(blockId)) { + if (shouldApplyPlainFootnotePainterReadOnly(blockId)) { el.setAttribute('contenteditable', 'false'); } }; diff --git a/packages/layout-engine/painters/dom/src/notes/story.test.ts b/packages/layout-engine/painters/dom/src/notes/story.test.ts index f8dd26fc3d..785cef9cb5 100644 --- a/packages/layout-engine/painters/dom/src/notes/story.test.ts +++ b/packages/layout-engine/painters/dom/src/notes/story.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getNoteStoryKind, isNonBodyStoryBlockId, shouldApplyPainterReadOnly } from './story.js'; +import { getNoteStoryKind, isNonBodyStoryBlockId, shouldApplyPlainFootnotePainterReadOnly } from './story.js'; describe('note story block ids', () => { it.each([ @@ -26,6 +26,6 @@ describe('note story block ids', () => { ['__sd_semantic_footnote-1-abc', false], ['__sd_semantic_endnote-1-abc', false], ] as const)('applies painter read-only for %s: %s', (blockId, expected) => { - expect(shouldApplyPainterReadOnly(blockId)).toBe(expected); + expect(shouldApplyPlainFootnotePainterReadOnly(blockId)).toBe(expected); }); }); diff --git a/packages/layout-engine/painters/dom/src/notes/story.ts b/packages/layout-engine/painters/dom/src/notes/story.ts index 9ac7f07657..357fa74469 100644 --- a/packages/layout-engine/painters/dom/src/notes/story.ts +++ b/packages/layout-engine/painters/dom/src/notes/story.ts @@ -23,5 +23,8 @@ export const getNoteStoryKind = (blockId: string | undefined): NoteStoryKind | u export const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => getNoteStoryKind(blockId) !== undefined; -export const shouldApplyPainterReadOnly = (blockId: string | undefined): boolean => +// AIDEV-NOTE: FootnotesBuilder emits `footnote-{id}-` blocks into the body painter. +// Endnote and semantic note blocks have dedicated editing sessions, so only plain +// footnote story frames are locked at the painter layer. +export const shouldApplyPlainFootnotePainterReadOnly = (blockId: string | undefined): boolean => getNoteStoryKind(blockId) === 'footnote'; From 20e7e5103e72deb2fd3ef9e682b134eadc9467e9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 15:51:21 -0300 Subject: [PATCH 4/5] refactor(super-editor): share note story block detection --- .../painters/dom/src/index.test.ts | 8 ++++++- .../layout-engine/painters/dom/src/index.ts | 1 + .../tests/DomPositionIndex.test.ts | 21 +++++++++++-------- .../v1/dom-observer/DomPositionIndex.ts | 8 ++----- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b88790d228..c6517f85f8 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; +import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes, isNonBodyStoryBlockId } from './index.js'; import { DomPainter } from './renderer.js'; import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; @@ -20,6 +20,12 @@ import type { const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] }; +describe('painter-dom exports', () => { + it('exports note story block detection for editor DOM indexing', () => { + expect(isNonBodyStoryBlockId('endnote-1-abc')).toBe(true); + }); +}); + /** * Test-only bridge: accepts old-style `{ blocks, measures, ...options }` and * returns a painter whose `paint()` automatically builds a `DomPainterInput`. diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index e649ce5f41..28d4575e92 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -48,6 +48,7 @@ export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './runs/index.j export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers'; export { buildImagePmSelector, buildInlineImagePmSelector } from './images/image-selectors.js'; +export { isNonBodyStoryBlockId } from './notes/story.js'; // Re-export PM position validation utilities export { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts index 95ff73bc77..7bc0885566 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts @@ -88,25 +88,28 @@ describe('DomPositionIndex', () => { expect(index.findElementAtPosition(10)).toBe(null); }); - it('skips footnote descendants when building the body DOM index', () => { - const container = document.createElement('div'); - container.innerHTML = ` + it.each(['footnote-1-0', 'endnote-1-0', '__sd_semantic_footnote-1-0', '__sd_semantic_endnote-1-0'])( + 'skips %s descendants when building the body DOM index', + (blockId) => { + const container = document.createElement('div'); + container.innerHTML = `
Simple
-
+
This
`; - const index = new DomPositionIndex(); - index.rebuild(container); + const index = new DomPositionIndex(); + index.rebuild(container); - expect(index.size).toBe(1); - expect(index.findElementAtPosition(1)?.textContent).toBe('Simple'); - }); + expect(index.size).toBe(1); + expect(index.findElementAtPosition(1)?.textContent).toBe('Simple'); + }, + ); it('correctly distributes elements across header, body, and footer sections', () => { const container = document.createElement('div'); 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..228d91df11 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -1,4 +1,5 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { isNonBodyStoryBlockId } from '@superdoc/painter-dom'; import { sortedIndexBy } from 'lodash'; import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js'; @@ -24,12 +25,7 @@ function isExcludedFromBodyDomIndex(node: HTMLElement): boolean { } const blockId = node.closest('[data-block-id]')?.dataset.blockId ?? ''; - return ( - blockId.startsWith('footnote-') || - blockId.startsWith('__sd_semantic_footnote-') || - blockId.startsWith('endnote-') || - blockId.startsWith('__sd_semantic_endnote-') - ); + return isNonBodyStoryBlockId(blockId); } /** From 7af7f6b5d1060499223e507c5c095784c20e6142 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 15:51:51 -0300 Subject: [PATCH 5/5] test(painter-dom): clarify note frame cases --- packages/layout-engine/painters/dom/src/notes/frame.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/notes/frame.test.ts b/packages/layout-engine/painters/dom/src/notes/frame.test.ts index b1314c4708..96b0705cd9 100644 --- a/packages/layout-engine/painters/dom/src/notes/frame.test.ts +++ b/packages/layout-engine/painters/dom/src/notes/frame.test.ts @@ -7,7 +7,7 @@ describe('applyNoteStoryFrameAttributes', () => { ['endnote-1-abc', null], ['__sd_semantic_footnote-1-abc', null], ['__sd_semantic_endnote-1-abc', null], - ] as const)('applies painter frame attributes for %s', (blockId, expected) => { + ] as const)('sets frame contenteditable for %s to %s', (blockId, expected) => { const el = document.createElement('div'); applyNoteStoryFrameAttributes(el, blockId);