From cc5f5aa096e9e2a2ccaeef5fe6ee6ff1ab4d1828 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 9 Feb 2026 13:29:06 +0200 Subject: [PATCH 1/3] fix: disable footnotes typing --- .../pointer-events/EditorInputManager.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index c613bfd6d4..c02a22d413 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -50,6 +50,11 @@ const SCROLL_DETECTION_TOLERANCE_PX = 1; const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); +/** Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder). */ +function isFootnoteBlockId(blockId: string): boolean { + return typeof blockId === 'string' && blockId.startsWith('footnote-'); +} + // ============================================================================= // Types // ============================================================================= @@ -881,6 +886,12 @@ export class EditorInputManager { return; } + // Disallow cursor placement in footnote lines (footnote content is read-only in the layout) + if (isFootnoteBlockId(rawHit.blockId)) { + this.#focusEditorAtFirstPosition(); + return; + } + if (!hit || !doc) { this.#callbacks.setPendingDocChange?.(); this.#callbacks.scheduleRerender?.(); @@ -1429,6 +1440,9 @@ export class EditorInputManager { if (!rawHit) return; + // Don't extend selection into footnote lines + if (isFootnoteBlockId(rawHit.blockId)) return; + const editor = this.#deps.getEditor(); const doc = editor.state?.doc; if (!doc) return; From 2a0cd1cea123512f8e2bcf73a2c5f074e3f40d7c Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 9 Feb 2026 18:20:37 +0200 Subject: [PATCH 2/3] fix: prevent cursor from placing in the footnotes block --- packages/layout-engine/painters/dom/src/renderer.ts | 5 +++++ .../pointer-events/EditorInputManager.ts | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 42774924a6..51f71f27f9 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5015,6 +5015,11 @@ export class DomPainter { el.dataset.blockId = fragment.blockId; el.dataset.layoutEpoch = String(this.layoutEpoch); + // Footnote content is read-only: prevent cursor placement and typing (blockId prefix from FootnotesBuilder) + if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { + el.setAttribute('contenteditable', 'false'); + } + if (fragment.kind === 'para') { // Assert PM positions are present for paragraph fragments // Only validate for body sections - header/footer fragments have their own PM coordinate space diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index c02a22d413..a4fd944216 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -831,6 +831,15 @@ export class EditorInputManager { const { x, y } = normalizedPoint; this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; + // Disallow cursor placement in footnote lines: check the clicked DOM element (painter sets data-block-id on fragments) + 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.#focusEditorAtFirstPosition(); + return; + } + // Check header/footer session state const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; if (sessionMode !== 'body') { From c810470f1aa8c9c4a5ef04ec8f112c8ee6f80ac8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 9 Feb 2026 10:00:25 -0800 Subject: [PATCH 3/3] fix: keep current selection when clicking footnotes in presentation editor --- .../pointer-events/EditorInputManager.ts | 9 +- .../EditorInputManager.footnoteClick.test.ts | 190 ++++++++++++++++++ 2 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index a4fd944216..5115496704 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -831,12 +831,12 @@ export class EditorInputManager { const { x, y } = normalizedPoint; this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; - // Disallow cursor placement in footnote lines: check the clicked DOM element (painter sets data-block-id on fragments) + // 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.#focusEditorAtFirstPosition(); + this.#focusEditor(); return; } @@ -895,9 +895,10 @@ export class EditorInputManager { return; } - // Disallow cursor placement in footnote lines (footnote content is read-only in the layout) + // 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)) { - this.#focusEditorAtFirstPosition(); + this.#focusEditor(); return; } diff --git a/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts new file mode 100644 index 0000000000..4538e9f318 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { clickToPosition } from '@superdoc/layout-bridge'; +import { TextSelection } from 'prosemirror-state'; + +import { + EditorInputManager, + type EditorInputDependencies, + type EditorInputCallbacks, +} from '../pointer-events/EditorInputManager.js'; + +vi.mock('@superdoc/layout-bridge', () => ({ + clickToPosition: vi.fn(() => ({ pos: 12, layoutEpoch: 1, pageIndex: 0, blockId: 'body-1' })), + getFragmentAtPosition: vi.fn(() => null), +})); + +vi.mock('prosemirror-state', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + TextSelection: { + ...original.TextSelection, + create: vi.fn(() => ({ + empty: true, + $from: { parent: { inlineContent: true } }, + })), + }, + }; +}); + +describe('EditorInputManager - Footnote click selection behavior', () => { + let manager: EditorInputManager; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let mockEditor: { + isEditable: boolean; + state: { + doc: { content: { size: number }; nodesBetween: Mock }; + tr: { setSelection: Mock; setStoredMarks: Mock }; + selection: { $anchor: null }; + storedMarks: null; + }; + view: { + dispatch: Mock; + dom: HTMLElement; + focus: Mock; + hasFocus: Mock; + }; + on: Mock; + off: Mock; + emit: Mock; + }; + let mockDeps: EditorInputDependencies; + let mockCallbacks: EditorInputCallbacks; + + beforeEach(() => { + viewportHost = document.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + visibleHost = document.createElement('div'); + visibleHost.className = 'presentation-editor__visible'; + visibleHost.appendChild(viewportHost); + + const container = document.createElement('div'); + container.className = 'presentation-editor'; + container.appendChild(visibleHost); + document.body.appendChild(container); + + mockEditor = { + isEditable: true, + state: { + doc: { + content: { size: 100 }, + nodesBetween: vi.fn((from, to, cb) => { + cb({ isTextblock: true }, 0); + }), + }, + tr: { + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), + }, + selection: { $anchor: null }, + storedMarks: null, + }, + view: { + dispatch: vi.fn(), + dom: document.createElement('div'), + focus: vi.fn(), + hasFocus: vi.fn(() => false), + }, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + + mockDeps = { + getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + })) as unknown as EditorInputDependencies['getEpochMapper'], + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical'), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing'), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }; + + mockCallbacks = { + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + scheduleSelectionUpdate: vi.fn(), + updateSelectionDebugHud: vi.fn(), + }; + + manager = new EditorInputManager(); + manager.setDependencies(mockDeps); + manager.setCallbacks(mockCallbacks); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { + return ( + (globalThis as unknown as { PointerEvent?: typeof PointerEvent; MouseEvent: typeof MouseEvent }).PointerEvent ?? + globalThis.MouseEvent + ); + } + + it('does not change editor selection on direct footnote fragment click', () => { + 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: 10, + clientY: 10, + } as PointerEventInit), + ); + + // Expected behavior: footnote click should not relocate caret to start of the document. + 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', () => { + (clickToPosition as unknown as Mock).mockReturnValue({ + pos: 22, + layoutEpoch: 1, + pageIndex: 0, + blockId: 'footnote-1-1', + }); + + 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: 12, + clientY: 14, + } as PointerEventInit), + ); + + // Expected behavior: block edits in footnotes without resetting user selection. + expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + }); +});