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) +
* 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,34 +210,66 @@
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<Record<string, unknown>>
});
// 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;
// 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,
@ -244,9 +277,15 @@
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);
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();
}}
>

@ -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 }

@ -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

@ -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),

Loading…
Cancel
Save

Powered by TurnKey Linux.