From f741e1b4366b57c27ce2a3e43473b46591a23d8c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 2 Feb 2026 21:46:26 -0800 Subject: [PATCH 1/5] fix: performance --- .../layout-engine/contracts/src/pm-range.ts | 5 +- .../layout-engine/layout-bridge/src/diff.ts | 4 + .../layout-bridge/src/incrementalLayout.ts | 49 +++- .../layout-engine/layout-bridge/src/index.ts | 31 ++- .../layout-bridge/test/performance.test.ts | 17 +- .../layout-bridge/vitest.config.ts | 6 +- .../layout-engine/src/layout-paragraph.ts | 1 - .../layout-engine/painters/dom/src/index.ts | 5 + .../painters/dom/src/renderer.ts | 57 +++-- .../layout-engine/pm-adapter/src/cache.ts | 240 ++++++++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 88 ++++++- .../layout-engine/pm-adapter/src/index.d.ts | 5 +- .../pm-adapter/src/index.test.ts | 18 ++ .../layout-engine/pm-adapter/src/index.ts | 6 +- .../layout-engine/pm-adapter/src/internal.ts | 37 ++- .../layout-engine/pm-adapter/src/types.d.ts | 8 +- .../layout-engine/pm-adapter/src/types.ts | 65 ++++- packages/super-editor/src/core/Editor.ts | 73 ++++-- .../super-editor/src/core/ExtensionService.js | 124 ++++++++- .../HeaderFooterRegistry.test.ts | 10 +- .../presentation-editor/PresentationEditor.ts | 139 +++++++++- .../dom/CoordinateTransform.ts | 81 +++--- .../selection/SelectionVirtualizationPins.ts | 1 + .../tests/FootnotesBuilder.test.ts | 40 +-- .../PresentationEditor.draggableFocus.test.ts | 10 +- .../PresentationEditor.focusWrapping.test.ts | 10 +- ...sentationEditor.footnotesPmMarkers.test.ts | 26 +- ...entationEditor.getCurrentPageIndex.test.ts | 10 +- ...PresentationEditor.getElementAtPos.test.ts | 10 +- .../PresentationEditor.goToAnchor.test.ts | 10 +- .../tests/PresentationEditor.media.test.ts | 16 +- ...esentationEditor.sectionPageStyles.test.ts | 10 +- .../tests/PresentationEditor.test.ts | 10 +- .../tests/PresentationEditor.zoom.test.ts | 10 +- .../src/core/types/NodeCategories.ts | 2 + .../src/extensions/block-node/block-node.js | 128 ++++++---- .../src/extensions/comment/comments-plugin.js | 5 + .../src/extensions/linked-styles/plugin.js | 6 + .../src/extensions/paragraph/dropcapPlugin.js | 6 + .../extensions/paragraph/numberingPlugin.js | 137 +++++++++- .../paragraph/numberingPlugin.test.js | 185 ++++++++++++++ .../src/extensions/paragraph/paragraph.js | 5 + .../src/extensions/run/commands/split-run.js | 10 +- .../document-section/helpers.js | 17 +- .../super-editor/src/extensions/tab/tab.js | 20 +- .../src/extensions/types/node-attributes.ts | 2 + packages/superdoc/src/core/SuperDoc.js | 10 + 47 files changed, 1527 insertions(+), 238 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/cache.ts 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/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 7ebe801867..19dd4a932c 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -59,6 +59,7 @@ export type DirtyRegion = { lastStableIndex: number; insertedBlockIds: string[]; deletedBlockIds: string[]; + stableBlockIds: Set; }; /** @@ -85,6 +86,7 @@ export type DirtyRegion = { export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): DirtyRegion => { const prevMap = new Map(previous.map((block, index) => [block.id, { block, index }])); const nextMap = new Map(next.map((block, index) => [block.id, { block, index }])); + const stableBlockIds = new Set(); let firstDirtyIndex = next.length; let lastStableIndex = -1; @@ -97,6 +99,7 @@ export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): D if (prevBlock.id === nextBlock.id && shallowEqual(prevBlock, nextBlock)) { lastStableIndex = nextPointer; + stableBlockIds.add(prevBlock.id); prevPointer += 1; nextPointer += 1; continue; @@ -127,6 +130,7 @@ export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): D lastStableIndex, insertedBlockIds, deletedBlockIds, + stableBlockIds, }; }; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 61383e9068..82433951d4 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -734,44 +734,84 @@ export async function incrementalLayout( constraints: HeaderFooterConstraints; measure?: HeaderFooterMeasureFn; }, + previousMeasures?: Measure[] | null, ): Promise { - const _perfStart = performance.now(); + // Dirty region computation + const dirtyStart = performance.now(); const dirty = computeDirtyRegions(previousBlocks, nextBlocks); + const dirtyTime = performance.now() - dirtyStart; + if (dirty.deletedBlockIds.length > 0) { measureCache.invalidate(dirty.deletedBlockIds); } + // Perf summary emitted at the end of the function. + const { measurementWidth, measurementHeight } = resolveMeasurementConstraints(options, nextBlocks); if (measurementWidth <= 0 || measurementHeight <= 0) { throw new Error('incrementalLayout: invalid measurement constraints resolved from options'); } + const hasPreviousMeasures = Array.isArray(previousMeasures) && previousMeasures.length === previousBlocks.length; + const previousConstraints = hasPreviousMeasures ? resolveMeasurementConstraints(options, previousBlocks) : null; + const canReusePreviousMeasures = + hasPreviousMeasures && + previousConstraints?.measurementWidth === measurementWidth && + previousConstraints?.measurementHeight === measurementHeight; + const previousMeasuresById = canReusePreviousMeasures + ? new Map(previousBlocks.map((block, index) => [block.id, previousMeasures![index]])) + : null; + const measureStart = performance.now(); const constraints = { maxWidth: measurementWidth, maxHeight: measurementHeight }; const measures: Measure[] = []; let cacheHits = 0; let cacheMisses = 0; + let reusedMeasures = 0; + let cacheLookupTime = 0; + let actualMeasureTime = 0; + for (const block of nextBlocks) { if (block.kind === 'sectionBreak') { measures.push({ kind: 'sectionBreak' }); continue; } + + if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) { + const previousMeasure = previousMeasuresById?.get(block.id); + if (previousMeasure) { + measures.push(previousMeasure); + reusedMeasures++; + continue; + } + } + + // Time the cache lookup (includes hashRuns computation) + const lookupStart = performance.now(); const cached = measureCache.get(block, measurementWidth, measurementHeight); + cacheLookupTime += performance.now() - lookupStart; if (cached) { measures.push(cached); cacheHits++; continue; } + + // Time the actual DOM measurement + const measureBlockStart = performance.now(); const measurement = await measureBlock(block, constraints); + actualMeasureTime += performance.now() - measureBlockStart; + measureCache.set(block, measurementWidth, measurementHeight, measurement); measures.push(measurement); cacheMisses++; } const measureEnd = performance.now(); + const totalMeasureTime = measureEnd - measureStart; + perfLog( - `[Perf] 4.1 Measure all blocks: ${(measureEnd - measureStart).toFixed(2)}ms (${cacheMisses} measured, ${cacheHits} cached)`, + `[Perf] 4.1 Measure all blocks: ${totalMeasureTime.toFixed(2)}ms (${cacheMisses} measured, ${cacheHits} cached, ${reusedMeasures} reused)`, ); // Pre-layout headers to get their actual content heights BEFORE body layout. @@ -1011,7 +1051,10 @@ export async function incrementalLayout( remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), }); const layoutEnd = performance.now(); - perfLog(`[Perf] 4.2 Layout document (pagination): ${(layoutEnd - layoutStart).toFixed(2)}ms`); + const layoutTime = layoutEnd - layoutStart; + perfLog(`[Perf] 4.2 Layout document (pagination): ${layoutTime.toFixed(2)}ms`); + + const pageCount = layout.pages.length; // Two-pass convergence loop for page number token resolution. // Steps: paginate -> build numbering context -> resolve PAGE/NUMPAGES tokens 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..c1fd5d4d0c 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. 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 681b800ba3..bb4384a196 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,17 @@ export class DomPainter { this.configureSpacerElement(this.topSpacerEl, 'top'); this.configureSpacerElement(this.bottomSpacerEl, 'bottom'); + // Create and configure pages container (handles the inter-page gap) + // Use pageGap for visual consistency with non-virtualized mode. + 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.pageGap}px`; + mount.appendChild(this.topSpacerEl); + mount.appendChild(this.virtualPagesEl); mount.appendChild(this.bottomSpacerEl); // Bind scroll and resize handlers @@ -1174,10 +1185,11 @@ export class DomPainter { this.virtualHeights = this.currentLayout.pages.map((p) => p.size?.h ?? this.currentLayout!.pageSize.h); } // Build offsets where offsets[i] = sum_{k < i} (height[k] + gap) + // Use pageGap for consistency with CSS gap on virtualPagesEl const offsets: number[] = new Array(this.virtualHeights.length + 1); offsets[0] = 0; for (let i = 0; i < this.virtualHeights.length; i += 1) { - offsets[i + 1] = offsets[i] + this.virtualHeights[i] + this.virtualGap; + offsets[i + 1] = offsets[i] + this.virtualHeights[i] + this.pageGap; } this.virtualOffsets = offsets; } @@ -1192,7 +1204,7 @@ export class DomPainter { // Total content height without trailing gap after last page const n = this.virtualHeights.length; if (n <= 0) return 0; - return this.virtualOffsets[n] - this.virtualGap; + return this.virtualOffsets[n] - this.pageGap; } private getMountPaddingTopPx(): number { @@ -1207,8 +1219,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 +1317,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 +1325,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. @@ -1317,13 +1343,13 @@ export class DomPainter { gap.dataset.gapFrom = String(prevIndex); gap.dataset.gapTo = String(idx); const gapHeight = - this.topOfIndex(idx) - this.topOfIndex(prevIndex) - this.virtualHeights[prevIndex] - this.virtualGap * 2; + this.topOfIndex(idx) - this.topOfIndex(prevIndex) - this.virtualHeights[prevIndex] - this.pageGap * 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; } @@ -1355,7 +1381,7 @@ export class DomPainter { const clampedLast = Math.max(0, Math.min(last, Math.max(0, n - 1))); const top = this.topOfIndex(clampedFirst); - const bottom = this.topOfIndex(n) - this.topOfIndex(clampedLast + 1) - this.virtualGap; + const bottom = this.topOfIndex(n) - this.topOfIndex(clampedLast + 1) - this.pageGap; this.topSpacerEl.style.height = `${Math.max(0, Math.floor(top))}px`; this.bottomSpacerEl.style.height = `${Math.max(0, Math.floor(bottom))}px`; } @@ -1687,6 +1713,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; @@ -1884,8 +1911,7 @@ export class DomPainter { }; const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); - - const fragments: FragmentDomState[] = page.fragments.map((fragment, index) => { + const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const fragmentEl = this.renderFragment(fragment, contextBase, sdtBoundary); el.appendChild(fragmentEl); @@ -1899,7 +1925,7 @@ export class DomPainter { }); this.renderDecorationsForPage(el, page); - return { element: el, fragments }; + return { element: el, fragments: fragmentStates }; } private getEffectivePageStyles(): PageStyles | undefined { @@ -2001,7 +2027,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..cb8a934776 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/cache.ts @@ -0,0 +1,240 @@ +/** + * 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; + /** Optional revision number for fast equality comparison */ + nodeRev?: number | null; + /** 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; + /** Parsed node revision (if present) */ + nodeRev?: number | null; +}; + +const getNodeRevision = (node: PMNode): number | null => { + const attrs = node?.attrs as Record | null | undefined; + if (!attrs) return null; + const raw = attrs.sdBlockRev; + if (typeof raw === 'number' && Number.isFinite(raw)) return raw; + if (typeof raw === 'string' && raw.trim() !== '') { + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed)) return parsed; + } + return null; +}; + +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 { + const nodeRev = getNodeRevision(node); + + const cached = this.#previous.get(id); + if (!cached) { + this.#misses++; + if (nodeRev != null) { + return { entry: null, nodeRev }; + } + // Serialize once - this is reused in set() to avoid double serialization + const nodeJson = JSON.stringify(node); + return { entry: null, nodeJson }; + } + + if (nodeRev != null && cached.nodeRev != null) { + if (cached.nodeRev !== nodeRev) { + this.#misses++; + return { entry: null, nodeRev }; + } + this.#hits++; + return { entry: cached, nodeRev }; + } + + // Fallback to JSON comparison when revision is unavailable + const nodeJson = JSON.stringify(node); + if (cached.nodeJson !== nodeJson) { + this.#misses++; + return { entry: null, nodeJson, nodeRev }; + } + + this.#hits++; + return { entry: cached, nodeJson, nodeRev }; + } + + /** + * 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 | undefined, + nodeRev: number | null | undefined, + blocks: FlowBlock[], + pmStart: number, + ): void { + this.#next.set(id, { nodeJson, nodeRev, 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..6e2051a921 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, nodeRev } = 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, nodeRev, 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, nodeRev, 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 ce3d536f03..ff9f5a3dba 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/Editor.ts b/packages/super-editor/src/core/Editor.ts index c51a136704..bca42f0eb3 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -192,6 +192,18 @@ export class Editor extends EventEmitter { */ #isDestroyed = false; + /** + * Monotonic counter for transaction performance logs. + */ + #perfTxnId = 0; + + /** + * Expose current performance transaction id for instrumentation. + */ + getPerfTxnId(): number { + return this.#perfTxnId; + } + /** * Editor lifecycle state. * Tracks the current phase of the editor's document lifecycle. @@ -2006,12 +2018,18 @@ export class Editor extends EventEmitter { */ #dispatchTransaction(transaction: Transaction): void { if (this.isDestroyed) return; - const start = Date.now(); + const perf = this.view?.dom?.ownerDocument?.defaultView?.performance ?? globalThis.performance; + const perfNow = () => (perf?.now ? perf.now() : Date.now()); + const perfId = ++this.#perfTxnId; + const perfStart = perfNow(); const prevState = this.state; let nextState: EditorState; let transactionToApply = transaction; + let trackTime = 0; + let applyTime = 0; try { + const trackStart = perfNow(); const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; @@ -2024,34 +2042,47 @@ export class Editor extends EventEmitter { user: this.options.user!, }) : transactionToApply; + trackTime = perfNow() - trackStart; + const applyStart = perfNow(); const { state: appliedState } = prevState.applyTransaction(transactionToApply); nextState = appliedState; + applyTime = perfNow() - applyStart; } catch (error) { + const applyStart = perfNow(); // just in case nextState = prevState.apply(transactionToApply); + applyTime = perfNow() - applyStart; console.log(error); } const selectionHasChanged = !prevState.selection.eq(nextState.selection); this._state = nextState; + let updateStateTime = 0; if (this.view) { + const updateStateStart = perfNow(); this.view.updateState(nextState); + updateStateTime = perfNow() - updateStateStart; } - const end = Date.now(); + const end = perfNow(); + const emitTransactionStart = perfNow(); this.emit('transaction', { editor: this, transaction: transactionToApply, - duration: end - start, + duration: end - perfStart, }); + const emitTransactionTime = perfNow() - emitTransactionStart; + let selectionEmitTime = 0; if (selectionHasChanged) { + const selectionStart = perfNow(); this.emit('selectionUpdate', { editor: this, transaction: transactionToApply, }); + selectionEmitTime = perfNow() - selectionStart; } const focus = transactionToApply.getMeta('focus'); @@ -2072,23 +2103,31 @@ export class Editor extends EventEmitter { }); } - if (!transactionToApply.docChanged) { - return; - } - - // Track document modifications and promote to GUID if needed - if (transaction.docChanged && this.converter) { - if (!this.converter.documentGuid) { - this.converter.promoteToGuid(); - console.debug('Document modified - assigned GUID:', this.converter.documentGuid); + let emitUpdateTime = 0; + if (transactionToApply.docChanged) { + // Track document modifications and promote to GUID if needed + if (transaction.docChanged && this.converter) { + if (!this.converter.documentGuid) { + this.converter.promoteToGuid(); + console.debug('Document modified - assigned GUID:', this.converter.documentGuid); + } + this.converter.documentModified = true; } - this.converter.documentModified = true; + + const emitUpdateStart = perfNow(); + this.emit('update', { + editor: this, + transaction: transactionToApply, + }); + emitUpdateTime = perfNow() - emitUpdateStart; } - this.emit('update', { - editor: this, - transaction: transactionToApply, - }); + const totalTime = perfNow() - perfStart; + const inputType = transactionToApply.getMeta('inputType'); + const inputLabel = inputType ? ` input=${String(inputType)}` : ''; + console.log( + `[Perf] dispatchTransaction#${perfId}: total=${totalTime.toFixed(2)}ms track=${trackTime.toFixed(2)}ms apply=${applyTime.toFixed(2)}ms updateState=${updateStateTime.toFixed(2)}ms emitTx=${emitTransactionTime.toFixed(2)}ms selection=${selectionEmitTime.toFixed(2)}ms emitUpdate=${emitUpdateTime.toFixed(2)}ms steps=${transactionToApply.steps.length} docChanged=${transactionToApply.docChanged}${inputLabel}`, + ); } /** diff --git a/packages/super-editor/src/core/ExtensionService.js b/packages/super-editor/src/core/ExtensionService.js index b09ea47117..838de48de0 100644 --- a/packages/super-editor/src/core/ExtensionService.js +++ b/packages/super-editor/src/core/ExtensionService.js @@ -8,6 +8,114 @@ import { callOrGet } from './utilities/callOrGet.js'; import { isExtensionRulesEnabled } from './helpers/isExtentionRulesEnabled.js'; import { inputRulesPlugin } from './InputRule.js'; +const PERF_PLUGIN_LOG_THRESHOLD_MS = 1; + +const perfNow = () => { + const perf = globalThis?.performance; + return perf?.now ? perf.now() : Date.now(); +}; + +const getPluginLabel = (plugin, fallbackLabel) => { + const key = plugin?.spec?.key?.key || plugin?.key?.key; + if (key) return key; + if (fallbackLabel) return fallbackLabel; + return 'pm-plugin'; +}; + +const logPluginPerf = (kind, label, duration, editor) => { + if (duration < PERF_PLUGIN_LOG_THRESHOLD_MS) return; + const txnId = typeof editor?.getPerfTxnId === 'function' ? editor.getPerfTxnId() : null; + const txnLabel = Number.isFinite(txnId) ? `#${txnId}` : ''; + console.log(`[Perf] pm.${kind}${txnLabel} ${label}: ${duration.toFixed(2)}ms`); +}; + +const instrumentPmPlugin = (plugin, label, editor) => { + if (!plugin || !plugin.spec) return plugin; + const pluginLabel = getPluginLabel(plugin, label); + const { spec } = plugin; + + if (spec.state?.apply && !spec.state.apply.__sdPerfWrapped) { + const originalApply = spec.state.apply; + spec.state.apply = function applyWithPerf(tr, value, oldState, newState) { + const start = perfNow(); + const result = originalApply.call(this, tr, value, oldState, newState); + const duration = perfNow() - start; + logPluginPerf('apply', pluginLabel, duration, editor); + return result; + }; + spec.state.apply.__sdPerfWrapped = true; + } + + if (typeof spec.appendTransaction === 'function' && !spec.appendTransaction.__sdPerfWrapped) { + const originalAppend = spec.appendTransaction; + spec.appendTransaction = function appendWithPerf(transactions, oldState, newState) { + const start = perfNow(); + const result = originalAppend.call(this, transactions, oldState, newState); + const duration = perfNow() - start; + logPluginPerf('appendTransaction', pluginLabel, duration, editor); + return result; + }; + spec.appendTransaction.__sdPerfWrapped = true; + } + + if (typeof spec.filterTransaction === 'function' && !spec.filterTransaction.__sdPerfWrapped) { + const originalFilter = spec.filterTransaction; + spec.filterTransaction = function filterWithPerf(tr, state) { + const start = perfNow(); + const result = originalFilter.call(this, tr, state); + const duration = perfNow() - start; + logPluginPerf('filterTransaction', pluginLabel, duration, editor); + return result; + }; + spec.filterTransaction.__sdPerfWrapped = true; + } + + if (typeof spec.props?.decorations === 'function' && !spec.props.decorations.__sdPerfWrapped) { + const originalDecorations = spec.props.decorations; + spec.props.decorations = function decorationsWithPerf(state) { + const start = perfNow(); + const result = originalDecorations.call(this, state); + const duration = perfNow() - start; + logPluginPerf('decorations', pluginLabel, duration, editor); + return result; + }; + spec.props.decorations.__sdPerfWrapped = true; + } + + if (typeof spec.view === 'function' && !spec.view.__sdPerfWrapped) { + const originalView = spec.view; + spec.view = function viewWithPerf(view) { + const pluginView = originalView.call(this, view); + if (pluginView && typeof pluginView.update === 'function' && !pluginView.update.__sdPerfWrapped) { + const originalUpdate = pluginView.update; + pluginView.update = function updateWithPerf(view, prevState) { + const start = perfNow(); + const result = originalUpdate.call(this, view, prevState); + const duration = perfNow() - start; + logPluginPerf('view.update', pluginLabel, duration, editor); + return result; + }; + pluginView.update.__sdPerfWrapped = true; + } + if (pluginView && typeof pluginView.destroy === 'function' && !pluginView.destroy.__sdPerfWrapped) { + const originalDestroy = pluginView.destroy; + pluginView.destroy = function destroyWithPerf() { + const start = perfNow(); + const result = originalDestroy.call(this); + const duration = perfNow() - start; + logPluginPerf('view.destroy', pluginLabel, duration, editor); + return result; + }; + pluginView.destroy.__sdPerfWrapped = true; + } + return pluginView; + }; + spec.view.__sdPerfWrapped = true; + } + + return plugin; +}; + /** * ExtensionService is the main class to work with extensions. */ @@ -174,7 +282,7 @@ export class ExtensionService { bindingsObject = { ...Object.fromEntries(entries) }; } - plugins.push(keymap(bindingsObject)); + plugins.push(instrumentPmPlugin(keymap(bindingsObject), `${extension.name}:keymap`, editor)); const addInputRules = getExtensionConfigField(extension, 'addInputRules', context); @@ -186,7 +294,9 @@ export class ExtensionService { if (addPmPlugins) { const pmPlugins = addPmPlugins(); - plugins.push(...pmPlugins); + pmPlugins.forEach((plugin, index) => { + plugins.push(instrumentPmPlugin(plugin, `${extension.name}:pm${index}`, editor)); + }); } return plugins; @@ -194,10 +304,14 @@ export class ExtensionService { .flat(); return [ - inputRulesPlugin({ + instrumentPmPlugin( + inputRulesPlugin({ + editor, + rules: inputRules, + }), + 'inputRules', editor, - rules: inputRules, - }), + ), ...allPlugins, ]; } 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 7486df9576..9a2e699984 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, @@ -169,9 +170,7 @@ const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65; const DEFAULT_PAGE_SIZE: PageSize = { w: 612, h: 792 }; // Letter @ 72dpi const DEFAULT_MARGINS: PageMargins = { top: 72, right: 72, bottom: 72, left: 72 }; -/** Default gap between pages when virtualization is enabled (matches renderer.ts virtualGap) */ -const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; -/** Default gap between pages without virtualization (from containerStyles in styles.ts) */ +/** Default gap between pages (from containerStyles in styles.ts) */ const DEFAULT_PAGE_GAP = 24; /** Default gap for horizontal layout mode */ const DEFAULT_HORIZONTAL_PAGE_GAP = 20; @@ -181,6 +180,16 @@ const DEFAULT_HORIZONTAL_PAGE_GAP = 20; const MULTI_CLICK_TIME_THRESHOLD_MS = 400; /** Maximum distance between clicks to register as multi-click (pixels) */ const MULTI_CLICK_DISTANCE_THRESHOLD_PX = 5; + +/** Debug flag for performance logging - enable with SD_DEBUG_LAYOUT env variable */ +const layoutDebugEnabled = + typeof process !== 'undefined' && typeof process.env !== 'undefined' && Boolean(process.env.SD_DEBUG_LAYOUT); + +/** Log performance metrics when debug is enabled */ +const perfLog = (...args: unknown[]): void => { + if (!layoutDebugEnabled) return; + console.log(...args); +}; /** Budget for header/footer initialization before warning (milliseconds) */ const HEADER_FOOTER_INIT_BUDGET_MS = 200; /** Maximum zoom level before warning */ @@ -254,6 +263,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 +287,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 +1045,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 +1084,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 +1120,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 +2156,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 +2185,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(); @@ -2430,6 +2462,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; } /** @@ -2751,9 +2829,13 @@ export class PresentationEditor extends EventEmitter { let docJson; const viewWindow = this.#visibleHost.ownerDocument?.defaultView ?? window; const perf = viewWindow?.performance ?? GLOBAL_PERFORMANCE; + const perfNow = () => (perf?.now ? perf.now() : Date.now()); const startMark = perf?.now?.(); try { + const getJsonStart = perfNow(); docJson = this.#editor.getJSON(); + const getJsonEnd = perfNow(); + perfLog(`[Perf] getJSON: ${(getJsonEnd - getJsonStart).toFixed(2)}ms`); } catch (error) { this.#handleLayoutError('render', this.#decorateError(error, 'getJSON')); return; @@ -2769,6 +2851,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; @@ -2780,9 +2863,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') { @@ -2799,10 +2895,14 @@ export class PresentationEditor extends EventEmitter { } : undefined; const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); + const positionMapStart = perfNow(); const positionMap = this.#editor?.state?.doc && docJson ? buildPositionMapFromPmDoc(this.#editor.state.doc, docJson) : null; + const positionMapEnd = perfNow(); + perfLog(`[Perf] buildPositionMapFromPmDoc: ${(positionMapEnd - positionMapStart).toFixed(2)}ms`); const commentsEnabled = this.#documentMode !== 'viewing' || this.#layoutOptions.enableCommentsInViewing === true; + const toFlowBlocksStart = perfNow(); const result = toFlowBlocks(docJson, { mediaFiles: (this.#editor?.storage?.image as { media?: Record })?.media, emitSectionBreaks: true, @@ -2813,9 +2913,14 @@ export class PresentationEditor extends EventEmitter { enableRichHyperlinks: true, themeColors: this.#editor?.converter?.themeColors ?? undefined, converterContext, + flowBlockCache: this.#flowBlockCache, ...(positionMap ? { positions: positionMap } : {}), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); + const toFlowBlocksEnd = perfNow(); + perfLog( + `[Perf] toFlowBlocks: ${(toFlowBlocksEnd - toFlowBlocksStart).toFixed(2)}ms (blocks=${result.blocks.length})`, + ); blocks = result.blocks; bookmarks = result.bookmarks ?? new Map(); } catch (error) { @@ -2842,6 +2947,7 @@ export class PresentationEditor extends EventEmitter { : baseLayoutOptions; const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; + const previousMeasures = this.#layoutState.measures; let layout: Layout; let measures: Measure[]; @@ -2851,6 +2957,7 @@ export class PresentationEditor extends EventEmitter { let extraMeasures: Measure[] | undefined; const headerFooterInput = this.#buildHeaderFooterInput(); try { + const incrementalLayoutStart = perfNow(); const result = await incrementalLayout( previousBlocks, previousLayout, @@ -2858,6 +2965,11 @@ export class PresentationEditor extends EventEmitter { layoutOptions, (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => measureBlock(block, constraints), headerFooterInput ?? undefined, + previousMeasures, + ); + const incrementalLayoutEnd = perfNow(); + perfLog( + `[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`, ); // Type guard: validate incrementalLayout return value @@ -2974,6 +3086,7 @@ export class PresentationEditor extends EventEmitter { } // Pass all blocks (main document + headers + footers + extras) to the painter + const painterSetDataStart = perfNow(); painter.setData?.( blocks, measures, @@ -2982,16 +3095,24 @@ export class PresentationEditor extends EventEmitter { footerBlocks.length > 0 ? footerBlocks : undefined, footerMeasures.length > 0 ? footerMeasures : undefined, ); + const painterSetDataEnd = perfNow(); + perfLog(`[Perf] painter.setData: ${(painterSetDataEnd - painterSetDataStart).toFixed(2)}ms`); // Avoid MutationObserver overhead while repainting large DOM trees. this.#domIndexObserverManager?.pause(); // Pass the transaction mapping for efficient position attribute updates. // Consumed here and cleared to prevent stale mappings on subsequent paints. const mapping = this.#pendingMapping; this.#pendingMapping = null; + const painterPaintStart = perfNow(); painter.paint(layout, this.#painterHost, mapping ?? undefined); + const painterPaintEnd = perfNow(); + perfLog(`[Perf] painter.paint: ${(painterPaintEnd - painterPaintStart).toFixed(2)}ms`); + const painterPostStart = perfNow(); this.#applyVertAlignToLayout(); this.#rebuildDomPositionIndex(); this.#domIndexObserverManager?.resume(); + const painterPostEnd = perfNow(); + perfLog(`[Perf] painter.postPaint: ${(painterPostEnd - painterPostStart).toFixed(2)}ms`); this.#layoutEpoch = layoutEpoch; if (this.#updateHtmlAnnotationMeasurements(layoutEpoch)) { this.#pendingDocChange = true; @@ -3989,10 +4110,12 @@ export class PresentationEditor extends EventEmitter { /** * Get effective page gap based on layout mode and virtualization settings. * Keeps painter, layout, and geometry in sync. + * Uses DEFAULT_PAGE_GAP for both virtualized and non-virtualized modes for visual consistency. */ #getEffectivePageGap(): number { if (this.#layoutOptions.virtualization?.enabled) { - return Math.max(0, this.#layoutOptions.virtualization.gap ?? DEFAULT_VIRTUALIZED_PAGE_GAP); + // Use explicit gap if provided, otherwise use same default as non-virtualized for consistency + return Math.max(0, this.#layoutOptions.virtualization.gap ?? DEFAULT_PAGE_GAP); } if (this.#layoutOptions.layoutMode === 'horizontal') { return DEFAULT_HORIZONTAL_PAGE_GAP; 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/core/types/NodeCategories.ts b/packages/super-editor/src/core/types/NodeCategories.ts index 11cfc89a5d..6a688750ae 100644 --- a/packages/super-editor/src/core/types/NodeCategories.ts +++ b/packages/super-editor/src/core/types/NodeCategories.ts @@ -33,6 +33,8 @@ export interface BlockNodeAttributes { /** SuperDoc block tracking ID */ sdBlockId?: string | null; + /** Incrementing revision for block-level changes */ + sdBlockRev?: number | null; /** Additional HTML attributes */ extraAttrs?: Record; } diff --git a/packages/super-editor/src/extensions/block-node/block-node.js b/packages/super-editor/src/extensions/block-node/block-node.js index fb00a70805..97a35902a0 100644 --- a/packages/super-editor/src/extensions/block-node/block-node.js +++ b/packages/super-editor/src/extensions/block-node/block-node.js @@ -3,11 +3,12 @@ import { Extension } from '@core/Extension.js'; import { helpers } from '@core/index.js'; import { mergeRanges, clampRange } from '@core/helpers/rangeUtils.js'; import { Plugin, PluginKey } from 'prosemirror-state'; -import { ReplaceStep } from 'prosemirror-transform'; +import { ReplaceStep, ReplaceAroundStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; import { v4 as uuidv4 } from 'uuid'; const { findChildren } = helpers; const SD_BLOCK_ID_ATTRIBUTE_NAME = 'sdBlockId'; +const SD_BLOCK_REV_ATTRIBUTE_NAME = 'sdBlockRev'; export const BlockNodePluginKey = new PluginKey('blockNodePlugin'); /** @@ -221,16 +222,24 @@ export const BlockNode = Extension.create({ * @param {import('prosemirror-model').Node} node - Node that needs the identifier. * @param {number} pos - Document position of the node. */ - const assignBlockId = (tr, node, pos) => { - tr.setNodeMarkup( - pos, - undefined, - { - ...node.attrs, - sdBlockId: uuidv4(), - }, - node.marks, - ); + const getNextBlockRev = (node) => { + const current = node?.attrs?.[SD_BLOCK_REV_ATTRIBUTE_NAME]; + if (typeof current === 'number' && Number.isFinite(current)) return current + 1; + const parsed = Number.parseInt(current, 10); + if (Number.isFinite(parsed)) return parsed + 1; + return 1; + }; + + const ensureBlockRev = (node) => { + const current = node?.attrs?.[SD_BLOCK_REV_ATTRIBUTE_NAME]; + if (typeof current === 'number' && Number.isFinite(current)) return current; + const parsed = Number.parseInt(current, 10); + if (Number.isFinite(parsed)) return parsed; + return 0; + }; + + const applyNodeAttrs = (tr, node, pos, nextAttrs) => { + tr.setNodeMarkup(pos, undefined, nextAttrs, node.marks); }; return [ @@ -243,18 +252,29 @@ export const BlockNode = Extension.create({ return; } - if (hasInitialized && !checkForNewBlockNodesInTrs([...transactions])) { - return; - } - const { tr } = newState; let changed = false; + const updatedPositions = new Set(); if (!hasInitialized) { // Initial pass: assign IDs to all block nodes in document newState.doc.descendants((node, pos) => { + if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; + const nextAttrs = { ...node.attrs }; + let nodeChanged = false; if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { - assignBlockId(tr, node, pos); + nextAttrs.sdBlockId = uuidv4(); + nodeChanged = true; + } + if (nodeAllowsSdBlockRevAttr(node)) { + const rev = ensureBlockRev(node); + if (nextAttrs.sdBlockRev !== rev) { + nextAttrs.sdBlockRev = rev; + nodeChanged = true; + } + } + if (nodeChanged) { + applyNodeAttrs(tr, node, pos, nextAttrs); changed = true; } }); @@ -265,24 +285,26 @@ export const BlockNode = Extension.create({ transactions.forEach((transaction, txIndex) => { transaction.steps.forEach((step, stepIndex) => { - if (!(step instanceof ReplaceStep)) return; - - const hasNewBlockNodes = step.slice?.content?.content?.some((node) => nodeAllowsSdBlockIdAttr(node)); - if (!hasNewBlockNodes) return; - - const stepMap = step.getMap(); - - stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => { - if (newEnd <= newStart) { - if (process.env.NODE_ENV === 'development') { - console.debug('Block node: invalid range in step map, falling back to full traversal'); + const stepRanges = []; + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + const stepMap = step.getMap(); + stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + if (newEnd <= newStart) { + // Deletions often yield zero-length ranges; still update the surrounding block. + stepRanges.push([newStart, newStart + 1]); + return; } - shouldFallbackToFullTraversal = true; - return; + stepRanges.push([newStart, newEnd]); + }); + } else if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + if (step.to > step.from) { + stepRanges.push([step.from, step.to]); } + } - let rangeStart = newStart; - let rangeEnd = newEnd; + stepRanges.forEach(([rangeStartRaw, rangeEndRaw]) => { + let rangeStart = rangeStartRaw; + let rangeEnd = rangeEndRaw; // Map through remaining steps in the current transaction for (let i = stepIndex + 1; i < transaction.steps.length; i++) { @@ -299,11 +321,7 @@ export const BlockNode = Extension.create({ } if (rangeEnd <= rangeStart) { - if (process.env.NODE_ENV === 'development') { - console.debug('Block node: invalid range after mapping, falling back to full traversal'); - } - shouldFallbackToFullTraversal = true; - return; + rangeEnd = rangeStart + 1; } rangesToCheck.push([rangeStart, rangeEnd]); @@ -318,19 +336,28 @@ export const BlockNode = Extension.create({ const clampedRange = clampRange(start, end, docSize); if (!clampedRange) { - if (process.env.NODE_ENV === 'development') { - console.debug('Block node: invalid range after clamping, falling back to full traversal'); - } - shouldFallbackToFullTraversal = true; - break; + continue; } const [safeStart, safeEnd] = clampedRange; try { newState.doc.nodesBetween(safeStart, safeEnd, (node, pos) => { + if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; + if (updatedPositions.has(pos)) return; + const nextAttrs = { ...node.attrs }; + let nodeChanged = false; if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { - assignBlockId(tr, node, pos); + nextAttrs.sdBlockId = uuidv4(); + nodeChanged = true; + } + if (nodeAllowsSdBlockRevAttr(node)) { + nextAttrs.sdBlockRev = getNextBlockRev(node); + nodeChanged = true; + } + if (nodeChanged) { + applyNodeAttrs(tr, node, pos, nextAttrs); + updatedPositions.add(pos); changed = true; } }); @@ -343,8 +370,19 @@ export const BlockNode = Extension.create({ if (shouldFallbackToFullTraversal) { newState.doc.descendants((node, pos) => { + if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; + const nextAttrs = { ...node.attrs }; + let nodeChanged = false; if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { - assignBlockId(tr, node, pos); + nextAttrs.sdBlockId = uuidv4(); + nodeChanged = true; + } + if (nodeAllowsSdBlockRevAttr(node)) { + nextAttrs.sdBlockRev = getNextBlockRev(node); + nodeChanged = true; + } + if (nodeChanged) { + applyNodeAttrs(tr, node, pos, nextAttrs); changed = true; } }); @@ -375,6 +413,10 @@ export const nodeAllowsSdBlockIdAttr = (node) => { return !!(node?.isBlock && node?.type?.spec?.attrs?.[SD_BLOCK_ID_ATTRIBUTE_NAME]); }; +export const nodeAllowsSdBlockRevAttr = (node) => { + return !!(node?.isBlock && node?.type?.spec?.attrs?.[SD_BLOCK_REV_ATTRIBUTE_NAME]); +}; + /** * Check if a node needs an sdBlockId (doesn't have one or has null/empty value) * @param {ProseMirrorNode} node - The ProseMirror node to check diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index c0094c4b1e..d6b8262b18 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -424,6 +424,7 @@ export const CommentsPlugin = Extension.create({ const { doc, tr } = state; const pluginState = CommentsPluginKey.getState(state); const currentActiveThreadId = pluginState.activeThreadId; + const layoutEngineActive = Boolean(editor.presentationEditor); const meta = tr.getMeta(CommentsPluginKey); if (meta?.type === 'setActiveComment' || meta?.forceUpdate) { @@ -447,6 +448,10 @@ export const CommentsPlugin = Extension.create({ prevDoc = doc; shouldUpdate = false; + if (layoutEngineActive) { + return; + } + const decorations = []; // Always rebuild positions fresh from the current document to avoid stale PM offsets const allCommentPositions = {}; diff --git a/packages/super-editor/src/extensions/linked-styles/plugin.js b/packages/super-editor/src/extensions/linked-styles/plugin.js index 26b4c51b15..ddb9dc0361 100644 --- a/packages/super-editor/src/extensions/linked-styles/plugin.js +++ b/packages/super-editor/src/extensions/linked-styles/plugin.js @@ -33,6 +33,9 @@ export const createLinkedStylesPlugin = (editor) => { */ init() { if (!editor.converter || editor.options.mode !== 'docx') return {}; + if (editor.presentationEditor) { + return { styles: editor.converter?.linkedStyles || [], decorations: DecorationSet.empty }; + } const styles = editor.converter?.linkedStyles || []; return { styles, @@ -50,6 +53,9 @@ export const createLinkedStylesPlugin = (editor) => { */ apply(tr, prev, oldEditorState, newEditorState) { if (!editor.converter || editor.options.mode !== 'docx') return { ...prev }; + if (editor.presentationEditor) { + return { ...prev, decorations: DecorationSet.empty }; + } let decorations = prev.decorations || DecorationSet.empty; // Only regenerate decorations when styles are affected diff --git a/packages/super-editor/src/extensions/paragraph/dropcapPlugin.js b/packages/super-editor/src/extensions/paragraph/dropcapPlugin.js index 1456bbb3c4..99e6622652 100644 --- a/packages/super-editor/src/extensions/paragraph/dropcapPlugin.js +++ b/packages/super-editor/src/extensions/paragraph/dropcapPlugin.js @@ -69,11 +69,17 @@ export function createDropcapPlugin(editor) { key: new PluginKey('dropcapPlugin'), state: { init(_, state) { + if (editor.presentationEditor) { + return DecorationSet.empty; + } const decorations = getDropcapDecorations(state, view, dropcapWidthCache); return DecorationSet.create(state.doc, decorations); }, apply(tr, oldDecorationSet, oldState, newState) { + if (editor.presentationEditor) { + return DecorationSet.empty; + } if (!tr.docChanged) return oldDecorationSet; // Early exit if no dropcaps in document diff --git a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js index e10c085458..b19e830bc4 100644 --- a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js +++ b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js @@ -1,4 +1,5 @@ import { Plugin, PluginKey } from 'prosemirror-state'; +import { AddMarkStep, RemoveMarkStep, ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; import { createNumberingManager } from './NumberingManager.js'; import { ListHelpers } from '@helpers/list-numbering-helpers.js'; import { generateOrderedListIndex } from '@helpers/orderedListUtils.js'; @@ -14,6 +15,7 @@ import { calculateResolvedParagraphProperties } from './resolvedPropertiesCache. */ export function createNumberingPlugin(editor) { const numberingManager = createNumberingManager(); + let forceFullRecompute = false; // Helpers to initialize and refresh start settings from definitions const applyStartSettingsFromDefinitions = (definitionsMap) => { @@ -33,6 +35,7 @@ export function createNumberingPlugin(editor) { const refreshStartSettings = () => { const definitions = ListHelpers.getAllListDefinitions(editor); applyStartSettingsFromDefinitions(definitions); + forceFullRecompute = true; }; // Initial setup @@ -63,16 +66,146 @@ export function createNumberingPlugin(editor) { * @returns {import('prosemirror-state').Transaction | null} */ appendTransaction(transactions, oldState, newState) { + const getParagraphAnchor = ($pos) => { + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'paragraph') { + return depth === 0 ? 0 : $pos.before(depth); + } + } + return null; + }; + + const isInlineOnlyChange = (tr) => { + if (!tr.docChanged) return true; + let inlineOnly = true; + const baseDoc = tr.before ?? oldState?.doc ?? newState?.doc; + + tr.steps.forEach((step) => { + if (!inlineOnly) return; + if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + return; + } + + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + const { from, to } = step; + if (from == null || to == null || !baseDoc) { + inlineOnly = false; + return; + } + if (from < 0 || to < 0 || from > baseDoc.content.size || to > baseDoc.content.size) { + inlineOnly = false; + return; + } + let $from; + let $to; + try { + $from = baseDoc.resolve(from); + $to = baseDoc.resolve(to); + } catch { + inlineOnly = false; + return; + } + const fromPara = getParagraphAnchor($from); + const toPara = getParagraphAnchor($to); + if (fromPara == null || toPara == null || fromPara !== toPara) { + inlineOnly = false; + return; + } + if (step.slice?.content) { + let hasBlock = false; + step.slice.content.descendants((node) => { + if (node.isBlock) { + hasBlock = true; + return false; + } + return; + }); + if (hasBlock) { + inlineOnly = false; + } + } + return; + } + + inlineOnly = false; + }); + + return inlineOnly; + }; const isFromPlugin = transactions.some((tr) => tr.getMeta('orderedListSync')); const forcePluginPass = transactions.some((tr) => tr.getMeta('forcePluginPass')); - if (isFromPlugin || (!forcePluginPass && !transactions.some((tr) => tr.docChanged))) { + const hasDocChanges = transactions.some((tr) => tr.docChanged); + if (isFromPlugin || (!forcePluginPass && !forceFullRecompute && !hasDocChanges)) { return null; } + if (!forcePluginPass && !forceFullRecompute) { + const inlineOnly = transactions.every((tr) => isInlineOnlyChange(tr)); + if (inlineOnly) { + return null; + } + } + + const hasNumberedParagraphInRange = (doc, from, to) => { + if (!doc || from == null || to == null) return false; + const docSize = doc.content.size; + const rangeStart = Math.max(0, Math.min(from, to)); + const rangeEnd = Math.min(docSize, Math.max(from, to)); + let found = false; + doc.nodesBetween(rangeStart, rangeEnd, (node, pos) => { + if (found) return false; + if (node.type.name !== 'paragraph') return; + const resolvedProps = calculateResolvedParagraphProperties(editor, node, doc.resolve(pos)); + if (resolvedProps?.numberingProperties) { + found = true; + return false; + } + return false; + }); + return found; + }; + + const shouldRecompute = (() => { + if (forcePluginPass || forceFullRecompute) return true; + if (!hasDocChanges) return false; + const diffStart = oldState.doc.content.findDiffStart(newState.doc.content); + if (diffStart == null) return false; + const diffEnd = oldState.doc.content.findDiffEnd(newState.doc.content); + const oldDiffEnd = diffEnd?.a ?? diffStart; + const newDiffEnd = diffEnd?.b ?? diffStart; + const oldHasList = hasNumberedParagraphInRange(oldState.doc, diffStart, oldDiffEnd); + if (oldHasList) return true; + const newHasList = hasNumberedParagraphInRange(newState.doc, diffStart, newDiffEnd); + return newHasList; + })(); + + if (!shouldRecompute) { + return null; + } + forceFullRecompute = false; // Mark the transaction to avoid re-processing const tr = newState.tr; tr.setMeta('orderedListSync', true); + // Increment sdBlockRev to notify the layout engine that the block changed. + // Handles legacy string values from older document formats. + const bumpBlockRev = (node, pos) => { + const current = node?.attrs?.sdBlockRev; + let nextRev; + if (typeof current === 'number' && Number.isFinite(current)) { + nextRev = current + 1; + } else if (typeof current === 'string' && current.trim() !== '') { + const parsed = Number.parseInt(current, 10); + if (Number.isFinite(parsed)) { + nextRev = parsed + 1; + } + } + if (nextRev != null) { + tr.setNodeAttribute(pos, 'sdBlockRev', nextRev); + } + }; + // Generate new list properties numberingManager.enableCache(); newState.doc.descendants((node, pos) => { @@ -88,6 +221,7 @@ export function createNumberingPlugin(editor) { if (!definitionDetails || Object.keys(definitionDetails).length === 0) { // Treat as normal paragraph if definition is missing tr.setNodeAttribute(pos, 'listRendering', null); + bumpBlockRev(node, pos); return; } @@ -120,6 +254,7 @@ export function createNumberingPlugin(editor) { if (JSON.stringify(node.attrs.listRendering) !== JSON.stringify(newListRendering)) { // Updating rendering attrs for node view usage tr.setNodeAttribute(pos, 'listRendering', newListRendering); + bumpBlockRev(node, pos); } return false; // no need to descend into a paragraph diff --git a/packages/super-editor/src/extensions/paragraph/numberingPlugin.test.js b/packages/super-editor/src/extensions/paragraph/numberingPlugin.test.js index a25092ae00..a47213466f 100644 --- a/packages/super-editor/src/extensions/paragraph/numberingPlugin.test.js +++ b/packages/super-editor/src/extensions/paragraph/numberingPlugin.test.js @@ -302,4 +302,189 @@ describe('numberingPlugin', () => { expect(result).toBeNull(); expect(tr.setMeta).not.toHaveBeenCalled(); }); + + describe('bumpBlockRev', () => { + it('increments numeric sdBlockRev when listRendering is updated', () => { + const editor = createEditor(); + const plugin = createNumberingPlugin(editor); + const { appendTransaction } = plugin.spec; + + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + sdBlockRev: 5, + listRendering: { markerText: 'old' }, + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }; + + const doc = makeDoc([{ node: paragraph, pos: 3 }]); + const tr = createTransaction(); + const transactions = [{ docChanged: true, getMeta: vi.fn().mockReturnValue(false) }]; + + numberingManager.calculateCounter.mockReturnValue(1); + numberingManager.calculatePath.mockReturnValue([1]); + generateOrderedListIndex.mockReturnValue('1.'); + ListHelpers.getListDefinitionDetails.mockReturnValue({ + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: '.', + justification: 'left', + abstractId: 'a1', + }); + + appendTransaction(transactions, {}, { doc, tr }); + + expect(tr.setNodeAttribute).toHaveBeenCalledWith(3, 'listRendering', expect.any(Object)); + expect(tr.setNodeAttribute).toHaveBeenCalledWith(3, 'sdBlockRev', 6); + }); + + it('increments sdBlockRev when listRendering is cleared due to missing definition', () => { + const editor = createEditor(); + const plugin = createNumberingPlugin(editor); + const { appendTransaction } = plugin.spec; + + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + sdBlockRev: 10, + paragraphProperties: { + numberingProperties: { numId: 2, ilvl: 0 }, + }, + }, + }; + + const doc = makeDoc([{ node: paragraph, pos: 5 }]); + const tr = createTransaction(); + const transactions = [{ docChanged: true, getMeta: vi.fn().mockReturnValue(false) }]; + + ListHelpers.getListDefinitionDetails.mockReturnValue(null); + + appendTransaction(transactions, {}, { doc, tr }); + + expect(tr.setNodeAttribute).toHaveBeenCalledWith(5, 'listRendering', null); + expect(tr.setNodeAttribute).toHaveBeenCalledWith(5, 'sdBlockRev', 11); + }); + + it('parses string sdBlockRev values and increments correctly', () => { + const editor = createEditor(); + const plugin = createNumberingPlugin(editor); + const { appendTransaction } = plugin.spec; + + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + sdBlockRev: '7', + listRendering: null, + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }; + + const doc = makeDoc([{ node: paragraph, pos: 2 }]); + const tr = createTransaction(); + const transactions = [{ docChanged: true, getMeta: vi.fn().mockReturnValue(false) }]; + + numberingManager.calculateCounter.mockReturnValue(1); + numberingManager.calculatePath.mockReturnValue([1]); + generateOrderedListIndex.mockReturnValue('1.'); + ListHelpers.getListDefinitionDetails.mockReturnValue({ + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: '.', + justification: 'left', + abstractId: 'a1', + }); + + appendTransaction(transactions, {}, { doc, tr }); + + expect(tr.setNodeAttribute).toHaveBeenCalledWith(2, 'sdBlockRev', 8); + }); + + it('does not bump sdBlockRev when listRendering has not changed', () => { + const editor = createEditor(); + const plugin = createNumberingPlugin(editor); + const { appendTransaction } = plugin.spec; + + const existingRendering = { + markerText: '1.', + suffix: '.', + justification: 'left', + path: [1], + numberingType: 'decimal', + }; + + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + sdBlockRev: 3, + listRendering: existingRendering, + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }; + + const doc = makeDoc([{ node: paragraph, pos: 4 }]); + const tr = createTransaction(); + const transactions = [{ docChanged: true, getMeta: vi.fn().mockReturnValue(false) }]; + + numberingManager.calculateCounter.mockReturnValue(1); + numberingManager.calculatePath.mockReturnValue([1]); + generateOrderedListIndex.mockReturnValue('1.'); + ListHelpers.getListDefinitionDetails.mockReturnValue({ + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: '.', + justification: 'left', + abstractId: 'a1', + }); + + appendTransaction(transactions, {}, { doc, tr }); + + // setNodeAttribute should not be called at all since listRendering is unchanged + expect(tr.setNodeAttribute).not.toHaveBeenCalled(); + }); + + it('does not bump sdBlockRev when it is undefined', () => { + const editor = createEditor(); + const plugin = createNumberingPlugin(editor); + const { appendTransaction } = plugin.spec; + + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + // no sdBlockRev + listRendering: null, + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }; + + const doc = makeDoc([{ node: paragraph, pos: 6 }]); + const tr = createTransaction(); + const transactions = [{ docChanged: true, getMeta: vi.fn().mockReturnValue(false) }]; + + numberingManager.calculateCounter.mockReturnValue(1); + numberingManager.calculatePath.mockReturnValue([1]); + generateOrderedListIndex.mockReturnValue('1.'); + ListHelpers.getListDefinitionDetails.mockReturnValue({ + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: '.', + justification: 'left', + abstractId: 'a1', + }); + + appendTransaction(transactions, {}, { doc, tr }); + + // Should set listRendering but not sdBlockRev since it was undefined + expect(tr.setNodeAttribute).toHaveBeenCalledWith(6, 'listRendering', expect.any(Object)); + expect(tr.setNodeAttribute).not.toHaveBeenCalledWith(6, 'sdBlockRev', expect.anything()); + }); + }); }); diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index aa96fc5689..a910cc2b29 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -114,6 +114,11 @@ export const Paragraph = OxmlNode.create({ return attrs.sdBlockId ? { 'data-sd-block-id': attrs.sdBlockId } : {}; }, }, + sdBlockRev: { + default: 0, + rendered: false, + keepOnSplit: false, + }, attributes: { rendered: false, }, 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..9650efd4fa 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,15 @@ 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, + sdBlockRev: 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/super-editor/src/extensions/structured-content/document-section/helpers.js b/packages/super-editor/src/extensions/structured-content/document-section/helpers.js index 9103c616e7..60ee074e85 100644 --- a/packages/super-editor/src/extensions/structured-content/document-section/helpers.js +++ b/packages/super-editor/src/extensions/structured-content/document-section/helpers.js @@ -60,8 +60,7 @@ export const getHTMLFromNode = (node, editor) => { const container = tempDocument.createElement('div'); const fragment = DOMSerializer.fromSchema(editor.schema).serializeFragment(node.content); container.appendChild(fragment); - let html = container.innerHTML; - return html; + return container.innerHTML; }; /** @@ -106,11 +105,15 @@ export const getLinkedSectionEditor = (id, options, editor) => { const child = editor.createChildEditor({ ...options, onUpdate: ({ editor: childEditor, transaction }) => { - const isFromtLinkedParent = transaction.getMeta('fromLinkedParent'); - if (isFromtLinkedParent) return; // Prevent feedback loop - - // 1. Get updated content from child editor - const updatedContent = childEditor.state.doc.content; + const isFromLinkedParent = transaction.getMeta('fromLinkedParent'); + if (isFromLinkedParent) return; // Prevent feedback loop + + // 1. Get updated content from child editor, converted to parent schema + const childDocJson = childEditor.state.doc.toJSON(); + const updatedContent = editor.schema.nodeFromJSON({ + type: 'doc', + content: childDocJson.content ?? [], + }).content; // 2. Find the section node and its position in the parent const sectionNode = getAllSections(editor)?.find((s) => s.node.attrs.id === id); diff --git a/packages/super-editor/src/extensions/tab/tab.js b/packages/super-editor/src/extensions/tab/tab.js index f60bd08e90..951074e976 100644 --- a/packages/super-editor/src/extensions/tab/tab.js +++ b/packages/super-editor/src/extensions/tab/tab.js @@ -71,13 +71,17 @@ export const TabNode = Node.create({ return []; } - const { view, helpers } = this.editor; + const editor = this.editor; + const { view, helpers } = editor; const tabPlugin = new Plugin({ name: 'tabPlugin', key: new PluginKey('tabPlugin'), state: { init() { + if (editor.presentationEditor) { + return { decorations: DecorationSet.empty, revision: 0 }; + } const initialDecorations = buildInitialDecorations(view.state.doc, view, helpers, 0); return { decorations: initialDecorations, revision: 0 }; }, @@ -85,6 +89,10 @@ export const TabNode = Node.create({ const currentDecorations = decorations && decorations.map ? decorations.map(tr.mapping, tr.doc) : DecorationSet.empty; + if (editor.presentationEditor) { + return { decorations: DecorationSet.empty, revision }; + } + // Early return for non-document changes if (!tr.docChanged || tr.getMeta('blockNodeInitialUpdate')) { return { decorations: currentDecorations, revision }; @@ -163,6 +171,16 @@ function buildInitialDecorations(doc, view, helpers, revision) { } function buildParagraphDecorations(doc, paragraphContentPos, paragraphNode, view, helpers, revision) { + let hasTab = false; + paragraphNode.descendants((child) => { + if (child.type.name === 'tab') { + hasTab = true; + return false; + } + return true; + }); + if (!hasTab) return []; + const request = createLayoutRequest(doc, paragraphContentPos, view, helpers, revision); if (!request) return []; const result = calculateTabLayout(request, undefined, view); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 583f98352a..dbb95aa8fc 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -185,6 +185,8 @@ export interface ParagraphAttrs extends TextContainerAttributes { listRendering: ListRendering | null; /** SuperDoc block tracking ID */ sdBlockId: string | null; + /** Incrementing revision for block-level changes */ + sdBlockRev: number | null; /** Additional HTML attributes */ extraAttrs: Record; /** Paragraph identifier (w:paraId) */ 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 = {}; From 7339d6a872b8fd23a5570db3946e2cbd9e63dd5d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 2 Feb 2026 22:11:23 -0800 Subject: [PATCH 2/5] chore: test fixes, image selection issue --- .../layout-bridge/test/performance.test.ts | 7 +- .../painters/dom/src/renderer.ts | 9 +- .../pm-adapter/src/cache.test.ts | 272 ++++++++++++++++++ .../layout-engine/pm-adapter/src/cache.ts | 33 ++- 4 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/cache.test.ts diff --git a/packages/layout-engine/layout-bridge/test/performance.test.ts b/packages/layout-engine/layout-bridge/test/performance.test.ts index 19fe18f12d..0ff96ecd4d 100644 --- a/packages/layout-engine/layout-bridge/test/performance.test.ts +++ b/packages/layout-engine/layout-bridge/test/performance.test.ts @@ -26,9 +26,10 @@ const describeIfRealCanvas = usingStub ? describe.skip : describe; 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, + // CI environments are slower and more variable; use generous buffers + p50: 500, + p90: 700, + p99: 1000, } : { p50: 70, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index bb4384a196..5b69abb9be 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1096,8 +1096,13 @@ export class DomPainter { // Always keep the latest layout reference for handlers this.currentLayout = layout; - // First-time init or mount changed - const needsInit = !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || this.mount !== mount; + // First-time init, mount changed, or spacers were detached (e.g., by innerHTML='' on zero-page layout) + const needsInit = + !this.topSpacerEl || + !this.bottomSpacerEl || + !this.virtualPagesEl || + this.mount !== mount || + this.topSpacerEl.parentElement !== mount; if (needsInit) { this.ensureVirtualizationSetup(mount); } diff --git a/packages/layout-engine/pm-adapter/src/cache.test.ts b/packages/layout-engine/pm-adapter/src/cache.test.ts new file mode 100644 index 0000000000..95296dbaf8 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/cache.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { shiftBlockPositions, shiftCachedBlocks } from './cache.js'; +import type { FlowBlock, ParagraphBlock, ImageBlock, DrawingBlock, Run } from '@superdoc/contracts'; + +describe('shiftBlockPositions', () => { + describe('paragraph blocks', () => { + it('shifts pmStart and pmEnd in runs', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'p1', + runs: [ + { text: 'hello', pmStart: 10, pmEnd: 15 } as Run, + { text: 'world', pmStart: 15, pmEnd: 20 } as Run, + ], + }; + + const shifted = shiftBlockPositions(block, 5) as ParagraphBlock; + + expect(shifted.runs[0].pmStart).toBe(15); + expect(shifted.runs[0].pmEnd).toBe(20); + expect(shifted.runs[1].pmStart).toBe(20); + expect(shifted.runs[1].pmEnd).toBe(25); + }); + + it('handles null pmStart/pmEnd in runs', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'hello', pmStart: null, pmEnd: undefined } as unknown as Run], + }; + + const shifted = shiftBlockPositions(block, 5) as ParagraphBlock; + + expect(shifted.runs[0].pmStart).toBeNull(); + expect(shifted.runs[0].pmEnd).toBeUndefined(); + }); + + it('returns a new block instance (does not mutate original)', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'hello', pmStart: 10, pmEnd: 15 } as Run], + }; + + const shifted = shiftBlockPositions(block, 5); + + expect(shifted).not.toBe(block); + expect((shifted as ParagraphBlock).runs).not.toBe(block.runs); + expect(block.runs[0].pmStart).toBe(10); // Original unchanged + }); + }); + + describe('image blocks', () => { + it('shifts pmStart and pmEnd in attrs', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 10, pmEnd: 12 }, + } as unknown as ImageBlock; + + const shifted = shiftBlockPositions(block, 5) as ImageBlock; + + expect((shifted.attrs as Record).pmStart).toBe(15); + expect((shifted.attrs as Record).pmEnd).toBe(17); + }); + + it('handles only pmStart in attrs', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 10 }, + } as unknown as ImageBlock; + + const shifted = shiftBlockPositions(block, 5) as ImageBlock; + + expect((shifted.attrs as Record).pmStart).toBe(15); + expect((shifted.attrs as Record).pmEnd).toBeUndefined(); + }); + + it('handles only pmEnd in attrs', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmEnd: 12 }, + } as unknown as ImageBlock; + + const shifted = shiftBlockPositions(block, 5) as ImageBlock; + + expect((shifted.attrs as Record).pmStart).toBeUndefined(); + expect((shifted.attrs as Record).pmEnd).toBe(17); + }); + + it('preserves other attrs properties', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 10, pmEnd: 12, customProp: 'value', isAnchor: true }, + } as unknown as ImageBlock; + + const shifted = shiftBlockPositions(block, 5) as ImageBlock; + + expect((shifted.attrs as Record).customProp).toBe('value'); + expect((shifted.attrs as Record).isAnchor).toBe(true); + }); + + it('returns shallow copy when no attrs positions', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { customProp: 'value' }, + } as unknown as ImageBlock; + + const shifted = shiftBlockPositions(block, 5); + + expect(shifted).not.toBe(block); + expect(shifted.kind).toBe('image'); + }); + + it('returns shallow copy when no attrs', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + } as ImageBlock; + + const shifted = shiftBlockPositions(block, 5); + + expect(shifted).not.toBe(block); + expect(shifted.kind).toBe('image'); + }); + + it('does not mutate original block', () => { + const block = { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 10, pmEnd: 12 }, + } as unknown as ImageBlock; + + shiftBlockPositions(block, 5); + + expect((block.attrs as Record).pmStart).toBe(10); + expect((block.attrs as Record).pmEnd).toBe(12); + }); + }); + + describe('drawing blocks', () => { + it('shifts pmStart and pmEnd in attrs', () => { + const block = { + kind: 'drawing', + id: 'draw1', + drawingKind: 'vectorShape', + attrs: { pmStart: 20, pmEnd: 22 }, + } as unknown as DrawingBlock; + + const shifted = shiftBlockPositions(block, -5) as DrawingBlock; + + expect((shifted.attrs as Record).pmStart).toBe(15); + expect((shifted.attrs as Record).pmEnd).toBe(17); + }); + + it('handles negative deltas correctly', () => { + const block = { + kind: 'drawing', + id: 'draw1', + drawingKind: 'vectorShape', + attrs: { pmStart: 100, pmEnd: 102 }, + } as unknown as DrawingBlock; + + const shifted = shiftBlockPositions(block, -50) as DrawingBlock; + + expect((shifted.attrs as Record).pmStart).toBe(50); + expect((shifted.attrs as Record).pmEnd).toBe(52); + }); + }); + + describe('blocks with top-level positions', () => { + it('shifts pmStart and pmEnd at block level', () => { + const block = { + kind: 'sectionBreak', + id: 'sb1', + pmStart: 100, + pmEnd: 102, + } as unknown as FlowBlock; + + const shifted = shiftBlockPositions(block, 10) as FlowBlock & { pmStart: number; pmEnd: number }; + + expect(shifted.pmStart).toBe(110); + expect(shifted.pmEnd).toBe(112); + }); + }); + + describe('blocks without positions', () => { + it('returns shallow copy for blocks without any position tracking', () => { + const block = { + kind: 'pageBreak', + id: 'pb1', + } as FlowBlock; + + const shifted = shiftBlockPositions(block, 10); + + expect(shifted).not.toBe(block); + expect(shifted.kind).toBe('pageBreak'); + expect(shifted.id).toBe('pb1'); + }); + }); +}); + +describe('shiftCachedBlocks', () => { + it('shifts all blocks in array', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'hello', pmStart: 10, pmEnd: 15 } as Run], + } as ParagraphBlock, + { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 20, pmEnd: 22 }, + } as unknown as ImageBlock, + ]; + + const shifted = shiftCachedBlocks(blocks, 5); + + expect(shifted.length).toBe(2); + expect((shifted[0] as ParagraphBlock).runs[0].pmStart).toBe(15); + expect(((shifted[1] as ImageBlock).attrs as Record).pmStart).toBe(25); + }); + + it('returns new array (does not mutate original)', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'hello', pmStart: 10, pmEnd: 15 } as Run], + } as ParagraphBlock, + ]; + + const shifted = shiftCachedBlocks(blocks, 5); + + expect(shifted).not.toBe(blocks); + expect((blocks[0] as ParagraphBlock).runs[0].pmStart).toBe(10); + }); + + it('handles empty array', () => { + const shifted = shiftCachedBlocks([], 5); + expect(shifted).toEqual([]); + }); + + it('creates copies even with delta of 0', () => { + const blocks: FlowBlock[] = [ + { + kind: 'image', + id: 'img1', + src: 'test.png', + attrs: { pmStart: 20, pmEnd: 22 }, + } as unknown as ImageBlock, + ]; + + const shifted = shiftCachedBlocks(blocks, 0); + + expect(shifted).not.toBe(blocks); + expect(shifted[0]).not.toBe(blocks[0]); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/cache.ts b/packages/layout-engine/pm-adapter/src/cache.ts index cb8a934776..664c493c36 100644 --- a/packages/layout-engine/pm-adapter/src/cache.ts +++ b/packages/layout-engine/pm-adapter/src/cache.ts @@ -173,6 +173,11 @@ export class FlowBlockCache { * * Always returns a shallow copy to prevent cache pollution from downstream mutations. * + * PM positions may be stored in different locations depending on block type: + * - Paragraph blocks: positions in each run (run.pmStart, run.pmEnd) + * - Atomic blocks (image, drawing): positions in attrs (block.attrs.pmStart, block.attrs.pmEnd) + * - Other blocks: positions at block level (block.pmStart, block.pmEnd) + * * @param block - The block to shift * @param delta - The position delta (newPmStart - oldPmStart) * @returns A new block (shallow copy) with shifted positions @@ -191,8 +196,34 @@ export function shiftBlockPositions(block: FlowBlock, delta: number): FlowBlock }; } + // Handle atomic blocks (image, drawing) that store PM positions in attrs + // These blocks store pmStart/pmEnd in block.attrs rather than at the block level + if (block.kind === 'image' || block.kind === 'drawing') { + const blockWithAttrs = block as FlowBlock & { attrs?: Record }; + if (blockWithAttrs.attrs) { + const attrsPmStart = blockWithAttrs.attrs.pmStart; + const attrsPmEnd = blockWithAttrs.attrs.pmEnd; + const hasAttrsPositions = + (typeof attrsPmStart === 'number' && Number.isFinite(attrsPmStart)) || + (typeof attrsPmEnd === 'number' && Number.isFinite(attrsPmEnd)); + + if (hasAttrsPositions) { + return { + ...block, + attrs: { + ...blockWithAttrs.attrs, + pmStart: + typeof attrsPmStart === 'number' && Number.isFinite(attrsPmStart) ? attrsPmStart + delta : attrsPmStart, + pmEnd: typeof attrsPmEnd === 'number' && Number.isFinite(attrsPmEnd) ? attrsPmEnd + delta : attrsPmEnd, + }, + } as unknown as FlowBlock; + } + } + // Fall through to shallow copy if no attrs positions + } + // For other block types, always create a shallow copy to prevent cache pollution. - // If the block has position tracking, shift the positions. + // If the block has position tracking at the block level, shift the positions. const blockWithPos = block as FlowBlock & { pmStart?: number; pmEnd?: number }; if (blockWithPos.pmStart != null || blockWithPos.pmEnd != null) { return { From 11ed2263d755088423a23b818f8b86d05394a84f Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 2 Feb 2026 22:16:34 -0800 Subject: [PATCH 3/5] chore: missing type file --- .../layout-engine/pm-adapter/src/cache.d.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/layout-engine/pm-adapter/src/cache.d.ts diff --git a/packages/layout-engine/pm-adapter/src/cache.d.ts b/packages/layout-engine/pm-adapter/src/cache.d.ts new file mode 100644 index 0000000000..ce88d8b301 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/cache.d.ts @@ -0,0 +1,112 @@ +/** + * FlowBlock Cache for Incremental toFlowBlocks Conversion + */ + +import type { FlowBlock } from '@superdoc/contracts'; +import type { PMNode } from './types.js'; + +export type CachedParagraphEntry = { + /** JSON string of the PM node for equality comparison */ + nodeJson?: string; + /** Optional revision number for fast equality comparison */ + nodeRev?: number | null; + /** 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; + /** Parsed node revision (if present) */ + nodeRev?: number | null; +}; + +export declare class FlowBlockCache { + /** + * Begin a new render cycle. Clears the "next" map and resets stats. + */ + begin(): void; + + /** + * Look up cached blocks for a paragraph by its stable ID. + * Returns the cached entry only if the node content matches (via JSON comparison). + * + * @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; + + /** + * 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 nodeRev - Node revision number (if available) + * @param blocks - All FlowBlocks produced from this paragraph + * @param pmStart - PM document position where this paragraph starts + */ + set( + id: string, + nodeJson: string | undefined, + nodeRev: number | null | undefined, + blocks: FlowBlock[], + pmStart: number, + ): void; + + /** + * Commit the current render cycle. + * Swaps "next" to "previous", so only blocks seen in this render are retained. + */ + commit(): void; + + /** + * Clear the entire cache. + * Call this on document load or when conversion settings change. + */ + clear(): void; + + /** + * Get cache statistics for the current render cycle. + */ + get stats(): FlowBlockCacheStats; +} + +/** + * Shift PM positions in a single block by a delta. + * + * @param block - The block to shift + * @param delta - The position delta (newPmStart - oldPmStart) + * @returns A new block (shallow copy) with shifted positions + */ +export declare function shiftBlockPositions(block: FlowBlock, delta: number): 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 declare function shiftCachedBlocks(blocks: FlowBlock[], delta: number): FlowBlock[]; + +/** + * Extract stable paragraph ID from PM node attributes. + * + * @param node - PM node (JSON object) to extract ID from + * @returns Stable ID string, or null if no stable ID is available + */ +export declare function getStableParagraphId(node: PMNode): string | null; From 5fdd3bbdeb4eb7f047d982de6b6a2ed334373f7a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 2 Feb 2026 22:23:01 -0800 Subject: [PATCH 4/5] chore: revert package.json --- packages/layout-engine/contracts/package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/contracts/package.json b/packages/layout-engine/contracts/package.json index 64926da874..4d6afd8aba 100644 --- a/packages/layout-engine/contracts/package.json +++ b/packages/layout-engine/contracts/package.json @@ -4,12 +4,13 @@ "description": "Shared layout contracts & engine interfaces for the SuperDoc layout engine pipeline.", "type": "module", "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" } }, "scripts": { From 0dc95120ab25c6f00a142660e3243a3dff8bb07d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 2 Feb 2026 22:30:40 -0800 Subject: [PATCH 5/5] chore: fix build --- e2e-tests/templates/vue/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/templates/vue/package.json b/e2e-tests/templates/vue/package.json index c3abe3912a..f52b66bc86 100644 --- a/e2e-tests/templates/vue/package.json +++ b/e2e-tests/templates/vue/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "pnpm --filter superdoc build", "build": "vite build", "preview": "vite preview" },