refactor(words)!: column block insertion moves to inspector (React Bricks pattern)

The floating in-canvas `+` overlay for column inserts was fundamentally
at odds with contenteditable semantics — it needed pointer-events:none
to let caret/text events pass through, but that ALSO disabled CSS
:hover detection, and any visible variant covered the column's auto-
trailing paragraph (the escape hatch the engine adds after atomic
inserts), hijacking clicks intended for typing.

Adopt React Bricks's pattern: container-block child insertion goes
through the sidebar/inspector, not via floating overlays on the canvas.

- New "Añadir bloque" dropdown per column in the Columns inspector
  panel (`words-block-panel.svelte`). Lists every insertable block
  type the engine knows (paragraph, headings, lists, image, divider,
  callout, etc.). Picking one dispatches `insertBlockInColumn`,
  appending it to the chosen column.
- Inspector lives outside the contenteditable, so the dropdown
  doesn't fight focus traps, doesn't cover content, doesn't need
  pointer-events gymnastics. Zero of the bug classes we hit.
- Deleted `words-column-inserter.svelte` and its mount in
  `words.svelte`. The `insertBlockInColumn` engine op stays — it's
  the right primitive, just driven from a different surface now.
- Added `ARIA_ADD_BLOCK_TO_COLUMN_N` and `LABEL_ADD_BLOCK` to the
  inspector langs catalog.

Top-level block insertion (between rows) keeps using the canonical
gutter handle `⋮⋮ → Insert below` and the slash menu (for in-flow
keyboard users).

Inside-column editing keeps working as before: click on the block,
type. Enter at end of a paragraph creates a new paragraph below
within the column.

Breaking: anyone who imported `words-column-inserter.svelte`
directly is broken. Nobody outside this folder did.

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

@ -46,6 +46,10 @@ export const WORDS_INSPECTOR_LANGS = {
'#?components.words.inspector.aria.add-column-end|Add column at end',
ARIA_REMOVE_COLUMN_N:
'#?components.words.inspector.aria.remove-column-n|Remove column {n}',
ARIA_ADD_BLOCK_TO_COLUMN_N:
'#?components.words.inspector.aria.add-block-to-column-n|Add block to column {n}',
LABEL_ADD_BLOCK:
'#?components.words.inspector.label.add-block|Add block',
PLACEHOLDER_COLUMN_WIDTH:
'#?components.words.inspector.placeholder.column-width|1fr, 200px, 30%',

@ -29,6 +29,7 @@
import { Button } from '$uix/eidos/components/button';
import { TextArea } from '$uix/eidos/components/textarea';
import Toggle from '$uix/eidos/components/toggle';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import WordsColorRow from './words-color-row.svelte';
import {
TextAlignStart,
@ -41,15 +42,17 @@
} from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import type {
ProviderSnippetProps,
WordsAlign,
WordsBlock,
WordsHeadingLevel,
WordsImageAlign,
WordsIntent,
WordsListKind,
WordsVerticalAlign
import {
defaultWordsSchema,
type ProviderSnippetProps,
type WordsAlign,
type WordsBlock,
type WordsBlockMenuEntry,
type WordsHeadingLevel,
type WordsImageAlign,
type WordsIntent,
type WordsListKind,
type WordsVerticalAlign
} from '$soma/components/words';
let {
@ -143,6 +146,24 @@
}
}
// Insertable block-type menu for the per-column "Add block" dropdown.
// Reuses the engine's canonical insertable list (same source the slash
// menu and gutter inserter draw from) so the inspector path stays in
// sync with the rest of the editor's block catalog.
const insertableEntries = $derived(defaultWordsSchema.insertable());
function insertIntoColumn(colIdx: number, entry: WordsBlockMenuEntry): void {
if (block.type !== 'columns') return;
const newBlock = entry.create();
if (!newBlock) return;
applyCommand({
type: 'insertBlockInColumn',
columnsIdx: blockIndex,
colIdx,
block: newBlock as unknown as Readonly<Record<string, unknown>>
});
}
// Columns helpers — operate on the whole `columns` array via
// `updateBlock`. No dedicated engine ops; the array IS the data.
function addColumn(): void {
@ -761,6 +782,37 @@
}}
/>
</div>
<!-- Add-block-to-column dropdown. Inspector-driven insertion is
the React-Bricks-style pattern that sidesteps the contenteditable
conflicts an in-canvas overlay would face (overlay covers the
trailing paragraph, focus trap fights api.focus(), etc.). The
menu lists every insertable block type the engine knows; picking
one dispatches `insertBlockInColumn`, appending it to the end of
this column. -->
<div data-words-inspector-row data-inline>
<DropdownMenu>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
aria-label={t(L.ARIA_ADD_BLOCK_TO_COLUMN_N).replace(
'{n}',
String(idx + 1)
)}
>
{#snippet icon()}<Plus />{/snippet}
{t(L.LABEL_ADD_BLOCK)}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content side="bottom" align="start">
{#each insertableEntries as entry (entry.id)}
<DropdownMenu.Item onSelect={() => insertIntoColumn(idx, entry)}>
{entry.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
{#if block.columns.length > 1}
<div data-words-inspector-row data-inline>
<Button

@ -1,488 +0,0 @@
<script lang="ts">
/**
* Column bottom-`+` inserter.
*
* Renders a `+` button at the BOTTOM of every `[data-words-column]`
* inside the editor — both for empty and populated columns. Click
* opens a dropdown of block types from `defaultWordsSchema.insertable()`;
* picking an entry appends a new block at the end of the column's
* `children` array, with a trailing paragraph added when the new
* block is atomic (so the caret has a place to land).
*
* Architecture choice: the inserter dispatches a single
* `insertBlockInColumn` engine command. That op returns a
* `WordsOperationResult` carrying BOTH the doc mutation AND the
* post-insert selection in one transaction — canonical Tiptap-style.
* Earlier iterations used `updateBlock` (patch the columns array)
* followed by a deferred `setSelection`; those two ticks raced
* against the engine's own `restoreDomSelection`, producing a stale
* caret. Combined with DropdownMenu's default focus-return-to-trigger,
* the next Space keystroke re-activated the `+` button and fired a
* phantom repeat-insert. Both pathologies disappear with the
* single-transaction op + `onCloseAutoFocus={(e) => e.preventDefault()}`
* on the dropdown Content (which suppresses the focus return).
*
* Positioning: same frame-relative measurement as
* `words-block-gutter`. The button sits at column bottom anchored to
* 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';
import {
defaultWordsSchema,
type ProviderSnippetProps,
type WordsBlock,
type WordsBlockMenuEntry,
type ColumnsBlock
} from '$soma/components/words';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
interface ColumnSlot {
readonly columnsIdx: number;
readonly colIdx: number;
/** True when the column has only a single empty paragraph — drives
* the visual size (large centered button for invitation vs. small
* bottom button for "add more"). Also drives the replace-in-place
* vs append semantics in `handleInsert`. */
readonly isEmpty: boolean;
readonly rect: { top: number; left: number; width: number; height: number };
}
let slots = $state<readonly ColumnSlot[]>([]);
let openColumnKey = $state<string | null>(null);
let hoveredColumnKey = $state<string | null>(null);
let raf = 0;
// Re-entrancy guard. The dropdown can fire `onSelect` twice in a
// row (pointerup + keyboard activation, or a single-click that gets
// re-emitted when the menu's portal repositions after the document
// mutation). Without the guard the user reported the same heading
// appearing 3× from a single click. The guard is cleared after a
// full tick so the next legitimate click is unaffected.
let busy = false;
function isColumnEmpty(col: Element): boolean {
const blocks = Array.from(col.children).filter(
(el) => el instanceof HTMLElement && el.matches("[data-words-node='block']")
) as HTMLElement[];
if (blocks.length !== 1) return false;
const only = blocks[0];
if (only.getAttribute('data-words-block') !== 'paragraph') return false;
const text = only.textContent ?? '';
return text.replace(/​/g, '').length === 0;
}
function readColumnPath(col: Element): { columnsIdx: number; colIdx: number } | null {
// Any block inside the column carries `data-words-path="C.K.…"` —
// the first two segments are the columns block's top-level index
// and the column's position. We read it off the first child block
// (always present after the engine's normalize seeds an empty
// paragraph) to avoid threading the path explicitly through DOM.
const firstBlock = Array.from(col.children).find(
(el) => el instanceof HTMLElement && el.matches("[data-words-node='block']")
) as HTMLElement | undefined;
const path = firstBlock?.getAttribute('data-words-path');
if (!path) return null;
const parts = path.split('.').map((s) => Number.parseInt(s, 10));
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null;
return { columnsIdx: parts[0], colIdx: parts[1] };
}
function relocate() {
if (!frame) {
slots = [];
return;
}
const fr = frame.getBoundingClientRect();
const cr = content.getBoundingClientRect();
const cols = Array.from(content.querySelectorAll('[data-words-column]')) as HTMLElement[];
const next: ColumnSlot[] = [];
for (const col of cols) {
const ids = readColumnPath(col);
if (!ids) continue;
const br = col.getBoundingClientRect();
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) continue;
const empty = isColumnEmpty(col);
// Empty column → cover the whole column rect (large centered
// `+` is the invitation to start). Non-empty → only a thin
// strip at the bottom of the column. The wrap has
// pointer-events: none AND its trigger overlays the wrap area,
// so a tall non-empty wrap WOULD cover content blocks AND
// (more importantly) the auto-added trailing paragraph that
// follows atomic inserts (image / divider). Clicks intended
// for that paragraph would hit the `+` trigger instead,
// opening the dropdown when the user just wanted to type.
// The strip height matches the trigger button (~26px) + a
// small padding so it sits cleanly at the column bottom.
const stripH = 28;
const top = empty ? br.top - fr.top : br.bottom - fr.top - stripH;
const height = empty ? br.height : stripH;
next.push({
columnsIdx: ids.columnsIdx,
colIdx: ids.colIdx,
isEmpty: empty,
rect: {
top,
left: br.left - fr.left,
width: br.width,
height
}
});
}
slots = next;
}
function schedule() {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
relocate();
});
}
$effect(() => {
const disposers = [dom.listen(content, 'scroll', schedule, { capture: true })];
schedule();
return () => {
for (const d of disposers) d?.();
if (raf) cancelAnimationFrame(raf);
};
});
// Re-measure after edits change which columns exist / shrink / grow.
$effect(() => {
void api.document;
schedule();
});
// Track which column the mouse is over — `pointer-events: none` on
// the inserter wrap is required so caret/text events pass through to
// the contenteditable below, but it ALSO disables CSS `:hover`
// detection on the wrap (the browser can't dispatch `mouseover` to a
// pointer-events:none element). So we listen on the column elements
// directly and stash the key in `hoveredColumnKey` — the wrap then
// reveals its trigger via `data-hovered={key === hoveredColumnKey}`.
$effect(() => {
// Re-bind when slots change (so we attach to fresh column elements).
void slots;
if (!content) return;
const cols = Array.from(content.querySelectorAll('[data-words-column]')) as HTMLElement[];
const disposers: Array<() => void> = [];
for (const col of cols) {
const ids = readColumnPath(col);
if (!ids) continue;
const key = `${ids.columnsIdx}-${ids.colIdx}`;
disposers.push(
dom.listen(col, 'mouseenter', () => {
hoveredColumnKey = key;
})
);
disposers.push(
dom.listen(col, 'mouseleave', () => {
if (hoveredColumnKey === key) hoveredColumnKey = null;
})
);
}
return () => {
for (const d of disposers) d?.();
};
});
function emptyParagraph(): WordsBlock {
return { type: 'paragraph', children: [{ type: 'text', text: '' }] } as WordsBlock;
}
function blockForEntry(entry: WordsBlockMenuEntry): WordsBlock | null {
// Mirror the gutter's collapsed UX: a single "Heading" entry creates
// H1 with a literal "Title" stub so the user sees what they got;
// a single "List" creates an unordered list seeded with a list-item.
// Picture also takes the placeholder shape so the inspector's
// URL field can drive the user to fill it.
if (entry.id === 'image') {
return { type: 'image', src: '' } as WordsBlock;
}
if (entry.id === 'heading') {
return {
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Title' }]
} as WordsBlock;
}
if (entry.id === 'list') {
return {
type: 'list',
kind: 'unordered',
items: [{ children: [{ type: 'text', text: 'List item' }] }]
} as WordsBlock;
}
if (entry.id === 'divider') {
return { type: 'divider' } as WordsBlock;
}
if (entry.id === 'table') {
// Registry creates a 3×3 empty table by default.
const created = entry.create();
return created as unknown as WordsBlock;
}
if (entry.id.startsWith('callout-')) {
const intent = entry.id.slice('callout-'.length);
return {
type: 'callout',
intent,
children: [emptyParagraph()]
} as WordsBlock;
}
// Fallback for registry-driven entries (paragraph, code-block, etc.).
const created = entry.create();
return created ? (created as unknown as WordsBlock) : null;
}
async function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) {
if (busy) return;
const newBlock = blockForEntry(entry);
if (!newBlock) return;
const colsBlock = api.document.children[slot.columnsIdx] as ColumnsBlock | undefined;
if (!colsBlock || colsBlock.type !== 'columns') return;
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 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;
}, 500);
const isAtomic = newBlock.type === 'image' || newBlock.type === 'divider';
const newInnerIdx = slot.isEmpty ? 0 : colsBlock.columns[slot.colIdx]?.children.length ?? 0;
// 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>>
});
// 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,
newInnerIdx
]);
}
if (win && timer !== undefined) win.clearTimeout(timer);
busy = false;
}
// Same collapsed entry set as the gutter for visual consistency.
const COLLAPSE_GROUPS: Record<string, { id: string; label: string }> = {
'heading-1': { id: 'heading', label: 'Heading' },
'unordered-list': { id: 'list', label: 'List' }
};
const COLLAPSED_DROPS = new Set(['heading-2', 'heading-3', 'ordered-list', 'check-list']);
const inserts = $derived.by(() => {
const all = defaultWordsSchema.insertable();
return all
.filter((entry) => !COLLAPSED_DROPS.has(entry.id))
.map((entry) => {
const replacement = COLLAPSE_GROUPS[entry.id];
if (!replacement) return entry;
return { ...entry, id: replacement.id, label: replacement.label };
});
});
function keyFor(slot: ColumnSlot): string {
return `${slot.columnsIdx}-${slot.colIdx}`;
}
</script>
{#if frame}
{#each slots as slot (keyFor(slot))}
{@const k = keyFor(slot)}
<!--
Positioning:
- Empty column → button centered (vertically AND horizontally)
inside the column. Big invitation to insert something.
- Non-empty column → button at the bottom seam of the column,
horizontally centered. Reads as "add another block here".
Both share the same dropdown content + the same `handleInsert`.
-->
<div
data-words-column-inserter
data-empty={slot.isEmpty ? '' : undefined}
data-hovered={hoveredColumnKey === k ? '' : undefined}
style="position:absolute; top:{slot.rect.top}px; left:{slot.rect.left}px; width:{slot.rect.width}px; height:{slot.rect.height}px;"
>
<DropdownMenu
open={openColumnKey === k}
onOpenChange={(v: boolean) => {
openColumnKey = v ? k : openColumnKey === k ? null : openColumnKey;
}}
>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
aria-label="Insertar bloque en columna"
data-words-column-inserter-trigger
>
{#snippet icon()}<Plus />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="center"
data-words-column-inserter-menu
trapFocus={false}
onCloseAutoFocus={(e) => {
// `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();
}}
>
{#each inserts as entry (entry.id)}
<DropdownMenu.Item onSelect={() => handleInsert(slot, entry)}>
{entry.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
{/each}
{/if}
<style>
/* Overlay layer — non-interactive so caret/text events pass through
to the contenteditable below. The trigger button alone re-enables
pointer events via the explicit override below. */
[data-words-column-inserter] {
display: flex;
justify-content: center;
pointer-events: none;
}
/* Empty column: center the button vertically too — feels like an
invitation in the middle of the empty rectangle. Always visible
so the user knows where to start. */
[data-words-column-inserter][data-empty] {
align-items: center;
}
/* Non-empty column: pin to the bottom seam — "add another block
below the current content". A small inset so the trigger doesn't
collide with the column's border line. */
[data-words-column-inserter]:not([data-empty]) {
align-items: flex-end;
padding-block-end: var(--space-1);
}
/* Empty column: always visible (full opacity) as invitation. */
[data-words-column-inserter][data-empty] :global([data-words-column-inserter-trigger]) {
pointer-events: auto;
opacity: 0.65;
transition: opacity 120ms ease;
}
[data-words-column-inserter][data-empty][data-hovered]
:global([data-words-column-inserter-trigger]),
[data-words-column-inserter][data-empty]
:global([data-words-column-inserter-trigger][data-state='open']) {
opacity: 1;
}
/* Non-empty column: HIDDEN AND non-interactive by default so the
button doesn't cover the last block's clickable area (a user
trying to click at the end of the last paragraph would otherwise
hit the button instead of placing the caret). Both `opacity: 0`
AND `pointer-events: none` are needed — opacity alone leaves the
click target alive at zero opacity. Appears on hover OR when the
user already opened the menu. */
[data-words-column-inserter]:not([data-empty]) :global([data-words-column-inserter-trigger]) {
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
/* `:hover` on the wrap can't fire (wrap has pointer-events: none) so
we drive visibility via `data-hovered` (a JS-tracked attribute set
by listening to `mouseenter`/`mouseleave` on the column elements
themselves — see the matching $effect in the script block). */
[data-words-column-inserter]:not([data-empty])[data-hovered]
:global([data-words-column-inserter-trigger]),
[data-words-column-inserter]:not([data-empty])
:global([data-words-column-inserter-trigger][data-state='open']) {
pointer-events: auto;
opacity: 0.65;
}
[data-words-column-inserter]:not([data-empty])[data-hovered]
:global([data-words-column-inserter-trigger]:hover),
[data-words-column-inserter]:not([data-empty])
:global([data-words-column-inserter-trigger][data-state='open']) {
opacity: 1;
}
</style>

@ -20,7 +20,6 @@
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
import WordsColumnInserter from './words-column-inserter.svelte';
import WordsInspector from './words-inspector.svelte';
import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
import type { WordsProps } from './types';
@ -72,7 +71,13 @@
<WordsBubble {api} />
{#if contentEl}
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsColumnInserter {api} content={contentEl} {dom} />
<!-- Column-content insertion lives in the Inspector's Columns
panel ("Añadir bloque" dropdown per column), React-Bricks-
style. Floating in-canvas overlay was removed: its
pointer-events:none required for caret pass-through ALSO
disabled `:hover` reveal, and any visible variant covered
the column's auto-trailing paragraph, hijacking clicks
meant for typing. Sidebar-driven insertion sidesteps both. -->
{/if}
{#if inspector === 'sidebar'}

Loading…
Cancel
Save

Powered by TurnKey Linux.