From 056cdda2532381fab37e796c4d3a58cd7c4744e9 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 9 Apr 2026 18:49:43 +0300 Subject: [PATCH 1/2] fix: allow putting cursor after inline sdt field --- .../pointer-events/EditorInputManager.ts | 132 ++++++++++++- .../v1/dom-observer/DomPointerMapping.test.ts | 49 ++++- .../v1/dom-observer/DomPointerMapping.ts | 27 ++- .../structured-content-select-plugin.js | 173 +++++++++++++++--- .../structured-content-select-plugin.test.js | 144 +++++++++++++++ 5 files changed, 496 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index a9e90d5b1f..ac1ed2d71b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -62,6 +62,10 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray Math.max(min, Math.min(max, value)); @@ -1217,10 +1221,18 @@ export class EditorInputManager { // Track click depth for multi-click const clickDepth = this.#registerPointerClick(event); + const hitPos = this.#normalizeInlineSdtBoundaryHitPosition( + target, + event.clientX, + event.clientY, + doc, + hit.pos, + clickDepth, + ); // Set up drag selection state if (clickDepth === 1) { - this.#dragAnchor = hit.pos; + this.#dragAnchor = hitPos; this.#dragAnchorPageIndex = hit.pageIndex; this.#pendingMarginClick = this.#callbacks.computePendingMarginClick?.(event.pointerId, x, y) ?? null; @@ -1290,17 +1302,35 @@ export class EditorInputManager { if (!handledByDepth) { try { // SD-1584: clicking inside a block SDT selects the node (NodeSelection). - const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null; + const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hitPos) : null; let nextSelection: Selection; + let inlineSdtBoundaryPos: number | null = null; + let inlineSdtBoundaryDirection: 'before' | 'after' | null = null; if (sdtBlock) { nextSelection = NodeSelection.create(doc, sdtBlock.pos); } else { - nextSelection = TextSelection.create(doc, hit.pos); + const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hitPos) : null; + if (inlineSdt && hitPos >= inlineSdt.end) { + const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize; + inlineSdtBoundaryPos = afterInlineSdt; + inlineSdtBoundaryDirection = 'after'; + nextSelection = TextSelection.create(doc, afterInlineSdt); + } else if (inlineSdt && hitPos <= inlineSdt.start) { + inlineSdtBoundaryPos = inlineSdt.pos; + inlineSdtBoundaryDirection = 'before'; + nextSelection = TextSelection.create(doc, inlineSdt.pos); + } else { + nextSelection = TextSelection.create(doc, hitPos); + } if (!nextSelection.$from.parent.inlineContent) { - nextSelection = Selection.near(doc.resolve(hit.pos), 1); + nextSelection = Selection.near(doc.resolve(hitPos), 1); } } - const tr = editor.state.tr.setSelection(nextSelection); + let tr = editor.state.tr.setSelection(nextSelection); + if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) { + tr = this.#ensureEditableSlotAtInlineSdtBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection); + nextSelection = tr.selection; + } // Preserve stored marks (e.g., formatting selected from toolbar before clicking) if (nextSelection instanceof TextSelection && nextSelection.empty && editor.state.storedMarks) { tr.setStoredMarks(editor.state.storedMarks); @@ -1314,6 +1344,98 @@ export class EditorInputManager { this.#callbacks.scheduleSelectionUpdate?.(); } + #normalizeInlineSdtBoundaryHitPosition( + target: HTMLElement, + clientX: number, + clientY: number, + doc: ProseMirrorNode, + fallbackPos: number, + clickDepth: number, + ): number { + if (clickDepth !== 1) return fallbackPos; + + const line = + target.closest(`.${DOM_CLASS_NAMES.LINE}`) ?? + (typeof document.elementsFromPoint === 'function' + ? (document + .elementsFromPoint(clientX, clientY) + .find((element) => element instanceof HTMLElement && element.closest(`.${DOM_CLASS_NAMES.LINE}`)) + ?.closest(`.${DOM_CLASS_NAMES.LINE}`) as HTMLElement | null) + : null); + if (!line) return fallbackPos; + + const wrappers = Array.from(line.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`)); + const wrapper = wrappers.find((candidate) => { + const rect = candidate.getBoundingClientRect(); + const verticallyAligned = clientY >= rect.top - 2 && clientY <= rect.bottom + 2; + if (!verticallyAligned) return false; + + const nearLeftEdge = + clientX >= rect.left - INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX && + clientX <= rect.left + INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX; + const nearRightEdge = + clientX >= rect.right - INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX && + clientX <= rect.right + INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX; + return nearLeftEdge || nearRightEdge; + }); + if (!wrapper) return fallbackPos; + + const rect = wrapper.getBoundingClientRect(); + // Treat clicks near left edge as "before SDT", and right half as "after SDT" intent. + const leftSideThreshold = rect.left + rect.width * 0.2; + const rightSideThreshold = rect.left + rect.width * 0.5; + if (clientX <= leftSideThreshold) { + const pmStartRaw = wrapper.dataset.pmStart; + const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; + if (!Number.isFinite(pmStart)) return fallbackPos; + return Math.max(0, Math.min(pmStart, doc.content.size)); + } + if (clientX < rightSideThreshold) return fallbackPos; + + const pmEndRaw = wrapper.dataset.pmEnd; + const pmEnd = pmEndRaw != null ? Number(pmEndRaw) : NaN; + if (!Number.isFinite(pmEnd)) return fallbackPos; + return Math.max(0, Math.min(pmEnd + 1, doc.content.size)); + } + + #ensureEditableSlotAtInlineSdtBoundary< + T extends { + doc: ProseMirrorNode; + insertText: (text: string, from?: number, to?: number) => unknown; + setSelection: (selection: Selection) => unknown; + selection: Selection; + }, + >(tr: T, pos: number, direction: 'before' | 'after'): T { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)); + const needsEditableSlot = (node: ProseMirrorNode | null | undefined, side: 'before' | 'after') => + !node || + node.type?.name === 'hardBreak' || + node.type?.name === 'lineBreak' || + node.type?.name === 'structuredContent' || + (node.type?.name === 'run' && !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText)); + + if (direction === 'before') { + const $pos = tr.doc.resolve(clampedPos); + const nodeBefore = $pos.nodeBefore; + const shouldInsertBefore = needsEditableSlot(nodeBefore, 'before'); + + if (!shouldInsertBefore) return tr; + + tr.insertText('\u200B', clampedPos); + tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); + return tr; + } + + const nodeAfter = tr.doc.nodeAt(clampedPos); + const shouldInsertAfter = needsEditableSlot(nodeAfter, 'after'); + + if (!shouldInsertAfter) return tr; + + tr.insertText('\u200B', clampedPos); + tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); + return tr; + } + #handlePointerMove(event: PointerEvent): void { if (!this.#deps) return; diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts index 17976385d4..7171e6fab8 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.test.ts @@ -274,7 +274,54 @@ describe('DomPointerMapping', () => { expect(result).not.toBeNull(); expect(result).toBeGreaterThanOrEqual(0); - expect(result).toBeLessThanOrEqual(20); + expect(result).toBeLessThanOrEqual(21); + }); + + it('returns the position after a terminal inline SDT when clicking to its visual right', () => { + container.innerHTML = ` +
+
+
+ Date: + + Agreement Date + Agreement Date + +
+
+
+ `; + + const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect(); + const textSpan = container.querySelector( + '.superdoc-structured-content-inline span[data-pm-start]', + ) as HTMLElement; + const spanRect = textSpan.getBoundingClientRect(); + + expect(clickToPositionDom(container, spanRect.right + 10, lineRect.top + 5)).toBe(26); + }); + + it('returns the position before a leading inline SDT when clicking to its visual left', () => { + container.innerHTML = ` +
+
+
+ + Agreement Date + Agreement Date + +
+
+
+ `; + + const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect(); + const textSpan = container.querySelector( + '.superdoc-structured-content-inline span[data-pm-start]', + ) as HTMLElement; + const spanRect = textSpan.getBoundingClientRect(); + + expect(clickToPositionDom(container, spanRect.left - 10, lineRect.top + 5)).toBe(10); }); }); diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts index a94024f075..a645c6dd4c 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPointerMapping.ts @@ -97,6 +97,21 @@ function readPmRange(el: HTMLElement): { start: number; end: number } { }; } +function getInlineSdtWrapperBoundaryPos( + spanEl: HTMLElement | null | undefined, + side: 'before' | 'after', +): number | null { + if (!(spanEl instanceof HTMLElement)) return null; + + const wrapper = spanEl.closest(`.${CLASS.inlineSdtWrapper}`) as HTMLElement | null; + if (!wrapper) return null; + + const { start, end } = readPmRange(wrapper); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + + return side === 'before' ? start - 1 : end + 1; +} + /** * Collects clickable span/anchor elements inside a line. * @@ -331,8 +346,16 @@ function resolvePositionInLine( const visualRight = Math.max(...boundsRects.map((r) => r.right)); // Boundary snapping: click outside all spans → return line start/end (RTL-aware) - if (viewX <= visualLeft) return rtl ? lineEnd : lineStart; - if (viewX >= visualRight) return rtl ? lineStart : lineEnd; + if (viewX <= visualLeft) { + const edgeSpan = rtl ? spanEls[spanEls.length - 1] : spanEls[0]; + const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'after' : 'before'); + return inlineBoundary ?? (rtl ? lineEnd : lineStart); + } + if (viewX >= visualRight) { + const edgeSpan = rtl ? spanEls[0] : spanEls[spanEls.length - 1]; + const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'before' : 'after'); + return inlineBoundary ?? (rtl ? lineStart : lineEnd); + } const targetEl = findSpanAtX(spanEls, viewX); if (!targetEl) return lineStart; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 978a434695..28de08ec47 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -1,5 +1,44 @@ import { Plugin, TextSelection } from 'prosemirror-state'; +function ensureEditableSlotAtPosition(tr, pos, direction = 'after') { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)); + if (direction === 'before') { + const $pos = tr.doc.resolve(clampedPos); + const nodeBefore = $pos.nodeBefore; + const shouldInsertBefore = + !nodeBefore || + nodeBefore.type?.name === 'hardBreak' || + nodeBefore.type?.name === 'lineBreak' || + nodeBefore.type?.name === 'structuredContent' || + (nodeBefore.type?.name === 'run' && !nodeBefore.lastChild?.isText); + + if (!shouldInsertBefore) { + tr.setSelection(TextSelection.create(tr.doc, clampedPos, clampedPos)); + return tr; + } + + tr.insertText('\u200B', clampedPos); + tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); + return tr; + } + const nodeAfter = tr.doc.nodeAt(clampedPos); + const shouldInsert = + !nodeAfter || + nodeAfter.type?.name === 'hardBreak' || + nodeAfter.type?.name === 'lineBreak' || + nodeAfter.type?.name === 'structuredContent' || + (nodeAfter.type?.name === 'run' && !nodeAfter.firstChild?.isText); + + if (!shouldInsert) { + tr.setSelection(TextSelection.create(tr.doc, clampedPos, clampedPos)); + return tr; + } + + tr.insertText('\u200B', clampedPos); + tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); + return tr; +} + /** * Select-all-on-click plugin for inline StructuredContent nodes. * @@ -13,14 +52,65 @@ import { Plugin, TextSelection } from 'prosemirror-state'; */ export function createStructuredContentSelectPlugin(editor) { return new Plugin({ + props: { + handleKeyDown(view, event) { + if (editor?.options?.documentMode === 'viewing') return false; + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return false; + // Keep native modified-arrow behavior (range extend, word/line jump). + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return false; + + const { state } = view; + const { selection, doc } = state; + + const resolveBoundaryExit = ($pos) => { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + const node = $pos.node(depth); + if (node.type.name !== 'structuredContent') continue; + + const contentFrom = $pos.start(depth); + const contentTo = $pos.end(depth); + const nodePos = $pos.before(depth); + const beforePos = nodePos; + const afterPos = nodePos + node.nodeSize; + + // Empty selection: exit only at exact boundaries. + if (selection.empty) { + // Be tolerant by 1 position to avoid requiring a second key press + // when PM lands just inside boundary positions. + if (event.key === 'ArrowRight' && selection.from >= contentTo - 1) return afterPos; + if (event.key === 'ArrowLeft' && selection.from <= contentFrom + 1) return beforePos; + return null; + } + + // Full SDT-content selection (first-click behavior): allow immediate exit. + const selectsWholeContent = selection.from === contentFrom && selection.to === contentTo; + if (!selectsWholeContent) return null; + if (event.key === 'ArrowRight') return afterPos; + if (event.key === 'ArrowLeft') return beforePos; + return null; + } + return null; + }; + + const nextPos = resolveBoundaryExit(selection.$from); + if (nextPos == null) return false; + + try { + const direction = event.key === 'ArrowLeft' ? 'before' : 'after'; + const tr = ensureEditableSlotAtPosition(state.tr, nextPos, direction); + view.dispatch(tr); + event.preventDefault(); + return true; + } catch { + return false; + } + }, + }, appendTransaction(transactions, oldState, newState) { if (editor?.options?.documentMode === 'viewing') return null; const { selection } = newState; - // Only for collapsed selections (cursor placement, not range selections) - if (!selection.empty) return null; - // Only when selection actually changed if (oldState.selection.eq(newState.selection)) return null; @@ -28,30 +118,71 @@ export function createStructuredContentSelectPlugin(editor) { // typing, paste, etc. that also move the cursor) if (transactions.some((tr) => tr.docChanged)) return null; - const $pos = selection.$from; + if (!selection.empty) { + let selectedSdt = null; + newState.doc.descendants((node, pos) => { + if (node.type.name !== 'structuredContent') return true; + + const contentFrom = pos + 1; + const contentTo = pos + node.nodeSize - 1; + const wrapsSelection = selection.from <= contentFrom && selection.to >= contentTo; + if (!wrapsSelection) return true; + + selectedSdt = { + node, + pos, + contentFrom, + contentTo, + }; + return false; + }); + + if (selectedSdt) { + const oldAtTrailingBoundary = + oldState.selection.empty && oldState.selection.from >= selectedSdt.pos + selectedSdt.node.nodeSize; + const oldAtLeadingBoundary = oldState.selection.empty && oldState.selection.from <= selectedSdt.pos; + + if (oldAtTrailingBoundary) { + return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos + selectedSdt.node.nodeSize, 'after'); + } + if (oldAtLeadingBoundary) { + return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos, 'before'); + } + } + return null; + } + + // Only for collapsed selections (cursor placement, not range selections) + if (!selection.empty) return null; // Walk up to find an enclosing inline structuredContent node + const $pos = selection.$from; + const old$pos = oldState.selection.$from; for (let d = $pos.depth; d > 0; d--) { const node = $pos.node(d); - if (node.type.name === 'structuredContent') { - const sdtStart = $pos.before(d); - const contentFrom = $pos.start(d); - const contentTo = $pos.end(d); - - // Don't select empty content - if (contentFrom === contentTo) return null; - - // If old selection was already inside this same SDT, allow normal - // cursor placement (second click / arrow navigation within SDT) - const old$pos = oldState.selection.$from; - for (let od = old$pos.depth; od > 0; od--) { - if (old$pos.node(od).type.name === 'structuredContent' && old$pos.before(od) === sdtStart) { - return null; - } - } + if (node.type.name !== 'structuredContent') continue; + const sdtStart = $pos.before(d); + const contentFrom = $pos.start(d); + const contentTo = $pos.end(d); - return newState.tr.setSelection(TextSelection.create(newState.doc, contentFrom, contentTo)); + // Boundary positions represent "before/after SDT content" intent and should + // not trigger first-click select-all behavior. + if (selection.from <= contentFrom || selection.from >= contentTo) { + return null; } + + // Don't select empty content + if (contentFrom === contentTo) return null; + + // If old selection was already inside this same SDT, allow normal + // cursor placement (second click / arrow navigation within SDT) + for (let od = old$pos.depth; od > 0; od--) { + if (old$pos.node(od).type.name === 'structuredContent' && old$pos.before(od) === sdtStart) { + return null; + } + } + + return newState.tr.setSelection(TextSelection.create(newState.doc, contentFrom, contentTo)); } return null; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js index 13d2912995..e9d23f2c1f 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js @@ -111,4 +111,148 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection).toBeInstanceOf(NodeSelection); expect(editor.options.documentMode).toBe('viewing'); }); + + it('exits inline SDT with one ArrowRight from near-end position', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentTo = sdt.pos + sdt.node.nodeSize - 1; + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentTo - 1))); + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }); + editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event)); + + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(afterSdt); + expect(editor.state.selection.to).toBe(afterSdt); + }); + + it('creates editable slot when exiting inline SDT without trailing text', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentTo = sdt.pos + sdt.node.nodeSize - 1; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentTo))); + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }); + editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event)); + + expect(editor.state.selection.empty).toBe(true); + // Cursor should not remain inside structuredContent after exiting. + let insideStructuredContent = false; + for (let depth = editor.state.selection.$from.depth; depth > 0; depth -= 1) { + if (editor.state.selection.$from.node(depth).type.name === 'structuredContent') { + insideStructuredContent = true; + } + } + expect(insideStructuredContent).toBe(false); + + // Editable slot insertion should add exactly one zero-width character. + const text = editor.state.doc.textContent; + expect((text.match(/\u200B/g) ?? []).length).toBe(1); + }); + + it('ArrowLeft exit does not insert zero-width text before inline SDT', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentFrom = sdt.pos + 1; + const beforeDocText = editor.state.doc.textContent; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom + 1))); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom))); + + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }); + editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event)); + + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.doc.textContent).toBe(beforeDocText); + expect((editor.state.doc.textContent.match(/\u200B/g) ?? []).length).toBe(0); + }); + + it('ArrowLeft exit creates editable slot before first inline SDT', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [inlineSdt, schema.text(' tail')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentFrom = sdt.pos + 1; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom + 1))); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom))); + + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }); + editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event)); + + expect(editor.state.selection.empty).toBe(true); + expect((editor.state.doc.textContent.match(/\u200B/g) ?? []).length).toBe(1); + expect(editor.state.doc.textContent).toContain('tail'); + expect(editor.state.selection.from).toBeGreaterThanOrEqual(sdt.pos + 1); + }); + + it('does not intercept Shift+ArrowRight near inline SDT boundary', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentTo = sdt.pos + sdt.node.nodeSize - 1; + const beforeSelection = TextSelection.create(editor.state.doc, contentTo - 1, contentTo); + editor.view.dispatch(editor.state.tr.setSelection(beforeSelection)); + const beforeFrom = editor.state.selection.from; + const beforeTo = editor.state.selection.to; + const beforeText = editor.state.doc.textContent; + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }); + let handled = false; + editor.view.someProp('handleKeyDown', (handler) => { + handled = handler(editor.view, event); + return handled; + }); + + expect(handled).toBe(false); + expect(editor.state.selection.from).toBe(beforeFrom); + expect(editor.state.selection.to).toBe(beforeTo); + expect(editor.state.doc.textContent).toBe(beforeText); + }); + + it('does not intercept Ctrl/Cmd+ArrowRight near inline SDT boundary', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const contentTo = sdt.pos + sdt.node.nodeSize - 1; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentTo - 1))); + const beforePos = editor.state.selection.from; + const beforeText = editor.state.doc.textContent; + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true, metaKey: true, bubbles: true }); + let handled = false; + editor.view.someProp('handleKeyDown', (handler) => { + handled = handler(editor.view, event); + return handled; + }); + + expect(handled).toBe(false); + expect(editor.state.selection.from).toBe(beforePos); + expect(editor.state.doc.textContent).toBe(beforeText); + }); }); From 15540d05c4bdf48ca8ca077598fc6b9fba09efd9 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 10 Apr 2026 18:17:03 +0300 Subject: [PATCH 2/2] fix: simplify code; create helper file --- ...sure-editable-slot-inline-boundary.test.ts | 163 ++++++++++++++++++ .../ensure-editable-slot-inline-boundary.ts | 35 ++++ .../pointer-events/EditorInputManager.ts | 122 +------------ .../structured-content-select-plugin.js | 47 +---- 4 files changed, 212 insertions(+), 155 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.ts diff --git a/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.test.ts b/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.test.ts new file mode 100644 index 0000000000..bc177b95e4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import type { Node as PMNode, Schema } from 'prosemirror-model'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { applyEditableSlotAtInlineBoundary } from './ensure-editable-slot-inline-boundary.js'; + +function findStructuredContent(doc: PMNode): { node: PMNode; pos: number } | null { + let found: { node: PMNode; pos: number } | null = null; + doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + found = { node, pos }; + return false; + } + return true; + }); + return found; +} + +function zwspCount(text: string): number { + return (text.match(/\u200B/g) ?? []).length; +} + +describe('applyEditableSlotAtInlineBoundary', () => { + let schema: Schema; + let destroy: (() => void) | undefined; + + beforeEach(() => { + const { editor } = initTestEditor(); + schema = editor.schema; + destroy = () => editor.destroy(); + }); + + afterEach(() => { + destroy?.(); + destroy = undefined; + }); + + it('inserts zero-width space after trailing inline SDT (direction after)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const afterSdt = sdt!.pos + sdt!.node.nodeSize; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after'); + + expect(tr.docChanged).toBe(true); + expect(zwspCount(tr.doc.textContent)).toBe(1); + expect(tr.selection.from).toBe(afterSdt + 1); + expect(tr.selection.empty).toBe(true); + }); + + it('does not insert when text follows inline SDT (direction after)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]), + ]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const afterSdt = sdt!.pos + sdt!.node.nodeSize; + const beforeText = doc.textContent; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after'); + + expect(tr.docChanged).toBe(false); + expect(tr.doc.textContent).toBe(beforeText); + expect(tr.selection.from).toBe(afterSdt); + }); + + it('inserts zero-width space before leading inline SDT (direction before)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, schema.text(' tail')])]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const beforeSdt = sdt!.pos; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before'); + + expect(tr.docChanged).toBe(true); + expect(zwspCount(tr.doc.textContent)).toBe(1); + expect(tr.doc.textContent).toContain('tail'); + expect(tr.selection.from).toBe(beforeSdt + 1); + }); + + it('does not insert when text precedes inline SDT (direction before)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const beforeSdt = sdt!.pos; + const beforeText = doc.textContent; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before'); + + expect(tr.docChanged).toBe(false); + expect(tr.doc.textContent).toBe(beforeText); + expect(tr.selection.from).toBe(beforeSdt); + }); + + it('inserts when following sibling is an empty run (direction after)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const emptyRun = schema.nodes.run.create(); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, emptyRun])]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const afterSdt = sdt!.pos + sdt!.node.nodeSize; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after'); + + expect(tr.docChanged).toBe(true); + expect(zwspCount(tr.doc.textContent)).toBe(1); + expect(tr.selection.from).toBe(afterSdt + 1); + }); + + it('inserts when preceding sibling is an empty run (direction before)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const emptyRun = schema.nodes.run.create(); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [emptyRun, inlineSdt])]); + const sdt = findStructuredContent(doc); + expect(sdt).not.toBeNull(); + const beforeSdt = sdt!.pos; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before'); + + expect(tr.docChanged).toBe(true); + expect(zwspCount(tr.doc.textContent)).toBe(1); + expect(tr.selection.from).toBe(beforeSdt + 1); + }); + + it('clamps an oversized position to doc end then may insert zero-width space (direction after)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]); + const sizeBefore = doc.content.size; + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, sizeBefore + 999, 'after'); + + // Clamps to `doc.content.size`; gap after last inline has no node → ZWSP + caret (size may grow by schema-specific steps). + expect(tr.docChanged).toBe(true); + expect(tr.doc.content.size).toBeGreaterThan(sizeBefore); + expect(zwspCount(tr.doc.textContent)).toBeGreaterThanOrEqual(1); + expect(tr.selection.from).toBeGreaterThan(0); + expect(tr.selection.from).toBeLessThanOrEqual(tr.doc.content.size); + }); + + it('clamps a negative position to 0 then may insert zero-width space (direction before)', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field')); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]); + + const state = EditorState.create({ schema, doc }); + const tr = applyEditableSlotAtInlineBoundary(state.tr, -999, 'before'); + + expect(tr.docChanged).toBe(true); + expect(zwspCount(tr.doc.textContent)).toBe(1); + expect(tr.selection.from).toBe(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.ts b/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.ts new file mode 100644 index 0000000000..efeb3ffe07 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/ensure-editable-slot-inline-boundary.ts @@ -0,0 +1,35 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import { TextSelection, type Transaction } from 'prosemirror-state'; + +function needsEditableSlot(node: PMNode | null | undefined, side: 'before' | 'after'): boolean { + if (!node) return true; + const name = node.type.name; + if (name === 'hardBreak' || name === 'lineBreak' || name === 'structuredContent') return true; + if (name === 'run') return !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText); + return false; +} + +/** + * Ensures a collapsed caret can live at an inline structuredContent boundary by + * inserting ZWSP when the adjacent slice has no text (keyboard + presentation clicks). + */ +export function applyEditableSlotAtInlineBoundary( + tr: Transaction, + pos: number, + direction: 'before' | 'after', +): Transaction { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)); + if (direction === 'before') { + const $pos = tr.doc.resolve(clampedPos); + if (!needsEditableSlot($pos.nodeBefore, 'before')) { + return tr.setSelection(TextSelection.create(tr.doc, clampedPos)); + } + tr.insertText('\u200B', clampedPos); + return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); + } + if (!needsEditableSlot(tr.doc.nodeAt(clampedPos), 'after')) { + return tr.setSelection(TextSelection.create(tr.doc, clampedPos)); + } + tr.insertText('\u200B', clampedPos); + return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index ac1ed2d71b..66db0bd8bd 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -39,6 +39,7 @@ import { } from '../tables/TableSelectionUtilities.js'; import { debugLog } from '../selection/SelectionDebug.js'; import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract'; +import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; @@ -62,11 +63,6 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray Math.max(min, Math.min(max, value)); type CommentThreadHit = { @@ -1221,18 +1217,10 @@ export class EditorInputManager { // Track click depth for multi-click const clickDepth = this.#registerPointerClick(event); - const hitPos = this.#normalizeInlineSdtBoundaryHitPosition( - target, - event.clientX, - event.clientY, - doc, - hit.pos, - clickDepth, - ); // Set up drag selection state if (clickDepth === 1) { - this.#dragAnchor = hitPos; + this.#dragAnchor = hit.pos; this.#dragAnchorPageIndex = hit.pageIndex; this.#pendingMarginClick = this.#callbacks.computePendingMarginClick?.(event.pointerId, x, y) ?? null; @@ -1302,33 +1290,33 @@ export class EditorInputManager { if (!handledByDepth) { try { // SD-1584: clicking inside a block SDT selects the node (NodeSelection). - const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hitPos) : null; + const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null; let nextSelection: Selection; let inlineSdtBoundaryPos: number | null = null; let inlineSdtBoundaryDirection: 'before' | 'after' | null = null; if (sdtBlock) { nextSelection = NodeSelection.create(doc, sdtBlock.pos); } else { - const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hitPos) : null; - if (inlineSdt && hitPos >= inlineSdt.end) { + const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hit.pos) : null; + if (inlineSdt && hit.pos >= inlineSdt.end) { const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize; inlineSdtBoundaryPos = afterInlineSdt; inlineSdtBoundaryDirection = 'after'; nextSelection = TextSelection.create(doc, afterInlineSdt); - } else if (inlineSdt && hitPos <= inlineSdt.start) { + } else if (inlineSdt && hit.pos <= inlineSdt.start) { inlineSdtBoundaryPos = inlineSdt.pos; inlineSdtBoundaryDirection = 'before'; nextSelection = TextSelection.create(doc, inlineSdt.pos); } else { - nextSelection = TextSelection.create(doc, hitPos); + nextSelection = TextSelection.create(doc, hit.pos); } if (!nextSelection.$from.parent.inlineContent) { - nextSelection = Selection.near(doc.resolve(hitPos), 1); + nextSelection = Selection.near(doc.resolve(hit.pos), 1); } } let tr = editor.state.tr.setSelection(nextSelection); if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) { - tr = this.#ensureEditableSlotAtInlineSdtBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection); + tr = applyEditableSlotAtInlineBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection); nextSelection = tr.selection; } // Preserve stored marks (e.g., formatting selected from toolbar before clicking) @@ -1344,98 +1332,6 @@ export class EditorInputManager { this.#callbacks.scheduleSelectionUpdate?.(); } - #normalizeInlineSdtBoundaryHitPosition( - target: HTMLElement, - clientX: number, - clientY: number, - doc: ProseMirrorNode, - fallbackPos: number, - clickDepth: number, - ): number { - if (clickDepth !== 1) return fallbackPos; - - const line = - target.closest(`.${DOM_CLASS_NAMES.LINE}`) ?? - (typeof document.elementsFromPoint === 'function' - ? (document - .elementsFromPoint(clientX, clientY) - .find((element) => element instanceof HTMLElement && element.closest(`.${DOM_CLASS_NAMES.LINE}`)) - ?.closest(`.${DOM_CLASS_NAMES.LINE}`) as HTMLElement | null) - : null); - if (!line) return fallbackPos; - - const wrappers = Array.from(line.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}`)); - const wrapper = wrappers.find((candidate) => { - const rect = candidate.getBoundingClientRect(); - const verticallyAligned = clientY >= rect.top - 2 && clientY <= rect.bottom + 2; - if (!verticallyAligned) return false; - - const nearLeftEdge = - clientX >= rect.left - INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX && - clientX <= rect.left + INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX; - const nearRightEdge = - clientX >= rect.right - INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX && - clientX <= rect.right + INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX; - return nearLeftEdge || nearRightEdge; - }); - if (!wrapper) return fallbackPos; - - const rect = wrapper.getBoundingClientRect(); - // Treat clicks near left edge as "before SDT", and right half as "after SDT" intent. - const leftSideThreshold = rect.left + rect.width * 0.2; - const rightSideThreshold = rect.left + rect.width * 0.5; - if (clientX <= leftSideThreshold) { - const pmStartRaw = wrapper.dataset.pmStart; - const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; - if (!Number.isFinite(pmStart)) return fallbackPos; - return Math.max(0, Math.min(pmStart, doc.content.size)); - } - if (clientX < rightSideThreshold) return fallbackPos; - - const pmEndRaw = wrapper.dataset.pmEnd; - const pmEnd = pmEndRaw != null ? Number(pmEndRaw) : NaN; - if (!Number.isFinite(pmEnd)) return fallbackPos; - return Math.max(0, Math.min(pmEnd + 1, doc.content.size)); - } - - #ensureEditableSlotAtInlineSdtBoundary< - T extends { - doc: ProseMirrorNode; - insertText: (text: string, from?: number, to?: number) => unknown; - setSelection: (selection: Selection) => unknown; - selection: Selection; - }, - >(tr: T, pos: number, direction: 'before' | 'after'): T { - const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)); - const needsEditableSlot = (node: ProseMirrorNode | null | undefined, side: 'before' | 'after') => - !node || - node.type?.name === 'hardBreak' || - node.type?.name === 'lineBreak' || - node.type?.name === 'structuredContent' || - (node.type?.name === 'run' && !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText)); - - if (direction === 'before') { - const $pos = tr.doc.resolve(clampedPos); - const nodeBefore = $pos.nodeBefore; - const shouldInsertBefore = needsEditableSlot(nodeBefore, 'before'); - - if (!shouldInsertBefore) return tr; - - tr.insertText('\u200B', clampedPos); - tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); - return tr; - } - - const nodeAfter = tr.doc.nodeAt(clampedPos); - const shouldInsertAfter = needsEditableSlot(nodeAfter, 'after'); - - if (!shouldInsertAfter) return tr; - - tr.insertText('\u200B', clampedPos); - tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); - return tr; - } - #handlePointerMove(event: PointerEvent): void { if (!this.#deps) return; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 28de08ec47..64a8f6dd6c 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -1,43 +1,6 @@ import { Plugin, TextSelection } from 'prosemirror-state'; -function ensureEditableSlotAtPosition(tr, pos, direction = 'after') { - const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)); - if (direction === 'before') { - const $pos = tr.doc.resolve(clampedPos); - const nodeBefore = $pos.nodeBefore; - const shouldInsertBefore = - !nodeBefore || - nodeBefore.type?.name === 'hardBreak' || - nodeBefore.type?.name === 'lineBreak' || - nodeBefore.type?.name === 'structuredContent' || - (nodeBefore.type?.name === 'run' && !nodeBefore.lastChild?.isText); - - if (!shouldInsertBefore) { - tr.setSelection(TextSelection.create(tr.doc, clampedPos, clampedPos)); - return tr; - } - - tr.insertText('\u200B', clampedPos); - tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); - return tr; - } - const nodeAfter = tr.doc.nodeAt(clampedPos); - const shouldInsert = - !nodeAfter || - nodeAfter.type?.name === 'hardBreak' || - nodeAfter.type?.name === 'lineBreak' || - nodeAfter.type?.name === 'structuredContent' || - (nodeAfter.type?.name === 'run' && !nodeAfter.firstChild?.isText); - - if (!shouldInsert) { - tr.setSelection(TextSelection.create(tr.doc, clampedPos, clampedPos)); - return tr; - } - - tr.insertText('\u200B', clampedPos); - tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1)); - return tr; -} +import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; /** * Select-all-on-click plugin for inline StructuredContent nodes. @@ -60,7 +23,7 @@ export function createStructuredContentSelectPlugin(editor) { if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return false; const { state } = view; - const { selection, doc } = state; + const { selection } = state; const resolveBoundaryExit = ($pos) => { for (let depth = $pos.depth; depth > 0; depth -= 1) { @@ -97,7 +60,7 @@ export function createStructuredContentSelectPlugin(editor) { try { const direction = event.key === 'ArrowLeft' ? 'before' : 'after'; - const tr = ensureEditableSlotAtPosition(state.tr, nextPos, direction); + const tr = applyEditableSlotAtInlineBoundary(state.tr, nextPos, direction); view.dispatch(tr); event.preventDefault(); return true; @@ -143,10 +106,10 @@ export function createStructuredContentSelectPlugin(editor) { const oldAtLeadingBoundary = oldState.selection.empty && oldState.selection.from <= selectedSdt.pos; if (oldAtTrailingBoundary) { - return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos + selectedSdt.node.nodeSize, 'after'); + return applyEditableSlotAtInlineBoundary(newState.tr, selectedSdt.pos + selectedSdt.node.nodeSize, 'after'); } if (oldAtLeadingBoundary) { - return ensureEditableSlotAtPosition(newState.tr, selectedSdt.pos, 'before'); + return applyEditableSlotAtInlineBoundary(newState.tr, selectedSdt.pos, 'before'); } } return null;