diff --git a/src/uix/eidos/components/words/words-column-inserter.svelte b/src/uix/eidos/components/words/words-column-inserter.svelte index e30f68f94..1bcaf99b9 100644 --- a/src/uix/eidos/components/words/words-column-inserter.svelte +++ b/src/uix/eidos/components/words/words-column-inserter.svelte @@ -27,6 +27,7 @@ * the column's bounding rect; re-measures on scroll (capture) + * after each `api.document` change. */ + import { tick } from 'svelte'; import type { ActiveDom } from '$adom'; import { DropdownMenu } from '$uix/eidos/components/dropdown-menu'; import { Plus } from '$uix/eidos/components/icon'; @@ -199,7 +200,7 @@ return created ? (created as unknown as WordsBlock) : null; } - function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) { + async function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) { if (busy) return; const newBlock = blockForEntry(entry); if (!newBlock) return; @@ -209,35 +210,75 @@ busy = true; // Hard timeout to release the busy lock no matter what — without - // this, any path that doesn't reach the end of the function - // (dropdown teardown swallows the close callback, exception in - // applyCommand, hot-reload mid-flow) leaves the `+` button dead - // forever. 250ms is well beyond any legitimate re-fire window. + // this, any path that doesn't reach the end of the function leaves + // the `+` button dead forever. 500ms is well beyond any legitimate + // re-fire window (covers two awaited ticks + the engine's + // restoreDomSelection tick). const win = dom.getWindow(); const timer = win?.setTimeout(() => { busy = false; - }, 250); + }, 500); const isAtomic = newBlock.type === 'image' || newBlock.type === 'divider'; const newInnerIdx = slot.isEmpty ? 0 : colsBlock.columns[slot.colIdx]?.children.length ?? 0; - // Single transaction: doc + selection land together. The provider's - // `applyCommandWithOptions` syncs the new selection back to DOM - // after a tick — see the matching change in `words-provider`. + // Canonical Tiptap order — `editor.chain().focus().insertContent(...).run()` + // adapted to a dropdown trigger: + // + // 1. Close the dropdown FIRST — Svelte flushes it and FocusScope + // tears down (its `focusin` capture-listener that bounces + // focus back inside the menu disappears). `trapFocus={false}` + // on Content makes step-2 ticks short. + // 2. `await tick()` — lets Content unmount + cleanup propagate. + // 3. `api.focus()` — editor receives focus before any DOM + // mutation that could steal it. + // 4. `api.applyCommand(insertBlockInColumn)` — engine sets the + // model selection inside the new block AND schedules + // `restoreDomSelection` (via the provider fix in + // `applyCommandWithOptions`), which writes the caret into + // the already-focused editor. + // + // Without this order: the dropdown's trap intercepts our focus + // call; `restoreDomSelection` writes a selection on a non-focused + // element; the next selectionchange echo overwrites the model to + // the stale caret position; typing goes nowhere. + openColumnKey = null; + await tick(); + api.focus(); api.applyCommand({ type: 'insertBlockInColumn', columnsIdx: slot.columnsIdx, colIdx: slot.colIdx, block: newBlock as unknown as Readonly> }); - // Close the dropdown. We don't try to move focus to the editor - // from here — the dropdown's FocusScope (alive until its content - // presence flips) reliably intercepts that. The user clicks into - // the new block to start typing — accepted trade-off until we - // rebuild this without a `DropdownMenu` whose trap fights us. - openColumnKey = null; - - if (isAtomic) { + // Capture the engine's INTENDED selection IMMEDIATELY after the + // command runs (before any selectionchange echo can overwrite it). + // `api.focus()` above lands focus on the contenteditable; that + // triggers a `selectionchange` event that fires + // `syncSelectionFromDom`, which reads the stale DOM caret (doc + // start, where focus defaulted) and overwrites our model's + // post-insert selection. By the time the engine's automatic + // `restoreDomSelection` runs (via tick), the model is already + // corrupted. + const intended = api.selection; + // Defeat the echo via setTimeout(0): runs AFTER the + // selectionchange handler, AFTER the engine's own restore (which + // would have written the corrupted selection). Re-set the model + // to the intended selection — `setSelection` schedules its own + // `restoreDomSelection` via tick, writing the correct caret AFTER + // all interference has settled. + if (win && intended) { + win.setTimeout(() => { + api.setSelection(intended); + if (isAtomic) { + api.selectAtomicBlock(slot.columnsIdx, [ + slot.columnsIdx, + slot.colIdx, + newInnerIdx + ]); + } + }, 0); + } else if (isAtomic) { api.selectAtomicBlock(slot.columnsIdx, [ slot.columnsIdx, slot.colIdx, @@ -245,8 +286,6 @@ ]); } - // Clear the lock at the end of THIS function; the setTimeout above - // is purely a safety net for catastrophic paths. if (win && timer !== undefined) win.clearTimeout(timer); busy = false; } @@ -309,12 +348,15 @@ side="bottom" align="center" data-words-column-inserter-menu + trapFocus={false} onCloseAutoFocus={(e) => { - // Block the dropdown from auto-returning focus to the - // `+` trigger button. With focus on the trigger, the - // next Space keystroke re-activates it (Space triggers - // focused buttons), reopens the menu, fires the first - // item — yielding a phantom repeat-insert. + // `trapFocus={false}` above means FocusScope skips + // installing its capture-phase `focusin` bouncer — + // `api.focus()` in handleInsert actually sticks on + // the contenteditable. + // `preventDefault` here still blocks the dropdown + // from auto-returning focus to the `+` trigger (Space + // on a focused button = phantom repeat-insert). e.preventDefault(); }} > diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.ts index 0fded95c9..e81442a8e 100644 --- a/src/uix/soma/components/words/engine/operations/insert-block-types.ts +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.ts @@ -442,15 +442,23 @@ export function insertBlockInColumn( sel = createCollapsedSelection(point.path, point.offset); } else { const stub = firstTextOfBlock(block); - const inlinePath = + // `pointFromInlineTextOffset` expects the path to the CONTAINER + // block (heading, paragraph, list-item), NOT to the inline text. + // It walks `containerPath.children` and appends the matched + // inline index. Passing a text-node path would produce a stale + // 5-segment path like `[colsIdx, colIdx, innerIdx, 0, 0]` instead + // of the canonical `[colsIdx, colIdx, innerIdx, 0]`. + // For list blocks the container is the FIRST LIST ITEM, not the + // list itself — list-items hold the inline children directly. + const containerPath = block.type === 'list' - ? [colsIdx, colIdx, newInnerIdx, 0, 0] - : [colsIdx, colIdx, newInnerIdx, 0]; - const startPoint = pointFromInlineTextOffset(normalized, inlinePath, 0); + ? [colsIdx, colIdx, newInnerIdx, 0] + : [colsIdx, colIdx, newInnerIdx]; + const startPoint = pointFromInlineTextOffset(normalized, containerPath, 0); if (stub.length === 0) { sel = createCollapsedSelection(startPoint.path, startPoint.offset); } else { - const endPoint = pointFromInlineTextOffset(normalized, inlinePath, stub.length); + const endPoint = pointFromInlineTextOffset(normalized, containerPath, stub.length); sel = { anchor: { path: startPoint.path, offset: startPoint.offset }, focus: { path: endPoint.path, offset: endPoint.offset } diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index eb0fba8c2..5f83e7046 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -168,6 +168,16 @@ export type WordsProviderSnippetProps = { * changed. */ readonly setSelection: (selection: WordsSelection | null) => boolean; + /** + * Force-write the current model selection to the DOM. Use after an + * imperative `applyCommand` that landed the caret in a freshly-rendered + * block — the engine's automatic sync via tick can race the Svelte + * render of the new block, leaving the DOM caret on the previous + * position. Calling this AFTER awaiting enough ticks for the new DOM + * to mount guarantees the caret lands where the model says. + * Returns `true` when the selection was actually written. + */ + readonly restoreCaret: () => boolean; readonly runCommand: (command: WordsCommandName) => void; /** * Apply a command directly without going through the sema runtime diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index e7c4c5a04..983c097a7 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -1271,6 +1271,7 @@ export class WordsProvider { this.selectAtomicBlock(blockIndex, blockPath), clearSelectedBlock: () => this.clearSelectedBlock(), setSelection: (selection: WordsSelection | null) => this.setSelectionPublic(selection), + restoreCaret: () => this.restoreDomSelection(), runCommand: (command: WordsCommandName) => this.runCommandName(command), applyCommand: (command: WordsCommand) => this.applyCommand(command), setCodeLanguage: (language?: string) => this.setCodeLanguage(language),