fix(words): column Add-block menu (one per kind, localized) + allow empty column

Two bugs in the column inspector's "Add block" flow:

1. Wrong options + untranslated. The menu rendered
   `defaultWordsSchema.insertable()` — the flattened menu ENTRIES, which
   expand heading → "Heading 1/2/3" and list → "Bulleted/Numbered/Check"
   with hard-coded English labels. Now it lists one entry per block KIND
   from `schema.all()` (paragraph / heading / quote / code / list / table /
   divider / callout), labelled via `blockKindLabel(type)` → fully
   localized ("Texto", "Encabezado", "Lista con viñetas", …). The exact
   level / list-kind is tuned afterward in that block's own panel. Inserts
   the kind's default via `spec.menu[0].create()`. Excludes `columns`
   (no columns-in-columns) and `image` (insertable:false — needs a URL).

2. Couldn't leave a column empty. `deleteChildInColumn` re-seeded an empty
   paragraph when the last block was removed ("must stay typable"). But an
   empty column is a valid, supported state — the engine renders the
   wrapper with `data-words-id` so it stays clickable, and neither the
   validator nor `normalizeColumns` re-seeds it (only the doc root + lists
   do). Removed the re-seed; deleting the last block now leaves the column
   genuinely empty.

Also added the 5 missing column-panel aria bundle entries
(select/delete/move-up/move-down/add-block-to-column-n) so the block-list
buttons read in ES instead of the English fallback.

Verified in the browser: menu shows 8 localized kinds (1 heading, 1 list,
no "Heading 1"); deleting the last column block → child count 0, no reseed,
column still clickable; aria-labels localized. npm run check 1 pre-existing
error (grafito, not Words) · words soma 452/452 · prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 2d60703ab4
commit 64e13d1dd6

@ -379,6 +379,14 @@ export const WORDS_INSPECTOR_BUNDLE = {
'remove-column': { en: 'Delete column', es: 'Eliminar columna' }, 'remove-column': { en: 'Delete column', es: 'Eliminar columna' },
'add-column-end': { en: 'Add column at end', es: 'Añadir columna al final' }, 'add-column-end': { en: 'Add column at end', es: 'Añadir columna al final' },
'remove-column-n': { en: 'Remove column {n}', es: 'Eliminar columna {n}' }, 'remove-column-n': { en: 'Remove column {n}', es: 'Eliminar columna {n}' },
'add-block-to-column-n': {
en: 'Add block to column {n}',
es: 'Añadir bloque a la columna {n}'
},
'select-block-in-column': { en: 'Select this block', es: 'Seleccionar este bloque' },
'delete-block-in-column': { en: 'Delete this block', es: 'Eliminar este bloque' },
'move-block-up': { en: 'Move block up', es: 'Subir el bloque' },
'move-block-down': { en: 'Move block down', es: 'Bajar el bloque' },
'header-row': { en: 'Toggle header row', es: 'Alternar fila de cabecera' }, 'header-row': { en: 'Toggle header row', es: 'Alternar fila de cabecera' },
'header-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' }, 'header-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' },
'cell-align': { 'cell-align': {

@ -43,7 +43,7 @@
type ProviderSnippetProps, type ProviderSnippetProps,
type WordsAncestryNode, type WordsAncestryNode,
type WordsBlock, type WordsBlock,
type WordsBlockMenuEntry type WordsBlockSpec
} from '$soma/components/words'; } from '$soma/components/words';
// Any node the inspector can edit visual props on: a typed block OR a // Any node the inspector can edit visual props on: a typed block OR a
@ -86,11 +86,25 @@
// paints on the active-block outline — the two surfaces read in agreement. // paints on the active-block outline — the two surfaces read in agreement.
const depth = $derived(Math.min(Math.max(activeLoc?.path.length ?? 1, 1), 4)); const depth = $derived(Math.min(Math.max(activeLoc?.path.length ?? 1, 1), 4));
// Insertable block types for the active-column "Add block" menu — same // Block TYPES offered in the active-column "Add block" menu. We list one
// catalog the gutter / slash menu use, minus `columns` (a column is a // entry per block KIND (from `schema.all()`), NOT the expanded menu
// nested Stack and can't hold another columns block). // entries (`schema.insertable()` flattens heading → H1/H2/H3 and list →
const columnInsertableEntries = $derived( // bulleted/numbered/check, with hard-coded English labels). The column
defaultWordsSchema.insertable().filter((entry) => entry.id !== 'columns') // inserter only needs "add a heading" / "add a list" — the precise level
// / kind is then tuned in that block's own inspector panel. Excludes:
// - `columns` — a column is a nested Stack; no columns-in-columns.
// - `image` — its spec is `insertable: false` (needs a URL / upload
// flow first), matching the slash menu's exclusion.
// Labels come from `blockKindLabel(type)` → fully localized.
const columnInsertableSpecs = $derived(
defaultWordsSchema.all().filter(
(spec) =>
spec.type !== 'columns' &&
spec.type !== 'image' &&
// Must expose at least one menu entry whose `create()` builds
// the default instance of the kind.
(spec.menu?.length ?? 0) > 0
)
); );
// Label for a child block in the active-column block list. // Label for a child block in the active-column block list.
@ -109,12 +123,13 @@
// Insert a block into the active column and auto-activate it (soma owns // Insert a block into the active column and auto-activate it (soma owns
// active-block: `insertBlockInColumn` sets it from the pre-assigned id). // active-block: `insertBlockInColumn` sets it from the pre-assigned id).
function insertIntoActiveColumn( // `spec.create()` builds a fresh, default instance of the block kind
colsIdx: number, // (paragraph / heading-1 / bulleted list / …); the user refines level /
colIdx: number, // kind afterwards in that block's inspector panel.
entry: WordsBlockMenuEntry function insertIntoActiveColumn(colsIdx: number, colIdx: number, spec: WordsBlockSpec): void {
): void { // The spec's FIRST menu entry is the kind's default (heading → H1,
const newBlock = entry.create(); // list → bulleted); `create()` lives on the entry, not the spec.
const newBlock = spec.menu?.[0]?.create();
if (!newBlock) return; if (!newBlock) return;
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
api.applyCommand({ api.applyCommand({
@ -126,18 +141,19 @@
} }
// Remove a child block from the active column (immutable rebuild via // Remove a child block from the active column (immutable rebuild via
// the path-aware update op). A column must stay typable, so removing // the path-aware update op). An EMPTY column is a valid, supported state
// the last block reseeds an empty paragraph. // — the engine renders the column wrapper with `data-words-id` so it
// stays clickable / addressable even with zero children, and neither the
// validator nor `normalizeColumns` re-seeds it (only the document root
// and lists do). So deleting the last block leaves the column genuinely
// empty; we don't reinsert a placeholder paragraph.
function deleteChildInColumn( function deleteChildInColumn(
colsIdx: number, colsIdx: number,
colIdx: number, colIdx: number,
childIdx: number, childIdx: number,
children: readonly WordsBlock[] children: readonly WordsBlock[]
): void { ): void {
let next = children.filter((_, i) => i !== childIdx); const next = children.filter((_, i) => i !== childIdx);
if (next.length === 0) {
next = [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }];
}
api.applyCommand({ api.applyCommand({
type: 'updateBlockAtPath', type: 'updateBlockAtPath',
blockPath: [colsIdx, colIdx], blockPath: [colsIdx, colIdx],
@ -468,11 +484,11 @@
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content side="bottom" align="start"> <DropdownMenu.Content side="bottom" align="start">
{#each columnInsertableEntries as entry (entry.id)} {#each columnInsertableSpecs as spec (spec.type)}
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, entry)} onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, spec)}
> >
{entry.label} {blockKindLabel(spec.type)}
</DropdownMenu.Item> </DropdownMenu.Item>
{/each} {/each}
</DropdownMenu.Content> </DropdownMenu.Content>
@ -500,7 +516,8 @@
attached attached
block block
value={[block.fontFamily ?? 'default']} value={[block.fontFamily ?? 'default']}
onValueChange={(v: string[]) => edit({ fontFamily: !v[0] || v[0] === 'default' ? undefined : v[0] })} onValueChange={(v: string[]) =>
edit({ fontFamily: !v[0] || v[0] === 'default' ? undefined : v[0] })}
aria-label={t(L.ARIA_FONT_FAMILY)} aria-label={t(L.ARIA_FONT_FAMILY)}
> >
{#each FONTS as f (f.v)} {#each FONTS as f (f.v)}
@ -587,7 +604,8 @@
attached attached
block block
value={[block.align ?? 'left']} value={[block.align ?? 'left']}
onValueChange={(v: string[]) => edit({ align: !v[0] || v[0] === 'left' ? undefined : (v[0] as AlignValue) })} onValueChange={(v: string[]) =>
edit({ align: !v[0] || v[0] === 'left' ? undefined : (v[0] as AlignValue) })}
aria-label={t(L.ARIA_ALIGN)} aria-label={t(L.ARIA_ALIGN)}
> >
{#each ALIGNS as a (a.v)} {#each ALIGNS as a (a.v)}

Loading…
Cancel
Save

Powered by TurnKey Linux.