diff --git a/packages/layout-engine/contracts/src/pm-range.ts b/packages/layout-engine/contracts/src/pm-range.ts index 81af35ea2c..35d77343d2 100644 --- a/packages/layout-engine/contracts/src/pm-range.ts +++ b/packages/layout-engine/contracts/src/pm-range.ts @@ -80,6 +80,7 @@ const coercePmEnd = (run: unknown): number | undefined => { * - Handles first/last run slicing based on line.fromChar and line.toChar */ export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange { + if (!line) return {}; if (block.kind !== 'paragraph') return {}; let pmStart: number | undefined; @@ -149,7 +150,9 @@ export function computeFragmentPmRange( let pmEnd: number | undefined; for (let index = fromLine; index < toLine; index += 1) { - const range = computeLinePmRange(block, lines[index]); + const line = lines[index]; + if (!line) continue; + const range = computeLinePmRange(block, line); if (range.pmStart != null && pmStart == null) { pmStart = range.pmStart; } diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 2282cc2ccc..edc8deff28 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -818,13 +818,29 @@ export function clickToPosition( if (blockIndex !== -1) { const measure = measures[blockIndex]; if (measure && measure.kind === 'paragraph') { - for (let li = fragment.fromLine; li < fragment.toLine; li++) { - const line = measure.lines[li]; - const range = computeLinePmRange(blocks[blockIndex], line); - if (range.pmStart != null && range.pmEnd != null) { - if (domPos >= range.pmStart && domPos <= range.pmEnd) { - lineIndex = li; - break; + // Use fragment-specific remeasured lines when present to avoid index mismatches. + if (fragment.lines && fragment.lines.length > 0) { + for (let localIndex = 0; localIndex < fragment.lines.length; localIndex++) { + const line = fragment.lines[localIndex]; + if (!line) continue; + const range = computeLinePmRange(blocks[blockIndex], line); + if (range.pmStart != null && range.pmEnd != null) { + if (domPos >= range.pmStart && domPos <= range.pmEnd) { + lineIndex = fragment.fromLine + localIndex; + break; + } + } + } + } else { + for (let li = fragment.fromLine; li < fragment.toLine; li++) { + const line = measure.lines[li]; + if (!line) continue; + const range = computeLinePmRange(blocks[blockIndex], line); + if (range.pmStart != null && range.pmEnd != null) { + if (domPos >= range.pmStart && domPos <= range.pmEnd) { + lineIndex = li; + break; + } } } } @@ -844,7 +860,6 @@ export function clickToPosition( } } - // Position found but couldn't locate in fragments - still return it logClickStage('log', 'success', { pos: domPos, usedMethod: 'DOM', diff --git a/packages/layout-engine/layout-bridge/test/performance.test.ts b/packages/layout-engine/layout-bridge/test/performance.test.ts index 37365a6d54..19fe18f12d 100644 --- a/packages/layout-engine/layout-bridge/test/performance.test.ts +++ b/packages/layout-engine/layout-bridge/test/performance.test.ts @@ -23,11 +23,18 @@ beforeAll(() => { const describeIfRealCanvas = usingStub ? describe.skip : describe; -const LATENCY_TARGETS = { - p50: 420, // Relaxed for CI environments which are slower than local machines - p90: 480, - p99: 800, -}; +const IS_CI = Boolean(process.env.CI); +const LATENCY_TARGETS = IS_CI + ? { + p50: 300, // CI is typically slower and more variable + p90: 400, + p99: 600, + } + : { + p50: 70, + p90: 80, + p99: 90, + }; const MIN_HIT_RATE = 0.95; describeIfRealCanvas('incremental pipeline benchmarks', () => { diff --git a/packages/layout-engine/layout-bridge/vitest.config.ts b/packages/layout-engine/layout-bridge/vitest.config.ts index 01c02b92b3..43e09f3107 100644 --- a/packages/layout-engine/layout-bridge/vitest.config.ts +++ b/packages/layout-engine/layout-bridge/vitest.config.ts @@ -1,14 +1,12 @@ import { defineConfig } from 'vitest/config'; import baseConfig from '../../../vitest.baseConfig'; -const includeBench = process.env.VITEST_BENCH === 'true'; - export default defineConfig({ ...baseConfig, test: { environment: 'node', - include: includeBench ? ['test/**/performance*.test.ts'] : ['test/**/*.test.ts'], - exclude: includeBench ? [] : ['test/**/performance*.test.ts'], + include: ['test/**/*.test.ts'], + exclude: [], globals: true, }, }); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 98cd916ed4..d947c1a9b9 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -23,7 +23,6 @@ import { import { computeAnchorX } from './floating-objects.js'; const spacingDebugEnabled = false; - /** * Type definition for Word layout attributes attached to paragraph blocks. * This is a subset of the WordParagraphLayoutOutput from @superdoc/word-layout. @@ -796,7 +795,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para // Store remeasured lines in fragment so renderer can use them. // This is needed because the original measure has different line breaks. - if (didRemeasureForColumnWidth) { + if (didRemeasureForColumnWidth || didRemeasureForFloats) { fragment.lines = lines.slice(fromLine, slice.toLine); } diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 5f05c74c56..35d59ec824 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -118,6 +118,7 @@ export const createDomPainter = ( setVirtualizationPins?: (pageIndices: number[] | null | undefined) => void; setActiveComment?: (commentId: string | null) => void; getActiveComment?: () => string | null; + onScroll?: () => void; } => { const painter = new DomPainter(options.blocks, options.measures, { pageStyles: options.pageStyles, @@ -156,5 +157,9 @@ export const createDomPainter = ( getActiveComment() { return painter.getActiveComment(); }, + // Trigger virtualization update when scroll container is external to the painter + onScroll() { + painter.onScroll(); + }, }; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index e4cc727b89..e4d16674ac 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -805,6 +805,7 @@ export class DomPainter { private virtualPaddingTop: number | null = null; // px; computed from mount if not provided private topSpacerEl: HTMLElement | null = null; private bottomSpacerEl: HTMLElement | null = null; + private virtualPagesEl: HTMLElement | null = null; private virtualGapSpacers: HTMLElement[] = []; private virtualPinnedPages: number[] = []; private virtualMountedKey = ''; @@ -1065,8 +1066,8 @@ export class DomPainter { applyStyles(mount, containerStyles); if (this.virtualEnabled) { - // Override container gap for consistent spacer math - mount.style.gap = `${this.virtualGap}px`; + // Keep container gap at 0 so spacers don't introduce extra offsets. + mount.style.gap = '0px'; this.renderVirtualized(layout, mount); this.currentLayout = layout; this.changedBlocks.clear(); @@ -1096,7 +1097,7 @@ export class DomPainter { this.currentLayout = layout; // First-time init or mount changed - const needsInit = !this.topSpacerEl || !this.bottomSpacerEl || this.mount !== mount; + const needsInit = !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || this.mount !== mount; if (needsInit) { this.ensureVirtualizationSetup(mount); } @@ -1121,7 +1122,16 @@ export class DomPainter { this.configureSpacerElement(this.topSpacerEl, 'top'); this.configureSpacerElement(this.bottomSpacerEl, 'bottom'); + // Create and configure pages container (handles the inter-page gap) + this.virtualPagesEl = this.doc.createElement('div'); + this.virtualPagesEl.style.display = 'flex'; + this.virtualPagesEl.style.flexDirection = 'column'; + this.virtualPagesEl.style.alignItems = 'center'; + this.virtualPagesEl.style.width = '100%'; + this.virtualPagesEl.style.gap = `${this.virtualGap}px`; + mount.appendChild(this.topSpacerEl); + mount.appendChild(this.virtualPagesEl); mount.appendChild(this.bottomSpacerEl); // Bind scroll and resize handlers @@ -1207,8 +1217,19 @@ export class DomPainter { return 0; } + /** + * Public method to trigger virtualization window update on scroll. + * Call this from external scroll handlers when the scroll container + * is different from the painter's mount element. + */ + public onScroll(): void { + if (this.virtualEnabled) { + this.updateVirtualWindow(); + } + } + private updateVirtualWindow(): void { - if (!this.mount || !this.topSpacerEl || !this.bottomSpacerEl || !this.currentLayout) return; + if (!this.mount || !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || !this.currentLayout) return; const layout = this.currentLayout; const N = layout.pages.length; if (N === 0) { @@ -1294,7 +1315,7 @@ export class DomPainter { newState.element.dataset.pageIndex = String(i); // Ensure virtualization uses page margin 0 applyStyles(newState.element, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); - this.mount.insertBefore(newState.element, this.bottomSpacerEl); + this.virtualPagesEl.appendChild(newState.element); this.pageIndexToState.set(i, newState); } else { // Patch in place @@ -1302,10 +1323,13 @@ export class DomPainter { } } - // Ensure top spacer is first and bottom spacer is last. + // Ensure top spacer is first, pages container is in the middle, and bottom spacer is last. if (this.mount.firstChild !== this.topSpacerEl) { this.mount.insertBefore(this.topSpacerEl, this.mount.firstChild); } + if (this.virtualPagesEl.parentElement !== this.mount) { + this.mount.insertBefore(this.virtualPagesEl, this.bottomSpacerEl); + } this.mount.appendChild(this.bottomSpacerEl); // Ensure mounted pages are ordered (with gap spacers) before bottom spacer. @@ -1320,10 +1344,10 @@ export class DomPainter { this.topOfIndex(idx) - this.topOfIndex(prevIndex) - this.virtualHeights[prevIndex] - this.virtualGap * 2; gap.style.height = `${Math.max(0, Math.floor(gapHeight))}px`; this.virtualGapSpacers.push(gap); - this.mount.insertBefore(gap, this.bottomSpacerEl); + this.virtualPagesEl.appendChild(gap); } const state = this.pageIndexToState.get(idx)!; - this.mount.insertBefore(state.element, this.bottomSpacerEl); + this.virtualPagesEl.appendChild(state.element); prevIndex = idx; } @@ -1440,9 +1464,10 @@ export class DomPainter { pageNumberText: page.numberText, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); + const fragments = this.dedupeFragments(page); + const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup); - page.fragments.forEach((fragment, index) => { + fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); el.appendChild(this.renderFragment(fragment, contextBase, sdtBoundary)); }); @@ -1687,6 +1712,7 @@ export class DomPainter { this.pageIndexToState.clear(); this.topSpacerEl = null; this.bottomSpacerEl = null; + this.virtualPagesEl = null; this.onScrollHandler = null; this.onWindowScrollHandler = null; this.onResizeHandler = null; @@ -1747,7 +1773,8 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); + const fragments = this.dedupeFragments(page); + const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -1756,7 +1783,7 @@ export class DomPainter { pageNumberText: page.numberText, }; - page.fragments.forEach((fragment, index) => { + fragments.forEach((fragment, index) => { const key = fragmentKey(fragment); const current = existing.get(key); const sdtBoundary = sdtBoundaries.get(index); @@ -1883,9 +1910,9 @@ export class DomPainter { section: 'body', }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); - - const fragments: FragmentDomState[] = page.fragments.map((fragment, index) => { + const fragments = this.dedupeFragments(page); + const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup); + const fragmentStates: FragmentDomState[] = fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const fragmentEl = this.renderFragment(fragment, contextBase, sdtBoundary); el.appendChild(fragmentEl); @@ -1899,7 +1926,27 @@ export class DomPainter { }); this.renderDecorationsForPage(el, page); - return { element: el, fragments }; + return { element: el, fragments: fragmentStates }; + } + + private dedupeFragments(page: Page): Fragment[] { + const fragments = page.fragments; + if (fragments.length <= 1) return fragments; + const seen = new Set(); + const result: Fragment[] = []; + + for (let i = fragments.length - 1; i >= 0; i -= 1) { + const fragment = fragments[i]; + const key = fragmentKey(fragment); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(fragment); + } + result.reverse(); + + return result; } private getEffectivePageStyles(): PageStyles | undefined { @@ -2001,7 +2048,6 @@ export class DomPainter { // Use fragment.lines if available (set when paragraph was remeasured for narrower column). // Otherwise, fall back to slicing from the original measure. const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); - applyParagraphBlockStyles(fragmentEl, block.attrs); const { shadingLayer, borderLayer } = createParagraphDecorationLayers(this.doc, fragment.width, block.attrs); if (shadingLayer) { diff --git a/packages/layout-engine/pm-adapter/src/cache.ts b/packages/layout-engine/pm-adapter/src/cache.ts new file mode 100644 index 0000000000..4eb549ff50 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/cache.ts @@ -0,0 +1,204 @@ +/** + * FlowBlock Cache for Incremental toFlowBlocks Conversion + * + * This cache stores converted blocks from paragraph nodes, keyed by their stable ID (sdBlockId/paraId). + * A single paragraph PM node can produce multiple FlowBlocks (page breaks, drawings, paragraph block), + * so we cache the entire array of blocks produced from each paragraph. + * + * This enables reusing previously converted blocks when the paragraph content hasn't changed, + * reducing toFlowBlocks time from ~35ms to ~5ms for typical single-character edits. + * + * Cache Lifecycle: + * 1. begin() - Called at start of toFlowBlocks, clears the "next" map + * 2. get() - Check if a paragraph with given ID exists and content matches + * 3. set() - Store converted blocks in the "next" map + * 4. commit() - Swap "next" to "previous", only retaining blocks seen this render + * 5. clear() - Reset cache on document load or major mode changes + */ + +import type { FlowBlock, ParagraphBlock } from '@superdoc/contracts'; +import type { PMNode } from './types.js'; + +export type CachedParagraphEntry = { + /** JSON string of the PM node for equality comparison */ + nodeJson: string; + /** All FlowBlocks produced from this paragraph (may include page breaks, drawings, etc.) */ + blocks: FlowBlock[]; + /** The PM document position where this paragraph node started */ + pmStart: number; +}; + +export type FlowBlockCacheStats = { + hits: number; + misses: number; +}; + +/** + * Result of a cache lookup. Always includes the serialized node JSON + * to avoid double serialization when storing on cache miss. + */ +export type CacheLookupResult = { + /** The cached entry if found and content matches, null otherwise */ + entry: CachedParagraphEntry | null; + /** Pre-computed JSON string of the node (reuse this in set() to avoid double serialization) */ + nodeJson: string; +}; + +export class FlowBlockCache { + #previous = new Map(); + #next = new Map(); + #hits = 0; + #misses = 0; + + /** + * Begin a new render cycle. Clears the "next" map and resets stats. + */ + begin(): void { + this.#next.clear(); + this.#hits = 0; + this.#misses = 0; + } + + /** + * Look up cached blocks for a paragraph by its stable ID. + * Returns the cached entry only if the node content matches (via JSON comparison). + * + * Always returns the serialized nodeJson to avoid double serialization - + * pass this to set() instead of the node object. + * + * @param id - Stable paragraph ID (sdBlockId or paraId) + * @param node - Current PM node (JSON object) to compare against cached version + * @returns Lookup result with entry (if hit) and pre-computed nodeJson + */ + get(id: string, node: PMNode): CacheLookupResult { + // Serialize once - this is reused in set() to avoid double serialization + const nodeJson = JSON.stringify(node); + + const cached = this.#previous.get(id); + if (!cached) { + this.#misses++; + return { entry: null, nodeJson }; + } + + // Compare with pre-serialized content + if (cached.nodeJson !== nodeJson) { + this.#misses++; + return { entry: null, nodeJson }; + } + + this.#hits++; + return { entry: cached, nodeJson }; + } + + /** + * Store converted blocks for a paragraph in the cache. + * + * @param id - Stable paragraph ID + * @param nodeJson - Pre-computed JSON string of the node (from get() result) + * @param blocks - All FlowBlocks produced from this paragraph + * @param pmStart - PM document position where this paragraph starts + */ + set(id: string, nodeJson: string, blocks: FlowBlock[], pmStart: number): void { + this.#next.set(id, { nodeJson, blocks, pmStart }); + } + + /** + * Commit the current render cycle. + * Swaps "next" to "previous", so only blocks seen in this render are retained. + */ + commit(): void { + this.#previous = this.#next; + this.#next = new Map(); + } + + /** + * Clear the entire cache. + * Call this on document load or when conversion settings change. + */ + clear(): void { + this.#previous.clear(); + this.#next.clear(); + } + + /** + * Get cache statistics for the current render cycle. + */ + get stats(): FlowBlockCacheStats { + return { hits: this.#hits, misses: this.#misses }; + } +} + +/** + * Shift PM positions in a single block by a delta. + * + * When reusing cached blocks, the paragraph's position in the document may have + * shifted (e.g., text was inserted earlier in the doc). This function adjusts + * the pmStart/pmEnd values to reflect the new position. + * + * Always returns a shallow copy to prevent cache pollution from downstream mutations. + * + * @param block - The block to shift + * @param delta - The position delta (newPmStart - oldPmStart) + * @returns A new block (shallow copy) with shifted positions + */ +export function shiftBlockPositions(block: FlowBlock, delta: number): FlowBlock { + // Handle paragraph blocks with runs - always copy to prevent cache pollution + if (block.kind === 'paragraph') { + const paragraphBlock = block as ParagraphBlock; + return { + ...paragraphBlock, + runs: paragraphBlock.runs.map((run) => ({ + ...run, + pmStart: run.pmStart == null ? run.pmStart : run.pmStart + delta, + pmEnd: run.pmEnd == null ? run.pmEnd : run.pmEnd + delta, + })), + }; + } + + // For other block types, always create a shallow copy to prevent cache pollution. + // If the block has position tracking, shift the positions. + const blockWithPos = block as FlowBlock & { pmStart?: number; pmEnd?: number }; + if (blockWithPos.pmStart != null || blockWithPos.pmEnd != null) { + return { + ...block, + pmStart: blockWithPos.pmStart == null ? blockWithPos.pmStart : blockWithPos.pmStart + delta, + pmEnd: blockWithPos.pmEnd == null ? blockWithPos.pmEnd : blockWithPos.pmEnd + delta, + } as unknown as FlowBlock; + } + + // No position tracking, but still return a shallow copy to prevent cache pollution + return { ...block } as FlowBlock; +} + +/** + * Shift PM positions in all blocks from a cached entry by a delta. + * + * @param blocks - Array of blocks to shift + * @param delta - The position delta (newPmStart - oldPmStart) + * @returns New array of blocks with shifted positions + */ +export function shiftCachedBlocks(blocks: FlowBlock[], delta: number): FlowBlock[] { + // Always map to new array with copied blocks to prevent cache pollution, + // even when delta is 0. shiftBlockPositions handles shallow copying. + return blocks.map((block) => shiftBlockPositions(block, delta)); +} + +/** + * Extract stable paragraph ID from PM node attributes. + * + * Uses sdBlockId (preferred) or paraId (fallback) from the node's attrs. + * These IDs are stable across edits and are used as cache keys. + * + * @param node - PM node (JSON object) to extract ID from + * @returns Stable ID string, or null if no stable ID is available + */ +export function getStableParagraphId(node: PMNode): string | null { + const attrs = node.attrs; + if (!attrs) return null; + + // Prefer sdBlockId (superdoc's internal ID), fallback to paraId (from DOCX w14:paraId) + const id = attrs.sdBlockId ?? attrs.paraId; + if (id == null) return null; + + return String(id); +} diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index c09c29cd60..748fc15260 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -17,6 +17,7 @@ import type { BlockIdGenerator, PositionMap, } from '../types.js'; +import { getStableParagraphId, shiftCachedBlocks } from '../cache.js'; import type { ConverterContext } from '../converter-context.js'; import { computeParagraphAttrs, deepClone } from '../attributes/index.js'; import { shouldRequirePageBoundary, hasIntrinsicBoundarySignals, createSectionBreakBlock } from '../sections/index.js'; @@ -253,12 +254,22 @@ export function paragraphToFlowBlocks({ converters, converterContext, enableComments = true, + stableBlockId, }: ParagraphToFlowBlocksParams): FlowBlock[] { + // Use stable ID if provided, otherwise fall back to generator + const baseBlockId = stableBlockId ?? nextBlockId('paragraph'); + + // When stableBlockId is provided, create a deterministic ID generator for inline blocks + // (images, shapes, tables, etc.) to ensure consistent IDs across cached/uncached renders. + // This prevents ID drift that would cause unnecessary dirty regions. + let inlineBlockCounter = 0; + const stableNextBlockId: BlockIdGenerator = stableBlockId + ? (prefix: string) => `${stableBlockId}-${prefix}-${inlineBlockCounter++}` + : nextBlockId; const paragraphProps = typeof para.attrs?.paragraphProperties === 'object' && para.attrs.paragraphProperties !== null ? (para.attrs.paragraphProperties as ParagraphProperties) : {}; - const baseBlockId = nextBlockId('paragraph'); const { paragraphAttrs, resolvedParagraphProperties } = computeParagraphAttrs(para, converterContext); const blocks: FlowBlock[] = []; @@ -274,7 +285,8 @@ export function paragraphToFlowBlocks({ if (paragraphAttrs.pageBreakBefore) { blocks.push({ kind: 'pageBreak', - id: nextBlockId('pageBreak'), + // Use deterministic suffix when stable ID is provided, otherwise use generator + id: stableBlockId ? `${stableBlockId}-pageBreak` : nextBlockId('pageBreak'), attrs: { source: 'pageBreakBefore' }, }); } @@ -379,12 +391,12 @@ export function paragraphToFlowBlocks({ bookmarks, tabOrdinal, paragraphAttrs, - nextBlockId, + nextBlockId: stableNextBlockId, }; const blockOptions: BlockConverterOptions = { blocks, - nextBlockId, + nextBlockId: stableNextBlockId, nextId, positions, trackedChangesConfig, @@ -435,13 +447,15 @@ export function paragraphToFlowBlocks({ throw error; } } - return; } - } else if (SHAPE_CONVERTERS_REGISTRY[node.type]) { + return; + } + + if (SHAPE_CONVERTERS_REGISTRY[node.type]) { const anchorParagraphId = nextId(); flushParagraph(); const converter = SHAPE_CONVERTERS_REGISTRY[node.type]; - const drawingBlock = converter(node, nextBlockId, positions); + const drawingBlock = converter(node, stableNextBlockId, positions); if (drawingBlock) { blocks.push(attachAnchorParagraphId(drawingBlock, anchorParagraphId)); } @@ -587,6 +601,12 @@ const SHAPE_CONVERTERS_REGISTRY: Record< * Special handling: Emits section breaks BEFORE processing the paragraph * if this paragraph starts a new section. * + * Supports incremental conversion via FlowBlockCache: + * - If cache is available and paragraph has stable ID (sdBlockId/paraId) + * - Check cache for matching node content + * - On cache hit: reuse blocks with position adjustment + * - On cache miss: convert normally and store in cache + * * @param node - Paragraph node to process * @param context - Shared handler context */ @@ -595,6 +615,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): blocks, recordBlockKind, nextBlockId, + blockIdPrefix = '', positions, trackedChangesConfig, bookmarks, @@ -603,6 +624,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): converters, converterContext, themeColors, + flowBlockCache, enableComments, } = context; const { ranges: sectionRanges, currentSectionIndex, currentParagraphIndex } = sectionState!; @@ -623,6 +645,55 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): } const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; + const stableId = getStableParagraphId(node); + const prefixedStableId = stableId ? `${blockIdPrefix}${stableId}` : null; + const nodePos = positions.get(node); + const pmStart = nodePos?.start ?? 0; + + if (prefixedStableId && flowBlockCache) { + // get() returns both the entry (if hit) and pre-computed nodeJson to avoid double serialization + const { entry: cached, nodeJson } = flowBlockCache.get(prefixedStableId, node); + if (cached) { + // Cache hit: reuse blocks with position adjustment + const delta = pmStart - cached.pmStart; + const reusedBlocks = shiftCachedBlocks(cached.blocks, delta); + + reusedBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind(block.kind); + }); + + // Store in next cache generation with current position (reuse nodeJson) + flowBlockCache.set(prefixedStableId, nodeJson, reusedBlocks, pmStart); + sectionState.currentParagraphIndex++; + return; + } + + // Cache miss: convert normally, then store using pre-computed nodeJson + const paragraphBlocks = paragraphToFlowBlocks({ + para: node, + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + themeColors, + converters, + converterContext, + enableComments, + stableBlockId: prefixedStableId, + }); + + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind(block.kind); + }); + + // Store in cache using pre-computed nodeJson (avoids double serialization) + flowBlockCache.set(prefixedStableId, nodeJson, paragraphBlocks, pmStart); + sectionState.currentParagraphIndex++; + return; + } const paragraphBlocks = paragraphToFlowBlocks({ para: node, @@ -632,9 +703,10 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): bookmarks, hyperlinkConfig, themeColors, - converterContext, converters, + converterContext, enableComments, + stableBlockId: prefixedStableId ?? undefined, }); paragraphBlocks.forEach((block) => { blocks.push(block); diff --git a/packages/layout-engine/pm-adapter/src/index.d.ts b/packages/layout-engine/pm-adapter/src/index.d.ts index 7203f384a8..f77a2e6cb2 100644 --- a/packages/layout-engine/pm-adapter/src/index.d.ts +++ b/packages/layout-engine/pm-adapter/src/index.d.ts @@ -29,6 +29,9 @@ export type { PMDocumentMap, BatchAdapterOptions, FlowBlocksResult, + ConverterContext, } from './types.js'; export { SectionType } from './types.js'; -export { toFlowBlocks } from './internal.js'; +export { toFlowBlocks, toFlowBlocksMap } from './internal.js'; +export { FlowBlockCache } from './cache.js'; +export type { CachedParagraphEntry, FlowBlockCacheStats, CacheLookupResult } from './cache.js'; diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 26e1e8a4d3..6904b34f86 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -1203,6 +1203,24 @@ describe('toFlowBlocks', () => { expect(block.id.startsWith('header-default-')).toBe(true); }); }); + + it('applies blockIdPrefix to stable paragraph ids', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { sdBlockId: 'ABC123' }, + content: [{ type: 'text', text: 'Alpha' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { blockIdPrefix: 'doc-' }); + const paragraph = blocks.find((block) => block.kind === 'paragraph'); + + expect(paragraph?.id).toBe('doc-ABC123'); + }); }); it('populates pm ranges on runs', () => { diff --git a/packages/layout-engine/pm-adapter/src/index.ts b/packages/layout-engine/pm-adapter/src/index.ts index 8294f80af7..21ce57c9cb 100644 --- a/packages/layout-engine/pm-adapter/src/index.ts +++ b/packages/layout-engine/pm-adapter/src/index.ts @@ -38,4 +38,8 @@ export type { export { SectionType } from './types.js'; // Re-export public API functions from internal implementation -export { toFlowBlocks } from './internal.js'; +export { toFlowBlocks, toFlowBlocksMap } from './internal.js'; + +// Re-export cache for incremental conversion +export { FlowBlockCache } from './cache.js'; +export type { CachedParagraphEntry, FlowBlockCacheStats, CacheLookupResult } from './cache.js'; diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 7ee06fb909..1eb1da8f8d 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -45,10 +45,12 @@ import type { HyperlinkConfig, FlowBlocksResult, AdapterOptions, + BatchAdapterOptions, NodeHandlerContext, NodeHandler, NestedConverters, ConverterContext, + PMDocumentMap, } from './types.js'; const DEFAULT_FONT = 'Times New Roman'; @@ -118,8 +120,13 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): const idPrefix = normalizePrefix(options?.blockIdPrefix); const doc = pmDoc as PMNode; + const flowBlockCache = options?.flowBlockCache; + + // Begin cache cycle if cache is provided + flowBlockCache?.begin(); if (!doc.content) { + flowBlockCache?.commit(); return { blocks: [], bookmarks: new Map() }; } @@ -133,6 +140,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): enableRichHyperlinks: options?.enableRichHyperlinks ?? false, }; const enableComments = options?.enableComments ?? true; + const themeColors = options?.themeColors; const converterContext: ConverterContext = normalizeConverterContext( options?.converterContext, defaultFont, @@ -171,6 +179,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): blocks, recordBlockKind, nextBlockId, + blockIdPrefix: idPrefix, positions, defaultFont, defaultSize, @@ -185,7 +194,8 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): currentParagraphIndex: 0, }, converters, - themeColors: options?.themeColors, + themeColors, + flowBlockCache, }; // Process nodes using handler dispatch pattern @@ -216,9 +226,34 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): // Post-process: Merge drop-cap paragraphs with their following text paragraphs const mergedBlocks = mergeDropCapParagraphs(hydratedBlocks); + // Commit cache cycle - swaps next to previous, retaining only blocks seen this render + flowBlockCache?.commit(); + return { blocks: mergedBlocks, bookmarks }; } +/** + * Batch convert a map of ProseMirror documents to FlowBlocks. + * + * Applies optional per-document block ID prefixes via blockIdPrefixFactory. + * + * @param documents - Map of document keys to PM nodes + * @param options - Optional batch options (shared across documents) + * @returns Map of document keys to FlowBlock arrays + */ +export function toFlowBlocksMap(documents: PMDocumentMap, options?: BatchAdapterOptions): Record { + const results: Record = {}; + const prefixFactory = options?.blockIdPrefixFactory; + + Object.entries(documents).forEach(([key, doc]) => { + const blockIdPrefix = prefixFactory ? prefixFactory(key) : options?.blockIdPrefix; + const result = toFlowBlocks(doc, { ...options, blockIdPrefix }); + results[key] = result.blocks; + }); + + return results; +} + /** * Merge drop-cap paragraphs with their following text paragraphs. * diff --git a/packages/layout-engine/pm-adapter/src/types.d.ts b/packages/layout-engine/pm-adapter/src/types.d.ts index 94988a9af4..13de3db49f 100644 --- a/packages/layout-engine/pm-adapter/src/types.d.ts +++ b/packages/layout-engine/pm-adapter/src/types.d.ts @@ -3,16 +3,11 @@ */ import type { TrackedChangesMode, SectionMetadata, FlowBlock, TrackedChangeMeta } from '@superdoc/contracts'; import type { Engines } from '@superdoc/contracts'; -import type { - StyleContext as StyleEngineContext, - StyleNode as StyleEngineNode, - ComputedParagraphStyle, -} from '@superdoc/style-engine'; +import type { StyleContext as StyleEngineContext, ComputedParagraphStyle } from '@superdoc/style-engine'; import type { SectionRange } from './sections/index.js'; import type { ConverterContext } from './converter-context.js'; export type { ConverterContext } from './converter-context.js'; export type StyleContext = StyleEngineContext; -export type StyleNode = StyleEngineNode; export type { ComputedParagraphStyle }; export type ThemeColorPalette = Record; /** @@ -230,6 +225,7 @@ export interface NodeHandlerContext { blocks: FlowBlock[]; recordBlockKind: (kind: string) => void; nextBlockId: BlockIdGenerator; + blockIdPrefix?: string; positions: PositionMap; defaultFont: string; defaultSize: number; diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 71a12418e1..751a4c0dd9 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -2,7 +2,8 @@ * Type definitions for ProseMirror to FlowBlock adapter */ -import type { TrackedChangesMode, SectionMetadata, FlowBlock } from '@superdoc/contracts'; +import type { TrackedChangesMode, SectionMetadata, FlowBlock, TrackedChangeMeta } from '@superdoc/contracts'; +import type { StyleContext as StyleEngineContext, ComputedParagraphStyle } from '@superdoc/style-engine'; import type { SectionRange } from './sections/index.js'; import type { ConverterContext } from './converter-context.js'; import type { paragraphToFlowBlocks } from './converters/paragraph.js'; @@ -16,6 +17,8 @@ import type { vectorShapeNodeToDrawingBlock, } from './converters/shapes.js'; export type { ConverterContext } from './converter-context.js'; +export type StyleContext = StyleEngineContext; +export type { ComputedParagraphStyle }; export type ThemeColorPalette = Record; @@ -176,6 +179,17 @@ export interface AdapterOptions { * renders match the original Word document more closely. */ converterContext?: ConverterContext; + + /** + * Optional FlowBlock cache for incremental conversion. + * When provided, paragraph blocks are cached and reused when content hasn't changed. + * This can significantly improve toFlowBlocks performance for large documents. + * + * The cache is managed externally (typically by PresentationEditor) and should + * persist across render cycles. Call cache.clear() on document load or when + * conversion settings change (tracked changes mode, comments enabled, etc.). + */ + flowBlockCache?: import('./cache.js').FlowBlockCache; } /** @@ -263,6 +277,7 @@ export interface NodeHandlerContext { // ID generation & positions nextBlockId: BlockIdGenerator; + blockIdPrefix?: string; positions: PositionMap; // Style & defaults @@ -290,6 +305,8 @@ export interface NodeHandlerContext { // Converters for nested content converters: NestedConverters; themeColors?: ThemeColorPalette; + // FlowBlock cache for incremental conversion (optional) + flowBlockCache?: import('./cache.js').FlowBlockCache; } /** @@ -298,6 +315,15 @@ export interface NodeHandlerContext { */ export type NodeHandler = (node: PMNode, context: NodeHandlerContext) => void; +/** + * List counter context for numbering + */ +export type ListCounterContext = { + getListCounter: (numId: number, ilvl: number) => number; + incrementListCounter: (numId: number, ilvl: number) => number; + resetListCounter: (numId: number, ilvl: number) => void; +}; + export type ParagraphToFlowBlocksParams = { para: PMNode; nextBlockId: BlockIdGenerator; @@ -309,6 +335,7 @@ export type ParagraphToFlowBlocksParams = { converters: NestedConverters; enableComments: boolean; converterContext: ConverterContext; + stableBlockId?: string; }; export type TableNodeToBlockParams = { @@ -323,6 +350,42 @@ export type TableNodeToBlockParams = { enableComments: boolean; }; +export type ParagraphToFlowBlocksConverter = ( + para: PMNode, + nextBlockId: BlockIdGenerator, + positions: PositionMap, + defaultFont: string, + defaultSize: number, + styleContext: StyleContext, + listCounterContext?: ListCounterContext, + trackedChanges?: TrackedChangesConfig, + bookmarks?: Map, + hyperlinkConfig?: HyperlinkConfig, + themeColors?: ThemeColorPalette, + converterContext?: ConverterContext, + enableComments?: boolean, + stableBlockId?: string, +) => FlowBlock[]; + +export type ImageNodeToBlockConverter = ( + node: PMNode, + nextBlockId: BlockIdGenerator, + positions: PositionMap, + trackedMeta?: TrackedChangeMeta, + trackedChanges?: TrackedChangesConfig, +) => FlowBlock | null; + +export type DrawingNodeToBlockConverter = ( + node: PMNode, + nextBlockId: BlockIdGenerator, + positions: PositionMap, +) => FlowBlock | null; + +export type TableNodeToBlockOptions = { + listCounterContext?: ListCounterContext; + converters?: NestedConverters; +}; + export type NestedConverters = { paragraphToFlowBlocks: typeof paragraphToFlowBlocks; tableNodeToBlock: typeof tableNodeToBlock; diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.test.ts index 2b8897b0e8..62d41ccc61 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.test.ts @@ -99,9 +99,13 @@ vi.mock('@extensions/collaboration/collaboration-helpers.js', () => ({ updateYdocDocxData: mockUpdateYdocDocxData, })); -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); const createConverter = () => ({ headers: { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index b0209ba30c..8484b7e09d 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -62,7 +62,7 @@ import { DragDropManager } from './input/DragDropManager.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; import { decodeRPrFromMarks } from '../super-converter/styles.js'; import { halfPointToPoints } from '../super-converter/helpers.js'; -import { toFlowBlocks, ConverterContext } from '@superdoc/pm-adapter'; +import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; import { incrementalLayout, selectionToRects, @@ -78,11 +78,12 @@ import type { HeaderFooterLayoutResult, HeaderFooterType, PositionHit, - MultiSectionHeaderFooterIdentifier, TableHitResult, } from '@superdoc/layout-bridge'; + import { createDomPainter } from '@superdoc/painter-dom'; -import type { LayoutMode, PageDecorationProvider, RulerOptions } from '@superdoc/painter-dom'; + +import type { LayoutMode } from '@superdoc/painter-dom'; import { measureBlock } from '@superdoc/measuring-dom'; import type { ColumnLayout, @@ -254,6 +255,9 @@ export class PresentationEditor extends EventEmitter { #hiddenHost: HTMLElement; #layoutOptions: LayoutEngineOptions; #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; + /** Cache for incremental toFlowBlocks conversion */ + #flowBlockCache: FlowBlockCache = new FlowBlockCache(); + #footnoteNumberSignature: string | null = null; #domPainter: ReturnType | null = null; #pageGeometryHelper: PageGeometryHelper | null = null; #dragDropManager: DragDropManager | null = null; @@ -275,6 +279,8 @@ export class PresentationEditor extends EventEmitter { #domIndexObserverManager: DomPositionIndexObserverManager | null = null; #rafHandle: number | null = null; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; + #scrollHandler: (() => void) | null = null; + #scrollContainer: Element | Window | null = null; #sectionMetadata: SectionMetadata[] = []; #documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing'; #inputBridge: PresentationInputBridge | null = null; @@ -1031,6 +1037,8 @@ export class PresentationEditor extends EventEmitter { // Re-render if mode changed OR tracked changes preferences changed. // Mode change affects enableComments in toFlowBlocks even if tracked changes didn't change. if (modeChanged || trackedChangesChanged) { + // Clear flow block cache since conversion-affecting settings changed + this.#flowBlockCache.clear(); this.#pendingDocChange = true; this.#scheduleRerender(); } @@ -1068,6 +1076,8 @@ export class PresentationEditor extends EventEmitter { this.#layoutOptions.trackedChanges = overrides; const trackedChangesChanged = this.#syncTrackedChangesPreferences(); if (trackedChangesChanged) { + // Clear flow block cache since conversion-affecting settings changed + this.#flowBlockCache.clear(); this.#pendingDocChange = true; this.#scheduleRerender(); } @@ -1102,6 +1112,8 @@ export class PresentationEditor extends EventEmitter { } if (hasChanges) { + // Clear flow block cache since comment settings affect block conversion + this.#flowBlockCache.clear(); this.#pendingDocChange = true; this.#scheduleRerender(); } @@ -2136,6 +2148,15 @@ export class PresentationEditor extends EventEmitter { }, 'Editor input manager'); } + if (this.#scrollHandler) { + if (this.#scrollContainer) { + this.#scrollContainer.removeEventListener('scroll', this.#scrollHandler); + } + const win = this.#visibleHost?.ownerDocument?.defaultView; + win?.removeEventListener('scroll', this.#scrollHandler); + this.#scrollHandler = null; + this.#scrollContainer = null; + } this.#inputBridge?.notifyTargetChanged(); this.#inputBridge?.destroy(); this.#inputBridge = null; @@ -2156,6 +2177,9 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession = null; }, 'Header/footer session manager'); + // Clear flow block cache to free memory + this.#flowBlockCache.clear(); + this.#domPainter = null; this.#pageGeometryHelper = null; this.#dragDropManager?.destroy(); @@ -2429,6 +2453,52 @@ export class PresentationEditor extends EventEmitter { #setupPointerHandlers() { // Delegate to EditorInputManager for pointer events this.#editorInputManager?.bind(); + + // Scroll handler for virtualization - find the actual scroll container + // by walking up the DOM tree to find the first scrollable ancestor + this.#scrollHandler = () => { + this.#domPainter?.onScroll?.(); + }; + + // Find the scrollable ancestor and attach listener there + this.#scrollContainer = this.#findScrollableAncestor(this.#visibleHost); + if (this.#scrollContainer) { + this.#scrollContainer.addEventListener('scroll', this.#scrollHandler, { passive: true }); + } + + // Also listen on window as fallback + const win = this.#visibleHost.ownerDocument?.defaultView; + if (win && this.#scrollContainer !== win) { + win.addEventListener('scroll', this.#scrollHandler, { passive: true }); + } + } + + /** + * Finds the first scrollable ancestor of an element. + * Returns the element itself if it's scrollable, or walks up the tree. + * + * Note: We only check for overflow CSS property, not whether content currently + * overflows. At setup time, content may not be laid out yet, but the element + * with overflow:auto/scroll will become the scroll container once content grows. + */ + #findScrollableAncestor(element: HTMLElement): Element | Window | null { + const win = element.ownerDocument?.defaultView; + if (!win) return null; + + let current: Element | null = element; + while (current) { + const style = win.getComputedStyle(current); + const overflowY = style.overflowY; + // Check for scrollable overflow property - don't require hasScroll since + // content may not be laid out yet at setup time + if (overflowY === 'auto' || overflowY === 'scroll') { + return current; + } + current = current.parentElement; + } + + // If no scrollable ancestor found, return window + return win; } /** @@ -2768,6 +2838,7 @@ export class PresentationEditor extends EventEmitter { // Compute visible footnote numbering (1-based) by first appearance in the document. // This matches Word behavior even when OOXML ids are non-contiguous or start at 0. const footnoteNumberById: Record = {}; + const footnoteOrder: string[] = []; try { const seen = new Set(); let counter = 1; @@ -2779,9 +2850,22 @@ export class PresentationEditor extends EventEmitter { if (!key || seen.has(key)) return; seen.add(key); footnoteNumberById[key] = counter; + footnoteOrder.push(key); counter += 1; }); - } catch {} + } catch (e) { + // Log traversal errors - footnote numbering may be incorrect if this fails + if (typeof console !== 'undefined' && console.warn) { + console.warn('[PresentationEditor] Failed to compute footnote numbering:', e); + } + } + // Invalidate flow block cache when footnote order changes, since footnote + // numbers are embedded in cached blocks and must be recomputed. + const footnoteSignature = footnoteOrder.join('|'); + if (footnoteSignature !== this.#footnoteNumberSignature) { + this.#flowBlockCache.clear(); + this.#footnoteNumberSignature = footnoteSignature; + } // Expose numbering to node views and layout adapter. try { if (converter && typeof converter === 'object') { @@ -2812,6 +2896,7 @@ export class PresentationEditor extends EventEmitter { enableRichHyperlinks: true, themeColors: this.#editor?.converter?.themeColors ?? undefined, converterContext, + flowBlockCache: this.#flowBlockCache, ...(positionMap ? { positions: positionMap } : {}), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); diff --git a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts index 6eadb82773..de7e0f8324 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts @@ -1,59 +1,42 @@ /** - * Calculates the horizontal offset of a page element within the viewport. + * Calculates the offset of a page element within the viewport. * * Pages are horizontally centered within the painter host container. When the viewport * is wider than the page, this offset must be included in overlay coordinate calculations * to prevent selections from appearing shifted left of the actual content. * + * With virtualization enabled, pages may not start at y=0, so we also return the + * actual Y offset of the page element relative to the viewport. + * * @param options - Configuration object containing DOM elements and page information - * @returns The horizontal offset in layout-space units, or null if calculation fails + * @returns The offsets in layout-space units, or null if calculation fails * * @remarks * Coordinate spaces: * - getBoundingClientRect returns values in screen space (includes zoom transform) - * - Return value is in layout space (divided by zoom to normalize) + * - Return values are in layout space (divided by zoom to normalize) * - Layout space matches the coordinate system used for overlay positioning * * The function accounts for: * - Horizontal centering of pages within the painter container * - Zoom transformation applied to the viewport * - Variable page widths (narrower than viewport) + * - Virtualization spacers that offset page positions * * Returns null if: * - painterHost or viewportHost is null * - Page element with matching data-page-index is not found - * - * This offset is critical for accurate overlay positioning when the viewport is wider - * than the page content, which commonly occurs on large displays or at low zoom levels. - * - * @example - * ```typescript - * const offsetX = getPageOffsetX({ - * painterHost, - * viewportHost, - * zoom: 1.5, - * pageIndex: 2 - * }); - * - * if (offsetX !== null) { - * // Use offsetX when converting page-local coordinates to overlay coordinates - * const overlayX = pageLocalX + offsetX; - * } - * ``` */ -export function getPageOffsetX(options: { +function getPageOffsets(options: { painterHost: HTMLElement | null; viewportHost: HTMLElement | null; zoom: number; pageIndex: number; -}): number | null { +}): { x: number; y: number } | null { if (!options.painterHost || !options.viewportHost) { return null; } - // Pages are horizontally centered inside the painter host. When the viewport is wider - // than the page, the left offset must be included in overlay coordinates or selections - // will appear shifted to the left of the rendered content. const pageEl = options.painterHost.querySelector( `.superdoc-page[data-page-index="${options.pageIndex}"]`, ) as HTMLElement | null; @@ -65,8 +48,22 @@ export function getPageOffsetX(options: { // getBoundingClientRect includes the applied zoom transform; divide by zoom to return // layout-space units that match the rest of the overlay math. const offsetX = (pageRect.left - viewportRect.left) / options.zoom; + const offsetY = (pageRect.top - viewportRect.top) / options.zoom; + + return { x: offsetX, y: offsetY }; +} - return offsetX; +/** + * Calculates the horizontal offset of a page element within the viewport. + * @deprecated Use getPageOffsets for both X and Y offsets. + */ +export function getPageOffsetX(options: { + painterHost: HTMLElement | null; + viewportHost: HTMLElement | null; + zoom: number; + pageIndex: number; +}): number | null { + return getPageOffsets(options)?.x ?? null; } /** @@ -172,18 +169,28 @@ export function convertPageLocalToOverlayCoords(options: { // BOTH #painterHost and #selectionOverlay), both are in the same coordinate system. // We position overlay elements in layout-space coordinates, and the transform handles scaling. // - // Pages are rendered vertically stacked at y = pageIndex * (pageHeight + pageGap). - // The page-local coordinates are already in layout space - just add the page stacking offset. - const pageOffsetX = - getPageOffsetX({ - painterHost: options.painterHost, - viewportHost: options.viewportHost, - zoom: options.zoom, - pageIndex: options.pageIndex, - }) ?? 0; + // With virtualization, pages may not be at their "natural" y position (pageIndex * (pageHeight + pageGap)) + // because unmounted pages are represented by spacer elements. We use the actual DOM position + // of the page element to get accurate coordinates that work with both virtualized and non-virtualized modes. + const pageOffsets = getPageOffsets({ + painterHost: options.painterHost, + viewportHost: options.viewportHost, + zoom: options.zoom, + pageIndex: options.pageIndex, + }); + + // If we can get the actual DOM offsets, use them for accurate positioning + if (pageOffsets) { + return { + x: pageOffsets.x + options.pageLocalX, + y: pageOffsets.y + options.pageLocalY, + }; + } + // Fallback to mathematical calculation for non-mounted pages (shouldn't happen in practice + // since we return null if the page isn't in the DOM, but kept for safety) return { - x: pageOffsetX + options.pageLocalX, + x: options.pageLocalX, y: options.pageIndex * (options.pageHeight + options.pageGap) + options.pageLocalY, }; } diff --git a/packages/super-editor/src/core/presentation-editor/selection/SelectionVirtualizationPins.ts b/packages/super-editor/src/core/presentation-editor/selection/SelectionVirtualizationPins.ts index 929d958dd2..0cf3db1ba1 100644 --- a/packages/super-editor/src/core/presentation-editor/selection/SelectionVirtualizationPins.ts +++ b/packages/super-editor/src/core/presentation-editor/selection/SelectionVirtualizationPins.ts @@ -47,6 +47,7 @@ export function computeSelectionVirtualizationPins(options: { const anchorFrag = getFragmentAtPosition(options.layout, options.blocks, options.measures, anchorPos); const headFrag = getFragmentAtPosition(options.layout, options.blocks, options.measures, headPos); + if (anchorFrag) add(anchorFrag.pageIndex); if (headFrag) add(headFrag.pageIndex); diff --git a/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts index 60834447f8..51b6ea7b27 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -4,24 +4,28 @@ import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuil import type { ConverterContext } from '@superdoc/pm-adapter'; // Mock toFlowBlocks -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { - // Return mock blocks based on blockIdPrefix - if (typeof opts?.blockIdPrefix === 'string') { - const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); - return { - blocks: [ - { - kind: 'paragraph', - runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], - }, - ], - bookmarks: new Map(), - }; - } - return { blocks: [], bookmarks: new Map() }; - }), -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { + // Return mock blocks based on blockIdPrefix + if (typeof opts?.blockIdPrefix === 'string') { + const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); + return { + blocks: [ + { + kind: 'paragraph', + runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], + }, + ], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), + }; +}); // ============================================================================= // Test Helpers diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 2721de8cba..f4d8b95657 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -136,9 +136,13 @@ vi.mock('../../Editor.js', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index 835885b7be..cd31c1a625 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -151,9 +151,13 @@ vi.mock('../../Editor.js', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index b3bc1ebe40..e5e67391e5 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -36,17 +36,21 @@ vi.mock('../../Editor', () => ({ })), })); -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: vi.fn((_: unknown, opts?: any) => { - if (typeof opts?.blockIdPrefix === 'string' && opts.blockIdPrefix.startsWith('footnote-')) { - return { - blocks: [{ kind: 'paragraph', runs: [{ kind: 'text', text: 'Body', pmStart: 5, pmEnd: 9 }] }], - bookmarks: new Map(), - }; - } - return { blocks: [], bookmarks: new Map() }; - }), -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: vi.fn((_: unknown, opts?: any) => { + if (typeof opts?.blockIdPrefix === 'string' && opts.blockIdPrefix.startsWith('footnote-')) { + return { + blocks: [{ kind: 'paragraph', runs: [{ kind: 'text', text: 'Body', pmStart: 5, pmEnd: 9 }] }], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), + }; +}); vi.mock('@superdoc/layout-bridge', () => ({ incrementalLayout: vi.fn(async (...args: any[]) => { diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts index b36d2d47e4..430627c45e 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts @@ -192,9 +192,13 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts index d62e524c68..04ced8d537 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts @@ -118,9 +118,13 @@ vi.mock('../../Editor.js', () => { }; }); -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); vi.mock('@superdoc/layout-bridge', () => ({ incrementalLayout: mockIncrementalLayout, diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index b3f4e5c75e..1f97a9864e 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -184,9 +184,13 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.media.test.ts index fa58e9ac77..eddcb3e2be 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -36,12 +36,16 @@ vi.mock('../../Editor', () => ({ })), })); -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: vi.fn((_, opts) => { - capturedMediaFiles = opts?.mediaFiles; - return { blocks: [], bookmarks: new Map() }; - }), -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: vi.fn((_, opts) => { + capturedMediaFiles = opts?.mediaFiles; + return { blocks: [], bookmarks: new Map() }; + }), + }; +}); vi.mock('@superdoc/layout-bridge', () => ({ incrementalLayout: vi.fn(async () => ({ layout: { pages: [] }, measures: [] })), diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts index d0c2478e1f..27af40f453 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts @@ -192,9 +192,13 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index 0e2c4d64c5..5ebd3ca091 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -207,9 +207,13 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.zoom.test.ts index 8e9e0552a8..221df84239 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.zoom.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.zoom.test.ts @@ -205,9 +205,13 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', () => ({ - toFlowBlocks: mockToFlowBlocks, -})); +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); // Mock layout-bridge functions vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/extensions/run/commands/split-run.js b/packages/super-editor/src/extensions/run/commands/split-run.js index eed5d903a9..83902d411a 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -65,7 +65,14 @@ export function splitBlockPatch(state, dispatch, editor) { atEnd = $from.end(d) == $from.pos + ($from.depth - d); atStart = $from.start(d) == $from.pos - ($from.depth - d); deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1))); - paragraphAttrs = { ...node.attrs }; + paragraphAttrs = /** @type {Record} */ ({ + ...node.attrs, + // Ensure newly created block gets a fresh ID (block-node plugin assigns one) + sdBlockId: null, + // Reset DOCX identifiers on split to avoid duplicate paragraph IDs + paraId: null, + textId: null, + }); types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); splitDepth = d; break; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 7e3f94d642..9b65b04b92 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -183,6 +183,16 @@ export class SuperDoc extends EventEmitter { }; } + // Enable virtualization by default for better performance on large documents. + // Only renders visible pages (~5) instead of all pages. + if (!this.config.layoutEngineOptions.virtualization) { + this.config.layoutEngineOptions.virtualization = { + enabled: true, + window: 5, + overscan: 1, + }; + } + this.config.modules = this.config.modules || {}; if (!Object.prototype.hasOwnProperty.call(this.config.modules, 'comments')) { this.config.modules.comments = {};