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 a9e90d5b1f..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,7 +63,6 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray Math.max(min, Math.min(max, value)); type CommentThreadHit = { @@ -1292,15 +1292,33 @@ export class EditorInputManager { // SD-1584: clicking inside a block SDT selects the node (NodeSelection). 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 { - nextSelection = TextSelection.create(doc, hit.pos); + 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 && hit.pos <= inlineSdt.start) { + inlineSdtBoundaryPos = inlineSdt.pos; + inlineSdtBoundaryDirection = 'before'; + nextSelection = TextSelection.create(doc, inlineSdt.pos); + } else { + nextSelection = TextSelection.create(doc, hit.pos); + } if (!nextSelection.$from.parent.inlineContent) { nextSelection = Selection.near(doc.resolve(hit.pos), 1); } } - const tr = editor.state.tr.setSelection(nextSelection); + let tr = editor.state.tr.setSelection(nextSelection); + if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) { + tr = applyEditableSlotAtInlineBoundary(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); 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..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,5 +1,7 @@ import { Plugin, TextSelection } from 'prosemirror-state'; +import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; + /** * Select-all-on-click plugin for inline StructuredContent nodes. * @@ -13,14 +15,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 } = 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 = applyEditableSlotAtInlineBoundary(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 +81,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 applyEditableSlotAtInlineBoundary(newState.tr, selectedSdt.pos + selectedSdt.node.nodeSize, 'after'); + } + if (oldAtLeadingBoundary) { + return applyEditableSlotAtInlineBoundary(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); + }); });