fix(words): column inserter — caret lands in new block, typing replaces stub

Three composing bugs were blocking the `+`→pick-block→type flow:

1. **Path 5-segmentos en `insertBlockInColumn`** — the op passed the
   path TO the text node (`[colsIdx, colIdx, innerIdx, 0]`) to
   `pointFromInlineTextOffset`, which expects the path to the
   CONTAINER block. The helper then appended its own inline-index,
   producing a stale 5-segment path that no `[data-words-path]`
   selector matches. Fix: pass `[colsIdx, colIdx, innerIdx]` for
   heading/paragraph/quote/code/callout, `[colsIdx, colIdx, innerIdx, 0]`
   for list (the first list-item IS the container).
2. **Selectionchange echo overwriting the model** — `api.focus()`
   landed the caret at doc-start (browser default for focusing a
   contenteditable without a prior selection), fired selectionchange,
   `syncSelectionFromDom` overwrote the model's intended post-insert
   selection with `0.0` before `restoreDomSelection` could run. Fix:
   capture `intended = api.selection` immediately after the op, then
   re-apply via `setTimeout(0)` AFTER the echo handler has settled.
3. **DropdownMenu focus trap intercepted `api.focus()`** — the
   FocusScope's capture-phase `focusin` bouncer kept yanking focus
   back inside the menu. Fix: `trapFocus={false}` on Content.

Also expose `api.restoreCaret()` (provider line 1274) for any consumer
that needs to force-write the model selection to DOM after awaiting
enough ticks for new blocks to mount.

Verified end-to-end in Chrome: empty column → click `+` → pick
Heading → "Title" stub selected → typing immediately replaces with
user's text. Inspector switches to Heading panel. Doc title and rest
of the doc untouched.

CONTINUE.md P0 — RESUELTO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent bcad6647a7
commit eb946148cd

@ -27,6 +27,7 @@
* the column's bounding rect; re-measures on scroll (capture) + * the column's bounding rect; re-measures on scroll (capture) +
* after each `api.document` change. * after each `api.document` change.
*/ */
import { tick } from 'svelte';
import type { ActiveDom } from '$adom'; import type { ActiveDom } from '$adom';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu'; import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { Plus } from '$uix/eidos/components/icon'; import { Plus } from '$uix/eidos/components/icon';
@ -199,7 +200,7 @@
return created ? (created as unknown as WordsBlock) : null; return created ? (created as unknown as WordsBlock) : null;
} }
function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) { async function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) {
if (busy) return; if (busy) return;
const newBlock = blockForEntry(entry); const newBlock = blockForEntry(entry);
if (!newBlock) return; if (!newBlock) return;
@ -209,34 +210,66 @@
busy = true; busy = true;
// Hard timeout to release the busy lock no matter what — without // Hard timeout to release the busy lock no matter what — without
// this, any path that doesn't reach the end of the function // this, any path that doesn't reach the end of the function leaves
// (dropdown teardown swallows the close callback, exception in // the `+` button dead forever. 500ms is well beyond any legitimate
// applyCommand, hot-reload mid-flow) leaves the `+` button dead // re-fire window (covers two awaited ticks + the engine's
// forever. 250ms is well beyond any legitimate re-fire window. // restoreDomSelection tick).
const win = dom.getWindow(); const win = dom.getWindow();
const timer = win?.setTimeout(() => { const timer = win?.setTimeout(() => {
busy = false; busy = false;
}, 250); }, 500);
const isAtomic = newBlock.type === 'image' || newBlock.type === 'divider'; const isAtomic = newBlock.type === 'image' || newBlock.type === 'divider';
const newInnerIdx = slot.isEmpty ? 0 : colsBlock.columns[slot.colIdx]?.children.length ?? 0; const newInnerIdx = slot.isEmpty ? 0 : colsBlock.columns[slot.colIdx]?.children.length ?? 0;
// Single transaction: doc + selection land together. The provider's // Canonical Tiptap order — `editor.chain().focus().insertContent(...).run()`
// `applyCommandWithOptions` syncs the new selection back to DOM // adapted to a dropdown trigger:
// after a tick — see the matching change in `words-provider`. //
// 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({ api.applyCommand({
type: 'insertBlockInColumn', type: 'insertBlockInColumn',
columnsIdx: slot.columnsIdx, columnsIdx: slot.columnsIdx,
colIdx: slot.colIdx, colIdx: slot.colIdx,
block: newBlock as unknown as Readonly<Record<string, unknown>> block: newBlock as unknown as Readonly<Record<string, unknown>>
}); });
// Close the dropdown. We don't try to move focus to the editor // Capture the engine's INTENDED selection IMMEDIATELY after the
// from here — the dropdown's FocusScope (alive until its content // command runs (before any selectionchange echo can overwrite it).
// presence flips) reliably intercepts that. The user clicks into // `api.focus()` above lands focus on the contenteditable; that
// the new block to start typing — accepted trade-off until we // triggers a `selectionchange` event that fires
// rebuild this without a `DropdownMenu` whose trap fights us. // `syncSelectionFromDom`, which reads the stale DOM caret (doc
openColumnKey = null; // 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) { if (isAtomic) {
api.selectAtomicBlock(slot.columnsIdx, [ api.selectAtomicBlock(slot.columnsIdx, [
slot.columnsIdx, slot.columnsIdx,
@ -244,9 +277,15 @@
newInnerIdx newInnerIdx
]); ]);
} }
}, 0);
} else if (isAtomic) {
api.selectAtomicBlock(slot.columnsIdx, [
slot.columnsIdx,
slot.colIdx,
newInnerIdx
]);
}
// 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); if (win && timer !== undefined) win.clearTimeout(timer);
busy = false; busy = false;
} }
@ -309,12 +348,15 @@
side="bottom" side="bottom"
align="center" align="center"
data-words-column-inserter-menu data-words-column-inserter-menu
trapFocus={false}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
// Block the dropdown from auto-returning focus to the // `trapFocus={false}` above means FocusScope skips
// `+` trigger button. With focus on the trigger, the // installing its capture-phase `focusin` bouncer —
// next Space keystroke re-activates it (Space triggers // `api.focus()` in handleInsert actually sticks on
// focused buttons), reopens the menu, fires the first // the contenteditable.
// item — yielding a phantom repeat-insert. // `preventDefault` here still blocks the dropdown
// from auto-returning focus to the `+` trigger (Space
// on a focused button = phantom repeat-insert).
e.preventDefault(); e.preventDefault();
}} }}
> >

@ -442,15 +442,23 @@ export function insertBlockInColumn(
sel = createCollapsedSelection(point.path, point.offset); sel = createCollapsedSelection(point.path, point.offset);
} else { } else {
const stub = firstTextOfBlock(block); 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' block.type === 'list'
? [colsIdx, colIdx, newInnerIdx, 0, 0] ? [colsIdx, colIdx, newInnerIdx, 0]
: [colsIdx, colIdx, newInnerIdx, 0]; : [colsIdx, colIdx, newInnerIdx];
const startPoint = pointFromInlineTextOffset(normalized, inlinePath, 0); const startPoint = pointFromInlineTextOffset(normalized, containerPath, 0);
if (stub.length === 0) { if (stub.length === 0) {
sel = createCollapsedSelection(startPoint.path, startPoint.offset); sel = createCollapsedSelection(startPoint.path, startPoint.offset);
} else { } else {
const endPoint = pointFromInlineTextOffset(normalized, inlinePath, stub.length); const endPoint = pointFromInlineTextOffset(normalized, containerPath, stub.length);
sel = { sel = {
anchor: { path: startPoint.path, offset: startPoint.offset }, anchor: { path: startPoint.path, offset: startPoint.offset },
focus: { path: endPoint.path, offset: endPoint.offset } focus: { path: endPoint.path, offset: endPoint.offset }

@ -168,6 +168,16 @@ export type WordsProviderSnippetProps = {
* changed. * changed.
*/ */
readonly setSelection: (selection: WordsSelection | null) => boolean; 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; readonly runCommand: (command: WordsCommandName) => void;
/** /**
* Apply a command directly without going through the sema runtime * Apply a command directly without going through the sema runtime

@ -1271,6 +1271,7 @@ export class WordsProvider {
this.selectAtomicBlock(blockIndex, blockPath), this.selectAtomicBlock(blockIndex, blockPath),
clearSelectedBlock: () => this.clearSelectedBlock(), clearSelectedBlock: () => this.clearSelectedBlock(),
setSelection: (selection: WordsSelection | null) => this.setSelectionPublic(selection), setSelection: (selection: WordsSelection | null) => this.setSelectionPublic(selection),
restoreCaret: () => this.restoreDomSelection(),
runCommand: (command: WordsCommandName) => this.runCommandName(command), runCommand: (command: WordsCommandName) => this.runCommandName(command),
applyCommand: (command: WordsCommand) => this.applyCommand(command), applyCommand: (command: WordsCommand) => this.applyCommand(command),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language), setCodeLanguage: (language?: string) => this.setCodeLanguage(language),

Loading…
Cancel
Save

Powered by TurnKey Linux.