Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions packages/layout-engine/painters/dom/src/notes/frame.test.ts
Original file line number Diff line number Diff line change
@@ -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)('sets frame contenteditable for %s to %s', (blockId, expected) => {
const el = document.createElement('div');

applyNoteStoryFrameAttributes(el, blockId);

expect(el.getAttribute('contenteditable')).toBe(expected);
});
});
7 changes: 7 additions & 0 deletions packages/layout-engine/painters/dom/src/notes/frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { shouldApplyPlainFootnotePainterReadOnly } from './story.js';

export const applyNoteStoryFrameAttributes = (el: HTMLElement, blockId: string | undefined): void => {
if (shouldApplyPlainFootnotePainterReadOnly(blockId)) {
el.setAttribute('contenteditable', 'false');
}
};
31 changes: 31 additions & 0 deletions packages/layout-engine/painters/dom/src/notes/story.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { getNoteStoryKind, isNonBodyStoryBlockId, shouldApplyPlainFootnotePainterReadOnly } 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(shouldApplyPlainFootnotePainterReadOnly(blockId)).toBe(expected);
});
});
30 changes: 30 additions & 0 deletions packages/layout-engine/painters/dom/src/notes/story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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;

// 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';
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,20 @@ const shiftByTwo = {
};

describe('DomPainter.updatePositionAttributes', () => {
it('does not remap footnote fragments with body transaction mappings', () => {
const painter = new DomPainter();
const { fragment, span } = makeFragment('footnote-1-abc', 2, 30);

(painter as any).updatePositionAttributes(fragment, shiftByTwo);

expect(fragment.dataset.pmStart).toBe('2');
expect(fragment.dataset.pmEnd).toBe('30');
expect(span.dataset.pmStart).toBe('2');
expect(span.dataset.pmEnd).toBe('30');
});
it.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('still remaps body fragments when the mapping applies', () => {
const painter = new DomPainter();
Expand Down
19 changes: 4 additions & 15 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import {
isHeaderWordArtWatermark,
renderDrawingFragment as renderDrawingFragmentElement,
} from './drawings/renderDrawingFragment.js';
import { applyNoteStoryFrameAttributes } from './notes/frame.js';
import { isNonBodyStoryBlockId } from './notes/story.js';

export type {
PaintSnapshotStructuredContentBlockEntity,
Expand Down Expand Up @@ -2663,10 +2665,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-')) {
el.setAttribute('contenteditable', 'false');
}
applyNoteStoryFrameAttributes(el, fragment.blockId);

if (fragment.kind === 'para') {
applyParagraphFragmentPmAttributes(el, fragment, section);
Expand All @@ -2684,10 +2683,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-')) {
el.setAttribute('contenteditable', 'false');
}
applyNoteStoryFrameAttributes(el, fragment.blockId);

if (fragment.kind === 'para') {
applyParagraphFragmentPmAttributes(el, fragment, section, resolvedItem as ResolvedFragmentItem | undefined);
Expand Down Expand Up @@ -2802,10 +2798,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-'));
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div class="superdoc-page">
<div class="superdoc-line">
<span data-pm-start="1" data-pm-end="6">Simple</span>
</div>
<div data-block-id="footnote-1-0" class="superdoc-line">
<div data-block-id="${blockId}" class="superdoc-line">
<span data-pm-start="1" data-pm-end="4">This</span>
</div>
</div>
`;

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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -24,12 +25,7 @@ function isExcludedFromBodyDomIndex(node: HTMLElement): boolean {
}

const blockId = node.closest<HTMLElement>('[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);
}

/**
Expand Down
Loading