Skip to content

Commit cd6bcd2

Browse files
committed
fix: don't select sdt when clicking outside
1 parent 0ad9a8a commit cd6bcd2

File tree

5 files changed

+139
-160
lines changed

5 files changed

+139
-160
lines changed

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

Lines changed: 28 additions & 16 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

@@ -1350,22 +1354,30 @@ export class EditorInputManager {
13501354
): number {
13511355
if (clickDepth !== 1) return fallbackPos;
13521356

1353-
let wrapper = target.closest(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`) as HTMLElement | null;
1354-
if (!wrapper) {
1355-
const line = target.closest(`.${DOM_CLASS_NAMES.LINE}`) as HTMLElement | null;
1356-
if (line) {
1357-
const wrappers = Array.from(line.querySelectorAll<HTMLElement>(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`));
1358-
for (const candidate of wrappers) {
1359-
const rect = candidate.getBoundingClientRect();
1360-
const verticallyAligned = clientY >= rect.top - 2 && clientY <= rect.bottom + 2;
1361-
const nearRightEdge = clientX >= rect.right - 4 && clientX <= rect.right + 12;
1362-
if (verticallyAligned && nearRightEdge) {
1363-
wrapper = candidate;
1364-
break;
1365-
}
1366-
}
1367-
}
1368-
}
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+
});
13691381
if (!wrapper) return fallbackPos;
13701382

13711383
const rect = wrapper.getBoundingClientRect();

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;

packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,53 @@ export function createStructuredContentSelectPlugin(editor) {
111111

112112
const { selection } = newState;
113113

114-
// Only for collapsed selections (cursor placement, not range selections)
115-
if (!selection.empty) return null;
116-
117114
// Only when selection actually changed
118115
if (oldState.selection.eq(newState.selection)) return null;
119116

120117
// Only for selection-only transactions (no doc changes — filters out
121118
// typing, paste, etc. that also move the cursor)
122119
if (transactions.some((tr) => tr.docChanged)) return null;
123120

124-
const $pos = selection.$from;
121+
if (!selection.empty) {
122+
let selectedSdt = null;
123+
newState.doc.descendants((node, pos) => {
124+
if (node.type.name !== 'structuredContent') return true;
125+
126+
const contentFrom = pos + 1;
127+
const contentTo = pos + node.nodeSize - 1;
128+
const wrapsSelection = selection.from <= contentFrom && selection.to >= contentTo;
129+
if (!wrapsSelection) return true;
130+
131+
selectedSdt = {
132+
node,
133+
pos,
134+
contentFrom,
135+
contentTo,
136+
};
137+
return false;
138+
});
139+
140+
if (selectedSdt) {
141+
const oldAtTrailingBoundary =
142+
oldState.selection.empty && oldState.selection.from >= selectedSdt.pos + selectedSdt.node.nodeSize;
143+
const oldAtLeadingBoundary = oldState.selection.empty && oldState.selection.from <= selectedSdt.pos;
144+
145+
if (oldAtTrailingBoundary) {
146+
return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos + selectedSdt.node.nodeSize, 'after');
147+
}
148+
if (oldAtLeadingBoundary) {
149+
return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos, 'before');
150+
}
151+
}
152+
return null;
153+
}
154+
155+
// Only for collapsed selections (cursor placement, not range selections)
156+
if (!selection.empty) return null;
125157

126158
// Walk up to find an enclosing inline structuredContent node
159+
const $pos = selection.$from;
160+
const old$pos = oldState.selection.$from;
127161
for (let d = $pos.depth; d > 0; d--) {
128162
const node = $pos.node(d);
129163
if (node.type.name !== 'structuredContent') continue;
@@ -142,7 +176,6 @@ export function createStructuredContentSelectPlugin(editor) {
142176

143177
// If old selection was already inside this same SDT, allow normal
144178
// cursor placement (second click / arrow navigation within SDT)
145-
const old$pos = oldState.selection.$from;
146179
for (let od = old$pos.depth; od > 0; od--) {
147180
if (old$pos.node(od).type.name === 'structuredContent' && old$pos.before(od) === sdtStart) {
148181
return null;

tests/behavior/tests/sdt/inline-sdt-exit-slot.spec.ts

Lines changed: 0 additions & 136 deletions
This file was deleted.

0 commit comments

Comments
 (0)