From 30a0bc71cb9dfc3cab447b22ecb2bbf8bc351601 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 11:51:11 -0300 Subject: [PATCH 01/13] refactor(editor): extract SDT selection styling into SdtSelectionStyleManager Move 12 methods and 4 state fields for structured content selection and hover styling from PresentationEditor into a dedicated manager class. SdtSelectionStyleManager handles: - Field annotation selection (ProseMirror-selectednode on .annotation) - SDT block selection (ProseMirror-selectednode on .superdoc-structured-content-block) - SDT inline selection (ProseMirror-selectednode on .superdoc-structured-content-inline) - SDT block hover (sdt-group-hover on mouseover/mouseout) Dependencies injected: painterHost (DOM reference) and getElementAtPos (fallback position lookup). PresentationEditor keeps thin delegation calls: syncAll, clearAll, clearFieldAnnotation. Reduces PresentationEditor from 5937 to 5531 lines (-406). --- .../presentation-editor/PresentationEditor.ts | 343 +---------------- .../selection/SdtSelectionStyleManager.ts | 352 ++++++++++++++++++ 2 files changed, 366 insertions(+), 329 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/selection/SdtSelectionStyleManager.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 0d920ec64a..ab6d09ce12 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2,6 +2,7 @@ import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; import { ContextMenuPluginKey } from '@extensions/context-menu/context-menu.js'; import { CellSelection } from 'prosemirror-tables'; import { DecorationBridge } from './dom/DecorationBridge.js'; +import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -329,22 +330,7 @@ export class PresentationEditor extends EventEmitter { #ariaLiveRegion: HTMLElement | null = null; #a11ySelectionAnnounceTimeout: number | null = null; #a11yLastAnnouncedSelectionKey: string | null = null; - #lastSelectedFieldAnnotation: { - element: HTMLElement; - pmStart: number; - } | null = null; - #lastSelectedStructuredContentBlock: { - id: string | null; - elements: HTMLElement[]; - } | null = null; - #lastSelectedStructuredContentInline: { - id: string | null; - elements: HTMLElement[]; - } | null = null; - #lastHoveredStructuredContentBlock: { - id: string | null; - elements: HTMLElement[]; - } | null = null; + #sdtStyles!: SdtSelectionStyleManager; // Remote cursor/presence state management /** Manager for remote cursor rendering and awareness subscriptions */ @@ -443,9 +429,13 @@ export class PresentationEditor extends EventEmitter { this.#painterHost.style.transformOrigin = 'top left'; this.#viewportHost.appendChild(this.#painterHost); - // Add event listeners for structured content hover coordination - this.#painterHost.addEventListener('mouseover', this.#handleStructuredContentBlockMouseEnter); - this.#painterHost.addEventListener('mouseout', this.#handleStructuredContentBlockMouseLeave); + // SDT selection/hover styling manager + this.#sdtStyles = new SdtSelectionStyleManager({ + painterHost: this.#painterHost, + getElementAtPos: (pos, opts) => this.getElementAtPos(pos, opts), + }); + this.#painterHost.addEventListener('mouseover', this.#sdtStyles.handleMouseEnter); + this.#painterHost.addEventListener('mouseout', this.#sdtStyles.handleMouseLeave); const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; this.#domIndexObserverManager = new DomPositionIndexObserverManager({ @@ -3854,307 +3844,6 @@ export class PresentationEditor extends EventEmitter { this.#selectionSync.requestRender(options); } - #clearSelectedFieldAnnotationClass() { - if (this.#lastSelectedFieldAnnotation?.element?.classList?.contains('ProseMirror-selectednode')) { - this.#lastSelectedFieldAnnotation.element.classList.remove('ProseMirror-selectednode'); - } - this.#lastSelectedFieldAnnotation = null; - } - - #setSelectedFieldAnnotationClass(element: HTMLElement, pmStart: number) { - if (this.#lastSelectedFieldAnnotation?.element && this.#lastSelectedFieldAnnotation.element !== element) { - this.#lastSelectedFieldAnnotation.element.classList.remove('ProseMirror-selectednode'); - } - element.classList.add('ProseMirror-selectednode'); - this.#lastSelectedFieldAnnotation = { element, pmStart }; - } - - #syncSelectedFieldAnnotationClass(selection: Selection | null | undefined) { - if (!selection || !(selection instanceof NodeSelection)) { - this.#clearSelectedFieldAnnotationClass(); - return; - } - - const node = selection.node; - if (!node || node.type?.name !== 'fieldAnnotation') { - this.#clearSelectedFieldAnnotationClass(); - return; - } - - if (!this.#painterHost) { - this.#clearSelectedFieldAnnotationClass(); - return; - } - - const pmStart = selection.from; - if (this.#lastSelectedFieldAnnotation?.pmStart === pmStart && this.#lastSelectedFieldAnnotation.element) { - return; - } - - const selector = `.annotation[data-pm-start="${pmStart}"]`; - const element = this.#painterHost.querySelector(selector) as HTMLElement | null; - if (!element) { - this.#clearSelectedFieldAnnotationClass(); - return; - } - - this.#setSelectedFieldAnnotationClass(element, pmStart); - } - - #clearSelectedStructuredContentBlockClass() { - if (!this.#lastSelectedStructuredContentBlock) return; - this.#lastSelectedStructuredContentBlock.elements.forEach((element) => { - element.classList.remove('ProseMirror-selectednode'); - }); - this.#lastSelectedStructuredContentBlock = null; - } - - #setSelectedStructuredContentBlockClass(elements: HTMLElement[], id: string | null) { - if ( - this.#lastSelectedStructuredContentBlock && - this.#lastSelectedStructuredContentBlock.id === id && - this.#lastSelectedStructuredContentBlock.elements.length === elements.length && - this.#lastSelectedStructuredContentBlock.elements.every((el) => elements.includes(el)) - ) { - return; - } - - this.#clearSelectedStructuredContentBlockClass(); - elements.forEach((element) => element.classList.add('ProseMirror-selectednode')); - this.#lastSelectedStructuredContentBlock = { id, elements }; - } - - #syncSelectedStructuredContentBlockClass(selection: Selection | null | undefined) { - if (!selection) { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - - let node: ProseMirrorNode | null = null; - let id: string | null = null; - - if (selection instanceof NodeSelection) { - if (selection.node?.type?.name !== 'structuredContentBlock') { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - node = selection.node; - } else { - const $pos = (selection as Selection & { $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode } }) - .$from; - if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - for (let depth = $pos.depth; depth > 0; depth--) { - const candidate = $pos.node(depth); - if (candidate.type?.name === 'structuredContentBlock') { - node = candidate; - break; - } - } - if (!node) { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - } - - if (!this.#painterHost) { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - - const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; - id = rawId != null ? String(rawId) : null; - let elements: HTMLElement[] = []; - - if (id) { - const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); - elements = Array.from( - this.#painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), - ) as HTMLElement[]; - } - - if (elements.length === 0) { - const elementAtPos = this.getElementAtPos(selection.from, { fallbackToCoords: true }); - const container = elementAtPos?.closest?.('.superdoc-structured-content-block') as HTMLElement | null; - if (container) { - elements = [container]; - } - } - - if (elements.length === 0) { - this.#clearSelectedStructuredContentBlockClass(); - return; - } - - this.#setSelectedStructuredContentBlockClass(elements, id); - } - - #handleStructuredContentBlockMouseEnter = (event: MouseEvent) => { - const target = event.target as HTMLElement; - const block = target.closest('.superdoc-structured-content-block'); - - if (!block || !(block instanceof HTMLElement)) return; - - // Don't show hover effect if already selected - if (block.classList.contains('ProseMirror-selectednode')) return; - - const rawId = block.dataset.sdtId; - if (!rawId) return; - - this.#setHoveredStructuredContentBlockClass(rawId); - }; - - #handleStructuredContentBlockMouseLeave = (event: MouseEvent) => { - const target = event.target as HTMLElement; - const block = target.closest('.superdoc-structured-content-block') as HTMLElement | null; - - if (!block) return; - - const relatedTarget = event.relatedTarget as HTMLElement | null; - if ( - relatedTarget && - block.dataset.sdtId && - relatedTarget.closest(`.superdoc-structured-content-block[data-sdt-id="${block.dataset.sdtId}"]`) - ) { - return; - } - - this.#clearHoveredStructuredContentBlockClass(); - }; - - #clearHoveredStructuredContentBlockClass() { - if (!this.#lastHoveredStructuredContentBlock) return; - this.#lastHoveredStructuredContentBlock.elements.forEach((element) => { - element.classList.remove('sdt-group-hover'); - }); - this.#lastHoveredStructuredContentBlock = null; - } - - #setHoveredStructuredContentBlockClass(id: string) { - if (this.#lastHoveredStructuredContentBlock?.id === id) return; - - this.#clearHoveredStructuredContentBlockClass(); - - if (!this.#painterHost) return; - - const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); - const elements = Array.from( - this.#painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), - ) as HTMLElement[]; - - if (elements.length === 0) return; - - elements.forEach((element) => { - if (!element.classList.contains('ProseMirror-selectednode')) { - element.classList.add('sdt-group-hover'); - } - }); - - this.#lastHoveredStructuredContentBlock = { id, elements }; - } - - #clearSelectedStructuredContentInlineClass() { - if (!this.#lastSelectedStructuredContentInline) return; - this.#lastSelectedStructuredContentInline.elements.forEach((element) => { - element.classList.remove('ProseMirror-selectednode'); - }); - this.#lastSelectedStructuredContentInline = null; - } - - #setSelectedStructuredContentInlineClass(elements: HTMLElement[], id: string | null) { - if ( - this.#lastSelectedStructuredContentInline && - this.#lastSelectedStructuredContentInline.id === id && - this.#lastSelectedStructuredContentInline.elements.length === elements.length && - this.#lastSelectedStructuredContentInline.elements.every((el) => elements.includes(el)) - ) { - return; - } - - this.#clearSelectedStructuredContentInlineClass(); - elements.forEach((element) => element.classList.add('ProseMirror-selectednode')); - this.#lastSelectedStructuredContentInline = { id, elements }; - } - - #syncSelectedStructuredContentInlineClass(selection: Selection | null | undefined) { - if (!selection) { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - - let node: ProseMirrorNode | null = null; - let id: string | null = null; - let pos: number | null = null; - - if (selection instanceof NodeSelection) { - if (selection.node?.type?.name !== 'structuredContent') { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - node = selection.node; - pos = selection.from; - } else { - const $pos = ( - selection as Selection & { - $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode; before?: (depth: number) => number }; - } - ).$from; - if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - for (let depth = $pos.depth; depth > 0; depth--) { - const candidate = $pos.node(depth); - if (candidate.type?.name === 'structuredContent') { - if (typeof $pos.before !== 'function') { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - node = candidate; - pos = $pos.before(depth); - break; - } - } - if (!node || pos == null) { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - } - - if (!this.#painterHost) { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - - const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; - id = rawId != null ? String(rawId) : null; - let elements: HTMLElement[] = []; - - if (id) { - const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); - elements = Array.from( - this.#painterHost.querySelectorAll(`.superdoc-structured-content-inline[data-sdt-id="${escapedId}"]`), - ) as HTMLElement[]; - } - - if (elements.length === 0) { - const elementAtPos = this.getElementAtPos(pos, { fallbackToCoords: true }); - const container = elementAtPos?.closest?.('.superdoc-structured-content-inline') as HTMLElement | null; - if (container) { - elements = [container]; - } - } - - if (elements.length === 0) { - this.#clearSelectedStructuredContentInlineClass(); - return; - } - - this.#setSelectedStructuredContentInlineClass(elements, id); - } - /** * Updates the visual cursor/selection overlay to match the current editor selection. * @@ -4185,7 +3874,7 @@ export class PresentationEditor extends EventEmitter { // In header/footer mode, the ProseMirror editor handles its own caret const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - this.#clearSelectedFieldAnnotationClass(); + this.#sdtStyles.clearFieldAnnotation(); return; } @@ -4197,7 +3886,7 @@ export class PresentationEditor extends EventEmitter { // In viewing mode, don't render caret or selection highlights if (this.#isViewLocked()) { try { - this.#clearSelectedFieldAnnotationClass(); + this.#sdtStyles.clearFieldAnnotation(); this.#localSelectionLayer.innerHTML = ''; } catch (error) { // DOM manipulation can fail if element is detached or in invalid state @@ -4222,7 +3911,7 @@ export class PresentationEditor extends EventEmitter { if (!hasFocus && !contextMenuOpen && !isOnEditorUi) { try { - this.#clearSelectedFieldAnnotationClass(); + this.#sdtStyles.clearFieldAnnotation(); this.#localSelectionLayer.innerHTML = ''; } catch {} return; @@ -4234,9 +3923,7 @@ export class PresentationEditor extends EventEmitter { if (!selection) { try { - this.#clearSelectedFieldAnnotationClass(); - this.#clearSelectedStructuredContentBlockClass(); - this.#clearSelectedStructuredContentInlineClass(); + this.#sdtStyles.clearAll(); this.#localSelectionLayer.innerHTML = ''; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -4259,9 +3946,7 @@ export class PresentationEditor extends EventEmitter { return; } - this.#syncSelectedFieldAnnotationClass(selection); - this.#syncSelectedStructuredContentBlockClass(selection); - this.#syncSelectedStructuredContentInlineClass(selection); + this.#sdtStyles.syncAll(selection); // Ensure selection endpoints remain mounted under virtualization so DOM-first // caret/selection rendering stays available during cross-page selection. diff --git a/packages/super-editor/src/core/presentation-editor/selection/SdtSelectionStyleManager.ts b/packages/super-editor/src/core/presentation-editor/selection/SdtSelectionStyleManager.ts new file mode 100644 index 0000000000..4f09c3ce94 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/selection/SdtSelectionStyleManager.ts @@ -0,0 +1,352 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { NodeSelection, type Selection } from 'prosemirror-state'; + +interface ElementIdState { + id: string | null; + elements: HTMLElement[]; +} + +interface FieldAnnotationState { + element: HTMLElement; + pmStart: number; +} + +export interface SdtSelectionStyleDeps { + painterHost: HTMLElement; + getElementAtPos: (pos: number, options?: { fallbackToCoords?: boolean }) => HTMLElement | null; +} + +/** + * Manages CSS class toggling for selected/hovered SDT (Structured Document Tag) + * elements in the DomPainter layer. Handles three element types: + * + * - Field annotations: `.annotation[data-pm-start]` → `ProseMirror-selectednode` + * - SDT blocks: `.superdoc-structured-content-block[data-sdt-id]` → `ProseMirror-selectednode` + * - SDT inline: `.superdoc-structured-content-inline[data-sdt-id]` → `ProseMirror-selectednode` + * - SDT block hover: `.superdoc-structured-content-block[data-sdt-id]` → `sdt-group-hover` + */ +export class SdtSelectionStyleManager { + #deps: SdtSelectionStyleDeps; + #lastSelectedFieldAnnotation: FieldAnnotationState | null = null; + #lastSelectedBlock: ElementIdState | null = null; + #lastSelectedInline: ElementIdState | null = null; + #lastHoveredBlock: ElementIdState | null = null; + + constructor(deps: SdtSelectionStyleDeps) { + this.#deps = deps; + } + + // ── Field Annotation ── + + clearFieldAnnotation(): void { + if (this.#lastSelectedFieldAnnotation?.element?.classList?.contains('ProseMirror-selectednode')) { + this.#lastSelectedFieldAnnotation.element.classList.remove('ProseMirror-selectednode'); + } + this.#lastSelectedFieldAnnotation = null; + } + + syncFieldAnnotation(selection: Selection | null | undefined): void { + if (!selection || !(selection instanceof NodeSelection)) { + this.clearFieldAnnotation(); + return; + } + + const node = selection.node; + if (!node || node.type?.name !== 'fieldAnnotation') { + this.clearFieldAnnotation(); + return; + } + + if (!this.#deps.painterHost) { + this.clearFieldAnnotation(); + return; + } + + const pmStart = selection.from; + if (this.#lastSelectedFieldAnnotation?.pmStart === pmStart && this.#lastSelectedFieldAnnotation.element) { + return; + } + + const selector = `.annotation[data-pm-start="${pmStart}"]`; + const element = this.#deps.painterHost.querySelector(selector) as HTMLElement | null; + if (!element) { + this.clearFieldAnnotation(); + return; + } + + if (this.#lastSelectedFieldAnnotation?.element && this.#lastSelectedFieldAnnotation.element !== element) { + this.#lastSelectedFieldAnnotation.element.classList.remove('ProseMirror-selectednode'); + } + element.classList.add('ProseMirror-selectednode'); + this.#lastSelectedFieldAnnotation = { element, pmStart }; + } + + // ── SDT Block Selection ── + + clearBlock(): void { + if (!this.#lastSelectedBlock) return; + this.#lastSelectedBlock.elements.forEach((el) => el.classList.remove('ProseMirror-selectednode')); + this.#lastSelectedBlock = null; + } + + syncBlock(selection: Selection | null | undefined): void { + if (!selection) { + this.clearBlock(); + return; + } + + let node: ProseMirrorNode | null = null; + + if (selection instanceof NodeSelection) { + if (selection.node?.type?.name !== 'structuredContentBlock') { + this.clearBlock(); + return; + } + node = selection.node; + } else { + const $pos = (selection as Selection & { $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode } }) + .$from; + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + this.clearBlock(); + return; + } + for (let depth = $pos.depth; depth > 0; depth--) { + const candidate = $pos.node(depth); + if (candidate.type?.name === 'structuredContentBlock') { + node = candidate; + break; + } + } + if (!node) { + this.clearBlock(); + return; + } + } + + if (!this.#deps.painterHost) { + this.clearBlock(); + return; + } + + const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; + const id = rawId != null ? String(rawId) : null; + let elements: HTMLElement[] = []; + + if (id) { + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + elements = Array.from( + this.#deps.painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + } + + if (elements.length === 0) { + const elementAtPos = this.#deps.getElementAtPos(selection.from, { fallbackToCoords: true }); + const container = elementAtPos?.closest?.('.superdoc-structured-content-block') as HTMLElement | null; + if (container) { + elements = [container]; + } + } + + if (elements.length === 0) { + this.clearBlock(); + return; + } + + this.#setBlock(elements, id); + } + + #setBlock(elements: HTMLElement[], id: string | null): void { + if ( + this.#lastSelectedBlock && + this.#lastSelectedBlock.id === id && + this.#lastSelectedBlock.elements.length === elements.length && + this.#lastSelectedBlock.elements.every((el) => elements.includes(el)) + ) { + return; + } + + this.clearBlock(); + elements.forEach((el) => el.classList.add('ProseMirror-selectednode')); + this.#lastSelectedBlock = { id, elements }; + } + + // ── SDT Block Hover ── + + handleMouseEnter = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + const block = target.closest('.superdoc-structured-content-block'); + + if (!block || !(block instanceof HTMLElement)) return; + if (block.classList.contains('ProseMirror-selectednode')) return; + + const rawId = block.dataset.sdtId; + if (!rawId) return; + + this.#setHoveredBlock(rawId); + }; + + handleMouseLeave = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + const block = target.closest('.superdoc-structured-content-block') as HTMLElement | null; + + if (!block) return; + + const relatedTarget = event.relatedTarget as HTMLElement | null; + if ( + relatedTarget && + block.dataset.sdtId && + relatedTarget.closest(`.superdoc-structured-content-block[data-sdt-id="${block.dataset.sdtId}"]`) + ) { + return; + } + + this.clearHoveredBlock(); + }; + + clearHoveredBlock(): void { + if (!this.#lastHoveredBlock) return; + this.#lastHoveredBlock.elements.forEach((el) => el.classList.remove('sdt-group-hover')); + this.#lastHoveredBlock = null; + } + + #setHoveredBlock(id: string): void { + if (this.#lastHoveredBlock?.id === id) return; + + this.clearHoveredBlock(); + + if (!this.#deps.painterHost) return; + + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + const elements = Array.from( + this.#deps.painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + + if (elements.length === 0) return; + + elements.forEach((el) => { + if (!el.classList.contains('ProseMirror-selectednode')) { + el.classList.add('sdt-group-hover'); + } + }); + + this.#lastHoveredBlock = { id, elements }; + } + + // ── SDT Inline Selection ── + + clearInline(): void { + if (!this.#lastSelectedInline) return; + this.#lastSelectedInline.elements.forEach((el) => el.classList.remove('ProseMirror-selectednode')); + this.#lastSelectedInline = null; + } + + syncInline(selection: Selection | null | undefined): void { + if (!selection) { + this.clearInline(); + return; + } + + let node: ProseMirrorNode | null = null; + let pos: number | null = null; + + if (selection instanceof NodeSelection) { + if (selection.node?.type?.name !== 'structuredContent') { + this.clearInline(); + return; + } + node = selection.node; + pos = selection.from; + } else { + const $pos = ( + selection as Selection & { + $from?: { depth?: number; node?: (depth: number) => ProseMirrorNode; before?: (depth: number) => number }; + } + ).$from; + if (!$pos || typeof $pos.depth !== 'number' || typeof $pos.node !== 'function') { + this.clearInline(); + return; + } + for (let depth = $pos.depth; depth > 0; depth--) { + const candidate = $pos.node(depth); + if (candidate.type?.name === 'structuredContent') { + if (typeof $pos.before !== 'function') { + this.clearInline(); + return; + } + node = candidate; + pos = $pos.before(depth); + break; + } + } + if (!node || pos == null) { + this.clearInline(); + return; + } + } + + if (!this.#deps.painterHost) { + this.clearInline(); + return; + } + + const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; + const id = rawId != null ? String(rawId) : null; + let elements: HTMLElement[] = []; + + if (id) { + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + elements = Array.from( + this.#deps.painterHost.querySelectorAll(`.superdoc-structured-content-inline[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + } + + if (elements.length === 0) { + const elementAtPos = this.#deps.getElementAtPos(pos, { fallbackToCoords: true }); + const container = elementAtPos?.closest?.('.superdoc-structured-content-inline') as HTMLElement | null; + if (container) { + elements = [container]; + } + } + + if (elements.length === 0) { + this.clearInline(); + return; + } + + this.#setInline(elements, id); + } + + #setInline(elements: HTMLElement[], id: string | null): void { + if ( + this.#lastSelectedInline && + this.#lastSelectedInline.id === id && + this.#lastSelectedInline.elements.length === elements.length && + this.#lastSelectedInline.elements.every((el) => elements.includes(el)) + ) { + return; + } + + this.clearInline(); + elements.forEach((el) => el.classList.add('ProseMirror-selectednode')); + this.#lastSelectedInline = { id, elements }; + } + + // ── Bulk Operations ── + + clearAll(): void { + this.clearFieldAnnotation(); + this.clearBlock(); + this.clearInline(); + } + + syncAll(selection: Selection | null | undefined): void { + this.syncFieldAnnotation(selection); + this.syncBlock(selection); + this.syncInline(selection); + } + + destroy(): void { + this.clearAll(); + this.clearHoveredBlock(); + } +} From 685daf94aa9f5ab111b9ecffdb4a52bc4a9ad4f5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 11:59:23 -0300 Subject: [PATCH 02/13] refactor(editor): extract semantic flow logic into SemanticFlowController Move ResizeObserver management, margin resolution, and container width calculation from PresentationEditor into a dedicated controller. SemanticFlowController owns: - ResizeObserver on visibleHost for container resize detection - RAF + debounce scheduling for resize relayout - Margin resolution (firstSection / none / custom modes) - Container inner width calculation - resolveSemanticLayout() for the layout options builder PresentationEditor keeps #isSemanticFlowMode() as a one-liner since it's called from many places. All other semantic flow state and logic is now in the controller. Reduces PresentationEditor from 5531 to 5424 lines (-107). --- .../presentation-editor/PresentationEditor.ts | 168 +++-------------- .../layout/SemanticFlowController.ts | 177 ++++++++++++++++++ 2 files changed, 208 insertions(+), 137 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/layout/SemanticFlowController.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index ab6d09ce12..81ac1c5943 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3,6 +3,7 @@ import { ContextMenuPluginKey } from '@extensions/context-menu/context-menu.js'; import { CellSelection } from 'prosemirror-tables'; import { DecorationBridge } from './dom/DecorationBridge.js'; import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js'; +import { SemanticFlowController } from './layout/SemanticFlowController.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -209,10 +210,6 @@ const HEADER_FOOTER_INIT_BUDGET_MS = 200; const MAX_ZOOM_WARNING_THRESHOLD = 10; /** Maximum number of selection rectangles per user (performance guardrail) */ const MAX_SELECTION_RECTS_PER_USER = 100; -/** Debounce delay for semantic-flow relayout after host resize (milliseconds). */ -const SEMANTIC_RESIZE_DEBOUNCE_MS = 120; -/** Minimum semantic content width in pixels. */ -const MIN_SEMANTIC_CONTENT_WIDTH_PX = 1; const GLOBAL_PERFORMANCE: Performance | undefined = typeof performance !== 'undefined' ? performance : undefined; @@ -309,10 +306,7 @@ export class PresentationEditor extends EventEmitter { /** RAF handle for coalesced decoration sync scheduling. */ #decorationSyncRafHandle: number | null = null; #rafHandle: number | null = null; - #semanticResizeObserver: ResizeObserver | null = null; - #semanticResizeRaf: number | null = null; - #semanticResizeDebounce: number | null = null; - #lastSemanticContainerWidth: number | null = null; + #semanticFlow!: SemanticFlowController; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; #scrollHandler: (() => void) | null = null; #scrollContainer: Element | Window | null = null; @@ -624,7 +618,23 @@ export class PresentationEditor extends EventEmitter { this.#setupDragHandlers(); this.#setupInputBridge(); this.#syncTrackedChangesPreferences(); - this.#setupSemanticResizeObserver(); + this.#semanticFlow = new SemanticFlowController({ + visibleHost: this.#visibleHost, + getFlowMode: () => this.#layoutOptions.flowMode, + getSemanticOptions: () => this.#layoutOptions.semanticOptions, + requestRerender: () => { + this.#pendingDocChange = true; + this.#scheduleRerender(); + }, + defaultPageWidth: DEFAULT_PAGE_SIZE.w, + defaultMargins: { + left: DEFAULT_MARGINS.left!, + right: DEFAULT_MARGINS.right!, + top: DEFAULT_MARGINS.top!, + bottom: DEFAULT_MARGINS.bottom!, + }, + }); + this.#semanticFlow.setup(); // Register this instance in the static registry. // Use a separate field to avoid mutating the caller's options object and to keep @@ -1532,99 +1542,6 @@ export class PresentationEditor extends EventEmitter { return this.#layoutOptions.flowMode === 'semantic'; } - #resolveSemanticMargins(margins: PageMargins): { left: number; right: number; top: number; bottom: number } { - const mode = this.#layoutOptions.semanticOptions?.marginsMode ?? 'firstSection'; - if (mode === 'none') { - return { left: 0, right: 0, top: 0, bottom: 0 }; - } - - const clamp = (value: number | undefined, fallback: number): number => { - const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback; - return v >= 0 ? v : fallback; - }; - - if (mode === 'custom') { - const custom = this.#layoutOptions.semanticOptions?.customMargins; - return { - left: clamp(custom?.left, clamp(margins.left, DEFAULT_MARGINS.left!)), - right: clamp(custom?.right, clamp(margins.right, DEFAULT_MARGINS.right!)), - top: clamp(custom?.top, clamp(margins.top, DEFAULT_MARGINS.top!)), - bottom: clamp(custom?.bottom, clamp(margins.bottom, DEFAULT_MARGINS.bottom!)), - }; - } - // mode === 'firstSection' — keep horizontal margins from the first DOCX section - // but zero vertical margins so stacked pages form a seamless continuous surface. - return { - left: clamp(margins.left, DEFAULT_MARGINS.left!), - right: clamp(margins.right, DEFAULT_MARGINS.right!), - top: 0, - bottom: 0, - }; - } - - #resolveSemanticContainerInnerWidth(): number { - const host = this.#visibleHost; - if (!host) return DEFAULT_PAGE_SIZE.w; - const win = host.ownerDocument?.defaultView ?? window; - const style = win.getComputedStyle(host); - const paddingLeft = Number.parseFloat(style.paddingLeft ?? '0'); - const paddingRight = Number.parseFloat(style.paddingRight ?? '0'); - const horizontalPadding = - (Number.isFinite(paddingLeft) ? paddingLeft : 0) + (Number.isFinite(paddingRight) ? paddingRight : 0); - const clientWidth = host.clientWidth; - if (Number.isFinite(clientWidth) && clientWidth > 0) { - return Math.max(1, clientWidth - horizontalPadding); - } - const rectWidth = host.getBoundingClientRect().width; - if (Number.isFinite(rectWidth) && rectWidth > 0) { - return Math.max(1, rectWidth - horizontalPadding); - } - return Math.max(1, DEFAULT_PAGE_SIZE.w - horizontalPadding); - } - - #setupSemanticResizeObserver(): void { - if (!this.#isSemanticFlowMode()) return; - const view = this.#visibleHost.ownerDocument?.defaultView ?? window; - const ResizeObs = view.ResizeObserver; - if (typeof ResizeObs !== 'function') return; - - this.#lastSemanticContainerWidth = this.#resolveSemanticContainerInnerWidth(); - this.#semanticResizeObserver = new ResizeObs(() => { - this.#scheduleSemanticResizeRelayout(); - }); - this.#semanticResizeObserver.observe(this.#visibleHost); - } - - #scheduleSemanticResizeRelayout(): void { - if (!this.#isSemanticFlowMode()) return; - const view = this.#visibleHost.ownerDocument?.defaultView ?? window; - if (this.#semanticResizeRaf == null) { - this.#semanticResizeRaf = view.requestAnimationFrame(() => { - this.#semanticResizeRaf = null; - this.#applySemanticResizeRelayout(); - }); - } - if (this.#semanticResizeDebounce != null) { - view.clearTimeout(this.#semanticResizeDebounce); - } - this.#semanticResizeDebounce = view.setTimeout(() => { - this.#semanticResizeDebounce = null; - this.#applySemanticResizeRelayout(); - }, SEMANTIC_RESIZE_DEBOUNCE_MS); - } - - #applySemanticResizeRelayout(): void { - if (!this.#isSemanticFlowMode()) return; - const nextWidth = this.#resolveSemanticContainerInnerWidth(); - const prevWidth = this.#lastSemanticContainerWidth; - if (prevWidth != null && Math.abs(nextWidth - prevWidth) < 1) { - return; - } - this.#lastSemanticContainerWidth = nextWidth; - this.#pendingDocChange = true; - this.#scheduleRerender(); - } - /** * Return a snapshot of painter output captured during the latest paint cycle. */ @@ -2471,22 +2388,7 @@ export class PresentationEditor extends EventEmitter { this.#cursorUpdateTimer = null; } - if (this.#semanticResizeRaf != null) { - safeCleanup(() => { - const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; - win.cancelAnimationFrame(this.#semanticResizeRaf!); - this.#semanticResizeRaf = null; - }, 'Semantic resize RAF'); - } - if (this.#semanticResizeDebounce != null) { - safeCleanup(() => { - const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; - win.clearTimeout(this.#semanticResizeDebounce!); - this.#semanticResizeDebounce = null; - }, 'Semantic resize debounce'); - } - this.#semanticResizeObserver?.disconnect(); - this.#semanticResizeObserver = null; + this.#semanticFlow.destroy(); // Clean up remote cursor manager if (this.#remoteCursorManager) { @@ -4150,32 +4052,24 @@ export class PresentationEditor extends EventEmitter { }; if (flowMode === 'semantic') { - const semanticMargins = this.#resolveSemanticMargins(margins); - const containerWidth = this.#resolveSemanticContainerInnerWidth(); - const semanticContentWidth = Math.max( - MIN_SEMANTIC_CONTENT_WIDTH_PX, - containerWidth - semanticMargins.left - semanticMargins.right, - ); - const semanticPageWidth = semanticContentWidth + semanticMargins.left + semanticMargins.right; - this.#hiddenHost.style.width = `${semanticContentWidth}px`; - this.#lastSemanticContainerWidth = containerWidth; + const resolved = this.#semanticFlow.resolveSemanticLayout(margins, pageSize, this.#hiddenHost); return { flowMode: 'semantic', - pageSize: { w: semanticPageWidth, h: pageSize.h }, + pageSize: { w: resolved.pageWidth, h: pageSize.h }, margins: { ...resolvedMargins, - top: semanticMargins.top, - right: semanticMargins.right, - bottom: semanticMargins.bottom, - left: semanticMargins.left, + top: resolved.margins.top, + right: resolved.margins.right, + bottom: resolved.margins.bottom, + left: resolved.margins.left, }, columns: { count: 1, gap: 0 }, semantic: { - contentWidth: semanticContentWidth, - marginLeft: semanticMargins.left, - marginRight: semanticMargins.right, - marginTop: semanticMargins.top, - marginBottom: semanticMargins.bottom, + contentWidth: resolved.contentWidth, + marginLeft: resolved.margins.left, + marginRight: resolved.margins.right, + marginTop: resolved.margins.top, + marginBottom: resolved.margins.bottom, }, sectionMetadata, }; diff --git a/packages/super-editor/src/core/presentation-editor/layout/SemanticFlowController.ts b/packages/super-editor/src/core/presentation-editor/layout/SemanticFlowController.ts new file mode 100644 index 0000000000..5376d6f1c3 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/layout/SemanticFlowController.ts @@ -0,0 +1,177 @@ +import type { PageMargins } from '@superdoc/contracts'; + +const SEMANTIC_RESIZE_DEBOUNCE_MS = 120; +const MIN_SEMANTIC_CONTENT_WIDTH_PX = 1; + +interface SemanticOptions { + marginsMode?: 'firstSection' | 'none' | 'custom'; + customMargins?: { left?: number; right?: number; top?: number; bottom?: number }; + footnotesMode?: string; +} + +export interface SemanticFlowDeps { + visibleHost: HTMLElement; + getFlowMode: () => string | undefined; + getSemanticOptions: () => SemanticOptions | undefined; + requestRerender: () => void; + defaultPageWidth: number; + defaultMargins: { left: number; right: number; top: number; bottom: number }; +} + +/** + * Manages semantic (non-paginated) flow mode behavior: + * - ResizeObserver on visibleHost to trigger relayout when container width changes + * - Margin resolution for semantic mode (firstSection / none / custom) + * - Container inner width calculation + */ +export class SemanticFlowController { + #deps: SemanticFlowDeps; + #resizeObserver: ResizeObserver | null = null; + #resizeRaf: number | null = null; + #resizeDebounce: number | null = null; + #lastContainerWidth: number | null = null; + + constructor(deps: SemanticFlowDeps) { + this.#deps = deps; + } + + get lastContainerWidth(): number | null { + return this.#lastContainerWidth; + } + + set lastContainerWidth(value: number | null) { + this.#lastContainerWidth = value; + } + + isActive(): boolean { + return this.#deps.getFlowMode() === 'semantic'; + } + + setup(): void { + if (!this.isActive()) return; + const view = this.#deps.visibleHost.ownerDocument?.defaultView ?? window; + const ResizeObs = view.ResizeObserver; + if (typeof ResizeObs !== 'function') return; + + this.#lastContainerWidth = this.resolveContainerInnerWidth(); + this.#resizeObserver = new ResizeObs(() => { + this.#scheduleRelayout(); + }); + this.#resizeObserver.observe(this.#deps.visibleHost); + } + + resolveMargins(margins: PageMargins): { left: number; right: number; top: number; bottom: number } { + const mode = this.#deps.getSemanticOptions()?.marginsMode ?? 'firstSection'; + const defaults = this.#deps.defaultMargins; + + if (mode === 'none') { + return { left: 0, right: 0, top: 0, bottom: 0 }; + } + + const clamp = (value: number | undefined, fallback: number): number => { + const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback; + return v >= 0 ? v : fallback; + }; + + if (mode === 'custom') { + const custom = this.#deps.getSemanticOptions()?.customMargins; + return { + left: clamp(custom?.left, clamp(margins.left, defaults.left)), + right: clamp(custom?.right, clamp(margins.right, defaults.right)), + top: clamp(custom?.top, clamp(margins.top, defaults.top)), + bottom: clamp(custom?.bottom, clamp(margins.bottom, defaults.bottom)), + }; + } + + return { + left: clamp(margins.left, defaults.left), + right: clamp(margins.right, defaults.right), + top: 0, + bottom: 0, + }; + } + + resolveContainerInnerWidth(): number { + const host = this.#deps.visibleHost; + if (!host) return this.#deps.defaultPageWidth; + const win = host.ownerDocument?.defaultView ?? window; + const style = win.getComputedStyle(host); + const paddingLeft = Number.parseFloat(style.paddingLeft ?? '0'); + const paddingRight = Number.parseFloat(style.paddingRight ?? '0'); + const horizontalPadding = + (Number.isFinite(paddingLeft) ? paddingLeft : 0) + (Number.isFinite(paddingRight) ? paddingRight : 0); + const clientWidth = host.clientWidth; + if (Number.isFinite(clientWidth) && clientWidth > 0) { + return Math.max(MIN_SEMANTIC_CONTENT_WIDTH_PX, clientWidth - horizontalPadding); + } + const rectWidth = host.getBoundingClientRect().width; + if (Number.isFinite(rectWidth) && rectWidth > 0) { + return Math.max(MIN_SEMANTIC_CONTENT_WIDTH_PX, rectWidth - horizontalPadding); + } + return Math.max(MIN_SEMANTIC_CONTENT_WIDTH_PX, this.#deps.defaultPageWidth - horizontalPadding); + } + + resolveSemanticLayout( + margins: PageMargins, + pageSize: { w: number; h: number }, + hiddenHost: HTMLElement, + ): { + contentWidth: number; + pageWidth: number; + margins: { left: number; right: number; top: number; bottom: number }; + } { + const semanticMargins = this.resolveMargins(margins); + const containerWidth = this.resolveContainerInnerWidth(); + const contentWidth = Math.max( + MIN_SEMANTIC_CONTENT_WIDTH_PX, + containerWidth - semanticMargins.left - semanticMargins.right, + ); + const pageWidth = contentWidth + semanticMargins.left + semanticMargins.right; + hiddenHost.style.width = `${contentWidth}px`; + this.#lastContainerWidth = containerWidth; + return { contentWidth, pageWidth, margins: semanticMargins }; + } + + #scheduleRelayout(): void { + if (!this.isActive()) return; + const view = this.#deps.visibleHost.ownerDocument?.defaultView ?? window; + if (this.#resizeRaf == null) { + this.#resizeRaf = view.requestAnimationFrame(() => { + this.#resizeRaf = null; + this.#applyRelayout(); + }); + } + if (this.#resizeDebounce != null) { + view.clearTimeout(this.#resizeDebounce); + } + this.#resizeDebounce = view.setTimeout(() => { + this.#resizeDebounce = null; + this.#applyRelayout(); + }, SEMANTIC_RESIZE_DEBOUNCE_MS); + } + + #applyRelayout(): void { + if (!this.isActive()) return; + const nextWidth = this.resolveContainerInnerWidth(); + const prevWidth = this.#lastContainerWidth; + if (prevWidth != null && Math.abs(nextWidth - prevWidth) < 1) { + return; + } + this.#lastContainerWidth = nextWidth; + this.#deps.requestRerender(); + } + + destroy(): void { + const win = this.#deps.visibleHost?.ownerDocument?.defaultView ?? window; + if (this.#resizeRaf != null) { + win.cancelAnimationFrame(this.#resizeRaf); + this.#resizeRaf = null; + } + if (this.#resizeDebounce != null) { + win.clearTimeout(this.#resizeDebounce); + this.#resizeDebounce = null; + } + this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; + } +} From 9040cf071d39c853b92cbe91af4211948519dbd2 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 12:03:36 -0300 Subject: [PATCH 03/13] refactor(editor): extract error banner into LayoutErrorBanner Move layout error banner DOM creation, show/dismiss logic from PresentationEditor into a dedicated UI class. The retry button callback is injected as a dependency. Reduces PresentationEditor from 5424 to 5379 lines (-45). --- .../presentation-editor/PresentationEditor.ts | 76 ++++-------------- .../ui/LayoutErrorBanner.ts | 77 +++++++++++++++++++ 2 files changed, 92 insertions(+), 61 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/ui/LayoutErrorBanner.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 81ac1c5943..62c3a4f292 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4,6 +4,7 @@ import { CellSelection } from 'prosemirror-tables'; import { DecorationBridge } from './dom/DecorationBridge.js'; import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js'; import { SemanticFlowController } from './layout/SemanticFlowController.js'; +import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -287,8 +288,7 @@ export class PresentationEditor extends EventEmitter { #dragDropManager: DragDropManager | null = null; #layoutError: LayoutError | null = null; #layoutErrorState: 'healthy' | 'degraded' | 'failed' = 'healthy'; - #errorBanner: HTMLElement | null = null; - #errorBannerMessage: HTMLElement | null = null; + #errorBanner!: LayoutErrorBanner; #renderScheduled = false; #pendingDocChange = false; #pendingMapping: Mapping | null = null; @@ -431,6 +431,16 @@ export class PresentationEditor extends EventEmitter { this.#painterHost.addEventListener('mouseover', this.#sdtStyles.handleMouseEnter); this.#painterHost.addEventListener('mouseout', this.#sdtStyles.handleMouseLeave); + this.#errorBanner = new LayoutErrorBanner({ + host: this.#visibleHost, + getDebugLabel: () => this.#layoutOptions.debugLabel, + onRetry: () => { + this.#layoutError = null; + this.#pendingDocChange = true; + this.#scheduleRerender(); + }, + }); + const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; this.#domIndexObserverManager = new DomPositionIndexObserverManager({ windowRoot: win, @@ -2461,7 +2471,7 @@ export class PresentationEditor extends EventEmitter { this.#modeBanner = null; this.#ariaLiveRegion?.remove(); this.#ariaLiveRegion = null; - this.#errorBanner?.remove(); + this.#errorBanner.destroy(); if (this.#editor) { (this.#editor as Editor & { presentationEditor?: PresentationEditor | null }).presentationEditor = null; this.#editor.destroy(); @@ -3588,7 +3598,7 @@ export class PresentationEditor extends EventEmitter { // Reset error state on successful layout this.#layoutError = null; this.#layoutErrorState = 'healthy'; - this.#dismissErrorBanner(); + this.#errorBanner.dismiss(); // Update viewport dimensions after layout (page count may have changed) this.#applyZoom(); @@ -5239,7 +5249,7 @@ export class PresentationEditor extends EventEmitter { } this.emit('layoutError', this.#layoutError); - this.#showLayoutErrorBanner(error); + this.#errorBanner.show(error); } #decorateError(error: unknown, stage: string): Error { @@ -5250,62 +5260,6 @@ export class PresentationEditor extends EventEmitter { return new Error(`[${stage}] ${String(error)}`); } - #showLayoutErrorBanner(error: Error) { - const doc = this.#visibleHost.ownerDocument ?? document; - if (!this.#errorBanner) { - const banner = doc.createElement('div'); - banner.className = 'presentation-editor__layout-error'; - banner.style.display = 'flex'; - banner.style.alignItems = 'center'; - banner.style.justifyContent = 'space-between'; - banner.style.gap = '8px'; - banner.style.padding = '8px 12px'; - banner.style.background = '#FFF6E5'; - banner.style.border = '1px solid #F5B971'; - banner.style.borderRadius = '6px'; - banner.style.marginBottom = '8px'; - - const message = doc.createElement('span'); - banner.appendChild(message); - - const retry = doc.createElement('button'); - retry.type = 'button'; - retry.textContent = 'Reload layout'; - retry.style.border = 'none'; - retry.style.borderRadius = '4px'; - retry.style.background = '#F5B971'; - retry.style.color = '#3F2D00'; - retry.style.padding = '6px 10px'; - retry.style.cursor = 'pointer'; - retry.addEventListener('click', () => { - this.#layoutError = null; - this.#dismissErrorBanner(); - this.#pendingDocChange = true; - this.#scheduleRerender(); - }); - - banner.appendChild(retry); - this.#visibleHost.prepend(banner); - - this.#errorBanner = banner; - this.#errorBannerMessage = message; - } - - if (this.#errorBannerMessage) { - this.#errorBannerMessage.textContent = - 'Layout engine hit an error. Your document is safe — try reloading layout.'; - if (this.#layoutOptions.debugLabel) { - this.#errorBannerMessage.textContent += ` (${this.#layoutOptions.debugLabel}: ${error.message})`; - } - } - } - - #dismissErrorBanner() { - this.#errorBanner?.remove(); - this.#errorBanner = null; - this.#errorBannerMessage = null; - } - /** * Determines whether the current viewing mode should block edits. * When documentMode is viewing but the active editor has been toggled diff --git a/packages/super-editor/src/core/presentation-editor/ui/LayoutErrorBanner.ts b/packages/super-editor/src/core/presentation-editor/ui/LayoutErrorBanner.ts new file mode 100644 index 0000000000..b5a2814553 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/ui/LayoutErrorBanner.ts @@ -0,0 +1,77 @@ +export interface LayoutErrorBannerDeps { + host: HTMLElement; + getDebugLabel: () => string | undefined; + onRetry: () => void; +} + +/** + * Manages the layout error banner UI: a dismissible warning bar shown at the + * top of the visible host when the layout engine encounters an error. + */ +export class LayoutErrorBanner { + #deps: LayoutErrorBannerDeps; + #banner: HTMLElement | null = null; + #message: HTMLElement | null = null; + + constructor(deps: LayoutErrorBannerDeps) { + this.#deps = deps; + } + + show(error: Error): void { + const doc = this.#deps.host.ownerDocument ?? document; + if (!this.#banner) { + const banner = doc.createElement('div'); + banner.className = 'presentation-editor__layout-error'; + banner.style.display = 'flex'; + banner.style.alignItems = 'center'; + banner.style.justifyContent = 'space-between'; + banner.style.gap = '8px'; + banner.style.padding = '8px 12px'; + banner.style.background = '#FFF6E5'; + banner.style.border = '1px solid #F5B971'; + banner.style.borderRadius = '6px'; + banner.style.marginBottom = '8px'; + + const message = doc.createElement('span'); + banner.appendChild(message); + + const retry = doc.createElement('button'); + retry.type = 'button'; + retry.textContent = 'Reload layout'; + retry.style.border = 'none'; + retry.style.borderRadius = '4px'; + retry.style.background = '#F5B971'; + retry.style.color = '#3F2D00'; + retry.style.padding = '6px 10px'; + retry.style.cursor = 'pointer'; + retry.addEventListener('click', () => { + this.dismiss(); + this.#deps.onRetry(); + }); + + banner.appendChild(retry); + this.#deps.host.prepend(banner); + + this.#banner = banner; + this.#message = message; + } + + if (this.#message) { + this.#message.textContent = 'Layout engine hit an error. Your document is safe — try reloading layout.'; + const debugLabel = this.#deps.getDebugLabel(); + if (debugLabel) { + this.#message.textContent += ` (${debugLabel}: ${error.message})`; + } + } + } + + dismiss(): void { + this.#banner?.remove(); + this.#banner = null; + this.#message = null; + } + + destroy(): void { + this.dismiss(); + } +} From d8b74c91a11aa9ef779aea48fb3d7392c121cba4 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 12:15:45 -0300 Subject: [PATCH 04/13] refactor(editor): extract zoom geometry into applyViewportZoom Move the 136-line #applyZoom method's CSS transform math into a pure function. Handles semantic flow, horizontal, and vertical layout modes with per-page dimension calculation and margin compensation. PresentationEditor's #applyZoom is now a 15-line delegation that assembles the parameters from its internal state. Reduces PresentationEditor from 5379 to 5260 lines (-119). --- .../presentation-editor/PresentationEditor.ts | 152 ++---------------- .../layout/applyViewportZoom.ts | 122 ++++++++++++++ 2 files changed, 139 insertions(+), 135 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/layout/applyViewportZoom.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 62c3a4f292..5b10649acd 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -5,6 +5,7 @@ import { DecorationBridge } from './dom/DecorationBridge.js'; import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js'; import { SemanticFlowController } from './layout/SemanticFlowController.js'; import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js'; +import { applyViewportZoom } from './layout/applyViewportZoom.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -4821,141 +4822,22 @@ export class PresentationEditor extends EventEmitter { * - Horizontal: Uses totalWidth for viewport width, maxHeight for scroll height */ #applyZoom() { - if (this.#isSemanticFlowMode()) { - // Semantic mode: fill the container with fluid widths, no zoom scaling. - this.#viewportHost.style.width = '100%'; - this.#viewportHost.style.minWidth = ''; - this.#viewportHost.style.minHeight = ''; - this.#viewportHost.style.transform = ''; - - this.#painterHost.style.width = '100%'; - this.#painterHost.style.minHeight = ''; - this.#painterHost.style.transformOrigin = ''; - this.#painterHost.style.transform = ''; - - this.#selectionOverlay.style.width = '100%'; - this.#selectionOverlay.style.height = '100%'; - this.#selectionOverlay.style.transformOrigin = ''; - this.#selectionOverlay.style.transform = ''; - return; - } - - // Apply zoom by scaling the children (#painterHost and #selectionOverlay) and - // setting the viewport dimensions to the scaled size. - // - // CSS transform: scale() only affects visual rendering, NOT layout box dimensions. - // Previously, transform was applied to #viewportHost which caused the parent scroll - // container to not see the scaled size, resulting in clipping at high zoom levels. - // - // The new approach: - // 1. Apply transform: scale(zoom) to #painterHost and #selectionOverlay (visual scaling) - // 2. Set #viewportHost width/height to scaled dimensions (layout box scaling) - // This ensures both visual rendering AND scroll container dimensions are correct. - const zoom = this.#layoutOptions.zoom ?? 1; - - const layoutMode = this.#layoutOptions.layoutMode ?? 'vertical'; - - // Calculate actual document dimensions from per-page sizes. - // Multi-section documents can have pages with different sizes (e.g., landscape pages). - const pages = this.#layoutState.layout?.pages; - // Always use current layout mode's gap - layout.pageGap may be stale if layoutMode changed - const pageGap = this.#getEffectivePageGap(); - const defaultWidth = this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w; - const defaultHeight = this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - - let maxWidth = defaultWidth; - let maxHeight = defaultHeight; - let totalWidth = 0; - let totalHeight = 0; - - if (Array.isArray(pages) && pages.length > 0) { - pages.forEach((page, index) => { - const pageWidth = page.size && typeof page.size.w === 'number' && page.size.w > 0 ? page.size.w : defaultWidth; - const pageHeight = - page.size && typeof page.size.h === 'number' && page.size.h > 0 ? page.size.h : defaultHeight; - maxWidth = Math.max(maxWidth, pageWidth); - maxHeight = Math.max(maxHeight, pageHeight); - totalWidth += pageWidth; - totalHeight += pageHeight; - if (index < pages.length - 1) { - totalWidth += pageGap; - totalHeight += pageGap; - } - }); - } else { - totalWidth = defaultWidth; - totalHeight = defaultHeight; - } - - // Horizontal layout stacks pages in a single row, so width grows with pageCount - if (layoutMode === 'horizontal') { - // For horizontal: sum widths, use max height - const scaledWidth = totalWidth * zoom; - const scaledHeight = maxHeight * zoom; - - this.#viewportHost.style.width = `${scaledWidth}px`; - this.#viewportHost.style.minWidth = `${scaledWidth}px`; - this.#viewportHost.style.minHeight = `${scaledHeight}px`; - this.#viewportHost.style.height = ''; - this.#viewportHost.style.overflow = ''; - this.#viewportHost.style.transform = ''; - - this.#painterHost.style.width = `${totalWidth}px`; - this.#painterHost.style.minHeight = `${maxHeight}px`; - // Negative margin compensates for the CSS box overflow from transform: scale(). - // At zoom < 1 the unscaled CSS box is larger than the visual; this pulls the - // bottom edge up to match, without clipping overlays (e.g., cursor labels). - this.#painterHost.style.marginBottom = zoom !== 1 ? `${maxHeight * zoom - maxHeight}px` : ''; - this.#painterHost.style.transformOrigin = 'top left'; - this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`; - - this.#selectionOverlay.style.width = `${totalWidth}px`; - this.#selectionOverlay.style.height = `${maxHeight}px`; - this.#selectionOverlay.style.transformOrigin = 'top left'; - this.#selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`; - return; - } - - // Vertical layout: use max width, sum heights - // Zoom implementation: - // 1. #viewportHost has SCALED dimensions (maxWidth * zoom) for proper scroll container sizing - // 2. #painterHost has UNSCALED dimensions with transform: scale(zoom) applied - // 3. When scaled, #painterHost visually fills #viewportHost exactly - // - // This ensures the scroll container sees the correct scaled content size while - // the transform provides visual scaling. - // - // CSS transform: scale() does NOT change the element's CSS box dimensions. - // At zoom < 1, painterHost's CSS box stays at the full unscaled height while its - // visual size is smaller. A negative margin-bottom on painterHost compensates for - // the difference, so the scroll container sees the correct scaled size without - // clipping overlays (e.g., collaboration cursor labels that extend above their caret). - const scaledWidth = maxWidth * zoom; - const scaledHeight = totalHeight * zoom; - - this.#viewportHost.style.width = `${scaledWidth}px`; - this.#viewportHost.style.minWidth = `${scaledWidth}px`; - this.#viewportHost.style.minHeight = `${scaledHeight}px`; - this.#viewportHost.style.height = ''; - this.#viewportHost.style.overflow = ''; - this.#viewportHost.style.transform = ''; - - // Set painterHost to UNSCALED dimensions and apply transform. - // Negative margin compensates for the CSS box overflow from transform: scale(). - // At zoom < 1: totalHeight=74304 with scale(0.75) → visual 55728px but CSS box stays 74304px. - // marginBottom = totalHeight * zoom - totalHeight = 74304 * 0.75 - 74304 = -18576px - // This shrinks the layout contribution to match the visual size. - this.#painterHost.style.width = `${maxWidth}px`; - this.#painterHost.style.minHeight = `${totalHeight}px`; - this.#painterHost.style.marginBottom = zoom !== 1 ? `${totalHeight * zoom - totalHeight}px` : ''; - this.#painterHost.style.transformOrigin = 'top left'; - this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`; - - // Selection overlay also scales - set to unscaled dimensions - this.#selectionOverlay.style.width = `${maxWidth}px`; - this.#selectionOverlay.style.height = `${totalHeight}px`; - this.#selectionOverlay.style.transformOrigin = 'top left'; - this.#selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`; + applyViewportZoom( + { + viewportHost: this.#viewportHost, + painterHost: this.#painterHost, + selectionOverlay: this.#selectionOverlay, + }, + { + zoom: this.#layoutOptions.zoom ?? 1, + layoutMode: (this.#layoutOptions.layoutMode ?? 'vertical') as 'vertical' | 'horizontal' | 'book', + isSemanticFlow: this.#isSemanticFlowMode(), + pages: this.#layoutState.layout?.pages, + pageGap: this.#getEffectivePageGap(), + defaultWidth: this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w, + defaultHeight: this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h, + }, + ); } /** diff --git a/packages/super-editor/src/core/presentation-editor/layout/applyViewportZoom.ts b/packages/super-editor/src/core/presentation-editor/layout/applyViewportZoom.ts new file mode 100644 index 0000000000..23547e5847 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/layout/applyViewportZoom.ts @@ -0,0 +1,122 @@ +interface Page { + size?: { w?: number; h?: number }; +} + +interface ViewportElements { + viewportHost: HTMLElement; + painterHost: HTMLElement; + selectionOverlay: HTMLElement; +} + +interface ZoomParams { + zoom: number; + layoutMode: 'vertical' | 'horizontal' | 'book'; + isSemanticFlow: boolean; + pages: Page[] | undefined; + pageGap: number; + defaultWidth: number; + defaultHeight: number; +} + +/** + * Applies CSS transform zoom to viewport elements. + * + * Computes document dimensions from per-page sizes and applies: + * 1. Scaled dimensions on viewportHost (for scroll container sizing) + * 2. Unscaled dimensions + transform:scale on painterHost and selectionOverlay + * + * This ensures both visual rendering AND scroll container dimensions are correct. + * CSS transform:scale() only affects visual rendering, not layout box dimensions, + * so negative marginBottom compensates for the difference at zoom < 1. + */ +export function applyViewportZoom(elements: ViewportElements, params: ZoomParams): void { + const { viewportHost, painterHost, selectionOverlay } = elements; + const { zoom, layoutMode, isSemanticFlow, pages, pageGap, defaultWidth, defaultHeight } = params; + + if (isSemanticFlow) { + viewportHost.style.width = '100%'; + viewportHost.style.minWidth = ''; + viewportHost.style.minHeight = ''; + viewportHost.style.transform = ''; + + painterHost.style.width = '100%'; + painterHost.style.minHeight = ''; + painterHost.style.transformOrigin = ''; + painterHost.style.transform = ''; + + selectionOverlay.style.width = '100%'; + selectionOverlay.style.height = '100%'; + selectionOverlay.style.transformOrigin = ''; + selectionOverlay.style.transform = ''; + return; + } + + let maxWidth = defaultWidth; + let maxHeight = defaultHeight; + let totalWidth = 0; + let totalHeight = 0; + + if (Array.isArray(pages) && pages.length > 0) { + pages.forEach((page, index) => { + const pageWidth = page.size && typeof page.size.w === 'number' && page.size.w > 0 ? page.size.w : defaultWidth; + const pageHeight = page.size && typeof page.size.h === 'number' && page.size.h > 0 ? page.size.h : defaultHeight; + maxWidth = Math.max(maxWidth, pageWidth); + maxHeight = Math.max(maxHeight, pageHeight); + totalWidth += pageWidth; + totalHeight += pageHeight; + if (index < pages.length - 1) { + totalWidth += pageGap; + totalHeight += pageGap; + } + }); + } else { + totalWidth = defaultWidth; + totalHeight = defaultHeight; + } + + if (layoutMode === 'horizontal') { + const scaledWidth = totalWidth * zoom; + const scaledHeight = maxHeight * zoom; + + viewportHost.style.width = `${scaledWidth}px`; + viewportHost.style.minWidth = `${scaledWidth}px`; + viewportHost.style.minHeight = `${scaledHeight}px`; + viewportHost.style.height = ''; + viewportHost.style.overflow = ''; + viewportHost.style.transform = ''; + + painterHost.style.width = `${totalWidth}px`; + painterHost.style.minHeight = `${maxHeight}px`; + painterHost.style.marginBottom = zoom !== 1 ? `${maxHeight * zoom - maxHeight}px` : ''; + painterHost.style.transformOrigin = 'top left'; + painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`; + + selectionOverlay.style.width = `${totalWidth}px`; + selectionOverlay.style.height = `${maxHeight}px`; + selectionOverlay.style.transformOrigin = 'top left'; + selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`; + return; + } + + // Vertical layout + const scaledWidth = maxWidth * zoom; + const scaledHeight = totalHeight * zoom; + + viewportHost.style.width = `${scaledWidth}px`; + viewportHost.style.minWidth = `${scaledWidth}px`; + viewportHost.style.minHeight = `${scaledHeight}px`; + viewportHost.style.height = ''; + viewportHost.style.overflow = ''; + viewportHost.style.transform = ''; + + painterHost.style.width = `${maxWidth}px`; + painterHost.style.minHeight = `${totalHeight}px`; + painterHost.style.marginBottom = zoom !== 1 ? `${totalHeight * zoom - totalHeight}px` : ''; + painterHost.style.transformOrigin = 'top left'; + painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`; + + selectionOverlay.style.width = `${maxWidth}px`; + selectionOverlay.style.height = `${totalHeight}px`; + selectionOverlay.style.transformOrigin = 'top left'; + selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`; +} From 97c9e97f965d0e7ae90051f585c6990960c2833a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 12:51:52 -0300 Subject: [PATCH 05/13] refactor(editor): extract scroll helpers into ScrollHelpers module Move reusable scroll utilities out of PresentationEditor: - findPageIndexForPosition: scan layout fragments for a PM position (was duplicated in scrollToPosition and scrollToPositionAsync) - computePageScrollOffset: prefix-sum page height calculation - findElementAtPosition: DOM element lookup by data-pm-start/end range - waitForPageMount: RAF polling for virtualized page mount PresentationEditor's public scroll API (scrollToPosition, scrollToPositionAsync, scrollToPage, goToAnchor) stays on the class. Internal helpers now delegate to the shared module. Reduces PresentationEditor from 5260 to 5167 lines (-93). --- .../presentation-editor/PresentationEditor.ts | 155 ++++-------------- .../scroll/ScrollHelpers.ts | 91 ++++++++++ 2 files changed, 120 insertions(+), 126 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/scroll/ScrollHelpers.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5b10649acd..9c85e11914 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -6,6 +6,12 @@ import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.j import { SemanticFlowController } from './layout/SemanticFlowController.js'; import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js'; import { applyViewportZoom } from './layout/applyViewportZoom.js'; +import { + findPageIndexForPosition, + computePageScrollOffset, + findElementAtPosition as findElementAtPositionHelper, + waitForPageMount as waitForPageMountHelper, +} from './scroll/ScrollHelpers.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -2061,73 +2067,23 @@ export class PresentationEditor extends EventEmitter { if (!Number.isFinite(pos)) return false; const clampedPos = Math.max(0, Math.min(pos, doc.content.size)); - - const behavior = options.behavior ?? 'auto'; - const block = options.block ?? 'center'; - - // Use a DOM marker + scrollIntoView so the browser finds the correct scroll container - // (window, parent overflow container, etc.) without us guessing. const layout = this.#layoutState.layout; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; + if (!layout || sessionMode !== 'body') return false; - if (layout && sessionMode === 'body') { - let pageIndex: number | null = null; - for (let idx = 0; idx < layout.pages.length; idx++) { - const page = layout.pages[idx]; - for (const fragment of page.fragments) { - const frag = fragment as { pmStart?: number; pmEnd?: number }; - if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) { - pageIndex = idx; - break; - } - } - if (pageIndex != null) break; - } - - if (pageIndex != null) { - const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); - if (pageEl) { - // Find the specific element containing this position for precise centering - const targetEl = this.#findElementAtPosition(pageEl, clampedPos); - (targetEl ?? pageEl).scrollIntoView({ block, inline: 'nearest', behavior }); - return true; - } - } + const pageIndex = findPageIndexForPosition(layout, clampedPos); + if (pageIndex == null) return false; - return false; - } else { - return false; - } - } + const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); + if (!pageEl) return false; - /** - * Find the DOM element containing a specific document position. - * Returns the most specific (smallest range) matching element. - */ - #findElementAtPosition(pageEl: HTMLElement, pos: number): HTMLElement | null { - const elements = Array.from(pageEl.querySelectorAll('[data-pm-start][data-pm-end]')); - let bestMatch: HTMLElement | null = null; - let smallestRange = Infinity; - - for (const el of elements) { - const htmlEl = el as HTMLElement; - // Skip header/footer fragments — their PM positions come from a separate - // document and can overlap with body positions, causing incorrect matches. - if (htmlEl.closest('.superdoc-page-header, .superdoc-page-footer')) continue; - - const start = Number(htmlEl.dataset.pmStart); - const end = Number(htmlEl.dataset.pmEnd); - if (!Number.isFinite(start) || !Number.isFinite(end)) continue; - - if (pos >= start && pos <= end) { - const range = end - start; - if (range < smallestRange) { - smallestRange = range; - bestMatch = htmlEl; - } - } - } - return bestMatch; + const targetEl = findElementAtPositionHelper(pageEl, clampedPos); + (targetEl ?? pageEl).scrollIntoView({ + block: options.block ?? 'center', + inline: 'nearest', + behavior: options.behavior ?? 'auto', + }); + return true; } /** @@ -2151,39 +2107,20 @@ export class PresentationEditor extends EventEmitter { pos: number, options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {}, ): Promise { - // Fast path: try sync scroll first (works if page already mounted) - if (this.scrollToPosition(pos, options)) { - return true; - } + if (this.scrollToPosition(pos, options)) return true; - // Page not mounted - find which page contains this position const activeEditor = this.getActiveEditor(); const doc = activeEditor?.state?.doc; if (!doc || !Number.isFinite(pos)) return false; const clampedPos = Math.max(0, Math.min(pos, doc.content.size)); const layout = this.#layoutState.layout; - const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; - if (!layout || sessionMode !== 'body') return false; + if (!layout || (this.#headerFooterSession?.session?.mode ?? 'body') !== 'body') return false; - let pageIndex: number | null = null; - for (let idx = 0; idx < layout.pages.length; idx++) { - const page = layout.pages[idx]; - for (const fragment of page.fragments) { - const frag = fragment as { pmStart?: number; pmEnd?: number }; - if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) { - pageIndex = idx; - break; - } - } - if (pageIndex != null) break; - } + const pageIndex = findPageIndexForPosition(layout, clampedPos); if (pageIndex == null) return false; - // Trigger virtualization to render the page this.#scrollPageIntoView(pageIndex); - - // Wait for page to mount in the DOM const mounted = await this.#waitForPageMount(pageIndex, { timeout: PresentationEditor.ANCHOR_NAV_TIMEOUT_MS, }); @@ -2191,8 +2128,6 @@ export class PresentationEditor extends EventEmitter { console.warn(`[PresentationEditor] scrollToPositionAsync: Page ${pageIndex} failed to mount within timeout`); return false; } - - // Retry now that page is mounted return this.scrollToPosition(pos, options); } @@ -4503,24 +4438,13 @@ export class PresentationEditor extends EventEmitter { */ #scrollPageIntoView(pageIndex: number): void { const layout = this.#layoutState.layout; - if (!layout) return; - - const defaultHeight = layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - const virtualGap = this.#getEffectivePageGap(); - - // Use cumulative per-page heights so mixed-size documents scroll to the - // correct position. The renderer's virtualizer uses the same prefix-sum - // approach, so the scroll position lands inside the correct window. - let yPosition = 0; - for (let i = 0; i < pageIndex; i++) { - const pageHeight = layout.pages[i]?.size?.h ?? defaultHeight; - yPosition += pageHeight + virtualGap; - } - - // Scroll viewport to the calculated position - if (this.#visibleHost) { - this.#visibleHost.scrollTop = yPosition; - } + if (!layout || !this.#visibleHost) return; + this.#visibleHost.scrollTop = computePageScrollOffset( + layout, + pageIndex, + this.#getEffectivePageGap(), + layout.pageSize?.h ?? DEFAULT_PAGE_SIZE.h, + ); } /** @@ -4593,28 +4517,7 @@ export class PresentationEditor extends EventEmitter { * @returns Promise that resolves to true if page was mounted, false if timeout */ async #waitForPageMount(pageIndex: number, options: { timeout?: number } = {}): Promise { - const timeout = options.timeout ?? 2000; - const startTime = performance.now(); - - return new Promise((resolve) => { - const checkPage = () => { - const pageElement = this.#getPageElement(pageIndex); - if (pageElement) { - resolve(true); - return; - } - - const elapsed = performance.now() - startTime; - if (elapsed >= timeout) { - resolve(false); - return; - } - - requestAnimationFrame(checkPage); - }; - - checkPage(); - }); + return waitForPageMountHelper((idx) => this.#getPageElement(idx), pageIndex, options.timeout); } /** diff --git a/packages/super-editor/src/core/presentation-editor/scroll/ScrollHelpers.ts b/packages/super-editor/src/core/presentation-editor/scroll/ScrollHelpers.ts new file mode 100644 index 0000000000..3e2e2a410c --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/scroll/ScrollHelpers.ts @@ -0,0 +1,91 @@ +import type { Layout, Page } from '@superdoc/contracts'; + +/** + * Finds the page index containing a given PM position by scanning fragment ranges. + */ +export function findPageIndexForPosition(layout: Layout, pos: number): number | null { + for (let idx = 0; idx < layout.pages.length; idx++) { + const page = layout.pages[idx]; + for (const fragment of page.fragments) { + const frag = fragment as { pmStart?: number; pmEnd?: number }; + if (frag.pmStart != null && frag.pmEnd != null && pos >= frag.pmStart && pos <= frag.pmEnd) { + return idx; + } + } + } + return null; +} + +/** + * Computes the scroll Y offset for a given page index using cumulative page heights. + * Matches the DomPainter virtualizer's prefix-sum approach. + */ +export function computePageScrollOffset( + layout: Layout, + pageIndex: number, + pageGap: number, + defaultPageHeight: number, +): number { + let yPosition = 0; + for (let i = 0; i < pageIndex; i++) { + const pageHeight = layout.pages[i]?.size?.h ?? defaultPageHeight; + yPosition += pageHeight + pageGap; + } + return yPosition; +} + +/** + * Finds the most specific DOM element containing a position within a page element. + * Returns the element with the smallest [data-pm-start, data-pm-end] range that + * contains pos, skipping header/footer fragments. + */ +export function findElementAtPosition(pageEl: HTMLElement, pos: number): HTMLElement | null { + const elements = Array.from(pageEl.querySelectorAll('[data-pm-start][data-pm-end]')); + let bestMatch: HTMLElement | null = null; + let smallestRange = Infinity; + + for (const el of elements) { + const htmlEl = el as HTMLElement; + if (htmlEl.closest('.superdoc-page-header, .superdoc-page-footer')) continue; + + const start = Number(htmlEl.dataset.pmStart); + const end = Number(htmlEl.dataset.pmEnd); + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; + + if (pos >= start && pos <= end) { + const range = end - start; + if (range < smallestRange) { + smallestRange = range; + bestMatch = htmlEl; + } + } + } + return bestMatch; +} + +/** + * Polls for a page element to appear in the DOM (for virtualized pages). + * Returns true if the page mounted within the timeout, false otherwise. + */ +export function waitForPageMount( + getPageElement: (pageIndex: number) => HTMLElement | null, + pageIndex: number, + timeout = 2000, +): Promise { + const startTime = performance.now(); + + return new Promise((resolve) => { + const checkPage = () => { + if (getPageElement(pageIndex)) { + resolve(true); + return; + } + if (performance.now() - startTime >= timeout) { + resolve(false); + return; + } + requestAnimationFrame(checkPage); + }; + checkPage(); + }); +} From c9b2162f479d7d33c07c46ad0bf0b250755291f3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 12:56:01 -0300 Subject: [PATCH 06/13] refactor(editor): extract vertical alignment post-processing Move the 84-line #applyVertAlignToLayout method into a pure function that takes a PM doc and painterHost. Handles subscript/superscript CSS styling by scanning painted spans, resolving run properties, and applying vertical-align and font-size styles. Also removes unused imports (decodeRPrFromMarks, halfPointToPoints) and constant (SUBSCRIPT_SUPERSCRIPT_SCALE) from PresentationEditor. Reduces PresentationEditor from 5167 to 5082 lines (-85). --- .../presentation-editor/PresentationEditor.ts | 85 +----------------- .../layout/applyVertAlignToLayout.ts | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 83 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/layout/applyVertAlignToLayout.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 9c85e11914..be3773e5c6 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -6,6 +6,7 @@ import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.j import { SemanticFlowController } from './layout/SemanticFlowController.js'; import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js'; import { applyViewportZoom } from './layout/applyViewportZoom.js'; +import { applyVertAlignToLayout as applyVertAlignToLayoutHelper } from './layout/applyVertAlignToLayout.js'; import { findPageIndexForPosition, computePageScrollOffset, @@ -77,8 +78,6 @@ import { import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.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, FlowBlockCache } from '@superdoc/pm-adapter'; import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { @@ -188,7 +187,6 @@ import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '@ * for vertical alignment (w:vertAlign) when set to 'superscript' or 'subscript'. * Applied to the base font size to reduce text size for sub/superscripts. */ -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 }; @@ -5079,85 +5077,6 @@ export class PresentationEditor extends EventEmitter { #applyVertAlignToLayout() { const doc = this.#editor?.state?.doc; if (!doc || !this.#painterHost) return; - - try { - const spans = this.#painterHost.querySelectorAll('.superdoc-line span[data-pm-start]') as NodeListOf; - spans.forEach((span) => { - try { - // Skip header/footer spans - they belong to separate PM documents - // and their data-pm-start values don't correspond to the body doc - if (span.closest('.superdoc-page-header, .superdoc-page-footer')) return; - - const pmStart = Number(span.dataset.pmStart ?? 'NaN'); - if (!Number.isFinite(pmStart)) return; - - const pos = Math.max(0, Math.min(pmStart, doc.content.size)); - const $pos = doc.resolve(pos); - - let runNode: ProseMirrorNode | null = null; - for (let depth = $pos.depth; depth >= 0; depth--) { - const node = $pos.node(depth); - if (node.type.name === 'run') { - runNode = node; - break; - } - } - - let vertAlign: string | null = runNode?.attrs?.runProperties?.vertAlign ?? null; - let position: number | null = runNode?.attrs?.runProperties?.position ?? null; - let fontSizeHalfPts: number | null = runNode?.attrs?.runProperties?.fontSize ?? null; - - if (!vertAlign && position == null && runNode) { - runNode.forEach((child: ProseMirrorNode) => { - if (!child.isText || !child.marks?.length) return; - const rpr = decodeRPrFromMarks(child.marks as Mark[]) as { - vertAlign?: string; - position?: number; - fontSize?: number; - }; - if (rpr.vertAlign && !vertAlign) vertAlign = rpr.vertAlign; - if (rpr.position != null && position == null) position = rpr.position; - if (rpr.fontSize != null && fontSizeHalfPts == null) fontSizeHalfPts = rpr.fontSize; - }); - } - - if (vertAlign == null && position == null) return; - - const styleEntries: string[] = []; - if (position != null && Number.isFinite(position)) { - const pts = halfPointToPoints(position); - if (Number.isFinite(pts)) { - styleEntries.push(`vertical-align: ${pts}pt`); - } - } else if (vertAlign === 'superscript' || vertAlign === 'subscript') { - styleEntries.push(`vertical-align: ${vertAlign === 'superscript' ? 'super' : 'sub'}`); - if (fontSizeHalfPts != null && Number.isFinite(fontSizeHalfPts)) { - const scaledPts = halfPointToPoints(fontSizeHalfPts * SUBSCRIPT_SUPERSCRIPT_SCALE); - if (Number.isFinite(scaledPts)) { - styleEntries.push(`font-size: ${scaledPts}pt`); - } else { - styleEntries.push(`font-size: ${SUBSCRIPT_SUPERSCRIPT_SCALE * 100}%`); - } - } else { - styleEntries.push(`font-size: ${SUBSCRIPT_SUPERSCRIPT_SCALE * 100}%`); - } - } else if (vertAlign === 'baseline') { - styleEntries.push('vertical-align: baseline'); - } - - if (!styleEntries.length) return; - const existing = span.getAttribute('style'); - const merged = existing ? `${existing}; ${styleEntries.join('; ')}` : styleEntries.join('; '); - span.setAttribute('style', merged); - } catch (error) { - // Silently catch errors for individual spans to prevent layout corruption - // DOM manipulation failures should not break the entire layout process - console.error('Failed to apply vertical alignment to span:', error); - } - }); - } catch (error) { - // Silently catch errors to prevent layout corruption - console.error('Failed to apply vertical alignment to layout:', error); - } + applyVertAlignToLayoutHelper(doc, this.#painterHost); } } diff --git a/packages/super-editor/src/core/presentation-editor/layout/applyVertAlignToLayout.ts b/packages/super-editor/src/core/presentation-editor/layout/applyVertAlignToLayout.ts new file mode 100644 index 0000000000..282c829a1b --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/layout/applyVertAlignToLayout.ts @@ -0,0 +1,90 @@ +import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; +import { decodeRPrFromMarks } from '../../super-converter/styles.js'; +import { halfPointToPoints } from '../../super-converter/helpers.js'; + +const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.583; + +/** + * Post-processes painted DOM to apply vertical alignment and font scaling + * for subscript/superscript rendering. + * + * Scans all text spans with PM position markers, resolves the containing + * run node, and applies CSS vertical-align and font-size based on the + * run's vertAlign/position properties or text style marks. + */ +export function applyVertAlignToLayout(doc: ProseMirrorNode, painterHost: HTMLElement): void { + try { + const spans = painterHost.querySelectorAll('.superdoc-line span[data-pm-start]') as NodeListOf; + spans.forEach((span) => { + try { + if (span.closest('.superdoc-page-header, .superdoc-page-footer')) return; + + const pmStart = Number(span.dataset.pmStart ?? 'NaN'); + if (!Number.isFinite(pmStart)) return; + + const pos = Math.max(0, Math.min(pmStart, doc.content.size)); + const $pos = doc.resolve(pos); + + let runNode: ProseMirrorNode | null = null; + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'run') { + runNode = node; + break; + } + } + + let vertAlign: string | null = runNode?.attrs?.runProperties?.vertAlign ?? null; + let position: number | null = runNode?.attrs?.runProperties?.position ?? null; + let fontSizeHalfPts: number | null = runNode?.attrs?.runProperties?.fontSize ?? null; + + if (!vertAlign && position == null && runNode) { + runNode.forEach((child: ProseMirrorNode) => { + if (!child.isText || !child.marks?.length) return; + const rpr = decodeRPrFromMarks(child.marks as Mark[]) as { + vertAlign?: string; + position?: number; + fontSize?: number; + }; + if (rpr.vertAlign && !vertAlign) vertAlign = rpr.vertAlign; + if (rpr.position != null && position == null) position = rpr.position; + if (rpr.fontSize != null && fontSizeHalfPts == null) fontSizeHalfPts = rpr.fontSize; + }); + } + + if (vertAlign == null && position == null) return; + + const styleEntries: string[] = []; + if (position != null && Number.isFinite(position)) { + const pts = halfPointToPoints(position); + if (Number.isFinite(pts)) { + styleEntries.push(`vertical-align: ${pts}pt`); + } + } else if (vertAlign === 'superscript' || vertAlign === 'subscript') { + styleEntries.push(`vertical-align: ${vertAlign === 'superscript' ? 'super' : 'sub'}`); + if (fontSizeHalfPts != null && Number.isFinite(fontSizeHalfPts)) { + const scaledPts = halfPointToPoints(fontSizeHalfPts * SUBSCRIPT_SUPERSCRIPT_SCALE); + if (Number.isFinite(scaledPts)) { + styleEntries.push(`font-size: ${scaledPts}pt`); + } else { + styleEntries.push(`font-size: ${SUBSCRIPT_SUPERSCRIPT_SCALE * 100}%`); + } + } else { + styleEntries.push(`font-size: ${SUBSCRIPT_SUPERSCRIPT_SCALE * 100}%`); + } + } else if (vertAlign === 'baseline') { + styleEntries.push('vertical-align: baseline'); + } + + if (!styleEntries.length) return; + const existing = span.getAttribute('style'); + const merged = existing ? `${existing}; ${styleEntries.join('; ')}` : styleEntries.join('; '); + span.setAttribute('style', merged); + } catch (error) { + console.error('Failed to apply vertical alignment to span:', error); + } + }); + } catch (error) { + console.error('Failed to apply vertical alignment to layout:', error); + } +} From b29f5783d95123645ee116ff21c71f589c4b15fc Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 13:06:14 -0300 Subject: [PATCH 07/13] chore(editor): remove dead code from PresentationEditor Remove unused imports, constants, types, and a private method: - Imports: Selection, Mark, getFirstTextPositionFromHelper, registerPointerClickFromHelper, SelectionDebugHudState, getCellPosFromTableHitFromHelper, getTablePosFromHitFromHelper, shouldUseCellSelectionFromHelper, getFragmentAtPosition, extractIdentifierFromConverter, HeaderFooterIdentifier, HeaderFooterType, Page, Fragment, Y (yjs), isInRegisteredSurface, HeaderFooterMode, HeaderFooterSession - Types: CellAnchorState, FootnoteReference, FootnotesLayoutInput, VirtualizationOptions, RemoteUserInfo, PresenceOptions, RemoteCursorsRenderPayload, LayoutUpdatePayload, ImageSelectedEvent, ImageDeselectedEvent, TelemetryEvent (still re-exported, just not imported twice) - Constants: MULTI_CLICK_TIME_THRESHOLD_MS, MULTI_CLICK_DISTANCE_THRESHOLD_PX - Stale JSDoc comment for removed SUBSCRIPT_SUPERSCRIPT_SCALE - Private method: #renderRemoteCursors (never called) Reduces PresentationEditor from 5082 to 5036 lines (-46). All eslint unused-var warnings for this file are now resolved. --- .../presentation-editor/PresentationEditor.ts | 62 ++----------------- 1 file changed, 5 insertions(+), 57 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index be3773e5c6..41df38c40c 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -1,4 +1,4 @@ -import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; +import { NodeSelection, TextSelection } from 'prosemirror-state'; import { ContextMenuPluginKey } from '@extensions/context-menu/context-menu.js'; import { CellSelection } from 'prosemirror-tables'; import { DecorationBridge } from './dom/DecorationBridge.js'; @@ -14,7 +14,7 @@ import { waitForPageMount as waitForPageMountHelper, } from './scroll/ScrollHelpers.js'; import type { EditorState, Transaction } from 'prosemirror-state'; -import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; import { Editor } from '../Editor.js'; import { EventEmitter } from '../EventEmitter.js'; @@ -50,8 +50,6 @@ import { buildPositionMapFromPmDoc } from './utils/PositionMapFromPm.js'; import { computeParagraphSelectionRangeAt as computeParagraphSelectionRangeAtFromHelper, computeWordSelectionRangeAt as computeWordSelectionRangeAtFromHelper, - getFirstTextPosition as getFirstTextPositionFromHelper, - registerPointerClick as registerPointerClickFromHelper, } from './input/ClickSelectionUtilities.js'; import { computeA11ySelectionAnnouncement as computeA11ySelectionAnnouncementFromHelper, @@ -59,7 +57,7 @@ import { syncHiddenEditorA11yAttributes as syncHiddenEditorA11yAttributesFromHelper, } from './utils/A11ySupport.js'; import { computeSelectionVirtualizationPins } from './selection/SelectionVirtualizationPins.js'; -import { debugLog, updateSelectionDebugHud, type SelectionDebugHudState } from './selection/SelectionDebug.js'; +import { debugLog, updateSelectionDebugHud } from './selection/SelectionDebug.js'; import { renderCellSelectionOverlay } from './selection/CellSelectionOverlay.js'; import { renderCaretOverlay, renderSelectionRects } from './selection/LocalSelectionOverlayRendering.js'; import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from './selection/CaretGeometry.js'; @@ -69,12 +67,7 @@ import { computeAnchorMap as computeAnchorMapFromHelper, goToAnchor as goToAnchorFromHelper, } from './utils/AnchorNavigation.js'; -import { - getCellPosFromTableHit as getCellPosFromTableHitFromHelper, - getTablePosFromHit as getTablePosFromHitFromHelper, - hitTestTable as hitTestTableFromHelper, - shouldUseCellSelection as shouldUseCellSelectionFromHelper, -} from './tables/TableSelectionUtilities.js'; +import { hitTestTable as hitTestTableFromHelper } from './tables/TableSelectionUtilities.js'; import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; @@ -84,19 +77,11 @@ import { incrementalLayout, selectionToRects, clickToPosition, - getFragmentAtPosition, - extractIdentifierFromConverter, buildMultiSectionIdentifier, layoutHeaderFooterWithCache as _layoutHeaderFooterWithCache, PageGeometryHelper, } from '@superdoc/layout-bridge'; -import type { - HeaderFooterIdentifier, - HeaderFooterLayoutResult, - HeaderFooterType, - PositionHit, - TableHitResult, -} from '@superdoc/layout-bridge'; +import type { HeaderFooterLayoutResult, PositionHit, TableHitResult } from '@superdoc/layout-bridge'; import { createDomPainter } from '@superdoc/painter-dom'; @@ -107,10 +92,8 @@ import type { FlowBlock, Layout, Measure, - Page, SectionMetadata, TrackedChangesMode, - Fragment, } from '@superdoc/contracts'; import { extractHeaderFooterSpace as _extractHeaderFooterSpace } from '@superdoc/contracts'; // TrackChangesBasePluginKey is used by #syncTrackedChangesPreferences and getTrackChangesPluginState. @@ -118,9 +101,7 @@ import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/ind // Collaboration cursor imports import { ySyncPluginKey } from 'y-prosemirror'; -import type * as Y from 'yjs'; import type { HeaderFooterDescriptor } from '../header-footer/HeaderFooterRegistry.js'; -import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js'; import { buildSemanticFootnoteBlocks } from './semantic-flow-footnotes.js'; import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; @@ -128,29 +109,16 @@ import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationB import type { PageSize, PageMargins, - VirtualizationOptions, - RemoteUserInfo, RemoteCursorState, - PresenceOptions, LayoutEngineOptions, TrackedChangesOverrides, PresentationEditorOptions, - RemoteCursorsRenderPayload, - LayoutUpdatePayload, - ImageSelectedEvent, - ImageDeselectedEvent, - TelemetryEvent, - CellAnchorState, EditorWithConverter, LayoutState, - FootnoteReference, - FootnotesLayoutInput, LayoutMetrics, LayoutError, LayoutRect, RangeRect, - HeaderFooterMode, - HeaderFooterSession, HeaderFooterRegion, HeaderFooterLayoutContext, PendingMarginClick, @@ -181,13 +149,6 @@ export type { import { CommentMarkName } from '@extensions/comment/comments-constants.js'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '@extensions/track-changes/constants.js'; -/** - * Font size scaling factor for subscript and superscript text. - * This value (0.65 or 65%) matches Microsoft Word's default rendering behavior - * for vertical alignment (w:vertAlign) when set to 'superscript' or 'subscript'. - * Applied to the base font size to reduce text size for sub/superscripts. - */ - 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 (from containerStyles in styles.ts) */ @@ -195,12 +156,6 @@ const DEFAULT_PAGE_GAP = 24; /** Default gap for horizontal layout mode */ const DEFAULT_HORIZONTAL_PAGE_GAP = 20; -// Constants for interaction timing and thresholds -/** Maximum time between clicks to register as multi-click (milliseconds) */ -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); @@ -2725,13 +2680,6 @@ export class PresentationEditor extends EventEmitter { /** * Render remote cursors from existing state without normalization. - * Delegates to RemoteCursorManager. - * @private - */ - #renderRemoteCursors() { - this.#remoteCursorManager?.render(this.#getRemoteCursorRenderDeps()); - } - /** * Initialize the EditorInputManager with dependencies and callbacks. * @private From 0f1138972b2995ffb42b4e09708826d160a2c421 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 13:22:25 -0300 Subject: [PATCH 08/13] refactor(editor): extract rerender sub-phases into RerenderHelpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 388-line #rerender method into named helpers: - computeFootnoteNumbering: footnote ordering by first doc appearance - buildConverterContext: assembles converter context for toFlowBlocks - collectHeaderFooterBlocks: merges H/F blocks from layout results and per-rId layouts into unified arrays - buildTocPageMap: blockId → pageNumber map for TOC resolution Each helper is a pure function with clear inputs/outputs. The #rerender method now reads as a high-level pipeline instead of a wall of code. Also removes unused readSettingsRoot/readDefaultTableStyle import. Reduces PresentationEditor from 5036 to 4946 lines (-90). --- .../presentation-editor/PresentationEditor.ts | 118 +++------------ .../layout/RerenderHelpers.ts | 136 ++++++++++++++++++ 2 files changed, 153 insertions(+), 101 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 41df38c40c..212d21e5d1 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -7,6 +7,12 @@ import { SemanticFlowController } from './layout/SemanticFlowController.js'; import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js'; import { applyViewportZoom } from './layout/applyViewportZoom.js'; import { applyVertAlignToLayout as applyVertAlignToLayoutHelper } from './layout/applyVertAlignToLayout.js'; +import { + computeFootnoteNumbering, + buildConverterContext, + collectHeaderFooterBlocks, + buildTocPageMap, +} from './layout/RerenderHelpers.js'; import { findPageIndexForPosition, computePageScrollOffset, @@ -72,7 +78,6 @@ import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; -import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { incrementalLayout, selectionToRects, @@ -3154,61 +3159,18 @@ export class PresentationEditor extends EventEmitter { let converterContext: ConverterContext | undefined = undefined; try { const converter = (this.#editor as Editor & { converter?: Record }).converter; - // 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; - this.#editor?.state?.doc?.descendants?.((node: any) => { - if (node?.type?.name !== 'footnoteReference') return; - const rawId = node?.attrs?.id; - if (rawId == null) return; - const key = String(rawId); - if (!key || seen.has(key)) return; - seen.add(key); - footnoteNumberById[key] = counter; - footnoteOrder.push(key); - counter += 1; - }); - } 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 { footnoteNumberById, footnoteOrder } = computeFootnoteNumbering(this.#editor?.state?.doc); 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') { converter['footnoteNumberById'] = footnoteNumberById; } } catch {} - - let defaultTableStyleId: string | undefined; - if (converter) { - const settingsRoot = readSettingsRoot(converter); - if (settingsRoot) { - defaultTableStyleId = readDefaultTableStyle(settingsRoot) ?? undefined; - } - } - - converterContext = converter - ? { - docx: converter.convertedXml, - ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), - translatedLinkedStyles: converter.translatedLinkedStyles, - translatedNumbering: converter.translatedNumbering, - ...(defaultTableStyleId ? { defaultTableStyleId } : {}), - } - : undefined; + converterContext = buildConverterContext(converter, footnoteNumberById); const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); const positionMapStart = perfNow(); const positionMap = @@ -3344,25 +3306,11 @@ export class PresentationEditor extends EventEmitter { const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocksForLayout); this.#layoutState = { blocks: blocksForLayout, measures, layout, bookmarks, anchorMap }; - // Build blockId → pageNumber map for TOC page-number resolution. - // Stored on editor.storage so the document-api adapter layer can read it - // when toc.update({ mode: 'pageNumbers' }) is called. - // pageMapDoc is the doc snapshot this map was derived from — the adapter - // layer compares it against editor.state.doc to reject stale maps. const tocStorage = ( this.#editor as unknown as { storage?: Record; pageMapDoc?: unknown }> } ).storage?.tableOfContents; if (tocStorage) { - const pageMap = new Map(); - for (const page of layout.pages) { - for (const fragment of page.fragments) { - // First occurrence wins — use the page where the block first appears - if (!pageMap.has(fragment.blockId)) { - pageMap.set(fragment.blockId, page.number); - } - } - } - tocStorage.pageMap = pageMap; + tocStorage.pageMap = buildTocPageMap(layout); tocStorage.pageMapDoc = this.#editor.state.doc; } if (this.#headerFooterSession) { @@ -3397,46 +3345,14 @@ export class PresentationEditor extends EventEmitter { ); } - // Extract header/footer blocks and measures from layout results - const headerBlocks: FlowBlock[] = []; - const headerMeasures: Measure[] = []; - if (headerLayouts) { - for (const headerResult of headerLayouts) { - headerBlocks.push(...headerResult.blocks); - headerMeasures.push(...headerResult.measures); - } - } - // Also include per-rId header blocks for multi-section support - const headerLayoutsByRId = this.#headerFooterSession?.headerLayoutsByRId; - if (headerLayoutsByRId) { - for (const rIdResult of headerLayoutsByRId.values()) { - headerBlocks.push(...rIdResult.blocks); - headerMeasures.push(...rIdResult.measures); - } - } - - const footerBlocks: FlowBlock[] = []; - const footerMeasures: Measure[] = []; - if (footerLayouts) { - for (const footerResult of footerLayouts) { - footerBlocks.push(...footerResult.blocks); - footerMeasures.push(...footerResult.measures); - } - } - // Also include per-rId footer blocks for multi-section support - const footerLayoutsByRId = this.#headerFooterSession?.footerLayoutsByRId; - if (footerLayoutsByRId) { - for (const rIdResult of footerLayoutsByRId.values()) { - footerBlocks.push(...rIdResult.blocks); - footerMeasures.push(...rIdResult.measures); - } - } - - // Merge any extra lookup blocks (e.g., footnotes injected into page fragments) - if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { - footerBlocks.push(...extraBlocks); - footerMeasures.push(...extraMeasures); - } + const { headerBlocks, headerMeasures, footerBlocks, footerMeasures } = collectHeaderFooterBlocks( + headerLayouts, + footerLayouts, + this.#headerFooterSession?.headerLayoutsByRId, + this.#headerFooterSession?.footerLayoutsByRId, + extraBlocks, + extraMeasures, + ); // Pass all blocks (main document + headers + footers + extras) to the painter const painterSetDataStart = perfNow(); diff --git a/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts b/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts new file mode 100644 index 0000000000..c51f5f3264 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts @@ -0,0 +1,136 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { FlowBlock, Measure, SectionMetadata } from '@superdoc/contracts'; +import type { ConverterContext } from '@superdoc/pm-adapter'; +import { readSettingsRoot, readDefaultTableStyle } from '../../../document-api-adapters/document-settings.js'; +import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../utils/SchemaNodeTypes.js'; +import { buildPositionMapFromPmDoc } from '../utils/PositionMapFromPm.js'; + +/** + * Computes footnote numbering by first appearance in the document. + * Returns the numbering map and the ordered list of footnote IDs (for cache invalidation). + */ +export function computeFootnoteNumbering(doc: ProseMirrorNode | null | undefined): { + footnoteNumberById: Record; + footnoteOrder: string[]; +} { + const footnoteNumberById: Record = {}; + const footnoteOrder: string[] = []; + if (!doc?.descendants) return { footnoteNumberById, footnoteOrder }; + + try { + const seen = new Set(); + let counter = 1; + doc.descendants((node: ProseMirrorNode) => { + if (node?.type?.name !== 'footnoteReference') return; + const rawId = (node?.attrs as { id?: unknown })?.id; + if (rawId == null) return; + const key = String(rawId); + if (!key || seen.has(key)) return; + seen.add(key); + footnoteNumberById[key] = counter; + footnoteOrder.push(key); + counter += 1; + }); + } catch (e) { + if (typeof console !== 'undefined' && console.warn) { + console.warn('[PresentationEditor] Failed to compute footnote numbering:', e); + } + } + + return { footnoteNumberById, footnoteOrder }; +} + +/** + * Builds the converter context needed for toFlowBlocks from the editor's converter. + */ +export function buildConverterContext( + converter: Record | null | undefined, + footnoteNumberById: Record, +): ConverterContext | undefined { + if (!converter) return undefined; + + let defaultTableStyleId: string | undefined; + const settingsRoot = readSettingsRoot(converter); + if (settingsRoot) { + defaultTableStyleId = readDefaultTableStyle(settingsRoot) ?? undefined; + } + + return { + docx: converter.convertedXml, + ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering, + ...(defaultTableStyleId ? { defaultTableStyleId } : {}), + } as ConverterContext; +} + +/** + * Collects header/footer blocks and measures from layout results and per-rId layouts. + */ +export function collectHeaderFooterBlocks( + headerLayouts: Array<{ blocks: FlowBlock[]; measures: Measure[] }> | undefined, + footerLayouts: Array<{ blocks: FlowBlock[]; measures: Measure[] }> | undefined, + headerLayoutsByRId: Map | undefined, + footerLayoutsByRId: Map | undefined, + extraBlocks?: FlowBlock[], + extraMeasures?: Measure[], +): { + headerBlocks: FlowBlock[]; + headerMeasures: Measure[]; + footerBlocks: FlowBlock[]; + footerMeasures: Measure[]; +} { + const headerBlocks: FlowBlock[] = []; + const headerMeasures: Measure[] = []; + if (headerLayouts) { + for (const result of headerLayouts) { + headerBlocks.push(...result.blocks); + headerMeasures.push(...result.measures); + } + } + if (headerLayoutsByRId) { + for (const result of headerLayoutsByRId.values()) { + headerBlocks.push(...result.blocks); + headerMeasures.push(...result.measures); + } + } + + const footerBlocks: FlowBlock[] = []; + const footerMeasures: Measure[] = []; + if (footerLayouts) { + for (const result of footerLayouts) { + footerBlocks.push(...result.blocks); + footerMeasures.push(...result.measures); + } + } + if (footerLayoutsByRId) { + for (const result of footerLayoutsByRId.values()) { + footerBlocks.push(...result.blocks); + footerMeasures.push(...result.measures); + } + } + + if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { + footerBlocks.push(...extraBlocks); + footerMeasures.push(...extraMeasures); + } + + return { headerBlocks, headerMeasures, footerBlocks, footerMeasures }; +} + +/** + * Builds the blockId → pageNumber map for TOC page-number resolution. + */ +export function buildTocPageMap(layout: { + pages: Array<{ number: number; fragments: Array<{ blockId: string }> }>; +}): Map { + const pageMap = new Map(); + for (const page of layout.pages) { + for (const fragment of page.fragments) { + if (!pageMap.has(fragment.blockId)) { + pageMap.set(fragment.blockId, page.number); + } + } + } + return pageMap; +} From 330ff03d84c7c67c9ee2c9ced7619afa137d81f8 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 13:27:06 -0300 Subject: [PATCH 09/13] refactor(editor): extract DOM element creation into createOverlayElements Move ~100 lines of createElement/className/style/appendChild calls from the constructor into a factory function. Creates all 10 overlay elements (viewportHost, painterHost, selectionOverlay, remoteCursorOverlay, localSelectionLayer, permissionOverlay, hoverOverlay, hoverTooltip, modeBanner, ariaLiveRegion) with correct z-ordering and styling. Constructor now destructures the factory result into its fields, reducing the DOM creation from ~100 lines to ~15 lines of assignment. Reduces PresentationEditor from 4946 to 4848 lines (-98). --- .../presentation-editor/PresentationEditor.ts | 131 ++------------- .../dom/createOverlayElements.ts | 153 ++++++++++++++++++ 2 files changed, 169 insertions(+), 115 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/dom/createOverlayElements.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 212d21e5d1..7dcb5aa201 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -41,6 +41,7 @@ import { denormalizeClientPoint as denormalizeClientPointFromPointer, } from './dom/PointerNormalization.js'; import { getPageElementByIndex } from './dom/PageDom.js'; +import { createOverlayElements } from './dom/createOverlayElements.js'; import { inchesToPx, parseColumns } from './layout/LayoutOptionParsing.js'; import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/PresentationLayoutMetrics.js'; import { buildFootnotesInput } from './layout/FootnotesBuilder.js'; @@ -370,23 +371,21 @@ export class PresentationEditor extends EventEmitter { }; this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges; - this.#viewportHost = doc.createElement('div'); - this.#viewportHost.className = 'presentation-editor__viewport'; - // Hide the viewport from screen readers - it's a visual rendering layer, not semantic content. - // The hidden ProseMirror editor (in #hiddenHost) provides the actual accessible document structure. - // This prevents screen readers from encountering duplicate or non-semantic visual elements. - this.#viewportHost.setAttribute('aria-hidden', 'true'); - this.#viewportHost.style.position = 'relative'; - this.#viewportHost.style.width = '100%'; - // Set min-height to at least one page so the viewport is clickable before layout renders - const pageHeight = this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - this.#viewportHost.style.minHeight = `${pageHeight}px`; - this.#visibleHost.appendChild(this.#viewportHost); - - this.#painterHost = doc.createElement('div'); - this.#painterHost.className = 'presentation-editor__pages'; - this.#painterHost.style.transformOrigin = 'top left'; - this.#viewportHost.appendChild(this.#painterHost); + const overlayElements = createOverlayElements( + this.#visibleHost, + `presentation-overlay-${options.documentId || 'default'}`, + this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h, + ); + this.#viewportHost = overlayElements.viewportHost; + this.#painterHost = overlayElements.painterHost; + this.#selectionOverlay = overlayElements.selectionOverlay; + this.#remoteCursorOverlay = overlayElements.remoteCursorOverlay; + this.#localSelectionLayer = overlayElements.localSelectionLayer; + this.#permissionOverlay = overlayElements.permissionOverlay; + this.#hoverOverlay = overlayElements.hoverOverlay; + this.#hoverTooltip = overlayElements.hoverTooltip; + this.#modeBanner = overlayElements.modeBanner; + this.#ariaLiveRegion = overlayElements.ariaLiveRegion; // SDT selection/hover styling manager this.#sdtStyles = new SdtSelectionStyleManager({ @@ -420,45 +419,6 @@ export class PresentationEditor extends EventEmitter { this.#selectionSync.on('render', () => this.#updateSelection()); this.#selectionSync.on('render', () => this.#updatePermissionOverlay()); - this.#permissionOverlay = doc.createElement('div'); - this.#permissionOverlay.className = 'presentation-editor__permission-overlay'; - Object.assign(this.#permissionOverlay.style, { - position: 'absolute', - inset: '0', - pointerEvents: 'none', - zIndex: '5', - }); - this.#viewportHost.appendChild(this.#permissionOverlay); - - // Create dual-layer overlay structure - // Container holds both remote (below) and local (above) layers - this.#selectionOverlay = doc.createElement('div'); - this.#selectionOverlay.className = 'presentation-editor__selection-overlay'; - this.#selectionOverlay.id = `presentation-overlay-${options.documentId || 'default'}`; - this.#selectionOverlay.style.position = 'absolute'; - this.#selectionOverlay.style.inset = '0'; - this.#selectionOverlay.style.pointerEvents = 'none'; - this.#selectionOverlay.style.zIndex = '10'; - - // Create remote layer (renders below local) - this.#remoteCursorOverlay = doc.createElement('div'); - this.#remoteCursorOverlay.className = 'presentation-editor__selection-layer--remote'; - this.#remoteCursorOverlay.style.position = 'absolute'; - this.#remoteCursorOverlay.style.inset = '0'; - this.#remoteCursorOverlay.style.pointerEvents = 'none'; - - // Create local layer (renders above remote) - this.#localSelectionLayer = doc.createElement('div'); - this.#localSelectionLayer.className = 'presentation-editor__selection-layer--local'; - this.#localSelectionLayer.style.position = 'absolute'; - this.#localSelectionLayer.style.inset = '0'; - this.#localSelectionLayer.style.pointerEvents = 'none'; - - // Append layers in correct z-index order (remote first, local second) - this.#selectionOverlay.appendChild(this.#remoteCursorOverlay); - this.#selectionOverlay.appendChild(this.#localSelectionLayer); - this.#viewportHost.appendChild(this.#selectionOverlay); - // Initialize remote cursor manager this.#remoteCursorManager = new RemoteCursorManager({ visibleHost: this.#visibleHost, @@ -474,51 +434,6 @@ export class PresentationEditor extends EventEmitter { // Wire up manager callbacks to use PresentationEditor methods this.#remoteCursorManager.setUpdateCallback(() => this.#updateRemoteCursors()); - this.#hoverOverlay = doc.createElement('div'); - this.#hoverOverlay.className = 'presentation-editor__hover-overlay'; - Object.assign(this.#hoverOverlay.style, { - position: 'absolute', - border: '1px dashed rgba(51, 102, 255, 0.8)', - borderRadius: '2px', - pointerEvents: 'none', - display: 'none', - zIndex: '11', - }); - this.#selectionOverlay.appendChild(this.#hoverOverlay); - - this.#hoverTooltip = doc.createElement('div'); - this.#hoverTooltip.className = 'presentation-editor__hover-tooltip'; - Object.assign(this.#hoverTooltip.style, { - position: 'absolute', - background: 'rgba(18, 22, 33, 0.85)', - color: '#fff', - padding: '2px 6px', - fontSize: '12px', - borderRadius: '2px', - pointerEvents: 'none', - display: 'none', - zIndex: '12', - whiteSpace: 'nowrap', - }); - this.#selectionOverlay.appendChild(this.#hoverTooltip); - - this.#modeBanner = doc.createElement('div'); - this.#modeBanner.className = 'presentation-editor__mode-banner'; - Object.assign(this.#modeBanner.style, { - position: 'absolute', - top: '0', - left: '50%', - transform: 'translate(-50%, -100%)', - background: '#1b3fbf', - color: '#fff', - padding: '4px 12px', - borderRadius: '6px', - fontSize: '13px', - display: 'none', - zIndex: '15', - }); - this.#visibleHost.appendChild(this.#modeBanner); - // Initialize header/footer session manager this.#headerFooterSession = new HeaderFooterSessionManager({ painterHost: this.#painterHost, @@ -537,20 +452,6 @@ export class PresentationEditor extends EventEmitter { }); this.#headerFooterSession.setDocumentMode(this.#documentMode); - this.#ariaLiveRegion = doc.createElement('div'); - this.#ariaLiveRegion.className = 'presentation-editor__aria-live'; - this.#ariaLiveRegion.setAttribute('role', 'status'); - this.#ariaLiveRegion.setAttribute('aria-live', 'polite'); - this.#ariaLiveRegion.setAttribute('aria-atomic', 'true'); - Object.assign(this.#ariaLiveRegion.style, { - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - clip: 'rect(1px, 1px, 1px, 1px)', - }); - this.#visibleHost.appendChild(this.#ariaLiveRegion); - this.#hiddenHost = createHiddenHost(doc, this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w); if (doc.body) { doc.body.appendChild(this.#hiddenHost); diff --git a/packages/super-editor/src/core/presentation-editor/dom/createOverlayElements.ts b/packages/super-editor/src/core/presentation-editor/dom/createOverlayElements.ts new file mode 100644 index 0000000000..bdd18fd889 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/dom/createOverlayElements.ts @@ -0,0 +1,153 @@ +export interface OverlayElements { + viewportHost: HTMLElement; + painterHost: HTMLElement; + selectionOverlay: HTMLElement; + remoteCursorOverlay: HTMLElement; + localSelectionLayer: HTMLElement; + permissionOverlay: HTMLElement; + hoverOverlay: HTMLElement; + hoverTooltip: HTMLElement; + modeBanner: HTMLElement; + ariaLiveRegion: HTMLElement; +} + +/** + * Creates all DOM elements for the PresentationEditor's visual layers. + * These elements are created once in the constructor and never recreated. + */ +export function createOverlayElements( + visibleHost: HTMLElement, + overlayId: string, + defaultPageHeight: number, +): OverlayElements { + const doc = visibleHost.ownerDocument ?? document; + + // Viewport host — contains painter + overlays + const viewportHost = doc.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + viewportHost.setAttribute('aria-hidden', 'true'); + viewportHost.style.position = 'relative'; + viewportHost.style.width = '100%'; + viewportHost.style.minHeight = `${defaultPageHeight}px`; + visibleHost.appendChild(viewportHost); + + // Painter host — DomPainter mounts pages here + const painterHost = doc.createElement('div'); + painterHost.className = 'presentation-editor__pages'; + painterHost.style.transformOrigin = 'top left'; + viewportHost.appendChild(painterHost); + + // Permission overlay + const permissionOverlay = doc.createElement('div'); + permissionOverlay.className = 'presentation-editor__permission-overlay'; + Object.assign(permissionOverlay.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '5', + }); + viewportHost.appendChild(permissionOverlay); + + // Selection overlay container (holds remote + local layers) + const selectionOverlay = doc.createElement('div'); + selectionOverlay.className = 'presentation-editor__selection-overlay'; + selectionOverlay.id = overlayId; + selectionOverlay.style.position = 'absolute'; + selectionOverlay.style.inset = '0'; + selectionOverlay.style.pointerEvents = 'none'; + selectionOverlay.style.zIndex = '10'; + + // Remote cursor layer (below local) + const remoteCursorOverlay = doc.createElement('div'); + remoteCursorOverlay.className = 'presentation-editor__selection-layer--remote'; + remoteCursorOverlay.style.position = 'absolute'; + remoteCursorOverlay.style.inset = '0'; + remoteCursorOverlay.style.pointerEvents = 'none'; + + // Local selection layer (above remote) + const localSelectionLayer = doc.createElement('div'); + localSelectionLayer.className = 'presentation-editor__selection-layer--local'; + localSelectionLayer.style.position = 'absolute'; + localSelectionLayer.style.inset = '0'; + localSelectionLayer.style.pointerEvents = 'none'; + + selectionOverlay.appendChild(remoteCursorOverlay); + selectionOverlay.appendChild(localSelectionLayer); + viewportHost.appendChild(selectionOverlay); + + // Hover overlay (H/F hover region indicator) + const hoverOverlay = doc.createElement('div'); + hoverOverlay.className = 'presentation-editor__hover-overlay'; + Object.assign(hoverOverlay.style, { + position: 'absolute', + border: '1px dashed rgba(51, 102, 255, 0.8)', + borderRadius: '2px', + pointerEvents: 'none', + display: 'none', + zIndex: '11', + }); + selectionOverlay.appendChild(hoverOverlay); + + // Hover tooltip + const hoverTooltip = doc.createElement('div'); + hoverTooltip.className = 'presentation-editor__hover-tooltip'; + Object.assign(hoverTooltip.style, { + position: 'absolute', + background: 'rgba(18, 22, 33, 0.85)', + color: '#fff', + padding: '2px 6px', + fontSize: '12px', + borderRadius: '2px', + pointerEvents: 'none', + display: 'none', + zIndex: '12', + whiteSpace: 'nowrap', + }); + selectionOverlay.appendChild(hoverTooltip); + + // Mode banner for H/F editing mode + const modeBanner = doc.createElement('div'); + modeBanner.className = 'presentation-editor__mode-banner'; + Object.assign(modeBanner.style, { + position: 'absolute', + top: '0', + left: '50%', + transform: 'translate(-50%, -100%)', + background: '#1b3fbf', + color: '#fff', + padding: '4px 12px', + borderRadius: '6px', + fontSize: '13px', + display: 'none', + zIndex: '15', + }); + visibleHost.appendChild(modeBanner); + + // ARIA live region for selection announcements + const ariaLiveRegion = doc.createElement('div'); + ariaLiveRegion.className = 'presentation-editor__aria-live'; + ariaLiveRegion.setAttribute('role', 'status'); + ariaLiveRegion.setAttribute('aria-live', 'polite'); + ariaLiveRegion.setAttribute('aria-atomic', 'true'); + Object.assign(ariaLiveRegion.style, { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(1px, 1px, 1px, 1px)', + }); + visibleHost.appendChild(ariaLiveRegion); + + return { + viewportHost, + painterHost, + selectionOverlay, + remoteCursorOverlay, + localSelectionLayer, + permissionOverlay, + hoverOverlay, + hoverTooltip, + modeBanner, + ariaLiveRegion, + }; +} From f793562df0b9a322131b664c81689956073ec2e7 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 13:41:49 -0300 Subject: [PATCH 10/13] refactor(editor): decompose setupEditorListeners into named handlers Replace 188 lines of inline closures with focused named methods: - #handleEditorUpdate: epoch tracking, cache invalidation, mapping - #handleEditorSelectionUpdate: selection sync, a11y announcement - #handleEditorTransaction: decoration bridge sync - #triggerRerender: common rerender trigger pattern (DRY) - #listenTo: DRY helper for event registration + cleanup tracking #setupEditorListeners is now a 25-line registration method that reads like a table of contents. Each handler has a descriptive name and a single responsibility. Reduces PresentationEditor from 4848 to 4757 lines (-91). --- .../presentation-editor/PresentationEditor.ts | 254 ++++++------------ 1 file changed, 82 insertions(+), 172 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 7dcb5aa201..dbc53d500f 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2334,193 +2334,103 @@ export class PresentationEditor extends EventEmitter { }); } - #setupEditorListeners() { - const handleUpdate = ({ transaction }: { transaction?: Transaction }) => { - const trackedChangesChanged = this.#syncTrackedChangesPreferences(); - if (transaction) { - this.#epochMapper.recordTransaction(transaction); - this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch()); - - // Detect Y.js-origin transactions (remote collaboration changes). - // These bypass the blockNodePlugin's sdBlockRev increment to prevent - // feedback loops, so the FlowBlockCache's fast revision comparison - // cannot be trusted — signal it to fall through to JSON comparison. - const ySyncMeta = transaction.getMeta?.(ySyncPluginKey); - if (ySyncMeta?.isChangeOrigin && transaction.docChanged) { - this.#flowBlockCache?.setHasExternalChanges(true); - } - // History undo/redo can restore prior paragraph content while preserving/reusing - // sdBlockRev values, which makes the cache's fast revision check unsafe. - // Force JSON comparison for this render cycle to avoid stale paragraph reuse. - const inputType = transaction.getMeta?.('inputType'); - const isHistoryType = inputType === 'historyUndo' || inputType === 'historyRedo'; - if (isHistoryType && transaction.docChanged) { - this.#flowBlockCache?.setHasExternalChanges(true); - } - } - if (trackedChangesChanged || transaction?.docChanged) { - this.#pendingDocChange = true; - // Store the mapping from this transaction for position updates during paint. - // Only stored for doc changes - other triggers don't have position shifts. - if (transaction?.docChanged) { - if (this.#pendingMapping !== null) { - // Multiple rapid transactions before rerender - compose the mappings. - // The painter's gate checks maps.length > 1 to trigger full rebuild, - // which is the safe fallback for complex/batched edits. - const combined = this.#pendingMapping.slice(); - combined.appendMapping(transaction.mapping); - this.#pendingMapping = combined; - } else { - this.#pendingMapping = transaction.mapping; - } - } - this.#selectionSync.onLayoutStart(); - this.#scheduleRerender(); - } - // Update local cursor in awareness whenever document changes - // This ensures cursor position is broadcast with each keystroke - if (transaction?.docChanged) { - this.#updateLocalAwarenessCursor(); - // Clear cell anchor on document changes to prevent stale references - // (table structure may have changed, cell positions may be invalid) - this.#editorInputManager?.clearCellAnchor(); - } - }; - const handleSelection = () => { - // Use immediate rendering for selection-only changes (clicks, arrow keys). - // Without immediate, the render is RAF-deferred — leaving a window where - // a remote collaborator's edit can cancel the pending render via - // setDocEpoch → cancelScheduledRender. Immediate rendering is safe here: - // if layout is updating (due to a concurrent doc change), flushNow() - // is a no-op and the render will be picked up after layout completes. - this.#scheduleSelectionUpdate({ immediate: true }); - // Update local cursor in awareness for collaboration - // This bypasses y-prosemirror's focus check which may fail for hidden PM views - this.#updateLocalAwarenessCursor(); - this.#scheduleA11ySelectionAnnouncement(); - }; - - // The 'transaction' event fires for ALL transactions (doc changes, - // selection changes, meta-only). The 'update' event only fires for - // docChanged transactions, and 'selectionUpdate' only for selection - // changes. A meta-only transaction (e.g., a custom command that sets - // plugin state without editing text) fires neither. - // - // We listen on 'transaction' so the decoration bridge picks up changes - // from any transaction type. The bridge's own identity check + RAF - // coalescing prevent unnecessary work. - // When decoration state changes without a doc change (e.g. setFocus), we must - // still run a full rerender so runs are split at the new decoration boundaries; - // otherwise the bridge applies the class to whole runs and highlights too much. - const handleTransaction = (event?: { transaction?: Transaction }) => { - const tr = event?.transaction; - this.#decorationBridge.recordTransaction(tr); - const state = this.#editor?.view?.state; - const decorationChanged = state && this.#decorationBridge.hasChanges(state); - // Sync immediately whenever decorations changed so e.g. clearFocus removes - // highlight-selection in the same tick. Only restore when we had a doc change. - if (decorationChanged) { - const restoreEmpty = tr ? tr.docChanged === true : false; - this.#decorationBridge.sync(state!, this.#domPositionIndex, { - restoreEmptyDecorations: restoreEmpty, - }); - } else { - // No immediate sync; schedule coalesced sync on next frame. - this.#scheduleDecorationSync(); - } - if (decorationChanged) { - this.#pendingDocChange = true; - this.#selectionSync.onLayoutStart(); - this.#scheduleRerender(); - } - }; - - this.#editor.on('update', handleUpdate); - this.#editor.on('selectionUpdate', handleSelection); - this.#editor.on('transaction', handleTransaction); - this.#editorListeners.push({ event: 'update', handler: handleUpdate as (...args: unknown[]) => void }); - this.#editorListeners.push({ event: 'selectionUpdate', handler: handleSelection as (...args: unknown[]) => void }); - this.#editorListeners.push({ event: 'transaction', handler: handleTransaction as (...args: unknown[]) => void }); - - // Listen for page style changes (e.g., margin adjustments via ruler). - // These changes don't modify document content (docChanged === false), - // so the 'update' event isn't emitted. The dedicated pageStyleUpdate event - // provides clearer semantics and better debugging than checking transaction meta flags. - const handlePageStyleUpdate = () => { - this.#pendingDocChange = true; - this.#selectionSync.onLayoutStart(); - this.#scheduleRerender(); - }; - this.#editor.on('pageStyleUpdate', handlePageStyleUpdate); - this.#editorListeners.push({ - event: 'pageStyleUpdate', - handler: handlePageStyleUpdate as (...args: unknown[]) => void, - }); + #listenTo(event: string, handler: (...args: any[]) => void) { + this.#editor.on(event, handler); + this.#editorListeners.push({ event, handler }); + } - // Listen for stylesheet default changes (e.g., styles.apply mutations to docDefaults). - // These changes mutate translatedLinkedStyles directly and need a full re-render - // so the style-engine picks up the updated default properties. - const handleStylesDefaultsChanged = () => { + #setupEditorListeners() { + this.#listenTo('update', (e: { transaction?: Transaction }) => this.#handleEditorUpdate(e.transaction)); + this.#listenTo('selectionUpdate', () => this.#handleEditorSelectionUpdate()); + this.#listenTo('transaction', (e?: { transaction?: Transaction }) => this.#handleEditorTransaction(e?.transaction)); + this.#listenTo('pageStyleUpdate', () => this.#triggerRerender()); + this.#listenTo('stylesDefaultsChanged', () => { this.#pendingDocChange = true; this.#scheduleRerender(); - }; - this.#editor.on('stylesDefaultsChanged', handleStylesDefaultsChanged); - this.#editorListeners.push({ - event: 'stylesDefaultsChanged', - handler: handleStylesDefaultsChanged as (...args: unknown[]) => void, }); - - const handleCollaborationReady = (payload: unknown) => { + this.#listenTo('collaborationReady', (payload: unknown) => { this.emit('collaborationReady', payload); - // Setup remote cursor rendering after collaboration is ready - // Only setup if presence is enabled in layout options if (this.#options.collaborationProvider?.awareness && this.#layoutOptions.presence?.enabled !== false) { this.#setupCollaborationCursors(); } - }; - this.#editor.on('collaborationReady', handleCollaborationReady); - this.#editorListeners.push({ - event: 'collaborationReady', - handler: handleCollaborationReady as (...args: unknown[]) => void, }); - - // Handle remote header/footer changes from collaborators - const handleRemoteHeaderFooterChanged = (payload: { - type: 'header' | 'footer'; - sectionId: string; - content: unknown; - }) => { + this.#listenTo('remoteHeaderFooterChanged', (payload: { sectionId: string }) => { this.#headerFooterSession?.adapter?.invalidate(payload.sectionId); this.#headerFooterSession?.manager?.refresh(); - this.#pendingDocChange = true; - this.#scheduleRerender(); - }; - this.#editor.on('remoteHeaderFooterChanged', handleRemoteHeaderFooterChanged); - this.#editorListeners.push({ - event: 'remoteHeaderFooterChanged', - handler: handleRemoteHeaderFooterChanged as (...args: unknown[]) => void, + this.#triggerRerender(); }); + this.#listenTo('commentsUpdate', (payload: { activeCommentId?: string | null }) => { + if (this.#domPainter?.setActiveComment && 'activeCommentId' in payload) { + this.#domPainter.setActiveComment(payload.activeCommentId ?? null); + this.#triggerRerender(); + } + }); + } - // Listen for comment selection changes to update Layout Engine highlighting - const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => { - if (this.#domPainter?.setActiveComment) { - // Only update active comment when the field is explicitly present in the payload. - // This prevents unrelated events (like tracked change updates) from clearing - // the active comment selection unexpectedly. - if ('activeCommentId' in payload) { - const activeId = payload.activeCommentId ?? null; - this.#domPainter.setActiveComment(activeId); - // Mark as needing re-render to apply the new active comment highlighting - this.#pendingDocChange = true; - this.#scheduleRerender(); + #triggerRerender() { + this.#pendingDocChange = true; + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + } + + #handleEditorUpdate(transaction?: Transaction) { + const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + if (transaction) { + this.#epochMapper.recordTransaction(transaction); + this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch()); + + // Y.js-origin or history undo/redo transactions may reuse sdBlockRev values, + // making the FlowBlockCache's fast revision check unsafe. Force JSON comparison. + const ySyncMeta = transaction.getMeta?.(ySyncPluginKey); + const inputType = transaction.getMeta?.('inputType'); + const needsFullComparison = + (ySyncMeta?.isChangeOrigin && transaction.docChanged) || + ((inputType === 'historyUndo' || inputType === 'historyRedo') && transaction.docChanged); + if (needsFullComparison) { + this.#flowBlockCache?.setHasExternalChanges(true); + } + } + + if (trackedChangesChanged || transaction?.docChanged) { + this.#pendingDocChange = true; + if (transaction?.docChanged) { + if (this.#pendingMapping !== null) { + const combined = this.#pendingMapping.slice(); + combined.appendMapping(transaction.mapping); + this.#pendingMapping = combined; + } else { + this.#pendingMapping = transaction.mapping; } } - }; - this.#editor.on('commentsUpdate', handleCommentsUpdate); - this.#editorListeners.push({ - event: 'commentsUpdate', - handler: handleCommentsUpdate as (...args: unknown[]) => void, - }); + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + } + + if (transaction?.docChanged) { + this.#updateLocalAwarenessCursor(); + this.#editorInputManager?.clearCellAnchor(); + } + } + + #handleEditorSelectionUpdate() { + this.#scheduleSelectionUpdate({ immediate: true }); + this.#updateLocalAwarenessCursor(); + this.#scheduleA11ySelectionAnnouncement(); + } + + #handleEditorTransaction(tr?: Transaction) { + this.#decorationBridge.recordTransaction(tr); + const state = this.#editor?.view?.state; + const decorationChanged = state && this.#decorationBridge.hasChanges(state); + + if (decorationChanged) { + this.#decorationBridge.sync(state!, this.#domPositionIndex, { + restoreEmptyDecorations: tr ? tr.docChanged === true : false, + }); + this.#triggerRerender(); + } else { + this.#scheduleDecorationSync(); + } } /** From 8c9602d6c19a7fe33c0ae96dadf6e43db07e68b0 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 13:45:09 -0300 Subject: [PATCH 11/13] refactor(editor): decompose updateSelection into named rendering methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 156-line #updateSelection into a decision tree + 2 renderers: - #updateSelection: guard chain + dispatch to caret or range path - #renderCaretOverlayIfNeeded: collapsed selection (caret) rendering - #renderRangeSelectionOverlay: range selection rects rendering #updateSelection now reads as: check guards → sync SDT styles → handle cell selection → dispatch to caret or range renderer. Reduces PresentationEditor from 4757 to 4749 lines (-8 net, but #updateSelection itself drops from 156 to 95 lines). --- .../presentation-editor/PresentationEditor.ts | 60 ++++++++----------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index dbc53d500f..b896686bfa 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3485,53 +3485,46 @@ export class PresentationEditor extends EventEmitter { } if (from === to) { - const caretLayout = this.#computeCaretLayoutRect(from); - if (!caretLayout) { - // Keep existing cursor visible rather than clearing it - return; - } - // Only clear old cursor after successfully computing new position - try { - this.#localSelectionLayer.innerHTML = ''; - renderCaretOverlay({ - localSelectionLayer: this.#localSelectionLayer, - caretLayout, - convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), - }); - } catch (error) { - // DOM manipulation can fail if element is detached or in invalid state - if (process.env.NODE_ENV === 'development') { - console.warn('[PresentationEditor] Failed to render caret overlay:', error); - } - } + this.#renderCaretOverlayIfNeeded(from); return; } + this.#renderRangeSelectionOverlay(selection, from, to); + } + + #renderCaretOverlayIfNeeded(pos: number): void { + const caretLayout = this.#computeCaretLayoutRect(pos); + if (!caretLayout) return; + try { + this.#localSelectionLayer.innerHTML = ''; + renderCaretOverlay({ + localSelectionLayer: this.#localSelectionLayer, + caretLayout, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render caret overlay:', error); + } + } + } + + #renderRangeSelectionOverlay(selection: import('prosemirror-state').Selection, from: number, to: number): void { const domRects = this.#computeSelectionRectsFromDom(from, to); if (domRects == null) { - // DOM-derived selection failed; keep last known-good overlay instead of drifting. debugLog('warn', 'Local selection: DOM rect computation failed', { from, to }); return; } - // When dragging across mark boundaries, the selection can briefly land in the - // 2-position structural gap between adjacent runs, producing zero DOM rects for - // one frame. Preserve the last overlay only during active drag to prevent flicker. - // Outside drag (scroll, programmatic changes), zero rects means the DOM is stale - // or virtualized — clearing the overlay is the safer default. - if (domRects.length === 0 && from !== to && this.#editorInputManager?.isDragging) { - debugLog('warn', '[drawSelection] zero rects for non-collapsed selection — preserving last overlay', { - from, - to, - }); + // Preserve last overlay during active drag when rects are empty (mark boundary gap) + if (domRects.length === 0 && this.#editorInputManager?.isDragging) { return; } try { this.#localSelectionLayer.innerHTML = ''; - const isFieldAnnotationSelection = - selection instanceof NodeSelection && selection.node?.type?.name === 'fieldAnnotation'; - if (domRects.length > 0 && !isFieldAnnotationSelection) { + const isFieldAnnotation = selection instanceof NodeSelection && selection.node?.type?.name === 'fieldAnnotation'; + if (domRects.length > 0 && !isFieldAnnotation) { renderSelectionRects({ localSelectionLayer: this.#localSelectionLayer, rects: domRects, @@ -3541,7 +3534,6 @@ export class PresentationEditor extends EventEmitter { }); } } catch (error) { - // DOM manipulation can fail if element is detached or in invalid state if (process.env.NODE_ENV === 'development') { console.warn('[PresentationEditor] Failed to render selection rects:', error); } From 99e562dd95701e173699a0fec99146f2a72f2bb5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 14:25:50 -0300 Subject: [PATCH 12/13] refactor(editor): decompose #rerender into named pipeline phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 299-line #rerender into 3 focused methods: - #convertDocToFlowBlocks: serialize doc, compute footnotes, build converter context, run toFlowBlocks, split decoration boundaries. Returns null on error (caller returns early). - #rerender: now 177 lines. Orchestrates the pipeline: convert → layout options → incremental layout → state update → paint. - #postPaint: DOM index rebuild, decorations, epoch sync, permission overlay, error reset, zoom, event emission, remote cursors. #rerender now reads as a clear 3-phase pipeline instead of a 299-line wall. Each phase can be understood independently. --- .../presentation-editor/PresentationEditor.ts | 264 +++++++++--------- 1 file changed, 138 insertions(+), 126 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index b896686bfa..172f7a42da 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2943,96 +2943,107 @@ export class PresentationEditor extends EventEmitter { } } + #convertDocToFlowBlocks(perfNow: () => number): { + docJson: unknown; + blocks: FlowBlock[]; + bookmarks: Map; + converterContext: ConverterContext | undefined; + sectionMetadata: SectionMetadata[]; + layoutEpoch: number; + } | null { + let docJson; + try { + const start = perfNow(); + docJson = this.#editor.getJSON(); + perfLog(`[Perf] getJSON: ${(perfNow() - start).toFixed(2)}ms`); + } catch (error) { + this.#handleLayoutError('render', this.#decorateError(error, 'getJSON')); + return null; + } + + const layoutEpoch = this.#epochMapper.getCurrentEpoch(); + const sectionMetadata: SectionMetadata[] = []; + + let blocks: FlowBlock[] | undefined; + let bookmarks: Map = new Map(); + let converterContext: ConverterContext | undefined; + try { + const converter = (this.#editor as Editor & { converter?: Record }).converter; + const { footnoteNumberById, footnoteOrder } = computeFootnoteNumbering(this.#editor?.state?.doc); + const footnoteSignature = footnoteOrder.join('|'); + if (footnoteSignature !== this.#footnoteNumberSignature) { + this.#flowBlockCache.clear(); + this.#footnoteNumberSignature = footnoteSignature; + } + try { + if (converter && typeof converter === 'object') { + converter['footnoteNumberById'] = footnoteNumberById; + } + } catch {} + converterContext = buildConverterContext(converter, footnoteNumberById); + + const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); + const positionMap = + this.#editor?.state?.doc && docJson ? buildPositionMapFromPmDoc(this.#editor.state.doc, docJson) : null; + const commentsEnabled = this.#documentMode !== 'viewing' || this.#layoutOptions.enableCommentsInViewing === true; + + const start = perfNow(); + const result = toFlowBlocks(docJson, { + mediaFiles: (this.#editor?.storage?.image as { media?: Record })?.media, + emitSectionBreaks: true, + sectionMetadata, + trackedChangesMode: this.#trackedChangesMode, + enableTrackedChanges: this.#trackedChangesEnabled, + enableComments: commentsEnabled, + enableRichHyperlinks: true, + themeColors: this.#editor?.converter?.themeColors ?? undefined, + converterContext, + flowBlockCache: this.#flowBlockCache, + ...(positionMap ? { positions: positionMap } : {}), + ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), + }); + perfLog(`[Perf] toFlowBlocks: ${(perfNow() - start).toFixed(2)}ms (blocks=${result.blocks.length})`); + blocks = result.blocks; + bookmarks = result.bookmarks ?? new Map(); + } catch (error) { + this.#handleLayoutError('render', this.#decorateError(error, 'toFlowBlocks')); + return null; + } + + if (!blocks) { + this.#handleLayoutError('render', new Error('toFlowBlocks returned undefined blocks')); + return null; + } + + // Split runs at decoration boundaries for highlight rendering + const state = this.#editor?.view?.state; + const decorationRanges = state ? this.#decorationBridge.collectDecorationRanges(state) : []; + if (decorationRanges.length > 0) { + blocks = splitRunsAtDecorationBoundaries( + blocks, + decorationRanges.map((r) => ({ from: r.from, to: r.to })), + ); + } + + this.#applyHtmlAnnotationMeasurements(blocks); + return { docJson, blocks, bookmarks, converterContext, sectionMetadata, layoutEpoch }; + } + async #rerender() { this.#selectionSync.onLayoutStart(); let layoutCompleted = false; try { - 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; - } - const layoutEpoch = this.#epochMapper.getCurrentEpoch(); - const sectionMetadata: SectionMetadata[] = []; - let blocks: FlowBlock[] | undefined; - let bookmarks: Map = new Map(); - let converterContext: ConverterContext | undefined = undefined; - try { - const converter = (this.#editor as Editor & { converter?: Record }).converter; - const { footnoteNumberById, footnoteOrder } = computeFootnoteNumbering(this.#editor?.state?.doc); - const footnoteSignature = footnoteOrder.join('|'); - if (footnoteSignature !== this.#footnoteNumberSignature) { - this.#flowBlockCache.clear(); - this.#footnoteNumberSignature = footnoteSignature; - } - try { - if (converter && typeof converter === 'object') { - converter['footnoteNumberById'] = footnoteNumberById; - } - } catch {} - converterContext = buildConverterContext(converter, footnoteNumberById); - 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, - sectionMetadata, - trackedChangesMode: this.#trackedChangesMode, - enableTrackedChanges: this.#trackedChangesEnabled, - enableComments: commentsEnabled, - 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) { - this.#handleLayoutError('render', this.#decorateError(error, 'toFlowBlocks')); - return; - } + // Phase 1: Serialize document + convert to FlowBlocks + const conversionResult = this.#convertDocToFlowBlocks(perfNow); + if (!conversionResult) return; + const { docJson, blocks, bookmarks, converterContext, sectionMetadata, layoutEpoch } = conversionResult; - if (!blocks) { - this.#handleLayoutError('render', new Error('toFlowBlocks returned undefined blocks')); - return; - } - - // Split runs at decoration boundaries so bridge sync applies background only to the - // selected portion (like highlight mark) without adding a document mark. - const state = this.#editor?.view?.state; - const decorationRanges = state ? this.#decorationBridge.collectDecorationRanges(state) : []; - if (decorationRanges.length > 0) { - blocks = splitRunsAtDecorationBoundaries( - blocks, - decorationRanges.map((r) => ({ from: r.from, to: r.to })), - ); - } - - this.#applyHtmlAnnotationMeasurements(blocks); const isSemanticFlow = this.#isSemanticFlowMode(); const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); @@ -3187,59 +3198,60 @@ export class PresentationEditor extends EventEmitter { 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.#syncDecorations(); - 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; - this.#scheduleRerender(); - } - this.#epochMapper.onLayoutComplete(layoutEpoch); - this.#selectionSync.onLayoutComplete(layoutEpoch); + this.#postPaint(perfNow, layoutEpoch, blocksForLayout, measures, layout, perf, startMark); layoutCompleted = true; - this.#updatePermissionOverlay(); + } finally { + if (!layoutCompleted) { + this.#selectionSync.onLayoutAbort(); + } + } + } - // Reset error state on successful layout - this.#layoutError = null; - this.#layoutErrorState = 'healthy'; - this.#errorBanner.dismiss(); + #postPaint( + perfNow: () => number, + layoutEpoch: number, + blocks: FlowBlock[], + measures: Measure[], + layout: Layout, + perf: Performance | undefined, + startMark: number | undefined, + ) { + const postStart = perfNow(); + this.#applyVertAlignToLayout(); + this.#rebuildDomPositionIndex(); + this.#syncDecorations(); + this.#domIndexObserverManager?.resume(); + perfLog(`[Perf] painter.postPaint: ${(perfNow() - postStart).toFixed(2)}ms`); + + this.#layoutEpoch = layoutEpoch; + if (this.#updateHtmlAnnotationMeasurements(layoutEpoch)) { + this.#pendingDocChange = true; + this.#scheduleRerender(); + } + this.#epochMapper.onLayoutComplete(layoutEpoch); + this.#selectionSync.onLayoutComplete(layoutEpoch); + this.#updatePermissionOverlay(); - // Update viewport dimensions after layout (page count may have changed) - this.#applyZoom(); + this.#layoutError = null; + this.#layoutErrorState = 'healthy'; + this.#errorBanner.dismiss(); - const metrics = createLayoutMetricsFromHelper(perf, startMark, layout, blocksForLayout); - const payload = { layout, blocks: blocksForLayout, measures, metrics }; - this.emit('layoutUpdated', payload); - this.emit('paginationUpdate', payload); - - // Emit fresh comment positions after layout completes. - // Always emit — even when empty — so the store can clear stale positions - // (e.g. when undo removes the last tracked-change mark). - const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true; - if (this.#documentMode !== 'viewing' || allowViewingCommentPositions) { - const commentPositions = this.#collectCommentPositions(); - this.emit('commentPositions', { positions: commentPositions }); - } + this.#applyZoom(); - this.#selectionSync.requestRender({ immediate: true }); + const metrics = createLayoutMetricsFromHelper(perf, startMark, layout, blocks); + const payload = { layout, blocks, measures, metrics }; + this.emit('layoutUpdated', payload); + this.emit('paginationUpdate', payload); - // Re-normalize remote cursor positions after layout completes. - // Local document changes shift absolute positions, so Yjs relative positions - // must be re-resolved against the updated editor state. Without this, - // remote cursors appear offset by the number of characters the local user typed. - if (this.#remoteCursorManager?.hasRemoteCursors()) { - this.#remoteCursorManager.markDirty(); - this.#remoteCursorManager.scheduleUpdate(); - } - } finally { - if (!layoutCompleted) { - this.#selectionSync.onLayoutAbort(); - } + if (this.#documentMode !== 'viewing' || this.#layoutOptions.emitCommentPositionsInViewing === true) { + this.emit('commentPositions', { positions: this.#collectCommentPositions() }); + } + + this.#selectionSync.requestRender({ immediate: true }); + + if (this.#remoteCursorManager?.hasRemoteCursors()) { + this.#remoteCursorManager.markDirty(); + this.#remoteCursorManager.scheduleUpdate(); } } From 88d420e880482f0e30aa9af42b66e30cc0fd0d92 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 14:40:35 -0300 Subject: [PATCH 13/13] chore(editor): remove unused imports from RerenderHelpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove SectionMetadata, getAtomNodeTypesFromSchema, and buildPositionMapFromPmDoc — leftover from the extraction. These functions stayed in PresentationEditor. --- .../src/core/presentation-editor/layout/RerenderHelpers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts b/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts index c51f5f3264..561caebe82 100644 --- a/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts +++ b/packages/super-editor/src/core/presentation-editor/layout/RerenderHelpers.ts @@ -1,9 +1,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { FlowBlock, Measure, SectionMetadata } from '@superdoc/contracts'; +import type { FlowBlock, Measure } from '@superdoc/contracts'; import type { ConverterContext } from '@superdoc/pm-adapter'; import { readSettingsRoot, readDefaultTableStyle } from '../../../document-api-adapters/document-settings.js'; -import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../utils/SchemaNodeTypes.js'; -import { buildPositionMapFromPmDoc } from '../utils/PositionMapFromPm.js'; /** * Computes footnote numbering by first appearance in the document.