Skip to content

Commit bc809f8

Browse files
authored
fix: allow putting cursor after inline sdt field (#2763)
* fix: allow putting cursor after inline sdt field * fix: simplify code; create helper file
1 parent adf4ea6 commit bc809f8

8 files changed

Lines changed: 603 additions & 28 deletions

File tree

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: 21 additions & 3 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,7 +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-
6666
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
6767

6868
type CommentThreadHit = {
@@ -1294,17 +1294,35 @@ export class EditorInputManager {
12941294
// selection so caret placement/editing inside table cells works.
12951295
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null;
12961296
let nextSelection: Selection;
1297+
let inlineSdtBoundaryPos: number | null = null;
1298+
let inlineSdtBoundaryDirection: 'before' | 'after' | null = null;
12971299
const insideTableInSdt =
12981300
!!sdtBlock && this.#isInsideTableWithinStructuredContentBlock(doc, hit.pos, sdtBlock.pos);
12991301
if (sdtBlock && !insideTableInSdt) {
13001302
nextSelection = NodeSelection.create(doc, sdtBlock.pos);
13011303
} else {
1302-
nextSelection = TextSelection.create(doc, hit.pos);
1304+
const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hit.pos) : null;
1305+
if (inlineSdt && hit.pos >= inlineSdt.end) {
1306+
const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize;
1307+
inlineSdtBoundaryPos = afterInlineSdt;
1308+
inlineSdtBoundaryDirection = 'after';
1309+
nextSelection = TextSelection.create(doc, afterInlineSdt);
1310+
} else if (inlineSdt && hit.pos <= inlineSdt.start) {
1311+
inlineSdtBoundaryPos = inlineSdt.pos;
1312+
inlineSdtBoundaryDirection = 'before';
1313+
nextSelection = TextSelection.create(doc, inlineSdt.pos);
1314+
} else {
1315+
nextSelection = TextSelection.create(doc, hit.pos);
1316+
}
13031317
if (!nextSelection.$from.parent.inlineContent) {
13041318
nextSelection = Selection.near(doc.resolve(hit.pos), 1);
13051319
}
13061320
}
1307-
const tr = editor.state.tr.setSelection(nextSelection);
1321+
let tr = editor.state.tr.setSelection(nextSelection);
1322+
if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) {
1323+
tr = applyEditableSlotAtInlineBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection);
1324+
nextSelection = tr.selection;
1325+
}
13081326
// Preserve stored marks (e.g., formatting selected from toolbar before clicking)
13091327
if (nextSelection instanceof TextSelection && nextSelection.empty && editor.state.storedMarks) {
13101328
tr.setStoredMarks(editor.state.storedMarks);

packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const { mockTextSelectionCreate, mockNodeSelectionCreate } = vi.hoisted(() => ({
66
mockTextSelectionCreate: vi.fn(),
77
mockNodeSelectionCreate: vi.fn(),
88
}));
9+
const { mockApplyEditableSlotAtInlineBoundary } = vi.hoisted(() => ({
10+
mockApplyEditableSlotAtInlineBoundary: vi.fn(),
11+
}));
912

1013
vi.mock('../input/PositionHitResolver.js', () => ({
1114
resolvePointerPositionHit: vi.fn(() => ({
@@ -22,6 +25,10 @@ vi.mock('@superdoc/layout-bridge', () => ({
2225
getFragmentAtPosition: vi.fn(() => null),
2326
}));
2427

28+
vi.mock('@helpers/ensure-editable-slot-inline-boundary.js', () => ({
29+
applyEditableSlotAtInlineBoundary: mockApplyEditableSlotAtInlineBoundary,
30+
}));
31+
2532
vi.mock('prosemirror-state', async (importOriginal) => {
2633
const original = await importOriginal<typeof import('prosemirror-state')>();
2734
return {
@@ -51,7 +58,7 @@ function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent {
5158
);
5259
}
5360

54-
function createMockDoc(mode: 'tableInSdt' | 'plainSdt') {
61+
function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary') {
5562
return {
5663
content: { size: 200 },
5764
nodeAt: vi.fn(() => ({ nodeSize: 20 })),
@@ -69,6 +76,19 @@ function createMockDoc(mode: 'tableInSdt' | 'plainSdt') {
6976
end: (depth: number) => (depth === 1 ? 30 : 29),
7077
};
7178
}
79+
if (mode === 'inlineSdtAfterBoundary') {
80+
return {
81+
depth: 2,
82+
node: (depth: number) => {
83+
if (depth === 2) return { type: { name: 'structuredContent' }, nodeSize: 3 };
84+
if (depth === 1) return { type: { name: 'paragraph' } };
85+
return { type: { name: 'doc' } };
86+
},
87+
before: (depth: number) => (depth === 2 ? 10 : 0),
88+
start: (depth: number) => (depth === 2 ? 11 : 1),
89+
end: (depth: number) => (depth === 2 ? 12 : 199),
90+
};
91+
}
7292
return {
7393
depth: 1,
7494
node: (depth: number) => {
@@ -126,13 +146,21 @@ describe('EditorInputManager structuredContentBlock table exception', () => {
126146
beforeEach(async () => {
127147
mockTextSelectionCreate.mockReset();
128148
mockNodeSelectionCreate.mockReset();
149+
mockApplyEditableSlotAtInlineBoundary.mockReset();
129150
mockTextSelectionCreate.mockReturnValue({
130151
empty: true,
131152
$from: { parent: { inlineContent: true } },
132153
});
133154
mockNodeSelectionCreate.mockReturnValue({
134155
empty: false,
135156
});
157+
mockApplyEditableSlotAtInlineBoundary.mockImplementation((tr) => {
158+
tr.selection = {
159+
empty: true,
160+
$from: { parent: { inlineContent: true } },
161+
};
162+
return tr;
163+
});
136164

137165
viewportHost = document.createElement('div');
138166
visibleHost = document.createElement('div');
@@ -252,4 +280,27 @@ describe('EditorInputManager structuredContentBlock table exception', () => {
252280
expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled();
253281
expect(mockNodeSelectionCreate).toHaveBeenCalled();
254282
});
283+
284+
it('applies inline structured content boundary handling when the click lands at the trailing edge', () => {
285+
mountWithDoc('inlineSdtAfterBoundary');
286+
const target = document.createElement('span');
287+
viewportHost.appendChild(target);
288+
289+
const PointerEventImpl = getPointerEventImpl();
290+
target.dispatchEvent(
291+
new PointerEventImpl('pointerdown', {
292+
bubbles: true,
293+
cancelable: true,
294+
button: 0,
295+
buttons: 1,
296+
clientX: 28,
297+
clientY: 28,
298+
} as PointerEventInit),
299+
);
300+
301+
expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled();
302+
expect(mockTextSelectionCreate).toHaveBeenCalledWith(mockEditor.state.doc, 13);
303+
expect(mockApplyEditableSlotAtInlineBoundary).toHaveBeenCalledWith(mockEditor.state.tr, 13, 'after');
304+
expect(mockNodeSelectionCreate).not.toHaveBeenCalled();
305+
});
255306
});

0 commit comments

Comments
 (0)