From 41f90a1728993cbc4c27000ae9c0508b8248b220 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 16 Apr 2026 15:23:37 -0700 Subject: [PATCH 01/16] feat: ui editing using parts system --- .../header-footer/EditorOverlayManager.ts | 8 + .../adapters/header-footer-part-descriptor.ts | 37 +- .../presentation-editor/PresentationEditor.ts | 177 +++++++++- .../HeaderFooterSessionManager.ts | 218 ++++++++---- .../pointer-events/EditorInputManager.ts | 44 +-- .../StoryPresentationSessionManager.test.ts | 314 +++++++++++++++++ .../StoryPresentationSessionManager.ts | 322 ++++++++++++++++++ .../createStoryHiddenHost.test.ts | 50 +++ .../story-session/createStoryHiddenHost.ts | 55 +++ .../story-session/index.ts | 22 ++ .../story-session/types.ts | 137 ++++++++ .../tests/HeaderFooterSessionManager.test.ts | 187 ++++++++++ .../v1/core/presentation-editor/types.ts | 15 + .../header-footer-story-runtime.ts | 22 +- .../story-runtime/note-story-runtime.ts | 106 +++--- .../story-runtime/story-types.ts | 14 + packages/superdoc/src/SuperDoc.vue | 1 + packages/superdoc/src/core/types/index.js | 1 + tests/behavior/fixtures/superdoc.ts | 2 + tests/behavior/harness/main.ts | 2 + .../headers/double-click-edit-header.spec.ts | 32 +- .../headers/header-footer-line-height.spec.ts | 52 ++- 22 files changed, 1596 insertions(+), 222 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts diff --git a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts index 0ece308cb5..1bc8a1a8d3 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts @@ -11,6 +11,14 @@ * - Toggle visibility between static decoration content and live editors * - Manage dimming overlay for body content during editing * - Control selection overlay visibility to prevent double caret rendering + * + * @deprecated (transitional) + * This visible child-PM overlay is the legacy header/footer editing model. + * When {@link PresentationEditorOptions.useHiddenHostForStoryParts} is enabled, + * header/footer editing runs through the story-session/hidden-host path + * (see `presentation-editor/story-session/`) and this overlay is bypassed. + * It will be retired once the story-session path has shipped and the flag + * defaults to `true`. See `plans/story-backed-parts-presentation-editing.md`. */ import type { HeaderFooterRegion } from './types.js'; diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts index fdb1aaa81d..9e4bc83f3e 100644 --- a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts +++ b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-part-descriptor.ts @@ -43,7 +43,17 @@ function getConverter(editor: Editor): ConverterForHeaderFooter | undefined { // Part ID Parsing // --------------------------------------------------------------------------- -/** Mutation source tag for local header/footer sub-editor edits. */ +/** + * Mutation source tag for local header/footer sub-editor edits. + * + * @remarks + * This tag remains a coordination signal used to suppress redundant refresh + * fan-out when a local sub-editor has already propagated an edit. The + * refactor described in + * `plans/story-backed-parts-presentation-editing.md` (Phase 5) aims to stop + * relying on local UI code to pre-update converter caches; the tag stays, but + * the descriptor path should become authoritative for cache rebuilds. + */ export const SOURCE_HEADER_FOOTER_LOCAL = 'header-footer-sync:local'; const HEADER_PATTERN = /^word\/header\d+\.xml$/; @@ -125,14 +135,17 @@ export function ensureHeaderFooterDescriptor(partId: PartId, sectionId: string): const resolvedSectionId = ctx.sectionId ?? sectionId; - // Local edits (header-footer-sync:local) already update the PM cache - // and refresh other sub-editors in onHeaderFooterDataUpdate. Running - // refreshActiveSubEditors here would re-replace the originating editor, - // causing a redundant update cycle with cursor churn. + // Local edits still emit SOURCE_HEADER_FOOTER_LOCAL as a coordination + // signal so we can suppress redundant live-editor fan-out, but the + // descriptor path is authoritative for rebuilding the PM cache from the + // committed OOXML. This avoids depending on UI callers to pre-update + // converter state before mutatePart runs. const isLocalSync = ctx.source === SOURCE_HEADER_FOOTER_LOCAL; - // For remote applies, rebuild the PM JSON from the updated OOXML - if (!isLocalSync && typeof converter.reimportHeaderFooterPart === 'function') { + // Rebuild the PM JSON cache from the updated OOXML for both local and + // remote applies. Local sync suppresses only the live-editor refresh + // fan-out below. + if (typeof converter.reimportHeaderFooterPart === 'function') { try { const pmJson = converter.reimportHeaderFooterPart(ctx.partId); if (pmJson) { @@ -222,14 +235,18 @@ function destroySubEditors(converter: ConverterForHeaderFooter, type: 'header' | function registerHeaderFooterInvalidationHandler(partId: PartId): void { registerInvalidationHandler(partId, (editor) => { + const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view; + if (!view?.dispatch) { + return; + } + try { const tr = (editor as unknown as { state: { tr: unknown } }).state.tr; const setMeta = (tr as unknown as { setMeta: (key: string, value: boolean) => unknown }).setMeta; setMeta.call(tr, 'forceUpdatePagination', true); - const view = (editor as unknown as { view?: { dispatch?: (tr: unknown) => void } }).view; - view?.dispatch?.(tr); + view.dispatch(tr); } catch { - // View may not be ready + // UI invalidation is best-effort only. } }); } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 5a9783e3e9..43e55977d4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -73,6 +73,10 @@ import { import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; +import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js'; +import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; +import { createStoryEditor } from '../story-editor-factory.js'; +import { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.js'; import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { @@ -369,6 +373,14 @@ export class PresentationEditor extends EventEmitter { #trackedChangesOverrides: TrackedChangesOverrides | undefined; // Header/footer session management #headerFooterSession: HeaderFooterSessionManager | null = null; + /** + * Generic story-backed presentation-session manager. Only constructed when + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} is true. + * When active, interactive editing of story-backed parts (headers, footers, + * future notes) runs through this manager instead of the visible-PM + * overlay owned by {@link HeaderFooterSessionManager}. + */ + #storySessionManager: StoryPresentationSessionManager | null = null; #hoverOverlay: HTMLElement | null = null; #hoverTooltip: HTMLElement | null = null; #modeBanner: HTMLElement | null = null; @@ -685,6 +697,7 @@ export class PresentationEditor extends EventEmitter { } this.#setupHeaderFooterSession(); + this.#setupStorySessionManager(); this.#applyZoom(); this.#setupEditorListeners(); this.#initializeEditorInputManager(); @@ -1086,6 +1099,11 @@ export class PresentationEditor extends EventEmitter { * ``` */ getActiveEditor(): Editor { + // Story-session path (behind useHiddenHostForStoryParts) takes + // precedence over the legacy header/footer overlay. + const storySession = this.#storySessionManager?.getActiveSession(); + if (storySession) return storySession.editor; + const session = this.#headerFooterSession?.session; const activeHfEditor = this.#headerFooterSession?.activeEditor; if (!session || session.mode === 'body' || !activeHfEditor) { @@ -1094,6 +1112,24 @@ export class PresentationEditor extends EventEmitter { return activeHfEditor; } + /** + * Access the generic story-session manager when the + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} rollout + * flag is enabled. Returns `null` when the flag is off — in that case + * story-backed interactive editing still runs through the legacy + * `HeaderFooterSessionManager` / visible-PM overlay path. + * + * This is a transitional surface exposed so tests and opt-in callers + * can drive activation while the full Phase 3/4 geometry/pointer + * plumbing is landed incrementally. Do not rely on it from product + * code yet. + * + * @experimental + */ + getStorySessionManager(): StoryPresentationSessionManager | null { + return this.#storySessionManager; + } + // ------------------------------------------------------------------- // Selection bridge — tracked handles + snapshot convenience // ------------------------------------------------------------------- @@ -1537,7 +1573,7 @@ export class PresentationEditor extends EventEmitter { * Return layout-relative rects for the current document selection. */ getSelectionRects(relativeTo?: HTMLElement): RangeRect[] { - const selection = this.#editor.state?.selection; + const selection = this.getActiveEditor().state?.selection; if (!selection || selection.empty) return []; return this.getRangeRects(selection.from, selection.to, relativeTo); } @@ -1617,11 +1653,8 @@ export class PresentationEditor extends EventEmitter { } } - // Fix Issue #1: Get actual header/footer page height instead of hardcoded 1 - // When in header/footer mode, we need to use the real page height from the layout context - // to correctly map coordinates for selection highlighting - const pageHeight = sessionMode === 'body' ? this.#getBodyPageHeight() : this.#getHeaderFooterPageHeight(); - const pageGap = this.#layoutState.layout?.pageGap ?? 0; + const pageHeight = this.#getBodyPageHeight(); + const pageGap = sessionMode === 'body' ? (this.#layoutState.layout?.pageGap ?? 0) : 0; const finalRects = rawRects .map((rect: LayoutRect, idx: number, allRects: LayoutRect[]) => { let adjustedX = rect.x; @@ -2288,11 +2321,14 @@ export class PresentationEditor extends EventEmitter { // Get selection rects from the header/footer layout (already transformed to viewport) const rects = this.#computeHeaderFooterSelectionRects(pos, pos); - if (!rects || rects.length === 0) { + let rect = rects?.[0] ?? null; + if (!rect) { + rect = this.#computeHeaderFooterCaretRect(pos); + } + if (!rect) { return null; } - const rect = rects[0]; const zoom = this.#layoutOptions.zoom ?? 1; const containerRect = this.#visibleHost.getBoundingClientRect(); const scrollLeft = this.#visibleHost.scrollLeft ?? 0; @@ -2949,6 +2985,12 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession = null; }, 'Header/footer session manager'); + // Clean up generic story-session manager (if the flag enabled it) + safeCleanup(() => { + this.#storySessionManager?.destroy(); + this.#storySessionManager = null; + }, 'Story presentation session manager'); + // Clear flow block cache to free memory this.#flowBlockCache.clear(); @@ -3803,6 +3845,7 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, + getStorySessionManager: () => this.#storySessionManager, }); // Set up callbacks @@ -3894,6 +3937,75 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession.initialize(); } + /** + * Set up the generic story-session manager. + * + * Only instantiated when {@link PresentationEditorOptions.useHiddenHostForStoryParts} + * is `true`. While the flag is off the manager stays `null` and the + * legacy visible header/footer overlay path remains active. + */ + #setupStorySessionManager() { + if (!this.#options.useHiddenHostForStoryParts) return; + + this.#storySessionManager = new StoryPresentationSessionManager({ + resolveRuntime: (locator) => resolveStoryRuntime(this.#editor, locator, { intent: 'write' }), + getMountContainer: () => { + const doc = this.#visibleHost?.ownerDocument; + return doc?.body ?? this.#visibleHost ?? null; + }, + editorFactory: ({ runtime, hostElement, activationOptions }) => { + const existing = runtime.editor; + const pmJson = existing.getJSON() as unknown as Record; + const editorContext = activationOptions.editorContext ?? {}; + const surfaceKind = editorContext.surfaceKind; + + let fresh: Editor; + if ( + runtime.kind === 'headerFooter' && + (surfaceKind === 'header' || surfaceKind === 'footer') && + runtime.locator.storyType === 'headerFooterPart' + ) { + const editorContainer = hostElement.ownerDocument.createElement('div'); + fresh = createHeaderFooterEditor({ + editor: this.#editor, + data: pmJson, + editorContainer, + editorHost: hostElement, + headerFooterRefId: runtime.locator.refId, + type: surfaceKind, + availableWidth: editorContext.availableWidth, + availableHeight: editorContext.availableHeight, + currentPageNumber: editorContext.currentPageNumber, + totalPageCount: editorContext.totalPageCount, + }); + } else { + fresh = createStoryEditor(this.#editor, pmJson, { + documentId: runtime.storyKey, + isHeaderOrFooter: runtime.kind === 'headerFooter', + headless: false, + element: hostElement, + currentPageNumber: editorContext.currentPageNumber, + totalPageCount: editorContext.totalPageCount, + }); + } + + return { + editor: fresh, + dispose: () => { + try { + fresh.destroy(); + } catch { + // best-effort teardown + } + }, + }; + }, + onActiveSessionChanged: () => { + this.#inputBridge?.notifyTargetChanged(); + }, + }); + } + /** * Attempts to perform a table hit test for the given normalized coordinates. * @@ -3935,7 +4047,8 @@ export class PresentationEditor extends EventEmitter { * @private */ #selectWordAt(pos: number): boolean { - const state = this.#editor.state; + const activeEditor = this.getActiveEditor(); + const state = activeEditor.state; if (!state?.doc) { return false; } @@ -3947,7 +4060,7 @@ export class PresentationEditor extends EventEmitter { const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to)); try { - this.#editor.view?.dispatch(tr); + activeEditor.view?.dispatch(tr); return true; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -3973,7 +4086,8 @@ export class PresentationEditor extends EventEmitter { * @private */ #selectParagraphAt(pos: number): boolean { - const state = this.#editor.state; + const activeEditor = this.getActiveEditor(); + const state = activeEditor.state; if (!state?.doc) { return false; } @@ -3983,7 +4097,7 @@ export class PresentationEditor extends EventEmitter { } const tr = state.tr.setSelection(TextSelection.create(state.doc, range.from, range.to)); try { - this.#editor.view?.dispatch(tr); + activeEditor.view?.dispatch(tr); return true; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -5621,6 +5735,12 @@ export class PresentationEditor extends EventEmitter { } #getActiveDomTarget(): HTMLElement | null { + // Story-session path (behind useHiddenHostForStoryParts) takes + // precedence: while a hidden-host story session is active, the + // active DOM target is the story editor's DOM, not the body's. + const storyTarget = this.#storySessionManager?.getActiveEditorDomTarget(); + if (storyTarget) return storyTarget; + const session = this.#headerFooterSession?.session; if (session && session.mode !== 'body') { const activeEditor = this.#headerFooterSession?.activeEditor; @@ -6223,6 +6343,10 @@ export class PresentationEditor extends EventEmitter { return this.#headerFooterSession?.computeSelectionRects(from, to) ?? []; } + #computeHeaderFooterCaretRect(pos: number): LayoutRect | null { + return this.#headerFooterSession?.computeCaretRect(pos) ?? null; + } + #syncTrackedChangesPreferences(): boolean { const mode = this.#deriveTrackedChangesMode(); const enabled = this.#deriveTrackedChangesEnabled(); @@ -6838,8 +6962,8 @@ export class PresentationEditor extends EventEmitter { * selection rectangles in layout space, then renders them into the shared * selection overlay so selection behaves consistently with body content. * - * Caret rendering is left to the ProseMirror header/footer editor; this - * overlay only mirrors non-collapsed selections. + * In hidden-host mode this also renders the caret from the active story + * editor's hidden DOM geometry. */ #updateHeaderFooterSelection() { this.#clearSelectedFieldAnnotationClass(); @@ -6859,11 +6983,32 @@ export class PresentationEditor extends EventEmitter { const { from, to } = selection; - // Let the header/footer ProseMirror editor handle caret rendering. if (from === to) { + const caretRect = this.#computeHeaderFooterCaretRect(from); + if (!caretRect) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + try { this.#localSelectionLayer.innerHTML = ''; - } catch {} + renderCaretOverlay({ + localSelectionLayer: this.#localSelectionLayer, + caretLayout: { + pageIndex: caretRect.pageIndex, + x: caretRect.x, + y: caretRect.y - caretRect.pageIndex * this.#getBodyPageHeight(), + height: caretRect.height, + }, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render header/footer caret:', error); + } + } return; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 698f155d34..0350b2f033 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -13,6 +13,7 @@ import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; +import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; import type { Editor } from '../../Editor.js'; import type { @@ -135,6 +136,11 @@ export type SessionManagerDependencies = { setPendingDocChange: () => void; /** Get total page count from body layout */ getBodyPageCount: () => number; + /** Get the generic story-session manager when enabled */ + getStorySessionManager?: () => { + activate: (locator: HeaderFooterPartStoryLocator, options?: Record) => { editor: Editor }; + exit: () => void; + } | null; }; /** @@ -725,6 +731,7 @@ export class HeaderFooterSessionManager { // Capture headerFooterRefId before clearing session - needed for cache invalidation const editedHeaderId = this.#session.headerFooterRefId; + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; if (this.#activeEditor) { this.#activeEditor.setEditable(false); @@ -732,8 +739,12 @@ export class HeaderFooterSessionManager { } this.#teardownActiveEditorEventBridge(); - this.#overlayManager?.hideEditingOverlay(); - this.#overlayManager?.showSelectionOverlay(); + if (storySessionManager) { + storySessionManager.exit(); + } else { + this.#overlayManager?.hideEditingOverlay(); + this.#overlayManager?.showSelectionOverlay(); + } this.#activeEditor = null; this.#session = { mode: 'body' }; @@ -765,9 +776,38 @@ export class HeaderFooterSessionManager { this.activateRegion(region); } + #activateStorySessionForRegion(region: HeaderFooterRegion, descriptor: HeaderFooterDescriptor): Editor | null { + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; + if (!storySessionManager) { + return null; + } + + const locator: HeaderFooterPartStoryLocator = { + kind: 'story', + storyType: 'headerFooterPart', + refId: descriptor.id, + }; + + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; + const session = storySessionManager.activate(locator, { + commitPolicy: 'onExit', + preferHiddenHost: true, + hostWidthPx: Math.max(1, region.width), + editorContext: { + availableWidth: Math.max(1, region.width), + availableHeight: Math.max(1, region.height), + currentPageNumber: Math.max(1, region.pageNumber ?? 1), + totalPageCount: Math.max(1, bodyPageCount), + surfaceKind: region.kind, + }, + }); + + return session?.editor ?? null; + } + async #enterMode(region: HeaderFooterRegion): Promise { try { - if (!this.#headerFooterManager || !this.#overlayManager) { + if (!this.#headerFooterManager) { this.clearHover(); return; } @@ -854,46 +894,63 @@ export class HeaderFooterSessionManager { return; } - const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; - const { success, editorHost, reason } = this.#overlayManager.showEditingOverlay( - pageElement, - region, - layoutOptions.zoom ?? 1, - ); - if (!success || !editorHost) { - console.error('[HeaderFooterSessionManager] Failed to create editor host:', reason); - this.clearHover(); - this.#callbacks.onError?.({ - error: new Error(`Failed to create editor host: ${reason}`), - context: 'enterMode.showOverlay', - }); - return; - } - - const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; let editor; - try { - editor = await this.#headerFooterManager.ensureEditor(descriptor, { - editorHost, - availableWidth: region.width, - availableHeight: region.height, - currentPageNumber: region.pageNumber, - totalPageCount: bodyPageCount, - }); - } catch (editorError) { - console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); - this.#overlayManager.hideEditingOverlay(); - this.clearHover(); - this.#callbacks.onError?.({ - error: editorError, - context: 'enterMode.ensureEditor', - }); - return; + const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; + if (storySessionManager) { + try { + editor = this.#activateStorySessionForRegion(region, descriptor); + } catch (editorError) { + console.error('[HeaderFooterSessionManager] Error creating story session:', editorError); + this.clearHover(); + this.#callbacks.onError?.({ + error: editorError, + context: 'enterMode.storySession', + }); + return; + } + } else { + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const { success, editorHost, reason } = this.#overlayManager.showEditingOverlay( + pageElement, + region, + layoutOptions.zoom ?? 1, + ); + if (!success || !editorHost) { + console.error('[HeaderFooterSessionManager] Failed to create editor host:', reason); + this.clearHover(); + this.#callbacks.onError?.({ + error: new Error(`Failed to create editor host: ${reason}`), + context: 'enterMode.showOverlay', + }); + return; + } + + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; + try { + editor = await this.#headerFooterManager.ensureEditor(descriptor, { + editorHost, + availableWidth: region.width, + availableHeight: region.height, + currentPageNumber: region.pageNumber, + totalPageCount: bodyPageCount, + }); + } catch (editorError) { + console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); + this.#overlayManager.hideEditingOverlay(); + this.clearHover(); + this.#callbacks.onError?.({ + error: editorError, + context: 'enterMode.ensureEditor', + }); + return; + } } if (!editor) { console.warn('[HeaderFooterSessionManager] Failed to ensure editor for descriptor:', descriptor); - this.#overlayManager.hideEditingOverlay(); + if (!storySessionManager) { + this.#overlayManager.hideEditingOverlay(); + } this.clearHover(); this.#callbacks.onError?.({ error: new Error('Failed to create editor instance'), @@ -902,44 +959,10 @@ export class HeaderFooterSessionManager { return; } - // For footers, apply positioning adjustments - if (region.kind === 'footer') { - const editorContainer = editorHost.firstElementChild; - if (editorContainer instanceof HTMLElement) { - editorContainer.style.overflow = 'visible'; - if (region.minY != null && region.minY < 0) { - const shiftDown = Math.abs(region.minY); - editorContainer.style.transform = `translateY(${shiftDown}px)`; - } else { - editorContainer.style.transform = ''; - } - } - } - try { editor.setEditable(true); editor.setOptions({ documentMode: 'editing' }); - // Ensure the header/footer editor receives focus on user interaction. - // Without this, subsequent clicks in newly-activated editors may not - // update ProseMirror selection because the view never regains focus. - try { - const editorView = editor.view; - if (editorView && editorHost) { - const focusHandler = () => { - try { - editorView.focus(); - } catch { - // Ignore focus errors; selection updates will still work when possible. - } - }; - editorHost.addEventListener('mousedown', focusHandler); - this.#managerCleanups.push(() => editorHost.removeEventListener('mousedown', focusHandler)); - } - } catch { - // Best-effort: if we can't wire the focus handler, continue without it. - } - // Move caret to end of content try { const doc = editor.state?.doc; @@ -953,7 +976,9 @@ export class HeaderFooterSessionManager { } } catch (editableError) { console.error('[HeaderFooterSessionManager] Error setting editor editable:', editableError); - this.#overlayManager.hideEditingOverlay(); + if (!storySessionManager) { + this.#overlayManager.hideEditingOverlay(); + } this.clearHover(); this.#callbacks.onError?.({ error: editableError, @@ -1487,6 +1512,53 @@ export class HeaderFooterSessionManager { return layoutRects; } + computeCaretRect(pos: number): LayoutRect | null { + if (this.#session.mode === 'body') { + return null; + } + + const activeEditor = this.#activeEditor; + const view = activeEditor?.view; + if (!view || typeof view.coordsAtPos !== 'function') { + return null; + } + + const context = this.getContext(); + if (!context) { + return null; + } + + const region = context.region; + const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const zoom = + typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 + ? layoutOptions.zoom + : 1; + + try { + const coords = view.coordsAtPos(pos); + const editorDom = view.dom as HTMLElement; + const editorHostRect = editorDom.getBoundingClientRect(); + const localX = (coords.left - editorHostRect.left) / zoom; + const localY = (coords.top - editorHostRect.top) / zoom; + const height = Math.max(1, (coords.bottom - coords.top) / zoom); + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex: region.pageIndex, + x: region.localX + localX, + y: region.pageIndex * bodyPageHeight + region.localY + localY, + width: 1, + height, + }; + } catch { + return null; + } + } + /** * Get the current header/footer layout context. */ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 6dd36c0bf2..45db95e0de 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1061,12 +1061,12 @@ export class EditorInputManager { return; } - const editor = this.#deps.getEditor(); - if (this.#handleSingleCommentHighlightClick(event, target, editor)) { + const bodyEditor = this.#deps.getEditor(); + if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { return; } - if (this.#handleRepeatClickOnActiveComment(event, target, editor)) { + if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { return; } @@ -1093,6 +1093,7 @@ export class EditorInputManager { // Check header/footer session state const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const editor = sessionMode === 'body' ? bodyEditor : this.#deps.getActiveEditor(); if (sessionMode !== 'body') { if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY)) return; @@ -1106,8 +1107,10 @@ export class EditorInputManager { normalizedPoint.pageLocalY, ); if (headerFooterRegion) { - event.preventDefault(); // Prevent native selection before double-click handles it - return; // Will be handled by double-click + if (sessionMode === 'body') { + event.preventDefault(); // Prevent native selection before double-click handles it + return; // Will be handled by double-click + } } // Get hit position @@ -1281,15 +1284,12 @@ export class EditorInputManager { // Handle double/triple click selection let handledByDepth = false; - const sessionModeForDepth = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - if (sessionModeForDepth === 'body') { - const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos; - - if (clickDepth >= 3) { - handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false; - } else if (clickDepth === 2) { - handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false; - } + const selectionPos = clickDepth >= 2 && this.#dragAnchor !== null ? this.#dragAnchor : hit.pos; + + if (clickDepth >= 3) { + handledByDepth = this.#callbacks.selectParagraphAt?.(selectionPos) ?? false; + } else if (clickDepth === 2) { + handledByDepth = this.#callbacks.selectWordAt?.(selectionPos) ?? false; } const hasFocus = editor.view?.hasFocus?.() ?? false; @@ -1475,13 +1475,15 @@ export class EditorInputManager { normalized.pageLocalY, ); if (region) { - event.preventDefault(); - event.stopPropagation(); + if (sessionMode === 'body') { + event.preventDefault(); + event.stopPropagation(); - // Materialization (if needed) now happens inside #enterMode via - // ensureExplicitHeaderFooterSlot. The pointer handler only triggers - // activation — it is not responsible for slot creation. - this.#callbacks.activateHeaderFooterRegion?.(region); + // Materialization (if needed) now happens inside #enterMode via + // ensureExplicitHeaderFooterSlot. The pointer handler only triggers + // activation — it is not responsible for slot creation. + this.#callbacks.activateHeaderFooterRegion?.(region); + } } else if ((this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body') !== 'body') { this.#callbacks.exitHeaderFooterMode?.(); } @@ -2267,7 +2269,7 @@ export class EditorInputManager { * operations with tracked changes. */ #focusEditor(): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); const view = editor?.view; const editorDom = view?.dom as HTMLElement | undefined; if (!editorDom) return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts new file mode 100644 index 0000000000..3f516aaa8f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StoryPresentationSessionManager } from './StoryPresentationSessionManager.js'; +import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; +import type { Editor } from '../../Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- +// +// The session manager only interacts with the runtime's commit / dispose +// hooks and with `editor.view.dom` when a DOM target is needed. Everything +// else is delegated to caller-supplied callbacks, so a bare-minimum +// Editor-shaped stub is sufficient. + +type StubEditor = Pick & { + options?: { parentEditor?: StubEditor }; + emitTransaction?: (docChanged?: boolean) => void; +}; + +function makeStubEditor(dom: HTMLElement | null): StubEditor { + const transactionListeners = new Set<(payload: { transaction: { docChanged: boolean } }) => void>(); + return { + view: dom ? ({ dom } as unknown as Editor['view']) : undefined, + on(event, handler) { + if (event === 'transaction') { + transactionListeners.add(handler as (payload: { transaction: { docChanged: boolean } }) => void); + } + }, + off(event, handler) { + if (event === 'transaction' && handler) { + transactionListeners.delete(handler as (payload: { transaction: { docChanged: boolean } }) => void); + } + }, + emitTransaction(docChanged = true) { + transactionListeners.forEach((listener) => listener({ transaction: { docChanged } })); + }, + } as StubEditor; +} + +function makeStubLocator(): StoryLocator { + return { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }; +} + +function makeStubRuntime(editor: StubEditor, overrides: Partial = {}): StoryRuntime { + return { + locator: makeStubLocator(), + storyKey: 'story:headerFooterPart:rId7', + editor: editor as unknown as Editor, + kind: 'headerFooter', + ...overrides, + }; +} + +describe('StoryPresentationSessionManager', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('refuses to host a body runtime', () => { + const editor = makeStubEditor(document.createElement('div')); + const runtime: StoryRuntime = { + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor: editor as unknown as Editor, + kind: 'body', + }; + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + expect(() => manager.activate({ kind: 'story', storyType: 'body' })).toThrow(/cannot host a body runtime/); + }); + + it('activates a session, tracks its editor DOM, and exits cleanly', () => { + const dom = document.createElement('div'); + const editor = makeStubEditor(dom); + const runtime = makeStubRuntime(editor); + + const onChange = vi.fn(); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + onActiveSessionChanged: onChange, + }); + + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getActiveEditorDomTarget()).toBeNull(); + + const session = manager.activate(makeStubLocator()); + expect(session.kind).toBe('headerFooter'); + expect(session.locator.storyType).toBe('headerFooterPart'); + expect(manager.getActiveSession()).toBe(session); + expect(manager.getActiveEditorDomTarget()).toBe(dom); + expect(onChange).toHaveBeenLastCalledWith(session); + + manager.exit(); + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getActiveEditorDomTarget()).toBeNull(); + expect(session.isDisposed).toBe(true); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('disposes the previous session when a new session activates over it', () => { + const first = makeStubRuntime(makeStubEditor(document.createElement('div')), { + dispose: vi.fn(), + cacheable: false, + }); + const second = makeStubRuntime(makeStubEditor(document.createElement('div')), { + dispose: vi.fn(), + cacheable: false, + }); + + const runtimes = [first, second]; + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtimes.shift()!, + getMountContainer: () => container, + }); + + const s1 = manager.activate(makeStubLocator()); + expect(s1.isDisposed).toBe(false); + + manager.activate(makeStubLocator()); + expect(s1.isDisposed).toBe(true); + expect(first.dispose).toHaveBeenCalledTimes(1); + }); + + it('commits on exit when commitPolicy is onExit (default)', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + expect(commit).not.toHaveBeenCalled(); + + manager.exit(); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not commit on exit when commitPolicy is manual', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + manager.exit(); + expect(commit).not.toHaveBeenCalled(); + }); + + it('manual commit() invokes the runtime.commit callback', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + session.commit(); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('manual commit() prefers runtime.commitEditor with the session editor', () => { + const runtimeEditor = makeStubEditor(document.createElement('div')); + const sessionEditor = makeStubEditor(document.createElement('div')); + const commitEditor = vi.fn(); + const runtime = makeStubRuntime(runtimeEditor, { commitEditor }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: () => ({ editor: sessionEditor as unknown as Editor }), + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'manual' }); + session.commit(); + expect(commitEditor).toHaveBeenCalledWith(expect.anything(), sessionEditor); + }); + + it('does not dispose cacheable runtimes on exit', () => { + const editor = makeStubEditor(document.createElement('div')); + const dispose = vi.fn(); + const runtime = makeStubRuntime(editor, { dispose, cacheable: true }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + manager.exit(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('disposes non-cacheable runtimes on exit', () => { + const editor = makeStubEditor(document.createElement('div')); + const dispose = vi.fn(); + const runtime = makeStubRuntime(editor, { dispose, cacheable: false }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + manager.activate(makeStubLocator()); + manager.exit(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('commits on doc-changing transactions when commitPolicy is continuous', () => { + const commit = vi.fn(); + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor, { commit }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator(), { commitPolicy: 'continuous' }); + editor.emitTransaction?.(true); + editor.emitTransaction?.(false); + + expect(commit).toHaveBeenCalledTimes(1); + manager.exit(); + expect(session.isDisposed).toBe(true); + }); + + it('appends a hidden-host wrapper and tears it down on exit when an editorFactory is supplied', () => { + const dom = document.createElement('div'); + const freshEditor = makeStubEditor(dom); + const runtime = makeStubRuntime(makeStubEditor(null)); + + const factory = vi.fn((input) => { + // The factory should be handed a hidden host element to mount into. + expect(input.hostElement).toBeInstanceOf(HTMLElement); + expect(input.hostElement.classList.contains('presentation-editor__story-hidden-host')).toBe(true); + return { editor: freshEditor as unknown as Editor }; + }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: factory, + }); + + const session = manager.activate(makeStubLocator()); + expect(factory).toHaveBeenCalledTimes(1); + expect(session.hostWrapper).not.toBeNull(); + expect(session.hostWrapper?.parentNode).toBe(container); + expect(session.domTarget).toBe(dom); + + manager.exit(); + expect(session.hostWrapper?.parentNode).toBeNull(); + }); + + it('destroy() deactivates any active session', () => { + const editor = makeStubEditor(document.createElement('div')); + const runtime = makeStubRuntime(editor); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + }); + + const session = manager.activate(makeStubLocator()); + manager.destroy(); + expect(session.isDisposed).toBe(true); + expect(manager.getActiveSession()).toBeNull(); + }); + + it('throws a clear error when hidden-host activation has no mount container', () => { + const runtime = makeStubRuntime(makeStubEditor(document.createElement('div'))); + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => null, + editorFactory: () => ({ editor: makeStubEditor(document.createElement('div')) as unknown as Editor }), + }); + expect(() => manager.activate(makeStubLocator())).toThrow(/no mount container/); + }); + + it('allows runtime reuse without a mount container when preferHiddenHost is false', () => { + const dom = document.createElement('div'); + const runtime = makeStubRuntime(makeStubEditor(dom)); + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => null, + }); + + const session = manager.activate(makeStubLocator(), { preferHiddenHost: false }); + expect(session.editor).toBe(runtime.editor); + expect(session.hostWrapper).toBeNull(); + expect(session.domTarget).toBe(dom); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts new file mode 100644 index 0000000000..4335efa56e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts @@ -0,0 +1,322 @@ +/** + * StoryPresentationSessionManager + * + * Owns the active interactive editing session for a story-backed part + * (header, footer, or future note/endnote). This is the generalization of + * `HeaderFooterSessionManager`'s session-lifecycle responsibilities, split + * out from the header/footer region/layout code so future story kinds can + * reuse it. + * + * Responsibilities: + * - Resolve a {@link StoryLocator} to a {@link StoryRuntime} through the + * caller-supplied resolver (so the manager doesn't reach across the + * document-api-adapters package boundary directly). + * - Create a hidden off-screen host and mount a story editor into it when + * the runtime does not already have a visible editor we can reuse. + * - Expose the active editor's DOM as the target for + * `PresentationInputBridge`. + * - Commit and dispose on exit. + * + * What it deliberately does NOT do (left to callers / future phases): + * - Region discovery or section-aware slot materialization (lives in the + * header/footer-specific adapter). + * - Caret/selection rendering (Phase 3 of the plan). + * - Pointer hit-testing (lives in EditorInputManager / region providers). + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../Editor.js'; +import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; +import type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js'; +import { createStoryHiddenHost } from './createStoryHiddenHost.js'; + +/** + * Creates (or returns) the ProseMirror editor that should back an active + * session for a given runtime. May return a fresh editor mounted into a + * freshly-created hidden host, or the runtime's existing editor. + */ +export interface StorySessionEditorFactoryInput { + /** The resolved story runtime. */ + runtime: StoryRuntime; + /** The element the story editor should be mounted into, if headless. */ + hostElement: HTMLElement; + /** Activation-time options for the session being created. */ + activationOptions: ActivateStorySessionOptions; +} + +export interface StorySessionEditorFactoryResult { + /** The editor that should be used for the session. */ + editor: Editor; + /** + * Optional teardown to run when the session is disposed. Only set when + * the factory created a fresh editor; reused editors are owned elsewhere. + */ + dispose?: () => void; +} + +/** Factory used by the manager to obtain a mountable story editor. */ +export type StorySessionEditorFactory = (input: StorySessionEditorFactoryInput) => StorySessionEditorFactoryResult; + +/** + * Constructor options for {@link StoryPresentationSessionManager}. + */ +export interface StoryPresentationSessionManagerOptions { + /** + * Resolve a locator to a {@link StoryRuntime}. In production this wraps + * `resolveStoryRuntime(hostEditor, locator, { intent: 'write' })`; in + * tests it can be any mock. + */ + resolveRuntime: (locator: StoryLocator) => StoryRuntime; + + /** + * Returns the host element the session will mount into. Defaults to the + * container the session manager was given on construction, but may be + * overridden per session (e.g., a page-local overlay). + */ + getMountContainer: () => HTMLElement | null; + + /** + * Optional factory for creating the session editor. When omitted the + * manager uses the runtime's existing editor (appending the hidden host + * is still performed, but ProseMirror's DOM lives wherever the runtime + * originally placed it). Most callers will pass a factory that invokes + * `createStoryEditor` to mount a fresh editor into the hidden host. + */ + editorFactory?: StorySessionEditorFactory; + + /** + * Called after the active session changes (activate, exit, dispose). + * Consumers use this to notify `PresentationInputBridge`. + */ + onActiveSessionChanged?: (session: StoryPresentationSession | null) => void; +} + +/** + * Manages the lifecycle of a single active story-backed editing session. + * + * The first rollout assumes only one session is active at a time; if two + * activations overlap, the current session is disposed before the new one + * is activated. + */ +export class StoryPresentationSessionManager { + #options: StoryPresentationSessionManagerOptions; + #active: MutableStorySession | null = null; + + constructor(options: StoryPresentationSessionManagerOptions) { + this.#options = options; + } + + /** Returns the active session, or `null` if none is active. */ + getActiveSession(): StoryPresentationSession | null { + return this.#active; + } + + /** + * Returns the DOM element that should receive forwarded input events + * while a session is active, or `null` if there is no active session. + */ + getActiveEditorDomTarget(): HTMLElement | null { + return this.#active?.domTarget ?? null; + } + + /** + * Activate a session for the given locator. If a session is already + * active, it is disposed first. + */ + activate(locator: StoryLocator, options: ActivateStorySessionOptions = {}): StoryPresentationSession { + if (this.#active) this.exit(); + + const runtime = this.#options.resolveRuntime(locator); + if (runtime.kind === 'body') { + throw new Error('StoryPresentationSessionManager cannot host a body runtime.'); + } + + const preferHiddenHost = options.preferHiddenHost !== false; + const commitPolicy: StoryCommitPolicy = options.commitPolicy ?? 'onExit'; + + let hostWrapper: HTMLElement | null = null; + let editor = runtime.editor; + let factoryDispose: (() => void) | undefined; + let sessionBeforeDispose: (() => void) | undefined; + + if (preferHiddenHost && this.#options.editorFactory) { + const mountContainer = this.#options.getMountContainer(); + if (!mountContainer) { + throw new Error('StoryPresentationSessionManager: no mount container available for hidden host.'); + } + const doc = mountContainer.ownerDocument ?? document; + const width = options.hostWidthPx ?? mountContainer.clientWidth ?? 1; + const hidden = createStoryHiddenHost(doc, width, { + storyKey: runtime.storyKey, + storyKind: runtime.kind, + }); + mountContainer.appendChild(hidden.wrapper); + const factoryResult = this.#options.editorFactory({ + runtime, + hostElement: hidden.host, + activationOptions: options, + }); + editor = factoryResult.editor; + factoryDispose = factoryResult.dispose; + hostWrapper = hidden.wrapper; + } + + if (commitPolicy === 'continuous' && typeof editor.on === 'function') { + const handleTransaction = ({ transaction }: { transaction?: { docChanged?: boolean } }) => { + if (transaction?.docChanged) { + session.commit(); + } + }; + editor.on('transaction', handleTransaction); + sessionBeforeDispose = () => { + editor.off?.('transaction', handleTransaction); + }; + } + + const domTarget = (editor.view?.dom as HTMLElement | undefined) ?? hostWrapper ?? null; + + const session = new MutableStorySession({ + locator, + runtime, + editor, + kind: runtime.kind as Exclude, + hostWrapper, + domTarget, + commitPolicy, + shouldDisposeRuntime: runtime.cacheable === false, + beforeDispose: sessionBeforeDispose, + teardown: () => { + try { + factoryDispose?.(); + } finally { + if (hostWrapper && hostWrapper.parentNode) { + hostWrapper.parentNode.removeChild(hostWrapper); + } + } + }, + }); + + this.#active = session; + this.#options.onActiveSessionChanged?.(session); + return session; + } + + /** + * Deactivate the current session. Safe to call when no session is active. + * Commits (if policy says so) and disposes the hidden host. + */ + exit(): void { + const active = this.#active; + if (!active) return; + this.#active = null; + try { + active.dispose(); + } finally { + this.#options.onActiveSessionChanged?.(null); + } + } + + /** + * Dispose the manager and any active session. + */ + destroy(): void { + this.exit(); + } +} + +// --------------------------------------------------------------------------- +// Mutable session record — the concrete object that implements the +// StoryPresentationSession contract exposed to callers. +// --------------------------------------------------------------------------- + +interface MutableStorySessionInit { + locator: StoryLocator; + runtime: StoryRuntime; + editor: Editor; + kind: Exclude; + hostWrapper: HTMLElement | null; + domTarget: HTMLElement | null; + commitPolicy: StoryCommitPolicy; + shouldDisposeRuntime: boolean; + afterActivate?: () => void; + beforeDispose?: () => void; + teardown: () => void; +} + +class MutableStorySession implements StoryPresentationSession { + readonly locator: StoryLocator; + readonly runtime: StoryRuntime; + readonly editor: Editor; + readonly kind: Exclude; + readonly hostWrapper: HTMLElement | null; + readonly domTarget: HTMLElement | null; + readonly commitPolicy: StoryCommitPolicy; + + #disposed = false; + #shouldDisposeRuntime: boolean; + #beforeDispose?: () => void; + #teardown: () => void; + + constructor(init: MutableStorySessionInit) { + this.locator = init.locator; + this.runtime = init.runtime; + this.editor = init.editor; + this.kind = init.kind; + this.hostWrapper = init.hostWrapper; + this.domTarget = init.domTarget; + this.commitPolicy = init.commitPolicy; + this.#shouldDisposeRuntime = init.shouldDisposeRuntime; + this.#beforeDispose = init.beforeDispose; + this.#teardown = init.teardown; + init.afterActivate?.(); + } + + get isDisposed(): boolean { + return this.#disposed; + } + + commit(): void { + if (this.#disposed) return; + const hostEditor = getHostEditor(this.editor) ?? getHostEditor(this.runtime.editor) ?? this.runtime.editor; + if (this.runtime.commitEditor) { + this.runtime.commitEditor(hostEditor, this.editor); + return; + } + this.runtime.commit?.(hostEditor); + } + + dispose(): void { + if (this.#disposed) return; + try { + if (this.commitPolicy === 'onExit') this.commit(); + } finally { + this.#disposed = true; + try { + this.#beforeDispose?.(); + } finally { + try { + if (this.#shouldDisposeRuntime) { + this.runtime.dispose?.(); + } + } finally { + this.#teardown(); + } + } + } + } +} + +/** + * Retrieve the parent/host editor from a story editor when present. + * + * `createStoryEditor` stores the parent editor as a non-enumerable + * `parentEditor` getter on `options`. When present we prefer it so the + * commit callback runs against the body editor the runtime was resolved + * for. + */ +function getHostEditor(editor: Editor): Editor | null { + const options = editor.options as Partial<{ parentEditor: Editor }>; + return options?.parentEditor ?? null; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts new file mode 100644 index 0000000000..ff52c55c61 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + createStoryHiddenHost, + STORY_HIDDEN_HOST_CLASS, + STORY_HIDDEN_HOST_WRAPPER_CLASS, +} from './createStoryHiddenHost.js'; + +describe('createStoryHiddenHost', () => { + let doc: Document; + + beforeEach(() => { + doc = document.implementation.createHTMLDocument('test'); + }); + + it('returns wrapper + host with body-hidden-host invariants', () => { + const { wrapper, host } = createStoryHiddenHost(doc, 800); + + // Wrapper keeps scroll-isolation invariants from createHiddenHost + expect(wrapper.style.position).toBe('fixed'); + expect(wrapper.style.overflow).toBe('hidden'); + expect(wrapper.style.width).toBe('1px'); + expect(wrapper.style.height).toBe('1px'); + + // Host must remain focusable + in the a11y tree + expect(host.style.visibility).not.toBe('hidden'); + expect(host.hasAttribute('aria-hidden')).toBe(false); + }); + + it('adds the story-specific class markers', () => { + const { wrapper, host } = createStoryHiddenHost(doc, 800); + expect(wrapper.classList.contains(STORY_HIDDEN_HOST_WRAPPER_CLASS)).toBe(true); + expect(host.classList.contains(STORY_HIDDEN_HOST_CLASS)).toBe(true); + }); + + it('propagates storyKey/storyKind as data attributes when provided', () => { + const { host } = createStoryHiddenHost(doc, 800, { + storyKey: 'story:headerFooterPart:rId7', + storyKind: 'headerFooter', + }); + expect(host.getAttribute('data-story-key')).toBe('story:headerFooterPart:rId7'); + expect(host.getAttribute('data-story-kind')).toBe('headerFooter'); + }); + + it('omits data attributes when options are not supplied', () => { + const { host } = createStoryHiddenHost(doc, 800); + expect(host.hasAttribute('data-story-key')).toBe(false); + expect(host.hasAttribute('data-story-kind')).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts new file mode 100644 index 0000000000..d733ff815e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts @@ -0,0 +1,55 @@ +/** + * Hidden-host factory for story-backed presentation editing sessions. + * + * Story editors need the same scroll-isolated, off-screen, focusable host + * as the body editor. Rather than re-implementing that contract, this helper + * delegates to {@link createHiddenHost} and adds a story-specific className + * so the two hosts are easy to tell apart in DevTools and in tests. + * + * The returned wrapper must be appended to the DOM before the story editor + * is created, and removed (or left for disposal) when the session exits. + */ + +import { createHiddenHost, type HiddenHostElements } from '../dom/HiddenHost.js'; + +/** Class name added to the story hidden host for introspection/testing. */ +export const STORY_HIDDEN_HOST_CLASS = 'presentation-editor__story-hidden-host'; + +/** Class name added to the story wrapper for introspection/testing. */ +export const STORY_HIDDEN_HOST_WRAPPER_CLASS = 'presentation-editor__story-hidden-host-wrapper'; + +/** + * Options for creating a story hidden host. + */ +export interface CreateStoryHiddenHostOptions { + /** + * Identifier used as `data-story-key` on the host. Purely informational — + * makes it trivial to see in DevTools which story a hidden host belongs to. + */ + storyKey?: string; + /** + * Identifier used as `data-story-kind` on the host (e.g., `"headerFooter"`, + * `"note"`). + */ + storyKind?: string; +} + +/** + * Creates an off-screen hidden host for a story editor. + * + * The host preserves the same accessibility invariants as the body hidden + * host (focusable, present in a11y tree, not `aria-hidden`, + * not `visibility: hidden`). + */ +export function createStoryHiddenHost( + doc: Document, + widthPx: number, + options: CreateStoryHiddenHostOptions = {}, +): HiddenHostElements { + const { wrapper, host } = createHiddenHost(doc, widthPx); + wrapper.classList.add(STORY_HIDDEN_HOST_WRAPPER_CLASS); + host.classList.add(STORY_HIDDEN_HOST_CLASS); + if (options.storyKey) host.setAttribute('data-story-key', options.storyKey); + if (options.storyKind) host.setAttribute('data-story-kind', options.storyKind); + return { wrapper, host }; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts new file mode 100644 index 0000000000..b9a5164604 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts @@ -0,0 +1,22 @@ +/** + * Public entry point for the story-session module. + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +export type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js'; + +export { + StoryPresentationSessionManager, + type StoryPresentationSessionManagerOptions, + type StorySessionEditorFactory, + type StorySessionEditorFactoryInput, + type StorySessionEditorFactoryResult, +} from './StoryPresentationSessionManager.js'; + +export { + createStoryHiddenHost, + STORY_HIDDEN_HOST_CLASS, + STORY_HIDDEN_HOST_WRAPPER_CLASS, + type CreateStoryHiddenHostOptions, +} from './createStoryHiddenHost.js'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts new file mode 100644 index 0000000000..62e0de00e7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts @@ -0,0 +1,137 @@ +/** + * Types for story-backed presentation editing sessions. + * + * A "story presentation session" is an interactive layout-mode editing + * context for a non-body story (header, footer, footnote, endnote, or a + * future content part). It holds: + * + * - the resolved {@link StoryLocator} + {@link StoryRuntime} for the story + * - the hidden off-screen DOM host that backs the story's ProseMirror editor + * - the presentation-editor side metadata needed to render caret/selection + * overlays and commit back through the parts system on exit + * + * This is the generalization of what `HeaderFooterSessionManager` does today + * for headers/footers, but intentionally story-kind agnostic so future + * callers (e.g. notes) can reuse the same lifecycle. + * + * See `plans/story-backed-parts-presentation-editing.md`. + */ + +import type { Editor } from '../../Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; +import type { StoryRuntime, StoryKind } from '../../../document-api-adapters/story-runtime/story-types.js'; + +/** + * How the session's edits should be persisted back to the canonical part. + * + * - `'onExit'` — commit once when the session ends (default). + * - `'continuous'` — commit on every PM transaction. Reserved for future + * collaborative or autosave-style behaviors; not required for the initial + * header/footer rollout. + * - `'manual'` — caller invokes {@link StoryPresentationSession.commit}. + */ +export type StoryCommitPolicy = 'onExit' | 'continuous' | 'manual'; + +/** + * A single active interactive editing session for a story-backed part. + * + * Sessions are created by {@link StoryPresentationSessionManager.activate} + * and disposed by {@link StoryPresentationSessionManager.exit}. While active, + * the session's editor DOM is the target of `PresentationInputBridge` and + * rendered content is still painted by the layout engine. + */ +export interface StoryPresentationSession { + /** The locator that was resolved to produce this session. */ + readonly locator: StoryLocator; + + /** The resolved story runtime (owns the editor, commit callback, dispose). */ + readonly runtime: StoryRuntime; + + /** + * The ProseMirror editor that backs this story while the session is + * active. For most non-body stories this is a freshly-created headless + * editor; for live PresentationEditor sub-editors it may be reused. + */ + readonly editor: Editor; + + /** Broad category of the story (headerFooter, note, body is not valid here). */ + readonly kind: Exclude; + + /** + * Off-screen wrapper element appended to the DOM. Removed on exit. + * May be `null` if the session reuses a pre-existing mounted editor + * whose DOM lifecycle is managed elsewhere. + */ + readonly hostWrapper: HTMLElement | null; + + /** + * The element that ProseMirror writes its visible DOM into — this is what + * `PresentationInputBridge` forwards input events to. For sessions that + * own a hidden host, this is the inner host element. For reused live + * sub-editors, it is `editor.view.dom` at activation time. + */ + readonly domTarget: HTMLElement | null; + + /** Commit policy — how changes persist back to the canonical part. */ + readonly commitPolicy: StoryCommitPolicy; + + /** Whether the session has been deactivated. Set to `true` by the manager on exit. */ + readonly isDisposed: boolean; + + /** + * Commit the session's changes back through the story runtime's commit + * callback. No-op if the runtime has no commit hook (e.g., body runtime). + */ + commit(): void; + + /** + * Tear down the session: commit if policy says so, dispose the hidden + * host (if owned), and invoke {@link StoryRuntime.dispose} when present. + * After calling this, the session's `isDisposed` is `true` and no further + * commits are performed. + */ + dispose(): void; +} + +/** + * Options passed when activating a session. + */ +export interface ActivateStorySessionOptions { + /** Override commit policy. Defaults to `'onExit'`. */ + commitPolicy?: StoryCommitPolicy; + + /** + * Explicit hidden-host width in layout pixels. + * + * When omitted, the session manager falls back to the mount container width. + */ + hostWidthPx?: number; + + /** + * Optional session-scoped editor context consumed by the editor factory. + * + * This is how visible story context such as page number, visible region size, + * and surface kind flows into a hidden-host editor instance without baking it + * into the runtime cache key. + */ + editorContext?: { + availableWidth?: number; + availableHeight?: number; + currentPageNumber?: number; + totalPageCount?: number; + surfaceKind?: 'header' | 'footer' | 'note' | 'endnote'; + }; + + /** + * When `true`, the manager must create its own hidden host and story + * editor instead of reusing any live sub-editor that the runtime might + * already have mounted visibly. This is the canonical mode under the + * `useHiddenHostForStoryParts` flag. + * + * When `false`, the manager may reuse whatever editor the runtime + * resolves (legacy behavior). + * + * @default true + */ + preferHiddenHost?: boolean; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 525cb6d327..6b785c624e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -261,4 +261,191 @@ describe('HeaderFooterSessionManager', () => { expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); }); + + it('activates header editing through the story-session manager without creating an overlay host', async () => { + const pageElement = document.createElement('div'); + pageElement.dataset.pageIndex = '0'; + painterHost.appendChild(pageElement); + + const overlayManager = { + showEditingOverlay: vi.fn(() => ({ + success: true, + editorHost: document.createElement('div'), + reason: null, + })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + }; + + const storyEditor = createHeaderFooterEditorStub(document.createElement('div')); + const activate = vi.fn(() => ({ editor: storyEditor })); + const exit = vi.fn(); + const descriptor = { id: 'rId-header-default', variant: 'default' }; + + mockInitHeaderFooterRegistry.mockReturnValue({ + overlayManager, + headerFooterIdentifier: null, + headerFooterManager: { + getDescriptorById: vi.fn(() => descriptor), + getDescriptors: vi.fn(() => [descriptor]), + ensureEditor: vi.fn(), + refresh: vi.fn(), + destroy: vi.fn(), + }, + headerFooterAdapter: null, + cleanups: [], + }); + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({ zoom: 1 })), + getPageElement: vi.fn(() => pageElement), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 3), + getStorySessionManager: vi.fn(() => ({ activate, exit })), + }); + + manager.initialize(); + + const region = { + kind: 'header' as const, + headerFooterRefId: 'rId-header-default', + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + pageIndex: 0, + pageNumber: 1, + localX: 36, + localY: 24, + width: 480, + height: 72, + }; + manager.headerRegions.set(region.pageIndex, region); + + manager.activateRegion(region); + await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor)); + + expect(overlayManager.showEditingOverlay).not.toHaveBeenCalled(); + expect(activate).toHaveBeenCalledWith( + { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId-header-default', + }, + expect.objectContaining({ + commitPolicy: 'onExit', + preferHiddenHost: true, + hostWidthPx: 480, + editorContext: expect.objectContaining({ + availableWidth: 480, + availableHeight: 72, + currentPageNumber: 1, + totalPageCount: 3, + surfaceKind: 'header', + }), + }), + ); + }); + + it('exits the active story session when leaving header/footer mode', async () => { + const pageElement = document.createElement('div'); + pageElement.dataset.pageIndex = '0'; + painterHost.appendChild(pageElement); + + const overlayManager = { + showEditingOverlay: vi.fn(), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + }; + + const storyEditor = createHeaderFooterEditorStub(document.createElement('div')); + const activate = vi.fn(() => ({ editor: storyEditor })); + const exit = vi.fn(); + const descriptor = { id: 'rId-header-default', variant: 'default' }; + + mockInitHeaderFooterRegistry.mockReturnValue({ + overlayManager, + headerFooterIdentifier: null, + headerFooterManager: { + getDescriptorById: vi.fn(() => descriptor), + getDescriptors: vi.fn(() => [descriptor]), + ensureEditor: vi.fn(), + refresh: vi.fn(), + destroy: vi.fn(), + }, + headerFooterAdapter: null, + cleanups: [], + }); + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({ zoom: 1 })), + getPageElement: vi.fn(() => pageElement), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + getStorySessionManager: vi.fn(() => ({ activate, exit })), + }); + + manager.initialize(); + + const region = { + kind: 'header' as const, + headerFooterRefId: 'rId-header-default', + sectionType: 'default', + sectionId: 'section-0', + sectionIndex: 0, + pageIndex: 0, + pageNumber: 1, + localX: 36, + localY: 24, + width: 480, + height: 72, + }; + manager.headerRegions.set(region.pageIndex, region); + + manager.activateRegion(region); + await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor)); + + manager.exitMode(); + expect(exit).toHaveBeenCalledTimes(1); + expect(manager.session.mode).toBe('body'); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index e442a6f34e..d3b9b9211d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -201,6 +201,21 @@ export type PresentationEditorOptions = ConstructorParameters[0] * @default false */ allowSelectionInViewMode?: boolean; + /** + * Route interactive story-backed part editing (headers, footers, and future + * story parts such as notes) through the body-style presentation editing + * architecture: a hidden off-screen ProseMirror host plus layout-engine + * rendering. When `false`, header/footer editing continues to mount a + * visible child PM overlay via {@link EditorOverlayManager}. + * + * This is a transitional flag governing the rollout of the story-backed + * parts presentation editing refactor. See + * `plans/story-backed-parts-presentation-editing.md`. + * + * @default false + * @experimental + */ + useHiddenHostForStoryParts?: boolean; }; /** diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts index f3ebd4cfa1..953d86e870 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts @@ -157,6 +157,8 @@ export function resolveHeaderFooterSlotRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, isolatedEditor, { commit: buildSlotCommit(locator, isolatedEditor, effectiveRefId, true), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, true)(hostEditor), }); } @@ -170,6 +172,8 @@ export function resolveHeaderFooterSlotRuntime( editor: liveEditor, kind: 'headerFooter', commit: buildSlotCommit(locator, liveEditor, effectiveRefId, false), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, false)(hostEditor), }; } @@ -179,6 +183,8 @@ export function resolveHeaderFooterSlotRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, storyEditor, { commit: buildSlotCommit(locator, storyEditor, effectiveRefId, false), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, effectiveRefId, false)(hostEditor), }); } @@ -293,6 +299,9 @@ export function resolveHeaderFooterPartRuntime( commit: (hostEditor: Editor) => { exportAndSyncCache(hostEditor, liveEditor, locator.refId, hfType); }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + exportAndSyncCache(hostEditor, sessionEditor, locator.refId, hfType); + }, }; } @@ -302,6 +311,9 @@ export function resolveHeaderFooterPartRuntime( commit: (hostEditor: Editor) => { exportAndSyncCache(hostEditor, storyEditor, locator.refId, hfType); }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + exportAndSyncCache(hostEditor, sessionEditor, locator.refId, hfType); + }, }); } @@ -314,10 +326,8 @@ export function resolveHeaderFooterPartRuntime( * converter's PM JSON cache. * * The OOXML write goes through `exportSubEditorToPart` → `mutatePart`. - * The PM cache update is needed because the part descriptor's afterCommit - * hook skips re-import for `SOURCE_HEADER_FOOTER_LOCAL` (it assumes the - * UI blur path already refreshed the cache). The headless document-api - * path bypasses that handler, so we must update the cache explicitly. + * The PM cache update keeps the converter's header/footer collections in sync + * immediately for the in-memory runtime that performed the export. */ function exportAndSyncCache(hostEditor: Editor, subEditor: Editor, refId: string, hfType: 'header' | 'footer'): void { exportSubEditorToPart(hostEditor, subEditor, refId, hfType); @@ -364,6 +374,7 @@ function createOwnedHeaderFooterRuntime( editor: Editor, options: { commit: (hostEditor: Editor) => void; + commitEditor?: (hostEditor: Editor, storyEditor: Editor) => void; }, ): StoryRuntime { return { @@ -374,6 +385,7 @@ function createOwnedHeaderFooterRuntime( cacheable: false, dispose: () => editor.destroy(), commit: options.commit, + commitEditor: options.commitEditor, }; } @@ -408,6 +420,8 @@ function createMissingSlotWriteRuntime( return createOwnedHeaderFooterRuntime(locator, storyKey, pendingEditor, { commit: buildSlotCommit(locator, pendingEditor, null, true), + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => + buildSlotCommit(locator, sessionEditor, null, true)(hostEditor), }); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index cdbfab8470..f9a0504461 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -88,63 +88,75 @@ export function resolveNoteRuntime(hostEditor: Editor, locator: NoteStoryLocator kind: 'note', dispose: () => storyEditor.destroy(), commit: (hostEditor: Editor) => { - const noteType = isFootnote ? 'footnote' : 'endnote'; - const notesConfig = getNotesConfig(noteType); - - // Try rich export via converter's exportToXmlJson (preserves formatting) - const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; - const pmJson = - typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); - - if (conv?.exportToXmlJson && pmJson) { - let ooxmlElements: unknown[] | null = null; - try { - const { result } = conv.exportToXmlJson({ - data: pmJson, - editor: storyEditor, - editorSchema: storyEditor.schema, - isHeaderFooter: true, - comments: [], - commentDefinitions: [], - }); - // result.elements[0] is the body wrapper; its children are all - // content elements (paragraphs, tables, etc.). Keep all of them - // so tables and other non-paragraph content survive the commit. - const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; - ooxmlElements = body?.elements ?? null; - } catch { - // Fall through to plain-text fallback - } - - if (ooxmlElements && ooxmlElements.length > 0) { - mutatePart({ - editor: hostEditor, - partId: notesConfig.partId, - operation: 'mutate', - source: `story-runtime:commit:${locator.storyType}`, - mutate({ part }) { - updateNoteContentFromOoxml(part, notesConfig, noteId, ooxmlElements!); - }, - }); - return; - } - } - - // Fallback: plain-text export (loses formatting) - const doc = storyEditor.state.doc; - const text = doc.textBetween(0, doc.content.size, '\n', '\n'); + commitNoteRuntime(hostEditor, storyEditor, locator, isFootnote); + }, + commitEditor: (hostEditor: Editor, sessionEditor: Editor) => { + commitNoteRuntime(hostEditor, sessionEditor, locator, isFootnote); + }, + }; +} +function commitNoteRuntime( + hostEditor: Editor, + storyEditor: Editor, + locator: NoteStoryLocator, + isFootnote: boolean, +): void { + const noteType = isFootnote ? 'footnote' : 'endnote'; + const notesConfig = getNotesConfig(noteType); + + // Try rich export via converter's exportToXmlJson (preserves formatting) + const conv = (hostEditor as unknown as { converter?: ConverterWithNoteExport }).converter; + const pmJson = + typeof storyEditor.getUpdatedJson === 'function' ? storyEditor.getUpdatedJson() : storyEditor.getJSON(); + + if (conv?.exportToXmlJson && pmJson) { + let ooxmlElements: unknown[] | null = null; + try { + const { result } = conv.exportToXmlJson({ + data: pmJson, + editor: storyEditor, + editorSchema: storyEditor.schema, + isHeaderFooter: true, + comments: [], + commentDefinitions: [], + }); + // result.elements[0] is the body wrapper; its children are all + // content elements (paragraphs, tables, etc.). Keep all of them + // so tables and other non-paragraph content survive the commit. + const body = result?.elements?.[0] as { elements?: unknown[] } | undefined; + ooxmlElements = body?.elements ?? null; + } catch { + // Fall through to plain-text fallback + } + + if (ooxmlElements && ooxmlElements.length > 0) { mutatePart({ editor: hostEditor, partId: notesConfig.partId, operation: 'mutate', source: `story-runtime:commit:${locator.storyType}`, mutate({ part }) { - updateNoteElement(part, notesConfig, noteId, text); + updateNoteContentFromOoxml(part, notesConfig, locator.noteId, ooxmlElements!); }, }); + return; + } + } + + // Fallback: plain-text export (loses formatting) + const doc = storyEditor.state.doc; + const text = doc.textBetween(0, doc.content.size, '\n', '\n'); + + mutatePart({ + editor: hostEditor, + partId: notesConfig.partId, + operation: 'mutate', + source: `story-runtime:commit:${locator.storyType}`, + mutate({ part }) { + updateNoteElement(part, notesConfig, locator.noteId, text); }, - }; + }); } // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts index e2f1276841..f115c2a03c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/story-types.ts @@ -67,4 +67,18 @@ export interface StoryRuntime { * @param hostEditor - The host (body) editor, needed for parts runtime access. */ commit?: (hostEditor: Editor) => void; + + /** + * Persists the provided editor state back to the canonical OOXML part. + * + * This is the session-aware variant of {@link commit}. It is used when the + * interactive editing session mounts a different editor instance than the + * runtime originally resolved (for example, a hidden-host editor created from + * a cached headless runtime). When omitted, callers may fall back to + * {@link commit}, which commits the runtime's own editor. + * + * @param hostEditor - The host (body) editor. + * @param storyEditor - The editor whose state should be exported. + */ + commitEditor?: (hostEditor: Editor, storyEditor: Editor) => void; } diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 6e75e4aed0..1bbea7e076 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -719,6 +719,7 @@ const editorOptions = (doc) => { viewOptions: proxy.$superdoc.config.viewOptions, contained: proxy.$superdoc.config.contained, linkPopoverResolver: proxy.$superdoc.config.modules?.links?.popoverResolver, + useHiddenHostForStoryParts: proxy.$superdoc.config.useHiddenHostForStoryParts, layoutEngineOptions: useLayoutEngine ? { ...(proxy.$superdoc.config.layoutEngineOptions || {}), diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 59af504f54..a68cfacb22 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -672,6 +672,7 @@ * uiDisplayFallbackFont: '"Inter", Arial, sans-serif' * @property {boolean} [isDev] Whether the SuperDoc is in development mode * @property {boolean} [disablePiniaDevtools=false] Disable Pinia/Vue devtools plugin setup for this SuperDoc instance (useful in non-Vue hosts) + * @property {boolean} [useHiddenHostForStoryParts=false] Route story-backed part editing (headers, footers, and future story parts) through PresentationEditor's hidden-host editing path instead of the legacy mounted overlay editor * @property {SuperDocLayoutEngineOptions} [layoutEngineOptions] Layout engine overrides passed through to PresentationEditor (page size, margins, virtualization, zoom, debug label, etc.) * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 774334c642..f44d8e0945 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -27,6 +27,7 @@ interface HarnessConfig { showSelection?: boolean; allowSelectionInViewMode?: boolean; documentMode?: 'editing' | 'viewing' | 'suggesting'; + useHiddenHostForStoryParts?: boolean; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; @@ -63,6 +64,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); if (config.allowSelectionInViewMode) params.set('allowSelectionInViewMode', '1'); if (config.documentMode) params.set('documentMode', config.documentMode); + if (config.useHiddenHostForStoryParts) params.set('useHiddenHostForStoryParts', '1'); const qs = params.toString(); return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index d83167e1db..71b79da0bf 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -32,6 +32,7 @@ const replacementsParam = params.get('replacements'); const replacements: 'paired' | 'independent' = replacementsParam === 'independent' ? 'independent' : 'paired'; const allowSelectionInViewMode = params.get('allowSelectionInViewMode') === '1'; const documentMode = params.get('documentMode') as 'editing' | 'viewing' | 'suggesting' | null; +const useHiddenHostForStoryParts = params.get('useHiddenHostForStoryParts') === '1'; const contentOverride = params.get('contentOverride') ?? undefined; const overrideType = (params.get('overrideType') as OverrideType | null) ?? undefined; @@ -72,6 +73,7 @@ function init(file?: File, content?: ContentOverrideInput) { const config: SuperDocConfig = { selector: '#editor', useLayoutEngine: layout, + useHiddenHostForStoryParts, telemetry: { enabled: false }, onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; diff --git a/tests/behavior/tests/headers/double-click-edit-header.spec.ts b/tests/behavior/tests/headers/double-click-edit-header.spec.ts index 6a2808dd6a..74b841f845 100644 --- a/tests/behavior/tests/headers/double-click-edit-header.spec.ts +++ b/tests/behavior/tests/headers/double-click-edit-header.spec.ts @@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +test.use({ config: { useHiddenHostForStoryParts: true, showCaret: true, showSelection: true } }); test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -23,21 +24,16 @@ test('double-click header to enter edit mode, type, and exit', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the header - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); + // Editing runs through the hidden-host PM while the visible header remains painted. await superdoc.page.keyboard.press('End'); - // Use insertText instead of type() to avoid character-by-character key events - // which may trigger PM shortcuts await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); - - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await expect(header).toContainText('Edited'); // Press Escape to exit header edit mode await superdoc.page.keyboard.press('Escape'); @@ -64,19 +60,15 @@ test('double-click footer to enter edit mode, type, and exit', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - // After dblclick, SuperDoc creates a separate editor host for the footer - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); - // Focus the PM editor inside the host, select all, move to end, then insert text - const pm = editorHost.locator('.ProseMirror'); - await pm.click(); await superdoc.page.keyboard.press('End'); await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); - - // Editor host should contain the typed text - await expect(editorHost).toContainText('Edited'); + await expect(footer).toContainText('Edited'); // Press Escape to exit footer edit mode await superdoc.page.keyboard.press('Escape'); diff --git a/tests/behavior/tests/headers/header-footer-line-height.spec.ts b/tests/behavior/tests/headers/header-footer-line-height.spec.ts index 89bbb5a138..d01e2cc95d 100644 --- a/tests/behavior/tests/headers/header-footer-line-height.spec.ts +++ b/tests/behavior/tests/headers/header-footer-line-height.spec.ts @@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +test.use({ config: { useHiddenHostForStoryParts: true, showCaret: true, showSelection: true } }); test('header editor uses line-height 1, not the default 1.2', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); @@ -21,15 +22,10 @@ test('header editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror element inside the header editor should have lineHeight: 1 - // (matching OOXML Header style w:line="240" w:lineRule="auto" = 240/240 = 1.0) - const pm = editorHost.locator('.ProseMirror'); - await expect(pm).toHaveCSS('line-height', /^\d+(\.\d+)?px$/); - - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -47,11 +43,10 @@ test('footer editor uses line-height 1, not the default 1.2', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - const pm = editorHost.locator('.ProseMirror'); - const lineHeight = await pm.evaluate((el) => el.style.lineHeight); + const pm = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror') + .first(); + const lineHeight = await pm.evaluate((el) => (el as HTMLElement).style.lineHeight); expect(lineHeight).toBe('1'); }); @@ -68,12 +63,14 @@ test('body editor still uses default line-height 1.2', async ({ superdoc }) => { expect(lineHeight).toBe('1.2'); }); -test('header content is not clipped when entering edit mode', async ({ superdoc }) => { +test('header content remains visible while hidden-host editing is active', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); const header = superdoc.page.locator('.superdoc-page-header').first(); await header.waitFor({ state: 'visible', timeout: 15_000 }); + const beforeBox = await header.boundingBox(); + expect(beforeBox).toBeTruthy(); // Double-click to enter header edit mode const box = await header.boundingBox(); @@ -81,20 +78,13 @@ test('header content is not clipped when entering edit mode', async ({ superdoc await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); - const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); - await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); - - // The ProseMirror content should not overflow the editor host container - const overflow = await editorHost.evaluate((host) => { - const pm = host.querySelector('.ProseMirror') as HTMLElement; - if (!pm) return { error: 'no PM' }; - return { - pmScrollHeight: pm.scrollHeight, - pmOffsetHeight: pm.offsetHeight, - hostHeight: host.offsetHeight, - isOverflowing: pm.scrollHeight > host.offsetHeight, - }; - }); - expect(overflow).not.toHaveProperty('error'); - expect(overflow.isOverflowing).toBe(false); + const storyHost = superdoc.page + .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') + .first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + const afterBox = await header.boundingBox(); + expect(afterBox).toBeTruthy(); + expect(afterBox!.height).toBeGreaterThan(0); + expect(Math.abs((afterBox?.height ?? 0) - (beforeBox?.height ?? 0))).toBeLessThan(1); }); From 735e19db244cafc15bc04bd533d103729c24b627 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 16 Apr 2026 15:37:55 -0700 Subject: [PATCH 02/16] feat: add footnotes editing surface --- .../header-footer/EditorOverlayManager.ts | 3 + .../presentation-editor/PresentationEditor.ts | 413 +++++++++++++++++- .../HeaderFooterSessionManager.ts | 7 + .../pointer-events/EditorInputManager.ts | 127 +++++- .../EditorInputManager.footnoteClick.test.ts | 56 ++- 5 files changed, 578 insertions(+), 28 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts index 1bc8a1a8d3..cbe9996dfb 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/EditorOverlayManager.ts @@ -192,6 +192,9 @@ export class EditorOverlayManager { // Find the editor container (first child with super-editor class) const editorContainer = editorHost.querySelector('.super-editor'); if (editorContainer instanceof HTMLElement) { + // Reset any stale transform from prior footer sessions before + // reapplying the top offset for the current region. + editorContainer.style.transform = ''; // Instead of top: 0, position from the calculated offset editorContainer.style.top = `${contentOffset}px`; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 43e55977d4..609c70f874 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -74,6 +74,7 @@ import { DragDropManager } from './input/DragDropManager.js'; import { processAndInsertImageFile } from '@extensions/image/imageHelpers/processAndInsertImageFile.js'; import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionManager.js'; import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js'; +import type { StoryPresentationSession } from './story-session/types.js'; import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.js'; @@ -127,6 +128,37 @@ type ThreadAnchorScrollPlan = { achievedClientY: number; applyScroll: (behavior: ScrollBehavior) => void; }; + +type RenderedNoteTarget = { + storyType: 'footnote' | 'endnote'; + noteId: string; +}; + +type NoteLayoutContext = { + target: RenderedNoteTarget; + blocks: FlowBlock[]; + measures: Measure[]; + firstPageIndex: number; + hostWidthPx: number; +}; + +function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { + if (typeof blockId !== 'string' || blockId.length === 0) { + return null; + } + + if (blockId.startsWith('footnote-')) { + const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('__sd_semantic_footnote-')) { + const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + return null; +} import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; import { DOM_CLASS_NAMES, buildSdtBlockSelector } from '@superdoc/dom-contract'; import { @@ -389,6 +421,8 @@ export class PresentationEditor extends EventEmitter { #a11yLastAnnouncedSelectionKey: string | null = null; #headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null; #headerFooterEditor: Editor | null = null; + #storySessionSelectionHandler: ((...args: unknown[]) => void) | null = null; + #storySessionEditor: Editor | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; pmStart: number; @@ -1112,6 +1146,21 @@ export class PresentationEditor extends EventEmitter { return activeHfEditor; } + #getActiveStorySession(): StoryPresentationSession | null { + return this.#storySessionManager?.getActiveSession() ?? null; + } + + #getActiveNoteStorySession(): StoryPresentationSession | null { + const session = this.#getActiveStorySession(); + if (!session || session.kind !== 'note') { + return null; + } + if (session.locator.storyType !== 'footnote' && session.locator.storyType !== 'endnote') { + return null; + } + return session; + } + /** * Access the generic story-session manager when the * {@link PresentationEditorOptions.useHiddenHostForStoryParts} rollout @@ -1605,10 +1654,14 @@ export class PresentationEditor extends EventEmitter { let usedDomRects = false; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; + const activeNoteSession = this.#getActiveNoteStorySession(); const layoutRectSource = () => { if (sessionMode !== 'body') { return this.#computeHeaderFooterSelectionRects(start, end); } + if (activeNoteSession) { + return this.#computeNoteSelectionRects(start, end); + } const domRects = this.#computeSelectionRectsFromDom(start, end); if (domRects != null) { usedDomRects = true; @@ -1633,7 +1686,7 @@ export class PresentationEditor extends EventEmitter { let domCaretStart: { pageIndex: number; x: number; y: number } | null = null; let domCaretEnd: { pageIndex: number; x: number; y: number } | null = null; const pageDelta: Record = {}; - if (!usedDomRects) { + if (!usedDomRects && !activeNoteSession) { // Geometry fallback path: apply a small DOM-based delta to reduce drift. try { domCaretStart = this.#computeDomCaretPageLocal(start); @@ -2088,6 +2141,34 @@ export class PresentationEditor extends EventEmitter { return hit; } + const noteContext = this.#buildActiveNoteLayoutContext(); + if (noteContext) { + const rawHit = + resolvePointerPositionHit({ + layout: this.#layoutState.layout, + blocks: noteContext.blocks, + measures: noteContext.measures, + containerPoint: normalized, + domContainer: this.#viewportHost, + clientX, + clientY, + geometryHelper: this.#pageGeometryHelper ?? undefined, + }) ?? null; + if (!rawHit) { + return null; + } + + const doc = this.getActiveEditor().state?.doc; + if (!doc) { + return rawHit; + } + + return { + ...rawHit, + pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)), + }; + } + if (!this.#layoutState.layout) { return null; } @@ -2349,6 +2430,36 @@ export class PresentationEditor extends EventEmitter { }; } + if (this.#getActiveNoteStorySession()) { + const rects = this.#computeNoteSelectionRects(pos, pos); + let rect = rects?.[0] ?? null; + if (!rect) { + rect = this.#computeNoteCaretRect(pos); + } + if (!rect) { + return null; + } + + const zoom = this.#layoutOptions.zoom ?? 1; + const containerRect = this.#visibleHost.getBoundingClientRect(); + const scrollLeft = this.#visibleHost.scrollLeft ?? 0; + const scrollTop = this.#visibleHost.scrollTop ?? 0; + const pageHeight = this.#getBodyPageHeight(); + const pageGap = this.#layoutState.layout?.pageGap ?? 0; + const pageLocalY = rect.y - rect.pageIndex * (pageHeight + pageGap); + const coords = this.#convertPageLocalToOverlayCoords(rect.pageIndex, rect.x, pageLocalY); + if (!coords) return null; + + return { + top: coords.y * zoom - scrollTop + containerRect.top, + bottom: coords.y * zoom - scrollTop + containerRect.top + rect.height * zoom, + left: coords.x * zoom - scrollLeft + containerRect.left, + right: coords.x * zoom - scrollLeft + containerRect.left + Math.max(1, rect.width) * zoom, + width: Math.max(1, rect.width) * zoom, + height: rect.height * zoom, + }; + } + // In body mode, use main document layout const rects = this.getRangeRects(pos, pos); if (rects && rects.length > 0) { @@ -2973,6 +3084,8 @@ export class PresentationEditor extends EventEmitter { this.#a11ySelectionAnnounceTimeout = null; } + this.#teardownStorySessionEventBridge(); + // Unregister from static registry if (this.#registryKey) { PresentationEditor.#instances.delete(this.#registryKey); @@ -3579,6 +3692,7 @@ export class PresentationEditor extends EventEmitter { getDocumentMode: () => this.#documentMode, getPageElement: (pageIndex: number) => this.#getPageElement(pageIndex), isSelectionAwareVirtualizationEnabled: () => this.#isSelectionAwareVirtualizationEnabled(), + getActiveStorySession: () => this.#getActiveStorySession(), }); // Set callbacks - functions that the manager calls to interact with PresentationEditor @@ -3618,6 +3732,8 @@ export class PresentationEditor extends EventEmitter { this.#scheduleSelectionUpdate({ immediate: true }); }, hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y), + activateRenderedNoteSession: (target, options) => this.#activateRenderedNoteSession(target, options), + exitActiveStorySession: () => this.#exitActiveStorySession(), }); } @@ -3937,6 +4053,34 @@ export class PresentationEditor extends EventEmitter { this.#headerFooterSession.initialize(); } + #teardownStorySessionEventBridge(): void { + if (this.#storySessionEditor && this.#storySessionSelectionHandler) { + this.#storySessionEditor.off?.('selectionUpdate', this.#storySessionSelectionHandler); + } + this.#storySessionEditor = null; + this.#storySessionSelectionHandler = null; + } + + #syncStorySessionEventBridge(session: StoryPresentationSession | null): void { + this.#teardownStorySessionEventBridge(); + + if (!session || session.kind !== 'note') { + this.#scheduleSelectionUpdate({ immediate: true }); + return; + } + + const handler = () => { + this.#scheduleSelectionUpdate(); + this.#scheduleA11ySelectionAnnouncement(); + }; + + session.editor.on?.('selectionUpdate', handler); + this.#storySessionEditor = session.editor; + this.#storySessionSelectionHandler = handler; + this.#scheduleSelectionUpdate({ immediate: true }); + this.#scheduleA11ySelectionAnnouncement({ immediate: true }); + } + /** * Set up the generic story-session manager. * @@ -4001,6 +4145,7 @@ export class PresentationEditor extends EventEmitter { }; }, onActiveSessionChanged: () => { + this.#syncStorySessionEventBridge(this.#storySessionManager?.getActiveSession() ?? null); this.#inputBridge?.notifyTargetChanged(); }, }); @@ -5122,6 +5267,10 @@ export class PresentationEditor extends EventEmitter { this.#updateHeaderFooterSelection(); return; } + if (this.#getActiveNoteStorySession()) { + this.#updateNoteSelection(); + return; + } // Only clear local layer, preserve remote cursor layer if (!this.#localSelectionLayer) { @@ -5734,6 +5883,148 @@ export class PresentationEditor extends EventEmitter { this.#editor.view?.focus(); } + #buildNoteLayoutContext(target: RenderedNoteTarget | null | undefined): NoteLayoutContext | null { + const layout = this.#layoutState.layout; + if (!target || !layout) { + return null; + } + + const blocks: FlowBlock[] = []; + const measures: Measure[] = []; + const noteBlockIds = new Set(); + + this.#layoutState.blocks.forEach((block, index) => { + const blockId = typeof block?.id === 'string' ? block.id : ''; + const parsed = parseRenderedNoteTarget(blockId); + if (!parsed) { + return; + } + if (parsed.storyType !== target.storyType || parsed.noteId !== target.noteId) { + return; + } + blocks.push(block); + measures.push(this.#layoutState.measures[index]); + noteBlockIds.add(blockId); + }); + + if (blocks.length === 0 || measures.length !== blocks.length) { + return null; + } + + let firstPageIndex = -1; + let hostWidthPx = 0; + + layout.pages.forEach((page, pageIndex) => { + page.fragments.forEach((fragment) => { + if (!noteBlockIds.has(fragment.blockId)) { + return; + } + if (firstPageIndex < 0) { + firstPageIndex = pageIndex; + } + const fragmentWidth = typeof fragment.width === 'number' ? fragment.width : 0; + hostWidthPx = Math.max(hostWidthPx, fragmentWidth); + }); + }); + + if (firstPageIndex < 0) { + firstPageIndex = 0; + } + + if (!(hostWidthPx > 0)) { + const page = layout.pages[firstPageIndex]; + const pageWidth = page?.size?.w ?? layout.pageSize.w ?? DEFAULT_PAGE_SIZE.w; + const margins = page?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; + const marginLeft = margins.left ?? DEFAULT_MARGINS.left ?? 0; + const marginRight = margins.right ?? DEFAULT_MARGINS.right ?? 0; + hostWidthPx = Math.max(1, pageWidth - marginLeft - marginRight); + } + + return { + target, + blocks, + measures, + firstPageIndex, + hostWidthPx: Math.max(1, hostWidthPx), + }; + } + + #buildActiveNoteLayoutContext(): NoteLayoutContext | null { + const session = this.#getActiveNoteStorySession(); + if (!session) { + return null; + } + return this.#buildNoteLayoutContext({ + storyType: session.locator.storyType, + noteId: session.locator.noteId, + }); + } + + #activateRenderedNoteSession( + target: RenderedNoteTarget, + options: { clientX: number; clientY: number; pageIndex?: number }, + ): boolean { + const storySessionManager = this.#storySessionManager; + if (!storySessionManager) { + return false; + } + + if (target.storyType !== 'footnote' && target.storyType !== 'endnote') { + return false; + } + + const targetContext = this.#buildNoteLayoutContext(target); + const totalPageCount = this.#layoutState.layout?.pages?.length ?? 1; + const pageNumber = Math.max(1, (options.pageIndex ?? targetContext?.firstPageIndex ?? 0) + 1); + + const session = storySessionManager.activate( + { + kind: 'story', + storyType: target.storyType, + noteId: target.noteId, + }, + { + commitPolicy: 'onExit', + preferHiddenHost: true, + hostWidthPx: targetContext?.hostWidthPx ?? this.#visibleHost.clientWidth ?? 1, + editorContext: { + currentPageNumber: pageNumber, + totalPageCount: Math.max(1, totalPageCount), + surfaceKind: target.storyType === 'endnote' ? 'endnote' : 'note', + }, + }, + ); + + const hit = this.hitTest(options.clientX, options.clientY); + const doc = session.editor.state?.doc; + if (hit && doc) { + const clampedPos = Math.max(0, Math.min(hit.pos, doc.content.size)); + try { + const tr = session.editor.state.tr.setSelection(TextSelection.create(doc, clampedPos)); + session.editor.view?.dispatch(tr); + } catch { + // Ignore stale pointer hits during activation races. + } + } + + session.editor.view?.focus(); + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + return true; + } + + #exitActiveStorySession(): void { + const session = this.#getActiveStorySession(); + if (!session) { + return; + } + + this.#storySessionManager?.exit(); + this.#pendingDocChange = true; + this.#scheduleRerender(); + this.#editor.view?.focus(); + } + #getActiveDomTarget(): HTMLElement | null { // Story-session path (behind useHiddenHostForStoryParts) takes // precedence: while a hidden-host story session is active, the @@ -6347,6 +6638,41 @@ export class PresentationEditor extends EventEmitter { return this.#headerFooterSession?.computeCaretRect(pos) ?? null; } + #computeNoteSelectionRects(from: number, to: number): LayoutRect[] { + const context = this.#buildActiveNoteLayoutContext(); + const layout = this.#layoutState.layout; + if (!context || !layout) { + return []; + } + + return ( + selectionToRects(layout, context.blocks, context.measures, from, to, this.#pageGeometryHelper ?? undefined) ?? [] + ); + } + + #computeNoteCaretRect(pos: number): LayoutRect | null { + const context = this.#buildActiveNoteLayoutContext(); + const layout = this.#layoutState.layout; + if (!context || !layout) { + return null; + } + + const geometry = computeCaretLayoutRectGeometryFromHelper( + { + layout, + blocks: context.blocks, + measures: context.measures, + painterHost: this.#painterHost, + viewportHost: this.#viewportHost, + visibleHost: this.#visibleHost, + zoom: this.#layoutOptions.zoom ?? 1, + }, + pos, + true, + ); + return geometry ? { ...geometry, width: 1 } : null; + } + #syncTrackedChangesPreferences(): boolean { const mode = this.#deriveTrackedChangesMode(); const enabled = this.#deriveTrackedChangesEnabled(); @@ -6838,6 +7164,21 @@ export class PresentationEditor extends EventEmitter { if (session && session.mode !== 'body') { return session.pageIndex ?? 0; } + if (this.#getActiveNoteStorySession()) { + const selection = this.getActiveEditor().state?.selection; + if (!selection) { + return this.#buildActiveNoteLayoutContext()?.firstPageIndex ?? 0; + } + const rects = this.#computeNoteSelectionRects(selection.from, selection.to); + if (rects.length > 0) { + return rects[0]?.pageIndex ?? 0; + } + return ( + this.#computeNoteCaretRect(selection.from)?.pageIndex ?? + this.#buildActiveNoteLayoutContext()?.firstPageIndex ?? + 0 + ); + } const layout = this.#layoutState.layout; const selection = this.#editor.state?.selection; if (!layout || !selection) { @@ -7040,6 +7381,76 @@ export class PresentationEditor extends EventEmitter { } } + #updateNoteSelection() { + this.#clearSelectedFieldAnnotationClass(); + + if (!this.#localSelectionLayer) { + return; + } + + const activeEditor = this.getActiveEditor(); + const selection = activeEditor?.state?.selection; + if (!selection) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + + const { from, to } = selection; + + if (from === to) { + const caretRect = this.#computeNoteCaretRect(from); + if (!caretRect) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + + try { + this.#localSelectionLayer.innerHTML = ''; + renderCaretOverlay({ + localSelectionLayer: this.#localSelectionLayer, + caretLayout: { + pageIndex: caretRect.pageIndex, + x: caretRect.x, + y: + caretRect.y - + caretRect.pageIndex * (this.#getBodyPageHeight() + (this.#layoutState.layout?.pageGap ?? 0)), + height: caretRect.height, + }, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render note caret:', error); + } + } + return; + } + + const rects = this.#computeNoteSelectionRects(from, to); + if (!rects.length) { + return; + } + + try { + this.#localSelectionLayer.innerHTML = ''; + renderSelectionRects({ + localSelectionLayer: this.#localSelectionLayer, + rects, + pageHeight: this.#getBodyPageHeight(), + pageGap: this.#layoutState.layout?.pageGap ?? 0, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render note selection rects:', error); + } + } + } + #dismissErrorBanner() { this.#errorBanner?.remove(); this.#errorBanner = null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 0350b2f033..007d40173a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -925,6 +925,13 @@ export class HeaderFooterSessionManager { return; } + if (region.kind === 'footer') { + const editorContainer = editorHost.querySelector('.super-editor'); + if (editorContainer instanceof HTMLElement) { + editorContainer.style.transform = ''; + } + } + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; try { editor = await this.#headerFooterManager.ensureEditor(descriptor, { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 45db95e0de..9076c00d2f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -23,6 +23,7 @@ import type { PositionHit, PageGeometryHelper, TableHitResult } from '@superdoc/ import type { SelectionDebugHudState } from '../selection/SelectionDebug.js'; import type { EpochPositionMapper } from '../layout/EpochPositionMapper.js'; import type { HeaderFooterSessionManager } from '../header-footer/HeaderFooterSessionManager.js'; +import type { StoryPresentationSession } from '../story-session/types.js'; import { getFragmentAtPosition } from '@superdoc/layout-bridge'; import { resolvePointerPositionHit } from '../input/PositionHitResolver.js'; @@ -79,6 +80,29 @@ function isFootnoteBlockId(blockId: string): boolean { return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId)); } +type RenderedNoteTarget = { + storyType: 'footnote' | 'endnote'; + noteId: string; +}; + +function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { + if (typeof blockId !== 'string' || blockId.length === 0) { + return null; + } + + if (blockId.startsWith('footnote-')) { + const noteId = blockId.slice('footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + if (blockId.startsWith('__sd_semantic_footnote-')) { + const noteId = blockId.slice('__sd_semantic_footnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'footnote', noteId } : null; + } + + return null; +} + function getCommentHighlightThreadIds(target: EventTarget | null): string[] { if (!(target instanceof Element)) { return []; @@ -288,6 +312,8 @@ export type EditorInputDependencies = { getPageElement: (pageIndex: number) => HTMLElement | null; /** Check if selection-aware virtualization is enabled */ isSelectionAwareVirtualizationEnabled: () => boolean; + /** Get the currently active non-body story session, if any */ + getActiveStorySession?: () => StoryPresentationSession | null; }; /** @@ -367,6 +393,13 @@ export type EditorInputCallbacks = { notifyDragSelectionEnded?: () => void; /** Hit test table at coordinates */ hitTestTable?: (x: number, y: number) => TableHitResult | null; + /** Activate a rendered note session from a visible note block click */ + activateRenderedNoteSession?: ( + target: RenderedNoteTarget, + options: { clientX: number; clientY: number; pageIndex?: number }, + ) => boolean; + /** Exit the active generic story session */ + exitActiveStorySession?: () => void; }; // ============================================================================= @@ -1082,18 +1115,46 @@ export class EditorInputManager { const { x, y } = normalizedPoint; this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - // Disallow cursor placement in footnote lines: keep current selection and only focus editor. const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null; const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? ''; - if (isFootnoteBlockId(clickedBlockId)) { - if (!isDraggableAnnotation) event.preventDefault(); - this.#focusEditor(); - return; - } + const clickedNoteTarget = parseRenderedNoteTarget(clickedBlockId); // Check header/footer session state const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - const editor = sessionMode === 'body' ? bodyEditor : this.#deps.getActiveEditor(); + const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; + const activeNoteSession = activeStorySession?.kind === 'note' ? activeStorySession : null; + const activeNoteTarget = + activeNoteSession && + (activeNoteSession.locator.storyType === 'footnote' || activeNoteSession.locator.storyType === 'endnote') + ? { + storyType: activeNoteSession.locator.storyType, + noteId: activeNoteSession.locator.noteId, + } + : null; + + if (clickedNoteTarget) { + const isSameActiveNote = + activeNoteTarget?.storyType === clickedNoteTarget.storyType && + activeNoteTarget.noteId === clickedNoteTarget.noteId; + if (!isSameActiveNote) { + if (!isDraggableAnnotation) event.preventDefault(); + const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalizedPoint.pageIndex, + }); + if (activated) { + return; + } + this.#focusEditor(); + return; + } + } else if (activeNoteSession) { + this.#callbacks.exitActiveStorySession?.(); + } + + const isNoteEditing = activeNoteSession != null; + const editor = sessionMode === 'body' && !isNoteEditing ? bodyEditor : this.#deps.getActiveEditor(); if (sessionMode !== 'body') { if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY)) return; @@ -1130,15 +1191,21 @@ export class EditorInputManager { const doc = editor.state?.doc; const epochMapper = this.#deps.getEpochMapper(); const mapped = - rawHit && doc ? epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) : null; + rawHit && doc && !isNoteEditing + ? epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) + : null; if (mapped && !mapped.ok) { debugLog('warn', 'pointerdown mapping failed', mapped); } const hit = - rawHit && doc && mapped?.ok - ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch } + rawHit && doc + ? isNoteEditing + ? { ...rawHit, pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)), layoutEpoch: rawHit.layoutEpoch } + : mapped?.ok + ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch } + : null : null; this.#debugLastHit = hit @@ -1194,9 +1261,19 @@ export class EditorInputManager { return; } - // Disallow cursor placement in footnote lines (footnote content is read-only in the layout). - // Keep the current selection unchanged instead of moving caret to document start. - if (isFootnoteBlockId(rawHit.blockId)) { + // Guard against stale note hits after a session switch or partial rerender. + if ( + isNoteEditing && + activeNoteTarget && + parseRenderedNoteTarget(rawHit.blockId)?.noteId !== activeNoteTarget.noteId + ) { + this.#callbacks.exitActiveStorySession?.(); + this.#focusEditor(); + return; + } + + // Disallow entering read-only note content unless it has been activated into a story session. + if (isFootnoteBlockId(rawHit.blockId) && !isNoteEditing) { this.#focusEditor(); return; } @@ -1364,6 +1441,10 @@ export class EditorInputManager { // Handle header/footer hover const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; + if (this.#deps.getActiveStorySession?.()?.kind === 'note') { + this.#callbacks.clearHoverRegion?.(); + return; + } this.#handleHover(normalized); } @@ -1468,6 +1549,20 @@ export class EditorInputManager { const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; + const targetBlockId = + (target?.closest?.('[data-block-id]') as HTMLElement | null)?.getAttribute?.('data-block-id') ?? ''; + const clickedNoteTarget = parseRenderedNoteTarget(targetBlockId); + if (clickedNoteTarget) { + event.preventDefault(); + event.stopPropagation(); + this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + pageIndex: normalized.pageIndex, + }); + return; + } + const region = this.#callbacks.hitTestHeaderFooterRegion?.( normalized.x, normalized.y, @@ -1514,11 +1609,17 @@ export class EditorInputManager { if (!this.#deps) return; const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; if (event.key === 'Escape' && sessionMode !== 'body') { event.preventDefault(); this.#callbacks.exitHeaderFooterMode?.(); return; } + if (event.key === 'Escape' && activeStorySession?.kind === 'note') { + event.preventDefault(); + this.#callbacks.exitActiveStorySession?.(); + return; + } // Ctrl+Alt+H/F shortcuts if (event.ctrlKey && event.altKey && !event.shiftKey) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 57183e6c67..22b6a63c58 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -64,6 +64,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; let mockDeps: EditorInputDependencies; let mockCallbacks: EditorInputCallbacks; + let activateRenderedNoteSession: Mock; beforeEach(() => { viewportHost = document.createElement('div'); @@ -106,6 +107,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { mockDeps = { getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getActiveStorySession: vi.fn(() => null), getEditor: vi.fn(() => mockEditor as unknown as ReturnType), getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), getEpochMapper: vi.fn(() => ({ @@ -124,10 +126,12 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; mockCallbacks = { + activateRenderedNoteSession: vi.fn(() => true), normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), scheduleSelectionUpdate: vi.fn(), updateSelectionDebugHud: vi.fn(), }; + activateRenderedNoteSession = mockCallbacks.activateRenderedNoteSession as Mock; manager = new EditorInputManager(); manager.setDependencies(mockDeps); @@ -148,7 +152,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { ); } - it('does not change editor selection on direct footnote fragment click', () => { + it('activates a note session on direct footnote fragment click', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); const nestedEl = document.createElement('span'); @@ -167,12 +171,15 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - // Expected behavior: footnote click should not relocate caret to start of the document. + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '1' }, + expect.objectContaining({ clientX: 10, clientY: 10 }), + ); expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); - it('does not change editor selection when hit-test resolves to a footnote block', () => { + it('keeps legacy read-only behavior for stale footnote hits without a rendered footnote target', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue({ pos: 22, layoutEpoch: 1, @@ -197,26 +204,47 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - // Expected behavior: block edits in footnotes without resetting user selection. + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); - it('does not change editor selection when hit-test resolves to a semantic footnote block', () => { + it('does not reactivate the same note session when clicking inside the active note', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue({ pos: 22, layoutEpoch: 1, pageIndex: 0, - blockId: '__sd_semantic_footnote-1-1', + blockId: 'footnote-1-1', column: 0, lineIndex: -1, }); - const target = document.createElement('span'); - viewportHost.appendChild(target); + const activeNoteEditor = { + ...mockEditor, + state: { + ...mockEditor.state, + doc: { ...mockEditor.state.doc, content: { size: 50 } }, + }, + view: { + ...mockEditor.view, + dispatch: vi.fn(), + }, + }; + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); const PointerEventImpl = getPointerEventImpl(); - target.dispatchEvent( + nestedEl.dispatchEvent( new PointerEventImpl('pointerdown', { bubbles: true, cancelable: true, @@ -227,11 +255,11 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); - expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + expect(mockEditor.view.focus).toHaveBeenCalled(); }); - it('does not change editor selection on semantic footnotes heading click', () => { + it('does not activate a note session on semantic footnotes heading click', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue(null); const headingEl = document.createElement('div'); @@ -252,7 +280,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { } as PointerEventInit), ); - expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); - expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + expect(mockEditor.view.focus).toHaveBeenCalled(); }); }); From e442663eb2dafead2d48e9f76398ad4cccb37f3b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 16 Apr 2026 15:51:44 -0700 Subject: [PATCH 03/16] fix: default story-part editing to hidden hosts and add footnote edit coverage --- .../presentation-editor/PresentationEditor.ts | 24 ++++++++---- .../tests/PresentationEditor.test.ts | 18 +++++++++ .../v1/core/presentation-editor/types.ts | 3 +- .../editors/v1/core/story-editor-factory.ts | 20 ++++++---- packages/superdoc/src/core/types/index.js | 2 +- tests/behavior/fixtures/superdoc.ts | 4 +- tests/behavior/harness/main.ts | 9 ++++- .../double-click-edit-footnote.spec.ts | 38 +++++++++++++++++++ 8 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 609c70f874..d9224af66f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -349,6 +349,8 @@ export class PresentationEditor extends EventEmitter { #hiddenHostWrapper: HTMLElement; #layoutOptions: LayoutEngineOptions; #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; + #layoutLookupBlocks: FlowBlock[] = []; + #layoutLookupMeasures: Measure[] = []; /** Cache for incremental toFlowBlocks conversion */ #flowBlockCache: FlowBlockCache = new FlowBlockCache(); #footnoteNumberSignature: string | null = null; @@ -3106,6 +3108,8 @@ export class PresentationEditor extends EventEmitter { // Clear flow block cache to free memory this.#flowBlockCache.clear(); + this.#layoutLookupBlocks = []; + this.#layoutLookupMeasures = []; this.#painterAdapter.reset(); this.#pageGeometryHelper = null; @@ -4084,12 +4088,12 @@ export class PresentationEditor extends EventEmitter { /** * Set up the generic story-session manager. * - * Only instantiated when {@link PresentationEditorOptions.useHiddenHostForStoryParts} - * is `true`. While the flag is off the manager stays `null` and the - * legacy visible header/footer overlay path remains active. + * Instantiated by default. Passing + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} as `false` + * opts out and keeps the legacy visible header/footer overlay path active. */ #setupStorySessionManager() { - if (!this.#options.useHiddenHostForStoryParts) return; + if (this.#options.useHiddenHostForStoryParts === false) return; this.#storySessionManager = new StoryPresentationSessionManager({ resolveRuntime: (locator) => resolveStoryRuntime(this.#editor, locator, { intent: 'write' }), @@ -4521,6 +4525,8 @@ export class PresentationEditor extends EventEmitter { let footerLayouts: HeaderFooterLayoutResult[] | undefined; let extraBlocks: FlowBlock[] | undefined; let extraMeasures: Measure[] | undefined; + let resolveBlocks: FlowBlock[] = blocksForLayout; + let resolveMeasures: Measure[] = previousMeasures; const headerFooterInput = this.#buildHeaderFooterInput(); try { const incrementalLayoutStart = perfNow(); @@ -4560,8 +4566,8 @@ export class PresentationEditor extends EventEmitter { // Include footnote-injected blocks (separators, footnote paragraphs) so // resolveLayout can find them when resolving page fragments. - const resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; - const resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures; + resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout; + resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures; resolvedLayout = resolveLayout({ layout, @@ -4590,6 +4596,8 @@ export class PresentationEditor extends EventEmitter { } const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocksForLayout); this.#layoutState = { blocks: blocksForLayout, measures, layout, bookmarks, anchorMap }; + this.#layoutLookupBlocks = resolveBlocks; + this.#layoutLookupMeasures = resolveMeasures; // Build blockId → pageNumber map for TOC page-number resolution. // Stored on editor.storage so the document-api adapter layer can read it @@ -5893,7 +5901,7 @@ export class PresentationEditor extends EventEmitter { const measures: Measure[] = []; const noteBlockIds = new Set(); - this.#layoutState.blocks.forEach((block, index) => { + this.#layoutLookupBlocks.forEach((block, index) => { const blockId = typeof block?.id === 'string' ? block.id : ''; const parsed = parseRenderedNoteTarget(blockId); if (!parsed) { @@ -5903,7 +5911,7 @@ export class PresentationEditor extends EventEmitter { return; } blocks.push(block); - measures.push(this.#layoutState.measures[index]); + measures.push(this.#layoutLookupMeasures[index]); noteBlockIds.add(blockId); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 40f9fd8b4e..11e4ab4740 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -1032,6 +1032,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); // Verify by checking that Editor was called with documentMode: 'editing' @@ -1057,6 +1058,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1073,6 +1075,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1088,6 +1091,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1108,6 +1112,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); // Call with invalid mode should throw @@ -1183,6 +1188,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1197,6 +1203,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1216,6 +1223,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const result = editor.normalizeClientPoint(120, 80); @@ -1226,6 +1234,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1240,6 +1249,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1258,6 +1268,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -1280,6 +1291,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ @@ -2230,6 +2242,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2288,6 +2301,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2356,6 +2370,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2414,6 +2429,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2504,6 +2520,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); @@ -2540,6 +2557,7 @@ describe('PresentationEditor', () => { editor = new PresentationEditor({ element: container, documentId: 'test-doc', + useHiddenHostForStoryParts: false, }); await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index d3b9b9211d..856c9af5ec 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -212,7 +212,8 @@ export type PresentationEditorOptions = ConstructorParameters[0] * parts presentation editing refactor. See * `plans/story-backed-parts-presentation-editing.md`. * - * @default false + * Enabled by default. Pass `false` to opt back into the legacy mounted + * overlay path while it still exists. * @experimental */ useHiddenHostForStoryParts?: boolean; diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index ffc7b8fe08..7483ad1bbb 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -169,17 +169,21 @@ export function createStoryEditor( // Store parent editor reference as a non-enumerable property to avoid // circular reference issues during serialization while still allowing // access when needed. - Object.defineProperty(storyEditor.options, 'parentEditor', { - enumerable: false, - configurable: true, - get() { - return parentEditor; - }, - }); + if (storyEditor.options && typeof storyEditor.options === 'object') { + Object.defineProperty(storyEditor.options, 'parentEditor', { + enumerable: false, + configurable: true, + get() { + return parentEditor; + }, + }); + } // Start non-editable; the caller (e.g. PresentationEditor) will enable // editing when entering edit mode. - storyEditor.setEditable(false, false); + if (typeof storyEditor.setEditable === 'function') { + storyEditor.setEditable(false, false); + } return storyEditor; } diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index a68cfacb22..1599d8d319 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -672,7 +672,7 @@ * uiDisplayFallbackFont: '"Inter", Arial, sans-serif' * @property {boolean} [isDev] Whether the SuperDoc is in development mode * @property {boolean} [disablePiniaDevtools=false] Disable Pinia/Vue devtools plugin setup for this SuperDoc instance (useful in non-Vue hosts) - * @property {boolean} [useHiddenHostForStoryParts=false] Route story-backed part editing (headers, footers, and future story parts) through PresentationEditor's hidden-host editing path instead of the legacy mounted overlay editor + * @property {boolean} [useHiddenHostForStoryParts=true] Route story-backed part editing (headers, footers, and future story parts) through PresentationEditor's hidden-host editing path instead of the legacy mounted overlay editor. Pass `false` to opt back into the legacy mounted overlay editor while it still exists. * @property {SuperDocLayoutEngineOptions} [layoutEngineOptions] Layout engine overrides passed through to PresentationEditor (page size, margins, virtualization, zoom, debug label, etc.) * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index f44d8e0945..4bf8451e00 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -64,7 +64,9 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); if (config.allowSelectionInViewMode) params.set('allowSelectionInViewMode', '1'); if (config.documentMode) params.set('documentMode', config.documentMode); - if (config.useHiddenHostForStoryParts) params.set('useHiddenHostForStoryParts', '1'); + if (config.useHiddenHostForStoryParts !== undefined) { + params.set('useHiddenHostForStoryParts', config.useHiddenHostForStoryParts ? '1' : '0'); + } const qs = params.toString(); return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 71b79da0bf..53ef9ece9f 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -32,7 +32,9 @@ const replacementsParam = params.get('replacements'); const replacements: 'paired' | 'independent' = replacementsParam === 'independent' ? 'independent' : 'paired'; const allowSelectionInViewMode = params.get('allowSelectionInViewMode') === '1'; const documentMode = params.get('documentMode') as 'editing' | 'viewing' | 'suggesting' | null; -const useHiddenHostForStoryParts = params.get('useHiddenHostForStoryParts') === '1'; +const useHiddenHostForStoryPartsParam = params.get('useHiddenHostForStoryParts'); +const useHiddenHostForStoryParts = + useHiddenHostForStoryPartsParam == null ? undefined : useHiddenHostForStoryPartsParam === '1'; const contentOverride = params.get('contentOverride') ?? undefined; const overrideType = (params.get('overrideType') as OverrideType | null) ?? undefined; @@ -73,7 +75,6 @@ function init(file?: File, content?: ContentOverrideInput) { const config: SuperDocConfig = { selector: '#editor', useLayoutEngine: layout, - useHiddenHostForStoryParts, telemetry: { enabled: false }, onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; @@ -85,6 +86,10 @@ function init(file?: File, content?: ContentOverrideInput) { }, }; + if (useHiddenHostForStoryParts !== undefined) { + config.useHiddenHostForStoryParts = useHiddenHostForStoryParts; + } + if (file) { config.document = file; } else { diff --git a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts new file mode 100644 index 0000000000..0265133d55 --- /dev/null +++ b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', +); + +test.use({ config: { showCaret: true, showSelection: true } }); + +test('double-click rendered footnote to edit it through the presentation surface', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const footnote = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText('This is a simple footnote'); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + const storyHost = superdoc.page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"]').first(); + await expect(storyHost).toHaveAttribute('data-story-key', /.+/); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + await superdoc.waitForStable(); + await expect(footnote).toContainText('This is a simple footnote edited'); + + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + await expect(footnote).toContainText('This is a simple footnote edited'); +}); From e1763d8a78c63c5c07ff0d35f2c79c01ed190af8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 13:59:37 -0700 Subject: [PATCH 04/16] fix: support story-aware editing and tracked changes across part surfaces --- .../document-api/available-operations.mdx | 3 +- .../reference/_generated-manifest.json | 4 +- .../reference/capabilities/get.mdx | 46 + .../reference/content-controls/create.mdx | 7 + .../document-api/reference/core/index.mdx | 1 + .../document-api/reference/create/heading.mdx | 6 +- .../reference/create/paragraph.mdx | 6 +- apps/docs/document-api/reference/extract.mdx | 222 +++++ apps/docs/document-api/reference/index.mdx | 3 +- .../document-api/reference/lists/insert.mdx | 6 +- .../reference/track-changes/decide.mdx | 9 +- .../reference/track-changes/get.mdx | 17 +- .../reference/track-changes/list.mdx | 18 +- packages/document-api/src/README.md | 14 +- packages/document-api/src/contract/schemas.ts | 10 +- packages/document-api/src/index.test.ts | 8 + .../src/track-changes/track-changes.ts | 17 +- packages/document-api/src/types/address.ts | 2 + .../src/types/track-changes.types.ts | 16 + packages/layout-engine/contracts/src/index.ts | 9 + .../layout-engine/layout-bridge/src/index.ts | 91 +- .../test/selectionToRects.test.ts | 98 ++ .../dom/src/renderer-position-mapping.test.ts | 49 + .../painters/dom/src/renderer.ts | 29 +- .../pm-adapter/src/converters/image.ts | 4 +- .../converters/inline-converters/common.ts | 2 + .../inline-converters/generic-token.ts | 3 +- .../src/converters/inline-converters/tab.ts | 3 +- .../converters/inline-converters/text-run.ts | 5 +- .../pm-adapter/src/converters/paragraph.ts | 16 +- .../pm-adapter/src/converters/table.ts | 12 +- .../pm-adapter/src/index.test.ts | 27 + .../layout-engine/pm-adapter/src/internal.ts | 1 + .../pm-adapter/src/marks/application.ts | 12 +- .../pm-adapter/src/tracked-changes.ts | 14 +- .../layout-engine/pm-adapter/src/types.ts | 10 + .../editors/v1/core/Editor.setOptions.test.ts | 32 + .../src/editors/v1/core/Editor.ts | 21 +- .../HeaderFooterRegistry.test.ts | 25 + .../header-footer/HeaderFooterRegistry.ts | 2 + .../presentation-editor/PresentationEditor.ts | 930 ++++++++++++++++-- .../HeaderFooterSessionManager.ts | 38 +- .../input/PresentationInputBridge.ts | 241 ++++- .../layout/FootnotesBuilder.ts | 80 +- .../pointer-events/EditorInputManager.ts | 348 +++++-- .../StoryPresentationSessionManager.test.ts | 40 + .../StoryPresentationSessionManager.ts | 23 +- .../tests/DomPositionIndex.test.ts | 20 + .../EditorInputManager.footnoteClick.test.ts | 274 +++++- .../tests/FootnotesBuilder.test.ts | 48 + .../tests/HeaderFooterSessionManager.test.ts | 62 +- ...sentationEditor.footnotesPmMarkers.test.ts | 6 +- .../tests/PresentationEditor.test.ts | 172 ++++ .../tests/PresentationInputBridge.test.ts | 70 ++ .../v1/core/presentation-editor/types.ts | 14 +- .../utils/CommentPositionCollection.ts | 73 +- .../editors/v1/core/story-editor-factory.ts | 1 + .../v2/importer/docxImporter.js | 8 +- .../v2/importer/trackedChangeIdMapper.js | 61 +- .../v2/importer/trackedChangeIdMapper.test.js | 92 +- .../v3/handlers/w/del/del-translator.js | 9 +- .../v3/handlers/w/del/del-translator.test.js | 16 +- .../v3/handlers/w/ins/ins-translator.js | 9 +- .../v3/handlers/w/ins/ins-translator.test.js | 16 +- .../src/editors/v1/core/types/EditorEvents.ts | 19 +- .../helpers/note-pm-json.test.ts | 112 +++ .../helpers/note-pm-json.ts | 52 + .../helpers/tracked-change-resolver.ts | 107 +- .../tracked-change-runtime-ref.test.ts | 46 + .../helpers/tracked-change-runtime-ref.ts | 82 ++ .../track-changes-wrappers.test.ts | 151 +++ .../plan-engine/track-changes-wrappers.ts | 309 ++++-- .../story-runtime/index.ts | 5 + .../live-story-session-runtime-registry.ts | 101 ++ .../story-runtime/note-story-runtime.test.ts | 41 + .../story-runtime/note-story-runtime.ts | 9 +- .../resolve-story-runtime.test.ts | 79 ++ .../story-runtime/resolve-story-runtime.ts | 5 + .../__tests__/tracked-change-index.test.ts | 296 ++++++ .../tracked-changes/enumerate-stories.test.ts | 68 ++ .../tracked-changes/enumerate-stories.ts | 88 ++ .../tracked-changes/story-labels.test.ts | 77 ++ .../tracked-changes/story-labels.ts | 76 ++ .../tracked-changes/tracked-change-index.ts | 362 +++++++ .../tracked-change-snapshot.ts | 44 + .../v1/dom-observer/DomPointerMapping.test.ts | 93 ++ .../v1/dom-observer/DomPointerMapping.ts | 87 +- .../v1/dom-observer/DomPositionIndex.ts | 16 +- .../src/editors/v1/dom-observer/index.ts | 7 +- .../pagination/pagination-helpers.js | 36 +- .../pagination/pagination-helpers.test.js | 56 +- packages/super-editor/src/editors/v1/index.js | 31 + packages/super-editor/src/index.ts | 1 + packages/superdoc/src/SuperDoc.vue | 12 +- .../CommentsLayer/CommentDialog.test.js | 52 +- .../CommentsLayer/CommentDialog.vue | 69 +- .../components/CommentsLayer/use-comment.js | 12 + .../superdoc/src/stores/comments-store.js | 299 +++++- .../src/stores/comments-store.test.js | 229 ++++- tests/behavior/fixtures/superdoc.ts | 2 +- tests/behavior/harness/vite.config.ts | 14 + .../double-click-edit-footnote.spec.ts | 715 +++++++++++++- .../part-surface-multiclick-selection.spec.ts | 302 ++++++ 103 files changed, 7110 insertions(+), 583 deletions(-) create mode 100644 apps/docs/document-api/reference/extract.mdx create mode 100644 packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts create mode 100644 tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index a19aa9b2fa..666c871990 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -20,7 +20,7 @@ Use the tables below to see what operations are available and where each one is | Citations | 15 | 0 | 15 | [Reference](/document-api/reference/citations/index) | | Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) | | Content Controls | 55 | 0 | 55 | [Reference](/document-api/reference/content-controls/index) | -| Core | 13 | 0 | 13 | [Reference](/document-api/reference/core/index) | +| Core | 14 | 0 | 14 | [Reference](/document-api/reference/core/index) | | Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Cross-References | 5 | 0 | 5 | [Reference](/document-api/reference/cross-refs/index) | | Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) | @@ -148,6 +148,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.getHtml(...) | [`getHtml`](/document-api/reference/get-html) | | editor.doc.markdownToFragment(...) | [`markdownToFragment`](/document-api/reference/markdown-to-fragment) | | editor.doc.info(...) | [`info`](/document-api/reference/info) | +| editor.doc.extract(...) | [`extract`](/document-api/reference/extract) | | editor.doc.clearContent(...) | [`clearContent`](/document-api/reference/clear-content) | | editor.doc.insert(...) | [`insert`](/document-api/reference/insert) | | editor.doc.replace(...) | [`replace`](/document-api/reference/replace) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 8887fe4a9e..b886c7b66d 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -130,6 +130,7 @@ "apps/docs/document-api/reference/diff/capture.mdx", "apps/docs/document-api/reference/diff/compare.mdx", "apps/docs/document-api/reference/diff/index.mdx", + "apps/docs/document-api/reference/extract.mdx", "apps/docs/document-api/reference/fields/get.mdx", "apps/docs/document-api/reference/fields/index.mdx", "apps/docs/document-api/reference/fields/insert.mdx", @@ -436,6 +437,7 @@ "getHtml", "markdownToFragment", "info", + "extract", "clearContent", "insert", "replace", @@ -1016,5 +1018,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "b61fad6a3a330af8a57b78ded260c8d8918486c9829b50804227fbeb15e8bf53" + "sourceHash": "e74a36833ec8587b67447a79517de348cfc9b4bba1c564729c184f6d5464a018" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f64034b1be..d928604dd0 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -855,6 +855,11 @@ _No fields._ | `operations.diff.compare.dryRun` | boolean | yes | | | `operations.diff.compare.reasons` | enum[] | no | | | `operations.diff.compare.tracked` | boolean | yes | | +| `operations.extract` | object | yes | | +| `operations.extract.available` | boolean | yes | | +| `operations.extract.dryRun` | boolean | yes | | +| `operations.extract.reasons` | enum[] | no | | +| `operations.extract.tracked` | boolean | yes | | | `operations.fields.get` | object | yes | | | `operations.fields.get.available` | boolean | yes | | | `operations.fields.get.dryRun` | boolean | yes | | @@ -3071,6 +3076,11 @@ _No fields._ "dryRun": false, "tracked": false }, + "extract": { + "available": true, + "dryRun": false, + "tracked": false + }, "fields.get": { "available": true, "dryRun": false, @@ -10179,6 +10189,41 @@ _No fields._ ], "type": "object" }, + "extract": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "fields.get": { "additionalProperties": false, "properties": { @@ -19570,6 +19615,7 @@ _No fields._ "getHtml", "markdownToFragment", "info", + "extract", "clearContent", "insert", "replace", diff --git a/apps/docs/document-api/reference/content-controls/create.mdx b/apps/docs/document-api/reference/content-controls/create.mdx index 177cb4c016..620c897a9e 100644 --- a/apps/docs/document-api/reference/content-controls/create.mdx +++ b/apps/docs/document-api/reference/content-controls/create.mdx @@ -27,6 +27,10 @@ Returns a ContentControlMutationResult with the created content control target. | Field | Type | Required | Description | | --- | --- | --- | --- | | `alias` | string | no | | +| `at` | SelectionTarget | no | SelectionTarget | +| `at.end` | SelectionPoint | no | SelectionPoint | +| `at.kind` | `"selection"` | no | Constant: `"selection"` | +| `at.start` | SelectionPoint | no | SelectionPoint | | `content` | string | no | | | `controlType` | string | no | | | `kind` | enum | yes | `"block"`, `"inline"` | @@ -120,6 +124,9 @@ Returns a ContentControlMutationResult with the created content control target. "alias": { "type": "string" }, + "at": { + "$ref": "#/$defs/SelectionTarget" + }, "content": { "type": "string" }, diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx index 19c242887f..6f4931ff98 100644 --- a/apps/docs/document-api/reference/core/index.mdx +++ b/apps/docs/document-api/reference/core/index.mdx @@ -21,6 +21,7 @@ Primary read and write operations. | getHtml | `getHtml` | No | `idempotent` | No | No | | markdownToFragment | `markdownToFragment` | No | `idempotent` | No | No | | info | `info` | No | `idempotent` | No | No | +| extract | `extract` | No | `idempotent` | No | No | | clearContent | `clearContent` | Yes | `conditional` | No | No | | insert | `insert` | Yes | `non-idempotent` | Yes | Yes | | replace | `replace` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/create/heading.mdx b/apps/docs/document-api/reference/create/heading.mdx index 1d7f43d48c..06bbbdc051 100644 --- a/apps/docs/document-api/reference/create/heading.mdx +++ b/apps/docs/document-api/reference/create/heading.mdx @@ -99,7 +99,11 @@ Returns a CreateHeadingResult with the new heading block ID and address. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx index e2d1c4a43a..c115b81b33 100644 --- a/apps/docs/document-api/reference/create/paragraph.mdx +++ b/apps/docs/document-api/reference/create/paragraph.mdx @@ -97,7 +97,11 @@ Returns a CreateParagraphResult with the new paragraph block ID and address. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx new file mode 100644 index 0000000000..0eb276f66a --- /dev/null +++ b/apps/docs/document-api/reference/extract.mdx @@ -0,0 +1,222 @@ +--- +title: extract +sidebarTitle: extract +description: Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +## Summary + +Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). + +- Operation ID: `extract` +- API member path: `editor.doc.extract(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ExtractResult with blocks (nodeId, type, text, headingLevel), comments (entityId, text, anchoredText, blockId, status, author), tracked changes (entityId, type, excerpt, author, date), and revision. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `blocks` | object[] | yes | | +| `comments` | object[] | yes | | +| `revision` | string | yes | | +| `trackedChanges` | object[] | yes | | + +### Example response + +```json +{ + "blocks": [ + { + "headingLevel": 1, + "nodeId": "node-def456", + "text": "Hello, world.", + "type": "example" + } + ], + "comments": [ + { + "anchoredText": "example", + "entityId": "entity-789", + "status": "open", + "text": "Hello, world." + } + ], + "revision": "example", + "trackedChanges": [ + { + "author": "Jane Doe", + "entityId": "entity-789", + "excerpt": "Sample excerpt...", + "type": "insert" + } + ] +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "additionalProperties": false, + "properties": { + "headingLevel": { + "description": "Heading level (1–6). Only present for headings.", + "type": "integer" + }, + "nodeId": { + "description": "Stable block ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "text": { + "description": "Full plain text content of the block.", + "type": "string" + }, + "type": { + "description": "Block type: paragraph, heading, listItem, table, image, etc.", + "type": "string" + } + }, + "required": [ + "nodeId", + "type", + "text" + ], + "type": "object" + }, + "type": "array" + }, + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "anchoredText": { + "description": "The document text the comment is anchored to.", + "type": "string" + }, + "author": { + "description": "Comment author name.", + "type": "string" + }, + "blockId": { + "description": "Block ID the comment is anchored to.", + "type": "string" + }, + "entityId": { + "description": "Comment entity ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "status": { + "enum": [ + "open", + "resolved" + ], + "type": "string" + }, + "text": { + "description": "Comment body text.", + "type": "string" + } + }, + "required": [ + "entityId", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "revision": { + "description": "Document revision at the time of extraction.", + "type": "string" + }, + "trackedChanges": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "description": "Change author name.", + "type": "string" + }, + "date": { + "description": "Change date (ISO string).", + "type": "string" + }, + "entityId": { + "description": "Tracked change entity ID — pass to scrollToElement() for navigation.", + "type": "string" + }, + "excerpt": { + "description": "Short text excerpt of the changed content.", + "type": "string" + }, + "type": { + "enum": [ + "insert", + "delete", + "format" + ], + "type": "string" + } + }, + "required": [ + "entityId", + "type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "blocks", + "comments", + "trackedChanges", + "revision" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index ec6d5c293e..8ddf5e92d2 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -19,7 +19,7 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | -| Core | 13 | 0 | 13 | [Open](/document-api/reference/core/index) | +| Core | 14 | 0 | 14 | [Open](/document-api/reference/core/index) | | Blocks | 3 | 0 | 3 | [Open](/document-api/reference/blocks/index) | | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | @@ -70,6 +70,7 @@ The tables below are grouped by namespace. | getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. | | markdownToFragment | editor.doc.markdownToFragment(...) | Convert a Markdown string into an SDM/1 structural fragment. | | info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. | +| extract | editor.doc.extract(...) | Extract all document content with stable IDs for RAG pipelines. Returns blocks with full text, comments, and tracked changes — each with an ID compatible with scrollToElement(). | | clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx index ce184c103e..05294706fe 100644 --- a/apps/docs/document-api/reference/lists/insert.mdx +++ b/apps/docs/document-api/reference/lists/insert.mdx @@ -98,7 +98,11 @@ Returns a ListsInsertResult with the new list item address and block ID. { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } } ] } diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index 5b0a3ba124..cfd98e37fb 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -35,7 +35,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan { "decision": "accept", "target": { - "id": "id-001" + "id": "id-001", + "story": { + "kind": "story", + "storyType": "body" + } } } ``` @@ -114,6 +118,9 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "id": { "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" } }, "required": [ diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index c57851388e..f3d9ab8a54 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -27,12 +27,17 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | +| `story` | StoryLocator | no | StoryLocator | ### Example request ```json { - "id": "id-001" + "id": "id-001", + "story": { + "kind": "story", + "storyType": "body" + } } ``` @@ -44,6 +49,7 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `address.entityId` | string | yes | | | `address.entityType` | `"trackedChange"` | yes | Constant: `"trackedChange"` | | `address.kind` | `"entity"` | yes | Constant: `"entity"` | +| `address.story` | StoryLocator | no | StoryLocator | | `author` | string | no | | | `authorEmail` | string | no | | | `authorImage` | string | no | | @@ -63,7 +69,11 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "address": { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } }, "author": "Jane Doe", "id": "id-001", @@ -92,6 +102,9 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "properties": { "id": { "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" } }, "required": [ diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index bcb7e86ada..6411a19b16 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -26,6 +26,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r | Field | Type | Required | Description | | --- | --- | --- | --- | +| `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` | | `limit` | integer | no | | | `offset` | integer | no | | | `type` | enum | no | `"insert"`, `"delete"`, `"format"` | @@ -61,7 +62,11 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "address": { "entityId": "entity-789", "entityType": "trackedChange", - "kind": "entity" + "kind": "entity", + "story": { + "kind": "story", + "storyType": "body" + } }, "author": "Jane Doe", "handle": { @@ -101,6 +106,17 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r { "additionalProperties": false, "properties": { + "in": { + "description": "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.", + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "const": "all" + } + ] + }, "limit": { "description": "Maximum number of tracked changes to return.", "type": "integer" diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index 7934baf06e..f1dc0b3bde 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -82,7 +82,7 @@ Deterministic outcomes: - Missing tracked-change capabilities must fail with `CAPABILITY_UNAVAILABLE`. - Text/format targets that cannot be resolved after remote edits must fail deterministically (`TARGET_NOT_FOUND` / `NO_OP`), never silently mutate the wrong range. - Tracked entity IDs returned by mutation receipts (`insert` / `replace` / `delete`) and `create.paragraph.trackedChangeRefs` must match canonical IDs from `trackChanges.list`. -- `trackChanges.get` / `accept` / `reject` accept canonical IDs only. +- `trackChanges.get` / `trackChanges.decide` accept canonical tracked-change IDs. Include `story` when targeting a non-body change. ## Common Workflows @@ -699,27 +699,27 @@ List all comments in the document. Optionally include resolved comments. ### `trackChanges.list` -List tracked changes in the document. Supports filtering by `type` and pagination via `limit`/`offset`. +List tracked changes in the document. Supports filtering by `type`, pagination via `limit`/`offset`, and story scoping via `in`. -- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type? }`) +- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type?, in?: StoryLocator | 'all' }`) - **Output**: `TrackChangesListResult` (`{ items, total }`) - **Mutates**: No - **Idempotency**: idempotent ### `trackChanges.get` -Retrieve full information for a single tracked change by its canonical ID. Throws `TARGET_NOT_FOUND` when the ID is invalid. +Retrieve full information for a single tracked change by its canonical ID. Include `story` for non-body changes. Throws `TARGET_NOT_FOUND` when the ID is invalid. -- **Input**: `TrackChangesGetInput` (`{ id }`) +- **Input**: `TrackChangesGetInput` (`{ id, story? }`) - **Output**: `TrackChangeInfo` (includes `wordRevisionIds` with raw imported Word OOXML `w:id` values when available) - **Mutates**: No - **Idempotency**: idempotent ### `trackChanges.decide` -Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. +Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. Include `story` when the change lives outside the body. -- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id } | { scope: 'all' } }`) +- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id, story? } | { scope: 'all' } }`) - **Output**: `Receipt` - **Mutates**: Yes - **Idempotency**: conditional diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index a3d1c9c321..4e2e73d1ff 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -390,6 +390,7 @@ const SHARED_DEFS: Record = { kind: { const: 'entity' }, entityType: { const: 'trackedChange' }, entityId: { type: 'string' }, + story: ref('StoryLocator'), }, ['kind', 'entityType', 'entityId'], ), @@ -4707,11 +4708,16 @@ const operationSchemas: Record = { enum: ['insert', 'delete', 'format'], description: "Filter by change type: 'insert', 'delete', or 'format'.", }, + in: { + oneOf: [storyLocatorSchema, { const: 'all' }], + description: + "Story scope. Omit for body only, pass a StoryLocator for a single story, or 'all' for body + every revision-capable non-body story.", + }, }), output: trackChangesListResultSchema, }, 'trackChanges.get': { - input: objectSchema({ id: { type: 'string' } }, ['id']), + input: objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), output: trackChangeInfoSchema, }, 'trackChanges.decide': { @@ -4721,7 +4727,7 @@ const operationSchemas: Record = { decision: { enum: ['accept', 'reject'] }, target: { oneOf: [ - objectSchema({ id: { type: 'string' } }, ['id']), + objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), objectSchema({ scope: { enum: ['all'] } }, ['scope']), ], }, diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index d03716a034..9aff031ee7 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -795,6 +795,7 @@ describe('createDocumentApi', () => { it('delegates trackChanges read operations', () => { const trackAdpt = makeTrackChangesAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -811,15 +812,20 @@ describe('createDocumentApi', () => { const listResult = api.trackChanges.list({ limit: 1 }); const getResult = api.trackChanges.get({ id: 'tc-1' }); + api.trackChanges.list({ in: footnoteStory, type: 'insert' }); + api.trackChanges.get({ id: 'tc-2', story: footnoteStory }); expect(listResult.total).toBe(0); expect(getResult.id).toBe('tc-1'); expect(trackAdpt.list).toHaveBeenCalledWith({ limit: 1 }); expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-1' }); + expect(trackAdpt.list).toHaveBeenCalledWith({ in: footnoteStory, type: 'insert' }); + expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }); }); it('delegates trackChanges.decide to trackChanges adapter methods', () => { const trackAdpt = makeTrackChangesAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; const api = createDocumentApi({ find: makeFindAdapter(FIND_RESULT), get: makeGetAdapter(), @@ -836,6 +842,7 @@ describe('createDocumentApi', () => { const acceptResult = api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } }); const rejectResult = api.trackChanges.decide({ decision: 'reject', target: { id: 'tc-1' } }); + api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-2', story: footnoteStory } }); const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } }); const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); @@ -845,6 +852,7 @@ describe('createDocumentApi', () => { expect(rejectAllResult.success).toBe(true); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); + expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }, undefined); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 30ec5d433f..06f1a90f64 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,4 +1,5 @@ import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; +import type { StoryLocator } from '../types/story.types.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; @@ -6,14 +7,20 @@ export type TrackChangesListInput = TrackChangesListQuery; export interface TrackChangesGetInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export interface TrackChangesAcceptInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export interface TrackChangesRejectInput { id: string; + /** Story containing the tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; } export type TrackChangesAcceptAllInput = Record; @@ -25,8 +32,8 @@ export type TrackChangesRejectAllInput = Record; // --------------------------------------------------------------------------- export type ReviewDecideInput = - | { decision: 'accept'; target: { id: string } } - | { decision: 'reject'; target: { id: string } } + | { decision: 'accept'; target: { id: string; story?: StoryLocator } } + | { decision: 'reject'; target: { id: string; story?: StoryLocator } } | { decision: 'accept'; target: { scope: 'all' } } | { decision: 'reject'; target: { scope: 'all' } }; @@ -133,11 +140,13 @@ export function executeTrackChangesDecide( } } + const story = (target as { story?: StoryLocator }).story; + if (input.decision === 'accept') { if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.accept({ id: target.id as string }, options); + return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); } if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); - return adapter.reject({ id: target.id as string }, options); + return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); } diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 1c9484d051..7414740445 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -125,6 +125,8 @@ export type TrackedChangeAddress = { kind: 'entity'; entityType: 'trackedChange'; entityId: string; + /** Story containing this tracked change. Omit for body (backward compatible). */ + story?: StoryLocator; }; export type EntityAddress = CommentAddress | TrackedChangeAddress; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 8f3adeb92f..3fa319211e 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -1,8 +1,17 @@ import type { TrackedChangeAddress } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; +import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; +/** + * Scope marker used by {@link TrackChangesListQuery.in} to request changes + * across every revision-capable story (body + headers + footers + footnotes + + * endnotes). Equivalent to a multi-story aggregate list. + */ +export const TRACK_CHANGES_IN_ALL = 'all' as const; +export type TrackChangesInAll = typeof TRACK_CHANGES_IN_ALL; + /** * Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. * @@ -36,6 +45,13 @@ export interface TrackChangesListQuery { limit?: number; offset?: number; type?: TrackChangeType; + /** + * Story scope. + * - `undefined` (default) — body only (backward compatible). + * - A {@link StoryLocator} — only that story. + * - `'all'` — flat list across body + every revision-capable non-body story. + */ + in?: StoryLocator | TrackChangesInAll; } /** diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 4cc339e09c..15add517c7 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -160,6 +160,15 @@ export type RunMark = { export type TrackedChangeMeta = { kind: TrackedChangeKind; id: string; + /** + * Internal story key identifying which content story owns this tracked + * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). + * + * Set by the PM adapter during conversion and stamped on the rendered DOM + * as `data-story-key` so downstream code can distinguish anchors across + * stories without re-resolving the story runtime. + */ + storyKey?: string; author?: string; authorEmail?: string; authorImage?: string; diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 9eb9fa4018..a32d60a517 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -576,6 +576,8 @@ export function selectionToRects( // (accounts for gaps in PM positions between runs) const charOffsetFrom = pmPosToCharOffset(block, line, sliceFrom); const charOffsetTo = pmPosToCharOffset(block, line, sliceTo); + const visualCharOffsetFrom = pmPosToVisualCharOffset(block, line, sliceFrom); + const visualCharOffsetTo = pmPosToVisualCharOffset(block, line, sliceTo); // Detect list items by checking for marker presence const markerWidth = fragment.markerWidth ?? measure.marker?.markerWidth ?? 0; const isListItemFlag = isListItem(markerWidth, block); @@ -589,7 +591,7 @@ export function selectionToRects( const startX = mapPmToX( block, line, - charOffsetFrom, + visualCharOffsetFrom, fragment.width, alignmentOverride, isFirstLine, @@ -598,7 +600,7 @@ export function selectionToRects( const endX = mapPmToX( block, line, - charOffsetTo, + visualCharOffsetTo, fragment.width, alignmentOverride, isFirstLine, @@ -676,6 +678,8 @@ export function selectionToRects( sliceTo, charOffsetFrom, charOffsetTo, + visualCharOffsetFrom, + visualCharOffsetTo, startX, endX, rect: { x: rectX, y: rectY, width: rectWidth, height: line.lineHeight }, @@ -903,13 +907,15 @@ export function selectionToRects( const charOffsetFrom = pmPosToCharOffset(info.block, line, sliceFrom); const charOffsetTo = pmPosToCharOffset(info.block, line, sliceTo); + const visualCharOffsetFrom = pmPosToVisualCharOffset(info.block, line, sliceFrom); + const visualCharOffsetTo = pmPosToVisualCharOffset(info.block, line, sliceTo); const availableWidth = Math.max(1, cellMeasure.width - padding.left - padding.right); const isFirstLine = index === 0; const cellMarkerTextWidth = info.measure?.marker?.markerTextWidth ?? undefined; const startX = mapPmToX( info.block, line, - charOffsetFrom, + visualCharOffsetFrom, availableWidth, alignmentOverride, isFirstLine, @@ -918,7 +924,7 @@ export function selectionToRects( const endX = mapPmToX( info.block, line, - charOffsetTo, + visualCharOffsetTo, availableWidth, alignmentOverride, isFirstLine, @@ -1325,6 +1331,83 @@ export function pmPosToCharOffset(block: FlowBlock, line: Line, pmPos: number): return charOffset; } +/** + * Convert a ProseMirror position to a rendered character offset within a line. + * + * Unlike {@link pmPosToCharOffset}, this helper includes visual-only text runs + * that do not carry PM positions. That matters for selection highlighting when + * a line starts with rendered chrome such as a synthetic footnote number: + * the marker consumes horizontal space in the painter, but it is not part of + * the editable PM story. Using a PM-only offset would place the highlight too + * far left by the marker's width. + * + * The returned offset is intended for visual X mapping, not for slicing PM text. + */ +export function pmPosToVisualCharOffset(block: FlowBlock, line: Line, pmPos: number): number { + if (block.kind !== 'paragraph') return 0; + + let visualOffset = 0; + + for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { + const run = block.runs[runIndex]; + if (!run) continue; + + const text = + 'src' in run || + run.kind === 'lineBreak' || + run.kind === 'break' || + run.kind === 'fieldAnnotation' || + run.kind === 'math' + ? '' + : (run.text ?? ''); + const runTextLength = text.length; + if (runTextLength === 0) { + continue; + } + + const isFirstRun = runIndex === line.fromRun; + const isLastRun = runIndex === line.toRun; + const lineStartChar = isFirstRun ? line.fromChar : 0; + const lineEndChar = isLastRun ? line.toChar : runTextLength; + const runSliceCharCount = lineEndChar - lineStartChar; + if (runSliceCharCount <= 0) { + continue; + } + + const runPmStart = run.pmStart ?? null; + const runPmEnd = run.pmEnd ?? (runPmStart != null ? runPmStart + runTextLength : null); + + if (runPmStart == null || runPmEnd == null) { + visualOffset += runSliceCharCount; + continue; + } + + const runPmRange = runPmEnd - runPmStart; + const runSlicePmStart = runPmStart + (lineStartChar / runTextLength) * runPmRange; + const runSlicePmEnd = runPmStart + (lineEndChar / runTextLength) * runPmRange; + + if (pmPos >= runSlicePmStart && pmPos <= runSlicePmEnd) { + const runSlicePmRange = runSlicePmEnd - runSlicePmStart; + if (runSlicePmRange <= 0) { + return visualOffset; + } + + const pmOffsetInSlice = pmPos - runSlicePmStart; + const visualOffsetInSlice = Math.round((pmOffsetInSlice / runSlicePmRange) * runSliceCharCount); + return visualOffset + Math.min(visualOffsetInSlice, runSliceCharCount); + } + + if (pmPos > runSlicePmEnd) { + visualOffset += runSliceCharCount; + continue; + } + + return visualOffset; + } + + return visualOffset; +} + // determineColumn, findLineIndexAtY are now in position-hit.ts and re-exported above. const lineHeightBeforeIndex = (measure: Measure, absoluteLineIndex: number): number => { diff --git a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts index 96bcf50c5b..cfce735914 100644 --- a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts +++ b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts @@ -74,6 +74,104 @@ describe('selectionToRects', () => { expect(rects[0].x).toBeGreaterThan(tableLayout.pages[0].fragments[0].x); }); + it('accounts for visual-only prefix runs when mapping PM selections to X coordinates', () => { + const blockWithoutMarker: FlowBlock = { + kind: 'paragraph', + id: 'note-without-marker', + runs: [{ text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 }], + attrs: {}, + }; + + const blockWithMarker: FlowBlock = { + kind: 'paragraph', + id: 'note-with-marker', + runs: [ + { text: '1', fontFamily: 'Arial', fontSize: 10 }, + { text: ' simple footnote', fontFamily: 'Arial', fontSize: 16, pmStart: 2, pmEnd: 18 }, + ], + attrs: {}, + }; + + const measureWithoutMarker: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 16, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const measureWithMarker: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 1, toChar: 16, width: 110, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layoutWithoutMarker: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'note-without-marker', + fromLine: 0, + toLine: 1, + x: 10, + y: 20, + width: 200, + pmStart: 2, + pmEnd: 18, + }, + ], + }, + ], + }; + + const layoutWithMarker: Layout = { + pageSize: { w: 300, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'note-with-marker', + fromLine: 0, + toLine: 1, + x: 10, + y: 20, + width: 200, + pmStart: 2, + pmEnd: 18, + }, + ], + }, + ], + }; + + const selectionFrom = 3; + const selectionTo = 9; + + const rectWithoutMarker = selectionToRects( + layoutWithoutMarker, + [blockWithoutMarker], + [measureWithoutMarker], + selectionFrom, + selectionTo, + )[0]; + const rectWithMarker = selectionToRects( + layoutWithMarker, + [blockWithMarker], + [measureWithMarker], + selectionFrom, + selectionTo, + )[0]; + + expect(rectWithoutMarker).toBeTruthy(); + expect(rectWithMarker).toBeTruthy(); + expect(rectWithMarker.x).toBeGreaterThan(rectWithoutMarker.x); + expect(rectWithMarker.x - rectWithoutMarker.x).toBeGreaterThan(1); + }); + describe('table cell spacing.before', () => { it('includes effective spacing.before in rect Y when paragraph has spacing.before', () => { const rects = selectionToRects( diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts new file mode 100644 index 0000000000..21dea44686 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { DomPainter } from './renderer.js'; + +function makeFragment(blockId: string, pmStart: number, pmEnd: number) { + const fragment = document.createElement('div'); + fragment.dataset.blockId = blockId; + fragment.dataset.pmStart = String(pmStart); + fragment.dataset.pmEnd = String(pmEnd); + + const span = document.createElement('span'); + span.dataset.pmStart = String(pmStart); + span.dataset.pmEnd = String(pmEnd); + fragment.appendChild(span); + + return { fragment, span }; +} + +const shiftByTwo = { + map(pos: number) { + return pos + 2; + }, + maps: [{}], +}; + +describe('DomPainter.updatePositionAttributes', () => { + it('does not remap footnote fragments with body transaction mappings', () => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment('footnote-1-abc', 2, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('2'); + expect(fragment.dataset.pmEnd).toBe('30'); + expect(span.dataset.pmStart).toBe('2'); + expect(span.dataset.pmEnd).toBe('30'); + }); + + it('still remaps body fragments when the mapping applies', () => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment('body-paragraph-1', 25, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('27'); + expect(fragment.dataset.pmEnd).toBe('32'); + expect(span.dataset.pmStart).toBe('27'); + expect(span.dataset.pmEnd).toBe('32'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ce6869b26d..f988dd2caa 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2826,6 +2826,10 @@ export class DomPainter { if (fragmentEl.closest('.superdoc-page-header, .superdoc-page-footer')) { return; } + // Notes use local story positions, so body mappings must not rewrite them. + if (isNonBodyStoryBlockId(fragmentEl.dataset.blockId)) { + return; + } // Wrap mapping logic in try-catch to prevent corrupted mappings from crashing paint cycle try { @@ -6693,6 +6697,7 @@ export class DomPainter { elem.dataset.trackChangeId = meta.id; elem.dataset.trackChangeKind = meta.kind; + elem.dataset.storyKey = meta.storyKey ?? 'body'; if (meta.author) { elem.dataset.trackChangeAuthor = meta.author; } @@ -7322,6 +7327,13 @@ const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => { return base; }; +const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => + typeof blockId === 'string' && + (blockId.startsWith('footnote-') || + blockId.startsWith('endnote-') || + blockId.startsWith('__sd_semantic_footnote-') || + blockId.startsWith('__sd_semantic_endnote-')); + const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { if (!metadata) return ''; if ('id' in metadata && metadata.id != null) { @@ -7489,6 +7501,19 @@ const deriveBlockVersion = (block: FlowBlock): string => { // Handle TextRun (kind is 'text' or undefined) const textRun = run as TextRun; + const trackedChangeVersion = textRun.trackedChange + ? [ + textRun.trackedChange.kind ?? '', + textRun.trackedChange.id ?? '', + textRun.trackedChange.storyKey ?? '', + textRun.trackedChange.author ?? '', + textRun.trackedChange.authorEmail ?? '', + textRun.trackedChange.authorImage ?? '', + textRun.trackedChange.date ?? '', + textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', + textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', + ].join(':') + : ''; return [ textRun.text ?? '', textRun.fontFamily, @@ -7506,8 +7531,8 @@ const deriveBlockVersion = (block: FlowBlock): string => { textRun.baselineShift != null ? textRun.baselineShift : '', // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection textRun.token ?? '', - // Tracked changes - force re-render when added or removed tracked change - textRun.trackedChange ? 1 : 0, + // Tracked changes - force re-render when any rendered tracked-change metadata changes. + trackedChangeVersion, // Comment annotations - force re-render when comments are enabled/disabled textRun.comments?.length ?? 0, ].join(','); diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index dc49f5a900..3779a0b1a3 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -338,7 +338,9 @@ export function imageNodeToBlock( export function handleImageNode(node: PMNode, context: NodeHandlerContext): ImageBlock | void { const { blocks, recordBlockKind, nextBlockId, positions, trackedChangesConfig } = context; - const trackedMeta = trackedChangesConfig.enabled ? collectTrackedChangeFromMarks(node.marks ?? []) : undefined; + const trackedMeta = trackedChangesConfig.enabled + ? collectTrackedChangeFromMarks(node.marks ?? [], context.storyKey) + : undefined; if (shouldHideTrackedNode(trackedMeta, trackedChangesConfig)) { return; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts index 002611fd3d..8f1c4b2d02 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts @@ -38,6 +38,7 @@ export class NotInlineNodeError extends Error { export type InlineConverterParams = { node: PMNode; positions: PositionMap; + storyKey?: string; inheritedMarks: PMMark[]; defaultFont: string; defaultSize: number; @@ -60,6 +61,7 @@ export type BlockConverterOptions = { nextBlockId: BlockIdGenerator; nextId: () => string; positions: WeakMap; + storyKey?: string; trackedChangesConfig: NodeHandlerContext['trackedChangesConfig']; defaultFont: string; defaultSize: number; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts index 12580d9b04..fe77a18543 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts @@ -19,6 +19,7 @@ import { TOKEN_INLINE_TYPES } from '../../constants.js'; export function tokenNodeToRun({ node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks, @@ -58,7 +59,7 @@ export function tokenNodeToRun({ const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs; const marks = [...effectiveMarks, ...(inheritedMarks ?? [])]; - applyMarksToRun(run, marks, hyperlinkConfig, themeColors); + applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey); applyInlineRunProperties(run, runProperties, converterContext); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts index dfde920094..da8b2bd4ff 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts @@ -15,6 +15,7 @@ import { type InlineConverterParams } from './common.js'; export function tabNodeToRun({ node, positions, + storyKey, tabOrdinal, paragraphAttrs, inheritedMarks, @@ -42,7 +43,7 @@ export function tabNodeToRun({ // Apply marks (e.g., underline) to the tab run const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; if (marks.length > 0) { - applyMarksToRun(run, marks); + applyMarksToRun(run, marks, undefined, undefined, undefined, true, storyKey); } return run; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts index 06722ac7bc..c051b8fe8e 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts @@ -28,6 +28,7 @@ import { applyInlineRunProperties, type InlineConverterParams } from './common.j export function textNodeToRun({ node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks = [], @@ -59,6 +60,7 @@ export function textNodeToRun({ themeColors, converterContext?.backgroundColor, enableComments, + storyKey, ); if (sdtMetadata) { run.sdt = sdtMetadata; @@ -89,6 +91,7 @@ export function tokenNodeToRun( token: TextRun['token'], hyperlinkConfig: HyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, themeColors?: ThemeColorPalette, + storyKey?: string, ): TextRun { // Tokens carry a placeholder character so measurers reserve width; painters will replace it with the real value. const run: TextRun = { @@ -115,7 +118,7 @@ export function tokenNodeToRun( const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs; const marks = [...effectiveMarks, ...(inheritedMarks ?? [])]; - applyMarksToRun(run, marks, hyperlinkConfig, themeColors); + applyMarksToRun(run, marks, hyperlinkConfig, themeColors, undefined, true, storyKey); // If marksAsAttrs carried font styling, mark the run so downstream defaults don't overwrite it. if (marksAsAttrs.length > 0) { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 0bc5a4d59b..23ba18bfe0 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -249,7 +249,10 @@ const toTrackChangeAttrs = (value: unknown): Record | undefined // Paragraph-mark revisions are stored in paragraphProperties.runProperties (pPr/rPr), not inline text marks. // Convert them into mark-like metadata so tracked-change filtering can reuse the same projection pipeline. -const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties): TrackedChangeMeta | undefined => { +const getParagraphMarkTrackedChange = ( + paragraphProperties: ParagraphProperties, + storyKey?: string, +): TrackedChangeMeta | undefined => { const runProperties = paragraphProperties?.runProperties && typeof paragraphProperties.runProperties === 'object' ? (paragraphProperties.runProperties as Record) @@ -271,7 +274,7 @@ const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties) if (trackDeleteAttrs) { marks.push({ type: 'trackDelete', attrs: trackDeleteAttrs }); } - return collectTrackedChangeFromMarks(marks); + return collectTrackedChangeFromMarks(marks, storyKey); }; const isEmptyTextRun = (run: Run): boolean => { @@ -509,6 +512,7 @@ export function paragraphToFlowBlocks({ para, nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, @@ -572,7 +576,7 @@ export function paragraphToFlowBlocks({ if (paragraphProps.runProperties?.vanish) { return blocks; } - const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps); + const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey); // Get the PM position of the empty paragraph for caret rendering const paraPos = positions.get(para); const emptyRun: TextRun = { @@ -619,6 +623,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); // Ghost list artifact suppression only applies in markup/review modes. @@ -726,6 +731,7 @@ export function paragraphToFlowBlocks({ const inlineConverterParams = { node: node, positions, + storyKey, defaultFont, defaultSize, inheritedMarks: inheritedMarks ?? [], @@ -748,6 +754,7 @@ export function paragraphToFlowBlocks({ nextBlockId: stableNextBlockId, nextId, positions, + storyKey, trackedChangesConfig, defaultFont, defaultSize, @@ -862,6 +869,7 @@ export function paragraphToFlowBlocks({ applyMarksToRun, themeColors, enableComments, + storyKey, ); if (trackedChangesConfig.enabled && filteredRuns.length === 0) { return; @@ -1082,6 +1090,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): para: node, nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1110,6 +1119,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): para: node, nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 452383b756..a570c8dd6f 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -108,6 +108,7 @@ function normalizeLegacyBorderStyle(value: string | undefined): BorderStyle { type TableParserDependencies = { nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; bookmarks: Map; hyperlinkConfig: HyperlinkConfig; @@ -340,6 +341,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { para: childNode, nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -361,6 +363,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { para: nestedNode, nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -376,6 +379,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { const tableBlock = tableNodeToBlock(nestedNode, { nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -398,6 +402,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { const tableBlock = tableNodeToBlock(childNode, { nextBlockId: context.nextBlockId, positions: context.positions, + storyKey: context.storyKey, trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, @@ -414,7 +419,9 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { if (childNode.type === 'image' && context.converters?.imageNodeToBlock) { const mergedMarks = [...(childNode.marks ?? [])]; - const trackedMeta = context.trackedChangesConfig ? collectTrackedChangeFromMarks(mergedMarks) : undefined; + const trackedMeta = context.trackedChangesConfig + ? collectTrackedChangeFromMarks(mergedMarks, context.storyKey) + : undefined; if (shouldHideTrackedNode(trackedMeta, context.trackedChangesConfig)) { continue; } @@ -788,6 +795,7 @@ export function tableNodeToBlock( { nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -804,6 +812,7 @@ export function tableNodeToBlock( const parserDeps: TableParserDependencies = { nextBlockId, positions, + storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1037,6 +1046,7 @@ export function handleTableNode(node: PMNode, context: NodeHandlerContext): void const tableBlock = tableNodeToBlock(node, { nextBlockId, positions, + storyKey: context.storyKey, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 4f387dd52b..441b210e73 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3656,6 +3656,25 @@ describe('toFlowBlocks', () => { expect(blocks[0].attrs?.trackedChangesEnabled).toBe(true); }); + it('propagates storyKey into tracked change metadata for non-body stories', () => { + const pmDoc = buildDocWithMarks([ + { + type: 'trackInsert', + attrs: { + id: 'ins-story', + }, + }, + ]); + + const { blocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' }); + const run = blocks[0].runs[0] as never; + expect(run.trackedChange).toMatchObject({ + kind: 'insert', + id: 'ins-story', + storyKey: 'hf:part:rId7', + }); + }); + it('hides insertions when trackedChangesMode is original', () => { const pmDoc = { type: 'doc', @@ -3875,6 +3894,14 @@ describe('toFlowBlocks', () => { const reviewImage = reviewBlocks.find((block): block is ImageBlock => block.kind === 'image'); expect(reviewImage?.attrs?.trackedChange).toMatchObject({ id: 'del-img', kind: 'delete' }); + const { blocks: storyBlocks } = toFlowBlocks(pmDoc, { storyKey: 'hf:part:rId7' }); + const storyImage = storyBlocks.find((block): block is ImageBlock => block.kind === 'image'); + expect(storyImage?.attrs?.trackedChange).toMatchObject({ + id: 'del-img', + kind: 'delete', + storyKey: 'hf:part:rId7', + }); + const { blocks: finalBlocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'final' }); expect(finalBlocks.some((block) => block.kind === 'image')).toBe(false); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 4ffd9da91d..dd689a7719 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -189,6 +189,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): recordBlockKind, nextBlockId, blockIdPrefix: idPrefix, + storyKey: options?.storyKey, positions, defaultFont, defaultSize, diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 3c2ee5467f..493b43232f 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -451,7 +451,7 @@ const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record { +export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => { const kind = pickTrackedChangeKind(mark.type); if (!kind) return undefined; const attrs = mark.attrs ?? {}; @@ -475,6 +475,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before); meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after); } + if (typeof storyKey === 'string' && storyKey.length > 0) { + meta.storyKey = storyKey; + } return meta; }; @@ -522,10 +525,10 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { * @param marks - Array of ProseMirror marks to process * @returns The highest-priority TrackedChangeMeta, or undefined if none found */ -export const collectTrackedChangeFromMarks = (marks?: PMMark[]): TrackedChangeMeta | undefined => { +export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { if (!marks || !marks.length) return undefined; return marks.reduce((current, mark) => { - const meta = buildTrackedChangeMetaFromMark(mark); + const meta = buildTrackedChangeMetaFromMark(mark, storyKey); if (!meta) return current; return selectTrackedChangeMeta(current, meta); }, undefined); @@ -835,6 +838,7 @@ export const applyMarksToRun = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments = true, + storyKey?: string, ): void => { // If comments are disabled, clear any existing annotations before processing marks. if (!enableComments && 'comments' in run && (run as TextRun).comments) { @@ -856,7 +860,7 @@ export const applyMarksToRun = ( case TRACK_FORMAT_MARK: { // Tracked change marks only apply to TextRun if (!isTabRun) { - const tracked = buildTrackedChangeMetaFromMark(mark); + const tracked = buildTrackedChangeMetaFromMark(mark, storyKey); if (tracked) { run.trackedChange = selectTrackedChangeMeta(run.trackedChange, tracked); } diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index 687f48c4a1..e69c9ee99b 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -213,7 +213,7 @@ export const deriveTrackedChangeId = (kind: TrackedChangeKind, attrs: Record { +export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): TrackedChangeMeta | undefined => { const kind = pickTrackedChangeKind(mark.type); if (!kind) return undefined; const attrs = mark.attrs ?? {}; @@ -237,6 +237,9 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark): TrackedChangeMeta meta.before = normalizeRunMarkList((attrs as { before?: unknown }).before); meta.after = normalizeRunMarkList((attrs as { after?: unknown }).after); } + if (typeof storyKey === 'string' && storyKey.length > 0) { + meta.storyKey = storyKey; + } return meta; }; @@ -363,9 +366,11 @@ export const applyFormatChangeMarks = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments?: boolean, + storyKey?: string, ) => void, themeColors?: ThemeColorPalette, enableComments = true, + storyKey?: string, ): void => { const tracked = run.trackedChange; if (!tracked || tracked.kind !== 'format') { @@ -402,7 +407,7 @@ export const applyFormatChangeMarks = ( resetRunFormatting(run); try { - applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments); + applyMarksToRun(run, beforeMarks as PMMark[], hyperlinkConfig, themeColors, undefined, enableComments, storyKey); } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn('[PM-Adapter] Error applying format change marks, resetting formatting:', error); @@ -433,9 +438,11 @@ export const applyTrackedChangesModeToRuns = ( themeColors?: ThemeColorPalette, backgroundColor?: string, enableComments?: boolean, + storyKey?: string, ) => void, themeColors?: ThemeColorPalette, enableComments = true, + storyKey?: string, ): Run[] => { if (!config) { return runs; @@ -451,7 +458,7 @@ export const applyTrackedChangesModeToRuns = ( // Apply format changes even when not filtering insertions/deletions runs.forEach((run) => { if (isTextRun(run)) { - applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments); + applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun, themeColors, enableComments, storyKey); } }); } @@ -491,6 +498,7 @@ export const applyTrackedChangesModeToRuns = ( applyMarksToRun, themeColors, enableComments, + storyKey, ); } }); diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 5a98b205ed..46d0cfda19 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -93,6 +93,13 @@ export interface AdapterOptions { */ blockIdPrefix?: string; + /** + * Story key for the document being converted. Used to stamp tracked-change + * metadata so rendered DOM anchors can distinguish body, header/footer, and + * note stories. + */ + storyKey?: string; + /** * Optional list of ProseMirror node type names that should be treated as atom/leaf nodes * for position mapping. Use this to keep PM positions correct when custom atom nodes exist. @@ -279,6 +286,7 @@ export interface NodeHandlerContext { // ID generation & positions nextBlockId: BlockIdGenerator; blockIdPrefix?: string; + storyKey?: string; positions: PositionMap; // Style & defaults @@ -333,6 +341,7 @@ export type ParagraphToFlowBlocksParams = { para: PMNode; nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; themeColors?: ThemeColorPalette; @@ -348,6 +357,7 @@ export type ParagraphToFlowBlocksParams = { export type TableNodeToBlockParams = { nextBlockId: BlockIdGenerator; positions: PositionMap; + storyKey?: string; trackedChangesConfig: TrackedChangesConfig; bookmarks: Map; hyperlinkConfig: HyperlinkConfig; diff --git a/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts new file mode 100644 index 0000000000..98223daf8d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Editor } from './Editor.ts'; + +describe('Editor.setOptions', () => { + it('preserves non-enumerable option metadata across updates', () => { + const parentEditor = { id: 'parent-editor' }; + const options: Record = { editable: false }; + Object.defineProperty(options, 'parentEditor', { + enumerable: false, + configurable: true, + get() { + return parentEditor; + }, + }); + + const context = { + options, + view: { + setProps: vi.fn(), + updateState: vi.fn(), + }, + state: { doc: null }, + isDestroyed: false, + }; + + Editor.prototype.setOptions.call(context as unknown as Editor, { documentMode: 'editing' }); + + expect((context.options as { parentEditor?: unknown }).parentEditor).toBe(parentEditor); + expect(Object.getOwnPropertyDescriptor(context.options, 'parentEditor')?.enumerable).toBe(false); + expect(context.view.updateState).toHaveBeenCalledWith(context.state); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 61cfe81c35..d43ec9f6ba 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1864,11 +1864,28 @@ export class Editor extends EventEmitter { * Set editor options and update state. */ setOptions(options: Partial = {}): void { - this.options = { - ...this.options, + const previousOptions = this.options ?? {}; + const nextOptions = { + ...previousOptions, ...options, }; + // Preserve non-enumerable option metadata (for example the story editor's + // `parentEditor` getter) across option updates. Plain object spreading drops + // those descriptors, which breaks commit routing for child/story editors. + const previousDescriptors = Object.getOwnPropertyDescriptors(previousOptions); + for (const [key, descriptor] of Object.entries(previousDescriptors)) { + if (descriptor.enumerable) { + continue; + } + if (Object.prototype.hasOwnProperty.call(options, key)) { + continue; + } + Object.defineProperty(nextOptions, key, descriptor); + } + + this.options = nextOptions; + if ((this.options.isNewFile || !this.options.ydoc) && this.options.isCommentsEnabled) { this.options.shouldLoadComments = true; } diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index 6c6b6d372f..0f912d2f21 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -521,6 +521,31 @@ describe('HeaderFooterLayoutAdapter', () => { expect(options?.mediaFiles).toEqual(manager.rootEditor.converter.media); }); + it('stamps header/footer FlowBlocks with the part story key', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + + const [, options] = mockToFlowBlocks.mock.calls[0] || []; + expect(options?.storyKey).toBe('hf:part:rId-header-default'); + }); + it('returns undefined when no descriptors have FlowBlocks', () => { const manager = { getDescriptors: () => [{ id: 'missing', kind: 'header', variant: 'default' }], diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index 2978355844..c1ba80f169 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -6,6 +6,7 @@ import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; +import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; const DEFAULT_HEADER_FOOTER_HEIGHT = 100; @@ -1186,6 +1187,7 @@ export class HeaderFooterLayoutAdapter { converterContext, defaultFont, defaultSize, + storyKey: buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId: descriptor.id }), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); const blocks = result.blocks; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d9224af66f..09c9f4743b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -26,6 +26,10 @@ import { computeDomCaretPageLocal as computeDomCaretPageLocalFromDom, computeSelectionRectsFromDom as computeSelectionRectsFromDomFromDom, } from '../../dom-observer/DomSelectionGeometry.js'; +import { + readLayoutEpochFromDom as readLayoutEpochFromDomFromDom, + resolvePositionWithinFragmentDom as resolvePositionWithinFragmentDomFromDom, +} from '../../dom-observer/index.js'; import { convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform, getPageOffsetX as getPageOffsetXFromTransform, @@ -76,6 +80,7 @@ import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionM import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js'; import type { StoryPresentationSession } from './story-session/types.js'; import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.js'; import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; @@ -142,6 +147,11 @@ type NoteLayoutContext = { hostWidthPx: number; }; +type RenderedNoteFragmentHit = { + fragmentElement: HTMLElement; + pageIndex: number; +}; + function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { if (typeof blockId !== 'string' || blockId.length === 0) { return null; @@ -166,10 +176,19 @@ import { ensureEditorFieldAnnotationInteractionStyles, } from './dom/EditorStyleInjector.js'; -import type { ResolveRangeOutput, DocumentApi, NavigableAddress, BlockNavigationAddress } from '@superdoc/document-api'; +import type { + ResolveRangeOutput, + DocumentApi, + NavigableAddress, + BlockNavigationAddress, + StoryLocator, +} from '@superdoc/document-api'; +import { isStoryLocator } from '@superdoc/document-api'; import { getBlockIndex } from '../../document-api-adapters/helpers/index-cache.js'; import { findBlockByNodeIdOnly, findBlockById } from '../../document-api-adapters/helpers/node-address-resolver.js'; import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js'; +import { makeTrackedChangeAnchorKey } from '../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; +import { getTrackedChangeIndex } from '../../document-api-adapters/tracked-changes/tracked-change-index.js'; import type { SelectionHandle } from '../selection-state.js'; const DOCUMENT_RELS_PART_ID = 'word/_rels/document.xml.rels'; @@ -408,11 +427,12 @@ export class PresentationEditor extends EventEmitter { // Header/footer session management #headerFooterSession: HeaderFooterSessionManager | null = null; /** - * Generic story-backed presentation-session manager. Only constructed when - * {@link PresentationEditorOptions.useHiddenHostForStoryParts} is true. - * When active, interactive editing of story-backed parts (headers, footers, - * future notes) runs through this manager instead of the visible-PM - * overlay owned by {@link HeaderFooterSessionManager}. + * Generic story-backed presentation-session manager. + * + * Header/footer editing uses this manager only when + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} is enabled. + * Note/endnote editing creates it lazily on first activation because notes + * have no legacy visible-PM overlay fallback. */ #storySessionManager: StoryPresentationSessionManager | null = null; #hoverOverlay: HTMLElement | null = null; @@ -424,6 +444,7 @@ export class PresentationEditor extends EventEmitter { #headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null; #headerFooterEditor: Editor | null = null; #storySessionSelectionHandler: ((...args: unknown[]) => void) | null = null; + #storySessionTransactionHandler: ((...args: unknown[]) => void) | null = null; #storySessionEditor: Editor | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; @@ -721,7 +742,7 @@ export class PresentationEditor extends EventEmitter { editorProps: normalizedEditorProps, documentMode: this.#documentMode, }); - this.#wrapHiddenEditorFocus(); + this.#wrapOffscreenEditorFocus(this.#editor); // Set bidirectional reference for renderer-neutral helpers // Type assertion is safe here as we control both Editor and PresentationEditor (this.#editor as Editor & { presentationEditor?: PresentationEditor | null }).presentationEditor = this; @@ -769,25 +790,33 @@ export class PresentationEditor extends EventEmitter { } /** - * Wraps the hidden editor's focus method to prevent unwanted scrolling when it receives focus. + * Wraps an off-screen editor's focus method to preserve selection and avoid scroll jumps. + * + * PresentationEditor keeps the body editor and hidden-host story-session editors + * mounted off-screen. These editors must stay focusable for accessibility and + * input routing, but a raw focus call can do two harmful things: + * + * 1. Scroll the page toward the off-screen contenteditable. + * 2. Let the browser's stale DOM selection overwrite the ProseMirror selection + * before the active story has a chance to re-apply its real caret position. * - * The hidden ProseMirror editor is positioned off-screen but must remain focusable for - * accessibility. When it receives focus, browsers may attempt to scroll it into view, - * disrupting the user's viewport position. This method wraps the view's focus function - * to prevent that scroll behavior using multiple fallback strategies. + * This wrapper installs the same focus contract on any off-screen editor we own: + * focus without scrolling, suppress transient selectionchange drift, then let + * ProseMirror re-synchronize its DOM selection. * * @remarks * **Why this exists:** - * - The hidden editor provides semantic document structure for screen readers - * - It must be focusable, but is positioned off-screen with `left: -9999px` + * - Hidden editors provide semantic document structure for screen readers + * - They must be focusable, but are positioned off-screen with `left: -9999px` * - Some browsers scroll to bring focused elements into view, breaking the user experience - * - This wrapper prevents that scroll while maintaining focus behavior + * - Story sessions can temporarily lose native focus to the body editor or a UI surface + * - Restoring focus must preserve the active story selection, not restart at position 1 * - * **Fallback strategies (in order):** + * **Focus strategies (in order):** * 1. Try `view.dom.focus({ preventScroll: true })` - the standard approach * 2. If that fails, try `view.dom.focus()` without options and restore scroll position - * 3. If both fail, call the original ProseMirror focus method as last resort - * 4. Always restore scroll position if it changed during any focus attempt + * 3. Always run the original ProseMirror focus logic so `selectionToDOM()` replays + * 4. Restore scroll position if any focus attempt changed it * * **Idempotency:** * - Safe to call multiple times - checks `__sdPreventScrollFocus` flag to avoid re-wrapping @@ -797,8 +826,8 @@ export class PresentationEditor extends EventEmitter { * - Skips wrapping if the focus function has a `mock` property (Vitest/Jest mocks) * - Prevents interference with test assertions and mock function tracking */ - #wrapHiddenEditorFocus(): void { - const view = this.#editor?.view; + #wrapOffscreenEditorFocus(editor: Editor | null | undefined): void { + const view = editor?.view; if (!view || !view.dom || typeof view.focus !== 'function') { return; } @@ -835,54 +864,60 @@ export class PresentationEditor extends EventEmitter { const beforeX = win.scrollX; const beforeY = win.scrollY; const alreadyFocused = view.hasFocus(); - let focused = false; + + if (!alreadyFocused) { + // When focus jumps back into an off-screen editor, browsers can emit a + // transient DOM selection at the document start before ProseMirror has + // re-applied the current PM selection. Suppress that drift first. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (view as any).domObserver.suppressSelectionUpdates(); + } + + let domFocused = false; // Strategy 1: Try focus with preventScroll option (modern browsers) try { view.dom.focus({ preventScroll: true }); - focused = true; + domFocused = true; } catch (error) { - debugLog('warn', 'Hidden editor focus: preventScroll failed', { + debugLog('warn', 'Off-screen editor focus: preventScroll failed', { error: String(error), strategy: 'preventScroll', }); } // Strategy 2: Fall back to focus without options - if (!focused) { + if (!domFocused) { try { view.dom.focus(); - focused = true; + domFocused = true; } catch (error) { - debugLog('warn', 'Hidden editor focus: standard focus failed', { + debugLog('warn', 'Off-screen editor focus: standard focus failed', { error: String(error), strategy: 'standard', }); } } - // Strategy 3: Last resort - call original ProseMirror focus - if (!focused) { - try { - originalFocus(); - } catch (error) { - debugLog('error', 'Hidden editor focus: all strategies failed', { + // Always let ProseMirror replay its own focus logic after the native DOM + // focus step. This is what writes the current PM selection back into the + // hidden contenteditable, which is critical for story-session carets. + try { + originalFocus(); + } catch (error) { + if (!domFocused) { + debugLog('error', 'Off-screen editor focus: all strategies failed', { + error: String(error), + strategy: 'original', + }); + } else { + debugLog('warn', 'Off-screen editor focus: ProseMirror selection sync failed', { error: String(error), strategy: 'original', }); } } - // When the editor was not focused before, the browser places the DOM selection - // at an arbitrary position inside the off-screen contenteditable. ProseMirror's - // DOMObserver would read this stale position via a selectionchange event and - // overwrite PM state, causing the cursor to jump. Suppress selection updates - // for the next 50ms so PM re-applies its own selection to the DOM instead. - if (!alreadyFocused) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (view as any).domObserver.suppressSelectionUpdates(); - } - // Restore scroll position if any focus attempt changed it if (win.scrollX !== beforeX || win.scrollY !== beforeY) { win.scrollTo(beforeX, beforeY); @@ -1135,8 +1170,8 @@ export class PresentationEditor extends EventEmitter { * ``` */ getActiveEditor(): Editor { - // Story-session path (behind useHiddenHostForStoryParts) takes - // precedence over the legacy header/footer overlay. + // An active story session (header/footer in hidden-host mode, or a note + // session) always owns the editable surface. const storySession = this.#storySessionManager?.getActiveSession(); if (storySession) return storySession.editor; @@ -1163,12 +1198,41 @@ export class PresentationEditor extends EventEmitter { return session; } + #getActiveTrackedChangeStorySurface(): { storyKey: string; editor: Editor } | null { + const storySession = this.#getActiveStorySession(); + if (storySession) { + return { + storyKey: buildStoryKey(storySession.locator), + editor: storySession.editor, + }; + } + + const headerFooterSession = this.#headerFooterSession?.session; + const activeHeaderFooterEditor = this.#headerFooterSession?.activeEditor; + const headerFooterRefId = + headerFooterSession && headerFooterSession.mode !== 'body' ? headerFooterSession.headerFooterRefId : null; + + if (!headerFooterRefId || !activeHeaderFooterEditor) { + return null; + } + + return { + storyKey: buildStoryKey({ + kind: 'story', + storyType: 'headerFooterPart', + refId: headerFooterRefId, + }), + editor: activeHeaderFooterEditor, + }; + } + /** - * Access the generic story-session manager when the - * {@link PresentationEditorOptions.useHiddenHostForStoryParts} rollout - * flag is enabled. Returns `null` when the flag is off — in that case - * story-backed interactive editing still runs through the legacy - * `HeaderFooterSessionManager` / visible-PM overlay path. + * Access the generic story-session manager. + * + * Header/footer consumers should still treat + * {@link PresentationEditorOptions.useHiddenHostForStoryParts} as the + * rollout gate for hidden-host header/footer editing. Note sessions may + * create the manager lazily even when that flag is off. * * This is a transitional surface exposed so tests and opt-in callers * can drive activation while the full Phase 3/4 geometry/pointer @@ -1461,6 +1525,7 @@ export class PresentationEditor extends EventEmitter { this.#documentMode = mode; this.#editor.setDocumentMode(mode); this.#headerFooterSession?.setDocumentMode(mode); + this.#syncActiveStorySessionDocumentMode(this.#storySessionManager?.getActiveSession() ?? null); this.#syncDocumentModeClass(); this.#syncHiddenEditorA11yAttributes(); const trackedChangesChanged = this.#syncTrackedChangesPreferences(); @@ -1842,6 +1907,19 @@ export class PresentationEditor extends EventEmitter { remapped[threadId] = data; return; } + + const storyTrackedBounds = this.#getStoryTrackedChangeBounds(data, relativeTo); + if (storyTrackedBounds) { + hasUpdates = true; + remapped[threadId] = { + ...data, + bounds: storyTrackedBounds.bounds, + rects: storyTrackedBounds.rects, + pageIndex: storyTrackedBounds.pageIndex, + }; + return; + } + const start = data.start ?? data.pos; const end = data.end ?? start; if (!Number.isFinite(start) || !Number.isFinite(end)) { @@ -1878,11 +1956,251 @@ export class PresentationEditor extends EventEmitter { * * @returns Map of threadId -> { threadId, start, end } */ - #collectCommentPositions(): Record { - return collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, { - commentMarkName: CommentMarkName, - trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName], + #collectCommentPositions(): Record< + string, + { + threadId: string; + start?: number; + end?: number; + key?: string; + storyKey?: string; + kind?: 'trackedChange' | 'comment'; + } + > { + return { + ...collectCommentPositionsFromHelper(this.#editor?.state?.doc ?? null, { + commentMarkName: CommentMarkName, + trackChangeMarkNames: [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName], + storyKey: BODY_STORY_KEY, + }), + ...this.#collectIndexedTrackedChangePositions(), + ...this.#collectRenderedTrackedChangePositions(), + }; + } + + #collectIndexedTrackedChangePositions(): Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + start?: number; + end?: number; + } + > { + const positions: Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + start?: number; + end?: number; + } + > = {}; + + let snapshots: ReadonlyArray<{ + anchorKey?: unknown; + runtimeRef?: { rawId?: unknown; storyKey?: unknown }; + range?: { from?: unknown; to?: unknown }; + }> = []; + + try { + snapshots = getTrackedChangeIndex(this.#editor).getAll(); + } catch { + return positions; + } + + snapshots.forEach((snapshot) => { + const key = typeof snapshot?.anchorKey === 'string' ? snapshot.anchorKey : null; + const storyKey = typeof snapshot?.runtimeRef?.storyKey === 'string' ? snapshot.runtimeRef.storyKey : null; + const rawId = snapshot?.runtimeRef?.rawId; + const threadId = rawId == null ? null : String(rawId); + + if (!key || !storyKey || !threadId || storyKey === BODY_STORY_KEY || positions[key]) { + return; + } + + const start = Number.isFinite(snapshot?.range?.from) ? Number(snapshot.range.from) : undefined; + const end = Number.isFinite(snapshot?.range?.to) ? Number(snapshot.range.to) : undefined; + + positions[key] = { + threadId, + key, + storyKey, + kind: 'trackedChange', + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), + }; }); + + return positions; + } + + #collectRenderedTrackedChangePositions(): Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + } + > { + const positions: Record< + string, + { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange'; + } + > = {}; + const host = this.#visibleHost; + + if (!host) { + return positions; + } + + const elements = host.querySelectorAll('[data-track-change-id][data-story-key]'); + elements.forEach((element) => { + const storyKey = element.dataset.storyKey?.trim(); + const rawId = element.dataset.trackChangeId?.trim(); + if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) { + return; + } + + const key = makeTrackedChangeAnchorKey({ storyKey, rawId }); + if (positions[key]) { + return; + } + + positions[key] = { + threadId: rawId, + key, + storyKey, + kind: 'trackedChange', + }; + }); + + return positions; + } + + #getStoryTrackedChangeBounds( + data: { threadId?: unknown; storyKey?: unknown; kind?: unknown; start?: unknown; end?: unknown }, + relativeTo?: HTMLElement, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + if (data?.kind !== 'trackedChange') { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + if (!storyKey || storyKey === BODY_STORY_KEY) { + return null; + } + + const activeSurface = this.#getActiveTrackedChangeStorySurface(); + if (!activeSurface || activeSurface.storyKey !== storyKey) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const start = Number.isFinite(data.start) ? Number(data.start) : undefined; + const end = Number.isFinite(data.end) ? Number(data.end) : start; + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const rects = this.getRangeRects(start!, end!, relativeTo); + if (!rects.length) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return this.#getRenderedTrackedChangeBounds(data, relativeTo); + } + + return { + bounds, + rects, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + + #getRenderedTrackedChangeBounds( + data: { threadId?: unknown; storyKey?: unknown; kind?: unknown }, + relativeTo?: HTMLElement, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + if (data?.kind !== 'trackedChange') { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + const rawId = typeof data.threadId === 'string' ? data.threadId : null; + if (!storyKey || !rawId || storyKey === BODY_STORY_KEY) { + return null; + } + + const elements = this.#findRenderedTrackedChangeElements(rawId, storyKey); + if (!elements.length) { + return null; + } + + const relativeRect = relativeTo?.getBoundingClientRect?.(); + const rects = elements + .map((element) => { + const rect = element.getBoundingClientRect(); + if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) { + return null; + } + + const pageIndex = Number(element.closest('.superdoc-page')?.dataset?.pageIndex ?? 0); + return { + pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0, + left: rect.left - (relativeRect?.left ?? 0), + top: rect.top - (relativeRect?.top ?? 0), + right: rect.right - (relativeRect?.left ?? 0), + bottom: rect.bottom - (relativeRect?.top ?? 0), + width: rect.width, + height: rect.height, + } satisfies RangeRect; + }) + .filter((rect): rect is RangeRect => Boolean(rect)); + + if (!rects.length) { + return null; + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return null; + } + + return { + bounds, + rects, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + + #findRenderedTrackedChangeElements(rawId: string, storyKey?: string): HTMLElement[] { + const host = this.#visibleHost; + if (!host) { + return []; + } + + const baseSelector = `[data-track-change-id="${escapeAttrValue(rawId)}"]`; + const selector = storyKey ? `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]` : baseSelector; + return Array.from(host.querySelectorAll(selector)); } /** @@ -2140,22 +2458,28 @@ export class PresentationEditor extends EventEmitter { y: headerPageIndex * headerPageHeight + (localY - headerPageIndex * headerPageHeight), }; const hit = clickToPositionGeometry(context.layout, context.blocks, context.measures, headerPoint) ?? null; - return hit; + if (!hit) { + return null; + } + + const doc = this.getActiveEditor().state?.doc; + if (!doc) { + return hit; + } + + return { + ...hit, + pos: Math.max(0, Math.min(hit.pos, doc.content.size)), + }; } const noteContext = this.#buildActiveNoteLayoutContext(); if (noteContext) { const rawHit = - resolvePointerPositionHit({ - layout: this.#layoutState.layout, - blocks: noteContext.blocks, - measures: noteContext.measures, - containerPoint: normalized, - domContainer: this.#viewportHost, - clientX, - clientY, + this.#resolveNoteDomHit(noteContext, clientX, clientY) ?? + clickToPositionGeometry(this.#layoutState.layout, noteContext.blocks, noteContext.measures, normalized, { geometryHelper: this.#pageGeometryHelper ?? undefined, - }) ?? null; + }); if (!rawHit) { return null; } @@ -2560,7 +2884,7 @@ export class PresentationEditor extends EventEmitter { options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {}, ): boolean { // Cancel any pending focus-scroll RAF so this intentional scroll is not undone - // by the wrapHiddenEditorFocus safety net (e.g. search navigation after focus). + // by the wrapOffscreenEditorFocus safety net (e.g. search navigation after focus). if (this.#focusScrollRafId != null) { const win = this.#visibleHost.ownerDocument?.defaultView; if (win) win.cancelAnimationFrame(this.#focusScrollRafId); @@ -2650,12 +2974,16 @@ export class PresentationEditor extends EventEmitter { #buildThreadAnchorScrollPlan(threadId: string, targetClientY: number): ThreadAnchorScrollPlan | null { if (!threadId || !Number.isFinite(targetClientY)) return null; - const threadPosition = this.#collectCommentPositions()[threadId]; + const threadPosition = this.#resolveCommentPositionEntry(threadId); if (!threadPosition) return null; - const selectionBounds = this.getSelectionBounds(threadPosition.start, threadPosition.end); - const currentTop = selectionBounds?.bounds?.top; - if (!Number.isFinite(currentTop)) return null; + const boundedEntry = this.getCommentBounds({ [threadId]: threadPosition })[threadId] ?? threadPosition; + const currentTopValue = + typeof boundedEntry?.bounds === 'object' && boundedEntry?.bounds != null + ? (boundedEntry.bounds as { top?: unknown }).top + : undefined; + if (!Number.isFinite(currentTopValue)) return null; + const currentTop = Number(currentTopValue); const requestedScrollDelta = currentTop - targetClientY; const scrollTarget = this.#scrollContainer ?? this.#visibleHost; @@ -2671,6 +2999,24 @@ export class PresentationEditor extends EventEmitter { return null; } + #resolveCommentPositionEntry(threadId: string): { + threadId: string; + start?: number; + end?: number; + pos?: number; + key?: string; + storyKey?: string; + kind?: 'trackedChange' | 'comment'; + } | null { + const positions = this.#collectCommentPositions(); + const directMatch = positions[threadId]; + if (directMatch) { + return directMatch; + } + + return Object.values(positions).find((entry) => entry?.key === threadId || entry?.threadId === threadId) ?? null; + } + #buildWindowThreadAnchorScrollPlan( scrollTarget: Window, currentTop: number, @@ -3503,6 +3849,7 @@ export class PresentationEditor extends EventEmitter { // These modify the OOXML part and derived cache but don't change the PM document, // so the normal 'update' event won't trigger a layout refresh. const handleNotesPartChanged = () => { + this.#flowBlockCache.setHasExternalChanges(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); @@ -3722,6 +4069,7 @@ export class PresentationEditor extends EventEmitter { updateSelectionDebugHud: () => this.#updateSelectionDebugHud(), clearHoverRegion: () => this.#clearHoverRegion(), renderHoverRegion: (region) => this.#renderHoverRegion(region), + hitTest: (clientX: number, clientY: number) => this.hitTest(clientX, clientY), focusEditorAfterImageSelection: () => this.#focusEditorAfterImageSelection(), resolveInlineImageElementByPmStart: (pmStart) => this.#painterAdapter.getInlineImageElementByPmStart(pmStart), resolveImageFragmentElementByPmStart: (pmStart) => this.#painterAdapter.getImageFragmentElementByPmStart(pmStart), @@ -3937,6 +4285,11 @@ export class PresentationEditor extends EventEmitter { this.#visibleHost, () => this.#getActiveDomTarget(), () => !this.#isViewLocked(), + () => this.#editorInputManager?.notifyTargetChanged(), + { + useWindowFallback: true, + getTargetEditor: () => this.getActiveEditor(), + }, ); this.#inputBridge.bind(); } @@ -3965,7 +4318,8 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; }, getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, - getStorySessionManager: () => this.#storySessionManager, + getStorySessionManager: () => + this.#options.useHiddenHostForStoryParts ? this.#ensureStorySessionManager() : null, }); // Set up callbacks @@ -4058,11 +4412,17 @@ export class PresentationEditor extends EventEmitter { } #teardownStorySessionEventBridge(): void { - if (this.#storySessionEditor && this.#storySessionSelectionHandler) { - this.#storySessionEditor.off?.('selectionUpdate', this.#storySessionSelectionHandler); + if (this.#storySessionEditor) { + if (this.#storySessionSelectionHandler) { + this.#storySessionEditor.off?.('selectionUpdate', this.#storySessionSelectionHandler); + } + if (this.#storySessionTransactionHandler) { + this.#storySessionEditor.off?.('transaction', this.#storySessionTransactionHandler); + } } this.#storySessionEditor = null; this.#storySessionSelectionHandler = null; + this.#storySessionTransactionHandler = null; } #syncStorySessionEventBridge(session: StoryPresentationSession | null): void { @@ -4077,23 +4437,47 @@ export class PresentationEditor extends EventEmitter { this.#scheduleSelectionUpdate(); this.#scheduleA11ySelectionAnnouncement(); }; + const transactionHandler = ({ transaction }: { transaction?: { docChanged?: boolean } }) => { + if (!transaction?.docChanged) { + return; + } + this.#flowBlockCache.setHasExternalChanges(true); + this.#pendingDocChange = true; + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + }; session.editor.on?.('selectionUpdate', handler); + session.editor.on?.('transaction', transactionHandler); this.#storySessionEditor = session.editor; this.#storySessionSelectionHandler = handler; + this.#storySessionTransactionHandler = transactionHandler; this.#scheduleSelectionUpdate({ immediate: true }); this.#scheduleA11ySelectionAnnouncement({ immediate: true }); } - /** - * Set up the generic story-session manager. - * - * Instantiated by default. Passing - * {@link PresentationEditorOptions.useHiddenHostForStoryParts} as `false` - * opts out and keeps the legacy visible header/footer overlay path active. - */ - #setupStorySessionManager() { - if (this.#options.useHiddenHostForStoryParts === false) return; + #syncActiveStorySessionDocumentMode(session: StoryPresentationSession | null): void { + if (!session || session.kind !== 'note') { + return; + } + + // Story editors default to viewing mode at construction time. When a note + // session becomes the active presentation surface, it must inherit the + // current document mode so double-clicking produces an actually editable + // footnote/endnote surface. + if (typeof session.editor.setDocumentMode === 'function') { + session.editor.setDocumentMode(this.#documentMode); + return; + } + + session.editor.setEditable?.(this.#documentMode !== 'viewing'); + session.editor.setOptions?.({ documentMode: this.#documentMode }); + } + + #ensureStorySessionManager(): StoryPresentationSessionManager { + if (this.#storySessionManager) { + return this.#storySessionManager; + } this.#storySessionManager = new StoryPresentationSessionManager({ resolveRuntime: (locator) => resolveStoryRuntime(this.#editor, locator, { intent: 'write' }), @@ -4149,10 +4533,29 @@ export class PresentationEditor extends EventEmitter { }; }, onActiveSessionChanged: () => { - this.#syncStorySessionEventBridge(this.#storySessionManager?.getActiveSession() ?? null); + const activeSession = this.#storySessionManager?.getActiveSession() ?? null; + if (activeSession?.hostWrapper) { + this.#wrapOffscreenEditorFocus(activeSession.editor); + } + this.#syncActiveStorySessionDocumentMode(activeSession); + this.#syncStorySessionEventBridge(activeSession); this.#inputBridge?.notifyTargetChanged(); }, }); + + return this.#storySessionManager; + } + + /** + * Set up the generic story-session manager. + * + * Header/footer hidden-host editing still rolls out behind + * {@link PresentationEditorOptions.useHiddenHostForStoryParts}. Note + * sessions call {@link #ensureStorySessionManager} lazily when activated. + */ + #setupStorySessionManager() { + if (!this.#options.useHiddenHostForStoryParts) return; + this.#ensureStorySessionManager(); } /** @@ -5272,11 +5675,11 @@ export class PresentationEditor extends EventEmitter { const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - this.#updateHeaderFooterSelection(); + this.#updateHeaderFooterSelection(shouldScrollIntoView); return; } if (this.#getActiveNoteStorySession()) { - this.#updateNoteSelection(); + this.#updateNoteSelection(shouldScrollIntoView); return; } @@ -5968,14 +6371,135 @@ export class PresentationEditor extends EventEmitter { }); } + #collectNoteBlockIds(context: NoteLayoutContext): Set { + return new Set( + context.blocks + .map((block) => (typeof block?.id === 'string' ? block.id : null)) + .filter((blockId): blockId is string => !!blockId), + ); + } + + #resolveRenderedPageIndexForElement(element: HTMLElement): number { + const pageElement = element.closest('[data-page-index]'); + const pageIndex = Number(pageElement?.dataset.pageIndex ?? 'NaN'); + if (Number.isFinite(pageIndex) && pageIndex >= 0) { + return pageIndex; + } + + const blockId = element.getAttribute('data-block-id') ?? ''; + const layout = this.#layoutState.layout; + if (!blockId || !layout) { + return 0; + } + + for (let index = 0; index < layout.pages.length; index += 1) { + if (layout.pages[index]?.fragments?.some((fragment) => fragment.blockId === blockId)) { + return index; + } + } + + return 0; + } + + #findRenderedNoteFragmentAtPoint( + noteBlockIds: ReadonlySet, + clientX: number, + clientY: number, + ): RenderedNoteFragmentHit | null { + const doc = this.#viewportHost.ownerDocument ?? document; + const elementsFromPoint = typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint.bind(doc) : null; + + const toFragmentHit = (element: Element | null): RenderedNoteFragmentHit | null => { + const fragmentElement = element instanceof HTMLElement ? element.closest('[data-block-id]') : null; + const blockId = fragmentElement?.getAttribute('data-block-id') ?? ''; + if (!fragmentElement || !noteBlockIds.has(blockId)) { + return null; + } + + return { + fragmentElement, + pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement), + }; + }; + + if (elementsFromPoint) { + for (const element of elementsFromPoint(clientX, clientY)) { + const fragmentHit = toFragmentHit(element); + if (fragmentHit) { + return fragmentHit; + } + } + } + + const renderedFragments = Array.from(this.#viewportHost.querySelectorAll('[data-block-id]')); + for (const fragmentElement of renderedFragments) { + const blockId = fragmentElement.getAttribute('data-block-id') ?? ''; + if (!noteBlockIds.has(blockId)) { + continue; + } + + const rect = fragmentElement.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { + continue; + } + + return { + fragmentElement, + pageIndex: this.#resolveRenderedPageIndexForElement(fragmentElement), + }; + } + + return null; + } + + #resolveNoteDomHit(context: NoteLayoutContext, clientX: number, clientY: number): PositionHit | null { + const layout = this.#layoutState.layout; + if (!layout) { + return null; + } + + const noteBlockIds = this.#collectNoteBlockIds(context); + if (noteBlockIds.size === 0) { + return null; + } + + const fragmentHit = this.#findRenderedNoteFragmentAtPoint(noteBlockIds, clientX, clientY); + if (!fragmentHit) { + return null; + } + + const pos = resolvePositionWithinFragmentDomFromDom(fragmentHit.fragmentElement, clientX, clientY); + if (pos == null) { + return null; + } + + return { + pos, + layoutEpoch: + readLayoutEpochFromDomFromDom(fragmentHit.fragmentElement, clientX, clientY) ?? layout.layoutEpoch ?? 0, + blockId: fragmentHit.fragmentElement.getAttribute('data-block-id') ?? '', + pageIndex: fragmentHit.pageIndex, + column: 0, + lineIndex: -1, + }; + } + + #createCollapsedSelectionNearInlineContent(doc: ProseMirrorNode, pos: number): Selection { + const clampedPos = Math.max(0, Math.min(pos, doc.content.size)); + const directSelection = TextSelection.create(doc, clampedPos); + if (directSelection.$from.parent.inlineContent) { + return directSelection; + } + + const bias = clampedPos >= doc.content.size ? -1 : 1; + return Selection.near(doc.resolve(clampedPos), bias); + } + #activateRenderedNoteSession( target: RenderedNoteTarget, options: { clientX: number; clientY: number; pageIndex?: number }, ): boolean { - const storySessionManager = this.#storySessionManager; - if (!storySessionManager) { - return false; - } + const storySessionManager = this.#ensureStorySessionManager(); if (target.storyType !== 'footnote' && target.storyType !== 'endnote') { return false; @@ -5992,7 +6516,9 @@ export class PresentationEditor extends EventEmitter { noteId: target.noteId, }, { - commitPolicy: 'onExit', + // Notes need to repaint while the user types; otherwise the hidden-host + // editor is active but the rendered footnote appears frozen until exit. + commitPolicy: 'continuous', preferHiddenHost: true, hostWidthPx: targetContext?.hostWidthPx ?? this.#visibleHost.clientWidth ?? 1, editorContext: { @@ -6006,9 +6532,9 @@ export class PresentationEditor extends EventEmitter { const hit = this.hitTest(options.clientX, options.clientY); const doc = session.editor.state?.doc; if (hit && doc) { - const clampedPos = Math.max(0, Math.min(hit.pos, doc.content.size)); try { - const tr = session.editor.state.tr.setSelection(TextSelection.create(doc, clampedPos)); + const selection = this.#createCollapsedSelectionNearInlineContent(doc, hit.pos); + const tr = session.editor.state.tr.setSelection(selection); session.editor.view?.dispatch(tr); } catch { // Ignore stale pointer hits during activation races. @@ -6034,9 +6560,8 @@ export class PresentationEditor extends EventEmitter { } #getActiveDomTarget(): HTMLElement | null { - // Story-session path (behind useHiddenHostForStoryParts) takes - // precedence: while a hidden-host story session is active, the - // active DOM target is the story editor's DOM, not the body's. + // While a story session is active, forwarded input targets the session + // editor's DOM rather than the body's hidden editor DOM. const storyTarget = this.#storySessionManager?.getActiveEditorDomTarget(); if (storyTarget) return storyTarget; @@ -6327,7 +6852,7 @@ export class PresentationEditor extends EventEmitter { return await this.#navigateToComment(target.entityId); } if (target.entityType === 'trackedChange') { - return await this.#navigateToTrackedChange(target.entityId); + return await this.#navigateToTrackedChange(target.entityId, resolveStoryKeyFromAddress(target.story)); } } @@ -6409,10 +6934,18 @@ export class PresentationEditor extends EventEmitter { return true; } - async #navigateToTrackedChange(entityId: string): Promise { + async #navigateToTrackedChange(entityId: string, storyKey?: string): Promise { const editor = this.#editor; if (!editor) return false; + if (storyKey && storyKey !== BODY_STORY_KEY) { + if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { + return true; + } + + return this.#scrollToRenderedTrackedChange(entityId, storyKey); + } + const setCursorById = editor.commands?.setCursorById; // Try direct cursor placement, then scroll to the new selection. @@ -6423,7 +6956,9 @@ export class PresentationEditor extends EventEmitter { // Fall back to resolving the tracked change position and scrolling. const resolved = resolveTrackedChange(editor, entityId); - if (!resolved) return false; + if (!resolved) { + return this.#scrollToRenderedTrackedChange(entityId); + } // Try with the raw ID (tracked changes may use a different internal ID). if (typeof setCursorById === 'function' && resolved.rawId !== entityId) { @@ -6445,6 +6980,57 @@ export class PresentationEditor extends EventEmitter { return true; } + #navigateToActiveStoryTrackedChange(entityId: string, storyKey: string): boolean { + const activeSurface = this.#getActiveTrackedChangeStorySurface(); + if (!activeSurface || activeSurface.storyKey !== storyKey) { + return false; + } + + const sessionEditor = activeSurface.editor; + const setCursorById = sessionEditor.commands?.setCursorById; + + if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) { + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + + const resolved = resolveTrackedChange(sessionEditor, entityId); + if (!resolved) { + return false; + } + + if (typeof setCursorById === 'function' && resolved.rawId !== entityId) { + if (setCursorById(resolved.rawId, { preferredActiveThreadId: resolved.rawId })) { + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + } + + sessionEditor.commands?.setTextSelection?.({ from: resolved.from, to: resolved.from }); + this.#focusAndRevealActiveStorySelection(sessionEditor); + return true; + } + + #focusAndRevealActiveStorySelection(editor: Editor): void { + editor.view?.focus?.(); + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + } + + async #scrollToRenderedTrackedChange(entityId: string, storyKey?: string): Promise { + const candidates = this.#findRenderedTrackedChangeElements(entityId, storyKey); + if (!candidates.length) { + return false; + } + + try { + candidates[0]?.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + return true; + } catch { + return false; + } + } + /** * Navigate to a bookmark/anchor in the current document (e.g., TOC links). * @@ -6658,6 +7244,87 @@ export class PresentationEditor extends EventEmitter { ); } + #computeNoteDomCaretRect(context: NoteLayoutContext, pos: number): LayoutRect | null { + const layout = this.#layoutState.layout; + const pageCount = layout?.pages?.length ?? 0; + if (pageCount === 0) { + return null; + } + + const noteBlockIds = this.#collectNoteBlockIds(context); + if (noteBlockIds.size === 0) { + return null; + } + + const zoom = + typeof this.#layoutOptions.zoom === 'number' && + Number.isFinite(this.#layoutOptions.zoom) && + this.#layoutOptions.zoom > 0 + ? this.#layoutOptions.zoom + : 1; + const pageStride = this.#getBodyPageHeight() + (layout?.pageGap ?? 0); + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const pageElement = this.#getPageElement(pageIndex); + if (!pageElement) { + continue; + } + + const pageRect = pageElement.getBoundingClientRect(); + const noteFragments = Array.from(pageElement.querySelectorAll('[data-block-id]')).filter((element) => + noteBlockIds.has(element.getAttribute('data-block-id') ?? ''), + ); + + for (const fragmentElement of noteFragments) { + const textRuns = fragmentElement.querySelectorAll('[data-pm-start][data-pm-end]'); + for (const runElement of Array.from(textRuns)) { + const pmStart = Number(runElement.dataset.pmStart); + const pmEnd = Number(runElement.dataset.pmEnd); + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || pos < pmStart || pos > pmEnd) { + continue; + } + + const textNode = Array.from(runElement.childNodes).find( + (node): node is Text => node.nodeType === Node.TEXT_NODE, + ); + if (!textNode) { + continue; + } + + const charIndex = Math.max(0, Math.min(textNode.length, pos - pmStart)); + const range = runElement.ownerDocument?.createRange(); + if (!range) { + continue; + } + + range.setStart(textNode, charIndex); + range.setEnd(textNode, charIndex); + const rect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + continue; + } + + const localX = (rect.left - pageRect.left) / zoom; + const localY = (rect.top - pageRect.top) / zoom; + const height = Math.max(1, rect.height / zoom); + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + continue; + } + + return { + pageIndex, + x: localX, + y: pageIndex * pageStride + localY, + width: 1, + height, + }; + } + } + } + + return null; + } + #computeNoteCaretRect(pos: number): LayoutRect | null { const context = this.#buildActiveNoteLayoutContext(); const layout = this.#layoutState.layout; @@ -6665,6 +7332,11 @@ export class PresentationEditor extends EventEmitter { return null; } + const domRect = this.#computeNoteDomCaretRect(context, pos); + if (domRect) { + return domRect; + } + const geometry = computeCaretLayoutRectGeometryFromHelper( { layout, @@ -6678,7 +7350,18 @@ export class PresentationEditor extends EventEmitter { pos, true, ); - return geometry ? { ...geometry, width: 1 } : null; + if (!geometry) { + return null; + } + + const pageStride = this.#getBodyPageHeight() + (layout.pageGap ?? 0); + return { + pageIndex: geometry.pageIndex, + x: geometry.x, + y: geometry.pageIndex * pageStride + geometry.y, + width: 1, + height: geometry.height, + }; } #syncTrackedChangesPreferences(): boolean { @@ -7314,7 +7997,7 @@ export class PresentationEditor extends EventEmitter { * In hidden-host mode this also renders the caret from the active story * editor's hidden DOM geometry. */ - #updateHeaderFooterSelection() { + #updateHeaderFooterSelection(shouldScrollIntoView = false) { this.#clearSelectedFieldAnnotationClass(); if (!this.#localSelectionLayer) { @@ -7358,6 +8041,9 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render header/footer caret:', error); } } + if (shouldScrollIntoView) { + this.#scrollActiveEndIntoView(caretRect.pageIndex); + } return; } @@ -7387,9 +8073,18 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render header/footer selection rects:', error); } } + + if (shouldScrollIntoView) { + const selectionHead = activeEditor?.state?.selection?.head ?? to; + const headCaretRect = this.#computeHeaderFooterCaretRect(selectionHead); + const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex; + if (Number.isFinite(headPageIndex)) { + this.#scrollActiveEndIntoView(headPageIndex!); + } + } } - #updateNoteSelection() { + #updateNoteSelection(shouldScrollIntoView = false) { this.#clearSelectedFieldAnnotationClass(); if (!this.#localSelectionLayer) { @@ -7435,6 +8130,9 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render note caret:', error); } } + if (shouldScrollIntoView) { + this.#scrollActiveEndIntoView(caretRect.pageIndex); + } return; } @@ -7457,6 +8155,15 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render note selection rects:', error); } } + + if (shouldScrollIntoView) { + const selectionHead = activeEditor?.state?.selection?.head ?? to; + const headCaretRect = this.#computeNoteCaretRect(selectionHead); + const headPageIndex = headCaretRect?.pageIndex ?? rects.at(-1)?.pageIndex ?? rects[0]?.pageIndex; + if (Number.isFinite(headPageIndex)) { + this.#scrollActiveEndIntoView(headPageIndex!); + } + } } #dismissErrorBanner() { @@ -7494,3 +8201,28 @@ export class PresentationEditor extends EventEmitter { return this.#documentMode === 'viewing'; } } + +function escapeAttrValue(value: string): string { + const cssApi = + typeof globalThis === 'object' && globalThis && 'CSS' in globalThis + ? (globalThis.CSS as { escape?: (input: string) => string } | undefined) + : undefined; + + if (typeof cssApi?.escape === 'function') { + return cssApi.escape(value); + } + + return value.replace(/["\\]/g, (char) => `\\${char}`); +} + +function resolveStoryKeyFromAddress(story: StoryLocator | unknown): string | undefined { + if (!isStoryLocator(story)) { + return undefined; + } + + try { + return buildStoryKey(story); + } catch { + return undefined; + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 007d40173a..99a1dc097e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -426,6 +426,9 @@ export class HeaderFooterSessionManager { */ setDocumentMode(mode: 'editing' | 'viewing' | 'suggesting'): void { this.#documentMode = mode; + if (this.#activeEditor) { + this.#applyChildEditorDocumentMode(this.#activeEditor, mode); + } } /** @@ -734,8 +737,7 @@ export class HeaderFooterSessionManager { const storySessionManager = this.#deps?.getStorySessionManager?.() ?? null; if (this.#activeEditor) { - this.#activeEditor.setEditable(false); - this.#activeEditor.setOptions({ documentMode: 'viewing' }); + this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing'); } this.#teardownActiveEditorEventBridge(); @@ -815,8 +817,7 @@ export class HeaderFooterSessionManager { // Clean up previous session if switching between pages while in editing mode if (this.#session.mode !== 'body') { if (this.#activeEditor) { - this.#activeEditor.setEditable(false); - this.#activeEditor.setOptions({ documentMode: 'viewing' }); + this.#applyChildEditorDocumentMode(this.#activeEditor, 'viewing'); } this.#teardownActiveEditorEventBridge(); this.#overlayManager.hideEditingOverlay(); @@ -967,8 +968,7 @@ export class HeaderFooterSessionManager { } try { - editor.setEditable(true); - editor.setOptions({ documentMode: 'editing' }); + this.#applyChildEditorDocumentMode(editor, this.#documentMode); // Move caret to end of content try { @@ -1038,6 +1038,32 @@ export class HeaderFooterSessionManager { } } + #applyChildEditorDocumentMode(editor: Editor, mode: 'editing' | 'viewing' | 'suggesting'): void { + const pm = editor.view?.dom ?? null; + + if (mode === 'viewing') { + editor.commands?.enableTrackChangesShowOriginal?.(); + editor.setOptions?.({ documentMode: 'viewing' }); + editor.setEditable?.(false); + } else if (mode === 'suggesting') { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.enableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'suggesting' }); + editor.setEditable?.(true); + } else { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.disableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'editing' }); + editor.setEditable?.(true); + } + + if (pm instanceof HTMLElement) { + pm.setAttribute('aria-readonly', mode === 'viewing' ? 'true' : 'false'); + pm.setAttribute('documentmode', mode); + pm.classList.toggle('view-mode', mode === 'viewing'); + } + } + #validateEditPermission(): { allowed: boolean; reason?: string } { if (this.#deps?.isViewLocked()) { return { allowed: false, reason: 'documentMode' }; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts index f7d6f7e8be..3e4bdff4a3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts @@ -5,6 +5,12 @@ export class PresentationInputBridge { #windowRoot: Window; #layoutSurfaces: Set; #getTargetDom: () => HTMLElement | null; + #getTargetEditor?: () => { + focus?: () => void; + view?: { + dom?: HTMLElement | null; + }; + } | null; /** Callback that returns whether the editor is in an editable mode (editing/suggesting vs viewing) */ #isEditable: () => boolean; #onTargetChanged?: (target: HTMLElement | null) => void; @@ -27,6 +33,8 @@ export class PresentationInputBridge { * @param onTargetChanged - Optional callback invoked when the target editor DOM element changes * @param options - Optional configuration including: * - useWindowFallback: Whether to attach window-level event listeners as fallback + * - getTargetEditor: Returns the active editor so focus restoration can + * use editor-aware focus logic instead of raw DOM focus */ constructor( windowRoot: Window, @@ -34,11 +42,20 @@ export class PresentationInputBridge { getTargetDom: () => HTMLElement | null, isEditable: () => boolean, onTargetChanged?: (target: HTMLElement | null) => void, - options?: { useWindowFallback?: boolean }, + options?: { + useWindowFallback?: boolean; + getTargetEditor?: () => { + focus?: () => void; + view?: { + dom?: HTMLElement | null; + }; + } | null; + }, ) { this.#windowRoot = windowRoot; this.#layoutSurfaces = new Set([layoutSurface]); this.#getTargetDom = getTargetDom; + this.#getTargetEditor = options?.getTargetEditor; this.#isEditable = isEditable; this.#onTargetChanged = onTargetChanged; this.#listeners = []; @@ -46,6 +63,15 @@ export class PresentationInputBridge { } bind() { + if (this.#useWindowFallback) { + this.#addListener('keydown', this.#captureStaleKeyboardEvent, this.#windowRoot, true); + this.#addListener('beforeinput', this.#captureStaleTextEvent, this.#windowRoot, true); + this.#addListener('input', this.#captureStaleTextEvent, this.#windowRoot, true); + this.#addListener('compositionstart', this.#captureStaleCompositionEvent, this.#windowRoot, true); + this.#addListener('compositionupdate', this.#captureStaleCompositionEvent, this.#windowRoot, true); + this.#addListener('compositionend', this.#captureStaleCompositionEvent, this.#windowRoot, true); + } + const keyboardTargets = this.#getListenerTargets(); keyboardTargets.forEach((target) => { this.#addListener('keydown', this.#forwardKeyboardEvent, target); @@ -120,12 +146,30 @@ export class PresentationInputBridge { } #dispatchToTarget(originalEvent: Event, synthetic: Event) { - if (this.#destroyed) return; - const target = this.#getTargetDom(); - this.#currentTarget = target; + const target = this.#resolveDispatchTarget(); if (!target) return; + this.#dispatchToResolvedTarget(originalEvent, synthetic, target); + } + + #dispatchToResolvedTarget( + originalEvent: Event, + synthetic: Event, + target: HTMLElement, + options?: { focusTarget?: boolean; suppressOriginal?: boolean }, + ) { + if (this.#destroyed) return; const isConnected = (target as { isConnected?: boolean }).isConnected; if (isConnected === false) return; + + if (options?.suppressOriginal) { + this.#suppressOriginalEvent(originalEvent); + } + + if (options?.focusTarget) { + this.#focusTargetDom(target); + } + + this.#currentTarget = target; try { const canceled = !target.dispatchEvent(synthetic) || synthetic.defaultPrevented; if (canceled) { @@ -138,6 +182,91 @@ export class PresentationInputBridge { } } + #resolveDispatchTarget(): HTMLElement | null { + const target = this.#getTargetDom(); + this.#currentTarget = target; + if (!target) return null; + const isConnected = (target as { isConnected?: boolean }).isConnected; + if (isConnected === false) return null; + return target; + } + + #focusTargetDom(target: HTMLElement) { + const targetEditor = this.#getTargetEditor?.() ?? null; + const targetEditorDom = targetEditor?.view?.dom ?? null; + if (targetEditorDom === target && typeof targetEditor?.focus === 'function') { + targetEditor.focus(); + return; + } + + const doc = target.ownerDocument ?? document; + const active = doc.activeElement as HTMLElement | null; + const activeIsTarget = active === target || (!!active && target.contains(active)); + if (activeIsTarget) { + return; + } + + try { + target.focus({ preventScroll: true }); + } catch { + target.focus(); + } + } + + #suppressOriginalEvent(event: Event) { + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === 'function') { + event.stopImmediatePropagation(); + } + } + + /** + * Resolve a hidden editor DOM that still owns native focus even though a + * different editor surface is currently active. + * + * This happens when body focus survives or is restored while a footnote / + * header / footer session is visually active. Native input then targets the + * stale hidden editor directly, bypassing the visible-surface bridge unless we + * intercept and reroute it. + */ + #resolveStaleEditorOrigin(event: Event): { activeTarget: HTMLElement; staleEditorTarget: HTMLElement } | null { + const activeTarget = this.#resolveDispatchTarget(); + if (!activeTarget) { + return null; + } + + if (this.#isEventOnActiveTarget(event)) { + return null; + } + + if (this.#isInLayoutSurface(event)) { + return null; + } + + if (isInRegisteredSurface(event)) { + return null; + } + + const originNode = event.target as Node | null; + const originElement = + originNode instanceof HTMLElement + ? originNode + : originNode?.parentElement instanceof HTMLElement + ? originNode.parentElement + : null; + const staleEditorTarget = originElement?.closest?.('.ProseMirror[contenteditable="true"]') as HTMLElement | null; + + if (!staleEditorTarget || staleEditorTarget === activeTarget) { + return null; + } + + return { + activeTarget, + staleEditorTarget, + }; + } + /** * Forwards keyboard events to the hidden editor, skipping IME composition events * and plain character keys (which are handled by beforeinput instead). @@ -178,6 +307,47 @@ export class PresentationInputBridge { this.#dispatchToTarget(event, synthetic); } + #captureStaleKeyboardEvent(event: KeyboardEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + // Plain text and IME composition complete through beforeinput/input. + // Restore the active editor view first so the browser routes the follow-up + // text events into the current story surface instead of the stale body DOM. + // Non-text commands (Backspace, Enter, arrows, shortcuts) must also be + // rerouted here because there may be no beforeinput. + this.#focusTargetDom(staleOrigin.activeTarget); + if (this.#isCompositionKeyboardEvent(event) || this.#isPlainCharacterKey(event)) { + return; + } + + const synthetic = new KeyboardEvent(event.type, { + key: event.key, + code: event.code, + location: event.location, + repeat: event.repeat, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + bubbles: true, + cancelable: true, + }); + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards text input events (beforeinput) to the hidden editor. * Uses microtask deferral for cooperative handling. @@ -225,6 +395,39 @@ export class PresentationInputBridge { queueMicrotask(dispatchSyntheticEvent); } + #captureStaleTextEvent(event: InputEvent | TextEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + let synthetic: Event; + if (typeof InputEvent !== 'undefined') { + synthetic = new InputEvent(event.type, { + data: (event as InputEvent).data ?? (event as TextEvent).data ?? null, + inputType: (event as InputEvent).inputType ?? 'insertText', + dataTransfer: (event as InputEvent).dataTransfer ?? null, + isComposing: (event as InputEvent).isComposing ?? false, + bubbles: true, + cancelable: true, + }); + } else { + synthetic = new Event(event.type, { bubbles: true, cancelable: true }); + } + + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards composition events (compositionstart, compositionupdate, compositionend) * to the hidden editor for IME input handling. @@ -255,6 +458,36 @@ export class PresentationInputBridge { this.#dispatchToTarget(event, synthetic); } + #captureStaleCompositionEvent(event: CompositionEvent) { + if (!this.#isEditable()) { + return; + } + if (event.defaultPrevented) { + return; + } + + const staleOrigin = this.#resolveStaleEditorOrigin(event); + if (!staleOrigin) { + return; + } + + let synthetic: Event; + if (typeof CompositionEvent !== 'undefined') { + synthetic = new CompositionEvent(event.type, { + data: event.data ?? '', + bubbles: true, + cancelable: true, + }); + } else { + synthetic = new Event(event.type, { bubbles: true, cancelable: true }); + } + + this.#dispatchToResolvedTarget(event, synthetic, staleOrigin.activeTarget, { + focusTarget: true, + suppressOriginal: true, + }); + } + /** * Forwards context menu events to the hidden editor. * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 9a73cadd3b..9f323f53b2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -6,14 +6,15 @@ * * ## Key Concepts * - * - `pmStart`/`pmEnd`: ProseMirror document positions that map layout elements - * back to their source positions in the editor. Used for selection, cursor - * placement, and click-to-position functionality. - * * - `data-sd-footnote-number`: A data attribute marking the superscript number * run (e.g., "1") at the start of footnote content. Used to distinguish the * marker from actual footnote text during rendering and selection. * + * The synthetic marker is visual chrome, not part of the editable note story. + * It must not carry `pmStart`/`pmEnd`, otherwise the rendered marker consumes + * horizontal space that the hidden story editor does not own. That creates + * caret drift and inaccurate click-to-position at the start of the note. + * * @module presentation-editor/layout/FootnotesBuilder */ @@ -24,6 +25,8 @@ import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; +import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; +import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; // Re-export types for consumers export type { FootnoteReference, FootnotesLayoutInput }; @@ -125,9 +128,10 @@ export function buildFootnotesInput( try { // Deep clone to prevent mutation of the original converter data const clonedContent = JSON.parse(JSON.stringify(content)); - const footnoteDoc = { type: 'doc', content: clonedContent }; + const footnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); const result = toFlowBlocks(footnoteDoc, { blockIdPrefix: `footnote-${id}-`, + storyKey: buildStoryKey({ kind: 'story', storyType: 'footnote', noteId: id }), enableRichHyperlinks: true, themeColors: themeColors as never, converterContext: converterContext as never, @@ -167,25 +171,6 @@ function isFootnoteMarker(run: Run): boolean { return Boolean(run.dataAttrs?.[FOOTNOTE_MARKER_DATA_ATTR]); } -/** - * Finds the first run with valid ProseMirror position data. - * Used to inherit position info for the marker run. - * - * @param runs - Array of runs to search - * @returns The first run with pmStart/pmEnd, or undefined - */ -function findRunWithPositions(runs: Run[]): Run | undefined { - return runs.find((r) => { - if (isFootnoteMarker(r)) return false; - return ( - typeof r.pmStart === 'number' && - Number.isFinite(r.pmStart) && - typeof r.pmEnd === 'number' && - Number.isFinite(r.pmEnd) - ); - }); -} - /** * Resolves the display number for a footnote. * Falls back to 1 if the footnote ID is not in the mapping or invalid. @@ -211,33 +196,6 @@ function resolveMarkerText(value: unknown): string { return String(value ?? ''); } -/** - * Computes the PM position range for the marker run. - * - * The marker inherits position info from an existing run so that clicking - * on the footnote number positions the cursor correctly. The end position - * is clamped to not exceed the original run's range. - * - * @param baseRun - The run to inherit positions from - * @param markerLength - Length of the marker text - * @returns Object with pmStart and pmEnd, or nulls if no base run - */ -function computeMarkerPositions( - baseRun: Run | undefined, - markerLength: number, -): { pmStart: number | null; pmEnd: number | null } { - if (baseRun?.pmStart == null) { - return { pmStart: null, pmEnd: null }; - } - - const pmStart = baseRun.pmStart; - // Clamp pmEnd to not exceed the base run's end position - const pmEnd = - baseRun.pmEnd != null ? Math.max(pmStart, Math.min(baseRun.pmEnd, pmStart + markerLength)) : pmStart + markerLength; - - return { pmStart, pmEnd }; -} - function resolveMarkerFontFamily(firstTextRun: Run | undefined): string { return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY; } @@ -254,11 +212,7 @@ function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number { return DEFAULT_MARKER_FONT_SIZE; } -function buildMarkerRun( - markerText: string, - firstTextRun: Run | undefined, - positions: { pmStart: number | null; pmEnd: number | null }, -): Run { +function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run { const markerRun: Run = { kind: 'text', text: markerText, @@ -274,8 +228,6 @@ function buildMarkerRun( markerRun.letterSpacing = firstTextRun.letterSpacing; } if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; - if (positions.pmStart != null) markerRun.pmStart = positions.pmStart; - if (positions.pmEnd != null) markerRun.pmEnd = positions.pmEnd; return markerRun; } @@ -292,8 +244,8 @@ function syncMarkerRun(target: Run, source: Run): void { target.color = source.color; target.vertAlign = source.vertAlign; target.baselineShift = source.baselineShift; - target.pmStart = source.pmStart ?? target.pmStart; - target.pmEnd = source.pmEnd ?? target.pmEnd; + delete target.pmStart; + delete target.pmEnd; } /** @@ -303,7 +255,8 @@ function syncMarkerRun(target: Run, source: Run): void { * number rendered as a normal digit with superscript styling. This function * prepends that marker to the first paragraph's runs. * - * If a marker already exists, updates its PM positions if missing. + * If a marker already exists, normalizes it back to the synthetic visual-only + * shape so stale PM ranges do not leak into the active editing surface. * Modifies the blocks array in place. * * @param blocks - Array of FlowBlocks to modify @@ -321,11 +274,8 @@ function ensureFootnoteMarker( const runs: Run[] = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; const displayNumber = resolveDisplayNumber(id, footnoteNumberById); const markerText = resolveMarkerText(displayNumber); - - const baseRun = findRunWithPositions(runs); - const positions = computeMarkerPositions(baseRun, markerText.length); const firstTextRun = runs.find((run) => typeof run.text === 'string' && !isFootnoteMarker(run)); - const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun, positions); + const normalizedMarkerRun = buildMarkerRun(markerText, firstTextRun); // Check if marker already exists const existingMarker = runs.find(isFootnoteMarker); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 9076c00d2f..b64cb2c7d0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -103,6 +103,17 @@ function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { return null; } +function isSameRenderedNoteTarget( + left: RenderedNoteTarget | null | undefined, + right: RenderedNoteTarget | null | undefined, +): boolean { + if (!left || !right) { + return false; + } + + return left.storyType === right.storyType && left.noteId === right.noteId; +} + function getCommentHighlightThreadIds(target: EventTarget | null): string[] { if (!(target instanceof Element)) { return []; @@ -393,6 +404,8 @@ export type EditorInputCallbacks = { notifyDragSelectionEnded?: () => void; /** Hit test table at coordinates */ hitTestTable?: (x: number, y: number) => TableHitResult | null; + /** Hit test the currently active editing surface */ + hitTest?: (clientX: number, clientY: number) => PositionHit | null; /** Activate a rendered note session from a visible note block click */ activateRenderedNoteSession?: ( target: RenderedNoteTarget, @@ -638,6 +651,18 @@ export class EditorInputManager { return this.#lastSelectedImageBlockId; } + /** + * Resets click-derived interaction state when the active editing surface + * changes (for example body -> footnote or footnote -> header). + * + * Without this, a single click in the previous surface can be mistaken for + * the first click of a double/triple click in the next surface. + */ + notifyTargetChanged(): void { + this.#resetMultiClickTracking(); + this.#pendingMarginClick = null; + } + /** Drag anchor page index */ get dragAnchorPageIndex(): number | null { return this.#dragAnchorPageIndex; @@ -692,6 +717,12 @@ export class EditorInputManager { this.#cellDragMode = 'none'; } + #resetMultiClickTracking(): void { + this.#clickCount = 0; + this.#lastClickTime = 0; + this.#lastClickPosition = null; + } + #registerPointerClick(event: MouseEvent): number { const nextState = registerPointerClickFromHelper( event, @@ -715,10 +746,86 @@ export class EditorInputManager { } #getFirstTextPosition(): number { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); return getFirstTextPositionFromHelper(editor?.state?.doc ?? null); } + #resolveBodyPointerHit( + layoutState: ReturnType, + normalized: { x: number; y: number }, + clientX: number, + clientY: number, + ): PositionHit | null { + const viewportHost = this.#deps?.getViewportHost(); + const pageGeometryHelper = this.#deps?.getPageGeometryHelper(); + if (!viewportHost) { + return null; + } + + return ( + resolvePointerPositionHit({ + layout: layoutState.layout, + blocks: layoutState.blocks, + measures: layoutState.measures, + containerPoint: normalized, + domContainer: viewportHost, + clientX, + clientY, + geometryHelper: pageGeometryHelper ?? undefined, + }) ?? null + ); + } + + #resolveSelectionPointerHit(options: { + layoutState: ReturnType; + normalized: { x: number; y: number }; + clientX: number; + clientY: number; + editor: Editor; + useActiveSurfaceHitTest: boolean; + }): { rawHit: PositionHit | null; hit: PositionHit | null } { + const { layoutState, normalized, clientX, clientY, editor, useActiveSurfaceHitTest } = options; + const doc = editor.state?.doc; + const rawHit = + useActiveSurfaceHitTest && this.#callbacks.hitTest + ? this.#callbacks.hitTest(clientX, clientY) + : this.#resolveBodyPointerHit(layoutState, normalized, clientX, clientY); + + if (!rawHit || !doc) { + return { rawHit, hit: null }; + } + + if (useActiveSurfaceHitTest) { + return { + rawHit, + hit: { + ...rawHit, + pos: clamp(rawHit.pos, 0, doc.content.size), + }, + }; + } + + const epochMapper = this.#deps?.getEpochMapper(); + if (!epochMapper) { + return { rawHit, hit: null }; + } + + const mapped = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1); + if (!mapped.ok) { + debugLog('warn', 'pointer mapping failed', mapped); + return { rawHit, hit: null }; + } + + return { + rawHit, + hit: { + ...rawHit, + pos: clamp(mapped.pos, 0, doc.content.size), + layoutEpoch: mapped.toEpoch, + }, + }; + } + #calculateExtendedSelection( anchor: number, head: number, @@ -1095,16 +1202,46 @@ export class EditorInputManager { } const bodyEditor = this.#deps.getEditor(); - if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { - return; - } + const layoutState = this.#deps.getLayoutState(); + const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY); - if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { - return; - } + // Check header/footer session state + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + let activeStorySession = this.#deps.getActiveStorySession?.() ?? null; + let activeNoteSession = activeStorySession?.kind === 'note' ? activeStorySession : null; + const activeNoteTarget = this.#getActiveRenderedNoteTarget(); - const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) { + if (clickedNoteTarget && !isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget)) { + if (!isDraggableAnnotation) { + event.preventDefault(); + } + const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { + clientX: event.clientX, + clientY: event.clientY, + }); + if (activated) { + return; + } + this.#focusEditor(); + return; + } + + if (!clickedNoteTarget && activeNoteSession) { + this.#callbacks.exitActiveStorySession?.(); + } + + const isActiveStorySurface = sessionMode !== 'body' || activeNoteSession != null; + if (!isActiveStorySurface) { + if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { + return; + } + + if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { + return; + } + } + this.#handleClickWithoutLayout(event, isDraggableAnnotation); return; } @@ -1115,27 +1252,8 @@ export class EditorInputManager { const { x, y } = normalizedPoint; this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null; - const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? ''; - const clickedNoteTarget = parseRenderedNoteTarget(clickedBlockId); - - // Check header/footer session state - const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; - const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; - const activeNoteSession = activeStorySession?.kind === 'note' ? activeStorySession : null; - const activeNoteTarget = - activeNoteSession && - (activeNoteSession.locator.storyType === 'footnote' || activeNoteSession.locator.storyType === 'endnote') - ? { - storyType: activeNoteSession.locator.storyType, - noteId: activeNoteSession.locator.noteId, - } - : null; - if (clickedNoteTarget) { - const isSameActiveNote = - activeNoteTarget?.storyType === clickedNoteTarget.storyType && - activeNoteTarget.noteId === clickedNoteTarget.noteId; + const isSameActiveNote = isSameRenderedNoteTarget(activeNoteTarget, clickedNoteTarget); if (!isSameActiveNote) { if (!isDraggableAnnotation) event.preventDefault(); const activated = this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { @@ -1151,9 +1269,23 @@ export class EditorInputManager { } } else if (activeNoteSession) { this.#callbacks.exitActiveStorySession?.(); + activeStorySession = null; + activeNoteSession = null; + } + + const isActiveStorySurface = sessionMode !== 'body' || activeStorySession != null; + if (!isActiveStorySurface) { + if (this.#handleSingleCommentHighlightClick(event, target, bodyEditor)) { + return; + } + + if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { + return; + } } const isNoteEditing = activeNoteSession != null; + const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null; const editor = sessionMode === 'body' && !isNoteEditing ? bodyEditor : this.#deps.getActiveEditor(); if (sessionMode !== 'body') { if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY)) @@ -1174,39 +1306,15 @@ export class EditorInputManager { } } - // Get hit position - const viewportHost = this.#deps.getViewportHost(); - const pageGeometryHelper = this.#deps.getPageGeometryHelper(); - const rawHit = resolvePointerPositionHit({ - layout: layoutState.layout, - blocks: layoutState.blocks, - measures: layoutState.measures, - containerPoint: { x, y }, - domContainer: viewportHost, + const { rawHit, hit } = this.#resolveSelectionPointerHit({ + layoutState, + normalized: { x, y }, clientX: event.clientX, clientY: event.clientY, - geometryHelper: pageGeometryHelper ?? undefined, + editor, + useActiveSurfaceHitTest, }); - const doc = editor.state?.doc; - const epochMapper = this.#deps.getEpochMapper(); - const mapped = - rawHit && doc && !isNoteEditing - ? epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1) - : null; - - if (mapped && !mapped.ok) { - debugLog('warn', 'pointerdown mapping failed', mapped); - } - - const hit = - rawHit && doc - ? isNoteEditing - ? { ...rawHit, pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)), layoutEpoch: rawHit.layoutEpoch } - : mapped?.ok - ? { ...rawHit, pos: Math.max(0, Math.min(mapped.pos, doc.content.size)), layoutEpoch: mapped.toEpoch } - : null - : null; this.#debugLastHit = hit ? { source: 'dom', pos: rawHit?.pos ?? null, layoutEpoch: rawHit?.layoutEpoch ?? null, mappedPos: hit.pos } @@ -1285,11 +1393,16 @@ export class EditorInputManager { } // Check for image/fragment hit - const fragmentHit = getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos); + const fragmentHit = useActiveSurfaceHitTest + ? null + : getFragmentAtPosition(layoutState.layout, layoutState.blocks, layoutState.measures, rawHit.pos); // Handle inline image click const targetImg = (event.target as HTMLElement | null)?.closest?.('img') as HTMLImageElement | null; - if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return; + if (!useActiveSurfaceHitTest) { + const epochMapper = this.#deps.getEpochMapper(); + if (this.#handleInlineImageClick(event, targetImg, rawHit, doc, epochMapper)) return; + } // Handle atomic fragment (image/drawing) click if (this.#handleFragmentClick(event, fragmentHit, hit, doc)) return; @@ -1355,6 +1468,7 @@ export class EditorInputManager { } // Capture pointer for reliable drag tracking + const viewportHost = this.#deps.getViewportHost(); if (typeof viewportHost.setPointerCapture === 'function') { viewportHost.setPointerCapture(event.pointerId); } @@ -1549,10 +1663,23 @@ export class EditorInputManager { const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); if (!normalized) return; - const targetBlockId = - (target?.closest?.('[data-block-id]') as HTMLElement | null)?.getAttribute?.('data-block-id') ?? ''; - const clickedNoteTarget = parseRenderedNoteTarget(targetBlockId); + const clickedNoteTarget = this.#resolveRenderedNoteTargetAtPointer(target, event.clientX, event.clientY); if (clickedNoteTarget) { + if (isSameRenderedNoteTarget(this.#getActiveRenderedNoteTarget(), clickedNoteTarget)) { + // Pointerdown already updated selection inside the live note session. + // Re-activating the same note here would remount the hidden editor and + // wipe out the word/paragraph selection that the multi-click logic just set. + // + // The activation gesture itself only registers one click inside the live + // note, so its trailing dblclick can leave a stale single-click marker + // behind. Clear only that activation residue and preserve genuine active + // multi-click state for triple-click paragraph selection. + if (this.#clickCount <= 1) { + this.#resetMultiClickTracking(); + } + return; + } + event.preventDefault(); event.stopPropagation(); this.#callbacks.activateRenderedNoteSession?.(clickedNoteTarget, { @@ -2038,7 +2165,7 @@ export class EditorInputManager { } #handleShiftClick(event: PointerEvent, headPos: number): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); if (!editor) return; const anchor = editor.state.selection.anchor; @@ -2067,26 +2194,26 @@ export class EditorInputManager { this.#pendingMarginClick = null; this.#dragLastPointer = { clientX, clientY, x: normalized.x, y: normalized.y }; - const viewportHost = this.#deps.getViewportHost(); - const pageGeometryHelper = this.#deps.getPageGeometryHelper(); - - const rawHit = resolvePointerPositionHit({ - layout: layoutState.layout, - blocks: layoutState.blocks, - measures: layoutState.measures, - containerPoint: { x: normalized.x, y: normalized.y }, - domContainer: viewportHost, + const activeStorySession = this.#deps.getActiveStorySession?.() ?? null; + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + const useActiveSurfaceHitTest = sessionMode !== 'body' || activeStorySession != null; + const editor = useActiveSurfaceHitTest + ? this.#deps.getActiveEditor() + : (this.#deps.getEditor() as ReturnType); + const { rawHit, hit } = this.#resolveSelectionPointerHit({ + layoutState, + normalized: { x: normalized.x, y: normalized.y }, clientX, clientY, - geometryHelper: pageGeometryHelper ?? undefined, + editor, + useActiveSurfaceHitTest, }); - if (!rawHit) return; + if (!rawHit || !hit) return; - // Don't extend selection into footnote lines - if (isFootnoteBlockId(rawHit.blockId)) return; + // Don't extend a body selection into read-only footnote content. + if (!useActiveSurfaceHitTest && isFootnoteBlockId(rawHit.blockId)) return; - const editor = this.#deps.getEditor(); const doc = editor.state?.doc; if (!doc) return; @@ -2099,21 +2226,8 @@ export class EditorInputManager { this.#callbacks.updateSelectionVirtualizationPins?.({ includeDragBuffer: true, extraPages: [rawHit.pageIndex] }); - const epochMapper = this.#deps.getEpochMapper(); - const mappedHead = epochMapper.mapPosFromLayoutToCurrentDetailed(rawHit.pos, rawHit.layoutEpoch, 1); - if (!mappedHead.ok) { - debugLog('warn', 'drag mapping failed', mappedHead); - return; - } - - const hit = { - ...rawHit, - pos: Math.max(0, Math.min(mappedHead.pos, doc.content.size)), - layoutEpoch: mappedHead.toEpoch, - }; - this.#debugLastHit = { - source: pageMounted ? 'dom' : 'geometry', + source: useActiveSurfaceHitTest || pageMounted ? 'dom' : 'geometry', pos: rawHit.pos, layoutEpoch: rawHit.layoutEpoch, mappedPos: hit.pos, @@ -2121,7 +2235,7 @@ export class EditorInputManager { this.#callbacks.updateSelectionDebugHud?.(); // Check for cell selection - const currentTableHit = this.#hitTestTable(normalized.x, normalized.y); + const currentTableHit = useActiveSurfaceHitTest ? null : this.#hitTestTable(normalized.x, normalized.y); const shouldUseCellSel = this.#shouldUseCellSelection(currentTableHit); if (shouldUseCellSel && this.#cellAnchor) { @@ -2342,8 +2456,56 @@ export class EditorInputManager { this.#callbacks.activateHeaderFooterRegion?.(region); } + #getActiveRenderedNoteTarget(): RenderedNoteTarget | null { + const activeStorySession = this.#deps?.getActiveStorySession?.() ?? null; + if (activeStorySession?.kind !== 'note') { + return null; + } + + const locator = activeStorySession.locator; + if (locator.storyType !== 'footnote' && locator.storyType !== 'endnote') { + return null; + } + + return { + storyType: locator.storyType, + noteId: locator.noteId, + }; + } + + #resolveRenderedNoteTargetAtPointer( + target: HTMLElement | null, + clientX: number, + clientY: number, + ): RenderedNoteTarget | null { + const blockIdFromTarget = target?.closest?.('[data-block-id]')?.getAttribute?.('data-block-id') ?? ''; + const parsedFromTarget = parseRenderedNoteTarget(blockIdFromTarget); + if (parsedFromTarget) { + return parsedFromTarget; + } + + const doc = this.#deps?.getViewportHost()?.ownerDocument ?? document; + if (typeof doc.elementsFromPoint !== 'function') { + return null; + } + + for (const element of doc.elementsFromPoint(clientX, clientY)) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const blockId = element.closest('[data-block-id]')?.getAttribute('data-block-id') ?? ''; + const parsed = parseRenderedNoteTarget(blockId); + if (parsed) { + return parsed; + } + } + + return null; + } + #focusEditorAtFirstPosition(): void { - const editor = this.#deps?.getEditor(); + const editor = this.#deps?.getActiveEditor() ?? this.#deps?.getEditor(); const editorDom = editor?.view?.dom as HTMLElement | undefined; if (!editorDom) return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts index 3f516aaa8f..c0aaaf3d1c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts @@ -4,6 +4,10 @@ import { StoryPresentationSessionManager } from './StoryPresentationSessionManag import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; import type { Editor } from '../../Editor.js'; import type { StoryLocator } from '@superdoc/document-api'; +import { + getLiveStorySessionCount, + resolveLiveStorySessionRuntime, +} from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; // --------------------------------------------------------------------------- // Test doubles @@ -53,6 +57,10 @@ function makeStubRuntime(editor: StubEditor, overrides: Partial = }; } +function makeHostEditor(): Editor { + return { state: { doc: { content: { size: 10 } } } } as unknown as Editor; +} + describe('StoryPresentationSessionManager', () => { let container: HTMLElement; @@ -311,4 +319,36 @@ describe('StoryPresentationSessionManager', () => { expect(session.hostWrapper).toBeNull(); expect(session.domTarget).toBe(dom); }); + + it('registers the active session editor as the live story runtime and unregisters it on exit', () => { + const hostEditor = makeHostEditor(); + const runtimeEditor = makeStubEditor(document.createElement('div')); + runtimeEditor.options = { parentEditor: hostEditor as unknown as StubEditor }; + + const sessionEditor = makeStubEditor(document.createElement('div')); + sessionEditor.options = { parentEditor: hostEditor as unknown as StubEditor }; + + const runtime = makeStubRuntime(runtimeEditor, { + locator: { kind: 'story', storyType: 'footnote', noteId: '8' }, + storyKey: 'fn:8', + kind: 'note', + }); + + const manager = new StoryPresentationSessionManager({ + resolveRuntime: () => runtime, + getMountContainer: () => container, + editorFactory: () => ({ editor: sessionEditor as unknown as Editor }), + }); + + manager.activate(runtime.locator); + + const liveRuntime = resolveLiveStorySessionRuntime(hostEditor, 'fn:8'); + expect(liveRuntime?.editor).toBe(sessionEditor); + expect(getLiveStorySessionCount(hostEditor)).toBe(1); + + manager.exit(); + + expect(resolveLiveStorySessionRuntime(hostEditor, 'fn:8')).toBeNull(); + expect(getLiveStorySessionCount(hostEditor)).toBe(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts index 4335efa56e..6548c5eeae 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts @@ -31,6 +31,7 @@ import type { Editor } from '../../Editor.js'; import type { StoryRuntime } from '../../../document-api-adapters/story-runtime/story-types.js'; import type { StoryPresentationSession, ActivateStorySessionOptions, StoryCommitPolicy } from './types.js'; import { createStoryHiddenHost } from './createStoryHiddenHost.js'; +import { registerLiveStorySessionRuntime } from '../../../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; /** * Creates (or returns) the ProseMirror editor that should back an active @@ -176,6 +177,8 @@ export class StoryPresentationSessionManager { } const domTarget = (editor.view?.dom as HTMLElement | undefined) ?? hostWrapper ?? null; + const hostEditor = resolveSessionHostEditor(editor, runtime); + const unregisterRuntime = registerLiveStorySessionRuntime(hostEditor, runtime, editor); const session = new MutableStorySession({ locator, @@ -187,6 +190,7 @@ export class StoryPresentationSessionManager { commitPolicy, shouldDisposeRuntime: runtime.cacheable === false, beforeDispose: sessionBeforeDispose, + unregisterRuntime, teardown: () => { try { factoryDispose?.(); @@ -242,6 +246,7 @@ interface MutableStorySessionInit { shouldDisposeRuntime: boolean; afterActivate?: () => void; beforeDispose?: () => void; + unregisterRuntime: () => void; teardown: () => void; } @@ -257,6 +262,7 @@ class MutableStorySession implements StoryPresentationSession { #disposed = false; #shouldDisposeRuntime: boolean; #beforeDispose?: () => void; + #unregisterRuntime: () => void; #teardown: () => void; constructor(init: MutableStorySessionInit) { @@ -269,6 +275,7 @@ class MutableStorySession implements StoryPresentationSession { this.commitPolicy = init.commitPolicy; this.#shouldDisposeRuntime = init.shouldDisposeRuntime; this.#beforeDispose = init.beforeDispose; + this.#unregisterRuntime = init.unregisterRuntime; this.#teardown = init.teardown; init.afterActivate?.(); } @@ -297,11 +304,15 @@ class MutableStorySession implements StoryPresentationSession { this.#beforeDispose?.(); } finally { try { - if (this.#shouldDisposeRuntime) { - this.runtime.dispose?.(); - } + this.#unregisterRuntime(); } finally { - this.#teardown(); + try { + if (this.#shouldDisposeRuntime) { + this.runtime.dispose?.(); + } + } finally { + this.#teardown(); + } } } } @@ -320,3 +331,7 @@ function getHostEditor(editor: Editor): Editor | null { const options = editor.options as Partial<{ parentEditor: Editor }>; return options?.parentEditor ?? null; } + +function resolveSessionHostEditor(editor: Editor, runtime: StoryRuntime): Editor { + return getHostEditor(editor) ?? getHostEditor(runtime.editor) ?? runtime.editor; +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts index 13ffa0ac7c..95ff73bc77 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts @@ -88,6 +88,26 @@ describe('DomPositionIndex', () => { expect(index.findElementAtPosition(10)).toBe(null); }); + it('skips footnote descendants when building the body DOM index', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+
+ Simple +
+
+ This +
+
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.size).toBe(1); + expect(index.findElementAtPosition(1)?.textContent).toBe('Simple'); + }); + it('correctly distributes elements across header, body, and footer sections', () => { const container = document.createElement('div'); container.innerHTML = ` diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 22b6a63c58..2b06fd9077 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -28,15 +28,14 @@ vi.mock('@superdoc/layout-bridge', () => ({ vi.mock('prosemirror-state', async (importOriginal) => { const original = await importOriginal(); + class MockTextSelection { + empty = true; + $from = { parent: { inlineContent: true } }; + static create = vi.fn(() => new MockTextSelection()); + } return { ...original, - TextSelection: { - ...original.TextSelection, - create: vi.fn(() => ({ - empty: true, - $from: { parent: { inlineContent: true } }, - })), - }, + TextSelection: MockTextSelection, }; }); @@ -152,6 +151,24 @@ describe('EditorInputManager - Footnote click selection behavior', () => { ); } + function createActiveSessionEditor(docSize = 50) { + return { + ...mockEditor, + state: { + ...mockEditor.state, + doc: { ...mockEditor.state.doc, content: { size: docSize } }, + tr: { + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), + }, + }, + view: { + ...mockEditor.view, + dispatch: vi.fn(), + }, + }; + } + it('activates a note session on direct footnote fragment click', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); @@ -179,6 +196,39 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); + it('prioritizes note activation over tracked-change highlight handling on footnote clicks', () => { + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.setAttribute('data-track-change-id', 'tc-1'); + fragmentEl.appendChild(trackedChangeEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 12, + clientY: 10, + } as PointerEventInit), + ); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'footnote', noteId: '1' }, + expect.objectContaining({ clientX: 12, clientY: 10 }), + ); + expect(mockEditor.emit).not.toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + type: expect.anything(), + }), + ); + }); + it('keeps legacy read-only behavior for stale footnote hits without a rendered footnote target', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue({ pos: 22, @@ -259,6 +309,32 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockEditor.view.focus).toHaveBeenCalled(); }); + it('does not reactivate the same note session on double-click inside the active note', () => { + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: createActiveSessionEditor(), + }); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + nestedEl.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 12, + clientY: 14, + }), + ); + + expect(activateRenderedNoteSession).not.toHaveBeenCalled(); + }); + it('does not activate a note session on semantic footnotes heading click', () => { (resolvePointerPositionHit as unknown as Mock).mockReturnValue(null); @@ -283,4 +359,188 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(activateRenderedNoteSession).not.toHaveBeenCalled(); expect(mockEditor.view.focus).toHaveBeenCalled(); }); + + it('uses story-surface hit testing for active note clicks', () => { + const activeNoteEditor = createActiveSessionEditor(); + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 41, + layoutEpoch: 7, + pageIndex: 0, + blockId: 'footnote-1-0', + column: 0, + lineIndex: -1, + })); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + nestedEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 16, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(activeNoteEditor.view.focus).toHaveBeenCalled(); + }); + + it('does not route tracked-change clicks through comment selection while a note is actively being edited', () => { + const activeNoteEditor = createActiveSessionEditor(); + (mockDeps.getActiveStorySession as Mock).mockReturnValue({ + kind: 'note', + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + editor: activeNoteEditor, + }); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeNoteEditor); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 21, + layoutEpoch: 7, + pageIndex: 0, + blockId: 'footnote-1-0', + column: 0, + lineIndex: -1, + })); + + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.setAttribute('data-track-change-id', 'tc-1'); + fragmentEl.appendChild(trackedChangeEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 16, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(mockEditor.emit).not.toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + type: expect.anything(), + }), + ); + }); + + it('uses story-surface hit testing for active header clicks', () => { + const activeHeaderEditor = createActiveSessionEditor(); + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => null) }, + }); + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 18, + layoutEpoch: 3, + pageIndex: 0, + blockId: 'header-1', + column: 0, + lineIndex: -1, + })); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 200, + height: 40, + })); + + const target = document.createElement('span'); + viewportHost.appendChild(target); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 24, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(24, 12); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + expect(activeHeaderEditor.view.focus).toHaveBeenCalled(); + }); + + it('resets multi-click state when the active editing target changes', () => { + const target = document.createElement('span'); + viewportHost.appendChild(target); + + const selectWordAt = vi.fn(() => true); + mockCallbacks.selectWordAt = selectWordAt; + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 22, + pointerId: 1, + } as PointerEventInit), + ); + viewportHost.dispatchEvent( + new PointerEventImpl('pointerup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + clientX: 18, + clientY: 22, + pointerId: 1, + } as PointerEventInit), + ); + + manager.notifyTargetChanged(); + + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 18, + clientY: 22, + pointerId: 2, + } as PointerEventInit), + ); + + expect(selectWordAt).not.toHaveBeenCalled(); + expect(TextSelection.create as unknown as Mock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index d4d0a7da6c..8bb2b63719 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -3,6 +3,7 @@ import type { EditorState } from 'prosemirror-state'; import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js'; import type { ConverterContext } from '@superdoc/pm-adapter'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import { toFlowBlocks } from '@superdoc/pm-adapter'; // Mock toFlowBlocks vi.mock('@superdoc/pm-adapter', async (importOriginal) => { @@ -147,6 +148,20 @@ describe('buildFootnotesInput', () => { expect(result?.dividerHeight).toBe(1); }); + it('stamps converted footnote blocks with the footnote story key', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const [, options] = + (toFlowBlocks as unknown as { mock: { calls: Array<[unknown, Record]> } }).mock.calls.at(-1) ?? + []; + expect(options?.storyKey).toBe('fn:1'); + }); + it('only includes footnotes that are referenced in the document', () => { const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc const converter = createMockConverter([ @@ -182,6 +197,39 @@ describe('buildFootnotesInput', () => { ?.runs?.[0]; expect(firstRun?.text).toBe('1'); expect(firstRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); + expect(firstRun).not.toHaveProperty('pmStart'); + expect(firstRun).not.toHaveProperty('pmEnd'); + }); + + it('normalizes away empty note reference runs before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ]); }); it('builds the marker as a scaled superscript run instead of a Unicode superscript glyph', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 6b785c624e..ea9605b2f7 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -43,6 +43,10 @@ function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { setOptions: vi.fn(), commands: { setTextSelection: vi.fn(), + enableTrackChanges: vi.fn(), + disableTrackChanges: vi.fn(), + enableTrackChangesShowOriginal: vi.fn(), + disableTrackChangesShowOriginal: vi.fn(), }, state: { doc: { @@ -92,7 +96,10 @@ describe('HeaderFooterSessionManager', () => { * and the editor host is at (100, 50) with size 600x120. The header region is * at localX=40, localY=30 on page 1 with bodyPageHeight=800. */ - async function setupWithZoom(zoom: number | undefined): Promise { + async function setupWithZoom( + zoom: number | undefined, + documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing', + ): Promise { const pageElement = document.createElement('div'); pageElement.dataset.pageIndex = '1'; painterHost.appendChild(pageElement); @@ -171,6 +178,7 @@ describe('HeaderFooterSessionManager', () => { manager.setDependencies(deps); manager.initialize(); + manager.setDocumentMode(documentMode); manager.setLayoutResults( [ { @@ -325,6 +333,7 @@ describe('HeaderFooterSessionManager', () => { }); manager.initialize(); + manager.setDocumentMode('suggesting'); const region = { kind: 'header' as const, @@ -345,6 +354,9 @@ describe('HeaderFooterSessionManager', () => { await vi.waitFor(() => expect(manager.activeEditor).toBe(storyEditor)); expect(overlayManager.showEditingOverlay).not.toHaveBeenCalled(); + expect(storyEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(storyEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(storyEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); expect(activate).toHaveBeenCalledWith( { kind: 'story', @@ -366,6 +378,54 @@ describe('HeaderFooterSessionManager', () => { ); }); + it('enters header edit mode in suggesting mode and enables tracked changes', async () => { + await setupWithZoom(1, 'suggesting'); + + const activeEditor = manager.activeEditor as unknown as { + commands: { + disableTrackChangesShowOriginal: ReturnType; + enableTrackChanges: ReturnType; + }; + setOptions: ReturnType; + setEditable: ReturnType; + view: { dom: HTMLElement }; + }; + + expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(activeEditor.setEditable).toHaveBeenCalledWith(true); + expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + expect(activeEditor.view.dom.getAttribute('aria-readonly')).toBe('false'); + }); + + it('updates the active header editor when the document mode changes to suggesting', async () => { + await setupWithZoom(1); + + const activeEditor = manager.activeEditor as unknown as { + commands: { + disableTrackChangesShowOriginal: ReturnType; + enableTrackChanges: ReturnType; + }; + setOptions: ReturnType; + setEditable: ReturnType; + view: { dom: HTMLElement }; + }; + + activeEditor.commands.disableTrackChangesShowOriginal.mockClear(); + activeEditor.commands.enableTrackChanges.mockClear(); + activeEditor.setOptions.mockClear(); + activeEditor.setEditable.mockClear(); + + manager.setDocumentMode('suggesting'); + + expect(activeEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(activeEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(activeEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(activeEditor.setEditable).toHaveBeenCalledWith(true); + expect(activeEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + }); + it('exits the active story session when leaving header/footer mode', async () => { const pageElement = document.createElement('div'); pageElement.dataset.pageIndex = '0'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index f2fcdad938..3e54517cb7 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -174,7 +174,7 @@ describe('PresentationEditor - footnote number marker PM position', () => { vi.clearAllMocks(); }); - it('adds pmStart/pmEnd to the data-sd-footnote-number marker run', async () => { + it('keeps the synthetic footnote number marker out of the editable PM range', async () => { editor = new PresentationEditor({ element: container }); await new Promise((r) => setTimeout(r, 100)); @@ -185,8 +185,8 @@ describe('PresentationEditor - footnote number marker PM position', () => { const markerRun = blocks?.[0]?.runs?.[0]; expect(markerRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); - expect(markerRun?.pmStart).toBe(5); - expect(markerRun?.pmEnd).toBe(6); + expect(markerRun?.pmStart).toBeUndefined(); + expect(markerRun?.pmEnd).toBeUndefined(); }); it('appends semantic footnotes as end-of-document blocks in semantic flow mode', async () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 11e4ab4740..d1880d6cf9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -23,7 +23,9 @@ const { mockMeasureBlock, mockEditorConverterStore, mockCreateHeaderFooterEditor, + mockCreateStoryEditor, createdSectionEditors, + createdStoryEditors, mockOnHeaderFooterDataUpdate, mockUpdateYdocDocxData, mockEditorOverlayManager, @@ -89,7 +91,10 @@ const { once: emitter.once, emit: emitter.emit, destroy: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })), + getUpdatedJson: vi.fn(() => ({ type: 'doc', content: [{ type: 'paragraph' }] })), setEditable: vi.fn(), + setDocumentMode: vi.fn(), setOptions: vi.fn(), commands: { setTextSelection: vi.fn(), @@ -99,8 +104,10 @@ const { content: { size: 10, }, + textBetween: vi.fn(() => 'Lazy note session'), }, }, + options: {}, view: { dom: document.createElement('div'), focus: vi.fn(), @@ -111,6 +118,7 @@ const { }; const editors: Array<{ editor: ReturnType }> = []; + const storyEditors: Array<{ editor: ReturnType }> = []; const mockFlowBlockCacheInstances: Array<{ clear: ReturnType; setHasExternalChanges: ReturnType; @@ -150,7 +158,14 @@ const { editors.push({ editor }); return editor; }), + mockCreateStoryEditor: vi.fn((parentEditor?: EditorInstance) => { + const editor = createSectionEditor(); + editor.options = { ...editor.options, parentEditor }; + storyEditors.push({ editor }); + return editor; + }), createdSectionEditors: editors, + createdStoryEditors: storyEditors, mockOnHeaderFooterDataUpdate: vi.fn(), mockUpdateYdocDocxData: vi.fn(() => Promise.resolve()), mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ @@ -324,6 +339,10 @@ vi.mock('@extensions/pagination/pagination-helpers.js', () => ({ onHeaderFooterDataUpdate: mockOnHeaderFooterDataUpdate, })); +vi.mock('../../story-editor-factory.js', () => ({ + createStoryEditor: mockCreateStoryEditor, +})); + vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); @@ -350,6 +369,7 @@ describe('PresentationEditor', () => { }; mockEditorConverterStore.mediaFiles = {}; createdSectionEditors.length = 0; + createdStoryEditors.length = 0; mockFlowBlockCacheInstances.length = 0; // Reset static instances @@ -2624,6 +2644,158 @@ describe('PresentationEditor', () => { }); }); + describe('footnote interactions', () => { + const prepareFootnoteEditor = async () => { + mockIncrementalLayout.mockResolvedValueOnce({ + layout: { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + numberText: '1', + size: { w: 612, h: 792 }, + fragments: [], + margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, + }, + ], + }, + measures: [], + }); + + mockEditorConverterStore.current = { + ...mockEditorConverterStore.current, + footnotes: [ + { + id: '1', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy note session' }] }], + }, + ], + convertedXml: { + 'word/footnotes.xml': { + elements: [ + { + name: 'w:footnotes', + elements: [ + { + name: 'w:footnote', + attributes: { 'w:id': '1' }, + elements: [{ name: 'w:p', elements: [] }], + }, + ], + }, + ], + }, + }, + }; + + editor = new PresentationEditor({ + element: container, + documentId: 'footnote-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement; + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 1000, + right: 800, + bottom: 1000, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const footnoteFragment = document.createElement('span'); + footnoteFragment.setAttribute('data-block-id', 'footnote-1-0'); + viewport.appendChild(footnoteFragment); + + return { viewport, footnoteFragment }; + }; + + const activateFootnoteSession = async () => { + const { viewport, footnoteFragment } = await prepareFootnoteEditor(); + + expect(editor.getStorySessionManager()).toBeNull(); + + footnoteFragment.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }), + ); + + await vi.waitFor(() => expect(mockCreateStoryEditor.mock.calls.length).toBeGreaterThanOrEqual(2)); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(2)); + + return { + viewport, + footnoteFragment, + sessionEditor: createdStoryEditors.at(-1)?.editor, + }; + }; + + it('activates a note editing session without enabling hidden-host header/footer rollout', async () => { + const { sessionEditor } = await activateFootnoteSession(); + + expect(editor.getStorySessionManager()).not.toBeNull(); + expect(editor.getStorySessionManager()?.getActiveSession()?.commitPolicy).toBe('continuous'); + expect(editor.getActiveEditor()).toBe(sessionEditor); + expect(sessionEditor?.setDocumentMode).toHaveBeenCalledWith('editing'); + + editor.setDocumentMode('viewing'); + expect(sessionEditor?.setDocumentMode).toHaveBeenLastCalledWith('viewing'); + expect(createdSectionEditors.length).toBe(0); + }); + + it('routes tracked-change navigation to the active note session editor', async () => { + const { sessionEditor } = await activateFootnoteSession(); + const setCursorById = vi.fn(() => true); + if (sessionEditor?.commands) { + sessionEditor.commands.setCursorById = setCursorById; + } + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-note-1', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + expect(setCursorById).toHaveBeenCalledWith('tc-note-1', { preferredActiveThreadId: 'tc-note-1' }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + }); + + it('falls back to rendered tracked-change stamps for inactive non-body stories', async () => { + const { viewport } = await prepareFootnoteEditor(); + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + + const renderedChange = document.createElement('span'); + renderedChange.dataset.trackChangeId = 'tc-footnote-2'; + renderedChange.dataset.storyKey = 'fn:2'; + renderedChange.scrollIntoView = vi.fn(); + page.appendChild(renderedChange); + viewport.appendChild(page); + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-footnote-2', + story: { kind: 'story', storyType: 'footnote', noteId: '2' }, + }); + + expect(didNavigate).toBe(true); + expect(renderedChange.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'center', + inline: 'nearest', + }); + }); + }); + describe('pageStyleUpdate event listener', () => { const buildLayoutResult = () => ({ layout: { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts index c056ee07ea..cc3484718f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts @@ -237,4 +237,74 @@ describe('PresentationInputBridge - Context Menu Handling', () => { expect(dispatchSpy).not.toHaveBeenCalled(); }); }); + + describe('stale hidden-editor rerouting', () => { + it('reroutes beforeinput from a stale hidden editor to the active target when window fallback is enabled', () => { + const staleBodyEditor = document.createElement('div'); + staleBodyEditor.className = 'ProseMirror'; + staleBodyEditor.setAttribute('contenteditable', 'true'); + document.body.appendChild(staleBodyEditor); + + const staleEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + staleBodyEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).toHaveBeenCalled(); + expect(targetDispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'beforeinput', + data: 'a', + inputType: 'insertText', + }), + ); + expect(staleEvent.defaultPrevented).toBe(true); + }); + + it('reroutes non-text keyboard commands from a stale hidden editor to the active target', () => { + const staleBodyEditor = document.createElement('div'); + staleBodyEditor.className = 'ProseMirror'; + staleBodyEditor.setAttribute('contenteditable', 'true'); + document.body.appendChild(staleBodyEditor); + + const staleEvent = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + staleBodyEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).toHaveBeenCalled(); + expect(targetDispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'keydown', + key: 'Backspace', + }), + ); + expect(staleEvent.defaultPrevented).toBe(true); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 856c9af5ec..94218d61e8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -202,11 +202,13 @@ export type PresentationEditorOptions = ConstructorParameters[0] */ allowSelectionInViewMode?: boolean; /** - * Route interactive story-backed part editing (headers, footers, and future - * story parts such as notes) through the body-style presentation editing - * architecture: a hidden off-screen ProseMirror host plus layout-engine - * rendering. When `false`, header/footer editing continues to mount a - * visible child PM overlay via {@link EditorOverlayManager}. + * Route interactive header/footer editing through the body-style + * presentation editing architecture: a hidden off-screen ProseMirror host + * plus layout-engine rendering. When `false`, header/footer editing + * continues to mount a visible child PM overlay via + * {@link EditorOverlayManager}. Notes/endnotes still use story-backed + * presentation sessions when activated because they have no legacy overlay + * editing surface. * * This is a transitional flag governing the rollout of the story-backed * parts presentation editing refactor. See @@ -441,7 +443,7 @@ export type PendingMarginClick = * to prevent unwanted scroll behavior when the hidden editor receives focus. * * @remarks - * This flag is set by {@link PresentationEditor#wrapHiddenEditorFocus} to ensure + * This flag is set by {@link PresentationEditor#wrapOffscreenEditorFocus} to ensure * the wrapping is idempotent (applied only once per view instance). */ export interface EditorViewWithScrollFlag { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts index 7fa546f0db..b8ee13f9c8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/CommentPositionCollection.ts @@ -1,48 +1,87 @@ import type { Mark, Node as ProseMirrorNode } from 'prosemirror-model'; +import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js'; +import { + makeCommentAnchorKey, + makeTrackedChangeAnchorKey, +} from '../../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; -export type CommentPosition = { threadId: string; start: number; end: number }; +export type CommentPosition = { + threadId: string; + key: string; + storyKey: string; + kind: 'trackedChange' | 'comment'; + start: number; + end: number; +}; + +export interface CollectCommentPositionsOptions { + commentMarkName: string; + trackChangeMarkNames: string[]; + storyKey?: string; +} export function collectCommentPositions( doc: ProseMirrorNode | null, - options: { commentMarkName: string; trackChangeMarkNames: string[] }, + options: CollectCommentPositionsOptions, ): Record { if (!doc) { return {}; } - const pmPositions: Record = {}; + const storyKey = options.storyKey ?? BODY_STORY_KEY; + const positions: Record = {}; doc.descendants((node, pos) => { const marks = node.marks || []; for (const mark of marks) { - const threadId = getThreadIdFromMark(mark, options); - if (!threadId) continue; + const descriptor = describeThreadMark(mark, options); + if (!descriptor) continue; + const canonicalKey = + descriptor.kind === 'trackedChange' + ? makeTrackedChangeAnchorKey({ storyKey, rawId: descriptor.rawId }) + : makeCommentAnchorKey(descriptor.rawId); + const storageKey = descriptor.kind === 'trackedChange' ? canonicalKey : descriptor.rawId; const nodeEnd = pos + node.nodeSize; + const existing = positions[storageKey]; - if (!pmPositions[threadId]) { - pmPositions[threadId] = { threadId, start: pos, end: nodeEnd }; - } else { - pmPositions[threadId].start = Math.min(pmPositions[threadId].start, pos); - pmPositions[threadId].end = Math.max(pmPositions[threadId].end, nodeEnd); + if (!existing) { + positions[storageKey] = { + threadId: descriptor.rawId, + key: canonicalKey, + storyKey, + kind: descriptor.kind, + start: pos, + end: nodeEnd, + }; + continue; } + + existing.start = Math.min(existing.start, pos); + existing.end = Math.max(existing.end, nodeEnd); } }); - return pmPositions; + return positions; +} + +interface ThreadMarkDescriptor { + rawId: string; + kind: 'trackedChange' | 'comment'; } -function getThreadIdFromMark( - mark: Mark, - options: { commentMarkName: string; trackChangeMarkNames: string[] }, -): string | undefined { +function describeThreadMark(mark: Mark, options: CollectCommentPositionsOptions): ThreadMarkDescriptor | undefined { if (mark.type.name === options.commentMarkName) { - return mark.attrs.commentId || mark.attrs.importedId; + const commentId = (mark.attrs.commentId as string | undefined) ?? (mark.attrs.importedId as string | undefined); + if (!commentId) return undefined; + return { rawId: commentId, kind: 'comment' }; } if (options.trackChangeMarkNames.includes(mark.type.name)) { - return mark.attrs.id; + const rawId = mark.attrs.id as string | undefined; + if (!rawId) return undefined; + return { rawId, kind: 'trackedChange' }; } return undefined; diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index 7483ad1bbb..43e35efc9e 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -144,6 +144,7 @@ export function createStoryEditor( media, mediaFiles: media, fonts: parentEditor.options.fonts, + user: parentEditor.options.user, isHeaderOrFooter, isHeadless, pagination: false, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index e1b0b058d4..4e4d18e725 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -19,7 +19,7 @@ import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; -import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; +import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js'; import { importFootnoteData, importEndnoteData } from './documentFootnotesImporter.js'; import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; @@ -152,9 +152,11 @@ export const createDocumentJson = (docx, converter, editor) => { patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); - converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, { + const trackedChangeIdMapOptions = { replacements: converter.trackedChangesOptions?.replacements ?? 'paired', - }); + }; + converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions); + converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index 0a9d6637eb..2710d1b6c0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -134,6 +134,26 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { } } +/** + * Scan a single OOXML part and return a fresh `w:id → internal UUID` map. + * + * The scan assumes the top-level element is a document / hdr / ftr / footnotes + * / endnotes root. Returns an empty map when the part is absent or malformed. + * + * @param {object | undefined} part Parsed OOXML part (from SuperConverter). + * @param {{ replacements?: TrackChangesReplacements }} [options] + * @returns {Map} + */ +function buildTrackedChangeIdMapForPart(part, options = {}) { + const root = part?.elements?.[0]; + if (!root?.elements) return new Map(); + + const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; + const idMap = new Map(); + walkElements(root.elements, idMap, { lastTrackedChange: null, replacements }); + return idMap; +} + /** * Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning * `word/document.xml`. @@ -153,12 +173,41 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * @returns {Map} Word `w:id` → internal UUID */ export function buildTrackedChangeIdMap(docx, options = {}) { - const body = docx?.['word/document.xml']?.elements?.[0]; - if (!body?.elements) return new Map(); + return buildTrackedChangeIdMapForPart(docx?.['word/document.xml'], options); +} - const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; - const idMap = new Map(); - walkElements(body.elements, idMap, { lastTrackedChange: null, replacements }); +/** + * Builds per-part `w:id → internal UUID` maps for every revision-capable + * content part in the DOCX package. + * + * Word revision IDs are not globally unique across parts, so each part keeps + * its own isolated `w:id` namespace. + * + * @param {Record | null | undefined} docx + * @param {{ replacements?: TrackChangesReplacements }} [options] + * @returns {Map>} + */ +export function buildTrackedChangeIdMapsByPart(docx, options = {}) { + /** @type {Map>} */ + const mapsByPart = new Map(); + if (!docx || typeof docx !== 'object') return mapsByPart; - return idMap; + /** @type {Record} */ + const parts = /** @type {Record} */ (docx); + + mapsByPart.set('word/document.xml', buildTrackedChangeIdMapForPart(parts['word/document.xml'], options)); + + for (const partPath of Object.keys(parts)) { + if (!/^word\/(?:header|footer)\d+\.xml$/.test(partPath)) continue; + mapsByPart.set(partPath, buildTrackedChangeIdMapForPart(parts[partPath], options)); + } + + if (parts['word/footnotes.xml']) { + mapsByPart.set('word/footnotes.xml', buildTrackedChangeIdMapForPart(parts['word/footnotes.xml'], options)); + } + if (parts['word/endnotes.xml']) { + mapsByPart.set('word/endnotes.xml', buildTrackedChangeIdMapForPart(parts['word/endnotes.xml'], options)); + } + + return mapsByPart; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js index 806ee8de63..04a87dd586 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js'; +import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js'; // --------------------------------------------------------------------------- // Test helpers @@ -291,3 +291,93 @@ describe('buildTrackedChangeIdMap', () => { }); }); }); + +function createDocxWithParts(partMap) { + const docx = {}; + for (const [path, bodyChildren] of Object.entries(partMap)) { + const rootName = path.includes('/footnotes.xml') + ? 'w:footnotes' + : path.includes('/endnotes.xml') + ? 'w:endnotes' + : path.includes('/header') + ? 'w:hdr' + : path.includes('/footer') + ? 'w:ftr' + : 'w:document'; + docx[path] = { + elements: [{ name: rootName, elements: bodyChildren }], + }; + } + return docx; +} + +describe('buildTrackedChangeIdMapsByPart', () => { + it('returns an empty Map when docx is missing or empty', () => { + expect(buildTrackedChangeIdMapsByPart(null).size).toBe(0); + expect(buildTrackedChangeIdMapsByPart(undefined).size).toBe(0); + }); + + it('always includes a body map at `word/document.xml`', () => { + const docx = createDocxWithParts({ 'word/document.xml': [paragraph(trackedChange('w:ins', '1'))] }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.has('word/document.xml')).toBe(true); + expect(maps.get('word/document.xml').get('1')).toBeTruthy(); + }); + + it('scans every header and footer part present in the package', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/header1.xml': [paragraph(wordDelete('100', 'gone'), wordInsert('101', 'new'))], + 'word/footer2.xml': [paragraph(trackedChange('w:ins', '200'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + + const headerMap = maps.get('word/header1.xml'); + expect(headerMap).toBeDefined(); + expect(headerMap.get('100')).toBeTruthy(); + expect(headerMap.get('100')).toBe(headerMap.get('101')); + + const footerMap = maps.get('word/footer2.xml'); + expect(footerMap).toBeDefined(); + expect(footerMap.get('200')).toBeTruthy(); + }); + + it('keeps per-part id spaces isolated when the same w:id appears in multiple parts', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [paragraph(trackedChange('w:ins', 'shared'))], + 'word/header1.xml': [paragraph(trackedChange('w:ins', 'shared'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.get('word/document.xml').get('shared')).not.toBe(maps.get('word/header1.xml').get('shared')); + }); + + it('includes footnotes and endnotes parts when present', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/footnotes.xml': [paragraph(wordDelete('300', 'x'), wordInsert('301', 'y'))], + 'word/endnotes.xml': [paragraph(trackedChange('w:ins', '400'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.get('word/footnotes.xml').get('300')).toBe(maps.get('word/footnotes.xml').get('301')); + expect(maps.get('word/endnotes.xml').get('400')).toBeTruthy(); + }); + + it("passes replacement mode options through to each part scan", () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/header1.xml': [paragraph(wordDelete('500', 'gone'), wordInsert('501', 'new'))], + }); + const maps = buildTrackedChangeIdMapsByPart(docx, { replacements: 'independent' }); + + expect(maps.get('word/header1.xml').get('500')).not.toBe(maps.get('word/header1.xml').get('501')); + }); + + it('does not introduce unrelated parts into the map', () => { + const docx = createDocxWithParts({ + 'word/document.xml': [], + 'word/styles.xml': [], + }); + const maps = buildTrackedChangeIdMapsByPart(docx); + expect(maps.has('word/styles.xml')).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index e2e8a32421..137d60aad8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -23,14 +23,17 @@ const validXmlAttributes = [ * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter } = params; + const { nodeListHandler, extraParams = {}, converter, filename } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. const originalWordId = encodedAttrs.id; - if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId); + const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; + const trackedChangeIdMap = + converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; + if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { + encodedAttrs.id = trackedChangeIdMap.get(originalWordId); } encodedAttrs.sourceId = originalWordId || ''; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 12cbcc97df..964d99d75a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -30,7 +30,7 @@ describe('w:del translator', () => { describe('encode', () => { const mockNode = { elements: [{ text: 'deleted text' }] }; - function encodeWith({ converter, id = '123' } = {}) { + function encodeWith({ converter, id = '123', filename } = {}) { const mockSubNodes = [{ content: [{ type: 'text', text: 'deleted text' }] }]; const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; @@ -46,6 +46,7 @@ describe('w:del translator', () => { nodeListHandler: mockNodeListHandler, extraParams: { node: mockNode }, converter, + filename, path: [], }, { ...encodedAttrs }, @@ -89,6 +90,19 @@ describe('w:del translator', () => { expect(attrs.id).toBe('shared-uuid-abc'); expect(attrs.sourceId).toBe('123'); }); + + it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => { + const converter = { + trackedChangeIdMap: new Map([['123', 'body-uuid']]), + trackedChangeIdMapsByPart: new Map([['word/footnotes.xml', new Map([['123', 'footnote-uuid']])]]), + }; + + const result = encodeWith({ converter, filename: 'footnotes.xml' }); + const attrs = getMarkAttrs(result); + + expect(attrs.id).toBe('footnote-uuid'); + expect(attrs.sourceId).toBe('123'); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 0ed46c4834..9ececf73e7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -23,14 +23,17 @@ const validXmlAttributes = [ * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter } = params; + const { nodeListHandler, extraParams = {}, converter, filename } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. const originalWordId = encodedAttrs.id; - if (originalWordId && converter?.trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = converter.trackedChangeIdMap.get(originalWordId); + const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; + const trackedChangeIdMap = + converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; + if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { + encodedAttrs.id = trackedChangeIdMap.get(originalWordId); } encodedAttrs.sourceId = originalWordId || ''; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index 113d0680b6..be99a7c505 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -29,7 +29,7 @@ describe('w:ins translator', () => { describe('encode', () => { const mockNode = { elements: [{ text: 'added text' }] }; - function encodeWith({ converter, id = '123' } = {}) { + function encodeWith({ converter, id = '123', filename } = {}) { const mockSubNodes = [{ content: [{ type: 'text', text: 'added text' }] }]; const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; @@ -45,6 +45,7 @@ describe('w:ins translator', () => { nodeListHandler: mockNodeListHandler, extraParams: { node: mockNode }, converter, + filename, path: [], }, { ...encodedAttrs }, @@ -97,6 +98,19 @@ describe('w:ins translator', () => { expect(attrs.id).toBe('shared-uuid-abc'); expect(attrs.sourceId).toBe('123'); }); + + it('prefers the per-part trackedChangeIdMapsByPart entry when filename is provided', () => { + const converter = { + trackedChangeIdMap: new Map([['123', 'body-uuid']]), + trackedChangeIdMapsByPart: new Map([['word/header1.xml', new Map([['123', 'header-uuid']])]]), + }; + + const { result } = encodeWith({ converter, filename: 'header1.xml' }); + const attrs = getMarkAttrs(result); + + expect(attrs.id).toBe('header-uuid'); + expect(attrs.sourceId).toBe('123'); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index b07139b2c9..16062847f6 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -2,7 +2,7 @@ import type { Transaction } from 'prosemirror-state'; import type { Editor } from '../Editor.js'; import type { DefaultEventMap } from '../EventEmitter.js'; import type { PartChangedEvent } from '../parts/types.js'; -import type { DocumentProtectionState } from '@superdoc/document-api'; +import type { DocumentProtectionState, StoryLocator } from '@superdoc/document-api'; /** Source of a protection state change. */ export type ProtectionChangeSource = 'init' | 'local-mutation' | 'remote-part-sync'; @@ -121,6 +121,15 @@ export interface ListDefinitionsPayload { editor?: unknown; } +/** Payload emitted with the `tracked-changes-changed` event. */ +export interface TrackedChangesChangedPayload { + editor: Editor; + /** Stories whose tracked-change snapshot has changed. `undefined` means full rebuild. */ + stories?: StoryLocator[]; + /** Optional origin hint. */ + source?: string; +} + /** * Event map for the Editor class */ @@ -204,4 +213,12 @@ export interface EditorEventMap extends DefaultEventMap { /** Called when document protection state changes (init, local mutation, or remote sync). */ protectionChanged: [{ editor: Editor; state: DocumentProtectionState; source: ProtectionChangeSource }]; + + /** + * Story-aware tracked-change invalidation signal. + * + * Emitted by the host-level `TrackedChangeIndex` service whenever one or + * more story caches are invalidated. + */ + 'tracked-changes-changed': [TrackedChangesChangedPayload]; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts new file mode 100644 index 0000000000..6206a38a6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeNotePmJson } from './note-pm-json.js'; + +describe('normalizeNotePmJson', () => { + it('returns the input unchanged when there is no content array', () => { + const doc = { type: 'doc' }; + expect(normalizeNotePmJson(doc)).toEqual({ type: 'doc' }); + }); + + it('drops empty leading run nodes inside paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [] }, + { type: 'run', content: [{ type: 'text', text: 'hello' }] }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'hello' }] }], + }, + ], + }); + }); + + it('preserves empty run nodes outside paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'custom', + content: [{ type: 'run', content: [] }], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual(doc); + }); + + it('treats runs with no content array as empty and strips them from paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run' }, { type: 'text', text: 'x' }], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x' }], + }, + ], + }); + }); + + it('recurses into nested structures', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'section', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [] }, + { type: 'run', content: [{ type: 'text', text: 'deep' }] }, + ], + }, + ], + }, + ], + }; + + const normalized = normalizeNotePmJson(doc) as { + content: Array<{ content: Array<{ content: unknown[] }> }>; + }; + expect(normalized.content[0].content[0].content).toEqual([ + { type: 'run', content: [{ type: 'text', text: 'deep' }] }, + ]); + }); + + it('does not mutate the input document', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [] }], + }, + ], + }; + const before = JSON.stringify(doc); + normalizeNotePmJson(doc); + expect(JSON.stringify(doc)).toBe(before); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts new file mode 100644 index 0000000000..617b6667cb --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts @@ -0,0 +1,52 @@ +type PmJsonNode = { + type?: unknown; + content?: unknown; + [key: string]: unknown; +}; + +function isPmJsonNode(value: unknown): value is PmJsonNode { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEmptyRunNode(value: unknown): value is PmJsonNode { + if (!isPmJsonNode(value) || value.type !== 'run') { + return false; + } + + return !Array.isArray(value.content) || value.content.length === 0; +} + +function normalizeNotePmNode(value: unknown): unknown { + if (!isPmJsonNode(value)) { + return value; + } + + const normalized: PmJsonNode = { ...value }; + if (!Array.isArray(value.content)) { + return normalized; + } + + const normalizedChildren = value.content + .map((child) => normalizeNotePmNode(child)) + .filter((child) => !(value.type === 'paragraph' && isEmptyRunNode(child))); + + normalized.content = normalizedChildren; + return normalized; +} + +/** + * Normalize note PM JSON so interactive layout and story editors share the same + * position space. + * + * The note importer preserves the leading OOXML footnote/endnote reference run + * as an empty `run` node. Story editors immediately normalize those empty runs + * away, but the presentation-footnote layout previously converted the raw + * content as-is. That left the rendered note and the active note editor offset + * by two PM positions, which made clicks in the rendered note type into the + * wrong place. Keeping both paths on the same normalized PM JSON fixes the + * mismatch at the source. + */ +export function normalizeNotePmJson>(docJson: T): T { + const normalized = normalizeNotePmNode(docJson); + return (isPmJsonNode(normalized) ? normalized : docJson) as T; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index 6323a078f1..f21bf2bdb2 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -1,6 +1,11 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; -import type { TrackChangeType, TrackChangeWordRevisionIds } from '@superdoc/document-api'; +import type { + StoryLocator, + TrackChangeType, + TrackChangeWordRevisionIds, + TrackedChangeAddress, +} from '@superdoc/document-api'; import { TrackDeleteMarkName, TrackFormatMarkName, @@ -8,6 +13,9 @@ import { } from '../../extensions/track-changes/constants.js'; import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { normalizeExcerpt, toNonEmptyString } from './value-utils.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { buildStoryKey, BODY_STORY_KEY } from '../story-runtime/story-key.js'; +import type { TrackedChangeRuntimeRef } from './tracked-change-runtime-ref.js'; const DERIVED_ID_LENGTH = 24; @@ -213,3 +221,100 @@ export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map void; +} + +type TrackedChangeLookupInput = string | TrackedChangeAddress; + +function toAddress(input: TrackedChangeLookupInput): TrackedChangeAddress { + if (typeof input === 'string') { + return { kind: 'entity', entityType: 'trackedChange', entityId: input }; + } + return input; +} + +/** + * Resolves a tracked-change id/address to the owning story editor and the + * grouped change within it. + * + * For body addresses (no `story` field) this is an O(n) search against the + * host editor's grouped marks — same as the legacy body-only resolver. + * + * For non-body addresses it resolves the correct story runtime, then performs + * the lookup within that editor's state. + * + * Returns `null` if the address resolves to no matching tracked change. + */ +export function resolveTrackedChangeInStory( + hostEditor: Editor, + input: TrackedChangeLookupInput, +): ResolvedStoryTrackedChange | null { + const address = toAddress(input); + const entityId = address.entityId; + + const story: StoryLocator = address.story ?? { kind: 'story', storyType: 'body' }; + const storyKey = address.story ? buildStoryKey(address.story) : BODY_STORY_KEY; + + if (storyKey === BODY_STORY_KEY) { + const match = findMatchingChange(hostEditor, entityId); + if (!match) return null; + return { + editor: hostEditor, + story, + runtimeRef: { storyKey: BODY_STORY_KEY, rawId: match.rawId }, + change: match, + }; + } + + let runtime; + try { + runtime = resolveStoryRuntime(hostEditor, story); + } catch { + return null; + } + + const match = findMatchingChange(runtime.editor, entityId); + if (!match) return null; + return { + editor: runtime.editor, + story: runtime.locator, + runtimeRef: { storyKey: runtime.storyKey, rawId: match.rawId }, + change: match, + commit: runtime.commit, + }; +} + +/** + * Lookup helper — accepts both the canonical id and the raw mark id to + * tolerate callers that stored whichever was convenient at the time. + */ +function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { + const grouped = groupTrackedChanges(editor); + return grouped.find((item) => item.id === id || item.rawId === id) ?? null; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts new file mode 100644 index 0000000000..e4eb051fe5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + isCommentAnchorKey, + isTrackedChangeAnchorKey, + makeCommentAnchorKey, + makeTrackedChangeAnchorKey, + parseTrackedChangeAnchorKey, +} from './tracked-change-runtime-ref.js'; + +describe('anchor key helpers', () => { + it('makeTrackedChangeAnchorKey formats tc::::', () => { + expect(makeTrackedChangeAnchorKey({ storyKey: 'body', rawId: 'rev-1' })).toBe('tc::body::rev-1'); + expect(makeTrackedChangeAnchorKey({ storyKey: 'hf:part:rId4', rawId: 'r7' })).toBe('tc::hf:part:rId4::r7'); + expect(makeTrackedChangeAnchorKey({ storyKey: 'fn:5', rawId: 'rev-123' })).toBe('tc::fn:5::rev-123'); + }); + + it('makeCommentAnchorKey formats comment::', () => { + expect(makeCommentAnchorKey('c-1')).toBe('comment::c-1'); + }); + + it('isTrackedChangeAnchorKey classifies keys', () => { + expect(isTrackedChangeAnchorKey('tc::body::r1')).toBe(true); + expect(isTrackedChangeAnchorKey('comment::c-1')).toBe(false); + expect(isTrackedChangeAnchorKey('r1')).toBe(false); + }); + + it('isCommentAnchorKey classifies keys', () => { + expect(isCommentAnchorKey('comment::c-1')).toBe(true); + expect(isCommentAnchorKey('tc::body::r1')).toBe(false); + }); + + it('parseTrackedChangeAnchorKey round-trips body and non-body', () => { + expect(parseTrackedChangeAnchorKey('tc::body::rev-1')).toEqual({ storyKey: 'body', rawId: 'rev-1' }); + expect(parseTrackedChangeAnchorKey('tc::hf:part:rId4::r7')).toEqual({ + storyKey: 'hf:part:rId4', + rawId: 'r7', + }); + expect(parseTrackedChangeAnchorKey('tc::fn:12::rev-abc')).toEqual({ storyKey: 'fn:12', rawId: 'rev-abc' }); + }); + + it('parseTrackedChangeAnchorKey rejects malformed keys', () => { + expect(parseTrackedChangeAnchorKey('not-an-anchor')).toBeNull(); + expect(parseTrackedChangeAnchorKey('tc::')).toBeNull(); + expect(parseTrackedChangeAnchorKey('comment::c1')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts new file mode 100644 index 0000000000..56939f6b4b --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts @@ -0,0 +1,82 @@ +/** + * Internal helpers for tracked-change runtime refs and shared anchor keys. + * + * Public tracked-change addresses use canonical IDs, while runtime refs use + * story-local raw IDs. This module intentionally stays on the runtime side of + * that boundary; it does not attempt to convert contract addresses. + */ + +/** + * Internal runtime form of a tracked-change identity. + * + * - `storyKey` — compact, cache-friendly story identity (see `story-key.ts`). + * - `rawId` — the raw tracked-change mark ID local to the owning story editor. + * + * Story runtimes are editor-scoped and revision-tracking is per-editor, so + * `rawId` is story-local by construction. This ref captures that scoping + * explicitly so sidebar position maps, accept/reject routers, and the + * TrackedChangeIndex can key on the full (storyKey, rawId) tuple without + * ambiguity. + */ +export interface TrackedChangeRuntimeRef { + storyKey: string; + rawId: string; +} + +/** Prefix for tracked-change anchor keys in shared position maps. */ +export const TRACKED_CHANGE_ANCHOR_KEY_PREFIX = 'tc::'; + +/** Prefix for comment anchor keys in shared position maps. */ +export const COMMENT_ANCHOR_KEY_PREFIX = 'comment::'; + +/** + * Builds the canonical shared-map anchor key for a tracked-change runtime ref. + * + * Format: `tc::::`. + */ +export function makeTrackedChangeAnchorKey(ref: TrackedChangeRuntimeRef): string { + return `${TRACKED_CHANGE_ANCHOR_KEY_PREFIX}${ref.storyKey}::${ref.rawId}`; +} + +/** + * Builds the canonical shared-map anchor key for a comment id. + * + * Format: `comment::`. + */ +export function makeCommentAnchorKey(commentId: string): string { + return `${COMMENT_ANCHOR_KEY_PREFIX}${commentId}`; +} + +/** + * Returns true when the given key is a canonical tracked-change anchor key. + */ +export function isTrackedChangeAnchorKey(key: string): boolean { + return typeof key === 'string' && key.startsWith(TRACKED_CHANGE_ANCHOR_KEY_PREFIX); +} + +/** + * Returns true when the given key is a canonical comment anchor key. + */ +export function isCommentAnchorKey(key: string): boolean { + return typeof key === 'string' && key.startsWith(COMMENT_ANCHOR_KEY_PREFIX); +} + +/** + * Parses a canonical tracked-change anchor key back into a {@link TrackedChangeRuntimeRef}. + * + * Returns `null` when the key is not a tracked-change anchor key or when + * the format is malformed. + */ +export function parseTrackedChangeAnchorKey(key: string): TrackedChangeRuntimeRef | null { + if (!isTrackedChangeAnchorKey(key)) return null; + + const body = key.slice(TRACKED_CHANGE_ANCHOR_KEY_PREFIX.length); + const separatorIndex = body.lastIndexOf('::'); + if (separatorIndex <= 0 || separatorIndex >= body.length - 2) return null; + + const storyKey = body.slice(0, separatorIndex); + const rawId = body.slice(separatorIndex + 2); + if (!storyKey || !rawId) return null; + + return { storyKey, rawId }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts new file mode 100644 index 0000000000..7e684e500f --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { StoryLocator } from '@superdoc/document-api'; + +const mocks = vi.hoisted(() => ({ + checkRevision: vi.fn(), + getRevision: vi.fn(() => '0'), + executeDomainCommand: vi.fn(), + resolveTrackedChangeInStory: vi.fn(), + getTrackedChangeIndex: vi.fn(), + resolveStoryRuntime: vi.fn(), +})); + +vi.mock('./revision-tracker.js', () => ({ + checkRevision: mocks.checkRevision, + getRevision: mocks.getRevision, +})); + +vi.mock('./plan-wrappers.js', () => ({ + executeDomainCommand: mocks.executeDomainCommand, +})); + +vi.mock('../helpers/tracked-change-resolver.js', () => ({ + resolveTrackedChangeInStory: mocks.resolveTrackedChangeInStory, + resolveTrackedChangeType: vi.fn(() => 'insert'), +})); + +vi.mock('../tracked-changes/tracked-change-index.js', () => ({ + getTrackedChangeIndex: mocks.getTrackedChangeIndex, +})); + +vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: mocks.resolveStoryRuntime, +})); + +import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper } from './track-changes-wrappers.js'; + +const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; + +function makeEditor(commands: Record = {}): Editor { + return { + commands, + state: { doc: { textBetween: vi.fn(() => '') } }, + } as unknown as Editor; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRevision.mockReturnValue('0'); + mocks.executeDomainCommand.mockReturnValue({ steps: [{ effect: 'changed' }] }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); +}); + +describe('track-changes-wrappers revision guard', () => { + it('checks expectedRevision on the host editor before accepting a non-body tracked change', () => { + const hostEditor = makeEditor(); + const storyEditor = makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }); + const commit = vi.fn(); + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + commit, + }); + mocks.getTrackedChangeIndex.mockReturnValue(index); + + const receipt = trackChangesAcceptWrapper( + hostEditor, + { id: 'canon-1', story: footnoteStory }, + { expectedRevision: '12' }, + ); + + expect(receipt).toEqual({ success: true }); + expect(mocks.checkRevision).toHaveBeenCalledWith(hostEditor, '12'); + expect(mocks.executeDomainCommand).toHaveBeenCalledWith(storyEditor, expect.any(Function)); + expect(commit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); + + it('checks expectedRevision once on the host editor for accept-all across multiple stories', () => { + const hostEditor = makeEditor(); + const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const footnoteEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const bodyCommit = vi.fn(); + const footnoteCommit = vi.fn(); + + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const snapshots = [ + { + story: bodyStory, + runtimeRef: { storyKey: 'body', rawId: 'raw-body' }, + }, + { + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-fn' }, + }, + ]; + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => snapshots), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.getTrackedChangeIndex.mockReturnValue(index); + mocks.resolveStoryRuntime.mockImplementation((_host: Editor, story: StoryLocator) => { + if (story.storyType === 'body') { + return { editor: bodyEditor, storyKey: 'body', locator: story, kind: 'body', commit: bodyCommit }; + } + + return { editor: footnoteEditor, storyKey: 'fn:5', locator: story, kind: 'note', commit: footnoteCommit }; + }); + + const receipt = trackChangesAcceptAllWrapper(hostEditor, {}, { expectedRevision: '33' }); + + expect(receipt).toEqual({ success: true }); + expect(mocks.checkRevision).toHaveBeenCalledTimes(1); + expect(mocks.checkRevision).toHaveBeenCalledWith(hostEditor, '33'); + expect(mocks.executeDomainCommand).toHaveBeenNthCalledWith(1, bodyEditor, expect.any(Function)); + expect(mocks.executeDomainCommand).toHaveBeenNthCalledWith(2, footnoteEditor, expect.any(Function)); + expect(bodyCommit).toHaveBeenCalledWith(hostEditor); + expect(footnoteCommit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledWith(bodyStory); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index dbb27f9c54..acd5b7d278 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -2,9 +2,13 @@ * Track-changes convenience wrappers — bridge track-change operations to * the plan engine's revision management and execution path. * - * Read operations (list, get) are pure queries. - * Mutating operations (accept, reject, acceptAll, rejectAll) delegate to - * editor commands with plan-engine revision tracking. + * Discovery (list / get) is a thin passthrough over the host-level + * {@link getTrackedChangeIndex} service, so there is a single owner for + * tracked-change enumeration across every revision-capable story. + * + * Mutating operations (accept, reject, acceptAll, rejectAll) route through + * the story runtime resolver so that non-body tracked changes execute in + * the owning story editor and commit back through `mutatePart(...)`. */ import type { Editor } from '../../core/Editor.js'; @@ -21,19 +25,19 @@ import type { TrackChangesRejectInput, TrackChangeType, TrackChangesListResult, + StoryLocator, } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; -import { requireEditorCommand } from '../helpers/mutation-helpers.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; -import { getRevision } from './revision-tracker.js'; -import { - groupTrackedChanges, - resolveTrackedChange, - resolveTrackedChangeType, - type GroupedTrackedChange, -} from '../helpers/tracked-change-resolver.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; +import { resolveTrackedChangeInStory, resolveTrackedChangeType } from '../helpers/tracked-change-resolver.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; +import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; +import { makeTrackedChangeAnchorKey } from '../helpers/tracked-change-runtime-ref.js'; import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; function normalizeWordRevisionIds( @@ -49,39 +53,26 @@ function normalizeWordRevisionIds( return Object.keys(normalized).length > 0 ? normalized : undefined; } -function buildTrackChangeInfo(editor: Editor, change: GroupedTrackedChange): TrackChangeInfo { - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); - const type = resolveTrackedChangeType(change); - +function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { return { - address: { - kind: 'entity', - entityType: 'trackedChange', - entityId: change.id, - }, - id: change.id, - type, - wordRevisionIds: normalizeWordRevisionIds(change.wordRevisionIds), - author: toNonEmptyString(change.attrs.author), - authorEmail: toNonEmptyString(change.attrs.authorEmail), - authorImage: toNonEmptyString(change.attrs.authorImage), - date: toNonEmptyString(change.attrs.date), - excerpt, + address: snapshot.address, + id: snapshot.address.entityId, + type: snapshot.type, + wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + author: snapshot.author, + authorEmail: snapshot.authorEmail, + authorImage: snapshot.authorImage, + date: snapshot.date, + excerpt: snapshot.excerpt, }; } -function filterByType(changes: GroupedTrackedChange[], requestedType?: TrackChangeType): GroupedTrackedChange[] { - if (!requestedType) return changes; - return changes.filter((change) => resolveTrackedChangeType(change) === requestedType); -} - -function requireTrackChangeById(editor: Editor, id: string): GroupedTrackedChange { - const change = resolveTrackedChange(editor, id); - if (change) return change; - - throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { - id, - }); +function filterByType( + snapshots: ReadonlyArray, + requestedType?: TrackChangeType, +): TrackedChangeSnapshot[] { + if (!requestedType) return [...snapshots]; + return snapshots.filter((snapshot) => snapshot.type === requestedType); } function toNoOpReceipt(message: string, details?: unknown): Receipt { @@ -95,20 +86,37 @@ function toNoOpReceipt(message: string, details?: unknown): Receipt { }; } -// --------------------------------------------------------------------------- -// Read operations (queries) -// --------------------------------------------------------------------------- +function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { + if (!input || input.in === undefined) return 'body'; + if (input.in === 'all') return 'all'; + return { story: input.in }; +} export function trackChangesListWrapper(editor: Editor, input?: TrackChangesListInput): TrackChangesListResult { - const query = input; - validatePaginationInput(query?.offset, query?.limit); - const grouped = filterByType(groupTrackedChanges(editor), query?.type); - const paged = paginate(grouped, query?.offset, query?.limit); + validatePaginationInput(input?.offset, input?.limit); + + const index = getTrackedChangeIndex(editor); + const scope = resolveListScope(input); + + let rawSnapshots: ReadonlyArray; + if (scope === 'all') { + rawSnapshots = index.getAll(); + } else if (scope === 'body') { + rawSnapshots = index.get({ kind: 'story', storyType: 'body' }); + } else { + rawSnapshots = index.get(scope.story); + } + + const filtered = filterByType(rawSnapshots, input?.type); + const paged = paginate(filtered, input?.offset, input?.limit); + // Track-changes discovery uses a document-level revision token across every + // scope. Part commits also advance the host revision, so one shared token + // correctly guards body, story-scoped, and aggregate review flows. const evaluatedRevision = getRevision(editor); - const items = paged.items.map((change) => { - const info = buildTrackChangeInfo(editor, change); - const handle = buildResolvedHandle(`tc:${info.id}`, 'stable', 'trackedChange'); + const items = paged.items.map((snapshot) => { + const info = snapshotToInfo(snapshot); + const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); const { address, type, wordRevisionIds, author, authorEmail, authorImage, date, excerpt } = info; return buildDiscoveryItem(info.id, handle, { address, @@ -126,54 +134,173 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList evaluatedRevision, total: paged.total, items, - page: { limit: query?.limit ?? paged.total, offset: query?.offset ?? 0, returned: items.length }, + page: { limit: input?.limit ?? paged.total, offset: input?.offset ?? 0, returned: items.length }, }); } export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInput): TrackChangeInfo { - const { id } = input; - return buildTrackChangeInfo(editor, requireTrackChangeById(editor, id)); + const { id, story } = input; + const resolved = resolveTrackedChangeInStory(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: id, + ...(story ? { story } : {}), + }); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { id }); + } + + const index = getTrackedChangeIndex(editor); + const storyKey = buildStoryKey(resolved.story); + const anchorKey = makeTrackedChangeAnchorKey(resolved.runtimeRef); + const snapshots = + storyKey === BODY_STORY_KEY ? index.get({ kind: 'story', storyType: 'body' }) : index.get(resolved.story); + const snapshot = snapshots.find((item) => item.anchorKey === anchorKey); + + if (snapshot) return snapshotToInfo(snapshot); + + return { + address: { + kind: 'entity', + entityType: 'trackedChange', + entityId: resolved.change.id, + ...(storyKey === BODY_STORY_KEY ? {} : { story: resolved.story }), + }, + id: resolved.change.id, + type: resolveTrackedChangeType(resolved.change), + wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), + author: toNonEmptyString(resolved.change.attrs.author), + authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), + authorImage: toNonEmptyString(resolved.change.attrs.authorImage), + date: toNonEmptyString(resolved.change.attrs.date), + excerpt: normalizeExcerpt( + resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc'), + ), + }; } -// --------------------------------------------------------------------------- -// Mutating operations (wrappers) -// --------------------------------------------------------------------------- +type ReviewDecision = 'accept' | 'reject'; -export function trackChangesAcceptWrapper( - editor: Editor, - input: TrackChangesAcceptInput, - options?: RevisionGuardOptions, +function decideSingle( + hostEditor: Editor, + decision: ReviewDecision, + id: string, + story: StoryLocator | undefined, + options: RevisionGuardOptions | undefined, ): Receipt { - const { id } = input; - const change = requireTrackChangeById(editor, id); - const acceptById = requireEditorCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); - - const receipt = executeDomainCommand(editor, () => Boolean(acceptById(change.rawId)), { - expectedRevision: options?.expectedRevision, + const resolved = resolveTrackedChangeInStory(hostEditor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: id, + ...(story ? { story } : {}), }); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { id, story }); + } + + const commandName = decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById'; + const command = (resolved.editor.commands as Record boolean) | undefined>)[commandName]; + if (typeof command !== 'function') { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `${decision === 'accept' ? 'Accept' : 'Reject'} tracked change command is not available on the story editor.`, + { reason: 'missing_command' }, + ); + } + + checkRevision(hostEditor, options?.expectedRevision); + + const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(resolved.change.rawId))); + if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`Accept tracked change "${id}" produced no change.`, { id }); + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, { + id, + story, + }); + } + + if (resolved.commit) { + resolved.commit(hostEditor); } + getTrackedChangeIndex(hostEditor).invalidate(resolved.story); + return { success: true }; } +export function trackChangesAcceptWrapper( + editor: Editor, + input: TrackChangesAcceptInput, + options?: RevisionGuardOptions, +): Receipt { + return decideSingle(editor, 'accept', input.id, input.story, options); +} + export function trackChangesRejectWrapper( editor: Editor, input: TrackChangesRejectInput, options?: RevisionGuardOptions, ): Receipt { - const { id } = input; - const change = requireTrackChangeById(editor, id); - const rejectById = requireEditorCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); + return decideSingle(editor, 'reject', input.id, input.story, options); +} - const receipt = executeDomainCommand(editor, () => Boolean(rejectById(change.rawId)), { - expectedRevision: options?.expectedRevision, - }); +function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGuardOptions | undefined): Receipt { + const index = getTrackedChangeIndex(editor); + const allSnapshots = index.getAll(); + if (allSnapshots.length === 0) { + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); + } - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`Reject tracked change "${id}" produced no change.`, { id }); + checkRevision(editor, options?.expectedRevision); + + const byStoryKey = new Map(); + for (const snapshot of allSnapshots) { + const key = snapshot.runtimeRef.storyKey; + const entry = byStoryKey.get(key); + if (entry) { + entry.snapshots.push(snapshot); + continue; + } + byStoryKey.set(key, { story: snapshot.story, snapshots: [snapshot] }); + } + + let anyApplied = false; + + for (const { story, snapshots } of byStoryKey.values()) { + const runtime = resolveStoryRuntime(editor, story); + const commandName = decision === 'accept' ? 'acceptAllTrackedChanges' : 'rejectAllTrackedChanges'; + const bulkCommand = (runtime.editor.commands as Record boolean) | undefined>)[commandName]; + + const receipt = executeDomainCommand(runtime.editor, (): boolean => { + if (typeof bulkCommand === 'function') return Boolean(bulkCommand()); + + const perChangeCommand = (runtime.editor.commands as Record boolean) | undefined>)[ + decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById' + ]; + if (typeof perChangeCommand !== 'function') return false; + + let applied = false; + for (const snapshot of snapshots) { + if (perChangeCommand(snapshot.runtimeRef.rawId)) { + applied = true; + } + } + return applied; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) continue; + + anyApplied = true; + if (runtime.commit) { + runtime.commit(editor); + } + index.invalidate(story); + } + + if (!anyApplied) { + return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); } return { success: true }; @@ -184,21 +311,7 @@ export function trackChangesAcceptAllWrapper( _input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions, ): Receipt { - const acceptAll = requireEditorCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); - - if (groupTrackedChanges(editor).length === 0) { - return toNoOpReceipt('Accept all tracked changes produced no change.'); - } - - const receipt = executeDomainCommand(editor, () => Boolean(acceptAll()), { - expectedRevision: options?.expectedRevision, - }); - - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt('Accept all tracked changes produced no change.'); - } - - return { success: true }; + return decideAll(editor, 'accept', options); } export function trackChangesRejectAllWrapper( @@ -206,19 +319,5 @@ export function trackChangesRejectAllWrapper( _input: TrackChangesRejectAllInput, options?: RevisionGuardOptions, ): Receipt { - const rejectAll = requireEditorCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); - - if (groupTrackedChanges(editor).length === 0) { - return toNoOpReceipt('Reject all tracked changes produced no change.'); - } - - const receipt = executeDomainCommand(editor, () => Boolean(rejectAll()), { - expectedRevision: options?.expectedRevision, - }); - - if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt('Reject all tracked changes produced no change.'); - } - - return { success: true }; + return decideAll(editor, 'reject', options); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts index aa9f630cbc..3e9a55fba4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/index.ts @@ -29,6 +29,11 @@ export { // Runtime cache export { StoryRuntimeCache } from './runtime-cache.js'; +export { + registerLiveStorySessionRuntime, + resolveLiveStorySessionRuntime, + unregisterLiveStorySessionRuntime, +} from './live-story-session-runtime-registry.js'; // Resolution export { resolveStoryRuntime, getStoryRuntimeCache } from './resolve-story-runtime.js'; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts new file mode 100644 index 0000000000..cfada76ea9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts @@ -0,0 +1,101 @@ +import type { Editor } from '../../core/Editor.js'; +import type { StoryRuntime } from './story-types.js'; + +/** + * A registered interactive story session. + * + * While a story session is active, tracked-change resolution and other + * document-api calls must target the session editor the user is typing in, + * not an older cached runtime editor. + */ +interface LiveStorySessionRegistration { + storyKey: string; + editor: Editor; + runtime: StoryRuntime; +} + +const liveSessionsByHost = new WeakMap>(); + +function getOrCreateLiveSessionMap(hostEditor: Editor): Map { + let sessions = liveSessionsByHost.get(hostEditor); + if (!sessions) { + sessions = new Map(); + liveSessionsByHost.set(hostEditor, sessions); + } + return sessions; +} + +function buildLiveSessionRuntime(registration: LiveStorySessionRegistration): StoryRuntime { + const { runtime, editor } = registration; + + return { + ...runtime, + editor, + cacheable: false, + commit: + runtime.commitEditor == null + ? runtime.commit + : (hostEditor: Editor) => { + runtime.commitEditor?.(hostEditor, editor); + }, + }; +} + +/** + * Register the currently active editor for a story session. + * + * Returns a cleanup callback that only unregisters the session if the same + * editor is still registered for that story key. + */ +export function registerLiveStorySessionRuntime(hostEditor: Editor, runtime: StoryRuntime, editor: Editor): () => void { + const sessions = getOrCreateLiveSessionMap(hostEditor); + const storyKey = runtime.storyKey; + + sessions.set(storyKey, { + storyKey, + editor, + runtime, + }); + + return () => { + unregisterLiveStorySessionRuntime(hostEditor, storyKey, editor); + }; +} + +/** + * Resolve the interactive runtime for a story session, if one is active. + */ +export function resolveLiveStorySessionRuntime(hostEditor: Editor, storyKey: string): StoryRuntime | null { + const registration = liveSessionsByHost.get(hostEditor)?.get(storyKey); + if (!registration) return null; + return buildLiveSessionRuntime(registration); +} + +/** + * Remove a registered interactive runtime. + * + * When `editor` is provided, the registration is removed only if it still + * points to that editor. This prevents a stale disposer from clearing a + * newer activation for the same story. + */ +export function unregisterLiveStorySessionRuntime(hostEditor: Editor, storyKey: string, editor?: Editor): void { + const sessions = liveSessionsByHost.get(hostEditor); + if (!sessions) return; + + const registration = sessions.get(storyKey); + if (!registration) return; + if (editor && registration.editor !== editor) return; + + sessions.delete(storyKey); + + if (sessions.size === 0) { + liveSessionsByHost.delete(hostEditor); + } +} + +/** + * Visible for tests. + */ +export function getLiveStorySessionCount(hostEditor: Editor): number { + return liveSessionsByHost.get(hostEditor)?.size ?? 0; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts index 9f86c83fa1..9fbdb60832 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts @@ -95,6 +95,47 @@ describe('resolveNoteRuntime — empty note content', () => { ); }); + it('normalizes empty footnote reference runs out of the editable note story', () => { + const hostEditor = makeHostEditor([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + it('resolves an endnote with content: [] as a valid empty story', () => { const hostEditor = makeHostEditor([], [{ id: '1', content: [] }]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts index f9a0504461..488797a3ce 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.ts @@ -19,6 +19,7 @@ import { ensureFootnoteRefRun, updateNoteElement, } from '../../core/parts/adapters/notes-part-descriptor.js'; +import { normalizeNotePmJson } from '../helpers/note-pm-json.js'; type NoteStoryLocator = FootnoteStoryLocator | EndnoteStoryLocator; @@ -184,20 +185,20 @@ function extractNotePmJson(converter: any, isFootnote: boolean, noteId: string): // Empty arrays represent blank notes (e.g., after the reference marker is stripped) // and are valid — they produce a minimal doc with an empty paragraph. if (Array.isArray(note.content)) { - return { + return normalizeNotePmJson({ type: 'doc', content: note.content.length > 0 ? note.content : [{ type: 'paragraph' }], - }; + }); } // If the note has a `doc` field (pre-built PM JSON), return it directly if (note.doc && typeof note.doc === 'object') { - return note.doc; + return normalizeNotePmJson(note.doc); } // If the note itself looks like PM JSON (has a `type` field) if (note.type === 'doc' || note.type === 'footnoteBody' || note.type === 'endnoteBody') { - return note; + return normalizeNotePmJson(note); } return null; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts index de7b3fef2a..0180186a06 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ if (locator.storyType === 'footnote') return `fn:${locator.noteId}`; if (locator.storyType === 'endnote') return `en:${locator.noteId}`; if (locator.storyType === 'body') return 'body'; + if (locator.storyType === 'headerFooterPart') return `hf:part:${locator.refId}`; return `unknown:${JSON.stringify(locator)}`; }), resolveNoteRuntime: vi.fn(), @@ -65,6 +66,10 @@ vi.mock('./story-revision-store.js', () => ({ })); import { resolveStoryRuntime, invalidateStoryRuntime } from './resolve-story-runtime.js'; +import { + registerLiveStorySessionRuntime, + unregisterLiveStorySessionRuntime, +} from './live-story-session-runtime-registry.js'; // --------------------------------------------------------------------------- // Helpers @@ -116,6 +121,7 @@ beforeEach(() => { if (locator.storyType === 'footnote') return `fn:${locator.noteId}`; if (locator.storyType === 'endnote') return `en:${locator.noteId}`; if (locator.storyType === 'body') return 'body'; + if (locator.storyType === 'headerFooterPart') return `hf:part:${locator.refId}`; return `unknown:${JSON.stringify(locator)}`; }); mocks.isHeaderFooterPartId.mockImplementation((partId: string) => /^word\/(header|footer)\d+\.xml$/.test(partId)); @@ -358,3 +364,76 @@ describe('invalidateStoryRuntime', () => { expect(result).toBe(false); }); }); + +describe('resolveStoryRuntime — active story sessions', () => { + it('prefers the active session editor over the cached runtime editor', () => { + const hostEditor = makeHostEditor(); + const cachedRuntime = { + locator: { kind: 'story', storyType: 'footnote', noteId: '3' }, + storyKey: 'fn:3', + editor: { id: 'cached-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any, + kind: 'note' as const, + commitEditor: vi.fn(), + }; + const activeSessionEditor = { id: 'session-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + + mocks.resolveNoteRuntime.mockReturnValue(cachedRuntime); + + const cached = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + expect(cached.editor).toBe(cachedRuntime.editor); + + const unregister = registerLiveStorySessionRuntime(hostEditor, cachedRuntime, activeSessionEditor); + + const live = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + + expect(live.editor).toBe(activeSessionEditor); + live.commit?.(hostEditor); + expect(cachedRuntime.commitEditor).toHaveBeenCalledWith(hostEditor, activeSessionEditor); + + unregister(); + + const afterExit = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'footnote', + noteId: '3', + }); + + expect(afterExit).toBe(cached); + }); + + it('ignores stale unregister callbacks when a newer session replaces the same story', () => { + const hostEditor = makeHostEditor(); + const runtime = { + locator: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId11' }, + storyKey: 'hf:part:rId11', + editor: { id: 'cached-editor', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any, + kind: 'headerFooter' as const, + commitEditor: vi.fn(), + }; + const firstSessionEditor = { id: 'session-1', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + const secondSessionEditor = { id: 'session-2', on: vi.fn(), state: { doc: { content: { size: 5 } } } } as any; + + const unregisterFirst = registerLiveStorySessionRuntime(hostEditor, runtime, firstSessionEditor); + registerLiveStorySessionRuntime(hostEditor, runtime, secondSessionEditor); + + unregisterFirst(); + + const live = resolveStoryRuntime(hostEditor, { + kind: 'story', + storyType: 'headerFooterPart', + refId: 'rId11', + } as any); + + expect(live.editor).toBe(secondSessionEditor); + + unregisterLiveStorySessionRuntime(hostEditor, 'hf:part:rId11', secondSessionEditor); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts index 981d1885a7..b368782ea4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/resolve-story-runtime.ts @@ -28,6 +28,7 @@ import { resolveNoteRuntime } from './note-story-runtime.js'; import { isHeaderFooterPartId } from '../../core/parts/adapters/header-footer-part-descriptor.js'; import { initRevision, trackRevisions, restoreRevision } from '../plan-engine/revision-tracker.js'; import { getStoryRevisionStore, getStoryRevision, incrementStoryRevision } from './story-revision-store.js'; +import { resolveLiveStorySessionRuntime } from './live-story-session-runtime-registry.js'; // --------------------------------------------------------------------------- // Cache — one per host editor, attached via WeakMap @@ -191,6 +192,10 @@ export function resolveStoryRuntime( // Non-body stories — validate key and dispatch // ----------------------------------------------------------------------- const storyKey = buildStoryKey(locator); + const liveSessionRuntime = resolveLiveStorySessionRuntime(hostEditor, storyKey); + if (liveSessionRuntime) { + return liveSessionRuntime; + } // Check the cache first. const cache = getOrCreateCache(hostEditor); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts new file mode 100644 index 0000000000..56e23a3d40 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for the host-level TrackedChangeIndex service. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../../core/Editor.js'; + +const mocks = vi.hoisted(() => ({ + resolveStoryRuntime: vi.fn(), + groupTrackedChanges: vi.fn(), + enumerateRevisionCapableStories: vi.fn(), + isHeaderFooterPartId: vi.fn(() => false), + resolveRIdFromRelsData: vi.fn(() => null), +})); + +vi.mock('../../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: mocks.resolveStoryRuntime, +})); + +vi.mock('../../helpers/tracked-change-resolver.js', async (importOriginal) => { + const original = await importOriginal>(); + return { + ...original, + groupTrackedChanges: mocks.groupTrackedChanges, + }; +}); + +vi.mock('../enumerate-stories.js', () => ({ + enumerateRevisionCapableStories: mocks.enumerateRevisionCapableStories, +})); + +vi.mock('../../../core/parts/adapters/header-footer-part-descriptor.js', () => ({ + isHeaderFooterPartId: mocks.isHeaderFooterPartId, +})); + +vi.mock('../../../core/parts/adapters/header-footer-sync.js', () => ({ + resolveRIdFromRelsData: mocks.resolveRIdFromRelsData, +})); + +import { getTrackedChangeIndex } from '../tracked-change-index.js'; + +type EventHandler = (...args: unknown[]) => void; + +interface FakeEditor extends Editor { + _emit: (event: string, payload?: unknown) => void; +} + +function makeEditor(): FakeEditor { + const listeners = new Map(); + return { + state: { doc: { textBetween: () => '' } }, + commands: {}, + on(event: string, handler: EventHandler) { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event)?.push(handler); + }, + off(event: string, handler: EventHandler) { + const list = listeners.get(event); + if (!list) return; + const index = list.indexOf(handler); + if (index >= 0) list.splice(index, 1); + }, + emit: vi.fn(), + _emit(event: string, payload?: unknown) { + for (const handler of listeners.get(event) ?? []) { + handler(payload); + } + }, + } as unknown as FakeEditor; +} + +function makeGroupedChange(rawId: string, from = 0, to = 5, overrides: Record = {}) { + return { + rawId, + id: `canon-${rawId}`, + from, + to, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: { author: 'Ada', date: '2026-01-01', ...overrides }, + wordRevisionIds: undefined, + }; +} + +function makeStoryRuntime(editor: Editor, locator: { storyType: string; [k: string]: unknown }, storyKey: string) { + return { + locator: { kind: 'story', ...locator } as any, + storyKey, + editor, + kind: + locator.storyType === 'body' ? 'body' : locator.storyType.startsWith('headerFooter') ? 'headerFooter' : 'note', + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.enumerateRevisionCapableStories.mockReturnValue([{ kind: 'story', storyType: 'body' }]); + mocks.groupTrackedChanges.mockReturnValue([]); + mocks.resolveStoryRuntime.mockImplementation((host: Editor, locator: any) => { + if (!locator || locator.storyType === 'body') { + return makeStoryRuntime(host, { storyType: 'body' }, 'body'); + } + if (locator.storyType === 'footnote') { + return makeStoryRuntime(makeEditor(), locator, `fn:${locator.noteId}`); + } + if (locator.storyType === 'endnote') { + return makeStoryRuntime(makeEditor(), locator, `en:${locator.noteId}`); + } + if (locator.storyType === 'headerFooterPart') { + return makeStoryRuntime(makeEditor(), locator, `hf:part:${locator.refId}`); + } + throw new Error(`Unexpected locator: ${JSON.stringify(locator)}`); + }); +}); + +describe('TrackedChangeIndex — per-story cache', () => { + it('returns body-only snapshots when no non-body stories exist', () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValueOnce([makeGroupedChange('rev-1')]); + + const index = getTrackedChangeIndex(editor); + const snapshots = index.get({ kind: 'story', storyType: 'body' }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.anchorKey).toBe('tc::body::rev-1'); + expect(snapshots[0]?.storyKind).toBe('body'); + expect(snapshots[0]?.address).toEqual({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canon-rev-1', + }); + }); + + it('returns story-scoped anchor keys for footnote stories', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '5' }, + ]); + mocks.groupTrackedChanges.mockReturnValueOnce([]).mockReturnValueOnce([makeGroupedChange('rev-7')]); + + const index = getTrackedChangeIndex(editor); + const all = index.getAll(); + + expect(all).toHaveLength(1); + expect(all[0]?.anchorKey).toBe('tc::fn:5::rev-7'); + expect(all[0]?.storyLabel).toBe('Footnote 5'); + expect(all[0]?.address).toEqual({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canon-rev-7', + story: { kind: 'story', storyType: 'footnote', noteId: '5' }, + }); + }); + + it('produces distinct snapshots when body and non-body share a rawId', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + ]); + mocks.groupTrackedChanges + .mockReturnValueOnce([makeGroupedChange('shared')]) + .mockReturnValueOnce([makeGroupedChange('shared')]); + + const index = getTrackedChangeIndex(editor); + const all = index.getAll(); + + expect(all).toHaveLength(2); + const keys = all.map((snapshot) => snapshot.anchorKey); + expect(keys).toContain('tc::body::shared'); + expect(keys).toContain('tc::fn:1::shared'); + }); +}); + +describe('TrackedChangeIndex — invalidation', () => { + it('body edits only dirty the body cache', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + ]); + mocks.groupTrackedChanges.mockReturnValueOnce([]).mockReturnValueOnce([makeGroupedChange('fn-1')]); + + const index = getTrackedChangeIndex(editor); + index.getAll(); + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(2); + + editor._emit('transaction', { transaction: { docChanged: true } }); + + mocks.groupTrackedChanges + .mockReturnValueOnce([makeGroupedChange('body-1')]) + .mockReturnValue([makeGroupedChange('fn-1')]); + + index.getAll(); + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(3); + }); + + it('invalidateAll wipes every cache', () => { + const editor = makeEditor(); + mocks.enumerateRevisionCapableStories.mockReturnValue([{ kind: 'story', storyType: 'body' }]); + mocks.groupTrackedChanges.mockReturnValue([makeGroupedChange('x')]); + + const index = getTrackedChangeIndex(editor); + index.getAll(); + index.invalidateAll(); + index.getAll(); + + expect(mocks.groupTrackedChanges).toHaveBeenCalledTimes(2); + }); +}); + +describe('TrackedChangeIndex — broadcast', () => { + it('emits a coalesced tracked-changes-changed event after invalidation', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + + index.invalidate({ kind: 'story', storyType: 'body' }); + index.invalidate({ kind: 'story', storyType: 'body' }); + index.invalidate({ kind: 'story', storyType: 'body' }); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ editor, source: 'invalidate' }), + ); + }); + + it('unions different stories invalidated in the same tick', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '7' } as const; + + index.invalidate(bodyStory); + index.invalidate(footnoteStory); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: 'invalidate', + stories: expect.arrayContaining([bodyStory, footnoteStory]), + }), + ); + }); + + it('drops the coalesced source when the same tick mixes body and non-body invalidations', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '7' } as const; + + editor._emit('transaction', { transaction: { docChanged: true } }); + index.invalidate(footnoteStory); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: undefined, + stories: expect.arrayContaining([{ kind: 'story', storyType: 'body' }, footnoteStory]), + }), + ); + }); + + it('notifies subscribers with the aggregated snapshot list', async () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValue([makeGroupedChange('r1')]); + + const index = getTrackedChangeIndex(editor); + const listener = vi.fn(); + const unsubscribe = index.subscribe(listener); + + index.invalidate({ kind: 'story', storyType: 'body' }); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledTimes(1); + const snapshot = listener.mock.calls[0][0]; + expect(snapshot).toHaveLength(1); + expect(snapshot[0].anchorKey).toBe('tc::body::r1'); + + unsubscribe(); + index.invalidate({ kind: 'story', storyType: 'body' }); + await Promise.resolve(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts new file mode 100644 index 0000000000..3c24cfddb3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { enumerateRevisionCapableStories } from './enumerate-stories.js'; + +function makeEditor(converter?: Record): Editor { + return { converter } as unknown as Editor; +} + +describe('enumerateRevisionCapableStories', () => { + it('returns only the body when the editor has no converter', () => { + expect(enumerateRevisionCapableStories(makeEditor())).toEqual([{ kind: 'story', storyType: 'body' }]); + }); + + it('includes headers and footers as part refs in converter-order', () => { + const editor = makeEditor({ + headers: { rId1: {}, rId2: {} }, + footers: { rId5: {} }, + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId2' }, + { kind: 'story', storyType: 'headerFooterPart', refId: 'rId5' }, + ]); + }); + + it('includes revision-capable footnotes and endnotes', () => { + const editor = makeEditor({ + footnotes: [{ id: 1 }, { id: '7' }], + endnotes: [{ id: 2 }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + { kind: 'story', storyType: 'footnote', noteId: '7' }, + { kind: 'story', storyType: 'endnote', noteId: '2' }, + ]); + }); + + it('skips notes with negative ids (separator / continuationSeparator)', () => { + const editor = makeEditor({ + footnotes: [{ id: -1 }, { id: 0 }, { id: 3 }], + endnotes: [{ id: '-2' }, { id: '4' }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '0' }, + { kind: 'story', storyType: 'footnote', noteId: '3' }, + { kind: 'story', storyType: 'endnote', noteId: '4' }, + ]); + }); + + it('skips notes missing an id rather than emitting "undefined" locators', () => { + const editor = makeEditor({ + footnotes: [{ id: undefined } as unknown as { id: string }, { id: 1 }], + endnotes: [null as unknown as { id: string }, { id: 2 }], + }); + + expect(enumerateRevisionCapableStories(editor)).toEqual([ + { kind: 'story', storyType: 'body' }, + { kind: 'story', storyType: 'footnote', noteId: '1' }, + { kind: 'story', storyType: 'endnote', noteId: '2' }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts new file mode 100644 index 0000000000..0bfdff7577 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts @@ -0,0 +1,88 @@ +/** + * Enumerate every revision-capable story in a host document. + * + * Used by {@link TrackedChangeIndex} to drive cross-story tracked-change + * discovery when callers pass `in: 'all'` or need to build a single + * aggregated snapshot. + * + * The enumeration is purely a read over the converter's derived caches — + * it never resolves a story runtime. Runtime resolution is deferred to + * the index so we do not pay editor construction cost for stories that + * hold zero tracked changes. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; + +interface NoteEntry { + id: string | number; +} + +interface ConverterShape { + headers?: Record; + footers?: Record; + footnotes?: NoteEntry[]; + endnotes?: NoteEntry[]; +} + +function getConverter(editor: Editor): ConverterShape | undefined { + return (editor as unknown as { converter?: ConverterShape }).converter; +} + +/** + * Returns the note's revision-capable id as a string, or `null` when the note + * lacks an id or uses a special-purpose negative id (separator, + * continuationSeparator, etc.). + */ +function toRevisionCapableNoteId(note: NoteEntry | undefined | null): string | null { + if (!note || note.id === undefined || note.id === null) return null; + const numeric = Number(note.id); + if (Number.isFinite(numeric) && numeric < 0) return null; + const noteId = String(note.id); + return noteId.length > 0 ? noteId : null; +} + +/** + * Returns every revision-capable story locator for the given host editor. + * + * Body is always first; header/footer parts, footnotes, and endnotes follow + * in insertion-order. Header/footer slots are intentionally NOT enumerated — + * tracked-change identity always addresses the owning part, so slot + * enumeration would produce duplicates against parts. + */ +export function enumerateRevisionCapableStories(editor: Editor): StoryLocator[] { + const stories: StoryLocator[] = [{ kind: 'story', storyType: 'body' }]; + + const converter = getConverter(editor); + if (!converter) return stories; + + if (converter.headers) { + for (const refId of Object.keys(converter.headers)) { + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + } + + if (converter.footers) { + for (const refId of Object.keys(converter.footers)) { + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + } + + if (Array.isArray(converter.footnotes)) { + for (const note of converter.footnotes) { + const noteId = toRevisionCapableNoteId(note); + if (!noteId) continue; + stories.push({ kind: 'story', storyType: 'footnote', noteId }); + } + } + + if (Array.isArray(converter.endnotes)) { + for (const note of converter.endnotes) { + const noteId = toRevisionCapableNoteId(note); + if (!noteId) continue; + stories.push({ kind: 'story', storyType: 'endnote', noteId }); + } + } + + return stories; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts new file mode 100644 index 0000000000..7ad06bc5b6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import type { StoryLocator } from '@superdoc/document-api'; +import { classifyStoryKind, describeStoryLocation } from './story-labels.js'; + +describe('classifyStoryKind', () => { + it('classifies body stories', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'body' })).toBe('body'); + }); + + it('classifies header/footer slot stories as headerFooter', () => { + const locator: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: 's1' }, + headerFooterKind: 'header', + variant: 'default', + }; + expect(classifyStoryKind(locator)).toBe('headerFooter'); + }); + + it('classifies header/footer part stories as headerFooter', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' })).toBe('headerFooter'); + }); + + it('classifies footnote and endnote stories', () => { + expect(classifyStoryKind({ kind: 'story', storyType: 'footnote', noteId: '1' })).toBe('footnote'); + expect(classifyStoryKind({ kind: 'story', storyType: 'endnote', noteId: '2' })).toBe('endnote'); + }); +}); + +describe('describeStoryLocation', () => { + it('returns an empty string for body stories', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'body' })).toBe(''); + }); + + it('labels default header/footer slots with kind and section', () => { + const locator: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '3' }, + headerFooterKind: 'header', + variant: 'default', + }; + expect(describeStoryLocation(locator)).toBe('Header · Section 3'); + }); + + it('includes variant when header/footer slot is first or even', () => { + const first: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '1' }, + headerFooterKind: 'footer', + variant: 'first', + }; + expect(describeStoryLocation(first)).toBe('Footer · Section 1 · First page'); + + const even: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: { kind: 'section', sectionId: '2' }, + headerFooterKind: 'header', + variant: 'even', + }; + expect(describeStoryLocation(even)).toBe('Header · Section 2 · Even pages'); + }); + + it('labels header/footer parts with their refId', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' })).toBe( + 'Header/Footer · rId7', + ); + }); + + it('labels footnotes and endnotes with their noteId', () => { + expect(describeStoryLocation({ kind: 'story', storyType: 'footnote', noteId: '12' })).toBe('Footnote 12'); + expect(describeStoryLocation({ kind: 'story', storyType: 'endnote', noteId: '4' })).toBe('Endnote 4'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts new file mode 100644 index 0000000000..408d63b88d --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts @@ -0,0 +1,76 @@ +/** + * Human-readable story labels for sidebar cards and review UI. + * + * Produces strings like: + * - Body tracked changes → `""` (empty — sidebar renders no extra badge) + * - Headers / footers → `"Header"`, `"Footer"`, `"Header · Section 3"`, `"Footer · First page"` + * - Footnotes / endnotes → `"Footnote 12"`, `"Endnote 4"` + * + * Labels are strictly informational — they never drive behavior. Identity + * continues to flow through `storyKey` / `StoryLocator`. + */ + +import type { StoryLocator } from '@superdoc/document-api'; + +export type StoryKind = 'body' | 'headerFooter' | 'footnote' | 'endnote'; + +/** Coarse classifier for UI decisions (icons, labels, sort groups). */ +export function classifyStoryKind(locator: StoryLocator): StoryKind { + switch (locator.storyType) { + case 'body': + return 'body'; + case 'headerFooterSlot': + case 'headerFooterPart': + return 'headerFooter'; + case 'footnote': + return 'footnote'; + case 'endnote': + return 'endnote'; + } +} + +function capitalize(word: string): string { + if (!word) return word; + return word.charAt(0).toUpperCase() + word.slice(1); +} + +function variantLabel(variant: 'default' | 'first' | 'even'): string { + switch (variant) { + case 'first': + return 'First page'; + case 'even': + return 'Even pages'; + case 'default': + return 'Default'; + } +} + +/** + * Returns a human-readable label describing where the tracked change lives. + * + * Body tracked changes return an empty string so the sidebar can render + * them without an extra location badge. + */ +export function describeStoryLocation(locator: StoryLocator): string { + switch (locator.storyType) { + case 'body': + return ''; + + case 'headerFooterSlot': { + const kind = capitalize(locator.headerFooterKind); + const variant = variantLabel(locator.variant); + const section = locator.section.sectionId; + if (variant === 'Default') return `${kind} · Section ${section}`; + return `${kind} · Section ${section} · ${variant}`; + } + + case 'headerFooterPart': + return `Header/Footer · ${locator.refId}`; + + case 'footnote': + return `Footnote ${locator.noteId}`; + + case 'endnote': + return `Endnote ${locator.noteId}`; + } +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts new file mode 100644 index 0000000000..6d143f1640 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -0,0 +1,362 @@ +/** + * Host-level tracked-change index service. + * + * Owns every aspect of tracked-change discovery across revision-capable + * stories: + * + * - Discovery: walks body + headers + footers + footnotes + endnotes. + * - Caching: per-story snapshot array keyed by `storyKey`. + * - Invalidation: targeted — `mutatePart` commits only invalidate the one + * part they touched; body edits only refresh the body cache. + * - Broadcast: emits `tracked-changes-changed` on the host editor so + * comments-store, navigation, and review surfaces can resync. + */ + +import type { StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { PartChangedEvent } from '../../core/parts/types.js'; +import { buildStoryKey, BODY_STORY_KEY, parseStoryKeyType } from '../story-runtime/story-key.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { + groupTrackedChanges, + resolveTrackedChangeType, + type GroupedTrackedChange, +} from '../helpers/tracked-change-resolver.js'; +import { makeTrackedChangeAnchorKey, type TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; +import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; +import { enumerateRevisionCapableStories } from './enumerate-stories.js'; +import { classifyStoryKind, describeStoryLocation } from './story-labels.js'; +import type { TrackedChangeSnapshot } from './tracked-change-snapshot.js'; +import { isHeaderFooterPartId } from '../../core/parts/adapters/header-footer-part-descriptor.js'; +import { resolveRIdFromRelsData } from '../../core/parts/adapters/header-footer-sync.js'; + +export type TrackedChangeIndexListener = (snapshots: ReadonlyArray) => void; + +export interface TrackedChangeIndex { + get(locator: StoryLocator): ReadonlyArray; + getAll(): ReadonlyArray; + invalidate(locator: StoryLocator): void; + invalidateAll(): void; + subscribe(listener: TrackedChangeIndexListener): () => void; + dispose(): void; +} + +const indexByHost = new WeakMap(); + +export function getTrackedChangeIndex(hostEditor: Editor): TrackedChangeIndex { + let index = indexByHost.get(hostEditor); + if (!index) { + index = new TrackedChangeIndexImpl(hostEditor); + indexByHost.set(hostEditor, index); + } + return index; +} + +function buildTrackedChangeAddress( + locator: StoryLocator, + storyKey: string, + canonicalId: string, +): TrackedChangeSnapshot['address'] { + return { + kind: 'entity', + entityType: 'trackedChange', + entityId: canonicalId, + ...(storyKey === BODY_STORY_KEY ? {} : { story: locator }), + }; +} + +class TrackedChangeIndexImpl implements TrackedChangeIndex { + readonly #hostEditor: Editor; + readonly #snapshots = new Map(); + #aggregated: TrackedChangeSnapshot[] | null = null; + readonly #dirtyStoryKeys = new Set(); + readonly #listeners = new Set(); + readonly #teardowns: Array<() => void> = []; + #broadcastScheduled = false; + #pendingBroadcastStories: StoryLocator[] | undefined | null = null; + #pendingBroadcastSource: string | undefined | null = null; + #bodyDirty = true; + + constructor(hostEditor: Editor) { + this.#hostEditor = hostEditor; + this.#attachHostListeners(); + } + + get(locator: StoryLocator): ReadonlyArray { + const storyKey = buildStoryKey(locator); + return this.#getByKey(storyKey, locator); + } + + getAll(): ReadonlyArray { + if (this.#aggregated && this.#dirtyStoryKeys.size === 0) { + return this.#aggregated; + } + + const stories = enumerateRevisionCapableStories(this.#hostEditor); + const flat: TrackedChangeSnapshot[] = []; + for (const story of stories) { + const storyKey = buildStoryKey(story); + const snapshots = this.#getByKey(storyKey, story); + flat.push(...snapshots); + } + + this.#aggregated = flat; + return flat; + } + + invalidate(locator: StoryLocator): void { + const storyKey = buildStoryKey(locator); + this.#invalidateKey(storyKey); + this.#scheduleBroadcast([locator], 'invalidate'); + } + + invalidateAll(): void { + this.#snapshots.clear(); + this.#dirtyStoryKeys.clear(); + this.#aggregated = null; + this.#bodyDirty = true; + this.#scheduleBroadcast(undefined, 'invalidateAll'); + } + + subscribe(listener: TrackedChangeIndexListener): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + dispose(): void { + for (const teardown of this.#teardowns) { + try { + teardown(); + } catch { + // Teardown errors during host disposal are non-fatal. + } + } + this.#teardowns.length = 0; + this.#listeners.clear(); + this.#snapshots.clear(); + this.#dirtyStoryKeys.clear(); + this.#aggregated = null; + indexByHost.delete(this.#hostEditor); + } + + #getByKey(storyKey: string, locator: StoryLocator): TrackedChangeSnapshot[] { + if (storyKey === BODY_STORY_KEY) { + if (this.#bodyDirty || !this.#snapshots.has(storyKey)) { + const bodySnapshots = this.#buildSnapshotsFromEditor(this.#hostEditor, storyKey, locator); + this.#snapshots.set(storyKey, bodySnapshots); + this.#bodyDirty = false; + this.#dirtyStoryKeys.delete(storyKey); + this.#aggregated = null; + } + return this.#snapshots.get(storyKey) ?? []; + } + + if (this.#dirtyStoryKeys.has(storyKey) || !this.#snapshots.has(storyKey)) { + const snapshots = this.#computeStorySnapshots(locator, storyKey); + this.#snapshots.set(storyKey, snapshots); + this.#dirtyStoryKeys.delete(storyKey); + this.#aggregated = null; + } + + return this.#snapshots.get(storyKey) ?? []; + } + + #computeStorySnapshots(locator: StoryLocator, storyKey: string): TrackedChangeSnapshot[] { + let runtime; + try { + runtime = resolveStoryRuntime(this.#hostEditor, locator); + } catch { + return []; + } + + return this.#buildSnapshotsFromEditor(runtime.editor, storyKey, locator); + } + + #buildSnapshotsFromEditor(editor: Editor, storyKey: string, locator: StoryLocator): TrackedChangeSnapshot[] { + const grouped = groupTrackedChanges(editor); + if (grouped.length === 0) return []; + + const storyKind = classifyStoryKind(locator); + const storyLabel = describeStoryLocation(locator); + + return grouped.map((change) => this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel)); + } + + #buildSnapshot( + editor: Editor, + change: GroupedTrackedChange, + storyKey: string, + locator: StoryLocator, + storyKind: TrackedChangeSnapshot['storyKind'], + storyLabel: string, + ): TrackedChangeSnapshot { + const runtimeRef: TrackedChangeRuntimeRef = { storyKey, rawId: change.rawId }; + const address = buildTrackedChangeAddress(locator, storyKey, change.id); + const type = resolveTrackedChangeType(change); + const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); + + return { + address, + runtimeRef, + story: locator, + type, + author: toNonEmptyString(change.attrs.author), + authorEmail: toNonEmptyString(change.attrs.authorEmail), + authorImage: toNonEmptyString(change.attrs.authorImage), + date: toNonEmptyString(change.attrs.date), + excerpt, + wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + storyLabel, + storyKind, + anchorKey: makeTrackedChangeAnchorKey(runtimeRef), + range: { from: change.from, to: change.to }, + }; + } + + #attachHostListeners(): void { + const editor = this.#hostEditor; + if (typeof editor.on !== 'function') return; + + const onTransaction = ({ transaction }: { transaction: { docChanged: boolean } }): void => { + if (!transaction.docChanged) return; + this.#bodyDirty = true; + this.#aggregated = null; + this.#scheduleBroadcast([{ kind: 'story', storyType: 'body' }], 'body-edit'); + }; + editor.on('transaction', onTransaction); + this.#teardowns.push(() => editor.off?.('transaction', onTransaction)); + + const onPartChanged = (event: PartChangedEvent): void => { + const invalidatedStories = this.#storiesFromPartChange(event); + if (invalidatedStories.length === 0) return; + for (const story of invalidatedStories) { + this.#invalidateKey(buildStoryKey(story)); + } + this.#scheduleBroadcast(invalidatedStories, 'partChanged'); + }; + editor.on('partChanged', onPartChanged); + this.#teardowns.push(() => editor.off?.('partChanged', onPartChanged)); + + const onNotesChanged = (): void => { + const wiped: StoryLocator[] = []; + for (const key of Array.from(this.#snapshots.keys())) { + if (!key.startsWith('fn:') && !key.startsWith('en:')) continue; + this.#invalidateKey(key); + const storyType: 'footnote' | 'endnote' = key.startsWith('fn:') ? 'footnote' : 'endnote'; + const noteId = key.slice(storyType === 'footnote' ? 'fn:'.length : 'en:'.length); + wiped.push({ kind: 'story', storyType, noteId }); + } + + if (wiped.length > 0) { + this.#scheduleBroadcast(wiped, 'notes-part-changed'); + return; + } + + this.#aggregated = null; + this.#scheduleBroadcast(undefined, 'notes-part-changed'); + }; + editor.on('notes-part-changed', onNotesChanged); + this.#teardowns.push(() => editor.off?.('notes-part-changed', onNotesChanged)); + + const onDestroy = (): void => { + this.dispose(); + }; + editor.on('destroy', onDestroy); + this.#teardowns.push(() => editor.off?.('destroy', onDestroy)); + } + + #storiesFromPartChange(event: PartChangedEvent): StoryLocator[] { + const stories: StoryLocator[] = []; + const converter = (this.#hostEditor as unknown as { converter?: { convertedXml?: Record } }) + .converter; + const relsData = converter?.convertedXml?.['word/_rels/document.xml.rels']; + + for (const part of event.parts) { + if (!isHeaderFooterPartId(part.partId)) continue; + const refId = resolveRIdFromRelsData(relsData, part.partId); + if (!refId) continue; + stories.push({ kind: 'story', storyType: 'headerFooterPart', refId }); + } + + return stories; + } + + #invalidateKey(storyKey: string): void { + if (storyKey === BODY_STORY_KEY) { + this.#bodyDirty = true; + } else { + this.#dirtyStoryKeys.add(storyKey); + this.#snapshots.delete(storyKey); + } + this.#aggregated = null; + } + + #scheduleBroadcast(stories: StoryLocator[] | undefined, source: string): void { + this.#pendingBroadcastStories = this.#mergePendingStories(this.#pendingBroadcastStories, stories); + this.#pendingBroadcastSource = this.#mergePendingSource(this.#pendingBroadcastSource, source); + + if (this.#broadcastScheduled) return; + + this.#broadcastScheduled = true; + void Promise.resolve().then(() => { + this.#broadcastScheduled = false; + const pendingStories = this.#pendingBroadcastStories; + const pendingSource = this.#pendingBroadcastSource; + this.#pendingBroadcastStories = null; + this.#pendingBroadcastSource = null; + + this.#emitHostEvent(pendingStories ?? undefined, pendingSource ?? undefined); + this.#notifySubscribers(); + }); + } + + #mergePendingStories( + current: StoryLocator[] | undefined | null, + next: StoryLocator[] | undefined, + ): StoryLocator[] | undefined { + if (current === undefined || next === undefined) return undefined; + + const merged = new Map(); + for (const story of current ?? []) { + merged.set(buildStoryKey(story), story); + } + for (const story of next ?? []) { + merged.set(buildStoryKey(story), story); + } + return Array.from(merged.values()); + } + + #mergePendingSource(current: string | undefined | null, next: string): string | undefined { + if (current === null) return next; + if (current === undefined || current === next) return current; + return undefined; + } + + #emitHostEvent(stories: StoryLocator[] | undefined, source?: string): void { + const editor = this.#hostEditor; + if (typeof editor.emit !== 'function') return; + editor.emit('tracked-changes-changed', { + editor, + stories, + source, + }); + } + + #notifySubscribers(): void { + if (this.#listeners.size === 0) return; + const snapshot = this.getAll(); + for (const listener of Array.from(this.#listeners)) { + try { + listener(snapshot); + } catch { + // Listener failures must not prevent other subscribers from syncing. + } + } + } +} + +export { classifyStoryKind, describeStoryLocation } from './story-labels.js'; +export type { TrackedChangeSnapshot } from './tracked-change-snapshot.js'; +export { parseStoryKeyType as parseStoryKind }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts new file mode 100644 index 0000000000..b9ed36a2e2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -0,0 +1,44 @@ +/** + * Canonical tracked-change snapshot — the single shape every downstream + * consumer (sidebar, navigation, document-api list/get, review tools) reads + * from the {@link TrackedChangeIndex}. + */ + +import type { + StoryLocator, + TrackedChangeAddress, + TrackChangeType, + TrackChangeWordRevisionIds, +} from '@superdoc/document-api'; +import type { TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; + +export interface TrackedChangeSnapshot { + /** Public, story-aware address for contract use. */ + address: TrackedChangeAddress; + /** Internal runtime ref for routing and caching. */ + runtimeRef: TrackedChangeRuntimeRef; + /** Story locator for this snapshot. */ + story: StoryLocator; + /** Tracked-change kind. */ + type: TrackChangeType; + /** Author display name, if captured on the mark. */ + author?: string; + /** Author email, if captured. */ + authorEmail?: string; + /** Author avatar URL, if captured. */ + authorImage?: string; + /** Change creation timestamp, if captured. */ + date?: string; + /** Short textual excerpt for sidebar display. */ + excerpt?: string; + /** Raw imported Word revision IDs, if present. */ + wordRevisionIds?: TrackChangeWordRevisionIds; + /** Human-readable label for sidebar cards ("Footer · Section 3", "Footnote 12"). */ + storyLabel: string; + /** Coarse classifier for UI decisions (icon, label). */ + storyKind: 'body' | 'headerFooter' | 'footnote' | 'endnote'; + /** Canonical shared-map anchor key (`tc::::`). */ + anchorKey: string; + /** Absolute PM position range within the story editor. */ + range: { from: number; to: number }; +} diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts index 7171e6fab8..7e94965c71 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts @@ -10,6 +10,7 @@ import { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from './D type MutableElementsFromPointDocument = Document & { elementsFromPoint?: (x: number, y: number) => Element[]; + caretRangeFromPoint?: (x: number, y: number) => Range | null; }; /** @@ -69,6 +70,27 @@ function buildPageDom( return page; } +function mockRect(element: Element, rect: { left: number; top: number; width: number; height: number }): void { + const value = { + x: rect.left, + y: rect.top, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON() { + return this; + }, + }; + + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => value, + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -189,6 +211,77 @@ describe('DomPointerMapping', () => { expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(10); }); + + it('maps the right half of a tracked-change span to the next rendered span start when PM has hidden gaps', () => { + container.innerHTML = ` +
+
+
+ This is a sim + Z + ple footnote +
+
+
+ `; + + const page = container.querySelector('.superdoc-page') as HTMLElement; + const fragment = container.querySelector('.superdoc-fragment') as HTMLElement; + const line = container.querySelector('.superdoc-line') as HTMLElement; + const spans = Array.from(container.querySelectorAll('span')) as HTMLElement[]; + const insertedSpan = spans[1]; + const insertedTextNode = insertedSpan.firstChild as Text; + + mockRect(page, { left: 100, top: 10, width: 240, height: 30 }); + mockRect(fragment, { left: 100, top: 10, width: 240, height: 30 }); + mockRect(line, { left: 110, top: 10, width: 160, height: 20 }); + mockRect(spans[0], { left: 110, top: 10, width: 77, height: 20 }); + mockRect(spans[1], { left: 187, top: 10, width: 8, height: 20 }); + mockRect(spans[2], { left: 195, top: 10, width: 70, height: 20 }); + + const doc = document as MutableElementsFromPointDocument; + const originalElementsFromPoint = doc.elementsFromPoint; + const originalCaretRangeFromPoint = doc.caretRangeFromPoint; + + doc.elementsFromPoint = () => [ + insertedSpan, + line, + fragment, + page, + container, + document.body, + document.documentElement, + ]; + doc.caretRangeFromPoint = (x: number) => { + if (x < 191) { + return { + startContainer: insertedTextNode, + startOffset: 0, + } as Range; + } + + return { + startContainer: insertedTextNode, + startOffset: 1, + } as Range; + }; + + try { + expect(clickToPositionDom(container, 194, 18)).toBe(21); + } finally { + if (originalElementsFromPoint) { + doc.elementsFromPoint = originalElementsFromPoint; + } else { + delete doc.elementsFromPoint; + } + + if (originalCaretRangeFromPoint) { + doc.caretRangeFromPoint = originalCaretRangeFromPoint; + } else { + delete doc.caretRangeFromPoint; + } + } + }); }); // ----------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts index a645c6dd4c..32fdfb2ff4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -214,6 +214,26 @@ export function clickToPositionDom(domContainer: HTMLElement, clientX: number, c return resolveFragment(fragmentEl, clientX, clientY); } +/** + * Resolves a click within a specific rendered fragment. + * + * Unlike {@link clickToPositionDom}, this helper does not scan the full page + * hit chain to choose a fragment. Callers that already know which rendered + * fragment owns the click can use this to avoid cross-surface ambiguity when + * multiple stories share overlapping PM position ranges. + */ +export function resolvePositionWithinFragmentDom( + fragmentEl: HTMLElement, + clientX: number, + clientY: number, +): number | null { + if (!fragmentEl.classList?.contains?.(CLASS.fragment)) { + return null; + } + + return resolveFragment(fragmentEl, clientX, clientY); +} + /** * Finds the page element containing the given viewport coordinates. * @@ -359,21 +379,52 @@ function resolvePositionInLine( const targetEl = findSpanAtX(spanEls, viewX); if (!targetEl) return lineStart; + const targetIndex = spanEls.indexOf(targetEl); + if (targetIndex < 0) { + return lineStart; + } const { start: spanStart, end: spanEnd } = readPmRange(targetEl); if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return null; + const rightCaretBoundary = resolveRightCaretBoundary(spanEls, targetIndex, spanStart, spanEnd); // Non-text or empty element → snap to nearest edge const firstChild = targetEl.firstChild; if (!firstChild || firstChild.nodeType !== Node.TEXT_NODE || !firstChild.textContent) { const targetRect = targetEl.getBoundingClientRect(); const closerToLeft = Math.abs(viewX - targetRect.left) <= Math.abs(viewX - targetRect.right); - return rtl ? (closerToLeft ? spanEnd : spanStart) : closerToLeft ? spanStart : spanEnd; + return rtl ? (closerToLeft ? rightCaretBoundary : spanStart) : closerToLeft ? spanStart : rightCaretBoundary; } const textNode = firstChild as Text; const charIndex = findCharIndexAtX(textNode, viewX, rtl); - return mapCharIndexToPm(spanStart, spanEnd, textNode.length, charIndex); + return mapCharIndexToPm(spanStart, spanEnd, rightCaretBoundary, textNode.length, charIndex); +} + +/** + * Visible text can be split across adjacent PM wrapper nodes, which creates + * hidden structural gaps between consecutive rendered spans. The caret the user + * sees at the right edge of the current span should land at the next rendered + * span's start, not inside the hidden wrapper gap. + */ +function resolveRightCaretBoundary( + spanEls: readonly HTMLElement[], + targetIndex: number, + spanStart: number, + spanEnd: number, +): number { + for (let index = targetIndex + 1; index < spanEls.length; index += 1) { + const { start: nextStart } = readPmRange(spanEls[index]); + if (!Number.isFinite(nextStart)) { + continue; + } + if (nextStart > spanEnd) { + return nextStart; + } + break; + } + + return spanEnd; } // --------------------------------------------------------------------------- @@ -431,19 +482,45 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null * Otherwise (e.g. ligatures or collapsed content) falls back to a midpoint * heuristic. */ -function mapCharIndexToPm(spanStart: number, spanEnd: number, textLength: number, charIndex: number): number { +function mapCharIndexToPm( + spanStart: number, + spanEnd: number, + rightCaretBoundary: number, + textLength: number, + charIndex: number, +): number { if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) return spanStart; if (textLength <= 0) return spanStart; const pmRange = spanEnd - spanStart; if (!Number.isFinite(pmRange) || pmRange <= 0) return spanStart; + const safeRightBoundary = + Number.isFinite(rightCaretBoundary) && rightCaretBoundary >= spanEnd ? rightCaretBoundary : spanEnd; + + const clampedIndex = Math.max(0, Math.min(textLength, charIndex)); + + // When text is split across wrapper nodes (for example tracked-change runs), + // PM exposes hidden boundary positions between visible spans. Preserve the + // normal 1:1 mapping for visible characters and reserve the structural gap + // for the final caret boundary only. + if (safeRightBoundary > spanEnd) { + if (clampedIndex >= textLength) { + return safeRightBoundary; + } + + const directPos = spanStart + clampedIndex; + if (directPos <= spanEnd) { + return directPos; + } + } + if (pmRange === textLength) { - return Math.min(spanEnd, Math.max(spanStart, spanStart + charIndex)); + return Math.min(spanEnd, Math.max(spanStart, spanStart + clampedIndex)); } // PM range ≠ text length — snap to closer half - return charIndex / textLength <= 0.5 ? spanStart : spanEnd; + return clampedIndex / textLength <= 0.5 ? spanStart : safeRightBoundary; } /** diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts index b555ff3a4b..924f883f8e 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -18,6 +18,20 @@ export type DomPositionIndexEntry = { el: HTMLElement; }; +function isExcludedFromBodyDomIndex(node: HTMLElement): boolean { + if (node.closest('.superdoc-page-header, .superdoc-page-footer')) { + return true; + } + + const blockId = node.closest('[data-block-id]')?.dataset.blockId ?? ''; + return ( + blockId.startsWith('footnote-') || + blockId.startsWith('__sd_semantic_footnote-') || + blockId.startsWith('endnote-') || + blockId.startsWith('__sd_semantic_endnote-') + ); +} + /** * Options for controlling how the DOM position index is rebuilt. */ @@ -98,7 +112,7 @@ export class DomPositionIndex { for (const node of pmNodes) { if (node.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) continue; - if (node.closest('.superdoc-page-header, .superdoc-page-footer')) continue; + if (isExcludedFromBodyDomIndex(node)) continue; if (leafOnly && nonLeaf.has(node)) continue; const pmStart = Number(node.dataset.pmStart ?? 'NaN'); diff --git a/packages/super-editor/src/editors/v1/dom-observer/index.ts b/packages/super-editor/src/editors/v1/dom-observer/index.ts index 44155994f1..869c70fbd2 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/index.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/index.ts @@ -20,4 +20,9 @@ export { deduplicateOverlappingRects, } from './DomSelectionGeometry.js'; export { getPageElementByIndex } from './PageDom.js'; -export { clickToPositionDom, findPageElement, readLayoutEpochFromDom } from './DomPointerMapping.js'; +export { + clickToPositionDom, + findPageElement, + readLayoutEpochFromDom, + resolvePositionWithinFragmentDom, +} from './DomPointerMapping.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js index 47112d67d5..030a9b26aa 100644 --- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js @@ -253,19 +253,43 @@ export const broadcastEditorEvents = (editor, sectionEditor) => { }); }; +const applyHeaderFooterEditorDocumentMode = (editor, documentMode) => { + if (!editor) return; + + if (documentMode === 'viewing') { + editor.commands?.enableTrackChangesShowOriginal?.(); + editor.setOptions?.({ documentMode: 'viewing' }); + editor.setEditable(false, false); + } else if (documentMode === 'suggesting') { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.enableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'suggesting' }); + editor.setEditable(true, false); + } else { + editor.commands?.disableTrackChangesShowOriginal?.(); + editor.commands?.disableTrackChanges?.(); + editor.setOptions?.({ documentMode: 'editing' }); + editor.setEditable(true, false); + } + + if (editor.view?.dom) { + editor.view.dom.setAttribute('aria-readonly', documentMode === 'viewing' ? 'true' : 'false'); + editor.view.dom.setAttribute('documentmode', documentMode); + editor.view.dom.classList.toggle('view-mode', documentMode === 'viewing'); + } +}; + export const toggleHeaderFooterEditMode = ({ editor, focusedSectionEditor, isEditMode, documentMode }) => { if (isHeadless(editor)) return; + const targetMode = isEditMode ? documentMode : 'viewing'; + editor.converter.headerEditors.forEach((item) => { - item.editor.setEditable(isEditMode, false); - item.editor.view.dom.setAttribute('aria-readonly', !isEditMode); - item.editor.view.dom.setAttribute('documentmode', documentMode); + applyHeaderFooterEditorDocumentMode(item.editor, targetMode); }); editor.converter.footerEditors.forEach((item) => { - item.editor.setEditable(isEditMode, false); - item.editor.view.dom.setAttribute('aria-readonly', !isEditMode); - item.editor.view.dom.setAttribute('documentmode', documentMode); + applyHeaderFooterEditorDocumentMode(item.editor, targetMode); }); if (isEditMode) { diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js index 007ec83f32..2161478588 100644 --- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.test.js @@ -9,6 +9,13 @@ const { MockEditor, getStarterExtensions, applyStyleIsolationClass } = vi.hoiste this.once = vi.fn(); this.emit = vi.fn(); this.setEditable = vi.fn(); + this.setOptions = vi.fn(); + this.commands = { + enableTrackChanges: vi.fn(), + disableTrackChanges: vi.fn(), + enableTrackChangesShowOriginal: vi.fn(), + disableTrackChangesShowOriginal: vi.fn(), + }; this.view = { dom: document.createElement('div') }; this.storage = { image: { media: {} } }; } @@ -41,13 +48,17 @@ vi.mock('@core/parts/adapters/header-footer-sync.js', () => ({ exportSubEditorToPart: vi.fn(), })); -import { createHeaderFooterEditor } from './pagination-helpers.js'; +import { createHeaderFooterEditor, toggleHeaderFooterEditMode } from './pagination-helpers.js'; function createParentEditor() { return { constructor: MockEditor, options: { role: 'editor', + user: { + name: 'SuperDoc Test', + email: 'test@superdoc.com', + }, fonts: {}, isHeadless: true, }, @@ -92,7 +103,50 @@ describe('createHeaderFooterEditor', () => { expect.objectContaining({ isHeaderOrFooter: true, headerFooterType: 'footer', + user: { + name: 'SuperDoc Test', + email: 'test@superdoc.com', + }, }), ); }); + + it('applies suggesting mode to header/footer editors when edit mode is enabled', () => { + const headerEditor = new MockEditor({}); + const footerEditor = new MockEditor({}); + const mainPm = document.createElement('div'); + const focusedSectionEditor = { + view: { + focus: vi.fn(), + }, + }; + + toggleHeaderFooterEditMode({ + editor: { + converter: { + headerEditors: [{ editor: headerEditor }], + footerEditors: [{ editor: footerEditor }], + }, + view: { + dom: mainPm, + }, + }, + focusedSectionEditor, + isEditMode: true, + documentMode: 'suggesting', + }); + + expect(headerEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(headerEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(headerEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(headerEditor.setEditable).toHaveBeenCalledWith(true, false); + expect(headerEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + + expect(footerEditor.commands.disableTrackChangesShowOriginal).toHaveBeenCalledTimes(1); + expect(footerEditor.commands.enableTrackChanges).toHaveBeenCalledTimes(1); + expect(footerEditor.setOptions).toHaveBeenCalledWith({ documentMode: 'suggesting' }); + expect(footerEditor.setEditable).toHaveBeenCalledWith(true, false); + expect(footerEditor.view.dom.getAttribute('documentmode')).toBe('suggesting'); + expect(focusedSectionEditor.view.focus).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index 4612c9e9de..ddcf8a8f01 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -50,6 +50,17 @@ import { seedEditorStateToYDoc } from './extensions/collaboration/seed-editor-to import { onCollaborationProviderSynced } from './core/helpers/collaboration-provider-sync.js'; import { resolveSelectionTarget } from './document-api-adapters/helpers/selection-target-resolver.js'; import { resolveDefaultInsertTarget } from './document-api-adapters/helpers/adapter-utils.js'; +import { resolveTrackedChangeInStory } from './document-api-adapters/helpers/tracked-change-resolver.js'; +import { getTrackedChangeIndex } from './document-api-adapters/tracked-changes/tracked-change-index.js'; +import { + makeTrackedChangeAnchorKey, + makeCommentAnchorKey, + isTrackedChangeAnchorKey, + isCommentAnchorKey, + parseTrackedChangeAnchorKey, + TRACKED_CHANGE_ANCHOR_KEY_PREFIX, + COMMENT_ANCHOR_KEY_PREFIX, +} from './document-api-adapters/helpers/tracked-change-runtime-ref.js'; const Extensions = { Node, @@ -145,4 +156,24 @@ export { resolveSelectionTarget, /** @internal */ resolveDefaultInsertTarget, + /** @internal */ + resolveTrackedChangeInStory, + + // Story-aware tracked-change service + /** @internal */ + getTrackedChangeIndex, + /** @internal */ + makeTrackedChangeAnchorKey, + /** @internal */ + makeCommentAnchorKey, + /** @internal */ + isTrackedChangeAnchorKey, + /** @internal */ + isCommentAnchorKey, + /** @internal */ + parseTrackedChangeAnchorKey, + /** @internal */ + TRACKED_CHANGE_ANCHOR_KEY_PREFIX, + /** @internal */ + COMMENT_ANCHOR_KEY_PREFIX, }; diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 75a484340e..79eb9970ba 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -43,6 +43,7 @@ export type { FontsResolvedPayload, PaginationPayload, ListDefinitionsPayload, + TrackedChangesChangedPayload, ProtectionChangeSource, EditorEventMap, } from './editors/v1/core/types/EditorEvents.js'; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 1bbea7e076..2626e44d8c 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -30,7 +30,7 @@ import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import { DOCX, PDF, HTML } from '@superdoc/common'; -import { SuperEditor, AIWriter, PresentationEditor } from '@superdoc/super-editor'; +import { SuperEditor, AIWriter, PresentationEditor, getTrackedChangeIndex } from '@superdoc/super-editor'; import { ySyncPluginKey } from 'y-prosemirror'; import HtmlViewer from './components/HtmlViewer/HtmlViewer.vue'; import useComment from './components/CommentsLayer/use-comment'; @@ -357,6 +357,7 @@ const onEditorReady = ({ editor, presentationEditor }) => { if (doc.password) doc.password = undefined; } presentationEditor.setContextMenuDisabled?.(proxy.$superdoc.config.disableContextMenu); + getTrackedChangeIndex(editor); // Listen for fresh comment positions from the layout engine. // PresentationEditor emits this after every layout with PM positions collected @@ -382,6 +383,15 @@ const onEditorReady = ({ editor, presentationEditor }) => { } }); + editor.on?.('tracked-changes-changed', ({ editor: sourceEditor, source }) => { + if (source === 'body-edit') return; + if (!shouldRenderCommentsInViewing.value) { + commentsStore.clearEditorCommentPositions?.(); + return; + } + syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor: sourceEditor ?? editor }); + }); + presentationEditor.on('paginationUpdate', ({ layout }) => { const totalPages = layout.pages.length; proxy.$superdoc.emit('pagination-update', { totalPages, superdoc: proxy.$superdoc }); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index 7094bd6a4b..c69c93c5d9 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -146,6 +146,7 @@ const mountDialog = async ({ removePendingComment: vi.fn(), requestInstantSidebarAlignment: vi.fn(), clearInstantSidebarAlignment: vi.fn(), + decideTrackedChangeFromSidebar: vi.fn(() => ({ ok: true, success: true })), getCommentDocumentId: vi.fn( (comment) => comment?.fileId ?? comment?.documentId ?? comment?.selection?.documentId ?? null, ), @@ -158,7 +159,7 @@ const mountDialog = async ({ (item) => item.commentId === commentOrId || item.importedId === commentOrId, ); - return [rawId, comment?.commentId, comment?.importedId].filter(Boolean); + return [rawId, comment?.trackedChangeAnchorKey, comment?.commentId, comment?.importedId].filter(Boolean); }), resolveCommentPositionEntry: vi.fn((commentOrId) => { const positions = commentsStoreStub.editorCommentPositions.value ?? {}; @@ -293,6 +294,7 @@ describe('CommentDialog.vue', () => { const presentation = { getReachableThreadAnchorClientY: vi.fn().mockReturnValue(165), scrollThreadAnchorToClientY: vi.fn().mockReturnValue(true), + navigateTo: vi.fn().mockResolvedValue(true), }; PresentationEditor.getInstance.mockReturnValue(presentation); @@ -307,16 +309,16 @@ describe('CommentDialog.vue', () => { }, }); - expect(presentation.getReachableThreadAnchorClientY).toHaveBeenCalledWith( - 'imported-tracked-change-1', - expect.any(Number), - ); - expect(presentation.scrollThreadAnchorToClientY).toHaveBeenCalledWith( - 'imported-tracked-change-1', + expect(presentation.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'imported-tracked-change-1', + }); + expect(presentation.getReachableThreadAnchorClientY).not.toHaveBeenCalled(); + expect(commentsStoreStub.requestInstantSidebarAlignment).toHaveBeenCalledWith( expect.any(Number), - { behavior: 'auto' }, + 'tracked-change-1', ); - expect(commentsStoreStub.requestInstantSidebarAlignment).toHaveBeenCalledWith(165, 'tracked-change-1'); }); it('prefers the actual visible highlight top after the scroll attempt', async () => { @@ -378,6 +380,7 @@ describe('CommentDialog.vue', () => { const presentation = { getReachableThreadAnchorClientY: vi.fn().mockReturnValue(456), scrollThreadAnchorToClientY: vi.fn().mockReturnValue(true), + navigateTo: vi.fn().mockResolvedValue(true), }; PresentationEditor.getInstance.mockReturnValue(presentation); @@ -421,7 +424,15 @@ describe('CommentDialog.vue', () => { await wrapper.trigger('click'); - expect(commentsStoreStub.requestInstantSidebarAlignment).toHaveBeenCalledWith(456, 'tracked-change-1'); + expect(presentation.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'imported-3f15df8f', + }); + expect(commentsStoreStub.requestInstantSidebarAlignment).toHaveBeenCalledWith( + expect.any(Number), + 'tracked-change-1', + ); }); it('does not ask the presentation layer to scroll when the bubble is already aligned', async () => { @@ -554,7 +565,9 @@ describe('CommentDialog.vue', () => { const header = wrapper.findComponent(CommentHeaderStub); header.vm.$emit('resolve'); await nextTick(); - expect(superdocStub.activeEditor.commands.acceptTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ comment: baseComment, decision: 'accept' }), + ); expect(baseComment.resolveComment).toHaveBeenCalledWith({ email: superdocStoreStub.user.email, name: superdocStoreStub.user.name, @@ -564,7 +577,9 @@ describe('CommentDialog.vue', () => { header.vm.$emit('reject'); await nextTick(); - expect(superdocStub.activeEditor.commands.rejectTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ comment: baseComment, decision: 'reject' }), + ); expect(superdocStub.focus).toHaveBeenCalledTimes(2); }); @@ -681,8 +696,9 @@ describe('CommentDialog.vue', () => { const header = wrapper.findComponent(CommentHeaderStub); header.vm.$emit('resolve'); - // Default behavior should be called - expect(superdocStub.activeEditor.commands.acceptTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ comment: baseComment, decision: 'accept' }), + ); expect(baseComment.resolveComment).toHaveBeenCalled(); }); @@ -703,12 +719,16 @@ describe('CommentDialog.vue', () => { // Test accept header.vm.$emit('resolve'); - expect(superdocStub.activeEditor.commands.acceptTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ comment: baseComment, decision: 'accept' }), + ); expect(baseComment.resolveComment).toHaveBeenCalled(); // Test reject header.vm.$emit('reject'); - expect(superdocStub.activeEditor.commands.rejectTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect(commentsStoreStub.decideTrackedChangeFromSidebar).toHaveBeenCalledWith( + expect.objectContaining({ comment: baseComment, decision: 'reject' }), + ); }); it('still runs cleanup when custom handler does nothing (no-op)', async () => { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 5af037ee93..fcd0c97a5c 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -136,6 +136,8 @@ const entriesOverlapRange = (entry, candidateEntry) => { const shouldIncludeThreadAlias = (entry, candidateEntry) => { if (!entry || !candidateEntry) return false; + if (entry.kind && candidateEntry.kind && entry.kind !== candidateEntry.kind) return false; + if (entry.storyKey && candidateEntry.storyKey && entry.storyKey !== candidateEntry.storyKey) return false; if (candidateEntry.start === entry.start && candidateEntry.end === entry.end) return true; return ( entriesShareLine(entry, candidateEntry) && @@ -379,6 +381,7 @@ const hasTextContent = computed(() => { const setFocus = () => { const editor = proxy.$superdoc.activeEditor; + const isTrackedChange = Boolean(props.comment?.trackedChange); const targetClientY = getPreferredCommentFocusTargetClientY(); const willChangeActiveThread = !props.comment.resolvedTime && activeComment.value !== props.comment.commentId; let instantAlignmentTargetY = targetClientY; @@ -395,21 +398,51 @@ const setFocus = () => { : visibleAnchorTargetY; const shouldSkipFocusScroll = isDialogAlreadyAlignedWithTarget(commentDialogElement.value, visibleThreadTargetY); const cursorId = getCommentFocusThreadId(props.comment); - if (props.comment.resolvedTime) { - editor.commands?.setCursorById(cursorId); + const documentId = getCommentDocumentId(props.comment); + const presentation = documentId ? PresentationEditor.getInstance(documentId) : null; + let reachableTargetY = null; + + if (isTrackedChange) { + const trackedTarget = props.comment.trackedChangeStory + ? { + kind: 'entity', + entityType: 'trackedChange', + entityId: cursorId, + story: props.comment.trackedChangeStory, + } + : { + kind: 'entity', + entityType: 'trackedChange', + entityId: cursorId, + }; + + if (presentation?.navigateTo) { + void presentation.navigateTo(trackedTarget); + } else if (props.comment.resolvedTime) { + editor.commands?.setCursorById(cursorId); + } else { + const activeCommentId = props.comment.commentId; + const didScroll = editor.commands?.setCursorById(cursorId, { activeCommentId }); + if (!didScroll) { + editor.commands?.setActiveComment({ commentId: activeCommentId }); + } + } } else { - const activeCommentId = props.comment.commentId; - const didScroll = editor.commands?.setCursorById(cursorId, { activeCommentId }); - if (!didScroll) { - editor.commands?.setActiveComment({ commentId: activeCommentId }); + if (props.comment.resolvedTime) { + editor.commands?.setCursorById(cursorId); + } else { + const activeCommentId = props.comment.commentId; + const didScroll = editor.commands?.setCursorById(cursorId, { activeCommentId }); + if (!didScroll) { + editor.commands?.setActiveComment({ commentId: activeCommentId }); + } } + + const fallbackThreadId = props.comment.commentId; + reachableTargetY = shouldSkipFocusScroll + ? null + : scrollThreadAnchorToFocusTarget(presentation, cursorId, fallbackThreadId, targetClientY); } - const documentId = getCommentDocumentId(props.comment); - const presentation = documentId ? PresentationEditor.getInstance(documentId) : null; - const fallbackThreadId = props.comment.commentId; - const reachableTargetY = shouldSkipFocusScroll - ? null - : scrollThreadAnchorToFocusTarget(presentation, cursorId, fallbackThreadId, targetClientY); if (Number.isFinite(visibleHighlightTargetY)) { instantAlignmentTargetY = visibleHighlightTargetY; } else if (Number.isFinite(visibleAnchorTargetY)) { @@ -502,7 +535,11 @@ const handleReject = () => { if (props.comment.trackedChange && typeof customHandler === 'function') { customHandler(props.comment, proxy.$superdoc.activeEditor); } else if (props.comment.trackedChange) { - proxy.$superdoc.activeEditor.commands.rejectTrackedChangeById(props.comment.commentId); + commentsStore.decideTrackedChangeFromSidebar({ + superdoc: proxy.$superdoc, + comment: props.comment, + decision: 'reject', + }); } else { commentsStore.deleteComment({ superdoc: proxy.$superdoc, commentId: props.comment.commentId }); } @@ -533,7 +570,11 @@ const handleResolve = () => { customHandler(props.comment, proxy.$superdoc.activeEditor); } else { if (props.comment.trackedChange) { - proxy.$superdoc.activeEditor.commands.acceptTrackedChangeById(props.comment.commentId); + commentsStore.decideTrackedChangeFromSidebar({ + superdoc: proxy.$superdoc, + comment: props.comment, + decision: 'accept', + }); } } diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.js b/packages/superdoc/src/components/CommentsLayer/use-comment.js index b67955fa27..e918d28c0d 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.js @@ -58,6 +58,10 @@ export default function useComment(params) { const trackedChangeType = ref(params.trackedChangeType || null); const trackedChangeText = ref(params.trackedChangeText || null); const trackedChangeDisplayType = ref(params.trackedChangeDisplayType || null); + const trackedChangeStory = ref(params.trackedChangeStory || null); + const trackedChangeStoryKind = ref(params.trackedChangeStoryKind || null); + const trackedChangeStoryLabel = ref(params.trackedChangeStoryLabel || ''); + const trackedChangeAnchorKey = ref(params.trackedChangeAnchorKey || null); const deletedText = ref(params.deletedText || null); const resolvedTime = ref(params.resolvedTime || null); @@ -253,6 +257,10 @@ export default function useComment(params) { trackedChangeText: trackedChangeText.value, trackedChangeType: trackedChangeType.value, trackedChangeDisplayType: trackedChangeDisplayType.value, + trackedChangeStory: trackedChangeStory.value, + trackedChangeStoryKind: trackedChangeStoryKind.value, + trackedChangeStoryLabel: trackedChangeStoryLabel.value, + trackedChangeAnchorKey: trackedChangeAnchorKey.value, deletedText: deletedText.value, resolvedTime: resolvedTime.value, resolvedByEmail: resolvedByEmail.value, @@ -289,6 +297,10 @@ export default function useComment(params) { trackedChangeType, trackedChangeText, trackedChangeDisplayType, + trackedChangeStory, + trackedChangeStoryKind, + trackedChangeStoryLabel, + trackedChangeAnchorKey, resolvedTime, resolvedByEmail, resolvedByName, diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index a2594c3d1d..4780e6d39f 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -9,11 +9,28 @@ import { CommentsPluginKey, getRichTextExtensions, createOrUpdateTrackedChangeComment, + getTrackedChangeIndex, + makeTrackedChangeAnchorKey, + resolveTrackedChangeInStory, } from '@superdoc/super-editor'; import useComment from '@superdoc/components/CommentsLayer/use-comment'; import { groupChanges } from '../helpers/group-changes.js'; export const useCommentsStore = defineStore('comments', () => { + const BODY_TRACKED_CHANGE_STORY = { kind: 'story', storyType: 'body' }; + + const isBodyTrackedChangeComment = (comment) => { + if (!comment?.trackedChange) return false; + const storyType = comment?.trackedChangeStory?.storyType; + if (storyType == null || storyType === 'body') return true; + return comment?.trackedChangeAnchorKey?.startsWith?.('tc::body::') === true; + }; + + const buildBodyTrackedChangeAnchorKey = (rawId) => { + if (rawId === undefined || rawId === null) return null; + return makeTrackedChangeAnchorKey({ storyKey: 'body', rawId: String(rawId) }); + }; + const superdocStore = useSuperdocStore(); const commentsConfig = reactive({ name: 'comments', @@ -136,16 +153,20 @@ export const useCommentsStore = defineStore('comments', () => { const commentId = resolvedComment.commentId ?? null; const importedId = resolvedComment.importedId ?? null; + const trackedChangeAnchorKey = resolvedComment.trackedChangeAnchorKey ?? null; + if (trackedChangeAnchorKey && positions[trackedChangeAnchorKey]) return trackedChangeAnchorKey; if (commentId && positions[commentId]) return commentId; if (importedId && positions[importedId]) return importedId; - return commentId ?? importedId ?? null; + return trackedChangeAnchorKey ?? commentId ?? importedId ?? null; } const commentId = commentOrId.commentId ?? null; const importedId = commentOrId.importedId ?? null; + const trackedChangeAnchorKey = commentOrId.trackedChangeAnchorKey ?? null; + if (trackedChangeAnchorKey && positions[trackedChangeAnchorKey]) return trackedChangeAnchorKey; if (commentId && positions[commentId]) return commentId; if (importedId && positions[importedId]) return importedId; - return commentId ?? importedId ?? null; + return trackedChangeAnchorKey ?? commentId ?? importedId ?? null; }; const normalizeCommentId = (id) => (id === undefined || id === null ? null : String(id)); @@ -160,7 +181,13 @@ export const useCommentsStore = defineStore('comments', () => { const comment = typeof commentOrId === 'object' ? commentOrId : getComment(commentOrId); const seen = new Set(); - return [rawId, getCommentPositionKey(comment), comment?.commentId, comment?.importedId] + return [ + rawId, + getCommentPositionKey(comment), + comment?.trackedChangeAnchorKey, + comment?.commentId, + comment?.importedId, + ] .map((id) => normalizeCommentId(id)) .filter((id) => { if (!id || seen.has(id)) return false; @@ -266,10 +293,22 @@ export const useCommentsStore = defineStore('comments', () => { .filter((id) => id !== undefined && id !== null) .map((id) => String(id)), ); + const trackedChangeIndex = typeof getTrackedChangeIndex === 'function' ? getTrackedChangeIndex(editor) : null; + let liveAnchorKeySource = []; + try { + liveAnchorKeySource = trackedChangeIndex?.getAll?.() ?? []; + } catch {} + const liveAnchorKeys = new Set( + liveAnchorKeySource + .map((snapshot) => snapshot?.anchorKey) + .filter((anchorKey) => typeof anchorKey === 'string' && anchorKey.length > 0), + ); // Any tracked-change roots whose aliases are missing from document marks are considered stale const staleRootPositionKeys = new Set( Array.from(candidateRootPositionKeys).filter((positionKey) => { const aliases = rootAliasesByPositionKey.get(positionKey) ?? new Set([positionKey]); + const hasLiveAnchorKey = Array.from(aliases).some((alias) => liveAnchorKeys.has(alias)); + if (hasLiveAnchorKey) return false; // Keep stale detection aligned with editorCommentPositions by matching against whichever // alias key (commentId/importedId) is currently present in the live position map. return !Array.from(aliases).some((alias) => trackedIds.has(alias)); @@ -484,8 +523,28 @@ export const useCommentsStore = defineStore('comments', () => { importedAuthor, documentId, coords, + trackedChangeStory, + trackedChangeStoryKind, + trackedChangeStoryLabel, + trackedChangeAnchorKey, } = params; + const normalizedChangeId = changeId != null ? String(changeId) : null; const normalizedDocumentId = documentId != null ? String(documentId) : null; + const hasStoryMetadata = + trackedChangeStory !== undefined || + trackedChangeStoryKind !== undefined || + trackedChangeStoryLabel !== undefined || + trackedChangeAnchorKey !== undefined; + const normalizedTrackedChangeStory = hasStoryMetadata ? (trackedChangeStory ?? null) : BODY_TRACKED_CHANGE_STORY; + const normalizedTrackedChangeStoryKind = hasStoryMetadata ? (trackedChangeStoryKind ?? null) : 'body'; + const normalizedTrackedChangeStoryLabel = + hasStoryMetadata && trackedChangeStoryLabel !== undefined ? trackedChangeStoryLabel : ''; + const normalizedTrackedChangeAnchorKey = + trackedChangeAnchorKey !== undefined + ? (trackedChangeAnchorKey ?? null) + : hasStoryMetadata + ? null + : buildBodyTrackedChangeAnchorKey(normalizedChangeId); const comment = getPendingComment({ documentId, @@ -501,6 +560,10 @@ export const useCommentsStore = defineStore('comments', () => { creatorImage: authorImage, isInternal: false, importedAuthor, + trackedChangeStory: normalizedTrackedChangeStory, + trackedChangeStoryKind: normalizedTrackedChangeStoryKind, + trackedChangeStoryLabel: normalizedTrackedChangeStoryLabel, + trackedChangeAnchorKey: normalizedTrackedChangeAnchorKey, selection: { source: 'super-editor', selectionBounds: coords, @@ -508,11 +571,17 @@ export const useCommentsStore = defineStore('comments', () => { }); const findTrackedChangeById = () => { - const normalizedChangeId = changeId != null ? String(changeId) : null; + const normalizedAnchorKey = + normalizedTrackedChangeAnchorKey != null ? String(normalizedTrackedChangeAnchorKey) : null; if (!normalizedChangeId) return null; const matchesId = (trackedComment) => { if (!trackedComment) return false; + const commentAnchorKey = + trackedComment.trackedChangeAnchorKey != null ? String(trackedComment.trackedChangeAnchorKey) : null; + if (normalizedAnchorKey && commentAnchorKey) { + return commentAnchorKey === normalizedAnchorKey; + } const commentId = trackedComment.commentId != null ? String(trackedComment.commentId) : null; const importedId = trackedComment.importedId != null ? String(trackedComment.importedId) : null; return commentId === normalizedChangeId || importedId === normalizedChangeId; @@ -534,6 +603,22 @@ export const useCommentsStore = defineStore('comments', () => { debounceEmit(changeId, event, superdoc); }; + const applyStoryMetadata = (target) => { + if (!target) return; + if (normalizedTrackedChangeStory !== undefined && normalizedTrackedChangeStory !== null) { + target.trackedChangeStory = normalizedTrackedChangeStory; + } + if (normalizedTrackedChangeStoryKind !== undefined && normalizedTrackedChangeStoryKind !== null) { + target.trackedChangeStoryKind = normalizedTrackedChangeStoryKind; + } + if (normalizedTrackedChangeStoryLabel !== undefined && normalizedTrackedChangeStoryLabel !== '') { + target.trackedChangeStoryLabel = normalizedTrackedChangeStoryLabel; + } + if (normalizedTrackedChangeAnchorKey !== undefined && normalizedTrackedChangeAnchorKey !== null) { + target.trackedChangeAnchorKey = normalizedTrackedChangeAnchorKey; + } + }; + if (event === 'add') { const existing = findTrackedChangeById(); if (existing) { @@ -548,6 +633,7 @@ export const useCommentsStore = defineStore('comments', () => { existing.trackedChangeType = trackedChangeType ?? null; existing.trackedChangeDisplayType = trackedChangeDisplayType ?? null; existing.deletedText = deletedText ?? null; + applyStoryMetadata(existing); const emitData = { type: COMMENT_EVENTS.UPDATE, @@ -570,6 +656,7 @@ export const useCommentsStore = defineStore('comments', () => { existingTrackedChange.trackedChangeType = trackedChangeType ?? null; existingTrackedChange.trackedChangeDisplayType = trackedChangeDisplayType ?? null; existingTrackedChange.deletedText = deletedText ?? null; + applyStoryMetadata(existingTrackedChange); const emitData = { type: COMMENT_EVENTS.UPDATE, @@ -666,6 +753,9 @@ export const useCommentsStore = defineStore('comments', () => { if (Number.isFinite(position.pos)) return position.pos; if (Number.isFinite(position.from)) return position.from; if (Number.isFinite(position.to)) return position.to; + if (Number.isFinite(position.pageIndex) && Number.isFinite(position?.bounds?.top)) { + return position.pageIndex * 1_000_000 + position.bounds.top; + } return null; }; @@ -1029,6 +1119,7 @@ export const useCommentsStore = defineStore('comments', () => { commentsList.value.forEach((comment) => { if (!comment?.trackedChange) return; if (!belongsToTrackedChangeSyncDocument(comment, activeDocumentId)) return; + if (!isBodyTrackedChangeComment(comment)) return; const commentIds = [comment.commentId, comment.importedId] .map((id) => (id != null ? String(id) : null)) .filter(Boolean); @@ -1089,6 +1180,11 @@ export const useCommentsStore = defineStore('comments', () => { }); if (params) { + const anchorKey = buildBodyTrackedChangeAnchorKey(params.changeId ?? id); + params.trackedChangeStory = BODY_TRACKED_CHANGE_STORY; + params.trackedChangeStoryKind = 'body'; + params.trackedChangeStoryLabel = ''; + params.trackedChangeAnchorKey = anchorKey; handleTrackedChangeUpdate({ superdoc, params, broadcastChanges }); if (!existingTrackedChange) { skipIds.add(normalizedId); @@ -1160,6 +1256,7 @@ export const useCommentsStore = defineStore('comments', () => { */ const pruneStaleTrackedChangeComments = ( liveTrackedChangeIds, + liveTrackedChangeAnchorKeys, activeDocumentId, superdoc = null, { broadcastChanges = true } = {}, @@ -1176,10 +1273,14 @@ export const useCommentsStore = defineStore('comments', () => { const commentId = comment.commentId != null ? String(comment.commentId) : null; const importedId = comment.importedId != null ? String(comment.importedId) : null; + const anchorKey = comment.trackedChangeAnchorKey != null ? String(comment.trackedChangeAnchorKey) : null; const hasLiveCommentId = Boolean(commentId && liveTrackedChangeIds.has(commentId)); const hasLiveImportedId = Boolean(importedId && liveTrackedChangeIds.has(importedId)); + const hasLiveAnchorKey = Boolean(anchorKey && liveTrackedChangeAnchorKeys?.has(anchorKey)); - if ((!commentId && !importedId) || hasLiveCommentId || hasLiveImportedId) return true; + if ((!commentId && !importedId && !anchorKey) || hasLiveCommentId || hasLiveImportedId || hasLiveAnchorKey) { + return true; + } if (comment.resolvedTime) return true; const resolutionSnapshot = trackedChangeResolutionSnapshots.get(comment); @@ -1279,6 +1380,35 @@ export const useCommentsStore = defineStore('comments', () => { * @param {Object} param0.editor The active Super Editor instance. * @returns {void} */ + const decideTrackedChangeFromSidebar = ({ superdoc, comment, decision }) => { + if (!comment?.trackedChange) return { ok: false }; + const activeEditor = superdoc?.activeEditor; + if (!activeEditor) return { ok: false }; + + const id = comment.commentId ?? comment.importedId; + if (!id) return { ok: false }; + + const story = comment.trackedChangeStory ?? undefined; + const documentApi = typeof activeEditor.doc === 'object' ? activeEditor.doc : null; + + if (documentApi?.trackChanges?.decide) { + try { + const target = story ? { id, story } : { id }; + const receipt = documentApi.trackChanges.decide({ decision, target }); + return { ok: true, success: Boolean(receipt?.success) }; + } catch (error) { + if (story) { + return { ok: false, error }; + } + } + } + + const commandName = decision === 'accept' ? 'acceptTrackedChangeById' : 'rejectTrackedChangeById'; + const command = activeEditor.commands?.[commandName]; + if (typeof command !== 'function') return { ok: false }; + return { ok: true, success: Boolean(command(id)) }; + }; + const syncTrackedChangeComments = ({ superdoc, editor, broadcastChanges = true }) => { if (!superdoc || !editor) return; const activeDocumentId = editor?.options?.documentId != null ? String(editor.options.documentId) : null; @@ -1292,12 +1422,148 @@ export const useCommentsStore = defineStore('comments', () => { liveTrackedChangeIds.add(String(id)); }); - pruneStaleTrackedChangeComments(liveTrackedChangeIds, activeDocumentId, superdoc, { broadcastChanges }); + const trackedChangeIndex = typeof getTrackedChangeIndex === 'function' ? getTrackedChangeIndex(editor) : null; + let storySnapshots = []; + try { + storySnapshots = trackedChangeIndex?.getAll?.() ?? []; + } catch {} + const liveTrackedChangeAnchorKeys = new Set( + storySnapshots + .map((snapshot) => snapshot?.anchorKey) + .filter((anchorKey) => typeof anchorKey === 'string' && anchorKey.length > 0), + ); + + pruneStaleTrackedChangeComments(liveTrackedChangeIds, liveTrackedChangeAnchorKeys, activeDocumentId, superdoc, { + broadcastChanges, + }); createCommentForTrackChanges(editor, superdoc, trackedChanges, { reopenResolved: true, refreshExisting: true, broadcastChanges, }); + + syncStoryTrackedChangeComments({ superdoc, editor, broadcastChanges, snapshots: storySnapshots }); + }; + + const syncStoryTrackedChangeComments = ({ superdoc, editor, broadcastChanges = true, snapshots = null }) => { + const activeDocumentId = editor?.options?.documentId != null ? String(editor.options.documentId) : null; + if (!activeDocumentId) return; + + let resolvedSnapshots = snapshots; + if (!Array.isArray(resolvedSnapshots)) { + if (typeof getTrackedChangeIndex !== 'function') return; + const index = getTrackedChangeIndex(editor); + if (!index) return; + try { + resolvedSnapshots = index.getAll(); + } catch { + return; + } + } + + for (const snapshot of resolvedSnapshots) { + if (snapshot.storyKind === 'body') continue; + upsertStoryTrackedChangeComment({ superdoc, editor, snapshot, documentId: activeDocumentId, broadcastChanges }); + } + }; + + const buildStoryTrackedChangeParams = ({ editor, snapshot, documentId, event }) => { + const fallbackParams = { + event, + changeId: snapshot.runtimeRef.rawId, + trackedChangeText: snapshot.type === 'insert' || snapshot.type === 'format' ? (snapshot.excerpt ?? '') : '', + trackedChangeType: snapshot.type, + trackedChangeDisplayType: snapshot.type, + deletedText: snapshot.type === 'delete' ? (snapshot.excerpt ?? '') : null, + authorEmail: snapshot.authorEmail, + authorImage: snapshot.authorImage, + date: snapshot.date, + author: snapshot.author, + documentId, + coords: null, + trackedChangeStory: snapshot.story, + trackedChangeStoryKind: snapshot.storyKind, + trackedChangeStoryLabel: snapshot.storyLabel, + trackedChangeAnchorKey: snapshot.anchorKey, + }; + + if (typeof resolveTrackedChangeInStory !== 'function') return fallbackParams; + + let resolvedChange = null; + try { + resolvedChange = resolveTrackedChangeInStory(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: snapshot.runtimeRef.rawId, + story: snapshot.story, + }); + } catch { + resolvedChange = null; + } + + const storyEditorState = resolvedChange?.editor?.state ?? null; + if (!storyEditorState) return fallbackParams; + + let trackedChangesForId = []; + try { + trackedChangesForId = trackChangesHelpers.getTrackChanges(storyEditorState, resolvedChange.change.rawId) ?? []; + } catch { + trackedChangesForId = []; + } + + const marks = { + insertedMark: trackedChangesForId.find(({ mark }) => mark?.type?.name === 'trackInsert')?.mark ?? null, + deletionMark: trackedChangesForId.find(({ mark }) => mark?.type?.name === 'trackDelete')?.mark ?? null, + formatMark: trackedChangesForId.find(({ mark }) => mark?.type?.name === 'trackFormat')?.mark ?? null, + }; + + const resolvedParams = createOrUpdateTrackedChangeComment({ + event, + marks, + nodes: [], + newEditorState: storyEditorState, + documentId, + trackedChangesForId, + }); + + if (!resolvedParams) return fallbackParams; + + resolvedParams.trackedChangeStory = snapshot.story; + resolvedParams.trackedChangeStoryKind = snapshot.storyKind; + resolvedParams.trackedChangeStoryLabel = snapshot.storyLabel; + resolvedParams.trackedChangeAnchorKey = snapshot.anchorKey; + return resolvedParams; + }; + + const upsertStoryTrackedChangeComment = ({ superdoc, editor, snapshot, documentId, broadcastChanges }) => { + if (!snapshot?.runtimeRef?.rawId) return; + + const existingComment = commentsList.value.find((comment) => { + if (!comment?.trackedChange) return false; + const commentAnchorKey = comment.trackedChangeAnchorKey != null ? String(comment.trackedChangeAnchorKey) : null; + if (commentAnchorKey && snapshot.anchorKey) { + return commentAnchorKey === snapshot.anchorKey; + } + + if (commentAnchorKey || snapshot.anchorKey) return false; + return comment.commentId === snapshot.runtimeRef.rawId || comment.importedId === snapshot.runtimeRef.rawId; + }); + + const params = buildStoryTrackedChangeParams({ + editor, + snapshot, + documentId, + event: existingComment ? 'update' : 'add', + }); + + handleTrackedChangeUpdate({ superdoc, params, broadcastChanges }); + + if (existingComment) { + existingComment.trackedChangeStory = snapshot.story; + existingComment.trackedChangeStoryKind = snapshot.storyKind; + existingComment.trackedChangeStoryLabel = snapshot.storyLabel; + existingComment.trackedChangeAnchorKey = snapshot.anchorKey; + } }; const normalizeDocxSchemaForExport = (value) => { @@ -1346,9 +1612,23 @@ export const useCommentsStore = defineStore('comments', () => { if (allCommentPositions == null) { return; } - // `{}` is authoritative: when marks are removed, positions can become empty - // and we must clear stale anchors instead of preserving previous ones. - editorCommentPositions.value = allCommentPositions; + const normalizedPositions = {}; + Object.entries(allCommentPositions).forEach(([key, entry]) => { + normalizedPositions[key] = entry; + const rawTrackedChangeKey = + entry?.kind === 'trackedChange' && entry?.storyKey === 'body' && entry?.threadId != null + ? String(entry.threadId) + : null; + if (rawTrackedChangeKey && normalizedPositions[rawTrackedChangeKey] === undefined) { + normalizedPositions[rawTrackedChangeKey] = entry; + } + const canonicalKey = typeof entry?.key === 'string' ? entry.key : null; + if (canonicalKey && normalizedPositions[canonicalKey] === undefined) { + normalizedPositions[canonicalKey] = entry; + } + }); + + editorCommentPositions.value = normalizedPositions; }; /** @@ -1552,5 +1832,6 @@ export const useCommentsStore = defineStore('comments', () => { peekInstantSidebarAlignment, clearInstantSidebarAlignment, syncTrackedChangeComments, + decideTrackedChangeFromSidebar, }; }); diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index ca62a043fc..450b3bae84 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -58,54 +58,74 @@ vi.mock('../helpers/group-changes.js', () => ({ groupChanges: vi.fn(() => []), })); -vi.mock('@superdoc/super-editor', () => ({ - Editor: class { - getJSON() { - return { content: [{}] }; - } - getHTML() { - return '

'; - } - get state() { - return {}; - } - get view() { - return { state: { tr: { setMeta: vi.fn() } }, dispatch: vi.fn() }; - } - }, - trackChangesHelpers: { - getTrackChanges: vi.fn(() => []), - }, - createOrUpdateTrackedChangeComment: vi.fn(({ event, marks, documentId }) => { - const changeId = marks?.insertedMark?.attrs?.id ?? marks?.deletionMark?.attrs?.id ?? marks?.formatMark?.attrs?.id; - if (changeId == null) return; - return { - event, - changeId, - trackedChangeText: `tracked-${changeId}`, - trackedChangeType: 'insert', - deletedText: null, - authorEmail: 'alice@example.com', - author: 'Alice', - date: 123, - importedAuthor: null, - documentId, - coords: {}, - }; - }), - TrackChangesBasePluginKey: 'TrackChangesBasePluginKey', - CommentsPluginKey: 'CommentsPluginKey', - getRichTextExtensions: vi.fn(() => []), -})); +vi.mock('@superdoc/super-editor', () => { + const getTrackedChangeIndex = vi.fn(() => ({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + })); + const makeTrackedChangeAnchorKey = vi.fn(({ storyKey, rawId }) => `tc::${storyKey}::${rawId}`); + + return { + Editor: class { + getJSON() { + return { content: [{}] }; + } + getHTML() { + return '

'; + } + get state() { + return {}; + } + get view() { + return { state: { tr: { setMeta: vi.fn() } }, dispatch: vi.fn() }; + } + }, + trackChangesHelpers: { + getTrackChanges: vi.fn(() => []), + }, + createOrUpdateTrackedChangeComment: vi.fn(({ event, marks, documentId }) => { + const changeId = marks?.insertedMark?.attrs?.id ?? marks?.deletionMark?.attrs?.id ?? marks?.formatMark?.attrs?.id; + if (changeId == null) return; + return { + event, + changeId, + trackedChangeText: `tracked-${changeId}`, + trackedChangeType: 'insert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + importedAuthor: null, + documentId, + coords: {}, + }; + }), + resolveTrackedChangeInStory: vi.fn(() => null), + TrackChangesBasePluginKey: 'TrackChangesBasePluginKey', + CommentsPluginKey: 'CommentsPluginKey', + getRichTextExtensions: vi.fn(() => []), + getTrackedChangeIndex, + makeTrackedChangeAnchorKey, + }; +}); import { useCommentsStore } from './comments-store.js'; import { __mockSuperdoc } from './superdoc-store.js'; import { comments_module_events } from '@superdoc/common'; import useComment from '@superdoc/components/CommentsLayer/use-comment'; import { syncCommentsToClients } from '../core/collaboration/helpers.js'; -import { trackChangesHelpers } from '@superdoc/super-editor'; import { groupChanges } from '../helpers/group-changes.js'; -import { trackChangesHelpers, createOrUpdateTrackedChangeComment } from '@superdoc/super-editor'; +import { + trackChangesHelpers, + createOrUpdateTrackedChangeComment, + getTrackedChangeIndex, + makeTrackedChangeAnchorKey, + resolveTrackedChangeInStory, +} from '@superdoc/super-editor'; const useCommentMock = useComment; const syncCommentsToClientsMock = syncCommentsToClients; @@ -113,6 +133,9 @@ const getTrackChangesMock = trackChangesHelpers.getTrackChanges; const groupChangesMock = groupChanges; const trackChangesHelpersMock = trackChangesHelpers; const createOrUpdateTrackedChangeCommentMock = createOrUpdateTrackedChangeComment; +const getTrackedChangeIndexMock = getTrackedChangeIndex; +const makeTrackedChangeAnchorKeyMock = makeTrackedChangeAnchorKey; +const resolveTrackedChangeInStoryMock = resolveTrackedChangeInStory; describe('comments-store', () => { let store; @@ -125,6 +148,33 @@ describe('comments-store', () => { __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; groupChangesMock.mockReturnValue([]); trackChangesHelpersMock.getTrackChanges.mockReturnValue([]); + createOrUpdateTrackedChangeCommentMock.mockImplementation(({ event, marks, documentId }) => { + const changeId = marks?.insertedMark?.attrs?.id ?? marks?.deletionMark?.attrs?.id ?? marks?.formatMark?.attrs?.id; + if (changeId == null) return; + return { + event, + changeId, + trackedChangeText: `tracked-${changeId}`, + trackedChangeType: 'insert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + importedAuthor: null, + documentId, + coords: {}, + }; + }); + resolveTrackedChangeInStoryMock.mockReturnValue(null); + getTrackedChangeIndexMock.mockReturnValue({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + makeTrackedChangeAnchorKeyMock.mockImplementation(({ storyKey, rawId }) => `tc::${storyKey}::${rawId}`); }); afterEach(() => { @@ -1434,6 +1484,103 @@ describe('comments-store', () => { expect(editorDispatch).toHaveBeenCalledWith(tr); }); + it('builds story tracked-change replacements from the resolved story editor state', () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const storyState = { doc: { type: 'story-doc' } }; + const superdoc = { + config: { isInternal: false }, + emit: vi.fn(), + }; + const snapshot = { + type: 'insert', + excerpt: 'footnotetest', + anchorKey: 'tc::fn:1::raw-1', + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + runtimeRef: { rawId: 'raw-1' }, + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + + trackChangesHelpersMock.getTrackChanges.mockImplementation((state, id) => { + if (state === storyState && id === 'raw-1') { + return [ + { mark: { type: { name: 'trackInsert' }, attrs: { id: 'raw-1' } } }, + { mark: { type: { name: 'trackDelete' }, attrs: { id: 'raw-1' } } }, + ]; + } + return []; + }); + groupChangesMock.mockReturnValue([]); + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + resolveTrackedChangeInStoryMock.mockReturnValue({ + editor: { state: storyState }, + story: snapshot.story, + runtimeRef: { storyKey: 'fn:1', rawId: 'raw-1' }, + change: { rawId: 'raw-1' }, + }); + createOrUpdateTrackedChangeCommentMock.mockReturnValue({ + event: 'add', + changeId: 'raw-1', + trackedChangeType: 'both', + trackedChangeDisplayType: 'insert', + trackedChangeText: 'test', + deletedText: 'footnote', + author: 'Alice', + authorEmail: 'alice@example.com', + documentId: 'doc-1', + coords: {}, + }); + + store.commentsList = []; + + store.syncTrackedChangeComments({ superdoc, editor }); + + expect(resolveTrackedChangeInStoryMock).toHaveBeenCalledWith(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'raw-1', + story: snapshot.story, + }); + expect(createOrUpdateTrackedChangeCommentMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'add', + newEditorState: storyState, + documentId: 'doc-1', + }), + ); + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'raw-1', + trackedChange: true, + trackedChangeType: 'both', + trackedChangeText: 'test', + deletedText: 'footnote', + trackedChangeStoryKind: 'footnote', + trackedChangeAnchorKey: 'tc::fn:1::raw-1', + }), + ]); + expect(tr.setMeta).toHaveBeenCalledWith('CommentsPluginKey', { type: 'force' }); + expect(editorDispatch).toHaveBeenCalledWith(tr); + }); + it('should load comments with correct created time', () => { store.init({ readOnly: true, diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 4bf8451e00..b6d9ef2659 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -71,7 +71,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } -async function waitForReady(page: Page, timeout = 30_000): Promise { +async function waitForReady(page: Page, timeout = 60_000): Promise { // Vite may trigger a dep-optimization reload on WebKit after the initial load event, // which destroys the execution context and resets `superdocReady`. Retry across // navigations until the flag is set or the overall deadline is reached. diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index b6e79f1746..0c6f60ac42 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -1,6 +1,20 @@ +import { createRequire } from 'node:module'; import { defineConfig } from 'vite'; +import { getAliases } from '../../../packages/superdoc/vite.config.js'; + +const superdocRequire = createRequire(new URL('../../../packages/superdoc/package.json', import.meta.url)); +const vue = superdocRequire('@vitejs/plugin-vue').default; export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify('behavior-harness'), + __IS_DEBUG__: true, + }, + plugins: [vue()], + resolve: { + alias: getAliases(true), + conditions: ['source'], + }, server: { port: 9990, strictPort: true, diff --git a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts index 0265133d55..6b132edf51 100644 --- a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts +++ b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { Locator, Page } from '@playwright/test'; import { test, expect } from '../../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -10,29 +11,729 @@ const DOC_PATH = path.resolve( test.use({ config: { showCaret: true, showSelection: true } }); -test('double-click rendered footnote to edit it through the presentation surface', async ({ superdoc }) => { +type FootnoteBehaviorHarness = { + page: Page; + loadDocument: (docPath: string) => Promise; + waitForStable: (ms?: number) => Promise; +}; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) { + return null; + } + return { + x: fallbackRect.left + 2, + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: rect.left + 1, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getBoundaryClickPoint(locator: Locator, searchText: string, offsetWithinMatch = 0) { + return locator.evaluate( + (element, params: { searchText: string; offsetWithinMatch: number }) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) { + return null; + } + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd(currentNode, clampedOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + x: rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch }, + ); +} + +async function getWordRect(locator: Locator, searchText: string) { + return locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode: Text | null = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, remaining); + range.setEnd(currentNode, endOffset); + const rect = range.getBoundingClientRect(); + if (!rect) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); +} + +async function getSelectionOverlayRect(page: Page) { + const selectionRect = page.locator('.presentation-editor__selection-rect').first(); + await expect(selectionRect).toBeVisible(); + const box = await selectionRect.boundingBox(); + expect(box).toBeTruthy(); + return box!; +} + +async function expectVisibleCaret(page: Page) { + const caret = page.locator('.presentation-editor__selection-caret').first(); + await expect(caret).toBeVisible(); + const box = await caret.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.y).toBeGreaterThanOrEqual(0); + return box!; +} + +async function getActiveSelectionPosition(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.selection?.from ?? null; + }); +} + +async function getHitTestPosition(page: Page, x: number, y: number) { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const hit = (window as any).editor?.presentationEditor?.hitTest?.(clientX, clientY); + return hit?.pos ?? null; + }, + { x, y }, + ); +} + +async function getActiveStorySession(page: Page) { + return page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +async function expectInsertedMarkerBeforeEdited(footnote: Locator) { + const text = await footnote.textContent(); + expect(text).toBeTruthy(); + + const insertedIndex = text!.indexOf('X'); + const editedIndex = text!.indexOf('edited'); + + expect(insertedIndex).toBeGreaterThanOrEqual(0); + expect(editedIndex).toBeGreaterThan(insertedIndex); +} + +async function getActiveStoryText(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + return activeEditor?.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +async function getBodyStoryText(page: Page) { + return page.evaluate(() => { + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +function getBodyFragmentLocator(page: Page, text: string): Locator { + return page + .locator('[data-block-id]:not([data-block-id^="footnote-"]):not([data-block-id^="__sd_semantic_footnote-"])') + .filter({ hasText: text }) + .first(); +} + +async function insertTextIntoBodyAtVisibleBoundary( + page: Page, + bodySurface: Locator, + searchText: string, + offsetWithinMatch: number, + insertedText: string, +): Promise { + const boundaryPoint = await getBoundaryClickPoint(bodySurface, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + const hitPosition = await getHitTestPosition(page, boundaryPoint!.x, boundaryPoint!.y); + expect(hitPosition).not.toBeNull(); + + await page.evaluate( + ({ position, text }) => { + const editor = (window as any).editor; + if (!editor?.view) { + throw new Error('Body editor view is unavailable.'); + } + + editor.view.dispatch(editor.state.tr.insertText(text, position, position)); + }, + { position: hitPosition, text: insertedText }, + ); + + return hitPosition!; +} + +async function loadAndActivateFootnote( + superdoc: FootnoteBehaviorHarness, + noteId: string, + expectedText: string, +): Promise { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - const footnote = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + const footnote = getFootnoteLocator(superdoc.page, noteId); await footnote.scrollIntoViewIfNeeded(); await footnote.waitFor({ state: 'visible', timeout: 15_000 }); - await expect(footnote).toContainText('This is a simple footnote'); + await expect(footnote).toContainText(expectedText); const box = await footnote.boundingBox(); expect(box).toBeTruthy(); await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId, + }); + + return footnote; +} + +async function clickFootnoteBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise<{ x: number; y: number }> { + const boundaryPoint = await getBoundaryClickPoint(footnote, searchText, offsetWithinMatch); + expect(boundaryPoint).toBeTruthy(); + + await page.mouse.click(boundaryPoint!.x, boundaryPoint!.y); + return boundaryPoint!; +} + +async function expectCaretAtClickBoundary( + page: Page, + footnote: Locator, + searchText: string, + offsetWithinMatch: number, +): Promise { + const boundaryPoint = await clickFootnoteBoundary(page, footnote, searchText, offsetWithinMatch); + await expect(page.locator('.presentation-editor__selection-caret').first()).toBeVisible(); + await expect.poll(() => getActiveSelectionPosition(page)).not.toBeNull(); + + const selectionAfterClick = await getActiveSelectionPosition(page); + const hitAfterClick = await getHitTestPosition(page, boundaryPoint.x, boundaryPoint.y); + const domSelectionAfterClick = await getActiveDomSelection(page); + + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick).toBe(hitAfterClick); + expect(domSelectionAfterClick?.anchorPos).toBe(selectionAfterClick); + + return selectionAfterClick!; +} + +async function expectStoryText(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +async function expectStoryTextToContain(page: Page, expectedText: string) { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} +async function getActiveDomSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const view = activeEditor?.view; + const selection = view?.dom?.ownerDocument?.getSelection?.(); + if (!view || !selection || !selection.anchorNode) { + return null; + } + + const anchorInside = view.dom.contains(selection.anchorNode); + const focusInside = selection.focusNode ? view.dom.contains(selection.focusNode) : false; + + let anchorPos = null; + let focusPos = null; + try { + if (anchorInside) { + anchorPos = view.posAtDOM(selection.anchorNode, selection.anchorOffset, -1); + } + if (focusInside && selection.focusNode) { + focusPos = view.posAtDOM(selection.focusNode, selection.focusOffset, -1); + } + } catch {} + + return { + anchorInside, + focusInside, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + anchorPos, + focusPos, + text: selection.toString(), + }; + }); +} + +test('double-click rendered footnote to edit it through the presentation surface', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); const storyHost = superdoc.page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"]').first(); await expect(storyHost).toHaveAttribute('data-story-key', /.+/); - await superdoc.page.keyboard.press('End'); - await superdoc.page.keyboard.insertText(' edited'); + if (browserName === 'firefox') { + await superdoc.page.evaluate(() => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + activeEditor?.commands?.insertContent?.(' edited'); + }); + } else { + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + } await superdoc.waitForStable(); - await expect(footnote).toContainText('This is a simple footnote edited'); + if (browserName !== 'firefox') { + await expect(footnote).toContainText('This is a simple footnote edited', { timeout: 10_000 }); + const selectionAtEnd = await getActiveSelectionPosition(superdoc.page); + expect(selectionAtEnd).not.toBeNull(); + + const startPoint = await getTextClickPoint(footnote, 'This', 0); + expect(startPoint).toBeTruthy(); + await superdoc.page.mouse.click(startPoint!.x, startPoint!.y); + await superdoc.waitForStable(); + await expectVisibleCaret(superdoc.page); + const selectionAfterClick = await getActiveSelectionPosition(superdoc.page); + expect(selectionAfterClick).not.toBeNull(); + expect(selectionAfterClick!).toBeLessThan(selectionAtEnd!); + + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectInsertedMarkerBeforeEdited(footnote); + } await superdoc.page.keyboard.press('Escape'); await superdoc.waitForStable(); - await expect(footnote).toContainText('This is a simple footnote edited'); + await expectInsertedMarkerBeforeEdited(footnote); +}); + +test('clicking inside footnote text inserts at the exact requested character boundary', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'footanote'); + await expect(footnote).toContainText('footanote'); +}); + +test('footnote caret placement supports inserts at the note start, inside a word, and at the note end', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'This', 0); + await superdoc.page.keyboard.insertText('X'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footnote'); + await expect(footnote).toContainText('XThis is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 4); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote'); + await expect(footnote).toContainText('XThis is a simple footanote'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText('!'); + await superdoc.waitForStable(); + await expectStoryText(superdoc.page, 'XThis is a simple footanote!'); + await expect(footnote).toContainText('XThis is a simple footanote!'); +}); + +test('footnote caret placement stays correct on later note lines above table content', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '2', 'A longer one with a table'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'with', 1); + await superdoc.page.keyboard.insertText('a'); + await superdoc.waitForStable(); + + await expectStoryTextToContain(superdoc.page, 'A longer one waith a table'); + await expectStoryTextToContain(superdoc.page, 'And multi-paragraph content'); + await expect(footnote).toContainText('A longer one waith a table'); +}); + +test('footnote backspace deletes the character immediately before the visible caret', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a siple footnote'); + await expect(footnote).toContainText('This is a siple footnote'); +}); + +test('double-click word selection stays horizontally aligned with rendered footnote text', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + const simplePoint = await getTextClickPoint(footnote, 'simple', 2); + const simpleRect = await getWordRect(footnote, 'simple'); + expect(simplePoint).toBeTruthy(); + expect(simpleRect).toBeTruthy(); + + await superdoc.page.mouse.dblclick(simplePoint!.x, simplePoint!.y); + await superdoc.waitForStable(); + + const domSelectionAfterClick = await getActiveDomSelection(superdoc.page); + expect(domSelectionAfterClick?.text).toBe('simple'); + + const overlayRect = await getSelectionOverlayRect(superdoc.page); + expect(Math.abs(overlayRect.x - simpleRect!.left)).toBeLessThanOrEqual(2.5); + expect(Math.abs(overlayRect.width - simpleRect!.width)).toBeLessThanOrEqual(3); +}); + +test.describe('suggesting mode routing', () => { + test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + showCaret: true, + showSelection: true, + useHiddenHostForStoryParts: true, + }, + }); + + test('typing stays in the active footnote even if body focus is restored underneath the session', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'simple', 3); + + const originalBodyText = await getBodyStoryText(superdoc.page); + expect(originalBodyText).toContain('Simple text'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.view?.focus?.(); + }); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const bodyEditor = (window as any).editor; + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + + return { + bodyHasFocus: bodyEditor?.view?.hasFocus?.() ?? false, + activeIsBody: activeEditor === bodyEditor, + sessionLocator: session?.locator ?? null, + }; + }), + ) + .toEqual({ + bodyHasFocus: true, + activeIsBody: false, + sessionLocator: { + kind: 'story', + storyType: 'footnote', + noteId: '1', + }, + }); + + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(originalBodyText); + }); + + test('footnote clicks stay accurately mapped after returning to the body in suggesting mode', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(1000); + + const footnote = getFootnoteLocator(superdoc.page, '1'); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(footnote).toContainText('This is a simple footnote'); + + const noteBox = await footnote.boundingBox(); + expect(noteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(noteBox!.x + noteBox!.width / 2, noteBox!.y + noteBox!.height / 2); + await superdoc.waitForStable(300); + + const initialBoundary = await getBoundaryClickPoint(footnote, 'simple', 3); + expect(initialBoundary).toBeTruthy(); + await superdoc.page.mouse.click(initialBoundary!.x, initialBoundary!.y); + await superdoc.waitForStable(200); + await superdoc.page.keyboard.type('Z'); + await superdoc.waitForStable(300); + + await expectStoryText(superdoc.page, 'This is a simZple footnote'); + await expect(footnote).toContainText('This is a simZple footnote'); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + const bodyBox = await bodySurface.boundingBox(); + expect(bodyBox).toBeTruthy(); + await superdoc.page.mouse.click(bodyBox!.x + bodyBox!.width / 2, bodyBox!.y + bodyBox!.height / 2); + await superdoc.waitForStable(300); + + const bodyTextAfterReturn = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterReturn).toContain('Simple text'); + + // First click re-enters the note. + const reentryActivationBoundary = await getBoundaryClickPoint(footnote, 'footnote', 2); + expect(reentryActivationBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryActivationBoundary!.x, reentryActivationBoundary!.y); + await superdoc.waitForStable(300); + + // Second click inside the now-active note must still map to the exact + // requested boundary after the tracked insert. + const reentryBoundary = await getBoundaryClickPoint(footnote, 'simZple', 4); + expect(reentryBoundary).toBeTruthy(); + await superdoc.page.mouse.click(reentryBoundary!.x, reentryBoundary!.y); + await superdoc.waitForStable(300); + + const reentryState = await superdoc.page.evaluate(({ x, y }) => { + const editor = (window as any).editor; + const presentation = editor?.presentationEditor; + const activeEditor = presentation?.getActiveEditor?.(); + const session = presentation?.getStorySessionManager?.()?.getActiveSession?.(); + const view = activeEditor?.view; + const selection = activeEditor?.state?.selection?.from ?? null; + const hit = presentation?.hitTest?.(x, y)?.pos ?? null; + const domSelection = view?.dom?.ownerDocument?.getSelection?.(); + + let anchorPos = null; + try { + if (view && domSelection?.anchorNode && view.dom.contains(domSelection.anchorNode)) { + anchorPos = view.posAtDOM(domSelection.anchorNode, domSelection.anchorOffset, -1); + } + } catch {} + + return { + session: session?.locator ?? null, + selection, + hit, + anchorPos, + }; + }, reentryBoundary!); + + expect(reentryState.session).toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + expect(reentryState.selection).toBe(reentryState.hit); + expect(reentryState.anchorPos).toBe(reentryState.selection); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterReturn); + }); + + test('body edits do not corrupt footnote click mapping after a footnote edit', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + const footnote = await loadAndActivateFootnote(superdoc, '1', 'This is a simple footnote'); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footnote', 1); + await superdoc.page.keyboard.insertText('X0'); + await superdoc.waitForStable(300); + await expect(footnote).toContainText('fX0ootnote'); + + await superdoc.page.evaluate(() => { + (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.exit?.(); + }); + await superdoc.waitForStable(300); + await expect.poll(() => getActiveStorySession(superdoc.page)).toBeNull(); + + const bodySurface = getBodyFragmentLocator(superdoc.page, 'Simple text'); + await insertTextIntoBodyAtVisibleBoundary(superdoc.page, bodySurface, 'footnotes', 1, 'X0'); + await superdoc.waitForStable(300); + + const bodyTextAfterBodyEdit = await getBodyStoryText(superdoc.page); + expect(bodyTextAfterBodyEdit).toContain('fX0ootnotes'); + await expect(footnote).toContainText('fX0ootnote'); + + await clickFootnoteBoundary(superdoc.page, footnote, 'fX0ootnote', 4); + await superdoc.waitForStable(300); + await expect + .poll(() => getActiveStorySession(superdoc.page)) + .toEqual({ + kind: 'story', + storyType: 'footnote', + noteId: '1', + }); + + await expectCaretAtClickBoundary(superdoc.page, footnote, 'fX0ootnote', 6); + + await superdoc.page.keyboard.insertText('Z'); + await superdoc.waitForStable(300); + + await expect(footnote).toContainText('fX0ootZnote'); + await expect(getBodyStoryText(superdoc.page)).resolves.toBe(bodyTextAfterBodyEdit); + }); }); diff --git a/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts new file mode 100644 index 0000000000..388fa7d22c --- /dev/null +++ b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts @@ -0,0 +1,302 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const HEADER_DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); +const FOOTNOTE_DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', +); + +test.use({ + config: { + showCaret: true, + showSelection: true, + useHiddenHostForStoryParts: true, + }, +}); + +const MULTI_CLICK_RESET_MS = 450; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getWordClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const length = currentNode.textContent?.length ?? 0; + if (remaining < length) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(length, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallback = currentNode.parentElement?.getBoundingClientRect(); + if (!fallback) { + return null; + } + return { + x: fallback.left + Math.min(8, fallback.width / 2), + y: fallback.top + fallback.height / 2, + }; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= length; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getFirstWord(locator: Locator) { + const word = await locator.evaluate((element) => { + const text = element.textContent ?? ''; + const match = text.match(/\p{L}[\p{L}\p{N}]*/u); + return match?.[0] ?? null; + }); + + expect(word).toBeTruthy(); + return word!; +} + +async function getSelectionOverlayRects(page: Page) { + return page.evaluate(() => + Array.from(document.querySelectorAll('.presentation-editor__selection-rect')) + .map((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }) + .filter(Boolean), + ); +} + +async function getActiveSelection(page: Page) { + return page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + const selection = state?.selection; + if (!state?.doc || !selection) { + return null; + } + + return { + from: selection.from, + to: selection.to, + empty: selection.empty, + text: state.doc.textBetween(selection.from, selection.to, '\n', '\n'), + }; + }); +} + +async function getActiveEditorText(page: Page) { + const text = await page.evaluate(() => { + const activeEditor = + (window as any).editor?.presentationEditor?.getActiveEditor?.() ?? (window as any).editor ?? null; + const state = activeEditor?.state; + if (!state?.doc) { + return null; + } + + return state.doc.textBetween(0, state.doc.content.size, '\n', '\n'); + }); + + return normalizeText(text); +} + +async function expectWordSelection(page: Page, expectedWord: string) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(normalizeText(selection?.text)).toBe(expectedWord); +} + +async function expectParagraphSelection(page: Page, expectedText: string, minWordLength: number) { + const selection = await getActiveSelection(page); + expect(selection).toBeTruthy(); + expect(selection?.empty).toBe(false); + expect(normalizeText(selection?.text)).toBe(expectedText); + expect(normalizeText(selection?.text).length).toBeGreaterThanOrEqual(minWordLength); +} + +test('body surface supports double-click word selection and triple-click paragraph selection', async ({ superdoc }) => { + await superdoc.type('alpha beta gamma'); + await superdoc.waitForStable(); + + const line = superdoc.page.locator('.superdoc-line').first(); + const point = await getWordClickPoint(line, 'beta'); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'beta'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, 'alpha beta gamma', 'beta'.length); +}); + +test('body surface selection does not leak into visible footnotes', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const point = await getWordClickPoint(bodyLine, 'Simple'); + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + + await expectWordSelection(superdoc.page, 'Simple'); + + const selectionRects = await getSelectionOverlayRects(superdoc.page); + expect(selectionRects).toHaveLength(1); +}); + +test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); + +test('active header supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const header = superdoc.page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + + const headerBox = await header.boundingBox(); + expect(headerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(headerBox!.x + headerBox!.width / 2, headerBox!.y + headerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(header); + const point = await getWordClickPoint(header, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); + +test('active footer supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, +}) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + + const footer = superdoc.page.locator('.superdoc-page-footer').first(); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + + const footerBox = await footer.boundingBox(); + expect(footerBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footerBox!.x + footerBox!.width / 2, footerBox!.y + footerBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText.length).toBeGreaterThan(0); + + const word = await getFirstWord(footer); + const point = await getWordClickPoint(footer, word); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, word); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); +}); + +test('active footnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const footnote = superdoc.page.locator('[data-block-id^="footnote-1-"]').first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const footnoteBox = await footnote.boundingBox(); + expect(footnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(footnoteBox!.x + footnoteBox!.width / 2, footnoteBox!.y + footnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple footnote'); + + const point = await getWordClickPoint(footnote, 'footnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'footnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'footnote'.length); +}); From 18ec631a88944e7648cae49507a594a5142edbc8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 14:30:48 -0700 Subject: [PATCH 05/16] fix(comments): clear active tracked-change bubble on header/footer plain clicks --- .../pointer-events/EditorInputManager.ts | 51 +++++- .../EditorInputManager.footnoteClick.test.ts | 90 +++++++++- ...-surface-tracked-change-activation.spec.ts | 169 ++++++++++++++++++ 3 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index b64cb2c7d0..679b396f3e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -56,6 +56,7 @@ const AUTO_SCROLL_MAX_SPEED_PX = 24; const SCROLL_DETECTION_TOLERANCE_PX = 1; const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight'; const TRACK_CHANGE_SELECTOR = '[data-track-change-id]'; +const PM_TRACK_CHANGE_SELECTOR = '.track-insert[data-id], .track-delete[data-id], .track-format[data-id]'; const COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], @@ -146,8 +147,10 @@ function resolveTrackChangeThreadId(target: EventTarget | null): string | null { return null; } - const trackedChangeElement = target.closest(TRACK_CHANGE_SELECTOR); - const threadId = trackedChangeElement?.getAttribute('data-track-change-id')?.trim(); + const trackedChangeElement = target.closest(`${TRACK_CHANGE_SELECTOR}, ${PM_TRACK_CHANGE_SELECTOR}`); + const threadId = + trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ?? + trackedChangeElement?.getAttribute('data-id')?.trim(); return threadId ? threadId : null; } @@ -1221,6 +1224,7 @@ export class EditorInputManager { clientY: event.clientY, }); if (activated) { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); return; } this.#focusEditor(); @@ -1240,6 +1244,8 @@ export class EditorInputManager { if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { return; } + } else { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); } this.#handleClickWithoutLayout(event, isDraggableAnnotation); @@ -1262,6 +1268,7 @@ export class EditorInputManager { pageIndex: normalizedPoint.pageIndex, }); if (activated) { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); return; } this.#focusEditor(); @@ -1282,6 +1289,8 @@ export class EditorInputManager { if (this.#handleRepeatClickOnActiveComment(event, target, bodyEditor)) { return; } + } else { + this.#syncNonBodyCommentActivation(event, target, bodyEditor); } const isNoteEditing = activeNoteSession != null; @@ -2039,6 +2048,9 @@ export class EditorInputManager { activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target); if (clickedInsideEditorHost) { + this.#syncNonBodyCommentSelection(event, event.target as HTMLElement | null, this.#deps.getEditor(), { + clearOnMiss: true, + }); return true; // Let editor handle it } @@ -2569,6 +2581,41 @@ export class EditorInputManager { return true; } + #syncNonBodyCommentActivation(event: PointerEvent, target: HTMLElement | null, editor: Editor): void { + this.#syncNonBodyCommentSelection(event, target, editor); + } + + #syncNonBodyCommentSelection( + event: PointerEvent, + target: HTMLElement | null, + editor: Editor, + { clearOnMiss = false }: { clearOnMiss?: boolean } = {}, + ): void { + const clickedThreadId = resolveCommentThreadIdNearPointer(target, event.clientX, event.clientY); + const activeThreadId = getActiveCommentThreadId(editor); + + if (!clickedThreadId) { + if (!clearOnMiss || !activeThreadId) { + return; + } + + editor.emit?.('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: null, + }); + return; + } + + if (clickedThreadId === activeThreadId) { + return; + } + + editor.emit?.('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: clickedThreadId, + }); + } + #handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean { // Direct hits on inline annotated text should not be intercepted here. // Let generic click-to-position place the caret at the clicked pixel. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 2b06fd9077..8c392e3fe0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -92,6 +92,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }, selection: { $anchor: null }, storedMarks: null, + comments$: { activeThreadId: null }, }, view: { dispatch: vi.fn(), @@ -196,7 +197,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); - it('prioritizes note activation over tracked-change highlight handling on footnote clicks', () => { + it('activates the note session and syncs the tracked-change bubble on footnote clicks', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); @@ -221,10 +222,10 @@ describe('EditorInputManager - Footnote click selection behavior', () => { { storyType: 'footnote', noteId: '1' }, expect.objectContaining({ clientX: 12, clientY: 10 }), ); - expect(mockEditor.emit).not.toHaveBeenCalledWith( + expect(mockEditor.emit).toHaveBeenCalledWith( 'commentsUpdate', expect.objectContaining({ - type: expect.anything(), + activeCommentId: 'tc-1', }), ); }); @@ -401,7 +402,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(activeNoteEditor.view.focus).toHaveBeenCalled(); }); - it('does not route tracked-change clicks through comment selection while a note is actively being edited', () => { + it('keeps note hit testing while syncing the tracked-change bubble during active note editing', () => { const activeNoteEditor = createActiveSessionEditor(); (mockDeps.getActiveStorySession as Mock).mockReturnValue({ kind: 'note', @@ -439,10 +440,10 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(18, 16); expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); - expect(mockEditor.emit).not.toHaveBeenCalledWith( + expect(mockEditor.emit).toHaveBeenCalledWith( 'commentsUpdate', expect.objectContaining({ - type: expect.anything(), + activeCommentId: 'tc-1', }), ); }); @@ -494,6 +495,83 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(activeHeaderEditor.view.focus).toHaveBeenCalled(); }); + it('syncs the tracked-change bubble for clicks inside the active header editor host', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const activeEditorHost = document.createElement('div'); + const trackedChangeEl = document.createElement('span'); + trackedChangeEl.className = 'track-insert'; + trackedChangeEl.setAttribute('data-id', 'tc-header-1'); + activeEditorHost.appendChild(trackedChangeEl); + viewportHost.appendChild(activeEditorHost); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => activeEditorHost) }, + }); + + const PointerEventImpl = getPointerEventImpl(); + trackedChangeEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 20, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: 'tc-header-1', + }), + ); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).not.toHaveBeenCalled(); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + }); + + it('clears the active tracked-change bubble for plain clicks inside the active header editor host', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const activeEditorHost = document.createElement('div'); + const plainTextEl = document.createElement('span'); + plainTextEl.textContent = 'Generic content header'; + activeEditorHost.appendChild(plainTextEl); + viewportHost.appendChild(activeEditorHost); + + (mockEditor.state as typeof mockEditor.state & { comments$: { activeThreadId: string | null } }).comments$ = { + activeThreadId: 'tc-header-1', + }; + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => activeEditorHost) }, + }); + + const PointerEventImpl = getPointerEventImpl(); + plainTextEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 28, + clientY: 12, + } as PointerEventInit), + ); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + activeCommentId: null, + }), + ); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).not.toHaveBeenCalled(); + expect(resolvePointerPositionHit).not.toHaveBeenCalled(); + }); + it('resets multi-click state when the active editing target changes', () => { const target = document.createElement('span'); viewportHost.appendChild(target); diff --git a/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts new file mode 100644 index 0000000000..17bbfda8cc --- /dev/null +++ b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts @@ -0,0 +1,169 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FOOTNOTE_DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', +); + +test.use({ + config: { + toolbar: 'full', + comments: 'on', + trackChanges: true, + documentMode: 'suggesting', + useHiddenHostForStoryParts: true, + showCaret: true, + showSelection: true, + }, +}); + +function getInsertedTrackChangeLocator(container: Locator, insertedText: string): Locator { + return container + .locator('[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]') + .filter({ hasText: insertedText }) + .first(); +} + +async function getTextClickPoint(locator: Locator, searchText: string) { + const point = await locator.evaluate((element, targetText: string) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(targetText); + if (matchStart < 0) { + return null; + } + + const doc = element.ownerDocument; + if (!doc) { + return null; + } + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = matchStart; + let currentNode = walker.nextNode() as Text | null; + + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining < textLength) { + const range = doc.createRange(); + const startOffset = Math.max(0, remaining); + const endOffset = Math.min(textLength, remaining + targetText.length); + range.setStart(currentNode, startOffset); + range.setEnd(currentNode, endOffset); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + return null; + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, searchText); + + expect(point).toBeTruthy(); + return point!; +} + +async function getTrackChangeThreadIdAtPoint(page: Page, x: number, y: number): Promise { + return page.evaluate( + ({ x: clientX, y: clientY }) => { + const target = document.elementFromPoint(clientX, clientY); + const trackedChangeElement = target?.closest?.( + '[data-track-change-id], .track-insert[data-id], .track-delete[data-id], .track-format[data-id]', + ); + + return ( + trackedChangeElement?.getAttribute('data-track-change-id')?.trim() ?? + trackedChangeElement?.getAttribute('data-id')?.trim() ?? + null + ); + }, + { x, y }, + ); +} + +async function clearActiveComment(page: Page): Promise { + await page.evaluate(() => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: null }); + }); +} + +async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = superdoc.page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('clicking tracked-change text inside an active footnote activates its floating bubble', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const insertedText = 'NOTEFIX'; + const footnote = await activateFootnote(superdoc, '1'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(insertedText); + await superdoc.waitForStable(); + + const insertedChange = getInsertedTrackChangeLocator(footnote, insertedText); + await expect(insertedChange).toBeVisible(); + + const clickPoint = await getTextClickPoint(footnote, insertedText); + const threadId = await getTrackChangeThreadIdAtPoint(superdoc.page, clickPoint.x, clickPoint.y); + expect(threadId).toBeTruthy(); + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await superdoc.page.mouse.click(clickPoint.x, clickPoint.y); + await superdoc.waitForStable(); + + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(threadId); +}); From ce53d7f319a72269b27ae165a8d57415dd89666c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 15:29:47 -0700 Subject: [PATCH 06/16] fix: bubble positioning for tcs in headers or footers --- .../presentation-editor/PresentationEditor.ts | 81 +++++++--- ...stays-in-body-during-footnote-edit.spec.ts | 145 ++++++++++++++++++ 2 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 09c9f4743b..d38cf3ce20 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -1694,17 +1694,12 @@ export class PresentationEditor extends EventEmitter { return this.getRangeRects(selection.from, selection.to, relativeTo); } - /** - * Convert an arbitrary document range into layout-based bounding rects. - * - * @param from - Start position in the ProseMirror document - * @param to - End position in the ProseMirror document - * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates - * relative to this element's bounding rect. If omitted, returns absolute viewport - * coordinates relative to the selection overlay. - * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height) - */ - getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] { + #computeRangeRects( + from: number, + to: number, + relativeTo?: HTMLElement, + options: { forceBodySurface?: boolean } = {}, + ): RangeRect[] { if (!this.#selectionOverlay) return []; if (!Number.isFinite(from) || !Number.isFinite(to)) return []; @@ -1722,11 +1717,13 @@ export class PresentationEditor extends EventEmitter { let usedDomRects = false; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; const activeNoteSession = this.#getActiveNoteStorySession(); + const useHeaderFooterSurface = !options.forceBodySurface && sessionMode !== 'body'; + const useNoteSurface = !options.forceBodySurface && activeNoteSession != null; const layoutRectSource = () => { - if (sessionMode !== 'body') { + if (useHeaderFooterSurface) { return this.#computeHeaderFooterSelectionRects(start, end); } - if (activeNoteSession) { + if (useNoteSurface) { return this.#computeNoteSelectionRects(start, end); } const domRects = this.#computeSelectionRectsFromDom(start, end); @@ -1753,7 +1750,7 @@ export class PresentationEditor extends EventEmitter { let domCaretStart: { pageIndex: number; x: number; y: number } | null = null; let domCaretEnd: { pageIndex: number; x: number; y: number } | null = null; const pageDelta: Record = {}; - if (!usedDomRects && !activeNoteSession) { + if (!usedDomRects && !useNoteSurface) { // Geometry fallback path: apply a small DOM-based delta to reduce drift. try { domCaretStart = this.#computeDomCaretPageLocal(start); @@ -1774,8 +1771,8 @@ export class PresentationEditor extends EventEmitter { } const pageHeight = this.#getBodyPageHeight(); - const pageGap = sessionMode === 'body' ? (this.#layoutState.layout?.pageGap ?? 0) : 0; - const finalRects = rawRects + const pageGap = useHeaderFooterSurface || !this.#layoutState.layout ? 0 : (this.#layoutState.layout.pageGap ?? 0); + return rawRects .map((rect: LayoutRect, idx: number, allRects: LayoutRect[]) => { let adjustedX = rect.x; let adjustedY = rect.y; @@ -1819,8 +1816,20 @@ export class PresentationEditor extends EventEmitter { }; }) .filter((rect: RangeRect | null): rect is RangeRect => Boolean(rect)); + } - return finalRects; + /** + * Convert an arbitrary document range into layout-based bounding rects. + * + * @param from - Start position in the ProseMirror document + * @param to - End position in the ProseMirror document + * @param relativeTo - Optional HTMLElement for coordinate reference. If provided, returns coordinates + * relative to this element's bounding rect. If omitted, returns absolute viewport + * coordinates relative to the selection overlay. + * @returns Array of rects, each containing pageIndex and position data (left, top, right, bottom, width, height) + */ + getRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] { + return this.#computeRangeRects(from, to, relativeTo); } /** @@ -1855,6 +1864,42 @@ export class PresentationEditor extends EventEmitter { }; } + #getThreadSelectionBounds( + data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown }, + relativeTo: HTMLElement | undefined, + ): { + bounds: { top: number; left: number; bottom: number; right: number; width: number; height: number }; + rects: RangeRect[]; + pageIndex: number; + } | null { + const start = Number.isFinite(data.start ?? data.pos) ? Number(data.start ?? data.pos) : undefined; + const end = Number.isFinite(data.end) ? Number(data.end) : start; + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + + const storyKey = typeof data.storyKey === 'string' ? data.storyKey : null; + const rects = + storyKey === BODY_STORY_KEY + ? this.#computeRangeRects(start!, end!, relativeTo, { forceBodySurface: true }) + : this.getRangeRects(start!, end!, relativeTo); + + if (!rects.length) { + return null; + } + + const bounds = this.#aggregateLayoutBounds(rects); + if (!bounds) { + return null; + } + + return { + rects, + bounds, + pageIndex: rects[0]?.pageIndex ?? 0, + }; + } + /** * Remap comment positions to layout coordinates with bounds and rects. * Takes a positions object with threadIds as keys and position data as values. @@ -1927,7 +1972,7 @@ export class PresentationEditor extends EventEmitter { return; } - const layoutRange = this.getSelectionBounds(start!, end!, relativeTo); + const layoutRange = this.#getThreadSelectionBounds(data, relativeTo); if (!layoutRange) { remapped[threadId] = data; return; diff --git a/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts new file mode 100644 index 0000000000..23392f5fdb --- /dev/null +++ b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts @@ -0,0 +1,145 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', +); + +test.use({ + config: { + toolbar: 'full', + comments: 'panel', + trackChanges: true, + documentMode: 'suggesting', + useHiddenHostForStoryParts: true, + showCaret: true, + showSelection: true, + }, +}); + +type TrackedChangePosition = { + key: string; + top: number; + left: number; + pageIndex: number | null; +}; + +function getFootnoteLocator(page: Page, noteId: string): Locator { + return page.locator(`[data-block-id^="footnote-${noteId}-"]`).first(); +} + +async function getBodyTrackedChangePosition(page: Page): Promise { + return page.evaluate(() => { + const positions = (window as any).superdoc?.commentsStore?.editorCommentPositions ?? {}; + for (const [key, entry] of Object.entries(positions)) { + if (!key.startsWith('tc::body::')) { + continue; + } + + const bounds = (entry as { bounds?: { top?: unknown; left?: unknown } }).bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + continue; + } + + const pageIndex = (entry as { pageIndex?: unknown }).pageIndex; + return { + key, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + } + + return null; + }); +} + +async function getTrackedChangePositionByKey(page: Page, key: string): Promise { + return page.evaluate((targetKey: string) => { + const entry = (window as any).superdoc?.commentsStore?.editorCommentPositions?.[targetKey]; + const bounds = entry?.bounds; + if (!bounds || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.left)) { + return null; + } + + const pageIndex = entry?.pageIndex; + return { + key: targetKey, + top: Number(bounds.top), + left: Number(bounds.left), + pageIndex: Number.isFinite(pageIndex) ? Number(pageIndex) : null, + }; + }, key); +} + +async function activateFootnote( + superdoc: { page: Page; waitForStable: (ms?: number) => Promise }, + noteId: string, +) { + const footnote = getFootnoteLocator(superdoc.page, noteId); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + await expect + .poll(() => + superdoc.page.evaluate(() => { + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator?.storyType ?? null; + }), + ) + .toBe('footnote'); + + return footnote; +} + +test('body tracked-change anchors stay in body space while editing a footnote in suggesting mode', async ({ + superdoc, + browserName, +}) => { + test.skip( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host footnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const bodyLine = superdoc.page.locator('.superdoc-line', { hasText: 'Simple text1 with footnotes' }).first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + + const lineBox = await bodyLine.boundingBox(); + expect(lineBox).toBeTruthy(); + + await superdoc.page.mouse.click(lineBox!.x + 12, lineBox!.y + lineBox!.height / 2); + await superdoc.page.keyboard.insertText('BODYFIX '); + await superdoc.waitForStable(); + + await expect.poll(() => getBodyTrackedChangePosition(superdoc.page)).not.toBeNull(); + const before = await getBodyTrackedChangePosition(superdoc.page); + expect(before).toBeTruthy(); + + const footnote = await activateFootnote(superdoc, '1'); + await expect(footnote).toContainText('This is a simple footnote'); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText('NOTEFIX'); + await superdoc.waitForStable(); + + await expect.poll(() => getTrackedChangePositionByKey(superdoc.page, before!.key)).not.toBeNull(); + const after = await getTrackedChangePositionByKey(superdoc.page, before!.key); + expect(after).toBeTruthy(); + + expect(after!.pageIndex).toBe(before!.pageIndex); + expect(Math.abs(after!.top - before!.top)).toBeLessThanOrEqual(40); + expect(Math.abs(after!.left - before!.left)).toBeLessThanOrEqual(40); +}); From ecd1342a11936612b2720af638a263d175502702 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 15:45:11 -0700 Subject: [PATCH 07/16] fix: initial comments loading when only story based tcs in place --- .../superdoc/src/stores/comments-store.js | 29 +++++- .../src/stores/comments-store.test.js | 88 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 4780e6d39f..fc98742063 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -1033,6 +1033,29 @@ export const useCommentsStore = defineStore('comments', () => { return normalizedName || null; }; + /** + * Bootstrap tracked-change comment threads after a DOCX import finishes. + * + * Initial import historically rebuilt only body tracked-change threads so + * resolved imported body comments stayed resolved. Header/footer and note + * tracked changes live outside the body PM state, so they need an additional + * story-aware bootstrap pass here. + * + * We intentionally keep the existing body-only rebuild instead of switching + * to the broader syncTrackedChangeComments() path so imported resolved body + * tracked-change threads preserve their initial resolved state. + * + * @param {Object | null | undefined} editor + * @param {Object | null | undefined} superdoc + * @returns {void} + */ + const bootstrapImportedTrackedChangeComments = (editor, superdoc) => { + if (!editor || !superdoc) return; + + createCommentForTrackChanges(editor, superdoc); + syncStoryTrackedChangeComments({ superdoc, editor }); + }; + /** * Initialize loaded comments into SuperDoc by mapping the imported * comment data to SuperDoc useComment objects. @@ -1096,9 +1119,9 @@ export const useCommentsStore = defineStore('comments', () => { }); setTimeout(() => { - // do not block the first rendering of the doc - // and create comments asynchronously. - createCommentForTrackChanges(editor, superdoc); + // Do not block the first rendering of the doc. Rebuild tracked-change + // threads asynchronously once the editor is ready for comment sync. + bootstrapImportedTrackedChangeComments(editor, superdoc); }, 0); }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 450b3bae84..db1d55ef63 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1581,6 +1581,94 @@ describe('comments-store', () => { expect(editorDispatch).toHaveBeenCalledWith(tr); }); + it('bootstraps story tracked-change comments during initial DOCX load', async () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + converter: { commentThreadingProfile: 'range-based' }, + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const storyState = { doc: { type: 'header-doc' } }; + const snapshot = { + type: 'insert', + excerpt: 'header test', + anchorKey: 'tc::hf:part:rId9::raw-hf-1', + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId9' }, + storyKind: 'headerFooter', + storyLabel: 'Header', + runtimeRef: { rawId: 'raw-hf-1' }, + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + + trackChangesHelpersMock.getTrackChanges.mockImplementation((state, id) => { + if (state === storyState && id === 'raw-hf-1') { + return [{ mark: { type: { name: 'trackInsert' }, attrs: { id: 'raw-hf-1' } } }]; + } + return []; + }); + groupChangesMock.mockReturnValue([]); + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + resolveTrackedChangeInStoryMock.mockReturnValue({ + editor: { state: storyState }, + story: snapshot.story, + runtimeRef: { storyKey: 'hf:part:rId9', rawId: 'raw-hf-1' }, + change: { rawId: 'raw-hf-1' }, + }); + createOrUpdateTrackedChangeCommentMock.mockReturnValue({ + event: 'add', + changeId: 'raw-hf-1', + trackedChangeType: 'insert', + trackedChangeDisplayType: 'insert', + trackedChangeText: 'header test', + deletedText: null, + author: 'Alice', + authorEmail: 'alice@example.com', + documentId: 'doc-1', + coords: {}, + }); + + store.processLoadedDocxComments({ + superdoc: __mockSuperdoc, + editor, + comments: [], + documentId: 'doc-1', + }); + + vi.runAllTimers(); + await nextTick(); + + expect(resolveTrackedChangeInStoryMock).toHaveBeenCalledWith(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'raw-hf-1', + story: snapshot.story, + }); + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'raw-hf-1', + trackedChange: true, + trackedChangeText: 'header test', + trackedChangeStoryKind: 'headerFooter', + trackedChangeAnchorKey: 'tc::hf:part:rId9::raw-hf-1', + }), + ]); + expect(tr.setMeta).toHaveBeenCalledWith('CommentsPluginKey', { type: 'force' }); + expect(editorDispatch).toHaveBeenCalledWith(tr); + }); + it('should load comments with correct created time', () => { store.init({ readOnly: true, From d747f95f8e604873c2fd1467a67baad537ebdc80 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 16:59:18 -0700 Subject: [PATCH 08/16] fix(track-changes): honor independent replacements in story editors --- .../presentation-editor/PresentationEditor.ts | 16 ++ .../v1/core/story-editor-factory.test.ts | 64 ++++++ .../editors/v1/core/story-editor-factory.ts | 8 +- ...ked-change-independent-replacement.spec.ts | 191 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts create mode 100644 tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d38cf3ce20..251cd50437 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4440,6 +4440,13 @@ export class PresentationEditor extends EventEmitter { }); }, onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => { + if (transaction?.docChanged && headerId) { + this.#invalidateTrackedChangesForStory({ + kind: 'story', + storyType: 'headerFooterPart', + refId: headerId, + }); + } this.emit('headerFooterTransaction', { editor: this.#editor, sourceEditor, @@ -4486,6 +4493,7 @@ export class PresentationEditor extends EventEmitter { if (!transaction?.docChanged) { return; } + this.#invalidateTrackedChangesForStory(session.locator); this.#flowBlockCache.setHasExternalChanges(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); @@ -4519,6 +4527,14 @@ export class PresentationEditor extends EventEmitter { session.editor.setOptions?.({ documentMode: this.#documentMode }); } + #invalidateTrackedChangesForStory(locator: StoryLocator): void { + try { + getTrackedChangeIndex(this.#editor).invalidate(locator); + } catch { + // Tracked-change sync is best-effort while a live story session is typing. + } + } + #ensureStorySessionManager(): StoryPresentationSessionManager { if (this.#storySessionManager) { return this.#storySessionManager; diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts new file mode 100644 index 0000000000..f688e776ee --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Editor } from './Editor.js'; +import { createStoryEditor } from './story-editor-factory.ts'; +import { initTestEditor } from '../tests/helpers/helpers.js'; + +const createdEditors: Editor[] = []; + +function trackEditor(editor: Editor): Editor { + createdEditors.push(editor); + return editor; +} + +afterEach(() => { + while (createdEditors.length > 0) { + const editor = createdEditors.pop(); + try { + editor?.destroy?.(); + } catch { + // best-effort cleanup for test editors + } + } +}); + +describe('createStoryEditor', () => { + it('inherits tracked changes configuration from the parent editor', () => { + const parent = trackEditor( + initTestEditor({ + mode: 'text', + content: '

Hello world

', + trackedChanges: { + visible: true, + mode: 'review', + enabled: true, + replacements: 'independent', + }, + }).editor as Editor, + ); + + const child = trackEditor( + createStoryEditor( + parent, + { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }], + }, + { + documentId: 'hf:part:rId9', + isHeaderOrFooter: true, + headless: true, + }, + ), + ); + + expect(child.options.trackedChanges).toEqual({ + visible: true, + mode: 'review', + enabled: true, + replacements: 'independent', + }); + + child.options.trackedChanges!.replacements = 'paired'; + expect(parent.options.trackedChanges?.replacements).toBe('independent'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index 43e35efc9e..d271bbd31e 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -129,6 +129,9 @@ export function createStoryEditor( const inheritedExtensions = parentEditor.options.extensions?.length ? [...parentEditor.options.extensions] : undefined; + const inheritedTrackedChanges = parentEditor.options.trackedChanges + ? { ...parentEditor.options.trackedChanges } + : undefined; const StoryEditorClass = parentEditor.constructor as new (options: Partial) => Editor; const storyEditor = new StoryEditorClass({ @@ -145,6 +148,7 @@ export function createStoryEditor( mediaFiles: media, fonts: parentEditor.options.fonts, user: parentEditor.options.user, + trackedChanges: inheritedTrackedChanges, isHeaderOrFooter, isHeadless, pagination: false, @@ -157,7 +161,9 @@ export function createStoryEditor( // Only set element when not headless ...(isHeadless ? {} : { element }), - // Disable collaboration, comments, and tracked changes for story editors + // Disable collaboration and comment threading for story editors. + // Tracked-change configuration is inherited from the parent editor so + // suggesting-mode story sessions honor the same replacement model. ydoc: null, collaborationProvider: null, isCommentsEnabled: false, diff --git a/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts new file mode 100644 index 0000000000..f5cd4b4910 --- /dev/null +++ b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test, type Locator, type Page } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const HEADER_DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); +const FOOTNOTE_DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', +); + +test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); +test.skip(!fs.existsSync(FOOTNOTE_DOC_PATH), 'Footnote test document not available'); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + replacements: 'independent', + useHiddenHostForStoryParts: true, + }, +}); + +async function activateHeader(page: Page) { + const header = page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await header.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); +} + +async function activateFooter(page: Page) { + const footer = page.locator('.superdoc-page-footer').first(); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await footer.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); +} + +async function activateFootnote(page: Page) { + const footnote = page.locator('[data-block-id^="footnote-1-"]').first(); + await footnote.scrollIntoViewIfNeeded(); + await footnote.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await footnote.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); +} + +async function replaceFirstTwoLettersInActiveStory(page: Page, replacementText: string) { + return page.evaluate( + ({ replacement }) => { + const presentation = (window as any).editor?.presentationEditor; + const hostEditor = (window as any).editor; + const activeEditor = presentation?.getActiveEditor?.(); + if (!activeEditor || activeEditor === hostEditor) { + throw new Error('Expected an active story editor.'); + } + + const storyText = activeEditor.state.doc.textBetween(0, activeEditor.state.doc.content.size, '\n', '\n') ?? ''; + const match = storyText.match(/[A-Za-z]{2,}/); + if (!match || match.index == null) { + throw new Error(`No replaceable word found in active story text: "${storyText}"`); + } + + const deletedText = storyText.slice(match.index, match.index + 2); + const positions: number[] = []; + activeEditor.state.doc.descendants((node: any, pos: number) => { + if (!node?.isText || !node.text) return; + for (let i = 0; i < node.text.length; i += 1) positions.push(pos + i); + }); + + const from = positions[match.index]; + const to = positions[match.index + 1] + 1; + const success = activeEditor.commands.insertTrackedChange({ from, to, text: replacement }); + + return { + success, + activeDocumentId: activeEditor.options.documentId, + trackedChanges: activeEditor.options.trackedChanges ?? null, + deletedText, + replacement, + }; + }, + { replacement: replacementText }, + ); +} + +async function expectIndependentStoryBubbles(page: Page, deletedText: string, insertedText: string) { + await expect + .poll( + () => + page.evaluate( + ({ deleted, inserted }) => { + const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; + const trackedChangeComments = comments.filter((comment: any) => comment?.trackedChange); + const matchingComments = trackedChangeComments.filter( + (comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted, + ); + const floatingCount = (window as any).superdoc?.commentsStore?.getFloatingComments?.length ?? 0; + const dialogTexts = Array.from(document.querySelectorAll('.comment-placeholder .comments-dialog')) + .map((node) => node.textContent ?? '') + .filter(Boolean); + + return { + matchingTypes: matchingComments.map((comment: any) => comment?.trackedChangeType).sort(), + matchingDeletedTexts: matchingComments.map((comment: any) => comment?.deletedText).filter(Boolean), + matchingInsertedTexts: matchingComments.map((comment: any) => comment?.trackedChangeText).filter(Boolean), + floatingCount, + dialogTexts, + }; + }, + { deleted: deletedText, inserted: insertedText }, + ), + { timeout: 10_000 }, + ) + .toEqual( + expect.objectContaining({ + matchingTypes: ['trackDelete', 'trackInsert'], + matchingDeletedTexts: [deletedText], + matchingInsertedTexts: [insertedText], + }), + ); +} + +async function expectActiveStoryReplacementMode(page: Page) { + await expect + .poll(() => + page.evaluate(() => (window as any).editor?.presentationEditor?.getActiveEditor?.()?.options?.trackedChanges), + ) + .toEqual( + expect.objectContaining({ + replacements: 'independent', + }), + ); +} + +test('header replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateHeader(superdoc.page); + await superdoc.waitForStable(); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + expect(result.activeDocumentId).not.toBe( + (await superdoc.page.evaluate(() => (window as any).editor?.options?.documentId)) ?? null, + ); + + await superdoc.waitForStable(); + await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement); +}); + +test('footer replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateFooter(superdoc.page); + await superdoc.waitForStable(); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement); +}); + +test('footnote replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTNOTE_DOC_PATH); + await superdoc.waitForStable(); + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await activateFootnote(superdoc.page); + await superdoc.waitForStable(); + await expectActiveStoryReplacementMode(superdoc.page); + + const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x'); + expect(result.success).toBe(true); + + await superdoc.waitForStable(); + await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement); +}); From efedaab1776b1b54037e78e125c6c265d838fd07 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 17:37:16 -0700 Subject: [PATCH 09/16] fix: types --- .../presentation-editor/PresentationEditor.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 251cd50437..7b1d05c892 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -139,6 +139,23 @@ type RenderedNoteTarget = { noteId: string; }; +type NoteStorySession = StoryPresentationSession & { + locator: Extract; +}; + +type BoundedCommentPositionEntry = { + threadId: string; + start?: number; + end?: number; + pos?: number; + key?: string; + storyKey?: string; + kind?: 'trackedChange' | 'comment'; + bounds?: unknown; + rects?: unknown; + pageIndex?: number; +}; + type NoteLayoutContext = { target: RenderedNoteTarget; blocks: FlowBlock[]; @@ -1187,7 +1204,7 @@ export class PresentationEditor extends EventEmitter { return this.#storySessionManager?.getActiveSession() ?? null; } - #getActiveNoteStorySession(): StoryPresentationSession | null { + #getActiveNoteStorySession(): NoteStorySession | null { const session = this.#getActiveStorySession(); if (!session || session.kind !== 'note') { return null; @@ -1195,7 +1212,7 @@ export class PresentationEditor extends EventEmitter { if (session.locator.storyType !== 'footnote' && session.locator.storyType !== 'endnote') { return null; } - return session; + return session as NoteStorySession; } #getActiveTrackedChangeStorySurface(): { storyKey: string; editor: Editor } | null { @@ -3022,9 +3039,10 @@ export class PresentationEditor extends EventEmitter { const threadPosition = this.#resolveCommentPositionEntry(threadId); if (!threadPosition) return null; - const boundedEntry = this.getCommentBounds({ [threadId]: threadPosition })[threadId] ?? threadPosition; + const boundedEntry = (this.getCommentBounds({ [threadId]: threadPosition })[threadId] ?? + threadPosition) as BoundedCommentPositionEntry; const currentTopValue = - typeof boundedEntry?.bounds === 'object' && boundedEntry?.bounds != null + typeof boundedEntry.bounds === 'object' && boundedEntry.bounds != null ? (boundedEntry.bounds as { top?: unknown }).top : undefined; if (!Number.isFinite(currentTopValue)) return null; @@ -3044,15 +3062,7 @@ export class PresentationEditor extends EventEmitter { return null; } - #resolveCommentPositionEntry(threadId: string): { - threadId: string; - start?: number; - end?: number; - pos?: number; - key?: string; - storyKey?: string; - kind?: 'trackedChange' | 'comment'; - } | null { + #resolveCommentPositionEntry(threadId: string): BoundedCommentPositionEntry | null { const positions = this.#collectCommentPositions(); const directMatch = positions[threadId]; if (directMatch) { @@ -4440,7 +4450,9 @@ export class PresentationEditor extends EventEmitter { }); }, onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => { - if (transaction?.docChanged && headerId) { + const documentTransaction = + transaction && typeof transaction === 'object' ? (transaction as { docChanged?: boolean }) : null; + if (documentTransaction?.docChanged && headerId) { this.#invalidateTrackedChangesForStory({ kind: 'story', storyType: 'headerFooterPart', From ca5e2896a92e83bb4b7122f35ae3006280c6e2ef Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 19:13:56 -0700 Subject: [PATCH 10/16] fix: story based endnotes, ttests --- .../presentation-editor/PresentationEditor.ts | 96 ++++++- .../layout/EndnotesBuilder.ts | 177 ++++++++++++ .../pointer-events/EditorInputManager.ts | 18 +- .../EditorInputManager.footnoteClick.test.ts | 27 ++ .../tests/PresentationEditor.test.ts | 114 ++++++++ .../v1/core/presentation-editor/types.ts | 4 + .../v2/importer/documentFootnotesImporter.js | 15 +- .../v2/importer/docxImporter.js | 2 + .../v2/importer/endnoteReferenceImporter.js | 7 + .../v1/tests/import/footnotesImporter.test.js | 39 +++ packages/superdoc/src/SuperDoc.test.js | 15 + .../commentsList/commentsList.vue | 6 +- .../commentsList/super-comments-list.js | 10 + .../commentsList/super-comments-list.test.js | 7 + tests/behavior/harness/index.html | 43 ++- tests/behavior/harness/main.ts | 101 +++++++ tests/behavior/helpers/document-api.ts | 25 +- tests/behavior/helpers/story-fixtures.ts | 269 ++++++++++++++++++ tests/behavior/helpers/story-surfaces.ts | 266 +++++++++++++++++ .../behavior/helpers/story-tracked-changes.ts | 244 ++++++++++++++++ ...stays-in-body-during-footnote-edit.spec.ts | 9 +- ...eader-footer-tracked-change-bubble.spec.ts | 71 +++++ ...-surface-tracked-change-activation.spec.ts | 9 +- .../story-surface-import-bootstrap.spec.ts | 47 +++ ...tory-surface-tracked-change-decide.spec.ts | 151 ++++++++++ ...ory-surface-tracked-change-sidebar.spec.ts | 35 +++ ...ked-change-independent-replacement.spec.ts | 17 +- .../double-click-edit-endnote.spec.ts | 51 ++++ .../double-click-edit-footnote.spec.ts | 11 +- .../headers/double-click-edit-header.spec.ts | 73 +++-- .../headers/header-footer-line-height.spec.ts | 8 +- .../header-footer-selection-overlay.spec.ts | 9 +- .../tests/search/search-and-navigate.spec.ts | 11 +- .../part-surface-multiclick-selection.spec.ts | 57 +++- 34 files changed, 1902 insertions(+), 142 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js create mode 100644 tests/behavior/helpers/story-fixtures.ts create mode 100644 tests/behavior/helpers/story-surfaces.ts create mode 100644 tests/behavior/helpers/story-tracked-changes.ts create mode 100644 tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts create mode 100644 tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 7b1d05c892..a2ba3dce05 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -80,9 +80,10 @@ import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionM import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js'; import type { StoryPresentationSession } from './story-session/types.js'; import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; -import { BODY_STORY_KEY, buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; +import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.js'; +import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js'; import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter'; import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { @@ -184,6 +185,11 @@ function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { return noteId ? { storyType: 'footnote', noteId } : null; } + if (blockId.startsWith('endnote-')) { + const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'endnote', noteId } : null; + } + return null; } import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; @@ -4985,7 +4991,16 @@ export class PresentationEditor extends EventEmitter { const semanticFootnoteBlocks = isSemanticFlow ? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode) : []; - const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks; + const endnoteBlocks = buildEndnoteBlocks( + this.#editor?.state, + (this.#editor as EditorWithConverter)?.converter, + converterContext, + this.#editor?.converter?.themeColors ?? undefined, + ); + const blocksForLayout = + semanticFootnoteBlocks.length > 0 || endnoteBlocks.length > 0 + ? [...blocks, ...semanticFootnoteBlocks, ...endnoteBlocks] + : blocks; const layoutOptions = !isSemanticFlow && footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } @@ -7016,6 +7031,12 @@ export class PresentationEditor extends EventEmitter { return true; } + if (await this.#activateTrackedChangeStorySurface(entityId, storyKey)) { + if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { + return true; + } + } + return this.#scrollToRenderedTrackedChange(entityId, storyKey); } @@ -7053,6 +7074,77 @@ export class PresentationEditor extends EventEmitter { return true; } + async #activateTrackedChangeStorySurface(entityId: string, storyKey: string): Promise { + let locator: StoryLocator | null = null; + try { + locator = parseStoryKey(storyKey); + } catch { + return false; + } + + if (!locator || locator.storyType === 'body') { + return false; + } + + const candidate = this.#findRenderedTrackedChangeElements(entityId, storyKey)[0] ?? null; + if (!candidate) { + return false; + } + + const rect = candidate.getBoundingClientRect(); + const clientX = rect.left + Math.max(rect.width / 2, 1); + const clientY = rect.top + Math.max(rect.height / 2, 1); + const pageIndex = this.#resolveRenderedPageIndexForElement(candidate); + + if (locator.storyType === 'footnote' || locator.storyType === 'endnote') { + try { + if ( + !this.#activateRenderedNoteSession( + { + storyType: locator.storyType, + noteId: locator.noteId, + }, + { clientX, clientY, pageIndex }, + ) + ) { + return false; + } + } catch { + return false; + } + + return this.#waitForTrackedChangeStorySurface(storyKey); + } + + if (locator.storyType !== 'headerFooterPart') { + return false; + } + + const pageElement = candidate.closest('.superdoc-page'); + const pageRect = pageElement?.getBoundingClientRect(); + const pageLocalY = pageRect ? clientY - pageRect.top : undefined; + const region = this.#hitTestHeaderFooterRegion(clientX, clientY, pageIndex, pageLocalY); + if (!region) { + return false; + } + + this.#activateHeaderFooterRegion(region); + return this.#waitForTrackedChangeStorySurface(storyKey); + } + + async #waitForTrackedChangeStorySurface(storyKey: string, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 16)); + } + + return this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey; + } + #navigateToActiveStoryTrackedChange(entityId: string, storyKey: string): boolean { const activeSurface = this.#getActiveTrackedChangeStorySurface(); if (!activeSurface || activeSurface.storyKey !== storyKey) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts new file mode 100644 index 0000000000..2504c27f4c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts @@ -0,0 +1,177 @@ +import type { EditorState } from 'prosemirror-state'; +import type { FlowBlock } from '@superdoc/contracts'; +import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; +import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; + +import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; +import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; +import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; + +export type EndnoteConverterLike = { + endnotes?: Array<{ id?: unknown; content?: unknown[] }>; +}; + +type Run = { + kind?: string; + text?: string; + fontFamily?: string; + fontSize?: number; + bold?: boolean; + italic?: boolean; + letterSpacing?: number; + color?: unknown; + vertAlign?: 'superscript' | 'subscript' | 'baseline'; + baselineShift?: number; + pmStart?: number | null; + pmEnd?: number | null; + dataAttrs?: Record; +}; + +type ParagraphBlock = FlowBlock & { + kind: 'paragraph'; + runs?: Run[]; +}; + +const ENDNOTE_MARKER_DATA_ATTR = 'data-sd-endnote-number'; +const DEFAULT_MARKER_FONT_FAMILY = 'Arial'; +const DEFAULT_MARKER_FONT_SIZE = 12; + +export function buildEndnoteBlocks( + editorState: EditorState | null | undefined, + converter: EndnoteConverterLike | null | undefined, + converterContext: ConverterContext | undefined, + themeColors: unknown, +): FlowBlock[] { + if (!editorState) return []; + + const endnoteNumberById = converterContext?.endnoteNumberById; + const importedEndnotes = Array.isArray(converter?.endnotes) ? converter.endnotes : []; + if (importedEndnotes.length === 0) return []; + + const orderedEndnoteIds: string[] = []; + const seen = new Set(); + + editorState.doc.descendants((node) => { + if (node.type?.name !== 'endnoteReference') return; + const id = node.attrs?.id; + if (id == null) return; + const key = String(id); + if (!key || seen.has(key)) return; + seen.add(key); + orderedEndnoteIds.push(key); + }); + + if (orderedEndnoteIds.length === 0) return []; + + const blocks: FlowBlock[] = []; + + orderedEndnoteIds.forEach((id) => { + const entry = findNoteEntryById(importedEndnotes, id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) return; + + try { + const clonedContent = JSON.parse(JSON.stringify(content)); + const endnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent }); + const result = toFlowBlocks(endnoteDoc, { + blockIdPrefix: `endnote-${id}-`, + storyKey: buildStoryKey({ kind: 'story', storyType: 'endnote', noteId: id }), + enableRichHyperlinks: true, + themeColors: themeColors as never, + converterContext: converterContext as never, + }); + + if (result?.blocks?.length) { + ensureEndnoteMarker(result.blocks, id, endnoteNumberById); + blocks.push(...result.blocks); + } + } catch {} + }); + + return blocks; +} + +function isEndnoteMarker(run: Run): boolean { + return Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]); +} + +function resolveDisplayNumber(id: string, endnoteNumberById: Record | undefined): number { + if (!endnoteNumberById || typeof endnoteNumberById !== 'object') return 1; + const num = endnoteNumberById[id]; + if (typeof num === 'number' && Number.isFinite(num) && num > 0) return num; + return 1; +} + +function resolveMarkerFontFamily(firstTextRun: Run | undefined): string { + return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY; +} + +function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number { + if ( + typeof firstTextRun?.fontSize === 'number' && + Number.isFinite(firstTextRun.fontSize) && + firstTextRun.fontSize > 0 + ) { + return firstTextRun.fontSize; + } + + return DEFAULT_MARKER_FONT_SIZE; +} + +function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run { + const markerRun: Run = { + kind: 'text', + text: markerText, + dataAttrs: { [ENDNOTE_MARKER_DATA_ATTR]: 'true' }, + fontFamily: resolveMarkerFontFamily(firstTextRun), + fontSize: resolveMarkerBaseFontSize(firstTextRun) * SUBSCRIPT_SUPERSCRIPT_SCALE, + vertAlign: 'superscript', + }; + + if (typeof firstTextRun?.bold === 'boolean') markerRun.bold = firstTextRun.bold; + if (typeof firstTextRun?.italic === 'boolean') markerRun.italic = firstTextRun.italic; + if (typeof firstTextRun?.letterSpacing === 'number' && Number.isFinite(firstTextRun.letterSpacing)) { + markerRun.letterSpacing = firstTextRun.letterSpacing; + } + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + + return markerRun; +} + +function syncMarkerRun(target: Run, source: Run): void { + target.kind = source.kind; + target.text = source.text; + target.dataAttrs = source.dataAttrs; + target.fontFamily = source.fontFamily; + target.fontSize = source.fontSize; + target.bold = source.bold; + target.italic = source.italic; + target.letterSpacing = source.letterSpacing; + target.color = source.color; + target.vertAlign = source.vertAlign; + target.baselineShift = source.baselineShift; + delete target.pmStart; + delete target.pmEnd; +} + +function ensureEndnoteMarker( + blocks: FlowBlock[], + id: string, + endnoteNumberById: Record | undefined, +): void { + const firstParagraph = blocks.find((block): block is ParagraphBlock => block.kind === 'paragraph'); + if (!firstParagraph) return; + + const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + firstParagraph.runs = runs; + + const firstTextRun = runs.find((run) => !isEndnoteMarker(run) && typeof run.text === 'string' && run.text.length > 0); + const markerRun = buildMarkerRun(String(resolveDisplayNumber(id, endnoteNumberById)), firstTextRun); + + if (runs[0] && isEndnoteMarker(runs[0])) { + syncMarkerRun(runs[0], markerRun); + return; + } + + runs.unshift(markerRun); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 679b396f3e..d33b9720d4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -73,12 +73,15 @@ type CommentThreadHit = { }; /** - * Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder). + * Block IDs for note content use `footnote-{id}-` / `endnote-{id}-` prefixes. * Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from * shared constants — it matches both heading and body footnote block IDs. */ -function isFootnoteBlockId(blockId: string): boolean { - return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId)); +function isRenderedNoteBlockId(blockId: string): boolean { + return ( + typeof blockId === 'string' && + (blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId)) + ); } type RenderedNoteTarget = { @@ -101,6 +104,11 @@ function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null { return noteId ? { storyType: 'footnote', noteId } : null; } + if (blockId.startsWith('endnote-')) { + const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? ''; + return noteId ? { storyType: 'endnote', noteId } : null; + } + return null; } @@ -1390,7 +1398,7 @@ export class EditorInputManager { } // Disallow entering read-only note content unless it has been activated into a story session. - if (isFootnoteBlockId(rawHit.blockId) && !isNoteEditing) { + if (isRenderedNoteBlockId(rawHit.blockId) && !isNoteEditing) { this.#focusEditor(); return; } @@ -2224,7 +2232,7 @@ export class EditorInputManager { if (!rawHit || !hit) return; // Don't extend a body selection into read-only footnote content. - if (!useActiveSurfaceHitTest && isFootnoteBlockId(rawHit.blockId)) return; + if (!useActiveSurfaceHitTest && isRenderedNoteBlockId(rawHit.blockId)) return; const doc = editor.state?.doc; if (!doc) return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 8c392e3fe0..4cc2039971 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -197,6 +197,33 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); }); + it('activates a note session on direct endnote fragment click', () => { + const fragmentEl = document.createElement('span'); + fragmentEl.setAttribute('data-block-id', 'endnote-1-0'); + const nestedEl = document.createElement('span'); + fragmentEl.appendChild(nestedEl); + viewportHost.appendChild(fragmentEl); + + const PointerEventImpl = getPointerEventImpl(); + nestedEl.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 16, + clientY: 12, + } as PointerEventInit), + ); + + expect(activateRenderedNoteSession).toHaveBeenCalledWith( + { storyType: 'endnote', noteId: '1' }, + expect.objectContaining({ clientX: 16, clientY: 12 }), + ); + expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + }); + it('activates the note session and syncs the tracked-change bubble on footnote clicks', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d1880d6cf9..ab237f8bc1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -98,6 +98,7 @@ const { setOptions: vi.fn(), commands: { setTextSelection: vi.fn(), + setCursorById: vi.fn(() => true), }, state: { doc: { @@ -2735,6 +2736,77 @@ describe('PresentationEditor', () => { }; }; + const prepareEndnoteEditor = async () => { + mockIncrementalLayout.mockResolvedValueOnce({ + layout: { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + numberText: '1', + size: { w: 612, h: 792 }, + fragments: [], + margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, + }, + ], + }, + measures: [], + }); + + mockEditorConverterStore.current = { + ...mockEditorConverterStore.current, + endnotes: [ + { + id: '1', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Lazy endnote session' }] }], + }, + ], + convertedXml: { + 'word/endnotes.xml': { + elements: [ + { + name: 'w:endnotes', + elements: [ + { + name: 'w:endnote', + attributes: { 'w:id': '1' }, + elements: [{ name: 'w:p', elements: [] }], + }, + ], + }, + ], + }, + }, + }; + + editor = new PresentationEditor({ + element: container, + documentId: 'endnote-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement; + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 1000, + right: 800, + bottom: 1000, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const endnoteFragment = document.createElement('span'); + endnoteFragment.setAttribute('data-block-id', 'endnote-1-0'); + viewport.appendChild(endnoteFragment); + + return { viewport, endnoteFragment }; + }; + it('activates a note editing session without enabling hidden-host header/footer rollout', async () => { const { sessionEditor } = await activateFootnoteSession(); @@ -2794,6 +2866,48 @@ describe('PresentationEditor', () => { inline: 'nearest', }); }); + + it('activates an inactive endnote story before routing tracked-change navigation', async () => { + const { viewport } = await prepareEndnoteEditor(); + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + + const renderedChange = document.createElement('span'); + renderedChange.dataset.trackChangeId = 'tc-endnote-1'; + renderedChange.dataset.storyKey = 'en:1'; + renderedChange.scrollIntoView = vi.fn(); + vi.spyOn(renderedChange, 'getBoundingClientRect').mockReturnValue({ + left: 140, + top: 720, + width: 20, + height: 12, + right: 160, + bottom: 732, + x: 140, + y: 720, + toJSON: () => ({}), + } as DOMRect); + page.appendChild(renderedChange); + viewport.appendChild(page); + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-endnote-1', + story: { kind: 'story', storyType: 'endnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + await vi.waitFor(() => expect(createdStoryEditors.length).toBeGreaterThanOrEqual(2)); + + const sessionEditor = createdStoryEditors.at(-1)?.editor; + expect(sessionEditor?.commands.setCursorById).toHaveBeenCalledWith('tc-endnote-1', { + preferredActiveThreadId: 'tc-endnote-1', + }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + expect(renderedChange.scrollIntoView).not.toHaveBeenCalled(); + }); }); describe('pageStyleUpdate event listener', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 94218d61e8..c6be222601 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -361,6 +361,10 @@ export interface EditorWithConverter extends Editor { id: string; content?: unknown[]; }>; + endnotes?: Array<{ + id: string; + content?: unknown[]; + }>; }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js index 75b7f2e94f..1d8bcb1913 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -2,21 +2,24 @@ import { defaultNodeListHandler } from './docxImporter'; import { carbonCopy } from '../../../utilities/carbonCopy.js'; /** - * Remove w:footnoteRef placeholders from converted footnote content. - * In OOXML footnotes, the first run often includes a w:footnoteRef marker which - * Word uses to render the footnote number. We render numbering ourselves. + * Remove w:footnoteRef / w:endnoteRef placeholders from converted note content. + * In OOXML notes, the first run often includes a reference marker which Word + * uses to render the display number. We render numbering ourselves. * * @param {Array} nodes * @returns {Array} */ -const stripFootnoteMarkerNodes = (nodes) => { +const stripNoteMarkerNodes = (nodes) => { if (!Array.isArray(nodes) || nodes.length === 0) return nodes; const walk = (list) => { if (!Array.isArray(list) || list.length === 0) return; for (let i = list.length - 1; i >= 0; i--) { const node = list[i]; if (!node) continue; - if (node.type === 'passthroughInline' && node.attrs?.originalName === 'w:footnoteRef') { + if ( + node.type === 'passthroughInline' && + (node.attrs?.originalName === 'w:footnoteRef' || node.attrs?.originalName === 'w:endnoteRef') + ) { list.splice(i, 1); continue; } @@ -109,7 +112,7 @@ function importNoteEntries({ path: [el], }); - const stripped = stripFootnoteMarkerNodes(converted); + const stripped = stripNoteMarkerNodes(converted); results.push({ id, type, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 4e4d18e725..6f44834272 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -25,6 +25,7 @@ import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; import { tabNodeEntityHandler } from './tabImporter.js'; import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js'; +import { endnoteReferenceHandlerEntity } from './endnoteReferenceImporter.js'; import { tableNodeHandlerEntity } from './tableImporter.js'; import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js'; import { indexHandlerEntity, indexEntryHandlerEntity } from './indexImporter.js'; @@ -242,6 +243,7 @@ export const defaultNodeListHandler = () => { trackChangeNodeHandlerEntity, tableNodeHandlerEntity, footnoteReferenceHandlerEntity, + endnoteReferenceHandlerEntity, tabNodeEntityHandler, tableOfContentsHandlerEntity, indexHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js new file mode 100644 index 0000000000..bd254029d4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js @@ -0,0 +1,7 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/w/endnoteReference/endnoteReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const endnoteReferenceHandlerEntity = generateV2HandlerEntity('endnoteReferenceHandler', translator); diff --git a/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js b/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js index 90fffdc7eb..273487ba2c 100644 --- a/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js +++ b/packages/super-editor/src/editors/v1/tests/import/footnotesImporter.test.js @@ -80,4 +80,43 @@ describe('footnotes import', () => { const types = collectNodeTypes(result.pmDoc); expect(types).toContain('footnoteReference'); }); + + it('imports w:endnoteReference and loads matching endnotes.xml entry', () => { + const documentXml = + '' + + '' + + '' + + 'Hello' + + '' + + '' + + '' + + ''; + + const endnotesXml = + '' + + '' + + 'Endnote text' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(documentXml), + 'word/endnotes.xml': parseXmlToJson(endnotesXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), + }; + + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + const result = createDocumentJson(docx, converter, editor); + expect(result).toBeTruthy(); + + expect(Array.isArray(result.endnotes)).toBe(true); + const endnote = result.endnotes.find((note) => note?.id === '1'); + expect(endnote).toBeTruthy(); + expect(Array.isArray(endnote.content)).toBe(true); + expect(extractPlainText(endnote.content)).toBe('Endnote text'); + + const types = collectNodeTypes(result.pmDoc); + expect(types).toContain('endnoteReference'); + }); }); diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 2a5d0307c9..0a0f55411b 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -104,10 +104,22 @@ const HrbrFieldsLayerStub = stubComponent('HrbrFieldsLayer'); const AiLayerStub = stubComponent('AiLayer'); const HtmlViewerStub = stubComponent('HtmlViewer'); +const createTrackedChangeIndexStub = () => ({ + subscribe: vi.fn(() => () => {}), + getAll: vi.fn(() => []), + get: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + dispose: vi.fn(), +}); + +const getTrackedChangeIndexMock = vi.fn(() => createTrackedChangeIndexStub()); + // Mock @superdoc/super-editor with stubs and PresentationEditor class vi.mock('@superdoc/super-editor', () => ({ SuperEditor: SuperEditorStub, AIWriter: AIWriterStub, + getTrackedChangeIndex: getTrackedChangeIndexMock, PresentationEditor: class PresentationEditorMock { static getInstance(documentId) { return mockState.instances.get(documentId); @@ -387,6 +399,8 @@ describe('SuperDoc.vue', () => { useSelectionMock.mockClear(); useAiMock.mockClear(); useSelectedTextMock.mockClear(); + getTrackedChangeIndexMock.mockClear(); + getTrackedChangeIndexMock.mockImplementation(() => createTrackedChangeIndexStub()); mockState.instances.clear(); // Make RAF synchronous in tests — jsdom has no rendering loop, and @@ -1285,6 +1299,7 @@ describe('SuperDoc.vue', () => { expect(doc.setPresentationEditor).toHaveBeenCalledWith(presentationEditor); expect(presentationEditor.setContextMenuDisabled).toHaveBeenCalledWith(true); expect(presentationEditor.on).toHaveBeenCalledWith('commentPositions', expect.any(Function)); + expect(getTrackedChangeIndexMock).toHaveBeenCalledWith(editor); }); it('forwards header/footer presentation events through the public update callbacks', async () => { diff --git a/packages/superdoc/src/components/CommentsLayer/commentsList/commentsList.vue b/packages/superdoc/src/components/CommentsLayer/commentsList/commentsList.vue index 64242abb56..c887c92ea6 100644 --- a/packages/superdoc/src/components/CommentsLayer/commentsList/commentsList.vue +++ b/packages/superdoc/src/components/CommentsLayer/commentsList/commentsList.vue @@ -1,6 +1,5 @@ diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 53ef9ece9f..a0aa67e06d 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -5,16 +5,43 @@ type SuperDocConfig = ConstructorParameters[0]; type SuperDocInstance = InstanceType; type SuperDocReadyPayload = Parameters>[0]; type OverrideType = 'markdown' | 'html' | 'text'; +type StoryLocator = + | { kind: 'story'; storyType: 'body' } + | { kind: 'story'; storyType: 'headerFooterPart'; refId: string } + | { kind: 'story'; storyType: 'footnote' | 'endnote'; noteId: string }; type ContentOverrideInput = { contentOverride?: string; overrideType?: OverrideType; }; +type BehaviorHarnessCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; +type BehaviorHarnessApi = { + getActiveStorySession: () => StoryLocator | null; + getActiveStoryText: () => string | null; + getBodyStoryText: () => string | null; + getCommentsSnapshot: () => BehaviorHarnessCommentSnapshot[]; + getEditorCommentPositions: () => Record; + getActiveCommentId: () => string | null; +}; type HarnessWindow = Window & typeof globalThis & { superdocReady?: boolean; superdoc?: SuperDocInstance; editor?: unknown; + behaviorHarness?: BehaviorHarnessApi; behaviorHarnessInit?: (input?: ContentOverrideInput) => void; }; @@ -43,6 +70,63 @@ if (!showCaret) { } let instance: SuperDocInstance | null = null; +const commentsPanel = document.querySelector('#comments-panel'); + +function getEditorText(editor: any): string | null { + const state = editor?.state; + const doc = state?.doc; + if (!doc || typeof doc.textBetween !== 'function' || typeof doc.content?.size !== 'number') return null; + return doc.textBetween(0, doc.content.size, '\n', '\n'); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function buildBehaviorHarnessApi(): BehaviorHarnessApi { + return { + getActiveStorySession: () => { + const session = (harnessWindow.editor as any)?.presentationEditor + ?.getStorySessionManager?.() + ?.getActiveSession?.(); + return session?.locator ?? null; + }, + getActiveStoryText: () => { + const activeEditor = (harnessWindow.editor as any)?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor || activeEditor === harnessWindow.editor) return null; + return getEditorText(activeEditor); + }, + getBodyStoryText: () => getEditorText(harnessWindow.editor), + getCommentsSnapshot: () => { + const comments = (harnessWindow.superdoc as any)?.commentsStore?.commentsList ?? []; + return comments.map((comment: any) => { + const raw = typeof comment?.getValues === 'function' ? comment.getValues() : comment; + return cloneJson({ + commentId: raw?.commentId, + importedId: raw?.importedId, + trackedChange: raw?.trackedChange === true, + trackedChangeText: raw?.trackedChangeText ?? null, + trackedChangeType: raw?.trackedChangeType ?? null, + trackedChangeDisplayType: raw?.trackedChangeDisplayType ?? null, + trackedChangeStory: raw?.trackedChangeStory ?? null, + trackedChangeStoryKind: raw?.trackedChangeStoryKind ?? null, + trackedChangeStoryLabel: raw?.trackedChangeStoryLabel ?? '', + trackedChangeAnchorKey: raw?.trackedChangeAnchorKey ?? null, + deletedText: raw?.deletedText ?? null, + resolvedTime: raw?.resolvedTime ?? null, + }); + }); + }, + getEditorCommentPositions: () => { + const positions = (harnessWindow.superdoc as any)?.commentsStore?.editorCommentPositions ?? {}; + return cloneJson(positions); + }, + getActiveCommentId: () => { + const activeComment = (harnessWindow.superdoc as any)?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }, + }; +} function applyContentOverride(config: SuperDocConfig, input?: ContentOverrideInput) { if (!input?.contentOverride || !input?.overrideType) return; @@ -78,10 +162,15 @@ function init(file?: File, content?: ContentOverrideInput) { telemetry: { enabled: false }, onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; + if (comments === 'panel' && commentsPanel) { + commentsPanel.replaceChildren(); + superdoc.addCommentsList(commentsPanel); + } superdoc.activeEditor.on('create', (payload: unknown) => { if (!payload || typeof payload !== 'object' || !('editor' in payload)) return; harnessWindow.editor = (payload as { editor: unknown }).editor; }); + harnessWindow.behaviorHarness = buildBehaviorHarnessApi(); harnessWindow.superdocReady = true; }, }; @@ -112,6 +201,14 @@ function init(file?: File, content?: ContentOverrideInput) { // Comments if (comments === 'on' || comments === 'panel') { config.comments = { visible: true }; + if (comments === 'panel') { + config.modules = { + ...(config.modules ?? {}), + comments: { + ...((config.modules as Record | undefined)?.comments as Record | undefined), + }, + }; + } } else if (comments === 'readonly') { config.comments = { visible: true, readOnly: true }; } else if (comments === 'disabled') { @@ -143,6 +240,10 @@ function init(file?: File, content?: ContentOverrideInput) { } instance = new SuperDoc(config); + if (commentsPanel) { + commentsPanel.classList.toggle('is-visible', comments === 'panel'); + if (comments !== 'panel') commentsPanel.replaceChildren(); + } if (!showSelection) { const style = document.createElement('style'); diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index 278f972128..6b24facabb 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -3,7 +3,11 @@ import type { TextAddress, SelectionTarget, MatchContext, + StoryLocator, TrackChangeType, + TrackChangesAcceptInput, + TrackChangesListInput, + TrackChangesRejectInput, CommentsListResult, TrackChangesListResult, TextMutationReceipt, @@ -320,10 +324,7 @@ export async function deleteText( }); } -export async function listTrackChanges( - page: Page, - query: { limit?: number; offset?: number; type?: TrackChangeType } = {}, -): Promise { +export async function listTrackChanges(page: Page, query: TrackChangesListInput = {}): Promise { return page.evaluate((input) => { const result = (window as any).editor.doc.trackChanges.list(input); if (Array.isArray(result?.changes)) { @@ -376,16 +377,24 @@ export async function listSeparate( return invokeListMutation(page, 'separate', input, options) as Promise; } -export async function acceptTrackChange(page: Page, input: { id: string }): Promise { +export async function acceptTrackChange(page: Page, input: TrackChangesAcceptInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'accept', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'accept', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } -export async function rejectTrackChange(page: Page, input: { id: string }): Promise { +export async function rejectTrackChange(page: Page, input: TrackChangesRejectInput): Promise { await page.evaluate( - (payload) => (window as any).editor.doc.trackChanges.decide({ decision: 'reject', target: { id: payload.id } }), + (payload) => + (window as any).editor.doc.trackChanges.decide({ + decision: 'reject', + target: payload.story ? { id: payload.id, story: payload.story } : { id: payload.id }, + }), input, ); } diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts new file mode 100644 index 0000000000..4896929592 --- /dev/null +++ b/tests/behavior/helpers/story-fixtures.ts @@ -0,0 +1,269 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { StoryLocator } from '@superdoc/document-api'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../..'); +const editorFixtureRoot = path.resolve(repoRoot, 'packages/super-editor/src/editors/v1/tests/data'); +const generatedFixtureRoot = path.resolve(os.tmpdir(), `superdoc-behavior-story-fixtures-${process.pid}`); + +const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + +function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(targetPath: string, contents: string): void { + ensureDir(path.dirname(targetPath)); + fs.writeFileSync(targetPath, contents); +} + +function run(command: string, args: string[], cwd?: string): void { + execFileSync(command, args, { + cwd, + stdio: 'ignore', + }); +} + +function rebuildDocx(sourceName: string, targetPath: string, replacements: Record): void { + const sourcePath = path.resolve(editorFixtureRoot, sourceName); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superdoc-behavior-story-fixture-build-')); + try { + run('unzip', ['-qq', sourcePath, '-d', tempRoot]); + for (const [relativePath, contents] of Object.entries(replacements)) { + writeFile(path.resolve(tempRoot, relativePath), contents); + } + + ensureDir(path.dirname(targetPath)); + fs.rmSync(targetPath, { force: true }); + run('zip', ['-q', '-X', '-r', targetPath, '.'], tempRoot); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function ensureGeneratedFixture(filename: string, sourceName: string, replacements: Record): string { + const targetPath = path.resolve(generatedFixtureRoot, filename); + if (!fs.existsSync(targetPath)) { + rebuildDocx(sourceName, targetPath, replacements); + } + return targetPath; +} + +function documentXmlWithEndnotes(): string { + return ` + + + + Simple endnote text + + with longer content + + + + + + + + + + + + +`; +} + +function endnotesXml(): string { + return ` + + + + + + + + + + + + This is a simple endnote + + + + + + + A longer endnote + + + + And more endnote content + + + +`; +} + +function storyOnlyTrackedChangeDocumentXml(): string { + return ` + + + + Body review anchor + with footnote + + and endnote + + . + + + + + + + + + + + +`; +} + +function trackedHeaderXml(): string { + return ` + + + + Header base + + HDR_TC_ALPHA + + + +`; +} + +function trackedFooterXml(): string { + return ` + + + + Footer base + + FTR_TC_BRAVO + + + +`; +} + +function trackedFootnotesXml(): string { + return ` + + + + + + + + + + + + Footnote base + + FN_TC_CHARLIE + + + + +`; +} + +function trackedEndnotesXml(): string { + return ` + + + + + + + + + + + + Endnote base + + EN_TC_DELTA + + + + +`; +} + +export const H_F_NORMAL_DOC_PATH = path.resolve(editorFixtureRoot, 'h_f-normal.docx'); +export const LONGER_HEADER_SIGN_AREA_DOC_PATH = path.resolve(editorFixtureRoot, 'longer-header-sign-area.docx'); +export const BASIC_FOOTNOTES_DOC_PATH = path.resolve(editorFixtureRoot, 'basic-footnotes.docx'); +export const BASIC_ENDNOTES_DOC_PATH = ensureGeneratedFixture('basic-endnotes.docx', 'h_f-normal.docx', { + 'word/document.xml': documentXmlWithEndnotes(), + 'word/endnotes.xml': endnotesXml(), +}); +export const STORY_ONLY_TRACKED_CHANGES_DOC_PATH = ensureGeneratedFixture( + 'story-only-tracked-changes.docx', + 'h_f-normal.docx', + { + 'word/document.xml': storyOnlyTrackedChangeDocumentXml(), + 'word/header2.xml': trackedHeaderXml(), + 'word/footer2.xml': trackedFooterXml(), + 'word/footnotes.xml': trackedFootnotesXml(), + 'word/endnotes.xml': trackedEndnotesXml(), + }, +); + +export type StoryTrackedChangeFixtureEntry = { + surface: 'header' | 'footer' | 'footnote' | 'endnote'; + story: StoryLocator; + storyKind: 'headerFooter' | 'footnote' | 'endnote'; + storyLabel?: string; + storyLabelPrefix?: string; + excerpt: string; +}; + +export function readStoryOnlyTrackedChangesManifest(): StoryTrackedChangeFixtureEntry[] { + return [ + { + surface: 'header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId8' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'HDR_TC_ALPHA', + }, + { + surface: 'footer', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId10' }, + storyKind: 'headerFooter', + storyLabelPrefix: 'Header/Footer', + excerpt: 'FTR_TC_BRAVO', + }, + { + surface: 'footnote', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + excerpt: 'FN_TC_CHARLIE', + }, + { + surface: 'endnote', + story: { kind: 'story', storyType: 'endnote', noteId: '1' }, + storyKind: 'endnote', + storyLabel: 'Endnote 1', + excerpt: 'EN_TC_DELTA', + }, + ]; +} diff --git a/tests/behavior/helpers/story-surfaces.ts b/tests/behavior/helpers/story-surfaces.ts new file mode 100644 index 0000000000..3c747bae8e --- /dev/null +++ b/tests/behavior/helpers/story-surfaces.ts @@ -0,0 +1,266 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; + +type NoteStoryType = 'footnote' | 'endnote'; + +function normalizeText(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +async function getTextPointInternal( + locator: Locator, + { + searchText, + offsetWithinMatch = 0, + align = 'center', + }: { + searchText: string; + offsetWithinMatch?: number; + align?: 'center' | 'boundary'; + }, +) { + const point = await locator.evaluate( + ( + element, + params: { + searchText: string; + offsetWithinMatch: number; + align: 'center' | 'boundary'; + }, + ) => { + const fullText = element.textContent ?? ''; + const matchStart = fullText.indexOf(params.searchText); + if (matchStart < 0) return null; + + const targetOffset = Math.max(0, Math.min(fullText.length, matchStart + params.offsetWithinMatch)); + const doc = element.ownerDocument; + if (!doc) return null; + + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = targetOffset; + let currentNode = walker.nextNode() as Text | null; + while (currentNode) { + const textLength = currentNode.textContent?.length ?? 0; + if (remaining <= textLength) { + const range = doc.createRange(); + const clampedOffset = Math.max(0, Math.min(textLength, remaining)); + range.setStart(currentNode, clampedOffset); + range.setEnd( + currentNode, + params.align === 'center' ? Math.min(textLength, clampedOffset + params.searchText.length) : clampedOffset, + ); + + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) { + const fallbackRect = currentNode.parentElement?.getBoundingClientRect(); + if (!fallbackRect) return null; + return { + x: + params.align === 'center' + ? fallbackRect.left + fallbackRect.width / 2 + : fallbackRect.left + Math.min(2, fallbackRect.width / 2), + y: fallbackRect.top + fallbackRect.height / 2, + }; + } + + return { + x: params.align === 'center' ? rect.left + rect.width / 2 : rect.left + 0.5, + y: rect.top + rect.height / 2, + }; + } + + remaining -= textLength; + currentNode = walker.nextNode() as Text | null; + } + + return null; + }, + { searchText, offsetWithinMatch, align }, + ); + + expect(point).toBeTruthy(); + return point!; +} + +export async function getRenderedTextPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'center' }); +} + +export async function getTextBoundaryPoint( + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + return getTextPointInternal(locator, { searchText, offsetWithinMatch, align: 'boundary' }); +} + +export async function clickTextBoundary( + page: Page, + locator: Locator, + searchText: string, + offsetWithinMatch = 0, +): Promise<{ x: number; y: number }> { + const point = await getTextBoundaryPoint(locator, searchText, offsetWithinMatch); + await page.mouse.click(point.x, point.y); + return point; +} + +export async function doubleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.dblclick(point.x, point.y); +} + +export async function tripleClickWord(page: Page, locator: Locator, word: string): Promise { + const point = await getRenderedTextPoint(locator, word); + await page.mouse.click(point.x, point.y, { clickCount: 3 }); +} + +export function getHeaderSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-header').nth(pageIndex); +} + +export function getFooterSurfaceLocator(page: Page, pageIndex = 0): Locator { + return page.locator('.superdoc-page-footer').nth(pageIndex); +} + +export function getHeaderEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getFooterEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"] .ProseMirror').first(); +} + +export function getNoteSurfaceLocator(page: Page, input: { storyType: NoteStoryType; noteId: string }): Locator { + const prefix = input.storyType === 'endnote' ? 'endnote' : 'footnote'; + return page + .locator( + `[data-block-id^="${prefix}-${input.noteId}-"], [data-block-id^="__sd_semantic_${prefix}-${input.noteId}-"]`, + ) + .first(); +} + +export function getActiveNoteEditorLocator(page: Page): Locator { + return page.locator('.presentation-editor__story-hidden-host[data-story-kind="note"] .ProseMirror').first(); +} + +export async function getActiveStorySession(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStorySession === 'function') { + return harness.getActiveStorySession(); + } + + const session = (window as any).editor?.presentationEditor?.getStorySessionManager?.()?.getActiveSession?.(); + return session?.locator ?? null; + }); +} + +export async function waitForActiveStory( + page: Page, + expected: + | null + | Partial + | { + match: (story: StoryLocator | null) => boolean; + description: string; + }, +): Promise { + if (expected === null) { + await expect.poll(() => getActiveStorySession(page)).toBeNull(); + return; + } + + if ('match' in expected) { + await expect + .poll(async () => expected.match(await getActiveStorySession(page)), { message: expected.description }) + .toBe(true); + return; + } + + await expect.poll(() => getActiveStorySession(page)).toEqual(expect.objectContaining(expected)); +} + +export async function getActiveStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveStoryText === 'function') { + return harness.getActiveStoryText(); + } + + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + if (!activeEditor) return null; + return activeEditor.state?.doc?.textBetween?.(0, activeEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function getBodyStoryText(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getBodyStoryText === 'function') { + return harness.getBodyStoryText(); + } + + const bodyEditor = (window as any).editor; + return bodyEditor?.state?.doc?.textBetween?.(0, bodyEditor.state.doc.content.size, '\n', '\n') ?? null; + }); +} + +export async function activateHeader(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const header = getHeaderSurfaceLocator(superdoc.page, pageIndex); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await header.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return header; +} + +export async function activateFooter(superdoc: SuperDocFixture, pageIndex = 0): Promise { + const footer = getFooterSurfaceLocator(superdoc.page, pageIndex); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + const box = await footer.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { storyType: 'headerFooterPart' }); + return footer; +} + +export async function activateNote( + superdoc: SuperDocFixture, + input: { storyType: NoteStoryType; noteId: string; expectedText?: string }, +): Promise { + const note = getNoteSurfaceLocator(superdoc.page, input); + await note.scrollIntoViewIfNeeded(); + await note.waitFor({ state: 'visible', timeout: 15_000 }); + if (input.expectedText) { + await expect(note).toContainText(input.expectedText); + } + + const box = await note.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: input.storyType, + noteId: input.noteId, + }); + return note; +} + +export async function expectActiveStoryText(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toBe(normalizeText(expectedText)); +} + +export async function expectActiveStoryTextToContain(page: Page, expectedText: string): Promise { + await expect.poll(async () => normalizeText(await getActiveStoryText(page))).toContain(normalizeText(expectedText)); +} diff --git a/tests/behavior/helpers/story-tracked-changes.ts b/tests/behavior/helpers/story-tracked-changes.ts new file mode 100644 index 0000000000..2513b16035 --- /dev/null +++ b/tests/behavior/helpers/story-tracked-changes.ts @@ -0,0 +1,244 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import type { StoryLocator, TrackChangeInfo, TrackChangeType } from '@superdoc/document-api'; +import { storyLocatorToKey } from '@superdoc/document-api'; +import type { SuperDocFixture } from '../fixtures/superdoc.js'; +import { listTrackChanges } from './document-api.js'; + +type TrackedChangeCommentSnapshot = { + commentId?: string; + importedId?: string; + trackedChange?: boolean; + trackedChangeText?: string | null; + trackedChangeType?: string | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string; + trackedChangeAnchorKey?: string | null; + deletedText?: string | null; + resolvedTime?: number | null; +}; + +function normalizeTrackedChangeExcerpt(change: TrackChangeInfo): string { + return String(change.excerpt ?? '').trim(); +} + +function mapTrackChangeTypeToCommentType(type: TrackChangeType | undefined): string | null { + if (!type) return null; + if (type === 'insert') return 'trackInsert'; + if (type === 'delete') return 'trackDelete'; + return 'trackFormat'; +} + +function sameStory(left: StoryLocator | null | undefined, right: StoryLocator | null | undefined): boolean { + if (!left || !right) return false; + return storyLocatorToKey(left) === storyLocatorToKey(right); +} + +function trackedChangeIdMatches(comment: TrackedChangeCommentSnapshot, id: string): boolean { + const canonicalId = String(id); + if (comment.commentId != null && String(comment.commentId) === canonicalId) return true; + if (comment.importedId != null && String(comment.importedId) === canonicalId) return true; + return comment.trackedChangeAnchorKey?.endsWith(`::${canonicalId}`) === true; +} + +export async function getCommentsSnapshot(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getCommentsSnapshot !== 'function') { + throw new Error('behaviorHarness.getCommentsSnapshot is unavailable.'); + } + + return harness.getCommentsSnapshot(); + }); +} + +export async function getEditorCommentPositions(page: Page): Promise> { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getEditorCommentPositions !== 'function') { + throw new Error('behaviorHarness.getEditorCommentPositions is unavailable.'); + } + + return harness.getEditorCommentPositions(); + }); +} + +export async function getActiveCommentId(page: Page): Promise { + return page.evaluate(() => { + const harness = (window as any).behaviorHarness; + if (typeof harness?.getActiveCommentId === 'function') { + return harness.getActiveCommentId(); + } + + const activeComment = (window as any).superdoc?.commentsStore?.activeComment; + return activeComment == null ? null : String(activeComment); + }); +} + +export async function findTrackedChange( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const result = await listTrackChanges(page, { in: input.story, ...(input.type ? { type: input.type } : {}) }); + const matched = result.changes.find((change) => { + if (input.id && change.id !== input.id) return false; + if (input.excerpt && !normalizeTrackedChangeExcerpt(change).includes(input.excerpt)) return false; + return true; + }); + + if (!matched) { + throw new Error( + `No tracked change found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export async function findTrackedChangeComment( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const commentType = mapTrackChangeTypeToCommentType(input.type); + const comments = await getCommentsSnapshot(page); + const matched = comments.find((comment) => { + if (comment.trackedChange !== true) return false; + if (!sameStory(comment.trackedChangeStory ?? null, input.story)) return false; + if (input.id && !trackedChangeIdMatches(comment, input.id)) return false; + if (commentType && comment.trackedChangeType !== commentType) return false; + if (input.excerpt) { + const haystack = [comment.trackedChangeText, comment.deletedText].filter(Boolean).join(' '); + if (!haystack.includes(input.excerpt)) return false; + } + return true; + }); + + if (!matched) { + throw new Error( + `No tracked-change comment found for ${storyLocatorToKey(input.story)} (${input.id ?? input.excerpt ?? input.type ?? 'any'}).`, + ); + } + + return matched; +} + +export function getTrackedChangeDialogLocator( + page: Page, + input: { excerpt?: string | null; activeOnly?: boolean }, +): Locator { + const selector = input.activeOnly ? '.comments-dialog.is-active' : '.comments-dialog'; + if (input.excerpt) { + return page.locator(selector, { hasText: input.excerpt }).first(); + } + + return page.locator(selector).first(); +} + +async function setActiveTrackedChangeComment(page: Page, comment: TrackedChangeCommentSnapshot): Promise { + const preferredId = comment.commentId ?? comment.importedId; + if (preferredId == null) { + throw new Error('Tracked-change comment is missing commentId/importedId.'); + } + + const activeId = String(preferredId); + await page.evaluate((commentId) => { + const store = (window as any).superdoc?.commentsStore; + store?.$patch?.({ activeComment: commentId }); + }, activeId); + + await expect.poll(() => getActiveCommentId(page)).toBe(activeId); + return activeId; +} + +export async function activateTrackedChangeDialog( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ change: TrackChangeInfo; comment: TrackedChangeCommentSnapshot; dialog: Locator }> { + const change = await findTrackedChange(superdoc.page, input); + const comment = await findTrackedChangeComment(superdoc.page, { + story: input.story, + ...(input.id ? { id: change.id } : {}), + ...(input.excerpt ? { excerpt: input.excerpt } : {}), + ...(input.type ? { type: input.type } : {}), + }); + await setActiveTrackedChangeComment(superdoc.page, comment); + const dialog = getTrackedChangeDialogLocator(superdoc.page, { + excerpt: input.excerpt ?? change.excerpt ?? comment.trackedChangeText ?? comment.deletedText ?? null, + activeOnly: true, + }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.click({ position: { x: 12, y: 12 } }); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + return { change, comment, dialog }; +} + +export async function acceptTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').first().click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function rejectTrackedChangeFromSidebar( + superdoc: SuperDocFixture, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise { + const { change, dialog } = await activateTrackedChangeDialog(superdoc, input); + await dialog.locator('.comment-header .overflow-menu__icon').nth(1).click({ force: true }); + await superdoc.waitForStable(); + return change; +} + +export async function getTrackedChangeAnchorPosition( + page: Page, + input: { + story: StoryLocator; + id?: string; + excerpt?: string; + type?: TrackChangeType; + }, +): Promise<{ key: string; bounds: Record; pageIndex: number | null } | null> { + const comment = await findTrackedChangeComment(page, input); + const key = comment.trackedChangeAnchorKey ?? comment.commentId ?? comment.importedId; + if (!key) return null; + + const positions = await getEditorCommentPositions(page); + const entry = positions[key]; + if (!entry?.bounds) return null; + + return { + key: String(key), + bounds: entry.bounds, + pageIndex: Number.isFinite(entry.pageIndex) ? Number(entry.pageIndex) : null, + }; +} diff --git a/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts index 23392f5fdb..7b64eb6de1 100644 --- a/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts +++ b/tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts @@ -1,13 +1,6 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve( - __dirname, - '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', -); +import { BASIC_FOOTNOTES_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; test.use({ config: { diff --git a/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts new file mode 100644 index 0000000000..4298190e70 --- /dev/null +++ b/tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { getActiveCommentId, findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; +import { activateFooter, activateHeader } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest().filter( + (entry) => entry.surface === 'header' || entry.surface === 'footer', +); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + useHiddenHostForStoryParts: true, + showCaret: true, + showSelection: true, + }, +}); + +async function clearActiveComment(page: Page) { + await page.evaluate(() => { + (window as any).superdoc?.commentsStore?.$patch?.({ activeComment: null }); + }); +} + +async function dispatchPointerDown(locator: import('@playwright/test').Locator): Promise { + await locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: rect.left + Math.min(8, Math.max(rect.width / 2, 1)), + clientY: rect.top + Math.min(8, Math.max(rect.height / 2, 1)), + }), + ); + }); +} + +for (const entry of STORY_CASES) { + test(`${entry.surface} tracked-change text activates its bubble and a body click clears it`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const surface = entry.surface === 'header' ? await activateHeader(superdoc) : await activateFooter(superdoc); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await clearActiveComment(superdoc.page); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + + await dispatchPointerDown(surface.locator('[data-track-change-id]', { hasText: entry.excerpt }).first()); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBe(String(comment.commentId ?? comment.importedId)); + + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + await expect.poll(() => getActiveCommentId(superdoc.page)).toBeNull(); + }); +} diff --git a/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts index 17bbfda8cc..6daaf669f9 100644 --- a/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts +++ b/tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts @@ -1,13 +1,6 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const FOOTNOTE_DOC_PATH = path.resolve( - __dirname, - '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', -); +import { BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH } from '../../helpers/story-fixtures.js'; test.use({ config: { diff --git a/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts new file mode 100644 index 0000000000..86d18ec7d5 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { findTrackedChangeComment, getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + useHiddenHostForStoryParts: true, + }, +}); + +test('imported story-only tracked changes bootstrap sidebar threads for every non-body story', async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await assertDocumentApiReady(superdoc.page); + await superdoc.waitForStable(); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total).toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(0); + + const comments = await getCommentsSnapshot(superdoc.page); + expect(comments.filter((comment) => comment.trackedChange)).toHaveLength(STORY_CASES.length); + + for (const entry of STORY_CASES) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + + const comment = await findTrackedChangeComment(superdoc.page, { + story: entry.story, + excerpt: entry.excerpt, + }); + + expect(comment.trackedChangeStoryKind).toBe(entry.storyKind); + if (entry.storyLabel) { + expect(comment.trackedChangeStoryLabel).toBe(entry.storyLabel); + } else if (entry.storyLabelPrefix) { + expect(comment.trackedChangeStoryLabel ?? '').toContain(entry.storyLabelPrefix); + } + expect(comment.trackedChangeAnchorKey).toMatch(/^tc::/); + expect(comment.resolvedTime ?? null).toBeNull(); + } +}); diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts new file mode 100644 index 0000000000..1ebb019978 --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Locator, type Page, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { listTrackChanges } from '../../helpers/document-api.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { acceptTrackedChangeFromSidebar, rejectTrackedChangeFromSidebar } from '../../helpers/story-tracked-changes.js'; +import { + activateFooter, + activateHeader, + expectActiveStoryTextToContain, + getFooterSurfaceLocator, + getHeaderSurfaceLocator, + getNoteSurfaceLocator, +} from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + useHiddenHostForStoryParts: true, + }, +}); + +function getSurfaceLocator(page: Page, surface: (typeof STORY_CASES)[number]['surface']): Locator { + if (surface === 'header') return getHeaderSurfaceLocator(page); + if (surface === 'footer') return getFooterSurfaceLocator(page); + return getNoteSurfaceLocator(page, { + storyType: surface, + noteId: '1', + }); +} + +async function expectSurfaceExcerpt( + superdoc: SuperDocFixture, + entry: (typeof STORY_CASES)[number], + visible: boolean, +): Promise { + const surface = getSurfaceLocator(superdoc.page, entry.surface); + await surface.scrollIntoViewIfNeeded(); + if (visible) { + if (entry.surface === 'header') { + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + if (entry.surface === 'footer') { + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, entry.excerpt); + return; + } + + await expect(surface).toContainText(entry.excerpt); + return; + } + + await expect(surface).not.toContainText(entry.excerpt); +} + +for (const entry of STORY_CASES) { + test(`accept from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await acceptTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, true); + }); + + test(`reject from sidebar resolves only the ${entry.surface} tracked change and supports undo/redo`, async ({ + superdoc, + }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expectSurfaceExcerpt(superdoc, entry, true); + + await rejectTrackedChangeFromSidebar(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + + for (const otherEntry of STORY_CASES.filter((candidate) => candidate.surface !== entry.surface)) { + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: otherEntry.story })).total).toBe(1); + } + + await superdoc.undo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length); + await expectSurfaceExcerpt(superdoc, entry, true); + + await superdoc.redo(); + await superdoc.waitForStable(); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { in: entry.story })).total).toBe(0); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { in: 'all' })).total) + .toBe(STORY_CASES.length - 1); + await expectSurfaceExcerpt(superdoc, entry, false); + }); +} diff --git a/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts new file mode 100644 index 0000000000..09f847c9bf --- /dev/null +++ b/tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + readStoryOnlyTrackedChangesManifest, + STORY_ONLY_TRACKED_CHANGES_DOC_PATH, +} from '../../helpers/story-fixtures.js'; +import { activateTrackedChangeDialog } from '../../helpers/story-tracked-changes.js'; +import { getActiveStoryText, getBodyStoryText, waitForActiveStory } from '../../helpers/story-surfaces.js'; + +const STORY_CASES = readStoryOnlyTrackedChangesManifest(); + +test.use({ + config: { + comments: 'panel', + trackChanges: true, + useHiddenHostForStoryParts: true, + }, +}); + +for (const entry of STORY_CASES) { + test(`sidebar tracked-change dialog navigates into the ${entry.surface} story`, async ({ superdoc }) => { + await superdoc.loadDocument(STORY_ONLY_TRACKED_CHANGES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const { dialog } = await activateTrackedChangeDialog(superdoc, { + story: entry.story, + excerpt: entry.excerpt, + }); + + await waitForActiveStory(superdoc.page, entry.story); + await expect(dialog).toContainText(entry.excerpt); + await expect.poll(() => getActiveStoryText(superdoc.page)).toContain(entry.excerpt); + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); + }); +} diff --git a/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts index f5cd4b4910..25901817c0 100644 --- a/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts +++ b/tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts @@ -1,17 +1,10 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { expect, test, type Locator, type Page } from '../../fixtures/superdoc.js'; +import { + BASIC_FOOTNOTES_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const HEADER_DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); -const FOOTNOTE_DOC_PATH = path.resolve( - __dirname, - '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', -); - -test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); -test.skip(!fs.existsSync(FOOTNOTE_DOC_PATH), 'Footnote test document not available'); +const FOOTNOTE_DOC_PATH = BASIC_FOOTNOTES_DOC_PATH; test.use({ config: { diff --git a/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts new file mode 100644 index 0000000000..9f4833f3d0 --- /dev/null +++ b/tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { BASIC_ENDNOTES_DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateNote, + expectActiveStoryTextToContain, + getBodyStoryText, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; + +test.use({ + config: { + showCaret: true, + showSelection: true, + useHiddenHostForStoryParts: true, + }, +}); + +test('double-click rendered endnote to edit it through the presentation surface', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(BASIC_ENDNOTES_DOC_PATH); + await superdoc.waitForStable(); + + const bodyBefore = await getBodyStoryText(superdoc.page); + const endnote = await activateNote(superdoc, { + storyType: 'endnote', + noteId: '1', + expectedText: 'This is a simple endnote', + }); + + await waitForActiveStory(superdoc.page, { + kind: 'story', + storyType: 'endnote', + noteId: '1', + }); + + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' edited'); + await superdoc.waitForStable(); + await expect(endnote).toContainText('This is a simple endnote edited'); + + await superdoc.page.keyboard.press('Backspace'); + await superdoc.waitForStable(); + await expectActiveStoryTextToContain(superdoc.page, 'simple endnote edite'); + await expect(endnote).toContainText('This is a simple endnote edite'); + + expect(await getBodyStoryText(superdoc.page)).toBe(bodyBefore); +}); diff --git a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts index 6b132edf51..c59ae476ac 100644 --- a/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts +++ b/tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts @@ -1,13 +1,6 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve( - __dirname, - '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', -); +import { BASIC_FOOTNOTES_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; test.use({ config: { showCaret: true, showSelection: true } }); @@ -464,7 +457,7 @@ test('footnote caret placement supports inserts at the note start, inside a word await expectStoryText(superdoc.page, 'XThis is a simple footanote'); await expect(footnote).toContainText('XThis is a simple footanote'); - await superdoc.page.keyboard.press('End'); + await expectCaretAtClickBoundary(superdoc.page, footnote, 'footanote', 'footanote'.length); await superdoc.page.keyboard.insertText('!'); await superdoc.waitForStable(); await expectStoryText(superdoc.page, 'XThis is a simple footanote!'); diff --git a/tests/behavior/tests/headers/double-click-edit-header.spec.ts b/tests/behavior/tests/headers/double-click-edit-header.spec.ts index 74b841f845..a1351575c8 100644 --- a/tests/behavior/tests/headers/double-click-edit-header.spec.ts +++ b/tests/behavior/tests/headers/double-click-edit-header.spec.ts @@ -1,29 +1,35 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, expect } from '../../fixtures/superdoc.js'; +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; +import { + activateFooter, + activateHeader, + expectActiveStoryTextToContain, + getActiveStorySession, + waitForActiveStory, +} from '../../helpers/story-surfaces.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); test.use({ config: { useHiddenHostForStoryParts: true, showCaret: true, showSelection: true } }); -test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { - await superdoc.loadDocument(DOC_PATH); +async function exitToBody(superdoc: SuperDocFixture) { + await superdoc.page.keyboard.press('Escape'); await superdoc.waitForStable(); - // Header should be visible - const header = superdoc.page.locator('.superdoc-page-header').first(); - await header.waitFor({ state: 'visible', timeout: 15_000 }); + if (await getActiveStorySession(superdoc.page)) { + const bodyLine = superdoc.page.locator('.superdoc-line').first(); + await bodyLine.waitFor({ state: 'visible', timeout: 15_000 }); + await bodyLine.click(); + await superdoc.waitForStable(); + } - // Double-click at the header's coordinates (header has pointer-events:none, - // so we must use raw mouse to reach the viewport host's dblclick handler) - const box = await header.boundingBox(); - expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await waitForActiveStory(superdoc.page, null); +} + +test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); + await activateHeader(superdoc); + const storyHost = superdoc.page .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') .first(); @@ -33,14 +39,12 @@ test('double-click header to enter edit mode, type, and exit', async ({ superdoc await superdoc.page.keyboard.press('End'); await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); - await expect(header).toContainText('Edited'); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Press Escape to exit header edit mode - await superdoc.page.keyboard.press('Escape'); - await superdoc.waitForStable(); + await exitToBody(superdoc); - // After exiting, the static header is re-rendered with the edited content - await expect(header).toContainText('Edited'); + await activateHeader(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); await superdoc.snapshot('header-edited'); }); @@ -49,16 +53,7 @@ test('double-click footer to enter edit mode, type, and exit', async ({ superdoc await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); - // Footer should be visible — scroll into view first since it's at page bottom - const footer = superdoc.page.locator('.superdoc-page-footer').first(); - await footer.scrollIntoViewIfNeeded(); - await footer.waitFor({ state: 'visible', timeout: 15_000 }); - - // Double-click at the footer's coordinates - const box = await footer.boundingBox(); - expect(box).toBeTruthy(); - await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); - await superdoc.waitForStable(); + await activateFooter(superdoc); const storyHost = superdoc.page .locator('.presentation-editor__story-hidden-host[data-story-kind="headerFooter"]') @@ -68,14 +63,12 @@ test('double-click footer to enter edit mode, type, and exit', async ({ superdoc await superdoc.page.keyboard.press('End'); await superdoc.page.keyboard.insertText(' - Edited'); await superdoc.waitForStable(); - await expect(footer).toContainText('Edited'); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); - // Press Escape to exit footer edit mode - await superdoc.page.keyboard.press('Escape'); - await superdoc.waitForStable(); + await exitToBody(superdoc); - // After exiting, the static footer is re-rendered with the edited content - await expect(footer).toContainText('Edited'); + await activateFooter(superdoc); + await expectActiveStoryTextToContain(superdoc.page, 'Edited'); await superdoc.snapshot('footer-edited'); }); diff --git a/tests/behavior/tests/headers/header-footer-line-height.spec.ts b/tests/behavior/tests/headers/header-footer-line-height.spec.ts index d01e2cc95d..776d239e1a 100644 --- a/tests/behavior/tests/headers/header-footer-line-height.spec.ts +++ b/tests/behavior/tests/headers/header-footer-line-height.spec.ts @@ -1,12 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); test.use({ config: { useHiddenHostForStoryParts: true, showCaret: true, showSelection: true } }); test('header editor uses line-height 1, not the default 1.2', async ({ superdoc }) => { diff --git a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts index 49ea916249..ef6910c3cc 100644 --- a/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts +++ b/tests/behavior/tests/headers/header-footer-selection-overlay.spec.ts @@ -1,17 +1,10 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { expect, test } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); +import { LONGER_HEADER_SIGN_AREA_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; test.use({ config: { showSelection: true } }); -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); - async function enterHeaderFooterEditMode( page: Page, surfaceSelector: string, diff --git a/tests/behavior/tests/search/search-and-navigate.spec.ts b/tests/behavior/tests/search/search-and-navigate.spec.ts index f890e746e9..84e6d3aecd 100644 --- a/tests/behavior/tests/search/search-and-navigate.spec.ts +++ b/tests/behavior/tests/search/search-and-navigate.spec.ts @@ -1,19 +1,12 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); - -test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); +import { H_F_NORMAL_DOC_PATH as DOC_PATH } from '../../helpers/story-fixtures.js'; test('search and navigate to results in document', async ({ superdoc }) => { await superdoc.loadDocument(DOC_PATH); await superdoc.waitForStable(); // Search for text that spans across content - const query = 'works of the Licensed Material'; + const query = 'NetHack'; const matches = await superdoc.page.evaluate((q: string) => { return (window as any).editor?.commands?.search?.(q) ?? []; }, query); diff --git a/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts index 388fa7d22c..453b104655 100644 --- a/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts +++ b/tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts @@ -1,15 +1,10 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Locator, Page } from '@playwright/test'; import { test, expect } from '../../fixtures/superdoc.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const HEADER_DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); -const FOOTNOTE_DOC_PATH = path.resolve( - __dirname, - '../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx', -); +import { + BASIC_ENDNOTES_DOC_PATH as ENDNOTE_DOC_PATH, + BASIC_FOOTNOTES_DOC_PATH as FOOTNOTE_DOC_PATH, + LONGER_HEADER_SIGN_AREA_DOC_PATH as HEADER_DOC_PATH, +} from '../../helpers/story-fixtures.js'; test.use({ config: { @@ -194,8 +189,6 @@ test('body surface selection does not leak into visible footnotes', async ({ sup expect(selectionRects).toHaveLength(1); }); -test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); - test('active header supports double-click word selection and triple-click paragraph selection', async ({ superdoc, }) => { @@ -228,8 +221,6 @@ test('active header supports double-click word selection and triple-click paragr await expectParagraphSelection(superdoc.page, activeParagraphText, word.length); }); -test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull'); - test('active footer supports double-click word selection and triple-click paragraph selection', async ({ superdoc, }) => { @@ -300,3 +291,41 @@ test('active footnote supports double-click word selection and triple-click para await superdoc.waitForStable(); await expectParagraphSelection(superdoc.page, activeParagraphText, 'footnote'.length); }); + +test('active endnote supports double-click word selection and triple-click paragraph selection', async ({ + superdoc, + browserName, +}) => { + test.fixme( + browserName === 'firefox', + 'Headless Firefox does not yet persist hidden-host endnote edits through the behavior harness.', + ); + + await superdoc.loadDocument(ENDNOTE_DOC_PATH); + await superdoc.waitForStable(); + + const endnote = superdoc.page.locator('[data-block-id^="endnote-1-"]').first(); + await endnote.scrollIntoViewIfNeeded(); + await endnote.waitFor({ state: 'visible', timeout: 15_000 }); + + const endnoteBox = await endnote.boundingBox(); + expect(endnoteBox).toBeTruthy(); + await superdoc.page.mouse.dblclick(endnoteBox!.x + endnoteBox!.width / 2, endnoteBox!.y + endnoteBox!.height / 2); + await superdoc.waitForStable(); + + const activeParagraphText = await getActiveEditorText(superdoc.page); + expect(activeParagraphText).toBe('This is a simple endnote'); + + const point = await getWordClickPoint(endnote, 'endnote'); + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.dblclick(point.x, point.y); + await superdoc.waitForStable(); + await expectWordSelection(superdoc.page, 'endnote'); + + await superdoc.page.waitForTimeout(MULTI_CLICK_RESET_MS); + + await superdoc.page.mouse.click(point.x, point.y, { clickCount: 3 }); + await superdoc.waitForStable(); + await expectParagraphSelection(superdoc.page, activeParagraphText, 'endnote'.length); +}); From d2c14f991e1e709e8c40b63801174aee6720603d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 19:26:01 -0700 Subject: [PATCH 11/16] fix: types --- .../layout/EndnotesBuilder.ts | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts index 2504c27f4c..c4ec3c5b3b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts @@ -1,5 +1,5 @@ import type { EditorState } from 'prosemirror-state'; -import type { FlowBlock } from '@superdoc/contracts'; +import type { FlowBlock, Run as LayoutRun, TextRun } from '@superdoc/contracts'; import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; @@ -11,25 +11,9 @@ export type EndnoteConverterLike = { endnotes?: Array<{ id?: unknown; content?: unknown[] }>; }; -type Run = { - kind?: string; - text?: string; - fontFamily?: string; - fontSize?: number; - bold?: boolean; - italic?: boolean; - letterSpacing?: number; - color?: unknown; - vertAlign?: 'superscript' | 'subscript' | 'baseline'; - baselineShift?: number; - pmStart?: number | null; - pmEnd?: number | null; - dataAttrs?: Record; -}; - type ParagraphBlock = FlowBlock & { kind: 'paragraph'; - runs?: Run[]; + runs?: LayoutRun[]; }; const ENDNOTE_MARKER_DATA_ATTR = 'data-sd-endnote-number'; @@ -91,8 +75,12 @@ export function buildEndnoteBlocks( return blocks; } -function isEndnoteMarker(run: Run): boolean { - return Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]); +function isTextRun(run: LayoutRun): run is TextRun { + return (run.kind === 'text' || run.kind == null) && typeof (run as { text?: unknown }).text === 'string'; +} + +function isEndnoteMarker(run: LayoutRun): boolean { + return isTextRun(run) && Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]); } function resolveDisplayNumber(id: string, endnoteNumberById: Record | undefined): number { @@ -102,11 +90,11 @@ function resolveDisplayNumber(id: string, endnoteNumberById: Record !isEndnoteMarker(run) && typeof run.text === 'string' && run.text.length > 0); + const firstTextRun = runs.find( + (run): run is TextRun => isTextRun(run) && !isEndnoteMarker(run) && run.text.length > 0, + ); const markerRun = buildMarkerRun(String(resolveDisplayNumber(id, endnoteNumberById)), firstTextRun); - if (runs[0] && isEndnoteMarker(runs[0])) { + if (runs[0] && isTextRun(runs[0]) && isEndnoteMarker(runs[0])) { syncMarkerRun(runs[0], markerRun); return; } From 6c9fd276e287c8f944126068968819f5cb5bbe9a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 21 Apr 2026 20:15:37 -0700 Subject: [PATCH 12/16] chore: fix tests --- .../inline-converters/generic-token.test.ts | 24 + .../converters/inline-converters/tab.test.ts | 37 +- .../inline-converters/text-run.test.ts | 7 + .../src/converters/paragraph.test.ts | 1 + .../pm-adapter/src/tracked-changes.test.ts | 10 +- .../input/PresentationInputBridge.ts | 26 + .../tests/PresentationInputBridge.test.ts | 25 + packages/superdoc/src/SuperDoc.test.js | 83 +++ .../CommentsLayer/CommentDialog.test.js | 99 ++++ .../commentsList/super-comments-list.test.js | 17 + .../src/stores/comments-store.test.js | 501 ++++++++++++++++++ 11 files changed, 819 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts index 25d369f31c..8f3d263cd6 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts @@ -88,6 +88,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -107,6 +110,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -128,6 +134,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -146,6 +155,9 @@ describe('tokenNodeToRun', () => { expect.any(Array), hyperlinkConfig, undefined, + undefined, + true, + undefined, ); }); @@ -198,6 +210,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -214,6 +229,9 @@ describe('tokenNodeToRun', () => { [], { enableRichHyperlinks: false }, undefined, + undefined, + true, + undefined, ); }); @@ -232,6 +250,9 @@ describe('tokenNodeToRun', () => { enableRichHyperlinks: false, }, undefined, + undefined, + true, + undefined, ); }); @@ -261,6 +282,9 @@ describe('tokenNodeToRun', () => { [], { enableRichHyperlinks: false }, undefined, + undefined, + true, + undefined, ); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts index 06dd66a9ff..ffe2ab1ca7 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts @@ -221,9 +221,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('calls applyMarksToRun with inherited marks', () => { @@ -238,9 +244,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('combines node marks and inherited marks', () => { @@ -258,10 +270,15 @@ describe('tabNodeToRun', () => { tabNodeToRun({ node: tabNode, positions, tabOrdinal: 0, paragraphAttrs, inheritedMarks }); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); - expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ - { type: 'bold' }, - { type: 'underline', attrs: { underlineType: 'single' } }, - ]); + expect(applyMarksToRunMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'tab' }), + [{ type: 'bold' }, { type: 'underline', attrs: { underlineType: 'single' } }], + undefined, + undefined, + undefined, + true, + undefined, + ); }); it('does not call applyMarksToRun when no marks present', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts index 4787983d58..e9c3d29a9a 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts @@ -74,6 +74,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -125,6 +126,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -147,6 +149,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -171,6 +174,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -220,6 +224,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -298,6 +303,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); @@ -337,6 +343,7 @@ describe('textNodeToRun', () => { undefined, undefined, false, + undefined, ); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index dfa44dbf3c..cbcb6ca314 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2731,6 +2731,7 @@ describe('paragraph converters', () => { applyMarksToRun, undefined, true, + undefined, ); const paraBlock = blocks[0] as ParagraphBlock; diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts index c3b185d1d1..cb84f6ffe6 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts @@ -745,7 +745,15 @@ describe('tracked-changes', () => { const applyMarksToRun = vi.fn(); applyFormatChangeMarks(run, config, hyperlinkConfig, applyMarksToRun); - expect(applyMarksToRun).toHaveBeenCalledWith(run, beforeMarks, hyperlinkConfig, undefined, undefined, true); + expect(applyMarksToRun).toHaveBeenCalledWith( + run, + beforeMarks, + hyperlinkConfig, + undefined, + undefined, + true, + undefined, + ); }); it('should handle errors in applyMarksToRun by resetting formatting', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts index 3e4bdff4a3..711900a7ef 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts @@ -1,6 +1,8 @@ import { isInRegisteredSurface } from '../utils/uiSurfaceRegistry.js'; import { CONTEXT_MENU_HANDLED_FLAG } from '../../../components/context-menu/event-flags.js'; +const BRIDGE_FORWARDED_FLAG = Symbol('presentation-input-bridge-forwarded'); + export class PresentationInputBridge { #windowRoot: Window; #layoutSurfaces: Set; @@ -275,6 +277,9 @@ export class PresentationInputBridge { * @param event - The keyboard event from the layout surface */ #forwardKeyboardEvent(event: KeyboardEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -290,6 +295,7 @@ export class PresentationInputBridge { if (this.#isPlainCharacterKey(event)) { return; } + this.#markForwardedByBridge(event); // Dispatch synchronously so browser defaults can still be prevented const synthetic = new KeyboardEvent(event.type, { @@ -355,6 +361,9 @@ export class PresentationInputBridge { * @param event - The input event from the layout surface */ #forwardTextEvent(event: InputEvent | TextEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -364,6 +373,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); const dispatchSyntheticEvent = () => { // Only re-check mutable state - surface check was already done @@ -435,6 +445,9 @@ export class PresentationInputBridge { * @param event - The composition event from the layout surface */ #forwardCompositionEvent(event: CompositionEvent) { + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -444,6 +457,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); let synthetic: Event; if (typeof CompositionEvent !== 'undefined') { @@ -505,6 +519,9 @@ export class PresentationInputBridge { if (handledByContextMenu) { return; } + if (this.#wasForwardedByBridge(event)) { + return; + } if (!this.#isEditable()) { return; } @@ -514,6 +531,7 @@ export class PresentationInputBridge { if (event.defaultPrevented) { return; } + this.#markForwardedByBridge(event); const synthetic = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, @@ -592,6 +610,14 @@ export class PresentationInputBridge { return origin ? this.#layoutSurfaces.has(origin) : false; } + #wasForwardedByBridge(event: Event): boolean { + return Boolean((event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG]); + } + + #markForwardedByBridge(event: Event) { + (event as Event & { [BRIDGE_FORWARDED_FLAG]?: boolean })[BRIDGE_FORWARDED_FLAG] = true; + } + /** * Returns the set of event targets to attach listeners to. * Includes registered layout surfaces and optionally the window for fallback. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts index cc3484718f..7ab7461867 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts @@ -239,6 +239,31 @@ describe('PresentationInputBridge - Context Menu Handling', () => { }); describe('stale hidden-editor rerouting', () => { + it('does not double-forward layout-surface composing beforeinput when window fallback is enabled', () => { + const event = new InputEvent('beforeinput', { + data: 'e', + inputType: 'insertCompositionText', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'isComposing', { value: true, writable: false }); + + const forwardedEvents: string[] = []; + targetDom.addEventListener('beforeinput', () => { + forwardedEvents.push('beforeinput'); + }); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + layoutSurface.dispatchEvent(event); + + expect(forwardedEvents).toEqual(['beforeinput']); + }); + it('reroutes beforeinput from a stale hidden editor to the active target when window fallback is enabled', () => { const staleBodyEditor = document.createElement('div'); staleBodyEditor.className = 'ProseMirror'; diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 0a0f55411b..91411ba7df 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -694,6 +694,17 @@ describe('SuperDoc.vue', () => { expect(options.layoutEngineOptions.flowMode).toBe('paginated'); }); + it('passes useHiddenHostForStoryParts through to SuperEditor options', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.useHiddenHostForStoryParts = true; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + expect(options.useHiddenHostForStoryParts).toBe(true); + }); + it('handles replay comment update/delete events and triggers tracked-change resync', async () => { const superdocStub = createSuperdocStub(); const wrapper = await mountComponent(superdocStub); @@ -1302,6 +1313,78 @@ describe('SuperDoc.vue', () => { expect(getTrackedChangeIndexMock).toHaveBeenCalledWith(editor); }); + it('resyncs tracked-change comments from non-body tracked-changes-changed events', async () => { + const superdocStub = createSuperdocStub(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn(); + + const listeners = {}; + const presentationEditor = { + setContextMenuDisabled: vi.fn(), + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + getCommentBounds: vi.fn(() => ({})), + }; + const bodyEditor = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[`editor:${event}`] = handler; + }), + }; + const sourceEditor = { options: { documentId: 'header-doc' } }; + + wrapper.findComponent(SuperEditorStub).vm.$emit('editor-ready', { + editor: bodyEditor, + presentationEditor, + }); + await nextTick(); + + listeners['editor:tracked-changes-changed']?.({ editor: sourceEditor, source: 'story-edit' }); + expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ + superdoc: superdocStub, + editor: sourceEditor, + }); + + commentsStoreStub.syncTrackedChangeComments.mockClear(); + listeners['editor:tracked-changes-changed']?.({ editor: sourceEditor, source: 'body-edit' }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + }); + + it('clears tracked-change positions for non-body tracked-change updates when viewing-mode comments are hidden', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.documentMode = 'viewing'; + const wrapper = await mountComponent(superdocStub); + await nextTick(); + superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn(); + + const listeners = {}; + const presentationEditor = { + setContextMenuDisabled: vi.fn(), + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + getCommentBounds: vi.fn(() => ({})), + }; + const bodyEditor = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[`editor:${event}`] = handler; + }), + }; + + wrapper.findComponent(SuperEditorStub).vm.$emit('editor-ready', { + editor: bodyEditor, + presentationEditor, + }); + await nextTick(); + + listeners['editor:tracked-changes-changed']?.({ source: 'story-edit' }); + expect(commentsStoreStub.clearEditorCommentPositions).toHaveBeenCalled(); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + }); + it('forwards header/footer presentation events through the public update callbacks', async () => { const superdocStub = createSuperdocStub(); superdocStub.config.onTransaction = vi.fn(); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index c69c93c5d9..23877351ef 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -321,6 +321,105 @@ describe('CommentDialog.vue', () => { ); }); + it('navigates tracked changes with story metadata through PresentationEditor', async () => { + const presentation = { + navigateTo: vi.fn().mockResolvedValue(true), + }; + PresentationEditor.getInstance.mockReturnValue(presentation); + + const trackedChangeStory = { kind: 'story', storyType: 'footnote', noteId: '1' }; + + await mountDialog({ + baseCommentOverrides: { + commentId: 'tracked-change-story-1', + importedId: 'imported-tracked-change-story-1', + trackedChange: true, + trackedChangeStory, + }, + }); + + expect(presentation.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'imported-tracked-change-story-1', + story: trackedChangeStory, + }); + }); + + it('falls back to setCursorById for resolved tracked changes when PresentationEditor navigation is unavailable', async () => { + PresentationEditor.getInstance.mockReturnValue({}); + + const { wrapper, superdocStub } = await mountDialog({ + props: { autoFocus: false }, + baseCommentOverrides: { + commentId: 'tracked-change-resolved-1', + importedId: 'imported-tracked-change-resolved-1', + trackedChange: true, + resolvedTime: Date.now(), + }, + }); + + superdocStub.activeEditor.commands.setCursorById.mockClear(); + await wrapper.trigger('click'); + + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith('tracked-change-resolved-1'); + expect(superdocStub.activeEditor.commands.setActiveComment).not.toHaveBeenCalled(); + }); + + it('activates the tracked-change bubble when cursor placement fallback fails', async () => { + PresentationEditor.getInstance.mockReturnValue({}); + + const { wrapper, superdocStub } = await mountDialog({ + props: { autoFocus: false }, + baseCommentOverrides: { + commentId: 'tracked-change-fallback-1', + importedId: 'imported-tracked-change-fallback-1', + trackedChange: true, + }, + }); + + superdocStub.activeEditor.commands.setCursorById.mockReturnValue(false); + superdocStub.activeEditor.commands.setCursorById.mockClear(); + superdocStub.activeEditor.commands.setActiveComment.mockClear(); + + await wrapper.trigger('click'); + + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith( + 'imported-tracked-change-fallback-1', + { + activeCommentId: 'tracked-change-fallback-1', + }, + ); + expect(superdocStub.activeEditor.commands.setActiveComment).toHaveBeenCalledWith({ + commentId: 'tracked-change-fallback-1', + }); + }); + + it('activates the comment thread when non-tracked cursor placement fallback fails', async () => { + PresentationEditor.getInstance.mockReturnValue(null); + + const { wrapper, superdocStub } = await mountDialog({ + props: { autoFocus: false }, + baseCommentOverrides: { + commentId: 'comment-fallback-1', + trackedChange: false, + }, + }); + + superdocStub.activeEditor.commands.setCursorById.mockReturnValue(false); + superdocStub.activeEditor.commands.setCursorById.mockClear(); + superdocStub.activeEditor.commands.setActiveComment.mockClear(); + + await wrapper.trigger('click'); + + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith('comment-fallback-1', { + activeCommentId: 'comment-fallback-1', + }); + expect(superdocStub.activeEditor.commands.setActiveComment).toHaveBeenCalledWith({ + commentId: 'comment-fallback-1', + }); + }); + it('prefers the actual visible highlight top after the scroll attempt', async () => { const presentation = { getReachableThreadAnchorClientY: vi.fn().mockReturnValue(274), diff --git a/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.test.js b/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.test.js index 6dfc9ff56e..aaf1abf5fd 100644 --- a/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.test.js +++ b/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.test.js @@ -51,6 +51,23 @@ describe('SuperComments', () => { expect(instance.app.config.globalProperties.$pinia).toBe(pinia); }); + it('inherits parent app provides when mounting inside an existing SuperDoc app', () => { + const parentProvides = { theme: 'shared-theme' }; + const instance = new SuperComments( + { element }, + { + ...superdocStub, + app: { + _context: { + provides: parentProvides, + }, + }, + }, + ); + + expect(Object.getPrototypeOf(instance.app._context.provides)).toBe(parentProvides); + }); + it('resolves element via selector when no element is provided', () => { const el = document.createElement('div'); el.id = 'my-comments-host'; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index db1d55ef63..f98e017acc 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -206,6 +206,27 @@ describe('comments-store', () => { expect(store.getComment(undefined)).toBeNull(); }); + it('prefers tracked-change anchor keys for position lookup and alias resolution', () => { + const comment = { + commentId: 'tc-1', + importedId: 'import-1', + trackedChange: true, + trackedChangeAnchorKey: 'tc::body::tc-1', + }; + store.commentsList = [comment]; + store.editorCommentPositions = { + 'tc::body::tc-1': { start: 10, end: 12 }, + }; + + expect(store.getCommentPositionKey('tc-1')).toBe('tc::body::tc-1'); + expect(store.getCommentPositionKey(comment)).toBe('tc::body::tc-1'); + expect(store.getCommentAliasIds('tc-1')).toEqual(expect.arrayContaining(['tc-1', 'import-1', 'tc::body::tc-1'])); + + store.editorCommentPositions = {}; + expect(store.getCommentPositionKey('tc-1')).toBe('tc::body::tc-1'); + expect(store.getCommentPositionKey(comment)).toBe('tc::body::tc-1'); + }); + it('sets active comment and updates the editor', () => { const setActiveCommentSpy = vi.fn(); const superdoc = { @@ -640,6 +661,91 @@ describe('comments-store', () => { expect(store.commentsList[0].trackedChangeDisplayType).toBe('hyperlinkAdded'); }); + it('applies story tracked-change metadata to created tracked-change comments', () => { + const superdoc = { + emit: vi.fn(), + config: { isInternal: false }, + }; + + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'add', + changeId: 'story-change-1', + trackedChangeText: 'Inserted text', + trackedChangeType: 'trackInsert', + authorEmail: 'user@example.com', + author: 'User', + date: Date.now(), + importedAuthor: null, + documentId: 'doc-1', + coords: { top: 10, left: 10, right: 20, bottom: 20 }, + trackedChangeStory: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + trackedChangeStoryKind: 'headerFooter', + trackedChangeStoryLabel: 'Header', + trackedChangeAnchorKey: 'tc::hf:part:rId1::story-change-1', + }, + }); + + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'story-change-1', + trackedChangeStoryKind: 'headerFooter', + trackedChangeStoryLabel: 'Header', + trackedChangeAnchorKey: 'tc::hf:part:rId1::story-change-1', + }), + ]); + expect(store.getCommentAliasIds('story-change-1')).toEqual( + expect.arrayContaining(['story-change-1', 'tc::hf:part:rId1::story-change-1']), + ); + }); + + it('applies story tracked-change label and anchor metadata when updating an existing thread', () => { + const superdoc = { + emit: vi.fn(), + }; + + const existingComment = { + commentId: 'story-change-2', + trackedChange: true, + trackedChangeText: 'Old text', + trackedChangeType: 'trackInsert', + deletedText: null, + getValues: vi.fn(() => ({ commentId: 'story-change-2' })), + }; + + store.commentsList = [existingComment]; + + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'update', + changeId: 'story-change-2', + trackedChangeText: 'Updated text', + trackedChangeType: 'trackInsert', + authorEmail: 'user@example.com', + author: 'User', + date: Date.now(), + importedAuthor: null, + documentId: 'doc-1', + coords: { top: 10, left: 10, right: 20, bottom: 20 }, + trackedChangeStory: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId2' }, + trackedChangeStoryKind: 'headerFooter', + trackedChangeStoryLabel: 'Footer', + trackedChangeAnchorKey: 'tc::hf:part:rId2::story-change-2', + }, + }); + + expect(existingComment).toEqual( + expect.objectContaining({ + trackedChangeText: 'Updated text', + trackedChangeStoryKind: 'headerFooter', + trackedChangeStoryLabel: 'Footer', + trackedChangeAnchorKey: 'tc::hf:part:rId2::story-change-2', + }), + ); + }); + it('clears stale tracked-change positions when editor sends empty positions', async () => { const trackedComment = { commentId: 'change-1', @@ -1581,6 +1687,365 @@ describe('comments-store', () => { expect(editorDispatch).toHaveBeenCalledWith(tr); }); + it('falls back to snapshot story data when resolving a story tracked change throws', () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const superdoc = { + config: { isInternal: false }, + emit: vi.fn(), + }; + const snapshot = { + type: 'delete', + excerpt: 'header text', + anchorKey: 'tc::hf:part:rId6::raw-fallback', + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId6' }, + storyKind: 'headerFooter', + storyLabel: 'Header', + runtimeRef: { rawId: 'raw-fallback' }, + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + resolveTrackedChangeInStoryMock.mockImplementation(() => { + throw new Error('boom'); + }); + + store.syncTrackedChangeComments({ superdoc, editor }); + + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'raw-fallback', + trackedChangeText: '', + deletedText: 'header text', + trackedChangeType: 'delete', + trackedChangeStoryLabel: 'Header', + trackedChangeAnchorKey: 'tc::hf:part:rId6::raw-fallback', + }), + ]); + }); + + it('falls back to snapshot story data when story mark lookup throws', () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const storyState = { doc: { type: 'story-doc' } }; + const editor = { + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const superdoc = { + config: { isInternal: false }, + emit: vi.fn(), + }; + const snapshot = { + type: 'insert', + excerpt: 'footnote text', + anchorKey: 'tc::fn:1::raw-fallback-2', + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + runtimeRef: { rawId: 'raw-fallback-2' }, + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + resolveTrackedChangeInStoryMock.mockReturnValue({ + editor: { state: storyState }, + story: snapshot.story, + runtimeRef: { storyKey: 'fn:1', rawId: 'raw-fallback-2' }, + change: { rawId: 'raw-fallback-2' }, + }); + trackChangesHelpersMock.getTrackChanges.mockImplementation((state, id) => { + if (state === storyState && id === 'raw-fallback-2') { + throw new Error('story lookup failed'); + } + return []; + }); + + store.syncTrackedChangeComments({ superdoc, editor }); + + expect(store.commentsList).toEqual([ + expect.objectContaining({ + commentId: 'raw-fallback-2', + trackedChangeText: 'footnote text', + deletedText: null, + trackedChangeType: 'insert', + trackedChangeStoryLabel: 'Footnote 1', + trackedChangeAnchorKey: 'tc::fn:1::raw-fallback-2', + }), + ]); + }); + + it('updates an existing story tracked-change thread by anchor key', () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const superdoc = { + config: { isInternal: false }, + emit: vi.fn(), + }; + const snapshot = { + type: 'insert', + excerpt: 'new header text', + anchorKey: 'tc::hf:part:rId6::raw-anchor', + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId6' }, + storyKind: 'headerFooter', + storyLabel: 'Header', + runtimeRef: { rawId: 'raw-anchor' }, + }; + const existingComment = { + commentId: 'different-runtime-id', + trackedChange: true, + trackedChangeText: 'old header text', + trackedChangeType: 'trackInsert', + trackedChangeStoryLabel: 'Old Header', + trackedChangeAnchorKey: 'tc::hf:part:rId6::raw-anchor', + getValues: vi.fn(() => ({ commentId: 'different-runtime-id' })), + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + store.commentsList = [existingComment]; + + store.syncTrackedChangeComments({ superdoc, editor }); + + expect(store.commentsList).toHaveLength(1); + expect(existingComment).toEqual( + expect.objectContaining({ + trackedChangeText: 'new header text', + trackedChangeStoryLabel: 'Header', + trackedChangeAnchorKey: 'tc::hf:part:rId6::raw-anchor', + }), + ); + }); + + it('updates an existing story tracked-change thread by raw id when no anchor key exists', async () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + converter: { commentThreadingProfile: 'range-based' }, + state: { doc: { type: 'body-doc' } }, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + const snapshot = { + type: 'insert', + excerpt: 'note text', + anchorKey: null, + author: 'Alice', + authorEmail: 'alice@example.com', + authorImage: null, + date: 123, + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKind: 'footnote', + storyLabel: 'Footnote 1', + runtimeRef: { rawId: 'raw-no-anchor' }, + }; + const existingComment = { + commentId: 'other-id', + importedId: 'raw-no-anchor', + trackedChange: true, + trackedChangeText: 'old note text', + trackedChangeType: 'trackInsert', + getValues: vi.fn(() => ({ commentId: 'other-id' })), + }; + + __mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx' }]; + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + store.commentsList = [existingComment]; + + store.processLoadedDocxComments({ + superdoc: __mockSuperdoc, + editor, + comments: [], + documentId: 'doc-1', + }); + vi.runAllTimers(); + await nextTick(); + + expect(store.commentsList).toHaveLength(1); + expect(existingComment.trackedChangeText).toBe('note text'); + }); + + it('ignores story tracked-change bootstrap when the index snapshot lookup throws during DOCX load', () => { + const editorDispatch = vi.fn(); + const tr = { setMeta: vi.fn() }; + const editor = { + converter: { commentThreadingProfile: 'range-based' }, + state: {}, + view: { state: { tr }, dispatch: editorDispatch }, + options: { documentId: 'doc-1' }, + }; + + getTrackedChangeIndexMock.mockReturnValue({ + getAll: vi.fn(() => { + throw new Error('index unavailable'); + }), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(() => () => {}), + dispose: vi.fn(), + }); + + expect(() => + store.processLoadedDocxComments({ + superdoc: __mockSuperdoc, + editor, + comments: [], + documentId: 'doc-1', + }), + ).not.toThrow(); + }); + + describe('decideTrackedChangeFromSidebar', () => { + it('returns { ok: false } when the comment, editor, or id is missing', () => { + expect(store.decideTrackedChangeFromSidebar({ superdoc: {}, comment: null, decision: 'accept' })).toEqual({ + ok: false, + }); + + expect( + store.decideTrackedChangeFromSidebar({ + superdoc: {}, + comment: { commentId: 'tc-1', trackedChange: true }, + decision: 'accept', + }), + ).toEqual({ ok: false }); + + expect( + store.decideTrackedChangeFromSidebar({ + superdoc: { activeEditor: {} }, + comment: { trackedChange: true }, + decision: 'accept', + }), + ).toEqual({ ok: false }); + }); + + it('uses the document API for story tracked changes when available', () => { + const story = { kind: 'story', storyType: 'footnote', noteId: '1' }; + const decide = vi.fn(() => ({ success: true })); + + const result = store.decideTrackedChangeFromSidebar({ + superdoc: { + activeEditor: { + doc: { trackChanges: { decide } }, + }, + }, + comment: { + commentId: 'tc-story-1', + trackedChange: true, + trackedChangeStory: story, + }, + decision: 'accept', + }); + + expect(decide).toHaveBeenCalledWith({ + decision: 'accept', + target: { id: 'tc-story-1', story }, + }); + expect(result).toEqual({ ok: true, success: true }); + }); + + it('returns the document API error for story tracked changes when decide throws', () => { + const error = new Error('story decide failed'); + + const result = store.decideTrackedChangeFromSidebar({ + superdoc: { + activeEditor: { + doc: { + trackChanges: { + decide: vi.fn(() => { + throw error; + }), + }, + }, + }, + }, + comment: { + commentId: 'tc-story-2', + trackedChange: true, + trackedChangeStory: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId6' }, + }, + decision: 'reject', + }); + + expect(result).toEqual({ ok: false, error }); + }); + + it('falls back to editor commands for body tracked changes when document-api decide is unavailable', () => { + const rejectTrackedChangeById = vi.fn(() => true); + + const result = store.decideTrackedChangeFromSidebar({ + superdoc: { + activeEditor: { + doc: { + trackChanges: { + decide: vi.fn(() => { + throw new Error('body decide failed'); + }), + }, + }, + commands: { + rejectTrackedChangeById, + }, + }, + }, + comment: { + importedId: 'tc-body-1', + trackedChange: true, + }, + decision: 'reject', + }); + + expect(rejectTrackedChangeById).toHaveBeenCalledWith('tc-body-1'); + expect(result).toEqual({ ok: true, success: true }); + }); + }); + it('bootstraps story tracked-change comments during initial DOCX load', async () => { const editorDispatch = vi.fn(); const tr = { setMeta: vi.fn() }; @@ -1888,6 +2353,27 @@ describe('comments-store', () => { 'tc-1': { from: 1, to: 5 }, }); }); + + it('adds raw body tracked-change ids and canonical keys as lookup aliases', () => { + const entry = { + kind: 'trackedChange', + storyKey: 'body', + threadId: 'tc-raw-1', + key: 'tc::body::tc-raw-1', + start: 5, + end: 8, + }; + + store.handleEditorLocationsUpdate({ + generated: entry, + }); + + expect(store.editorCommentPositions).toEqual({ + generated: entry, + 'tc-raw-1': entry, + 'tc::body::tc-raw-1': entry, + }); + }); }); describe('viewing visibility filters', () => { @@ -2079,6 +2565,21 @@ describe('comments-store', () => { const ordered = store.getCommentsByPosition.parentComments.map((c) => c.commentId); expect(ordered).toEqual(['c-1', null, undefined]); }); + + it('uses page index and bounds top when range offsets are unavailable', () => { + store.commentsList = [ + { commentId: 'c-1', createdTime: 2 }, + { commentId: 'c-2', createdTime: 1 }, + ]; + + store.editorCommentPositions = { + 'c-1': { pageIndex: 1, bounds: { top: 10 } }, + 'c-2': { pageIndex: 0, bounds: { top: 50 } }, + }; + + const ordered = store.getCommentsByPosition.parentComments.map((c) => c.commentId); + expect(ordered).toEqual(['c-2', 'c-1']); + }); }); describe('comment anchor helpers', () => { From 6f0ba582203cc96688f593609b5b7f51fbfb04f2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 22 Apr 2026 09:20:49 -0700 Subject: [PATCH 13/16] fix: review feedback --- .../layout-engine/layout-bridge/src/index.ts | 33 ++- .../HeaderFooterPerRidLayout.test.ts | 69 +++++++ .../header-footer/HeaderFooterPerRidLayout.ts | 96 ++++----- .../HeaderFooterRegistry.test.ts | 55 +++++ .../header-footer/HeaderFooterRegistry.ts | 40 +++- .../presentation-editor/PresentationEditor.ts | 39 +++- .../HeaderFooterSessionManager.ts | 190 +++++++++++++++--- .../pointer-events/EditorInputManager.ts | 34 +++- .../EditorInputManager.footnoteClick.test.ts | 75 +++++++ .../tests/FootnotesBuilder.test.ts | 73 +++++++ .../tests/HeaderFooterSessionManager.test.ts | 35 +++- .../tests/PresentationInputBridge.test.ts | 32 +++ .../helpers/note-pm-json.test.ts | 147 ++++++++++++++ .../helpers/note-pm-json.ts | 135 ++++++++++++- .../story-runtime/note-story-runtime.test.ts | 93 +++++++++ packages/superdoc/src/SuperDoc.test.js | 47 ++++- packages/superdoc/src/SuperDoc.vue | 46 ++++- .../CommentsLayer/CommentDialog.vue | 1 + tests/behavior/helpers/comments.ts | 20 +- tests/behavior/helpers/story-fixtures.ts | 66 ++++++ ...-footer-live-tracked-change-bounds.spec.ts | 181 +++++++++++++++++ .../undo-redo-tracked-change-sidebar.spec.ts | 8 +- ...ndo-tracked-insert-removes-sidebar.spec.ts | 31 +-- .../double-click-edit-footnote.spec.ts | 54 ++++- 24 files changed, 1451 insertions(+), 149 deletions(-) create mode 100644 tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index a32d60a517..9fb004a717 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -210,9 +210,15 @@ const logClickStage = (_level: 'log' | 'warn' | 'error', _stage: string, _payloa // No-op in production. Enable for debugging click-to-position mapping. }; +const readSelectionDebugEnabled = (): boolean => { + if (typeof globalThis === 'undefined') return false; + return (globalThis as { __sdSelectionDebug?: boolean }).__sdSelectionDebug === true; +}; + const SELECTION_DEBUG_ENABLED = false; const logSelectionDebug = (payload: Record): void => { - if (!SELECTION_DEBUG_ENABLED) return; + const enabled = SELECTION_DEBUG_ENABLED || readSelectionDebugEnabled(); + if (!enabled) return; try { console.log('[SELECTION-DEBUG]', JSON.stringify(payload)); } catch { @@ -220,6 +226,15 @@ const logSelectionDebug = (payload: Record): void => { } }; +const pushSelectionDebugSnapshot = (payload: Record): void => { + if (typeof globalThis === 'undefined') return; + const target = globalThis as { __sdSelectionDebugLog?: Record[] }; + if (!Array.isArray(target.__sdSelectionDebugLog)) { + target.__sdSelectionDebugLog = []; + } + target.__sdSelectionDebugLog.push(payload); +}; + /** * Debug flag for DOM and geometry position mapping. * Set to true to enable detailed logging of click-to-position operations. @@ -636,7 +651,8 @@ export function selectionToRects( pageIndex, }); - if (SELECTION_DEBUG_ENABLED) { + const selectionDebugEnabled = SELECTION_DEBUG_ENABLED || readSelectionDebugEnabled(); + if (selectionDebugEnabled) { const runs = block.runs.slice(line.fromRun, line.toRun + 1).map((run: Run, idx: number) => { const isAtomic = 'src' in run || @@ -657,7 +673,7 @@ export function selectionToRects( }; }); - debugEntries.push({ + const debugEntry = { pageIndex, blockId: block.id, lineIndex: index, @@ -690,9 +706,18 @@ export function selectionToRects( Math.max(charOffsetFrom, charOffsetTo), ), indent: (block.attrs as { indent?: unknown } | undefined)?.indent, + alignment: (block.attrs as { alignment?: unknown } | undefined)?.alignment, marker: measure.marker, + markerWidth, + isListItemFlag, + alignmentOverride, lineSegments: line.segments, - }); + lineSpaceCount: (line as { spaceCount?: unknown }).spaceCount, + lineNaturalWidth: (line as { naturalWidth?: unknown }).naturalWidth, + lineMaxWidth: (line as { maxWidth?: unknown }).maxWidth, + }; + debugEntries.push(debugEntry); + pushSelectionDebugSnapshot(debugEntry); } }); return; diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 23d1f9412d..9b1dda41b0 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -129,4 +129,73 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-first')).toBe(true); expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + + it('lays out first-page header refs in multi-section documents with per-section constraints', async () => { + const headerBlocksByRId = new Map([ + ['rId-header-default', [makeBlock('block-default')]], + ['rId-header-first', [makeBlock('block-first')]], + ['rId-header-section-1', [makeBlock('block-section-1')]], + ]); + + const headerFooterInput = { + headerBlocksByRId, + footerBlocksByRId: undefined, + headerBlocks: undefined, + footerBlocks: undefined, + constraints: { + width: 400, + height: 80, + pageWidth: 600, + pageHeight: 800, + margins: { + top: 50, + right: 50, + bottom: 50, + left: 50, + header: 20, + }, + }, + }; + + const layout = { + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + ], + } as unknown as Layout; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 20 }, + headerRefs: { + default: 'rId-header-default', + first: 'rId-header-first', + }, + }, + { + sectionIndex: 1, + margins: { top: 55, right: 55, bottom: 55, left: 55, header: 20 }, + headerRefs: { + default: 'rId-header-section-1', + }, + }, + ]; + + const deps = { + headerLayoutsByRId: new Map(), + footerLayoutsByRId: new Map(), + }; + + await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps); + + const laidOutBlockIds = new Set( + mockLayoutHeaderFooterWithCache.mock.calls.map((call) => call[0].default?.[0]?.id).filter(Boolean), + ); + + expect(laidOutBlockIds).toEqual(new Set(['block-default', 'block-first', 'block-section-1'])); + expect(deps.headerLayoutsByRId.has('rId-header-default::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-header-first::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-header-section-1::s1')).toBe(true); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index e705c4a41b..68c3f90338 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -189,36 +189,6 @@ function collectReferencedRIdsBySection(effectiveRefsBySection: Map { - const result = new Map(); - let inheritedDefaultRId: string | undefined; - - for (const section of sectionMetadata) { - const refs = getRefsForKind(section, kind); - const explicitDefaultRId = refs?.default; - - if (explicitDefaultRId) { - inheritedDefaultRId = explicitDefaultRId; - } - - if (inheritedDefaultRId) { - result.set(section.sectionIndex, inheritedDefaultRId); - } - } - - return result; -} - /** * Layout header/footer blocks per rId, respecting per-section margins. * @@ -411,7 +381,7 @@ async function layoutWithPerSectionConstraints( ): Promise { if (!blocksByRId) return; - const defaultRIdPerSection = resolveDefaultRIdPerSection(sectionMetadata, kind); + const effectiveRefsBySection = buildEffectiveRefsBySection(sectionMetadata, kind); // Extract table width specs per rId (SD-1837). // Word allows tables in headers/footers to extend beyond content margins. @@ -429,36 +399,48 @@ async function layoutWithPerSectionConstraints( // Key: `${rId}::w${effectiveWidth}`, Value: { constraints, sections[] } const groups = new Map< string, - { sectionConstraints: Constraints; sectionIndices: number[]; rId: string; effectiveWidth: number } + { sectionConstraints: Constraints; sectionIndices: Set; rId: string; effectiveWidth: number } >(); for (const section of sectionMetadata) { - const rId = defaultRIdPerSection.get(section.sectionIndex); - if (!rId || !blocksByRId.has(rId)) continue; - - // Resolve the minimum width needed for tables in this section. - // For pct tables, this depends on the section's content width. - const contentWidth = buildSectionContentWidth(section, fallbackConstraints); - const tableWidthSpec = tableWidthSpecByRId.get(rId); - const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); - const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); - const effectiveWidth = sectionConstraints.width; - // Include vertical geometry in the key so sections with different page heights, - // vertical margins, or header distance get separate layouts (page-relative anchors - // and header band origin resolve differently). - const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; - - let group = groups.get(groupKey); - if (!group) { - group = { - sectionConstraints, - sectionIndices: [], - rId, - effectiveWidth, - }; - groups.set(groupKey, group); + const refs = effectiveRefsBySection.get(section.sectionIndex); + if (!refs) continue; + + const uniqueRIds = new Set(); + for (const variant of HEADER_FOOTER_VARIANTS) { + const rId = refs[variant]; + if (rId) { + uniqueRIds.add(rId); + } + } + + for (const rId of uniqueRIds) { + if (!blocksByRId.has(rId)) continue; + + // Resolve the minimum width needed for tables in this section. + // For pct tables, this depends on the section's content width. + const contentWidth = buildSectionContentWidth(section, fallbackConstraints); + const tableWidthSpec = tableWidthSpecByRId.get(rId); + const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); + const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); + const effectiveWidth = sectionConstraints.width; + // Include vertical geometry in the key so sections with different page heights, + // vertical margins, or header distance get separate layouts (page-relative anchors + // and header band origin resolve differently). + const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; + + let group = groups.get(groupKey); + if (!group) { + group = { + sectionConstraints, + sectionIndices: new Set(), + rId, + effectiveWidth, + }; + groups.set(groupKey, group); + } + group.sectionIndices.add(section.sectionIndex); } - group.sectionIndices.push(section.sectionIndex); } // Measure and layout each unique (rId, effectiveWidth) group diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index 0f912d2f21..c7af0a33ee 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -546,6 +546,61 @@ describe('HeaderFooterLayoutAdapter', () => { expect(options?.storyKey).toBe('hf:part:rId-header-default'); }); + it('passes tracked change render config through to header/footer flow blocks', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: false }); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + + const [, options] = mockToFlowBlocks.mock.calls[0] || []; + expect(options?.trackedChangesMode).toBe('final'); + expect(options?.enableTrackedChanges).toBe(false); + }); + + it('invalidates cached header/footer flow blocks when tracked change render config changes', () => { + const descriptor = { id: 'rId-header-default', kind: 'header', variant: 'default' }; + const doc = { type: 'doc', content: [{ type: 'paragraph' }] }; + + const manager = { + rootEditor: { + converter: { + convertedXml: {}, + numbering: {}, + linkedStyles: {}, + }, + }, + getDescriptors: (kind: string) => (kind === 'header' ? [descriptor] : []), + getDocumentJson: vi.fn(() => doc), + } as unknown as HeaderFooterEditorManager; + + const adapter = new HeaderFooterLayoutAdapter(manager); + + mockToFlowBlocks.mockClear(); + adapter.getBatch('header'); + adapter.getBatch('header'); + expect(mockToFlowBlocks).toHaveBeenCalledTimes(1); + + adapter.setTrackedChangesRenderConfig({ mode: 'final', enabled: true }); + adapter.getBatch('header'); + expect(mockToFlowBlocks).toHaveBeenCalledTimes(2); + }); + it('returns undefined when no descriptors have FlowBlocks', () => { const manager = { getDescriptors: () => [{ id: 'missing', kind: 'header', variant: 'default' }], diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index c1ba80f169..8c61429e08 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -1,6 +1,6 @@ import { toFlowBlocks } from '@superdoc/pm-adapter'; import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js'; -import type { FlowBlock } from '@superdoc/contracts'; +import type { FlowBlock, TrackedChangesMode } from '@superdoc/contracts'; import type { HeaderFooterBatch } from '@superdoc/layout-bridge'; import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; @@ -79,9 +79,15 @@ export interface HeaderFooterDocument { type HeaderFooterLayoutCacheEntry = { docRef: unknown; + renderConfigKey: string; blocks: FlowBlock[]; }; +export type HeaderFooterTrackedChangesRenderConfig = { + mode: TrackedChangesMode; + enabled: boolean; +}; + type HeaderFooterEditorEntry = { descriptor: HeaderFooterDescriptor; editor: Editor; @@ -1007,6 +1013,10 @@ export class HeaderFooterLayoutAdapter { #manager: HeaderFooterEditorManager; #mediaFiles?: Record; #blockCache: Map = new Map(); + #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: 'review', + enabled: true, + }; /** * Creates a new HeaderFooterLayoutAdapter. @@ -1019,6 +1029,23 @@ export class HeaderFooterLayoutAdapter { this.#mediaFiles = mediaFiles; } + setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void { + const nextConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: config.mode, + enabled: config.enabled, + }; + + if ( + this.#trackedChangesRenderConfig.mode === nextConfig.mode && + this.#trackedChangesRenderConfig.enabled === nextConfig.enabled + ) { + return; + } + + this.#trackedChangesRenderConfig = nextConfig; + this.invalidateAll(); + } + /** * Retrieves FlowBlock batches for all variants of a given header/footer kind. * @@ -1160,8 +1187,9 @@ export class HeaderFooterLayoutAdapter { const doc = this.#manager.getDocumentJson(descriptor); if (!doc) return undefined; + const renderConfigKey = this.#serializeRenderConfig(); const cacheEntry = this.#blockCache.get(descriptor.id); - if (cacheEntry?.docRef === doc) { + if (cacheEntry?.docRef === doc && cacheEntry.renderConfigKey === renderConfigKey) { return cacheEntry.blocks; } @@ -1187,14 +1215,20 @@ export class HeaderFooterLayoutAdapter { converterContext, defaultFont, defaultSize, + trackedChangesMode: this.#trackedChangesRenderConfig.mode, + enableTrackedChanges: this.#trackedChangesRenderConfig.enabled, storyKey: buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId: descriptor.id }), ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), }); const blocks = result.blocks; - this.#blockCache.set(descriptor.id, { docRef: doc, blocks }); + this.#blockCache.set(descriptor.id, { docRef: doc, renderConfigKey, blocks }); return blocks; } + + #serializeRenderConfig(): string { + return `${this.#trackedChangesRenderConfig.mode}|${this.#trackedChangesRenderConfig.enabled ? '1' : '0'}`; + } /** * Extracts converter context needed for FlowBlock conversion. * Uses type guard for safe access to converter property. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index a2ba3dce05..108b5d19ab 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -722,6 +722,10 @@ export class PresentationEditor extends EventEmitter { modeBanner: this.#modeBanner, }); this.#headerFooterSession.setDocumentMode(this.#documentMode); + this.#headerFooterSession.setTrackedChangesRenderConfig({ + mode: this.#trackedChangesMode, + enabled: this.#trackedChangesEnabled, + }); this.#ariaLiveRegion = doc.createElement('div'); this.#ariaLiveRegion.className = 'presentation-editor__aria-live'; @@ -785,6 +789,7 @@ export class PresentationEditor extends EventEmitter { this.#setupDragHandlers(); this.#setupInputBridge(); this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); this.#setupSemanticResizeObserver(); this.#initializeProofing(); @@ -1552,6 +1557,7 @@ export class PresentationEditor extends EventEmitter { this.#syncDocumentModeClass(); this.#syncHiddenEditorA11yAttributes(); const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); // Re-render if mode changed OR tracked changes preferences changed. // Mode change affects enableComments in toFlowBlocks even if tracked changes didn't change. if (modeChanged || trackedChangesChanged) { @@ -1598,6 +1604,7 @@ export class PresentationEditor extends EventEmitter { this.#trackedChangesOverrides = overrides; this.#layoutOptions.trackedChanges = overrides; const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); if (trackedChangesChanged) { // Clear flow block cache since conversion-affecting settings changed this.#flowBlockCache.clear(); @@ -2013,6 +2020,23 @@ export class PresentationEditor extends EventEmitter { return hasUpdates ? remapped : positions; } + #shouldEmitCommentPositions(): boolean { + const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true; + return this.#documentMode !== 'viewing' || allowViewingCommentPositions; + } + + #emitCommentPositions(relativeTo?: HTMLElement): void { + if (!this.#shouldEmitCommentPositions()) { + return; + } + + const commentPositions = this.#collectCommentPositions(); + const positionsWithBounds = + relativeTo != null ? this.getCommentBounds(commentPositions, relativeTo) : commentPositions; + + this.emit('commentPositions', { positions: positionsWithBounds }); + } + /** * Collect all comment and tracked change positions from the PM document. * @@ -3765,6 +3789,7 @@ export class PresentationEditor extends EventEmitter { #setupEditorListeners() { const handleUpdate = ({ transaction }: { transaction?: Transaction }) => { const trackedChangesChanged = this.#syncTrackedChangesPreferences(); + this.#syncHeaderFooterTrackedChangesRenderConfig(); if (transaction) { this.#epochMapper.recordTransaction(transaction); this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch()); @@ -4464,6 +4489,7 @@ export class PresentationEditor extends EventEmitter { storyType: 'headerFooterPart', refId: headerId, }); + this.#emitCommentPositions(); } this.emit('headerFooterTransaction', { editor: this.#editor, @@ -5236,11 +5262,7 @@ export class PresentationEditor extends EventEmitter { // 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.#emitCommentPositions(); this.#selectionSync.requestRender({ immediate: true }); @@ -7540,6 +7562,13 @@ export class PresentationEditor extends EventEmitter { return hasChanged; } + #syncHeaderFooterTrackedChangesRenderConfig(): void { + this.#headerFooterSession?.setTrackedChangesRenderConfig({ + mode: this.#trackedChangesMode, + enabled: this.#trackedChangesEnabled, + }); + } + #deriveTrackedChangesMode(): TrackedChangesMode { const overrideMode = this.#trackedChangesOverrides?.mode; if (overrideMode) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 99a1dc097e..aed0792f9f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -28,6 +28,7 @@ import { HeaderFooterEditorManager, HeaderFooterLayoutAdapter, type HeaderFooterDescriptor, + type HeaderFooterTrackedChangesRenderConfig, } from '../../header-footer/HeaderFooterRegistry.js'; import { EditorOverlayManager } from '../../header-footer/EditorOverlayManager.js'; import { initHeaderFooterRegistry } from '../../header-footer/HeaderFooterRegistryInit.js'; @@ -43,6 +44,7 @@ import { type MultiSectionHeaderFooterIdentifier, type HeaderFooterConstraints, } from '@superdoc/layout-bridge'; +import { selectionToRects } from '@superdoc/layout-bridge'; import { deduplicateOverlappingRects } from '../../../dom-observer/DomSelectionGeometry.js'; import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js'; import { @@ -230,6 +232,10 @@ export class HeaderFooterSessionManager { // Document mode #documentMode: 'editing' | 'viewing' | 'suggesting' = 'editing'; + #trackedChangesRenderConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: 'review', + enabled: true, + }; constructor(options: HeaderFooterSessionManagerOptions) { this.#options = options; @@ -431,6 +437,23 @@ export class HeaderFooterSessionManager { } } + setTrackedChangesRenderConfig(config: HeaderFooterTrackedChangesRenderConfig): void { + const nextConfig: HeaderFooterTrackedChangesRenderConfig = { + mode: config.mode, + enabled: config.enabled, + }; + + if ( + this.#trackedChangesRenderConfig.mode === nextConfig.mode && + this.#trackedChangesRenderConfig.enabled === nextConfig.enabled + ) { + return; + } + + this.#trackedChangesRenderConfig = nextConfig; + this.#headerFooterAdapter?.setTrackedChangesRenderConfig(nextConfig); + } + /** * Set layout results from external layout computation. */ @@ -492,6 +515,7 @@ export class HeaderFooterSessionManager { this.#headerFooterIdentifier = result.headerFooterIdentifier; this.#headerFooterManager = result.headerFooterManager; this.#headerFooterAdapter = result.headerFooterAdapter; + this.#headerFooterAdapter?.setTrackedChangesRenderConfig(this.#trackedChangesRenderConfig); this.#managerCleanups = result.cleanups; } @@ -1435,15 +1459,11 @@ export class HeaderFooterSessionManager { /** * Compute selection rectangles in header/footer mode. * - * This method intentionally does NOT use layout-engine geometry. Header/footer - * editing is driven by a dedicated ProseMirror editor instance mounted inside - * an overlay host. For selection, we rely on the browser's native DOM selection - * rectangles from that editor and then remap them into layout coordinates using - * the current region and body page height. - * - * Selection rectangles are therefore derived from: - * - Native ProseMirror selection → DOM Range → client rects - * - Header/footer region → pageIndex / local offset + * In visible overlay-host mode we read the active editor's DOM Range geometry + * directly for tight browser-aligned highlights. In hidden-host story-session + * mode, those DOM rects live off-screen, so we instead project the requested + * PM range through the header/footer layout data and then remap it into the + * active page region. */ computeSelectionRects(from: number, to: number): LayoutRect[] { // Guard: must be in header/footer mode with an active editor and region context. @@ -1469,30 +1489,15 @@ export class HeaderFooterSessionManager { const region = context.region; const pageIndex = region.pageIndex; + const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; - // Compute DOM-based rectangles local to the editor host. We intentionally - // ignore the numeric from/to arguments and any cached ProseMirror - // selection, and instead rely solely on the live DOM selection inside the - // active header/footer editor. This avoids stale selection state when - // switching between multiple header/footer editors. - const domSelection = view.dom.ownerDocument?.getSelection?.(); - let domRectList: DOMRect[] = []; - - if (domSelection && domSelection.rangeCount > 0) { - for (let i = 0; i < domSelection.rangeCount; i += 1) { - const range = domSelection.getRangeAt(i); - if (!range) continue; - const rangeRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; - domRectList.push(...rangeRects); - } - - // Normalize to a minimal set of rects. Browsers often return both a - // line-box rect and a text-content rect on the same line; without - // deduplication this produces overlapping highlights that look like - // intersecting selections. - domRectList = deduplicateOverlappingRects(domRectList); + const hiddenHostRects = this.#computeHiddenHostSelectionRects(context, from, to, bodyPageHeight); + if (hiddenHostRects) { + return hiddenHostRects; } + const domRectList = this.#computeEditorRangeClientRects(view, from, to); + if (!domRectList.length) { return []; } @@ -1505,7 +1510,6 @@ export class HeaderFooterSessionManager { // deltas and sizes must be converted back out of zoom space here. const editorDom = view.dom as HTMLElement; const editorHostRect = editorDom.getBoundingClientRect(); - const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; const zoom = typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 @@ -1545,6 +1549,130 @@ export class HeaderFooterSessionManager { return layoutRects; } + #computeHiddenHostSelectionRects( + context: HeaderFooterLayoutContext, + from: number, + to: number, + bodyPageHeight: number, + ): LayoutRect[] | null { + const activeEditor = this.#activeEditor; + const editorDom = activeEditor?.view?.dom as HTMLElement | null; + if (!editorDom?.closest?.('.presentation-editor__story-hidden-host')) { + return null; + } + + const localRects = selectionToRects(context.layout, context.blocks, context.measures, from, to) ?? []; + if (localRects.length) { + return localRects.map((rect) => ({ + pageIndex: context.region.pageIndex, + x: context.region.localX + rect.x, + y: context.region.pageIndex * bodyPageHeight + context.region.localY + rect.y, + width: rect.width, + height: rect.height, + })); + } + + const liveRect = activeEditor + ? this.#computeHiddenHostLiveRangeRect(activeEditor, from, to, context, bodyPageHeight) + : null; + return liveRect ? [liveRect] : []; + } + + #computeHiddenHostLiveRangeRect( + editor: Editor, + from: number, + to: number, + context: HeaderFooterLayoutContext, + bodyPageHeight: number, + ): LayoutRect | null { + const view = editor.view as + | (Editor['view'] & { + coordsAtPos?: (pos: number, side?: number) => { left: number; right: number; top: number; bottom: number }; + }) + | null + | undefined; + + if (!view || typeof view.coordsAtPos !== 'function') { + return null; + } + + const docSize = editor.state?.doc?.content?.size ?? 0; + const start = Math.max(0, Math.min(Math.min(from, to), docSize)); + const end = Math.max(0, Math.min(Math.max(from, to), docSize)); + if (start === end) { + return null; + } + + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const zoom = + typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 + ? layoutOptions.zoom + : 1; + const editorHostRect = view.dom.getBoundingClientRect(); + + try { + const startCoords = view.coordsAtPos(start); + const endCoords = view.coordsAtPos(end, -1); + const left = Math.min(startCoords.left, endCoords.left); + const right = Math.max(startCoords.right, endCoords.right); + const top = Math.min(startCoords.top, endCoords.top); + const bottom = Math.max(startCoords.bottom, endCoords.bottom); + const width = Math.max(1, (right - left) / zoom); + const height = Math.max(1, (bottom - top) / zoom); + const localX = (left - editorHostRect.left) / zoom; + const localY = (top - editorHostRect.top) / zoom; + + if (!Number.isFinite(localX) || !Number.isFinite(localY)) { + return null; + } + + return { + pageIndex: context.region.pageIndex, + x: context.region.localX + localX, + y: context.region.pageIndex * bodyPageHeight + context.region.localY + localY, + width, + height, + }; + } catch { + return null; + } + } + + #computeEditorRangeClientRects(view: Editor['view'], from: number, to: number): DOMRect[] { + if (!Number.isFinite(from) || !Number.isFinite(to)) { + return []; + } + + const docSize = view.state?.doc?.content?.size ?? 0; + const start = Math.max(0, Math.min(Math.min(from, to), docSize)); + const end = Math.max(0, Math.min(Math.max(from, to), docSize)); + if (start === end || typeof view.domAtPos !== 'function') { + return []; + } + + const doc = view.dom.ownerDocument; + const range = doc?.createRange?.(); + if (!range) { + return []; + } + + try { + const startBoundary = view.domAtPos(start); + const endBoundary = view.domAtPos(end); + range.setStart(startBoundary.node, startBoundary.offset); + range.setEnd(endBoundary.node, endBoundary.offset); + } catch { + return []; + } + + try { + const clientRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; + return deduplicateOverlappingRects(clientRects); + } catch { + return []; + } + } + computeCaretRect(pos: number): LayoutRect | null { if (this.#session.mode === 'body') { return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index d33b9720d4..ebc4a27850 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -57,6 +57,7 @@ const SCROLL_DETECTION_TOLERANCE_PX = 1; const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight'; const TRACK_CHANGE_SELECTOR = '[data-track-change-id]'; const PM_TRACK_CHANGE_SELECTOR = '.track-insert[data-id], .track-delete[data-id], .track-format[data-id]'; +const VISIBLE_HEADER_FOOTER_SELECTOR = '.superdoc-page-header, .superdoc-page-footer'; const COMMENT_THREAD_HIT_TOLERANCE_PX = 3; const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray = [ [0, 0], @@ -250,6 +251,29 @@ function resolveCommentThreadIdNearPointer( return null; } +function getVisibleHeaderFooterSurfaceAtPointer( + target: EventTarget | null, + clientX: number, + clientY: number, +): HTMLElement | null { + const ownerDocument = target instanceof Element ? target.ownerDocument : document; + const ownerWindow = ownerDocument.defaultView; + + if (typeof ownerDocument.elementFromPoint !== 'function' || !ownerWindow) { + return null; + } + + const sampleX = clamp(clientX, 0, Math.max(ownerWindow.innerWidth - 1, 0)); + const sampleY = clamp(clientY, 0, Math.max(ownerWindow.innerHeight - 1, 0)); + const topmostElement = ownerDocument.elementFromPoint(sampleX, sampleY); + + if (!(topmostElement instanceof HTMLElement)) { + return null; + } + + return topmostElement.closest(VISIBLE_HEADER_FOOTER_SELECTOR) as HTMLElement | null; +} + function getActiveCommentThreadId(editor: Editor): string | null { const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null; const activeThreadId = pluginState?.activeThreadId; @@ -2054,6 +2078,10 @@ export class EditorInputManager { const activeEditorHost = session?.overlayManager?.getActiveEditorHost?.(); const clickedInsideEditorHost = activeEditorHost && (activeEditorHost.contains(event.target as Node) || activeEditorHost === event.target); + const activeSurfaceSelector = + session?.session?.mode === 'footer' ? '.superdoc-page-footer' : '.superdoc-page-header'; + const visibleSurfaceAtPointer = getVisibleHeaderFooterSurfaceAtPointer(event.target, event.clientX, event.clientY); + const clickedInsideVisibleActiveSurface = visibleSurfaceAtPointer?.closest(activeSurfaceSelector) != null; if (clickedInsideEditorHost) { this.#syncNonBodyCommentSelection(event, event.target as HTMLElement | null, this.#deps.getEditor(), { @@ -2062,9 +2090,13 @@ export class EditorInputManager { return true; // Let editor handle it } + if (!clickedInsideVisibleActiveSurface) { + this.#callbacks.exitHeaderFooterMode?.(); + return false; // Continue to body click handling after exiting the active H/F session + } + const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y, pageIndex, pageLocalY); if (!headerFooterRegion) { - this.#callbacks.exitHeaderFooterMode?.(); return false; // Continue to body click handling } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts index 4cc2039971..d2d381fe64 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -43,6 +43,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { let manager: EditorInputManager; let viewportHost: HTMLElement; let visibleHost: HTMLElement; + let originalElementFromPoint: typeof document.elementFromPoint | undefined; let mockEditor: { isEditable: boolean; state: { @@ -66,6 +67,7 @@ describe('EditorInputManager - Footnote click selection behavior', () => { let activateRenderedNoteSession: Mock; beforeEach(() => { + originalElementFromPoint = document.elementFromPoint?.bind(document); viewportHost = document.createElement('div'); viewportHost.className = 'presentation-editor__viewport'; visibleHost = document.createElement('div'); @@ -142,6 +144,14 @@ describe('EditorInputManager - Footnote click selection behavior', () => { afterEach(() => { manager.destroy(); document.body.innerHTML = ''; + if (originalElementFromPoint) { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: originalElementFromPoint, + }); + } else { + Reflect.deleteProperty(document, 'elementFromPoint'); + } vi.clearAllMocks(); }); @@ -170,6 +180,15 @@ describe('EditorInputManager - Footnote click selection behavior', () => { }; } + function stubElementFromPoint(element: Element | null): Mock { + const elementFromPoint = vi.fn(() => element); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPoint, + }); + return elementFromPoint; + } + it('activates a note session on direct footnote fragment click', () => { const fragmentEl = document.createElement('span'); fragmentEl.setAttribute('data-block-id', 'footnote-1-0'); @@ -522,6 +541,62 @@ describe('EditorInputManager - Footnote click selection behavior', () => { expect(activeHeaderEditor.view.focus).toHaveBeenCalled(); }); + it('exits active header editing when the topmost visible target is body content even if region hit-testing still says header', () => { + const activeHeaderEditor = createActiveSessionEditor(); + const exitHeaderFooterMode = vi.fn(); + + const visibleHeader = document.createElement('div'); + visibleHeader.className = 'superdoc-page-header'; + viewportHost.appendChild(visibleHeader); + + const bodyText = document.createElement('span'); + bodyText.textContent = 'Visible body text'; + viewportHost.appendChild(bodyText); + stubElementFromPoint(bodyText); + + (mockDeps.getActiveEditor as Mock).mockReturnValue(activeHeaderEditor); + (mockDeps.getHeaderFooterSession as Mock).mockReturnValue({ + session: { mode: 'header' }, + overlayManager: { getActiveEditorHost: vi.fn(() => null) }, + }); + mockCallbacks.exitHeaderFooterMode = exitHeaderFooterMode; + mockCallbacks.hitTest = vi.fn(() => ({ + pos: 24, + layoutEpoch: 3, + pageIndex: 0, + blockId: 'body-1', + column: 0, + lineIndex: -1, + })); + mockCallbacks.hitTestHeaderFooterRegion = vi.fn(() => ({ + kind: 'header', + pageIndex: 0, + pageNumber: 1, + sectionType: 'default', + localX: 0, + localY: 0, + width: 300, + height: 220, + })); + manager.setCallbacks(mockCallbacks); + + const PointerEventImpl = getPointerEventImpl(); + bodyText.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 30, + clientY: 220, + } as PointerEventInit), + ); + + expect(exitHeaderFooterMode).toHaveBeenCalledTimes(1); + expect(mockCallbacks.hitTest as Mock).toHaveBeenCalledWith(30, 220); + expect(mockCallbacks.scheduleSelectionUpdate as Mock).toHaveBeenCalled(); + }); + it('syncs the tracked-change bubble for clicks inside the active header editor host', () => { const activeHeaderEditor = createActiveSessionEditor(); const activeEditorHost = document.createElement('div'); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index 8bb2b63719..aa9d2e7cfc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -232,6 +232,79 @@ describe('buildFootnotesInput', () => { ]); }); + it('normalizes away note separator tabs before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Note' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Note' }], + }, + ]); + }); + + it('normalizes away hidden passthrough field-code nodes before layout conversion', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'passthroughInline', attrs: { originalName: 'w:fldChar' } }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.content).toEqual([ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ]); + }); + it('builds the marker as a scaled superscript run instead of a Unicode superscript glyph', () => { const editorState = createMockEditorState([{ id: '1', pos: 10 }]); const converter = createMockConverter([ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index ea9605b2f7..06c3b71172 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -38,6 +38,9 @@ function createMainEditorStub(): Editor { } function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { + const textNode = editorDom.ownerDocument.createTextNode('abcdefghij'); + editorDom.appendChild(textNode); + return { setEditable: vi.fn(), setOptions: vi.fn(), @@ -58,6 +61,17 @@ function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { view: { dom: editorDom, focus: vi.fn(), + state: { + doc: { + content: { + size: 10, + }, + }, + }, + domAtPos: vi.fn((pos: number) => ({ + node: textNode, + offset: Math.max(0, Math.min(textNode.length, pos - 1)), + })), }, on: vi.fn(), off: vi.fn(), @@ -92,7 +106,7 @@ describe('HeaderFooterSessionManager', () => { * Sets up a full manager with an active header region and returns the manager * ready for `computeSelectionRects` assertions. * - * The DOM selection mock returns a single rect at (120, 90) with size 200x32, + * The DOM range mock returns a single rect at (120, 90) with size 200x32, * and the editor host is at (100, 50) with size 600x120. The header region is * at localX=40, localY=30 on page 1 with bodyPageHeight=800. */ @@ -211,12 +225,11 @@ describe('HeaderFooterSessionManager', () => { manager.headerRegions.set(headerRegion.pageIndex, headerRegion); vi.spyOn(editorDom, 'getBoundingClientRect').mockReturnValue(createRect(100, 50, 600, 120)); - vi.spyOn(document, 'getSelection').mockReturnValue({ - rangeCount: 1, - getRangeAt: vi.fn(() => ({ - getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]), - })), - } as unknown as Selection); + vi.spyOn(document, 'createRange').mockReturnValue({ + setStart: vi.fn(), + setEnd: vi.fn(), + getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]), + } as unknown as Range); manager.activateRegion(headerRegion); await vi.waitFor(() => expect(manager.activeEditor).toBe(headerFooterEditor)); @@ -270,6 +283,14 @@ describe('HeaderFooterSessionManager', () => { expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); }); + it('uses the requested PM range instead of the live DOM selection', async () => { + await setupWithZoom(1); + + vi.spyOn(document, 'getSelection').mockReturnValue(null); + + expect(manager.computeSelectionRects(3, 7)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + it('activates header editing through the story-session manager without creating an overlay host', async () => { const pageElement = document.createElement('div'); pageElement.dataset.pageIndex = '0'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts index 7ab7461867..4e9abb5450 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts @@ -331,5 +331,37 @@ describe('PresentationInputBridge - Context Menu Handling', () => { ); expect(staleEvent.defaultPrevented).toBe(true); }); + + it('does not reroute keyboard input from a registered UI surface editor', () => { + const commentEditor = document.createElement('div'); + commentEditor.className = 'ProseMirror'; + commentEditor.setAttribute('contenteditable', 'true'); + + const commentDialog = document.createElement('div'); + commentDialog.setAttribute('data-editor-ui-surface', ''); + commentDialog.appendChild(commentEditor); + document.body.appendChild(commentDialog); + + const staleEvent = new KeyboardEvent('keydown', { + key: 'U', + bubbles: true, + cancelable: true, + }); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + + commentEditor.dispatchEvent(staleEvent); + + expect(targetFocusSpy).not.toHaveBeenCalled(); + expect(targetDispatchSpy).not.toHaveBeenCalled(); + expect(staleEvent.defaultPrevented).toBe(false); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts index 6206a38a6f..23ec01c353 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts @@ -32,6 +32,153 @@ describe('normalizeNotePmJson', () => { }); }); + it('strips a leading tab separator after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('strips a whitespace-only run after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: ' ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('trims a leading space from the first text run after the note reference run', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'EndnoteReference' } } }, + { + type: 'run', + content: [{ type: 'text', text: ' Hello' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }); + }); + + it('strips hidden passthrough inline nodes from note paragraphs', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'passthroughInline', attrs: { originalName: 'w:fldChar' } }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }; + + expect(normalizeNotePmJson(doc)).toEqual({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }); + }); + it('preserves empty run nodes outside paragraphs', () => { const doc = { type: 'doc', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts index 617b6667cb..358ab858a9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts @@ -16,6 +16,114 @@ function isEmptyRunNode(value: unknown): value is PmJsonNode { return !Array.isArray(value.content) || value.content.length === 0; } +function isLeadingNoteReferenceRun(value: unknown): value is PmJsonNode { + if (!isEmptyRunNode(value)) { + return false; + } + + const styleId = (value.attrs as { runProperties?: { styleId?: unknown } } | undefined)?.runProperties?.styleId; + if (styleId === 'FootnoteReference' || styleId === 'EndnoteReference') { + return true; + } + + return true; +} + +function isWhitespaceOnlyTextNode(value: unknown): value is PmJsonNode { + return isPmJsonNode(value) && value.type === 'text' && typeof value.text === 'string' && /^\s*$/.test(value.text); +} + +function isInvisibleNotePassthroughNode(value: unknown): value is PmJsonNode { + return isPmJsonNode(value) && value.type === 'passthroughInline'; +} + +function stripLeadingWhitespaceFromTextNode(value: unknown): unknown { + if (!isPmJsonNode(value) || value.type !== 'text' || typeof value.text !== 'string') { + return value; + } + + const trimmed = value.text.replace(/^\s+/, ''); + if (trimmed.length === 0) { + return null; + } + + return trimmed === value.text ? value : { ...value, text: trimmed }; +} + +function stripLeadingNoteSeparatorFromRun(value: unknown): unknown { + if (!isPmJsonNode(value) || value.type !== 'run' || !Array.isArray(value.content)) { + return value; + } + + const remainingContent = [...value.content]; + while (remainingContent.length > 0) { + const firstChild = remainingContent[0]; + if (isPmJsonNode(firstChild) && firstChild.type === 'tab') { + remainingContent.shift(); + continue; + } + if (isWhitespaceOnlyTextNode(firstChild)) { + remainingContent.shift(); + continue; + } + + const normalizedFirstChild = stripLeadingWhitespaceFromTextNode(firstChild); + if (normalizedFirstChild == null) { + remainingContent.shift(); + continue; + } + + remainingContent[0] = normalizedFirstChild; + break; + } + + if (remainingContent.length === 0) { + return null; + } + + return { + ...value, + content: remainingContent, + }; +} + +function stripLeadingNoteSeparatorChildren(children: unknown[]): unknown[] { + const remainingChildren = [...children]; + + while (remainingChildren.length > 0) { + const firstChild = remainingChildren[0]; + if (!isPmJsonNode(firstChild)) { + break; + } + + if (firstChild.type === 'run') { + const normalizedRun = stripLeadingNoteSeparatorFromRun(firstChild); + if (normalizedRun == null) { + remainingChildren.shift(); + continue; + } + remainingChildren[0] = normalizedRun; + break; + } + + if (firstChild.type === 'tab' || isWhitespaceOnlyTextNode(firstChild)) { + remainingChildren.shift(); + continue; + } + + const normalizedText = stripLeadingWhitespaceFromTextNode(firstChild); + if (normalizedText == null) { + remainingChildren.shift(); + continue; + } + + remainingChildren[0] = normalizedText; + break; + } + + return remainingChildren; +} + function normalizeNotePmNode(value: unknown): unknown { if (!isPmJsonNode(value)) { return value; @@ -26,10 +134,17 @@ function normalizeNotePmNode(value: unknown): unknown { return normalized; } - const normalizedChildren = value.content + const originalChildren = value.content; + const normalizedChildren = originalChildren .map((child) => normalizeNotePmNode(child)) + .filter((child) => !isInvisibleNotePassthroughNode(child)) .filter((child) => !(value.type === 'paragraph' && isEmptyRunNode(child))); + if (value.type === 'paragraph' && originalChildren[0] && isLeadingNoteReferenceRun(originalChildren[0])) { + normalized.content = stripLeadingNoteSeparatorChildren(normalizedChildren); + return normalized; + } + normalized.content = normalizedChildren; return normalized; } @@ -38,13 +153,17 @@ function normalizeNotePmNode(value: unknown): unknown { * Normalize note PM JSON so interactive layout and story editors share the same * position space. * - * The note importer preserves the leading OOXML footnote/endnote reference run - * as an empty `run` node. Story editors immediately normalize those empty runs - * away, but the presentation-footnote layout previously converted the raw - * content as-is. That left the rendered note and the active note editor offset - * by two PM positions, which made clicks in the rendered note type into the - * wrong place. Keeping both paths on the same normalized PM JSON fixes the - * mismatch at the source. + * The note importer preserves note-only content from OOXML: + * the empty footnote/endnote reference run, the separator Word places + * immediately after it (typically a tab or a whitespace-only run), and any + * hidden passthrough field-code nodes. + * + * The rendered footnote surface does not expose those invisible note-only + * nodes as editable PM positions, so leaving them in the hidden story editor + * shifts the visible click surface and the active editor into different + * coordinate spaces. + * Keeping both paths on the same normalized PM JSON fixes the mismatch at the + * source. */ export function normalizeNotePmJson>(docJson: T): T { const normalized = normalizeNotePmNode(docJson); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts index 9fbdb60832..ece7557c3b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/note-story-runtime.test.ts @@ -136,6 +136,99 @@ describe('resolveNoteRuntime — empty note content', () => { ); }); + it('normalizes note separator tabs out of the editable footnote story', () => { + const hostEditor = makeHostEditor([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [], attrs: { runProperties: { styleId: 'FootnoteReference' } } }, + { + type: 'run', + content: [{ type: 'tab' }, { type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + + it('strips hidden passthrough field-code nodes out of the editable note story', () => { + const hostEditor = makeHostEditor([ + { + id: '1', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'passthroughInline', attrs: { originalName: 'w:fldChar' } }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }, + ]); + + resolveNoteRuntime(hostEditor, footnoteLocator); + + expect(mockCreateStoryEditor).toHaveBeenCalledWith( + hostEditor, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Section ' }], + }, + { + type: 'run', + content: [{ type: 'text', text: '1.2(b)' }], + }, + ], + }, + ], + }, + expect.any(Object), + ); + }); + it('resolves an endnote with content: [] as a valid empty story', () => { const hostEditor = makeHostEditor([], [{ id: '1', content: [] }]); diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 91411ba7df..b73a8ee999 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -266,9 +266,28 @@ const buildCommentsStore = () => ({ isCommentHighlighted: ref(false), }); -const mountComponent = async (superdocStub, { surfaceManager = null } = {}) => { - superdocStoreStub = buildSuperdocStore(); - commentsStoreStub = buildCommentsStore(); +const createCommentsStoreWithFloatingGetter = () => { + const store = buildCommentsStore(); + const floatingCommentsState = ref([]); + + delete store.getFloatingComments; + Object.defineProperty(store, 'getFloatingComments', { + configurable: true, + enumerable: true, + get() { + return floatingCommentsState.value; + }, + }); + + return { store, floatingCommentsState }; +}; + +const mountComponent = async ( + superdocStub, + { surfaceManager = null, superdocStore = null, commentsStore = null } = {}, +) => { + superdocStoreStub = superdocStore ?? buildSuperdocStore(); + commentsStoreStub = commentsStore ?? buildCommentsStore(); superdocStoreStub.modules.ai = { endpoint: '/ai' }; commentsStoreStub.documentsWithConverations.value = [{ id: 'doc-1' }]; @@ -767,6 +786,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -786,6 +807,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -821,6 +844,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -858,6 +883,8 @@ describe('SuperDoc.vue', () => { documentId: 'doc-1', editor: editorMock, }); + expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled(); + await Promise.resolve(); expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({ superdoc: superdocStub, editor: editorMock, @@ -1646,6 +1673,20 @@ describe('SuperDoc.vue', () => { expect(wrapper.find('.floating-comments').exists()).toBe(true); }); + it('shows floating comments when the comments store exposes them through a getter', async () => { + const superdocStub = createSuperdocStub(); + const { store, floatingCommentsState } = createCommentsStoreWithFloatingGetter(); + const wrapper = await mountComponent(superdocStub, { commentsStore: store }); + await nextTick(); + + floatingCommentsState.value = [{ commentId: 'tracked-1' }]; + superdocStoreStub.isReady.value = true; + await nextTick(); + + expect(wrapper.vm.showCommentsSidebar).toBe(true); + expect(wrapper.find('.floating-comments').exists()).toBe(true); + }); + it('hides floating comments sidebar entirely in viewing mode even with comment positions', async () => { const superdocStub = createSuperdocStub(); superdocStub.config.documentMode = 'viewing'; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 2626e44d8c..167a16dfda 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -8,6 +8,7 @@ import { getCurrentInstance, inject, ref, + unref, onMounted, onBeforeUnmount, nextTick, @@ -104,7 +105,6 @@ const { isCommentsListVisible, isFloatingCommentsReady, generalCommentIds, - getFloatingComments, hasSyncedCollaborationComments, editorCommentPositions, hasInitializedLocations, @@ -128,6 +128,11 @@ const { const { proxy } = getCurrentInstance(); commentsStore.proxy = proxy; +const floatingComments = computed(() => { + const currentFloatingComments = unref(commentsStore.getFloatingComments); + return Array.isArray(currentFloatingComments) ? currentFloatingComments : []; +}); + const { isHighContrastMode } = useHighContrastMode(); const { uiFontFamily } = useUiFontFamily(); @@ -241,6 +246,36 @@ const flushPendingReplayTrackedChangeSync = () => { syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor: proxy.$superdoc?.activeEditor }); }; +let queuedTrackedChangeCommentResync = null; +let isTrackedChangeCommentResyncQueued = false; + +const flushQueuedTrackedChangeCommentResync = () => { + isTrackedChangeCommentResyncQueued = false; + + const pendingResync = queuedTrackedChangeCommentResync; + queuedTrackedChangeCommentResync = null; + if (!pendingResync?.editor) return; + + syncTrackedChangeComments({ + superdoc: proxy.$superdoc, + editor: pendingResync.editor, + broadcastChanges: pendingResync.broadcastChanges, + }); +}; + +const queueTrackedChangeCommentResync = ({ editor, broadcastChanges = true } = {}) => { + if (!editor) return; + + queuedTrackedChangeCommentResync = { + editor, + broadcastChanges: Boolean(queuedTrackedChangeCommentResync?.broadcastChanges) || Boolean(broadcastChanges), + }; + + if (isTrackedChangeCommentResyncQueued) return; + isTrackedChangeCommentResyncQueued = true; + queueMicrotask(flushQueuedTrackedChangeCommentResync); +}; + const scheduleReplayTrackedChangeSync = () => { pendingReplayTrackedChangeSync.value = true; @@ -1131,8 +1166,7 @@ const onEditorTransaction = (payload = {}) => { if (shouldResyncTrackedChangeThreads(transaction, ySyncMeta)) { const documentId = editor?.options?.documentId; syncTrackedChangePositionsWithDocument({ documentId, editor }); - syncTrackedChangeComments({ - superdoc: proxy.$superdoc, + queueTrackedChangeCommentResync({ editor, // Remote replay should rebuild only local sidebar state. The authoritative // collaboration comment update is already shared through the comments ydoc. @@ -1148,7 +1182,7 @@ const showCommentsSidebar = computed(() => { if (!shouldRenderCommentsInViewing.value) return false; return ( pendingComment.value || - (getFloatingComments.value?.length > 0 && + (floatingComments.value.length > 0 && isReady.value && layers.value && isCommentsEnabled.value && @@ -1490,7 +1524,7 @@ watch( // Ensure hasInitializedLocations is set when comments arrive (backup for cases // where handleDocumentReady hasn't fired yet). Never toggle false→true→false — // the virtualized FloatingComments reacts to comment changes via computed properties. -watch(getFloatingComments, () => { +watch(floatingComments, () => { if (!hasInitializedLocations.value) { hasInitializedLocations.value = true; } @@ -1648,7 +1682,7 @@ const getPDFViewer = () => {