Skip to content

Commit 0965a51

Browse files
committed
fix: simplify code; create helper file
1 parent ee13514 commit 0965a51

File tree

4 files changed

+212
-155
lines changed

4 files changed

+212
-155
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { EditorState } from 'prosemirror-state';
3+
import type { Node as PMNode, Schema } from 'prosemirror-model';
4+
import { initTestEditor } from '@tests/helpers/helpers.js';
5+
import { applyEditableSlotAtInlineBoundary } from './ensure-editable-slot-inline-boundary.js';
6+
7+
function findStructuredContent(doc: PMNode): { node: PMNode; pos: number } | null {
8+
let found: { node: PMNode; pos: number } | null = null;
9+
doc.descendants((node, pos) => {
10+
if (node.type.name === 'structuredContent') {
11+
found = { node, pos };
12+
return false;
13+
}
14+
return true;
15+
});
16+
return found;
17+
}
18+
19+
function zwspCount(text: string): number {
20+
return (text.match(/\u200B/g) ?? []).length;
21+
}
22+
23+
describe('applyEditableSlotAtInlineBoundary', () => {
24+
let schema: Schema;
25+
let destroy: (() => void) | undefined;
26+
27+
beforeEach(() => {
28+
const { editor } = initTestEditor();
29+
schema = editor.schema;
30+
destroy = () => editor.destroy();
31+
});
32+
33+
afterEach(() => {
34+
destroy?.();
35+
destroy = undefined;
36+
});
37+
38+
it('inserts zero-width space after trailing inline SDT (direction after)', () => {
39+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
40+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]);
41+
const sdt = findStructuredContent(doc);
42+
expect(sdt).not.toBeNull();
43+
const afterSdt = sdt!.pos + sdt!.node.nodeSize;
44+
45+
const state = EditorState.create({ schema, doc });
46+
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');
47+
48+
expect(tr.docChanged).toBe(true);
49+
expect(zwspCount(tr.doc.textContent)).toBe(1);
50+
expect(tr.selection.from).toBe(afterSdt + 1);
51+
expect(tr.selection.empty).toBe(true);
52+
});
53+
54+
it('does not insert when text follows inline SDT (direction after)', () => {
55+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
56+
const doc = schema.nodes.doc.create(null, [
57+
schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]),
58+
]);
59+
const sdt = findStructuredContent(doc);
60+
expect(sdt).not.toBeNull();
61+
const afterSdt = sdt!.pos + sdt!.node.nodeSize;
62+
const beforeText = doc.textContent;
63+
64+
const state = EditorState.create({ schema, doc });
65+
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');
66+
67+
expect(tr.docChanged).toBe(false);
68+
expect(tr.doc.textContent).toBe(beforeText);
69+
expect(tr.selection.from).toBe(afterSdt);
70+
});
71+
72+
it('inserts zero-width space before leading inline SDT (direction before)', () => {
73+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
74+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, schema.text(' tail')])]);
75+
const sdt = findStructuredContent(doc);
76+
expect(sdt).not.toBeNull();
77+
const beforeSdt = sdt!.pos;
78+
79+
const state = EditorState.create({ schema, doc });
80+
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');
81+
82+
expect(tr.docChanged).toBe(true);
83+
expect(zwspCount(tr.doc.textContent)).toBe(1);
84+
expect(tr.doc.textContent).toContain('tail');
85+
expect(tr.selection.from).toBe(beforeSdt + 1);
86+
});
87+
88+
it('does not insert when text precedes inline SDT (direction before)', () => {
89+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
90+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]);
91+
const sdt = findStructuredContent(doc);
92+
expect(sdt).not.toBeNull();
93+
const beforeSdt = sdt!.pos;
94+
const beforeText = doc.textContent;
95+
96+
const state = EditorState.create({ schema, doc });
97+
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');
98+
99+
expect(tr.docChanged).toBe(false);
100+
expect(tr.doc.textContent).toBe(beforeText);
101+
expect(tr.selection.from).toBe(beforeSdt);
102+
});
103+
104+
it('inserts when following sibling is an empty run (direction after)', () => {
105+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
106+
const emptyRun = schema.nodes.run.create();
107+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, emptyRun])]);
108+
const sdt = findStructuredContent(doc);
109+
expect(sdt).not.toBeNull();
110+
const afterSdt = sdt!.pos + sdt!.node.nodeSize;
111+
112+
const state = EditorState.create({ schema, doc });
113+
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');
114+
115+
expect(tr.docChanged).toBe(true);
116+
expect(zwspCount(tr.doc.textContent)).toBe(1);
117+
expect(tr.selection.from).toBe(afterSdt + 1);
118+
});
119+
120+
it('inserts when preceding sibling is an empty run (direction before)', () => {
121+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
122+
const emptyRun = schema.nodes.run.create();
123+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [emptyRun, inlineSdt])]);
124+
const sdt = findStructuredContent(doc);
125+
expect(sdt).not.toBeNull();
126+
const beforeSdt = sdt!.pos;
127+
128+
const state = EditorState.create({ schema, doc });
129+
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');
130+
131+
expect(tr.docChanged).toBe(true);
132+
expect(zwspCount(tr.doc.textContent)).toBe(1);
133+
expect(tr.selection.from).toBe(beforeSdt + 1);
134+
});
135+
136+
it('clamps an oversized position to doc end then may insert zero-width space (direction after)', () => {
137+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
138+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]);
139+
const sizeBefore = doc.content.size;
140+
141+
const state = EditorState.create({ schema, doc });
142+
const tr = applyEditableSlotAtInlineBoundary(state.tr, sizeBefore + 999, 'after');
143+
144+
// Clamps to `doc.content.size`; gap after last inline has no node → ZWSP + caret (size may grow by schema-specific steps).
145+
expect(tr.docChanged).toBe(true);
146+
expect(tr.doc.content.size).toBeGreaterThan(sizeBefore);
147+
expect(zwspCount(tr.doc.textContent)).toBeGreaterThanOrEqual(1);
148+
expect(tr.selection.from).toBeGreaterThan(0);
149+
expect(tr.selection.from).toBeLessThanOrEqual(tr.doc.content.size);
150+
});
151+
152+
it('clamps a negative position to 0 then may insert zero-width space (direction before)', () => {
153+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
154+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]);
155+
156+
const state = EditorState.create({ schema, doc });
157+
const tr = applyEditableSlotAtInlineBoundary(state.tr, -999, 'before');
158+
159+
expect(tr.docChanged).toBe(true);
160+
expect(zwspCount(tr.doc.textContent)).toBe(1);
161+
expect(tr.selection.from).toBe(1);
162+
});
163+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Node as PMNode } from 'prosemirror-model';
2+
import { TextSelection, type Transaction } from 'prosemirror-state';
3+
4+
function needsEditableSlot(node: PMNode | null | undefined, side: 'before' | 'after'): boolean {
5+
if (!node) return true;
6+
const name = node.type.name;
7+
if (name === 'hardBreak' || name === 'lineBreak' || name === 'structuredContent') return true;
8+
if (name === 'run') return !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText);
9+
return false;
10+
}
11+
12+
/**
13+
* Ensures a collapsed caret can live at an inline structuredContent boundary by
14+
* inserting ZWSP when the adjacent slice has no text (keyboard + presentation clicks).
15+
*/
16+
export function applyEditableSlotAtInlineBoundary(
17+
tr: Transaction,
18+
pos: number,
19+
direction: 'before' | 'after',
20+
): Transaction {
21+
const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size));
22+
if (direction === 'before') {
23+
const $pos = tr.doc.resolve(clampedPos);
24+
if (!needsEditableSlot($pos.nodeBefore, 'before')) {
25+
return tr.setSelection(TextSelection.create(tr.doc, clampedPos));
26+
}
27+
tr.insertText('\u200B', clampedPos);
28+
return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
29+
}
30+
if (!needsEditableSlot(tr.doc.nodeAt(clampedPos), 'after')) {
31+
return tr.setSelection(TextSelection.create(tr.doc, clampedPos));
32+
}
33+
tr.insertText('\u200B', clampedPos);
34+
return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
35+
}

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

Lines changed: 9 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from '../tables/TableSelectionUtilities.js';
4040
import { debugLog } from '../selection/SelectionDebug.js';
4141
import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract';
42+
import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js';
4243
import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js';
4344
import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js';
4445

@@ -62,11 +63,6 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray<readonly [number, number]
6263
[0, -COMMENT_THREAD_HIT_TOLERANCE_PX],
6364
[0, COMMENT_THREAD_HIT_TOLERANCE_PX],
6465
];
65-
// Boundary clicks are intentionally forgiving so near-edge clicks can place the
66-
// caret before/after an inline SDT instead of selecting it.
67-
const INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX = 12;
68-
const INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX = 4;
69-
7066
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
7167

7268
type CommentThreadHit = {
@@ -1221,18 +1217,10 @@ export class EditorInputManager {
12211217

12221218
// Track click depth for multi-click
12231219
const clickDepth = this.#registerPointerClick(event);
1224-
const hitPos = this.#normalizeInlineSdtBoundaryHitPosition(
1225-
target,
1226-
event.clientX,
1227-
event.clientY,
1228-
doc,
1229-
hit.pos,
1230-
clickDepth,
1231-
);
12321220

12331221
// Set up drag selection state
12341222
if (clickDepth === 1) {
1235-
this.#dragAnchor = hitPos;
1223+
this.#dragAnchor = hit.pos;
12361224
this.#dragAnchorPageIndex = hit.pageIndex;
12371225
this.#pendingMarginClick = this.#callbacks.computePendingMarginClick?.(event.pointerId, x, y) ?? null;
12381226

@@ -1302,33 +1290,33 @@ export class EditorInputManager {
13021290
if (!handledByDepth) {
13031291
try {
13041292
// SD-1584: clicking inside a block SDT selects the node (NodeSelection).
1305-
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hitPos) : null;
1293+
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null;
13061294
let nextSelection: Selection;
13071295
let inlineSdtBoundaryPos: number | null = null;
13081296
let inlineSdtBoundaryDirection: 'before' | 'after' | null = null;
13091297
if (sdtBlock) {
13101298
nextSelection = NodeSelection.create(doc, sdtBlock.pos);
13111299
} else {
1312-
const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hitPos) : null;
1313-
if (inlineSdt && hitPos >= inlineSdt.end) {
1300+
const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hit.pos) : null;
1301+
if (inlineSdt && hit.pos >= inlineSdt.end) {
13141302
const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize;
13151303
inlineSdtBoundaryPos = afterInlineSdt;
13161304
inlineSdtBoundaryDirection = 'after';
13171305
nextSelection = TextSelection.create(doc, afterInlineSdt);
1318-
} else if (inlineSdt && hitPos <= inlineSdt.start) {
1306+
} else if (inlineSdt && hit.pos <= inlineSdt.start) {
13191307
inlineSdtBoundaryPos = inlineSdt.pos;
13201308
inlineSdtBoundaryDirection = 'before';
13211309
nextSelection = TextSelection.create(doc, inlineSdt.pos);
13221310
} else {
1323-
nextSelection = TextSelection.create(doc, hitPos);
1311+
nextSelection = TextSelection.create(doc, hit.pos);
13241312
}
13251313
if (!nextSelection.$from.parent.inlineContent) {
1326-
nextSelection = Selection.near(doc.resolve(hitPos), 1);
1314+
nextSelection = Selection.near(doc.resolve(hit.pos), 1);
13271315
}
13281316
}
13291317
let tr = editor.state.tr.setSelection(nextSelection);
13301318
if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) {
1331-
tr = this.#ensureEditableSlotAtInlineSdtBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection);
1319+
tr = applyEditableSlotAtInlineBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection);
13321320
nextSelection = tr.selection;
13331321
}
13341322
// Preserve stored marks (e.g., formatting selected from toolbar before clicking)
@@ -1344,98 +1332,6 @@ export class EditorInputManager {
13441332
this.#callbacks.scheduleSelectionUpdate?.();
13451333
}
13461334

1347-
#normalizeInlineSdtBoundaryHitPosition(
1348-
target: HTMLElement,
1349-
clientX: number,
1350-
clientY: number,
1351-
doc: ProseMirrorNode,
1352-
fallbackPos: number,
1353-
clickDepth: number,
1354-
): number {
1355-
if (clickDepth !== 1) return fallbackPos;
1356-
1357-
const line =
1358-
target.closest(`.${DOM_CLASS_NAMES.LINE}`) ??
1359-
(typeof document.elementsFromPoint === 'function'
1360-
? (document
1361-
.elementsFromPoint(clientX, clientY)
1362-
.find((element) => element instanceof HTMLElement && element.closest(`.${DOM_CLASS_NAMES.LINE}`))
1363-
?.closest(`.${DOM_CLASS_NAMES.LINE}`) as HTMLElement | null)
1364-
: null);
1365-
if (!line) return fallbackPos;
1366-
1367-
const wrappers = Array.from(line.querySelectorAll<HTMLElement>(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`));
1368-
const wrapper = wrappers.find((candidate) => {
1369-
const rect = candidate.getBoundingClientRect();
1370-
const verticallyAligned = clientY >= rect.top - 2 && clientY <= rect.bottom + 2;
1371-
if (!verticallyAligned) return false;
1372-
1373-
const nearLeftEdge =
1374-
clientX >= rect.left - INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX &&
1375-
clientX <= rect.left + INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX;
1376-
const nearRightEdge =
1377-
clientX >= rect.right - INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX &&
1378-
clientX <= rect.right + INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX;
1379-
return nearLeftEdge || nearRightEdge;
1380-
});
1381-
if (!wrapper) return fallbackPos;
1382-
1383-
const rect = wrapper.getBoundingClientRect();
1384-
// Treat clicks near left edge as "before SDT", and right half as "after SDT" intent.
1385-
const leftSideThreshold = rect.left + rect.width * 0.2;
1386-
const rightSideThreshold = rect.left + rect.width * 0.5;
1387-
if (clientX <= leftSideThreshold) {
1388-
const pmStartRaw = wrapper.dataset.pmStart;
1389-
const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN;
1390-
if (!Number.isFinite(pmStart)) return fallbackPos;
1391-
return Math.max(0, Math.min(pmStart, doc.content.size));
1392-
}
1393-
if (clientX < rightSideThreshold) return fallbackPos;
1394-
1395-
const pmEndRaw = wrapper.dataset.pmEnd;
1396-
const pmEnd = pmEndRaw != null ? Number(pmEndRaw) : NaN;
1397-
if (!Number.isFinite(pmEnd)) return fallbackPos;
1398-
return Math.max(0, Math.min(pmEnd + 1, doc.content.size));
1399-
}
1400-
1401-
#ensureEditableSlotAtInlineSdtBoundary<
1402-
T extends {
1403-
doc: ProseMirrorNode;
1404-
insertText: (text: string, from?: number, to?: number) => unknown;
1405-
setSelection: (selection: Selection) => unknown;
1406-
selection: Selection;
1407-
},
1408-
>(tr: T, pos: number, direction: 'before' | 'after'): T {
1409-
const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size));
1410-
const needsEditableSlot = (node: ProseMirrorNode | null | undefined, side: 'before' | 'after') =>
1411-
!node ||
1412-
node.type?.name === 'hardBreak' ||
1413-
node.type?.name === 'lineBreak' ||
1414-
node.type?.name === 'structuredContent' ||
1415-
(node.type?.name === 'run' && !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText));
1416-
1417-
if (direction === 'before') {
1418-
const $pos = tr.doc.resolve(clampedPos);
1419-
const nodeBefore = $pos.nodeBefore;
1420-
const shouldInsertBefore = needsEditableSlot(nodeBefore, 'before');
1421-
1422-
if (!shouldInsertBefore) return tr;
1423-
1424-
tr.insertText('\u200B', clampedPos);
1425-
tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
1426-
return tr;
1427-
}
1428-
1429-
const nodeAfter = tr.doc.nodeAt(clampedPos);
1430-
const shouldInsertAfter = needsEditableSlot(nodeAfter, 'after');
1431-
1432-
if (!shouldInsertAfter) return tr;
1433-
1434-
tr.insertText('\u200B', clampedPos);
1435-
tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
1436-
return tr;
1437-
}
1438-
14391335
#handlePointerMove(event: PointerEvent): void {
14401336
if (!this.#deps) return;
14411337

0 commit comments

Comments
 (0)