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' },
'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}' },
'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-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' },
'cell-align': {

@ -43,7 +43,7 @@
type ProviderSnippetProps,
type WordsAncestryNode,
type WordsBlock,
type WordsBlockMenuEntry
type WordsBlockSpec
} from '$soma/components/words';
// 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.
const depth = $derived(Math.min(Math.max(activeLoc?.path.length ?? 1, 1), 4));
// Insertable block types for the active-column "Add block" menu — same
// catalog the gutter / slash menu use, minus `columns` (a column is a
// nested Stack and can't hold another columns block).
const columnInsertableEntries = $derived(
defaultWordsSchema.insertable().filter((entry) => entry.id !== 'columns')
// Block TYPES offered in the active-column "Add block" menu. We list one
// entry per block KIND (from `schema.all()`), NOT the expanded menu
// entries (`schema.insertable()` flattens heading → H1/H2/H3 and list →
// bulleted/numbered/check, with hard-coded English labels). The column
// 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.
@ -109,12 +123,13 @@
// Insert a block into the active column and auto-activate it (soma owns
// active-block: `insertBlockInColumn` sets it from the pre-assigned id).
function insertIntoActiveColumn(
colsIdx: number,
colIdx: number,
entry: WordsBlockMenuEntry
): void {
const newBlock = entry.create();
// `spec.create()` builds a fresh, default instance of the block kind
// (paragraph / heading-1 / bulleted list / …); the user refines level /
// kind afterwards in that block's inspector panel.
function insertIntoActiveColumn(colsIdx: number, colIdx: number, spec: WordsBlockSpec): void {
// The spec's FIRST menu entry is the kind's default (heading → H1,
// list → bulleted); `create()` lives on the entry, not the spec.
const newBlock = spec.menu?.[0]?.create();
if (!newBlock) return;
const newId = crypto.randomUUID();
api.applyCommand({
@ -126,18 +141,19 @@
}
// Remove a child block from the active column (immutable rebuild via
// the path-aware update op). A column must stay typable, so removing
// the last block reseeds an empty paragraph.
// the path-aware update op). An EMPTY column is a valid, supported state
// — 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(
colsIdx: number,
colIdx: number,
childIdx: number,
children: readonly WordsBlock[]
): void {
let next = children.filter((_, i) => i !== childIdx);
if (next.length === 0) {
next = [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }];
}
const next = children.filter((_, i) => i !== childIdx);
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: [colsIdx, colIdx],
@ -468,11 +484,11 @@
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content side="bottom" align="start">
{#each columnInsertableEntries as entry (entry.id)}
{#each columnInsertableSpecs as spec (spec.type)}
<DropdownMenu.Item
onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, entry)}
onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, spec)}
>
{entry.label}
{blockKindLabel(spec.type)}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
@ -500,7 +516,8 @@
attached
block
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)}
>
{#each FONTS as f (f.v)}
@ -587,7 +604,8 @@
attached
block
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)}
>
{#each ALIGNS as a (a.v)}

Loading…
Cancel
Save

Powered by TurnKey Linux.