Skip to content

Commit ee13514

Browse files
committed
fix: allow putting cursor after inline sdt field
1 parent a995731 commit ee13514

File tree

5 files changed

+496
-29
lines changed

5 files changed

+496
-29
lines changed

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

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray<readonly [number, number]
6262
[0, -COMMENT_THREAD_HIT_TOLERANCE_PX],
6363
[0, COMMENT_THREAD_HIT_TOLERANCE_PX],
6464
];
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;
6569

6670
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
6771

@@ -1217,10 +1221,18 @@ export class EditorInputManager {
12171221

12181222
// Track click depth for multi-click
12191223
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+
);
12201232

12211233
// Set up drag selection state
12221234
if (clickDepth === 1) {
1223-
this.#dragAnchor = hit.pos;
1235+
this.#dragAnchor = hitPos;
12241236
this.#dragAnchorPageIndex = hit.pageIndex;
12251237
this.#pendingMarginClick = this.#callbacks.computePendingMarginClick?.(event.pointerId, x, y) ?? null;
12261238

@@ -1290,17 +1302,35 @@ export class EditorInputManager {
12901302
if (!handledByDepth) {
12911303
try {
12921304
// SD-1584: clicking inside a block SDT selects the node (NodeSelection).
1293-
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null;
1305+
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hitPos) : null;
12941306
let nextSelection: Selection;
1307+
let inlineSdtBoundaryPos: number | null = null;
1308+
let inlineSdtBoundaryDirection: 'before' | 'after' | null = null;
12951309
if (sdtBlock) {
12961310
nextSelection = NodeSelection.create(doc, sdtBlock.pos);
12971311
} else {
1298-
nextSelection = TextSelection.create(doc, hit.pos);
1312+
const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hitPos) : null;
1313+
if (inlineSdt && hitPos >= inlineSdt.end) {
1314+
const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize;
1315+
inlineSdtBoundaryPos = afterInlineSdt;
1316+
inlineSdtBoundaryDirection = 'after';
1317+
nextSelection = TextSelection.create(doc, afterInlineSdt);
1318+
} else if (inlineSdt && hitPos <= inlineSdt.start) {
1319+
inlineSdtBoundaryPos = inlineSdt.pos;
1320+
inlineSdtBoundaryDirection = 'before';
1321+
nextSelection = TextSelection.create(doc, inlineSdt.pos);
1322+
} else {
1323+
nextSelection = TextSelection.create(doc, hitPos);
1324+
}
12991325
if (!nextSelection.$from.parent.inlineContent) {
1300-
nextSelection = Selection.near(doc.resolve(hit.pos), 1);
1326+
nextSelection = Selection.near(doc.resolve(hitPos), 1);
13011327
}
13021328
}
1303-
const tr = editor.state.tr.setSelection(nextSelection);
1329+
let tr = editor.state.tr.setSelection(nextSelection);
1330+
if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) {
1331+
tr = this.#ensureEditableSlotAtInlineSdtBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection);
1332+
nextSelection = tr.selection;
1333+
}
13041334
// Preserve stored marks (e.g., formatting selected from toolbar before clicking)
13051335
if (nextSelection instanceof TextSelection && nextSelection.empty && editor.state.storedMarks) {
13061336
tr.setStoredMarks(editor.state.storedMarks);
@@ -1314,6 +1344,98 @@ export class EditorInputManager {
13141344
this.#callbacks.scheduleSelectionUpdate?.();
13151345
}
13161346

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+
13171439
#handlePointerMove(event: PointerEvent): void {
13181440
if (!this.#deps) return;
13191441

packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,54 @@ describe('DomPointerMapping', () => {
274274

275275
expect(result).not.toBeNull();
276276
expect(result).toBeGreaterThanOrEqual(0);
277-
expect(result).toBeLessThanOrEqual(20);
277+
expect(result).toBeLessThanOrEqual(21);
278+
});
279+
280+
it('returns the position after a terminal inline SDT when clicking to its visual right', () => {
281+
container.innerHTML = `
282+
<div class="superdoc-page" data-page-index="0">
283+
<div class="superdoc-fragment" data-block-id="block1">
284+
<div class="superdoc-line" data-pm-start="2" data-pm-end="25">
285+
<span data-pm-start="2" data-pm-end="8">Date: </span>
286+
<span class="superdoc-structured-content-inline" data-pm-start="11" data-pm-end="25">
287+
<span class="superdoc-structured-content-inline__label">Agreement Date</span>
288+
<span data-pm-start="11" data-pm-end="25">Agreement Date</span>
289+
</span>
290+
</div>
291+
</div>
292+
</div>
293+
`;
294+
295+
const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect();
296+
const textSpan = container.querySelector(
297+
'.superdoc-structured-content-inline span[data-pm-start]',
298+
) as HTMLElement;
299+
const spanRect = textSpan.getBoundingClientRect();
300+
301+
expect(clickToPositionDom(container, spanRect.right + 10, lineRect.top + 5)).toBe(26);
302+
});
303+
304+
it('returns the position before a leading inline SDT when clicking to its visual left', () => {
305+
container.innerHTML = `
306+
<div class="superdoc-page" data-page-index="0">
307+
<div class="superdoc-fragment" data-block-id="block1">
308+
<div class="superdoc-line" data-pm-start="11" data-pm-end="25">
309+
<span class="superdoc-structured-content-inline" data-pm-start="11" data-pm-end="25">
310+
<span class="superdoc-structured-content-inline__label">Agreement Date</span>
311+
<span data-pm-start="11" data-pm-end="25">Agreement Date</span>
312+
</span>
313+
</div>
314+
</div>
315+
</div>
316+
`;
317+
318+
const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect();
319+
const textSpan = container.querySelector(
320+
'.superdoc-structured-content-inline span[data-pm-start]',
321+
) as HTMLElement;
322+
const spanRect = textSpan.getBoundingClientRect();
323+
324+
expect(clickToPositionDom(container, spanRect.left - 10, lineRect.top + 5)).toBe(10);
278325
});
279326
});
280327

packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ function readPmRange(el: HTMLElement): { start: number; end: number } {
9797
};
9898
}
9999

100+
function getInlineSdtWrapperBoundaryPos(
101+
spanEl: HTMLElement | null | undefined,
102+
side: 'before' | 'after',
103+
): number | null {
104+
if (!(spanEl instanceof HTMLElement)) return null;
105+
106+
const wrapper = spanEl.closest(`.${CLASS.inlineSdtWrapper}`) as HTMLElement | null;
107+
if (!wrapper) return null;
108+
109+
const { start, end } = readPmRange(wrapper);
110+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
111+
112+
return side === 'before' ? start - 1 : end + 1;
113+
}
114+
100115
/**
101116
* Collects clickable span/anchor elements inside a line.
102117
*
@@ -331,8 +346,16 @@ function resolvePositionInLine(
331346
const visualRight = Math.max(...boundsRects.map((r) => r.right));
332347

333348
// Boundary snapping: click outside all spans → return line start/end (RTL-aware)
334-
if (viewX <= visualLeft) return rtl ? lineEnd : lineStart;
335-
if (viewX >= visualRight) return rtl ? lineStart : lineEnd;
349+
if (viewX <= visualLeft) {
350+
const edgeSpan = rtl ? spanEls[spanEls.length - 1] : spanEls[0];
351+
const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'after' : 'before');
352+
return inlineBoundary ?? (rtl ? lineEnd : lineStart);
353+
}
354+
if (viewX >= visualRight) {
355+
const edgeSpan = rtl ? spanEls[0] : spanEls[spanEls.length - 1];
356+
const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'before' : 'after');
357+
return inlineBoundary ?? (rtl ? lineStart : lineEnd);
358+
}
336359

337360
const targetEl = findSpanAtX(spanEls, viewX);
338361
if (!targetEl) return lineStart;

0 commit comments

Comments
 (0)