diff --git a/src/uix/eidos/components/words/langs-inspector.ts b/src/uix/eidos/components/words/langs-inspector.ts index 112621e0d..13851e81d 100644 --- a/src/uix/eidos/components/words/langs-inspector.ts +++ b/src/uix/eidos/components/words/langs-inspector.ts @@ -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': { diff --git a/src/uix/eidos/components/words/words-inspector.svelte b/src/uix/eidos/components/words/words-inspector.svelte index 32b20834e..055fa9a1f 100644 --- a/src/uix/eidos/components/words/words-inspector.svelte +++ b/src/uix/eidos/components/words/words-inspector.svelte @@ -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 @@ - {#each columnInsertableEntries as entry (entry.id)} + {#each columnInsertableSpecs as spec (spec.type)} insertIntoActiveColumn(colsIdx, colIdx, entry)} + onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, spec)} > - {entry.label} + {blockKindLabel(spec.type)} {/each} @@ -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)}