Skip to content

Commit 92b4d62

Browse files
authored
fix: disable footnotes typing (#1974)
* fix: disable footnotes typing * fix: prevent cursor from placing in the footnotes block * fix: keep current selection when clicking footnotes in presentation editor
1 parent 3ca7aa3 commit 92b4d62

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5015,6 +5015,11 @@ export class DomPainter {
50155015
el.dataset.blockId = fragment.blockId;
50165016
el.dataset.layoutEpoch = String(this.layoutEpoch);
50175017

5018+
// Footnote content is read-only: prevent cursor placement and typing (blockId prefix from FootnotesBuilder)
5019+
if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) {
5020+
el.setAttribute('contenteditable', 'false');
5021+
}
5022+
50185023
if (fragment.kind === 'para') {
50195024
// Assert PM positions are present for paragraph fragments
50205025
// Only validate for body sections - header/footer fragments have their own PM coordinate space

packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ const SCROLL_DETECTION_TOLERANCE_PX = 1;
5050

5151
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
5252

53+
/** Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder). */
54+
function isFootnoteBlockId(blockId: string): boolean {
55+
return typeof blockId === 'string' && blockId.startsWith('footnote-');
56+
}
57+
5358
// =============================================================================
5459
// Types
5560
// =============================================================================
@@ -826,6 +831,15 @@ export class EditorInputManager {
826831
const { x, y } = normalizedPoint;
827832
this.#debugLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y };
828833

834+
// Disallow cursor placement in footnote lines: keep current selection and only focus editor.
835+
const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null;
836+
const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? '';
837+
if (isFootnoteBlockId(clickedBlockId)) {
838+
if (!isDraggableAnnotation) event.preventDefault();
839+
this.#focusEditor();
840+
return;
841+
}
842+
829843
// Check header/footer session state
830844
const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
831845
if (sessionMode !== 'body') {
@@ -881,6 +895,13 @@ export class EditorInputManager {
881895
return;
882896
}
883897

898+
// Disallow cursor placement in footnote lines (footnote content is read-only in the layout).
899+
// Keep the current selection unchanged instead of moving caret to document start.
900+
if (isFootnoteBlockId(rawHit.blockId)) {
901+
this.#focusEditor();
902+
return;
903+
}
904+
884905
if (!hit || !doc) {
885906
this.#callbacks.setPendingDocChange?.();
886907
this.#callbacks.scheduleRerender?.();
@@ -1429,6 +1450,9 @@ export class EditorInputManager {
14291450

14301451
if (!rawHit) return;
14311452

1453+
// Don't extend selection into footnote lines
1454+
if (isFootnoteBlockId(rawHit.blockId)) return;
1455+
14321456
const editor = this.#deps.getEditor();
14331457
const doc = editor.state?.doc;
14341458
if (!doc) return;
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
2+
3+
import { clickToPosition } from '@superdoc/layout-bridge';
4+
import { TextSelection } from 'prosemirror-state';
5+
6+
import {
7+
EditorInputManager,
8+
type EditorInputDependencies,
9+
type EditorInputCallbacks,
10+
} from '../pointer-events/EditorInputManager.js';
11+
12+
vi.mock('@superdoc/layout-bridge', () => ({
13+
clickToPosition: vi.fn(() => ({ pos: 12, layoutEpoch: 1, pageIndex: 0, blockId: 'body-1' })),
14+
getFragmentAtPosition: vi.fn(() => null),
15+
}));
16+
17+
vi.mock('prosemirror-state', async (importOriginal) => {
18+
const original = await importOriginal<typeof import('prosemirror-state')>();
19+
return {
20+
...original,
21+
TextSelection: {
22+
...original.TextSelection,
23+
create: vi.fn(() => ({
24+
empty: true,
25+
$from: { parent: { inlineContent: true } },
26+
})),
27+
},
28+
};
29+
});
30+
31+
describe('EditorInputManager - Footnote click selection behavior', () => {
32+
let manager: EditorInputManager;
33+
let viewportHost: HTMLElement;
34+
let visibleHost: HTMLElement;
35+
let mockEditor: {
36+
isEditable: boolean;
37+
state: {
38+
doc: { content: { size: number }; nodesBetween: Mock };
39+
tr: { setSelection: Mock; setStoredMarks: Mock };
40+
selection: { $anchor: null };
41+
storedMarks: null;
42+
};
43+
view: {
44+
dispatch: Mock;
45+
dom: HTMLElement;
46+
focus: Mock;
47+
hasFocus: Mock;
48+
};
49+
on: Mock;
50+
off: Mock;
51+
emit: Mock;
52+
};
53+
let mockDeps: EditorInputDependencies;
54+
let mockCallbacks: EditorInputCallbacks;
55+
56+
beforeEach(() => {
57+
viewportHost = document.createElement('div');
58+
viewportHost.className = 'presentation-editor__viewport';
59+
visibleHost = document.createElement('div');
60+
visibleHost.className = 'presentation-editor__visible';
61+
visibleHost.appendChild(viewportHost);
62+
63+
const container = document.createElement('div');
64+
container.className = 'presentation-editor';
65+
container.appendChild(visibleHost);
66+
document.body.appendChild(container);
67+
68+
mockEditor = {
69+
isEditable: true,
70+
state: {
71+
doc: {
72+
content: { size: 100 },
73+
nodesBetween: vi.fn((from, to, cb) => {
74+
cb({ isTextblock: true }, 0);
75+
}),
76+
},
77+
tr: {
78+
setSelection: vi.fn().mockReturnThis(),
79+
setStoredMarks: vi.fn().mockReturnThis(),
80+
},
81+
selection: { $anchor: null },
82+
storedMarks: null,
83+
},
84+
view: {
85+
dispatch: vi.fn(),
86+
dom: document.createElement('div'),
87+
focus: vi.fn(),
88+
hasFocus: vi.fn(() => false),
89+
},
90+
on: vi.fn(),
91+
off: vi.fn(),
92+
emit: vi.fn(),
93+
};
94+
95+
mockDeps = {
96+
getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType<EditorInputDependencies['getActiveEditor']>),
97+
getEditor: vi.fn(() => mockEditor as unknown as ReturnType<EditorInputDependencies['getEditor']>),
98+
getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })),
99+
getEpochMapper: vi.fn(() => ({
100+
mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })),
101+
})) as unknown as EditorInputDependencies['getEpochMapper'],
102+
getViewportHost: vi.fn(() => viewportHost),
103+
getVisibleHost: vi.fn(() => visibleHost),
104+
getLayoutMode: vi.fn(() => 'vertical'),
105+
getHeaderFooterSession: vi.fn(() => null),
106+
getPageGeometryHelper: vi.fn(() => null),
107+
getZoom: vi.fn(() => 1),
108+
isViewLocked: vi.fn(() => false),
109+
getDocumentMode: vi.fn(() => 'editing'),
110+
getPageElement: vi.fn(() => null),
111+
isSelectionAwareVirtualizationEnabled: vi.fn(() => false),
112+
};
113+
114+
mockCallbacks = {
115+
normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })),
116+
scheduleSelectionUpdate: vi.fn(),
117+
updateSelectionDebugHud: vi.fn(),
118+
};
119+
120+
manager = new EditorInputManager();
121+
manager.setDependencies(mockDeps);
122+
manager.setCallbacks(mockCallbacks);
123+
manager.bind();
124+
});
125+
126+
afterEach(() => {
127+
manager.destroy();
128+
document.body.innerHTML = '';
129+
vi.clearAllMocks();
130+
});
131+
132+
function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent {
133+
return (
134+
(globalThis as unknown as { PointerEvent?: typeof PointerEvent; MouseEvent: typeof MouseEvent }).PointerEvent ??
135+
globalThis.MouseEvent
136+
);
137+
}
138+
139+
it('does not change editor selection on direct footnote fragment click', () => {
140+
const fragmentEl = document.createElement('span');
141+
fragmentEl.setAttribute('data-block-id', 'footnote-1-0');
142+
const nestedEl = document.createElement('span');
143+
fragmentEl.appendChild(nestedEl);
144+
viewportHost.appendChild(fragmentEl);
145+
146+
const PointerEventImpl = getPointerEventImpl();
147+
nestedEl.dispatchEvent(
148+
new PointerEventImpl('pointerdown', {
149+
bubbles: true,
150+
cancelable: true,
151+
button: 0,
152+
buttons: 1,
153+
clientX: 10,
154+
clientY: 10,
155+
} as PointerEventInit),
156+
);
157+
158+
// Expected behavior: footnote click should not relocate caret to start of the document.
159+
expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
160+
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
161+
});
162+
163+
it('does not change editor selection when hit-test resolves to a footnote block', () => {
164+
(clickToPosition as unknown as Mock).mockReturnValue({
165+
pos: 22,
166+
layoutEpoch: 1,
167+
pageIndex: 0,
168+
blockId: 'footnote-1-1',
169+
});
170+
171+
const target = document.createElement('span');
172+
viewportHost.appendChild(target);
173+
174+
const PointerEventImpl = getPointerEventImpl();
175+
target.dispatchEvent(
176+
new PointerEventImpl('pointerdown', {
177+
bubbles: true,
178+
cancelable: true,
179+
button: 0,
180+
buttons: 1,
181+
clientX: 12,
182+
clientY: 14,
183+
} as PointerEventInit),
184+
);
185+
186+
// Expected behavior: block edits in footnotes without resetting user selection.
187+
expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
188+
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
189+
});
190+
});

0 commit comments

Comments
 (0)