feat(words): column inserter + provider DOM-selection sync after commands

Three architectural pieces for inserting blocks into a column slot from
an overlay button, plus the missing DOM-selection sync that any
imperative consumer of `applyCommand` needs.

- Engine op `insertBlockInColumn` returns `{document, selection,
  activeMarks}` in one transaction (Tiptap-style). Atomic blocks land
  with a trailing paragraph escape hatch + caret there; text-bearing
  blocks select any stub text ("Title", "List item") so the next
  keystroke replaces it Notion-style.
- Provider `applyCommandWithOptions` now schedules `restoreDomSelection`
  via tick when the command changes the model selection (typing-batch
  excluded — the browser already placed the caret). Was the hidden gap:
  overlay buttons, drag-drop, slash menu, the new column inserter, all
  updated the model but the DOM caret stayed wherever the user last
  clicked, breaking subsequent text editing.
- `words-column-inserter.svelte` rebuilt around a busy guard with a
  hard 250ms safety timeout (the previous pendingInsert + onCloseAuto
  Focus pattern could leave the `+` button dead forever if the
  dropdown's teardown swallowed the close callback).

Plus type sync: `WordsProviderSnippetProps` now declares
`selectedBlockPath`, the second arg of `selectAtomicBlock`, and
`setSelection` — they were exposed by the runtime but missing from
the type, breaking typecheck on eidos consumers.

Demo carries a `columns` block in the initial doc as a permanent test
fixture for column-related fixes.

**Known issue documented in CONTINUE.md P0:** typing inside a `columns`
block does NOT insert — selection sync (`syncSelectionFromDom`) isn't
mapping nested paths (`12.0.0.0`) to the model correctly. The inserter
flow is wired correctly; once the path encoding for nested selections
lands, the full Notion-style "click + → pick Heading → type" flow
works end to end.

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

@ -0,0 +1,421 @@
/**
* Translation refs consumed by the block inspector (and its
* `WordsNumRow` / `WordsColorRow` / `WordsSpacingRow` building blocks).
*
* Each entry is an `idlangref` of the form `#?components.words.{key}|fallback`
* — the eidos runtime (`eidos.langs.ts(KEY)`) returns the localized
* string when a translation is registered, or the post-`|` fallback when
* not. Apps that bundle the canonical en/es packs see translations
* automatically; consumers without packs see the English fallback.
*
* Three layers of labels:
*
* - **Block titles** — what kind of block the inspector is editing
* (`Heading 1`, `Text`, `Quote`, …). The heading title uses a
* placeholder for the level number rendered by the consumer.
* - **Section titles** — the accordion section headers
* (`Typography`, `Color`, `Layout`, `Spacing`, `Border`).
* - **Field labels + options** — every form control's visible label and
* every enumerated option that the inspector renders (font family,
* font weight, alignment, border style, etc.). Includes the aria
* labels used by the per-side spacing inputs and the link toggle.
*
* Adding a new label: insert the entry here AND in the consumer
* component, then call `eidos.langs.ts(WORDS_INSPECTOR_LANGS.YOUR_KEY)`
* to read it. The fallback after `|` is the canonical English string
* (the source of truth — translation packs derive from it).
*/
export const WORDS_INSPECTOR_LANGS = {
// Block titles -------------------------------------------------------
BLOCK_HEADING: '#?components.words.inspector.block.heading|Heading',
BLOCK_PARAGRAPH: '#?components.words.inspector.block.paragraph|Text',
BLOCK_QUOTE: '#?components.words.inspector.block.quote|Quote',
BLOCK_CODE: '#?components.words.inspector.block.code|Code',
BLOCK_LIST_ORDERED: '#?components.words.inspector.block.list-ordered|Numbered list',
BLOCK_LIST_CHECK: '#?components.words.inspector.block.list-check|Check list',
BLOCK_LIST_BULLET: '#?components.words.inspector.block.list-bullet|Bulleted list',
BLOCK_TABLE: '#?components.words.inspector.block.table|Table',
BLOCK_IMAGE: '#?components.words.inspector.block.image|Image',
BLOCK_DIVIDER: '#?components.words.inspector.block.divider|Divider',
BLOCK_CALLOUT: '#?components.words.inspector.block.callout|Callout',
BLOCK_GENERIC: '#?components.words.inspector.block.generic|Block',
BLOCK_COLUMNS: '#?components.words.inspector.block.columns|Columns',
LABEL_COLUMN_WIDTH: '#?components.words.inspector.label.column-width|Width',
LABEL_COLUMN_N: '#?components.words.inspector.label.column-n|Column {n}',
ARIA_ADD_COLUMN_END:
'#?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}',
PLACEHOLDER_COLUMN_WIDTH:
'#?components.words.inspector.placeholder.column-width|1fr, 200px, 30%',
// Section titles -----------------------------------------------------
SECTION_BLOCK: '#?components.words.inspector.section.block|Block',
SECTION_TYPOGRAPHY: '#?components.words.inspector.section.typography|Typography',
SECTION_COLOR: '#?components.words.inspector.section.color|Color',
SECTION_LAYOUT: '#?components.words.inspector.section.layout|Layout',
SECTION_SPACING: '#?components.words.inspector.section.spacing|Spacing',
SECTION_BORDER: '#?components.words.inspector.section.border|Border',
// Block-specific labels (heading / list / code / image) -------------
LABEL_HEADING_LEVEL: '#?components.words.inspector.label.heading-level|Level',
LABEL_LIST_KIND: '#?components.words.inspector.label.list-kind|Kind',
LABEL_CODE_LANGUAGE: '#?components.words.inspector.label.code-language|Language',
LABEL_SRC: '#?components.words.inspector.label.src|URL',
LABEL_ALT: '#?components.words.inspector.label.alt|Alt text',
LABEL_CAPTION: '#?components.words.inspector.label.caption|Caption',
LABEL_WIDTH: '#?components.words.inspector.label.width|Width',
LABEL_HEIGHT: '#?components.words.inspector.label.height|Height',
LABEL_IMAGE_ALIGN: '#?components.words.inspector.label.image-align|Align',
LABEL_IMAGE_FULL_WIDTH:
'#?components.words.inspector.label.image-full-width|Full width',
IMAGE_FULL_WIDTH_ON: '#?components.words.inspector.image-full-width.on|On',
IMAGE_FULL_WIDTH_OFF: '#?components.words.inspector.image-full-width.off|Off',
LABEL_INTENT: '#?components.words.inspector.label.intent|Intent',
LABEL_TITLE: '#?components.words.inspector.label.title|Title',
LABEL_ROWS: '#?components.words.inspector.label.rows|Rows',
LABEL_COLUMNS: '#?components.words.inspector.label.columns|Columns',
LABEL_HEADER_ROW: '#?components.words.inspector.label.header-row|Header row',
LABEL_HEADER_COLUMN: '#?components.words.inspector.label.header-column|Header column',
LABEL_CELL_ALIGN: '#?components.words.inspector.label.cell-align|Cell align',
LABEL_CELL_VALIGN: '#?components.words.inspector.label.cell-valign|Vertical align',
SECTION_CELL: '#?components.words.inspector.section.cell|Cell',
SECTION_ROW: '#?components.words.inspector.section.row|Row',
SECTION_TABLE: '#?components.words.inspector.section.table|Table',
VALIGN_TOP: '#?components.words.inspector.valign.top|Top',
VALIGN_MIDDLE: '#?components.words.inspector.valign.middle|Middle',
VALIGN_BOTTOM: '#?components.words.inspector.valign.bottom|Bottom',
ARIA_CELL_ALIGN:
'#?components.words.inspector.aria.cell-align|Cell horizontal align',
ARIA_CELL_VALIGN:
'#?components.words.inspector.aria.cell-valign|Cell vertical align',
// Callout intent options — labels carry the perceptual reading the
// sema doctrine assigns to each value (cap. 12). Used as chip text
// in the intent ToggleGroup; the chip background is the doctrinal
// color of the intent (rendered via the `data-intent` data-attr).
INTENT_NEUTRAL: '#?components.words.inspector.intent.neutral|Info',
INTENT_AFFIRM: '#?components.words.inspector.intent.affirm|Tip',
INTENT_FULFILL: '#?components.words.inspector.intent.fulfill|Success',
INTENT_RISK: '#?components.words.inspector.intent.risk|Warning',
INTENT_THREAT: '#?components.words.inspector.intent.threat|Error',
INTENT_LOSS: '#?components.words.inspector.intent.loss|Critical',
// List kind options --------------------------------------------------
LIST_BULLETED: '#?components.words.inspector.list.bulleted|Bulleted',
LIST_NUMBERED: '#?components.words.inspector.list.numbered|Numbered',
LIST_CHECK: '#?components.words.inspector.list.check|Check',
// Block-specific ARIA + placeholders --------------------------------
ARIA_HEADING_LEVEL: '#?components.words.inspector.aria.heading-level|Heading level',
ARIA_LIST_KIND: '#?components.words.inspector.aria.list-kind|List kind',
ARIA_CODE_LANGUAGE: '#?components.words.inspector.aria.code-language|Code language',
ARIA_SRC: '#?components.words.inspector.aria.src|Image URL',
ARIA_ALT: '#?components.words.inspector.aria.alt|Image alternative text',
ARIA_CAPTION: '#?components.words.inspector.aria.caption|Image caption',
ARIA_IMAGE_ALIGN: '#?components.words.inspector.aria.image-align|Image alignment',
ARIA_IMAGE_FULL_WIDTH:
'#?components.words.inspector.aria.image-full-width|Image fills container width',
ARIA_UPLOAD_IMAGE:
'#?components.words.inspector.aria.upload-image|Upload image (encodes inline as data URL)',
ARIA_INTENT: '#?components.words.inspector.aria.intent|Callout intent',
ARIA_TITLE: '#?components.words.inspector.aria.title|Callout title',
ARIA_ADD_ROW_ABOVE:
'#?components.words.inspector.aria.add-row-above|Insert row above',
ARIA_ADD_ROW_BELOW:
'#?components.words.inspector.aria.add-row-below|Insert row below',
ARIA_REMOVE_ROW: '#?components.words.inspector.aria.remove-row|Delete row',
ARIA_ADD_COLUMN_LEFT:
'#?components.words.inspector.aria.add-column-left|Insert column left',
ARIA_ADD_COLUMN_RIGHT:
'#?components.words.inspector.aria.add-column-right|Insert column right',
ARIA_REMOVE_COLUMN:
'#?components.words.inspector.aria.remove-column|Delete column',
ARIA_HEADER_ROW: '#?components.words.inspector.aria.header-row|Toggle header row',
ARIA_HEADER_COLUMN:
'#?components.words.inspector.aria.header-column|Toggle header column',
PLACEHOLDER_CODE_LANGUAGE:
'#?components.words.inspector.placeholder.code-language|e.g. javascript',
PLACEHOLDER_SRC: '#?components.words.inspector.placeholder.src|https://...',
PLACEHOLDER_ALT: '#?components.words.inspector.placeholder.alt|Describe the image',
PLACEHOLDER_CAPTION:
'#?components.words.inspector.placeholder.caption|Optional caption',
PLACEHOLDER_CALLOUT_TITLE:
'#?components.words.inspector.placeholder.callout-title|Optional title',
// Field labels -------------------------------------------------------
LABEL_FONT: '#?components.words.inspector.label.font|Font',
LABEL_FONT_SIZE: '#?components.words.inspector.label.font-size|Font size',
LABEL_WEIGHT: '#?components.words.inspector.label.weight|Weight',
LABEL_LINE_HEIGHT: '#?components.words.inspector.label.line-height|Line height',
LABEL_TEXT: '#?components.words.inspector.label.text|Text',
LABEL_BACKGROUND: '#?components.words.inspector.label.background|Background',
LABEL_ALIGN: '#?components.words.inspector.label.align|Align',
LABEL_MARGIN: '#?components.words.inspector.label.margin|Margin',
LABEL_PADDING: '#?components.words.inspector.label.padding|Padding',
LABEL_STYLE: '#?components.words.inspector.label.style|Style',
LABEL_BORDER_WIDTH: '#?components.words.inspector.label.border-width|Border width',
LABEL_COLOR: '#?components.words.inspector.label.color|Color',
LABEL_CORNER_RADIUS: '#?components.words.inspector.label.corner-radius|Corner radius',
LABEL_PRESET: '#?components.words.inspector.label.preset|Preset',
// Font family options ------------------------------------------------
FONT_DEFAULT: '#?components.words.inspector.font.default|Default',
FONT_SANS: '#?components.words.inspector.font.sans|Sans',
FONT_SERIF: '#?components.words.inspector.font.serif|Serif',
FONT_MONO: '#?components.words.inspector.font.mono|Mono',
// Font weight options ------------------------------------------------
WEIGHT_REGULAR: '#?components.words.inspector.weight.regular|Regular',
WEIGHT_MEDIUM: '#?components.words.inspector.weight.medium|Medium',
WEIGHT_SEMIBOLD: '#?components.words.inspector.weight.semibold|Semibold',
WEIGHT_BOLD: '#?components.words.inspector.weight.bold|Bold',
// Align options ------------------------------------------------------
ALIGN_LEFT: '#?components.words.inspector.align.left|Left',
ALIGN_CENTER: '#?components.words.inspector.align.center|Center',
ALIGN_RIGHT: '#?components.words.inspector.align.right|Right',
ALIGN_JUSTIFY: '#?components.words.inspector.align.justify|Justify',
// Border style options -----------------------------------------------
BORDER_SOLID: '#?components.words.inspector.border.solid|Solid',
BORDER_DASHED: '#?components.words.inspector.border.dashed|Dashed',
BORDER_DOTTED: '#?components.words.inspector.border.dotted|Dotted',
// Corner radius preset options ---------------------------------------
PRESET_NONE: '#?components.words.inspector.preset.none|None',
PRESET_FULL: '#?components.words.inspector.preset.full|Full',
// ARIA labels (aria-label / title) -----------------------------------
ARIA_FONT_FAMILY: '#?components.words.inspector.aria.font-family|Font family',
ARIA_FONT_WEIGHT: '#?components.words.inspector.aria.font-weight|Font weight',
ARIA_ALIGN: '#?components.words.inspector.aria.align|Align',
ARIA_BORDER_STYLE: '#?components.words.inspector.aria.border-style|Border style',
ARIA_RADIUS_PRESET: '#?components.words.inspector.aria.radius-preset|Corner radius preset',
// Spacing per-side aria labels ---------------------------------------
SPACING_TOP: '#?components.words.inspector.spacing.top|Top',
SPACING_RIGHT: '#?components.words.inspector.spacing.right|Right',
SPACING_BOTTOM: '#?components.words.inspector.spacing.bottom|Bottom',
SPACING_LEFT: '#?components.words.inspector.spacing.left|Left',
SPACING_LINK: '#?components.words.inspector.spacing.link|Chain pairs',
SPACING_UNLINK: '#?components.words.inspector.spacing.unlink|Unchain sides',
SPACING_LINK_TITLE:
'#?components.words.inspector.spacing.link-title|Chain pairs — top with bottom, left with right',
SPACING_UNLINK_TITLE:
'#?components.words.inspector.spacing.unlink-title|Unchain — edit each side independently',
// Empty state --------------------------------------------------------
EMPTY: '#?components.words.inspector.empty|Select a block to edit its style.'
} as const;
// ── Translation bundle ───────────────────────────────────────────────────────
//
// The eidos langs engine resolves a ref's fallback (the text after `|`)
// ONLY when no entry is registered at the ref's path. Declaring refs is
// not enough — apps must call `langs.extend(namespace, bundle)` so the
// resolver finds localized values when the active locale changes.
//
// Words registers this bundle automatically from `Words.svelte`'s
// `onMount` (one-shot, idempotent). Apps that want to override or add
// locales can call `eidos.langs.extend('components.words.inspector', …)`
// after the component mounts — later registrations deep-merge over this
// one (the engine warns on leaf overwrites in DEV).
//
// Each leaf is a `LangRecord` — `{ [locale]: string }`. The engine
// picks the entry that matches the active locale, falling back through
// the configured chain when an entry is missing.
/**
* Localized strings for the inspector. Shape mirrors the ref path —
* every entry sits under `components.words.inspector.{section}.{key}`.
*
* Adding a locale: add the locale key to each leaf (e.g. `pt: '...'`).
* Adding a new entry: declare it in `WORDS_INSPECTOR_LANGS` above AND
* here; keep both ends in sync. Lint / runtime falls back to the ref's
* inline string when the locale lookup misses.
*/
export const WORDS_INSPECTOR_BUNDLE = {
block: {
heading: { en: 'Heading', es: 'Encabezado' },
paragraph: { en: 'Text', es: 'Texto' },
quote: { en: 'Quote', es: 'Cita' },
code: { en: 'Code', es: 'Código' },
'list-ordered': { en: 'Numbered list', es: 'Lista numerada' },
'list-check': { en: 'Check list', es: 'Lista de verificación' },
'list-bullet': { en: 'Bulleted list', es: 'Lista con viñetas' },
table: { en: 'Table', es: 'Tabla' },
image: { en: 'Image', es: 'Imagen' },
divider: { en: 'Divider', es: 'Separador' },
callout: { en: 'Callout', es: 'Aviso' },
generic: { en: 'Block', es: 'Bloque' },
columns: { en: 'Columns', es: 'Columnas' }
},
section: {
block: { en: 'Block', es: 'Bloque' },
typography: { en: 'Typography', es: 'Tipografía' },
color: { en: 'Color', es: 'Color' },
layout: { en: 'Layout', es: 'Disposición' },
spacing: { en: 'Spacing', es: 'Espaciado' },
border: { en: 'Border', es: 'Borde' },
cell: { en: 'Cell', es: 'Celda' },
row: { en: 'Row', es: 'Fila' },
table: { en: 'Table', es: 'Tabla' }
},
label: {
'heading-level': { en: 'Level', es: 'Nivel' },
'list-kind': { en: 'Kind', es: 'Tipo' },
'code-language': { en: 'Language', es: 'Lenguaje' },
src: { en: 'URL', es: 'URL' },
alt: { en: 'Alt text', es: 'Texto alternativo' },
caption: { en: 'Caption', es: 'Pie' },
width: { en: 'Width', es: 'Ancho' },
height: { en: 'Height', es: 'Alto' },
'image-align': { en: 'Align', es: 'Alinear' },
'image-full-width': { en: 'Full width', es: 'Ancho total' },
intent: { en: 'Intent', es: 'Intención' },
title: { en: 'Title', es: 'Título' },
rows: { en: 'Rows', es: 'Filas' },
columns: { en: 'Columns', es: 'Columnas' },
'header-row': { en: 'Header row', es: 'Fila de cabecera' },
'header-column': { en: 'Header column', es: 'Columna de cabecera' },
'cell-align': { en: 'Cell align', es: 'Alineación celda' },
'cell-valign': { en: 'Vertical align', es: 'Alineación vertical' },
'column-width': { en: 'Width', es: 'Ancho' },
'column-n': { en: 'Column {n}', es: 'Columna {n}' },
font: { en: 'Font', es: 'Fuente' },
'font-size': { en: 'Font size', es: 'Tamaño' },
weight: { en: 'Weight', es: 'Grosor' },
'line-height': { en: 'Line height', es: 'Interlineado' },
text: { en: 'Text', es: 'Texto' },
background: { en: 'Background', es: 'Fondo' },
align: { en: 'Align', es: 'Alinear' },
margin: { en: 'Margin', es: 'Margen' },
padding: { en: 'Padding', es: 'Relleno' },
style: { en: 'Style', es: 'Estilo' },
'border-width': { en: 'Border width', es: 'Grosor del borde' },
color: { en: 'Color', es: 'Color' },
'corner-radius': { en: 'Corner radius', es: 'Radio de esquina' },
preset: { en: 'Preset', es: 'Preajuste' }
},
intent: {
neutral: { en: 'Info', es: 'Info' },
affirm: { en: 'Tip', es: 'Consejo' },
fulfill: { en: 'Success', es: 'Éxito' },
risk: { en: 'Warning', es: 'Aviso' },
threat: { en: 'Error', es: 'Error' },
loss: { en: 'Critical', es: 'Crítico' }
},
list: {
bulleted: { en: 'Bulleted', es: 'Viñetas' },
numbered: { en: 'Numbered', es: 'Numerada' },
check: { en: 'Check', es: 'Verificación' }
},
'image-full-width': {
on: { en: 'On', es: 'Sí' },
off: { en: 'Off', es: 'No' }
},
font: {
default: { en: 'Default', es: 'Predeterminada' },
sans: { en: 'Sans', es: 'Sans' },
serif: { en: 'Serif', es: 'Serif' },
mono: { en: 'Mono', es: 'Mono' }
},
weight: {
regular: { en: 'Regular', es: 'Normal' },
medium: { en: 'Medium', es: 'Media' },
semibold: { en: 'Semibold', es: 'Semi-negrita' },
bold: { en: 'Bold', es: 'Negrita' }
},
align: {
left: { en: 'Left', es: 'Izquierda' },
center: { en: 'Center', es: 'Centro' },
right: { en: 'Right', es: 'Derecha' },
justify: { en: 'Justify', es: 'Justificar' }
},
valign: {
top: { en: 'Top', es: 'Arriba' },
middle: { en: 'Middle', es: 'Medio' },
bottom: { en: 'Bottom', es: 'Abajo' }
},
border: {
solid: { en: 'Solid', es: 'Sólido' },
dashed: { en: 'Dashed', es: 'Discontinuo' },
dotted: { en: 'Dotted', es: 'Punteado' }
},
preset: {
none: { en: 'None', es: 'Ninguno' },
full: { en: 'Full', es: 'Completo' }
},
aria: {
'heading-level': { en: 'Heading level', es: 'Nivel del encabezado' },
'list-kind': { en: 'List kind', es: 'Tipo de lista' },
'code-language': { en: 'Code language', es: 'Lenguaje del código' },
src: { en: 'Image URL', es: 'URL de la imagen' },
alt: { en: 'Image alternative text', es: 'Texto alternativo de la imagen' },
caption: { en: 'Image caption', es: 'Pie de la imagen' },
'image-align': { en: 'Image alignment', es: 'Alineación de la imagen' },
'image-full-width': {
en: 'Image fills container width',
es: 'La imagen ocupa todo el ancho del contenedor'
},
'upload-image': {
en: 'Upload image (encodes inline as data URL)',
es: 'Subir imagen (codifica en línea como URL de datos)'
},
intent: { en: 'Callout intent', es: 'Intención del aviso' },
title: { en: 'Callout title', es: 'Título del aviso' },
'add-row-above': { en: 'Insert row above', es: 'Insertar fila encima' },
'add-row-below': { en: 'Insert row below', es: 'Insertar fila debajo' },
'remove-row': { en: 'Delete row', es: 'Eliminar fila' },
'add-column-left': { en: 'Insert column left', es: 'Insertar columna a la izquierda' },
'add-column-right': { en: 'Insert column right', es: 'Insertar columna a la derecha' },
'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}' },
'header-row': { en: 'Toggle header row', es: 'Alternar fila de cabecera' },
'header-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' },
'cell-align': {
en: 'Cell horizontal align',
es: 'Alineación horizontal de celda'
},
'cell-valign': {
en: 'Cell vertical align',
es: 'Alineación vertical de celda'
},
'font-family': { en: 'Font family', es: 'Familia tipográfica' },
'font-weight': { en: 'Font weight', es: 'Grosor de fuente' },
align: { en: 'Align', es: 'Alinear' },
'border-style': { en: 'Border style', es: 'Estilo del borde' },
'radius-preset': { en: 'Corner radius preset', es: 'Preajuste de radio' }
},
placeholder: {
'code-language': { en: 'e.g. javascript', es: 'p. ej. javascript' },
src: { en: 'https://...', es: 'https://...' },
alt: { en: 'Describe the image', es: 'Describe la imagen' },
caption: { en: 'Optional caption', es: 'Pie opcional' },
'callout-title': { en: 'Optional title', es: 'Título opcional' },
'column-width': { en: '1fr, 200px, 30%', es: '1fr, 200px, 30%' }
},
spacing: {
top: { en: 'Top', es: 'Arriba' },
right: { en: 'Right', es: 'Derecha' },
bottom: { en: 'Bottom', es: 'Abajo' },
left: { en: 'Left', es: 'Izquierda' },
link: { en: 'Chain pairs', es: 'Encadenar pares' },
unlink: { en: 'Unchain sides', es: 'Desencadenar lados' },
'link-title': {
en: 'Chain pairs — top with bottom, left with right',
es: 'Encadenar pares — arriba con abajo, izquierda con derecha'
},
'unlink-title': {
en: 'Unchain — edit each side independently',
es: 'Desencadenar — editar cada lado independiente'
}
},
empty: {
en: 'Select a block to edit its style.',
es: 'Selecciona un bloque para editar su estilo.'
}
} as const;

@ -21,7 +21,11 @@
import type { ActiveDom } from '$adom';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { GripVertical } from '$uix/eidos/components/icon';
import { defaultWordsSchema, type ProviderSnippetProps } from '$soma/components/words';
import {
defaultWordsSchema,
type ProviderSnippetProps,
type WordsBlockMenuEntry
} from '$soma/components/words';
let {
api,
@ -76,22 +80,37 @@
function locate(clientX: number, clientY: number) {
const blocks = topLevelBlocks();
if (blocks.length === 0) {
show = false;
inGutter = false;
return;
}
// Voronoi-on-Y: each block claims a `[from, to)` zone where the
// boundary with a neighbour is the midpoint of the inter-block
// gap. Every vertical pixel belongs to exactly one block. This is
// the only way thin blocks (`<hr data-words-block='divider'>` has
// `block-size: 0` — the visible line is a border, the element's
// rect is ~1px tall) become hover-targetable. The old code
// matched "cursor inside the block's rect OR the last block whose
// top is ≤ cursor.y", which left dividers with a single-pixel
// hit zone.
const rects = blocks.map((el) => el.getBoundingClientRect());
let target: HTMLElement | null = null;
let targetIndex = -1;
let targetRect: DOMRect | null = null;
for (let i = 0; i < blocks.length; i++) {
const r = blocks[i].getBoundingClientRect();
if (clientY >= r.top && clientY <= r.bottom) {
const r = rects[i];
const prev = rects[i - 1];
const next = rects[i + 1];
const from = prev ? (prev.bottom + r.top) / 2 : -Infinity;
const to = next ? (r.bottom + next.top) / 2 : Infinity;
if (clientY >= from && clientY < to) {
target = blocks[i];
targetIndex = i;
targetRect = r;
break;
}
if (r.top <= clientY) {
target = blocks[i];
targetIndex = i;
targetRect = r;
}
}
if (target && targetRect) {
place(target, targetIndex);
@ -151,6 +170,21 @@
// The soma menu owns close-on-select (closeOnSelect defaults true); the
// closed panel hides via opacity, so mutating the document here doesn't
// interfere with the menu's dismissal.
function inspect() {
// Hand the active block to the inspector by marking it as the
// visually-selected atomic. Works for ALL top-level block types
// — image / divider that genuinely can't host a caret, AND blocks
// like `columns` / `table` / `list` whose Block panel offers
// editor surfaces (add column, header toggles, etc.) that the
// caret-driven path wouldn't normally surface.
//
// Passing `[index]` as the path ensures `selectedBlockPath` is set
// too — keeps the inspector's path-aware activeBlock derivation
// happy and lines up with the "still inside same top-level
// wrapper" guard in `syncSelectionFromDom` (no spurious clear on
// the next selectionchange).
api.selectAtomicBlock(index, [index]);
}
function moveUp() {
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
}
@ -168,11 +202,101 @@
api.applyCommand({ type: 'insertBlock', blockIndex: index + 1, block });
}
// Image entries can't ship a valid `create()` (their `src` is unknown
// until the user picks a file or types a URL). The gutter inserter
// opens a file picker → FileReader → data URL when an image entry
// is selected, then inserts the block with a valid `src`. URLs from
// real backends would replace this with a fetch + upload.
function insertImageInteractive() {
const doc =
(typeof document !== 'undefined' ? document : null) ??
content?.ownerDocument ??
null;
if (!doc) return;
const input = doc.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = typeof reader.result === 'string' ? reader.result : '';
if (!src) return;
const alt = file.name.replace(/\.[^/.]+$/, '') || undefined;
insert({ type: 'image', src, alt });
};
reader.readAsDataURL(file);
});
doc.body.appendChild(input);
input.click();
}
function handleInsert(entry: WordsBlockMenuEntry) {
if (entry.id === 'image') {
insertImageInteractive();
return;
}
// Headings inserted from the gutter ship a literal "Title" text
// so the user sees what they got — relying on CSS `:empty:before`
// placeholders is brittle (contenteditable browsers strip the
// pseudo on caret entry, the rule needs `position: relative` on
// the block, etc.). Literal text always renders; the user
// select-all + type to replace. Slash menu users keep getting an
// empty heading (they explicitly typed `/h1` and start typing
// straight away). List gets the same treatment.
if (entry.id === 'heading') {
insert({
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Title' }]
});
return;
}
if (entry.id === 'list') {
insert({
type: 'list',
kind: 'unordered',
items: [{ children: [{ type: 'text', text: 'List item' }] }]
});
return;
}
insert(entry.create());
}
// Insert-menu entries come from the block registry — the single source
// of truth shared with the slash menu, so the two lists can't drift.
// Entries flagged non-insertable (e.g. image, which needs a URL/upload
// flow) are skipped.
const inserts = defaultWordsSchema.insertable().filter((entry) => entry.insertable !== false);
// Image is `insertable: false` because its `create()` returns an empty
// `src`; we surface it anyway and route through the file picker above
// so the user can actually pick an image from the gutter menu.
//
// Collapse multi-variant entries into ONE canonical chip in the
// gutter (creates the default variant — the inspector then exposes
// a level/kind toggle to switch to the others). The slash menu keeps
// every variant because typing `/h2` or `/check` is the natural way
// to pick the variant there; a click-pick menu doesn't get that
// ergonomic, so a flat "Heading" / "List" reads cleaner.
//
// Today the registry exposes:
// heading-1 / heading-2 / heading-3 → "Heading" (creates H1)
// unordered-list / ordered-list / check-list → "List" (creates bullets)
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, ...replacement };
return entry;
});
});
</script>
{#if (show || menuOpen) && rect}
@ -182,7 +306,14 @@
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width + 6}px; height: {rect.height + 6}px;"
></div>
{/if}
<div data-words-block-gutter style="top: {rect.top}px;">
<!-- Center the handle vertically against the block's height: position
it at the block midpoint and let `transform: translateY(-50%)`
in CSS pull it up by half its own height. The previous
`margin-block-start` hack only worked for the FIRST line of
short blocks (paragraph, heading) — tall blocks (code, table,
image) had the handle pinned to the top, far from the cursor's
natural target. -->
<div data-words-block-gutter style="top: {rect.top + rect.height / 2}px;">
<DropdownMenu bind:open={menuOpen}>
<DropdownMenu.Trigger
variant="ghost"
@ -205,6 +336,8 @@
Block actions
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
<DropdownMenu.Item onSelect={inspect}>Inspect</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>Move up</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveDown}>Move down</DropdownMenu.Item>
<DropdownMenu.Item onSelect={duplicate}>Duplicate</DropdownMenu.Item>
@ -213,7 +346,7 @@
<DropdownMenu.SubTrigger>Insert below</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent data-words-block-handle-menu>
{#each inserts as ins (ins.id)}
<DropdownMenu.Item onSelect={() => insert(ins.create())}>{ins.label}</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => handleInsert(ins)}>{ins.label}</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>

@ -0,0 +1,780 @@
<script lang="ts">
/**
* Inspector block-specific properties panel.
*
* Sits at the TOP of the inspector body (above the generic
* Typography / Color / … accordion) and renders controls that are
* unique to the active block's TYPE — i.e. properties that the
* shared `Block` interface doesn't carry.
*
* Today the panel exposes:
* - **heading** → level toggle (H1 / H2 / H3, the only levels the
* engine supports per `WordsHeadingLevel`).
* - **list** → kind toggle (Bulleted / Numbered / Check).
* - **code** → language input (free-text; common languages
* are auto-suggested via the `<datalist>`).
*
* Blocks without per-type props (paragraph, quote, divider) render
* nothing — the parent decides whether to hide the whole section.
*
* The panel never reads the engine directly: every edit goes through
* `applyCommand` so undo/redo + sema + history all behave correctly.
* `setBlock` swaps the block shape (paragraph↔heading↔code); the
* generic `updateBlock` patches the active block in-place for ops
* that don't change the shape (list `kind`, code `language`).
*/
import type { Component } from 'svelte';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { NumberField } from '$uix/eidos/components/number-field';
import { Button } from '$uix/eidos/components/button';
import { TextArea } from '$uix/eidos/components/textarea';
import Toggle from '$uix/eidos/components/toggle';
import WordsColorRow from './words-color-row.svelte';
import {
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify,
Plus,
Minus,
Upload
} 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
} from '$soma/components/words';
let {
block,
blockIndex,
blockPath = null,
selectionPath = null,
applyCommand
}: {
block: WordsBlock;
/** Used by `updateBlock`-based edits (image / callout / table).
* Heading / list / code ops use selection-derived commands and
* don't need it, but it's required when patching the block
* in-place. */
blockIndex: number;
/** Full path to the active block. For top-level blocks this is
* `[blockIndex]`; for nested atomics (image inside a column)
* it's the full walk — `patchBlock` routes through the
* path-aware command when the path has more than one segment. */
blockPath?: readonly number[] | null;
/** Full selection path inside the active block — used by the
* table panel to expose cell-scoped controls (text/vertical
* align) when the user's cursor is inside a cell. `null` when
* no caret is set or the block was selected from the gutter. */
selectionPath?: readonly number[] | null;
applyCommand: ProviderSnippetProps['applyCommand'];
} = $props();
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
// Heading level chips — string values so ToggleGroup's single-select
// API (which deals in strings) can drive a typed numeric level.
const HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[];
const LIST_KINDS = $derived<readonly { v: WordsListKind; label: string }[]>([
{ v: 'unordered', label: t(L.LIST_BULLETED) },
{ v: 'ordered', label: t(L.LIST_NUMBERED) },
{ v: 'check', label: t(L.LIST_CHECK) }
]);
// Image align is a 3-way enum (left / center / right) — float intent
// for inline images. Reusing the TextAlign* icons because they
// represent the same directional concept visually; lucide has no
// dedicated image-float icons.
const IMAGE_ALIGNS = $derived<
readonly { v: WordsImageAlign; label: string; icon: Component }[]
>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd }
]);
// Full 4-way align used by table cells (image's `imageAlign` doesn't
// have justify). Reuses the `TextAlign*` icons — same visual axis.
const ALIGNS = $derived<readonly { v: WordsAlign; label: string; icon: Component }[]>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd },
{ v: 'justify', label: t(L.ALIGN_JUSTIFY), icon: TextAlignJustify }
]);
// Callout intents — the 6 doctrinal sema valences (libro cap. 12).
// Each chip carries a `data-intent` so the recipe CSS can paint the
// swatch with the intent's doctrinal color (the same palette token
// the actual callout renders against). Labels are localized — the
// chip is text + a tiny color dot, not pure color.
const INTENTS = $derived<readonly { v: WordsIntent; label: string }[]>([
{ v: 'neutral', label: t(L.INTENT_NEUTRAL) },
{ v: 'affirm', label: t(L.INTENT_AFFIRM) },
{ v: 'fulfill', label: t(L.INTENT_FULFILL) },
{ v: 'risk', label: t(L.INTENT_RISK) },
{ v: 'threat', label: t(L.INTENT_THREAT) },
{ v: 'loss', label: t(L.INTENT_LOSS) }
]);
// Generic in-place patcher for the active block. Used by branches
// (image, callout) that mutate a block's intrinsic data without
// changing its shape. `updateBlock` shallow-merges the patch and
// re-normalises the document — undo-safe, sema-safe.
//
// Routes through the path-aware command when the active block is
// nested (e.g. an image inside a column at path [10,0,1]) — the
// top-level `updateBlock` would patch the WRAPPER block, which is
// the wrong target.
function patchBlock(patch: Record<string, unknown>): void {
if (blockPath && blockPath.length > 1) {
applyCommand({ type: 'updateBlockAtPath', blockPath, patch });
} else {
applyCommand({ type: 'updateBlock', blockIndex, patch });
}
}
// Columns helpers — operate on the whole `columns` array via
// `updateBlock`. No dedicated engine ops; the array IS the data.
function addColumn(): void {
if (block.type !== 'columns') return;
const next = [
...block.columns,
{
children: [{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '' }] }]
}
];
patchBlock({ columns: next });
}
function removeColumnAt(idx: number): void {
if (block.type !== 'columns' || block.columns.length <= 1) return;
const next = block.columns.filter((_, i) => i !== idx);
patchBlock({ columns: next });
}
function setColumnWidth(idx: number, width: string): void {
if (block.type !== 'columns') return;
const trimmed = width.trim();
const next = block.columns.map((col, i) =>
i === idx ? { ...col, width: trimmed || undefined } : col
);
patchBlock({ columns: next });
}
// Hierarchical scopes for the table block — the path inside the
// block tells us which level the user's cursor is on:
// path = [blockIdx] → table (gutter)
// path = [blockIdx, rowIdx] → row
// path = [blockIdx, rowIdx, cellIdx] → cell
// path = [blockIdx, rowIdx, cellIdx, inlineIdx] → cell-element
// The "Cell" subsection appears when the user is in a cell or
// deeper (length ≥ 3). The active cell is read off `block.rows`.
const tableCellIndices = $derived.by<{ row: number; cell: number } | null>(() => {
if (block.type !== 'table' || !selectionPath || selectionPath.length < 3) return null;
const row = selectionPath[1];
const cell = selectionPath[2];
if (row === undefined || cell === undefined) return null;
return { row, cell };
});
const activeCell = $derived.by(() => {
if (block.type !== 'table' || !tableCellIndices) return undefined;
return block.rows[tableCellIndices.row]?.cells[tableCellIndices.cell];
});
// Active row = the row enclosing the user's selection. Exists when
// the caret is at least at `[i, r]` deep, regardless of whether a
// cell is identified. Powers the Row subsection.
const tableRowIndex = $derived.by<number | null>(() => {
if (block.type !== 'table' || !selectionPath || selectionPath.length < 2) return null;
return selectionPath[1] ?? null;
});
const activeRow = $derived.by(() => {
if (block.type !== 'table' || tableRowIndex === null) return undefined;
return block.rows[tableRowIndex];
});
// Color presets matched to the inspector's WordsColorRow consumers.
// Kept inline because the panel is a leaf component; the inspector's
// own COLOR_PRESETS is its own private constant.
const TABLE_COLOR_PRESETS = [
'#fff7ed',
'#fef3c7',
'#dcfce7',
'#dbeafe',
'#ede9fe',
'#fce7f3',
'#f1f5f9',
'#e2e8f0',
'#1a1a1a',
'#ffffff'
];
// A small starter set of common languages. Free-text via the input,
// the datalist just nudges typing.
const CODE_LANGUAGE_SUGGESTIONS = [
'plaintext',
'javascript',
'typescript',
'tsx',
'jsx',
'css',
'scss',
'html',
'json',
'yaml',
'toml',
'markdown',
'bash',
'shell',
'python',
'go',
'rust',
'java',
'kotlin',
'swift',
'c',
'cpp',
'csharp',
'sql',
'svelte',
'vue'
];
</script>
{#if block.type === 'heading'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_HEADING_LEVEL)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[String(block.level)]}
onValueChange={(v: string[]) => {
const next = v[0];
if (!next) return;
const level = Number(next) as WordsHeadingLevel;
applyCommand({ type: 'setBlock', block: 'heading', level });
}}
aria-label={t(L.ARIA_HEADING_LEVEL)}
>
{#each HEADING_LEVELS as lvl (lvl)}
<ToggleGroup.Item value={String(lvl)}>H{lvl}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'list'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_LIST_KIND)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.kind]}
onValueChange={(v: string[]) => {
const kind = v[0] as WordsListKind | undefined;
if (!kind) return;
applyCommand({ type: 'toggleList', kind });
}}
aria-label={t(L.ARIA_LIST_KIND)}
>
{#each LIST_KINDS as k (k.v)}
<ToggleGroup.Item value={k.v}>{k.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'code'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CODE_LANGUAGE)}</span>
<!-- Free-text input with datalist suggestions. `setCodeLanguage`
is the engine's dedicated command for this. The `list` attr
ties to the `<datalist>` below so common languages
auto-suggest while still accepting anything the user types. -->
<input
data-words-inspector-code-language
type="text"
list="words-inspector-code-language-suggestions"
value={block.language ?? ''}
placeholder={t(L.PLACEHOLDER_CODE_LANGUAGE)}
aria-label={t(L.ARIA_CODE_LANGUAGE)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value.trim();
applyCommand({ type: 'setCodeLanguage', language: next || undefined });
}}
/>
<datalist id="words-inspector-code-language-suggestions">
{#each CODE_LANGUAGE_SUGGESTIONS as lang (lang)}
<option value={lang}></option>
{/each}
</datalist>
</div>
{:else if block.type === 'image'}
<!-- Image-specific properties: src URL (first — the user has just
inserted a placeholder image and the very first action expected
is "what URL?" + an Upload picker as a shortcut for binary
sources), alt + caption (text), fullWidth toggle (gates the next
three), width + height (numbers), float align (icon toggle).
Every edit goes through `updateBlock` so undo/redo behaves
correctly. Inputs commit on `change` (blur / Enter) — the engine
never sees mid-keystroke state. Empty strings collapse to
`undefined` for alt/caption (an empty alt is meaningful HTML-wise;
treat truly missing as omission, not empty); the src field
intentionally keeps the literal empty string so the placeholder
render branch stays active when the user clears it.
Text rows use `data-inline` for horizontal `space-between`
(label left + input right) so the panel reads compactly. -->
{@const imgFullWidth = block.fullWidth === true}
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_SRC)}</span>
<div data-words-inspector-src-group>
<input
data-words-inspector-text
type="url"
value={block.src}
placeholder={t(L.PLACEHOLDER_SRC)}
aria-label={t(L.ARIA_SRC)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value.trim();
patchBlock({ src: next });
}}
/>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_UPLOAD_IMAGE)}
title={t(L.ARIA_UPLOAD_IMAGE)}
onclick={() => {
// File picker → FileReader → data URL. Hostable apps that wire
// `onUploadImage` on the editor will see the same path used by
// drag-and-drop. Here in the panel we keep the inspector self-
// contained and write a data URL straight to `src`; consumers
// that want a backend upload can intercept by replacing the
// inspector or pre-processing the model.
const doc = typeof document !== 'undefined' ? document : null;
if (!doc) return;
const input = doc.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
input.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = typeof reader.result === 'string' ? reader.result : '';
if (!src) return;
const altGuess = file.name.replace(/\.[^/.]+$/, '') || undefined;
patchBlock({
src,
...(block.alt === undefined && altGuess ? { alt: altGuess } : {})
});
};
reader.readAsDataURL(file);
});
doc.body.appendChild(input);
input.click();
}}
>
{#snippet icon()}<Upload />{/snippet}
</Button>
</div>
</div>
<div data-words-inspector-row>
<!-- Alt stays stacked (textarea wants the full row); a screen reader's
description of the image can be more than one short line. Commits
on blur — the eidos TextArea exposes `onBlur` via the headless soma,
keeping the model out of mid-keystroke history noise. `autosize`
+ minRows/maxRows grows the field with the content; the inspector
row hosts it cleanly because it stays width:100%. -->
<span data-words-inspector-label>{t(L.LABEL_ALT)}</span>
<TextArea
size="xs"
value={block.alt ?? ''}
autosize
minRows={2}
maxRows={6}
onValueChange={(v: string) => patchBlock({ alt: v || undefined })}
>
<TextArea.Input
placeholder={t(L.PLACEHOLDER_ALT)}
aria-label={t(L.ARIA_ALT)}
/>
</TextArea>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_CAPTION)}</span>
<input
data-words-inspector-text
type="text"
value={block.caption ?? ''}
placeholder={t(L.PLACEHOLDER_CAPTION)}
aria-label={t(L.ARIA_CAPTION)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value;
patchBlock({ caption: next || undefined });
}}
/>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_IMAGE_FULL_WIDTH)}</span>
<Toggle
size="xs"
pressed={imgFullWidth}
onPressedChange={(v: boolean) => patchBlock({ fullWidth: v || undefined })}
aria-label={t(L.ARIA_IMAGE_FULL_WIDTH)}
>
{imgFullWidth ? t(L.IMAGE_FULL_WIDTH_ON) : t(L.IMAGE_FULL_WIDTH_OFF)}
</Toggle>
</div>
<div data-words-inspector-row data-inline data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_WIDTH)}</span>
<NumberField
value={block.width ?? 0}
min={0}
step={1}
size="xs"
variant="ghost"
disabled={imgFullWidth}
aria-label={t(L.LABEL_WIDTH)}
onValueCommit={(n: number) => patchBlock({ width: n > 0 ? n : undefined })}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-row data-inline data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_HEIGHT)}</span>
<NumberField
value={block.height ?? 0}
min={0}
step={1}
size="xs"
variant="ghost"
disabled={imgFullWidth}
aria-label={t(L.LABEL_HEIGHT)}
onValueCommit={(n: number) => patchBlock({ height: n > 0 ? n : undefined })}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-row data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_IMAGE_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
disabled={imgFullWidth}
value={block.imageAlign ? [block.imageAlign] : []}
onValueChange={(v: string[]) =>
patchBlock({ imageAlign: (v[0] as WordsImageAlign | undefined) ?? undefined })}
aria-label={t(L.ARIA_IMAGE_ALIGN)}
>
{#each IMAGE_ALIGNS as a (a.v)}
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'callout'}
<!-- Callout-specific properties: intent (6 doctrinal valences from
the sema canon — neutral / affirm / fulfill / risk / threat /
loss) + title. Each intent chip carries `data-intent` so the
recipe CSS can render a tiny color dot for the doctrinal hue. -->
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_INTENT)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.intent]}
onValueChange={(v: string[]) => {
const intent = v[0] as WordsIntent | undefined;
if (!intent) return;
patchBlock({ intent });
}}
aria-label={t(L.ARIA_INTENT)}
>
{#each INTENTS as i (i.v)}
<ToggleGroup.Item value={i.v} aria-label={i.label} title={i.label}>
<span data-words-intent-swatch data-intent={i.v}></span>
{i.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_TITLE)}</span>
<input
data-words-inspector-text
type="text"
value={block.title ?? ''}
placeholder={t(L.PLACEHOLDER_CALLOUT_TITLE)}
aria-label={t(L.ARIA_TITLE)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value;
patchBlock({ title: next || undefined });
}}
/>
</div>
{:else if block.type === 'table'}
<!-- Three hierarchical scopes — Cell (caret in cell), Row (caret in
row), Table (always). Each scope edits ONLY its own element:
`setCellVisual` patches the active cell, `setRowVisual` patches
the active row, `updateBlock` patches the table itself. No edit
ever propagates beyond its scope. This is the Notion / Google
Docs / Word convention — the previous "everything goes to the
table" model was the canonical 2026-05-30 bug. -->
{#if activeCell}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_CELL)}</header>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CELL_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={activeCell.align ? [activeCell.align] : []}
onValueChange={(v: string[]) => {
const align = v[0] as WordsAlign | undefined;
if (!align) return;
applyCommand({ type: 'setCellTextAlign', align });
}}
aria-label={t(L.ARIA_CELL_ALIGN)}
>
{#each ALIGNS as a (a.v)}
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CELL_VALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={activeCell.verticalAlign ? [activeCell.verticalAlign] : []}
onValueChange={(v: string[]) => {
const verticalAlign = v[0] as WordsVerticalAlign | undefined;
if (!verticalAlign) return;
applyCommand({ type: 'setCellVerticalAlign', verticalAlign });
}}
aria-label={t(L.ARIA_CELL_VALIGN)}
>
<ToggleGroup.Item value="top">{t(L.VALIGN_TOP)}</ToggleGroup.Item>
<ToggleGroup.Item value="middle">{t(L.VALIGN_MIDDLE)}</ToggleGroup.Item>
<ToggleGroup.Item value="bottom">{t(L.VALIGN_BOTTOM)}</ToggleGroup.Item>
</ToggleGroup>
</div>
<WordsColorRow
label={t(L.LABEL_TEXT)}
current={activeCell.color}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) => applyCommand({ type: 'setCellVisual', visual: { color: hex } })}
/>
<WordsColorRow
label={t(L.LABEL_BACKGROUND)}
current={activeCell.background}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) =>
applyCommand({ type: 'setCellVisual', visual: { background: hex } })}
/>
</div>
{/if}
{#if activeRow}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_ROW)}</header>
<WordsColorRow
label={t(L.LABEL_BACKGROUND)}
current={activeRow.background}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) =>
applyCommand({ type: 'setRowVisual', visual: { background: hex } })}
/>
</div>
{/if}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_TABLE)}</header>
<!-- 6-button matrix (Notion / Google Docs / Word convention):
above/below/delete per row, left/right/delete per column.
Operates relative to the caret cell — the user must be
inside a cell for these to fire; otherwise the engine
silently no-ops. Counts shown stay authoritative. -->
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_ROWS)}</span>
<span data-words-inspector-value>{block.rows.length}</span>
</div>
<div data-words-inspector-table-ops>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_ADD_ROW_ABOVE)}
title={t(L.ARIA_ADD_ROW_ABOVE)}
onclick={() => applyCommand({ type: 'insertTableRow', position: 'before' })}
>
↑ Row
</Button>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_ADD_ROW_BELOW)}
title={t(L.ARIA_ADD_ROW_BELOW)}
onclick={() => applyCommand({ type: 'insertTableRow', position: 'after' })}
>
↓ Row
</Button>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_REMOVE_ROW)}
title={t(L.ARIA_REMOVE_ROW)}
onclick={() => applyCommand({ type: 'deleteTableRow' })}
>
{#snippet icon()}<Minus />{/snippet}
Row
</Button>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_COLUMNS)}</span>
<span data-words-inspector-value>{block.rows[0]?.cells.length ?? 0}</span>
</div>
<div data-words-inspector-table-ops>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_ADD_COLUMN_LEFT)}
title={t(L.ARIA_ADD_COLUMN_LEFT)}
onclick={() => applyCommand({ type: 'insertTableColumn', position: 'before' })}
>
← Col
</Button>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_ADD_COLUMN_RIGHT)}
title={t(L.ARIA_ADD_COLUMN_RIGHT)}
onclick={() => applyCommand({ type: 'insertTableColumn', position: 'after' })}
>
→ Col
</Button>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_REMOVE_COLUMN)}
title={t(L.ARIA_REMOVE_COLUMN)}
onclick={() => applyCommand({ type: 'deleteTableColumn' })}
>
{#snippet icon()}<Minus />{/snippet}
Col
</Button>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_HEADER_ROW)}</span>
<Toggle
size="xs"
variant="ghost"
pressed={block.headerRow ?? false}
onPressedChange={() => applyCommand({ type: 'toggleTableHeaderRow' })}
aria-label={t(L.ARIA_HEADER_ROW)}
>
{block.headerRow ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
</Toggle>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_HEADER_COLUMN)}</span>
<Toggle
size="xs"
variant="ghost"
pressed={block.headerCol ?? false}
onPressedChange={() => applyCommand({ type: 'toggleTableHeaderColumn' })}
aria-label={t(L.ARIA_HEADER_COLUMN)}
>
{block.headerCol ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
</Toggle>
</div>
</div>
{:else if block.type === 'columns'}
<!-- Columns block — count + per-column width (CSS flex value:
'1' / '200px' / '30%'). Each column is identified by index.
Add appends an empty column at the end; remove drops the
numbered column (disabled when only 1 column remains so the
block doesn't collapse to nothing). -->
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_COLUMNS)}</span>
<span data-words-inspector-value>{block.columns.length}</span>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_ADD_COLUMN_END)}
title={t(L.ARIA_ADD_COLUMN_END)}
onclick={addColumn}
>
{#snippet icon()}<Plus />{/snippet}
</Button>
</div>
{#each block.columns as col, idx (idx)}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>
{t(L.LABEL_COLUMN_N).replace('{n}', String(idx + 1))}
</header>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_COLUMN_WIDTH)}</span>
<input
data-words-inspector-text
type="text"
value={col.width ?? ''}
placeholder={t(L.PLACEHOLDER_COLUMN_WIDTH)}
aria-label={t(L.LABEL_COLUMN_WIDTH)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value;
setColumnWidth(idx, next);
}}
/>
</div>
{#if block.columns.length > 1}
<div data-words-inspector-row data-inline>
<Button
size="xs"
variant="ghost"
aria-label={t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
title={t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
onclick={() => removeColumnAt(idx)}
>
{#snippet icon()}<Minus />{/snippet}
{t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
</Button>
</div>
{/if}
</div>
{/each}
{/if}

@ -52,27 +52,57 @@
// no-op on table / image / divider / callout), so turn-into is disabled
// elsewhere instead of offering inert options.
const CONVERTIBLE_BLOCKS = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(api.currentBlock));
const currentListKind = $derived.by<WordsListKind | undefined>(() => {
const index = api.selection?.anchor.path[0];
if (index == null) return undefined;
const block = api.document.children[index] as WordsBlock | undefined;
return block?.type === 'list' ? block.kind : undefined;
// When the caret sits inside a column the top-level `api.currentBlock`
// is `'columns'` — but the user is editing the NESTED block inside,
// which is the one Turn-into can convert. Resolve the effective
// block (and its heading level) by descending the selection path
// into the active column. The engine's `setBlock` already handles
// columns-recursive conversion (see block-format.ts).
const effectiveBlock = $derived.by<{
type: string;
level?: WordsHeadingLevel;
kind?: WordsListKind;
}>(() => {
const path = api.selection?.anchor.path;
if (!path || path.length === 0) {
return { type: api.currentBlock };
}
const top = api.document.children[path[0]] as WordsBlock | undefined;
if (top?.type === 'columns' && path.length >= 3) {
const col = top.columns[path[1] ?? 0];
const inner = col?.children[path[2] ?? 0];
if (inner) {
const result: { type: string; level?: WordsHeadingLevel; kind?: WordsListKind } = {
type: inner.type
};
if (inner.type === 'heading') result.level = inner.level;
if (inner.type === 'list') result.kind = inner.kind;
return result;
}
}
return {
type: api.currentBlock,
level: api.currentHeadingLevel,
kind: top?.type === 'list' ? top.kind : undefined
};
});
const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(effectiveBlock.type));
const currentListKind = $derived(effectiveBlock.kind);
const blockLabel = $derived.by(() => {
switch (api.currentBlock) {
switch (effectiveBlock.type) {
case 'heading':
return `Heading ${api.currentHeadingLevel ?? 1}`;
return `Heading ${effectiveBlock.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return currentListKind === 'ordered'
return effectiveBlock.kind === 'ordered'
? 'Numbered list'
: currentListKind === 'check'
: effectiveBlock.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
@ -85,6 +115,8 @@
return 'Image';
case 'divider':
return 'Divider';
case 'columns':
return 'Columns';
default:
return 'Turn into';
}
@ -94,43 +126,43 @@
{
id: 'paragraph',
label: 'Text',
active: api.currentBlock === 'paragraph',
active: effectiveBlock.type === 'paragraph',
run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' })
},
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: api.currentBlock === 'heading' && (api.currentHeadingLevel ?? 1) === level,
active: effectiveBlock.type === 'heading' && (effectiveBlock.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{
id: 'unordered-list',
label: 'Bulleted list',
active: api.currentBlock === 'list' && currentListKind === 'unordered',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'unordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
},
{
id: 'ordered-list',
label: 'Numbered list',
active: api.currentBlock === 'list' && currentListKind === 'ordered',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'ordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
},
{
id: 'check-list',
label: 'Check list',
active: api.currentBlock === 'list' && currentListKind === 'check',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'check',
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
},
{
id: 'quote',
label: 'Quote',
active: api.currentBlock === 'quote',
active: effectiveBlock.type === 'quote',
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
},
{
id: 'code',
label: 'Code',
active: api.currentBlock === 'code',
active: effectiveBlock.type === 'code',
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
}
]);

@ -9,8 +9,6 @@
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Button } from '$uix/eidos/components/button';
import { X } from '$uix/eidos/components/icon';
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
let {
@ -49,23 +47,8 @@
</script>
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
{#if current}
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Clear {label.toLowerCase()} color"
onclick={() => onPick(undefined)}
>
{#snippet icon()}<X />{/snippet}
</Button>
{/if}
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{label}</span>
<ColorPicker
size="sm"
bind:value={draft}
@ -90,6 +73,11 @@
<ColorPicker.Area />
<ColorPicker.ChannelSlider channel="hue" />
<ColorPicker.ChannelSlider channel="alpha" />
<!-- Manual hex/RGB editing — default rendering gives a
value swatch + segmented inputs + format select.
User can type the hex directly (committed on
blur / Enter by the soma channel-input). -->
<ColorPicker.ChannelInput />
<ColorPicker.SwatchGroup>
{#each presets as c (c)}
<ColorPicker.SwatchTrigger color={c}>

@ -0,0 +1,391 @@
<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 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 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;
next.push({
columnsIdx: ids.columnsIdx,
colIdx: ids.colIdx,
isEmpty: isColumnEmpty(col),
rect: {
top: br.top - fr.top,
left: br.left - fr.left,
width: br.width,
height: br.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();
});
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;
}
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
// (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.
const win = dom.getWindow();
const timer = win?.setTimeout(() => {
busy = false;
}, 250);
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`.
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;
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;
}
// 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}
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
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.
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]:hover :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;
}
[data-words-column-inserter]:not([data-empty]):hover
: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]):hover
: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>

@ -19,57 +19,201 @@
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Slider } from '$uix/eidos/components/slider';
import { ActiveEidos } from '$uix/eidos';
import type { Component } from 'svelte';
import {
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify
} from '$uix/eidos/components/icon';
import WordsBlockPanel from './words-block-panel.svelte';
import WordsColorRow from './words-color-row.svelte';
import WordsNumRow from './words-num-row.svelte';
import WordsSpacingRow from './words-spacing-row.svelte';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import type { ProviderSnippetProps, WordsBlock } from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
// `eidos.langs.ts(KEY)` is the canonical i18n surface for eidos
// components (parallel to soma's `langs.ts`). It returns the localized
// string when a translation pack is registered for the active locale,
// or the post-`|` fallback verbatim when not. Reading inside a
// `$derived` makes the inspector re-render when the locale changes.
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
// Active block index + block, derived from the `api` snippet prop — the
// same reactive pattern the selection bubble uses (`currentListKind`):
// pure `$derived`, no effect / listener, so it tracks the provider's
// document + selection through the snippet API and re-derives on change.
const activeIndex = $derived(api.selection?.anchor.path[0] ?? api.selectedBlockIndex ?? -1);
const activeBlock = $derived(
activeIndex >= 0 ? (api.document.children[activeIndex] as WordsBlock | undefined) : undefined
);
//
// IMPORTANT — `selectedBlockIndex` wins over `selection.anchor.path[0]`.
// Atomic blocks (image, divider) live inside `contenteditable=false`
// figures so clicking them DOES NOT move the DOM caret; soma instead
// sets `selectedBlockIndex` to flag the explicit atomic selection.
// If we read the caret first the inspector stays on whatever paragraph
// the caret WAS in (frequently the one before the image). Soma clears
// `selectedBlockIndex` the moment the caret genuinely moves elsewhere,
// so this priority is safe for the round trip back to text editing.
const activeIndex = $derived(api.selectedBlockIndex ?? api.selection?.anchor.path[0] ?? -1);
// `selectedBlockPath` carries the FULL path to a clicked atomic block
// (e.g. `[colsIdx, colIdx, innerIdx]` for an image inside a column).
// When set we walk the document by that path to find the real block —
// otherwise the inspector reads the top-level wrapper (`ColumnsBlock`)
// and shows the wrong panel.
function walkPath(path: readonly number[]): WordsBlock | undefined {
let node: unknown = api.document;
for (const idx of path) {
if (!node || typeof node !== 'object') return undefined;
const obj = node as Record<string, unknown>;
const t = obj['type'];
if (Array.isArray(obj['children'])) {
const child = (obj['children'] as unknown[])[idx];
if (child === undefined) return undefined;
node = child;
continue;
}
if (t === 'columns' && Array.isArray(obj['columns'])) {
const child = (obj['columns'] as unknown[])[idx];
if (child === undefined) return undefined;
node = child;
continue;
}
if (Array.isArray(obj['items'])) {
const child = (obj['items'] as unknown[])[idx];
if (child === undefined) return undefined;
node = child;
continue;
}
return undefined;
}
if (node && typeof node === 'object' && 'type' in (node as Record<string, unknown>)) {
return node as WordsBlock;
}
return undefined;
}
const activeBlock = $derived.by(() => {
const path = api.selectedBlockPath;
if (path && path.length > 1) {
// Nested atomic — walk the doc to find the actual clicked block
// (otherwise we'd land on the columns wrapper).
const walked = walkPath(path);
if (walked) return walked;
}
return activeIndex >= 0
? (api.document.children[activeIndex] as WordsBlock | undefined)
: undefined;
});
const activeBlockPath = $derived.by<readonly number[] | undefined>(() => {
const path = api.selectedBlockPath;
if (path && path.length > 1) return path;
return activeIndex >= 0 ? [activeIndex] : undefined;
});
function edit(visual: Partial<WordsBlock>) {
if (activeIndex < 0) return;
api.applyCommand({ type: 'setBlockVisual', blockIndex: activeIndex, visual });
const path = activeBlockPath;
if (!path) return;
// Path-aware route when the active block is nested; otherwise the
// top-level command is cheaper (no walk).
if (path.length > 1) {
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual });
} else {
api.applyCommand({ type: 'setBlockVisual', blockIndex: path[0], visual });
}
}
// Which inspector sections are expanded. Multiple may be open.
let openSections = $state<string[]>(['typography']);
// Which inspector sections are expanded. Multiple may be open. The
// block-specific section opens by default when present — it's the
// most contextual surface ("change H2 to H3", "switch list kind",
// etc.) and should not require a click to reveal.
let openSections = $state<string[]>(['block', 'typography']);
type AlignValue = 'left' | 'center' | 'right' | 'justify';
const ALIGNS: readonly { v: AlignValue; label: string }[] = [
{ v: 'left', label: 'Left' },
{ v: 'center', label: 'Center' },
{ v: 'right', label: 'Right' },
{ v: 'justify', label: 'Justify' }
];
// Which block types expose per-type props in the WordsBlockPanel.
// Kept here (not inside the panel) so the inspector can decide
// whether to render the Block accordion section at all — sections
// with empty bodies feel broken.
function blockHasPanelProps(b: WordsBlock): boolean {
return (
b.type === 'heading' ||
b.type === 'list' ||
b.type === 'code' ||
b.type === 'image' ||
b.type === 'callout' ||
b.type === 'table' ||
b.type === 'columns'
);
}
// Per-block-type relevance of the generic accordion sections. Each
// generic section is shown only when it actually applies to the
// selected block's intrinsic nature:
// - text-bearing blocks (paragraph/heading/quote/code/list/
// callout) → all sections apply.
// - image → no text props (Typography / Color / Layout) make
// sense; Spacing + Border around the figure do.
// - divider → it IS a border line; only Color (line tint) +
// Spacing (gap around the line) apply.
// - table → ALL generic sections are hidden; the Block panel
// owns visual editing via Cell / Row / Table subsections so
// edits target the right scope (cell color does NOT propagate
// to every cell).
type GenericSection = 'typography' | 'color' | 'layout' | 'spacing' | 'border';
const SECTION_VISIBILITY: Record<string, ReadonlySet<GenericSection>> = {
paragraph: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
heading: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
quote: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
code: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
list: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
callout: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
image: new Set<GenericSection>(['spacing', 'border']),
divider: new Set<GenericSection>(['color', 'spacing']),
table: new Set<GenericSection>(), // none — Block panel owns the scopes
columns: new Set<GenericSection>(['spacing']) // gap belongs to the wrapper; per-column visuals live in Block panel
};
function shows(section: GenericSection): boolean {
const allowed = activeBlock ? SECTION_VISIBILITY[activeBlock.type] : undefined;
return allowed ? allowed.has(section) : true;
}
type AlignValue = 'left' | 'center' | 'right' | 'justify';
type BorderStyleValue = 'solid' | 'dashed' | 'dotted';
const BORDER_STYLES: readonly { v: BorderStyleValue; label: string }[] = [
{ v: 'solid', label: 'Solid' },
{ v: 'dashed', label: 'Dashed' },
{ v: 'dotted', label: 'Dotted' }
];
const FONTS: readonly { v: string; label: string }[] = [
{ v: '', label: 'Default' },
{ v: 'sans-serif', label: 'Sans' },
{ v: 'serif', label: 'Serif' },
{ v: 'monospace', label: 'Mono' }
];
// Option arrays are `$derived` (not plain `const`) so the labels
// re-evaluate when the active locale changes. The value list is
// stable; only the visible `label` is reactive. Align ships an
// `icon` (visual) AND a `label` (aria-only) — the chip renders the
// icon; screen readers read the label.
const ALIGNS = $derived<readonly { v: AlignValue; label: string; icon: Component }[]>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd },
{ v: 'justify', label: t(L.ALIGN_JUSTIFY), icon: TextAlignJustify }
]);
const WEIGHTS: readonly { v: string; label: string }[] = [
{ v: '400', label: 'Regular' },
{ v: '500', label: 'Medium' },
{ v: '600', label: 'Semibold' },
{ v: '700', label: 'Bold' }
];
const BORDER_STYLES = $derived<readonly { v: BorderStyleValue; label: string }[]>([
{ v: 'solid', label: t(L.BORDER_SOLID) },
{ v: 'dashed', label: t(L.BORDER_DASHED) },
{ v: 'dotted', label: t(L.BORDER_DOTTED) }
]);
const FONTS = $derived<readonly { v: string; label: string }[]>([
{ v: '', label: t(L.FONT_DEFAULT) },
{ v: 'sans-serif', label: t(L.FONT_SANS) },
{ v: 'serif', label: t(L.FONT_SERIF) },
{ v: 'monospace', label: t(L.FONT_MONO) }
]);
const WEIGHTS = $derived<readonly { v: string; label: string }[]>([
{ v: '400', label: t(L.WEIGHT_REGULAR) },
{ v: '500', label: t(L.WEIGHT_MEDIUM) },
{ v: '600', label: t(L.WEIGHT_SEMIBOLD) },
{ v: '700', label: t(L.WEIGHT_BOLD) }
]);
const COLOR_PRESETS = [
'#e5484d',
@ -87,53 +231,35 @@
function blockTitle(b: WordsBlock): string {
switch (b.type) {
case 'heading':
return `Heading ${b.level}`;
return `${t(L.BLOCK_HEADING)} ${b.level}`;
case 'paragraph':
return 'Text';
return t(L.BLOCK_PARAGRAPH);
case 'quote':
return 'Quote';
return t(L.BLOCK_QUOTE);
case 'code':
return 'Code';
return t(L.BLOCK_CODE);
case 'list':
return b.kind === 'ordered'
? 'Numbered list'
? t(L.BLOCK_LIST_ORDERED)
: b.kind === 'check'
? 'Check list'
: 'Bulleted list';
? t(L.BLOCK_LIST_CHECK)
: t(L.BLOCK_LIST_BULLET);
case 'table':
return 'Table';
return t(L.BLOCK_TABLE);
case 'image':
return 'Image';
return t(L.BLOCK_IMAGE);
case 'divider':
return 'Divider';
return t(L.BLOCK_DIVIDER);
case 'callout':
return 'Callout';
return t(L.BLOCK_CALLOUT);
case 'columns':
return t(L.BLOCK_COLUMNS);
default:
return 'Block';
return t(L.BLOCK_GENERIC);
}
}
</script>
{#snippet numRow(label: string, value: number, min: number, max: number, step: number, onChange: (n: number) => void)}
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
<span data-words-inspector-value>{value}</span>
</div>
<Slider
value={[value]}
{min}
{max}
{step}
aria-label={label}
onValueChange={(v: number[]) => onChange(v[0] ?? min)}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>
{/snippet}
<div data-words-inspector data-current-block={activeBlock?.type ?? ''}>
{#if activeBlock}
{@const block = activeBlock}
@ -141,15 +267,35 @@
<Accordion type="multiple" bind:value={openSections} variant="ghost" size="sm">
{#if blockHasPanelProps(block)}
<Accordion.Item value="block">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_BLOCK)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsBlockPanel
{block}
blockIndex={activeIndex}
blockPath={activeBlockPath ?? null}
selectionPath={api.selection?.anchor.path ?? null}
applyCommand={api.applyCommand}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('typography')}
<Accordion.Item value="typography">
<Accordion.Header>
<Accordion.Trigger>Typography</Accordion.Trigger>
<Accordion.Trigger>{t(L.SECTION_TYPOGRAPHY)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Font</span>
<span data-words-inspector-label>{t(L.LABEL_FONT)}</span>
<ToggleGroup
type="single"
size="xs"
@ -158,18 +304,23 @@
block
value={[block.fontFamily ?? '']}
onValueChange={(v: string[]) => edit({ fontFamily: v[0] ? v[0] : undefined })}
aria-label="Font family"
aria-label={t(L.ARIA_FONT_FAMILY)}
>
{#each FONTS as f (f.v)}
<ToggleGroup.Item value={f.v}>{f.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Font size', block.fontSize ?? 16, 12, 40, 1, (n) =>
edit({ fontSize: n })
)}
<WordsNumRow
label={t(L.LABEL_FONT_SIZE)}
value={block.fontSize ?? 16}
min={12}
max={40}
step={1}
onCommit={(n) => edit({ fontSize: n })}
/>
<div data-words-inspector-row>
<span data-words-inspector-label>Weight</span>
<span data-words-inspector-label>{t(L.LABEL_WEIGHT)}</span>
<ToggleGroup
type="single"
size="xs"
@ -179,52 +330,61 @@
value={block.fontWeight ? [String(block.fontWeight)] : []}
onValueChange={(v: string[]) =>
edit({ fontWeight: v[0] ? Number(v[0]) : undefined })}
aria-label="Font weight"
aria-label={t(L.ARIA_FONT_WEIGHT)}
>
{#each WEIGHTS as w (w.v)}
<ToggleGroup.Item value={w.v}>{w.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Line height', block.lineHeight ?? 1.6, 1, 2.5, 0.1, (n) =>
edit({ lineHeight: Math.round(n * 10) / 10 })
)}
<WordsNumRow
label={t(L.LABEL_LINE_HEIGHT)}
value={block.lineHeight ?? 1.6}
min={1}
max={2.5}
step={0.1}
onCommit={(n) => edit({ lineHeight: Math.round(n * 10) / 10 })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('color')}
<Accordion.Item value="color">
<Accordion.Header>
<Accordion.Trigger>Color</Accordion.Trigger>
<Accordion.Trigger>{t(L.SECTION_COLOR)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsColorRow
label="Text"
label={t(L.LABEL_TEXT)}
current={block.color}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ color: hex })}
/>
<WordsColorRow
label="Background"
label={t(L.LABEL_BACKGROUND)}
current={block.background}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ background: hex })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('layout')}
<Accordion.Item value="layout">
<Accordion.Header>
<Accordion.Trigger>Layout</Accordion.Trigger>
<Accordion.Trigger>{t(L.SECTION_LAYOUT)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Align</span>
<span data-words-inspector-label>{t(L.LABEL_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
@ -233,47 +393,52 @@
block
value={block.align ? [block.align] : []}
onValueChange={(v: string[]) => edit({ align: v[0] as AlignValue | undefined })}
aria-label="Align"
aria-label={t(L.ARIA_ALIGN)}
>
{#each ALIGNS as a (a.v)}
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('spacing')}
<Accordion.Item value="spacing">
<Accordion.Header>
<Accordion.Trigger>Spacing</Accordion.Trigger>
<Accordion.Trigger>{t(L.SECTION_SPACING)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
{@render numRow('Margin top/bottom', block.margin?.block ?? 0, 0, 64, 1, (n) =>
edit({ margin: { ...block.margin, block: n } })
)}
{@render numRow('Margin left/right', block.margin?.inline ?? 0, 0, 64, 1, (n) =>
edit({ margin: { ...block.margin, inline: n } })
)}
{@render numRow('Padding top/bottom', block.padding?.block ?? 0, 0, 64, 1, (n) =>
edit({ padding: { ...block.padding, block: n } })
)}
{@render numRow('Padding left/right', block.padding?.inline ?? 0, 0, 64, 1, (n) =>
edit({ padding: { ...block.padding, inline: n } })
)}
<WordsSpacingRow
label={t(L.LABEL_MARGIN)}
value={block.margin}
onCommit={(next) => edit({ margin: next })}
/>
<WordsSpacingRow
label={t(L.LABEL_PADDING)}
value={block.padding}
onCommit={(next) => edit({ padding: next })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('border')}
<Accordion.Item value="border">
<Accordion.Header>
<Accordion.Trigger>Border</Accordion.Trigger>
<Accordion.Trigger>{t(L.SECTION_BORDER)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Style</span>
<span data-words-inspector-label>{t(L.LABEL_STYLE)}</span>
<ToggleGroup
type="single"
size="xs"
@ -289,29 +454,73 @@
: undefined
});
}}
aria-label="Border style"
aria-label={t(L.ARIA_BORDER_STYLE)}
>
{#each BORDER_STYLES as b (b.v)}
<ToggleGroup.Item value={b.v}>{b.label}</ToggleGroup.Item>
<ToggleGroup.Item value={b.v} aria-label={b.label} title={b.label}>
<!-- Visual chip = an actual border-{style} line. No lucide
icon exists for solid/dashed/dotted; rendering the
CSS style itself makes the chip self-documenting. -->
<span data-words-border-style-swatch={b.v}></span>
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Border width', block.border?.width ?? 0, 0, 8, 1, (n) =>
edit({ border: { ...block.border, width: n } })
)}
{@render numRow('Corner radius', block.border?.radius ?? 0, 0, 24, 1, (n) =>
edit({ border: { ...block.border, radius: n } })
)}
<WordsNumRow
label={t(L.LABEL_BORDER_WIDTH)}
value={block.border?.width ?? 0}
min={0}
max={8}
step={1}
onCommit={(n) => edit({ border: { ...block.border, width: n } })}
/>
<WordsColorRow
label={t(L.LABEL_COLOR)}
current={block.border?.color}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ border: { ...block.border, color: hex } })}
/>
<WordsNumRow
label={t(L.LABEL_CORNER_RADIUS)}
value={block.border?.radius ?? 0}
min={0}
max={48}
step={1}
onCommit={(n) => edit({ border: { ...block.border, radius: n } })}
/>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_PRESET)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
value={
block.border?.radius === 9999 ? ['full']
: block.border?.radius === 0 ? ['none']
: []
}
onValueChange={(v: string[]) => {
const preset = v[0];
if (preset === 'full') edit({ border: { ...block.border, radius: 9999 } });
else if (preset === 'none') edit({ border: { ...block.border, radius: 0 } });
}}
aria-label={t(L.ARIA_RADIUS_PRESET)}
>
<ToggleGroup.Item value="none">{t(L.PRESET_NONE)}</ToggleGroup.Item>
<ToggleGroup.Item value="full">{t(L.PRESET_FULL)}</ToggleGroup.Item>
</ToggleGroup>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
</Accordion>
{:else}
<p data-words-inspector-empty>Select a block to edit its style.</p>
<p data-words-inspector-empty>{t(L.EMPTY)}</p>
{/if}
</div>

@ -0,0 +1,104 @@
<script lang="ts">
/**
* Inspector numeric row — labelled Slider bound to a single block style
* value (font size, line height, margin/padding, border width, corner
* radius, etc.).
*
* Pattern: local `$state` draft + `onValueChange` (mid-drag, label only)
* + `onValueCommit` (release, engine commit). Same architecture as
* `words-color-row.svelte`.
*
* Anti-pattern that this component replaces — the original inline
* `numRow` snippet wired `onValueChange` straight to
* `inspector.applyCommand({type:'setBlockVisual', ...})`. Every
* `pointermove` during a slider drag dispatched ONE engine edit:
*
* pointermove → onValueChange → applyCommand → normalizeDocument
* → publishHistory → document re-derive → editor + inspector
* re-render → value prop changes → slider re-sync.
*
* At 60-120Hz a 1-second drag pushed 60-120 history entries and
* triggered the same number of editor re-renders. The Editor visibly
* stuttered and the undo stack was unusable.
*
* The fix mirrors the color-picker draft pattern: the slider mutates
* a LOCAL draft per pointermove (label updates live), the engine
* receives ONE commit on release. The `$effect` syncs `draft` ←
* external `value` only when `value` changes — read `draft` inside
* `untrack` so the effect doesn't re-run when the slider writes to it,
* which would otherwise clobber the user's mid-drag value with the
* still-committed `value` prop.
*/
import { untrack } from 'svelte';
import { NumberField } from '$uix/eidos/components/number-field';
import { Slider } from '$uix/eidos/components/slider';
let {
label,
value,
min,
max,
step,
onCommit
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onCommit: (n: number) => void;
} = $props();
// Initialize from `value` once at mount; the `$effect` below keeps it
// synced thereafter (only when `value` actually changes — `draft` is
// read inside `untrack` so mid-drag writes don't re-trigger the sync).
// svelte-ignore state_referenced_locally
let draft = $state(value);
$effect(() => {
const next = value;
untrack(() => {
if (next !== draft) draft = next;
});
});
</script>
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
<!-- Editable numeric input — typing commits on blur / Enter
(`onValueCommit`). Intentionally NO `max` so the user can type
values beyond the slider's range (slider clamps visually; the
committed value is whatever was typed). `min` propagates so
spacing / sizes can't go negative. -->
<NumberField
bind:value={draft}
{min}
{step}
size="xs"
variant="ghost"
onValueCommit={(n: number) => onCommit(n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<Slider
value={[draft]}
{min}
{max}
{step}
aria-label={label}
onValueChange={(v: number[]) => {
const n = v[0];
if (n !== undefined) draft = n;
}}
onValueCommit={(v: number[]) => {
const n = v[0];
if (n !== undefined) onCommit(n);
}}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>

@ -0,0 +1,250 @@
<script lang="ts">
/**
* Inspector spacing row — per-pair chained 4-side editor.
*
* Restructured 2026-05-30 after the single-link-toggle prototype
* failed user-acceptance: a single toggle reads as "join all 4
* together" no matter what the underlying code does. The fix is
* **two independent link toggles, one per pair**:
*
* Block pair (Top + Bottom) — its own 🔗 toggle
* Inline pair (Left + Right) — its own 🔗 toggle
*
* Each pair behaves as a self-contained unit:
* • Linked → editing one side propagates ONLY to its pair sibling.
* • Unlinked → that side is independent of the rest.
* The two pairs never affect each other, regardless of toggle state.
*
* Default for each pair: linked when its two sides are equal at mount
* (the overwhelmingly common case). The user can break the chain
* per pair without affecting the other.
*
* Data model — engine `WordsSpacing` accepts the compact axis shape
* (`block`/`inline`) AND optional per-side keys
* (`blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`). Whenever a
* pair is symmetric we write its axis key; the moment a pair splits
* we write the two per-side keys for that pair (the other pair can
* still be a single axis key). `emit()` chooses the shape
* automatically based on the resulting `sides` snapshot.
*
* `onCommit` receives the next `WordsSpacing` (or `undefined` to
* clear all spacing on the block). Commits fire on blur / Enter via
* the underlying NumberField; mid-typing keystrokes only mutate the
* local draft, never the engine.
*/
import { untrack } from 'svelte';
import { NumberField } from '$uix/eidos/components/number-field';
import Toggle from '$uix/eidos/components/toggle';
import { Link, Unlink } from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import type { WordsSpacing } from '$soma/components/words';
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
let {
label,
value,
onCommit
}: {
label: string;
value: WordsSpacing | undefined;
onCommit: (next: WordsSpacing | undefined) => void;
} = $props();
function readSides(s: WordsSpacing | undefined): {
top: number;
right: number;
bottom: number;
left: number;
} {
// Per-side keys win over axis keys (matches engine render.ts).
const top = s?.blockStart ?? s?.block ?? 0;
const bottom = s?.blockEnd ?? s?.block ?? 0;
const left = s?.inlineStart ?? s?.inline ?? 0;
const right = s?.inlineEnd ?? s?.inline ?? 0;
return { top, right, bottom, left };
}
// svelte-ignore state_referenced_locally
let sides = $state(readSides(value));
// Per-pair link state. Each pair gets its OWN toggle so the user can
// have symmetric vertical AND asymmetric horizontal (or any combo).
// svelte-ignore state_referenced_locally
let blockLinked = $state(sides.top === sides.bottom);
// svelte-ignore state_referenced_locally
let inlineLinked = $state(sides.left === sides.right);
$effect(() => {
const next = readSides(value);
untrack(() => {
if (
next.top !== sides.top ||
next.right !== sides.right ||
next.bottom !== sides.bottom ||
next.left !== sides.left
) {
sides = next;
}
});
});
function emit(next: { top: number; right: number; bottom: number; left: number }): void {
const allZero = next.top === 0 && next.right === 0 && next.bottom === 0 && next.left === 0;
if (allZero) {
onCommit(undefined);
return;
}
const blockEqual = next.top === next.bottom;
const inlineEqual = next.left === next.right;
if (blockEqual && inlineEqual) {
// Both pairs symmetric → compact axis shape.
onCommit({ block: next.top, inline: next.left });
return;
}
// At least one pair is split. Write a mixed shape: the symmetric
// pair keeps its axis key, the split pair writes its per-side
// keys. The engine reads per-side ?? axis, so this is unambiguous.
// `WordsSpacing` keys are `readonly`; build a mutable record then
// hand it off as `WordsSpacing` once frozen by the caller.
const out: {
block?: number;
inline?: number;
blockStart?: number;
blockEnd?: number;
inlineStart?: number;
inlineEnd?: number;
} = {};
if (blockEqual) {
out.block = next.top;
} else {
out.blockStart = next.top;
out.blockEnd = next.bottom;
}
if (inlineEqual) {
out.inline = next.left;
} else {
out.inlineStart = next.left;
out.inlineEnd = next.right;
}
onCommit(out as WordsSpacing);
}
function setSide(which: 'top' | 'right' | 'bottom' | 'left', n: number): void {
if ((which === 'top' || which === 'bottom') && blockLinked) {
sides = { ...sides, top: n, bottom: n };
} else if ((which === 'left' || which === 'right') && inlineLinked) {
sides = { ...sides, left: n, right: n };
} else {
sides = { ...sides, [which]: n };
}
emit(sides);
}
</script>
<div data-words-inspector-spacing>
<div data-words-inspector-spacing-head>
<span data-words-inspector-label>{label}</span>
</div>
<div data-words-inspector-spacing-grid>
<!-- Block pair (Top + Bottom) — its OWN link toggle. -->
<div data-words-inspector-spacing-pair>
<div data-words-inspector-spacing-cell data-side="top">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_TOP)}</span>
<NumberField
value={sides.top}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_TOP)}
onValueCommit={(n: number) => setSide('top', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-spacing-cell data-side="bottom">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_BOTTOM)}</span>
<NumberField
value={sides.bottom}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_BOTTOM)}
onValueCommit={(n: number) => setSide('bottom', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<Toggle
size="xs"
variant="ghost"
pressed={blockLinked}
onPressedChange={(p: boolean) => (blockLinked = p)}
aria-label={blockLinked ? t(L.SPACING_UNLINK) : t(L.SPACING_LINK)}
title={blockLinked ? t(L.SPACING_UNLINK_TITLE) : t(L.SPACING_LINK_TITLE)}
>
{#if blockLinked}
<Link size="14px" />
{:else}
<Unlink size="14px" />
{/if}
</Toggle>
</div>
<!-- Inline pair (Left + Right) — its OWN link toggle. -->
<div data-words-inspector-spacing-pair>
<div data-words-inspector-spacing-cell data-side="left">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_LEFT)}</span>
<NumberField
value={sides.left}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_LEFT)}
onValueCommit={(n: number) => setSide('left', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-spacing-cell data-side="right">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_RIGHT)}</span>
<NumberField
value={sides.right}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_RIGHT)}
onValueCommit={(n: number) => setSide('right', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<Toggle
size="xs"
variant="ghost"
pressed={inlineLinked}
onPressedChange={(p: boolean) => (inlineLinked = p)}
aria-label={inlineLinked ? t(L.SPACING_UNLINK) : t(L.SPACING_LINK)}
title={inlineLinked ? t(L.SPACING_UNLINK_TITLE) : t(L.SPACING_LINK_TITLE)}
>
{#if inlineLinked}
<Link size="14px" />
{:else}
<Unlink size="14px" />
{/if}
</Toggle>
</div>
</div>
</div>

@ -86,20 +86,27 @@
position: absolute;
inset-inline-start: 0.35rem;
z-index: 2;
/* nudge so the grip centres on the block's first line */
margin-block-start: 0.12em;
/* The script sets `top` to the block's vertical midpoint; this
transform pulls the grip up by half its own height so the icon
lands ON the midpoint rather than starting from it. Works
regardless of block height (paragraph, code, table, image). */
transform: translateY(-50%);
/* slide smoothly between blocks as the cursor moves */
transition: top 120ms var(--words-transition-ease);
animation: words-gutter-in 140ms var(--words-transition-ease);
}
@keyframes words-gutter-in {
/* `translateY(-50%)` keeps the grip vertically centered against the
block midpoint — the keyframe MUST preserve it on both frames, or
the end state snaps the grip back to its `top` anchor and the
centering is lost the moment the animation completes. */
from {
opacity: 0;
transform: translateX(-4px);
transform: translateY(-50%) translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
transform: translateY(-50%) translateX(0);
}
}
[data-words-block-gutter] [data-words-block-handle] {
@ -241,8 +248,39 @@
[data-words-inspector] {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
gap: var(--space-3);
/* Tightened from space-4 → space-2 (16px → 8px) — the sidebar is narrow
and the previous padding ate ~20% of the usable inline width. */
padding: var(--space-2);
/* Force the system sans stack on EVERY descendant of the inspector
(labels, values, subsection titles, NumberField inputs, ToggleGroup
chips, accordion triggers, table cell counters, etc.). The editor's
content font can be serif when the surface is themed for reading
(`Words` body inherits `--words-font-family`); the inspector chrome
must stay sans regardless or the controls render with mixed
typography.
The container declaration alone is NOT enough — eidos primitives
(Accordion.Trigger, Button, NumberField.Input, ToggleGroup.Item,
…) set their own `font-family` in their recipe CSS, breaking the
cascade. The `* { font-family: inherit }` rule forces every
descendant to inherit from the container regardless of recipe
overrides. This is the standard pattern when you want a subtree
to share one typeface against shipped components that ship their
own font choices. */
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
Helvetica,
Arial,
sans-serif;
}
[data-words-inspector] *,
[data-words-inspector] *::before,
[data-words-inspector] *::after {
font-family: inherit;
}
[data-words-inspector-title] {
font-size: var(--words-font-size-md);
@ -266,6 +304,59 @@
flex-direction: column;
gap: var(--space-1-5);
}
/* Inspector row gated by a sibling toggle (e.g. image Width / Height /
Align gated by Fullwidth). Reduce contrast + block pointer events so
the user sees the field is dimmed but not removed (gives context for
what Fullwidth controls). The actual controls inside still set
`disabled`/`aria-disabled`; this is the visual sugar. */
[data-words-inspector-row][data-disabled] {
opacity: 0.45;
pointer-events: none;
}
/* Image src field shares its row with an inline `Upload` button. The
wrapping group is the row's "value" slot — `data-inline` gives it
`justify-content: space-between` against the label, and the inner
flex distributes input (stretch) + button (auto) along the row. */
[data-words-inspector-src-group] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-1);
flex: 1;
min-inline-size: 0;
}
[data-words-inspector-src-group] input[data-words-inspector-text] {
flex: 1;
min-inline-size: 0;
}
/* Single-line row variant — label on the left, control on the right.
Used by `WordsColorRow` (each color = `Label [Picker]` per the
inspector's compact spec). Sliders / toggle groups keep the stacked
layout (label above, control below) because the control is wider. */
[data-words-inspector-row][data-inline] {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
/* The eidos ColorPicker root declares `inline-size: 100%` (so it fills
form rows by default). Inside an inline inspector row that override
collapses the label-to-picker distribution because the picker stretches
to the full remaining width. Pin to auto here so `justify-content:
space-between` actually pushes the picker's trigger to the right edge. */
[data-words-inspector-row][data-inline] [data-color-picker] {
inline-size: auto;
}
/* Pin the trigger to a fixed inline size so every color row's picker has
the same visual width regardless of the hex string length (6 chars vs
8 chars with alpha). `tabular-nums` keeps the hex characters at a
consistent advance width so the text doesn't jitter while the picker
value oscillates during drag. */
[data-words-inspector-row][data-inline] [data-color-picker-trigger] {
inline-size: 7.5rem;
justify-content: flex-start;
font-variant-numeric: tabular-nums;
}
[data-words-inspector-rowhead] {
display: flex;
align-items: baseline;
@ -274,12 +365,193 @@
[data-words-inspector-label] {
font-size: var(--words-font-size-sm);
color: var(--words-command-color);
/* Font-family is inherited from `[data-words-inspector]` (forced sans
at the container level). */
}
[data-words-inspector-value] {
font-size: var(--words-font-size-sm);
color: var(--words-content-color);
font-variant-numeric: tabular-nums;
}
/* Compact numeric input in `WordsNumRow` rowhead — pins width so the
label / slider layout doesn't shift as the user types longer numbers.
`tabular-nums` keeps the digits at uniform advance width. */
[data-words-inspector-rowhead] [data-number-field] {
inline-size: 4.5rem;
}
[data-words-inspector-rowhead] [data-number-field-input] {
inline-size: 100%;
text-align: end;
font-variant-numeric: tabular-nums;
font-size: var(--words-font-size-sm);
}
/* WordsSpacingRow — 4-side editor with PAIR chain.
Layout: a vertical stack of TWO pair rows. Each pair holds two cells
(label-above-input) plus its OWN link toggle. The two pairs are
independent — chaining one does not chain the other. This makes the
pair semantics unmistakable visually. */
[data-words-inspector-spacing] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-words-inspector-spacing-head] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
[data-words-inspector-spacing-grid] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-words-inspector-spacing-pair] {
display: grid;
/* Two equal-width inputs + a small fixed slot for the link toggle. */
grid-template-columns: 1fr 1fr auto;
align-items: end;
gap: var(--space-1);
/* Bump icon size in this scope so the link/unlink chip is visible at
the Toggle xs size (default `--icon-size` is too small here). */
--icon-size: 14px;
}
[data-words-inspector-spacing-cell] {
display: flex;
flex-direction: column;
gap: 2px;
min-inline-size: 0;
}
[data-words-inspector-spacing-cell-label] {
font-size: var(--words-font-size-xs, 0.75rem);
color: var(--words-content-color);
opacity: 0.7;
letter-spacing: 0.02em;
}
[data-words-inspector-spacing-cell] [data-number-field] {
inline-size: 100%;
min-inline-size: 0;
}
[data-words-inspector-spacing-cell] [data-number-field-input] {
inline-size: 100%;
text-align: end;
font-variant-numeric: tabular-nums;
font-size: var(--words-font-size-sm);
}
/* Block panel — bare text inputs (code language, image alt + caption).
Styled to match the inspector chrome (sans, sm size, ghost
background) so they sit among the ToggleGroups without visual noise.
`<datalist>` suggestions render natively on focus. The shared
`[data-words-inspector-text]` selector is for plain text edits;
`[data-words-inspector-code-language]` keeps the original selector
for backward compatibility — both inherit the same base rules. */
[data-words-inspector-text],
[data-words-inspector-code-language] {
flex: 1 1 0;
min-inline-size: 0;
font: inherit;
font-size: var(--words-font-size-sm);
padding: 4px 8px;
border: 1px solid var(--words-border);
border-radius: var(--radius-2, 4px);
background: transparent;
color: inherit;
}
[data-words-inspector-text]:focus-visible,
[data-words-inspector-code-language]:focus-visible {
outline: 2px solid var(--color-primary, currentColor);
outline-offset: -1px;
}
/* Callout intent swatch — small color dot rendered INSIDE the
ToggleGroup chip alongside its localized label. Color comes from
the doctrinal palette token for each intent (so a `risk` swatch
matches the actual `risk` callout's accent). Lives in the chip
button's flex flow; sits at the inline-start. */
[data-words-intent-swatch] {
display: inline-block;
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: 50%;
margin-inline-end: 4px;
vertical-align: -1px;
background: var(--color-neutral-solid);
}
[data-words-intent-swatch][data-intent='neutral'] {
background: var(--color-neutral-solid);
}
[data-words-intent-swatch][data-intent='affirm'] {
background: var(--color-affirm-solid);
}
[data-words-intent-swatch][data-intent='fulfill'] {
background: var(--color-fulfill-solid);
}
[data-words-intent-swatch][data-intent='risk'] {
background: var(--color-risk-solid);
}
[data-words-intent-swatch][data-intent='threat'] {
background: var(--color-threat-solid);
}
[data-words-intent-swatch][data-intent='loss'] {
background: var(--color-loss-solid);
}
/* Border-style swatch — the chip IS the line preview. No lucide icon
for solid/dashed/dotted is fitting, so we render a short top border
in the current ink. The swatch line scales with the chip width so
bigger sizes show a longer sample. */
[data-words-border-style-swatch] {
display: inline-block;
inline-size: 1.25rem;
block-size: 0;
border-block-start-width: 2px;
border-block-start-color: currentColor;
}
[data-words-border-style-swatch='solid'] {
border-block-start-style: solid;
}
[data-words-border-style-swatch='dashed'] {
border-block-start-style: dashed;
}
[data-words-border-style-swatch='dotted'] {
border-block-start-style: dotted;
}
/* WordsBlockPanel hierarchical subsections (table cell + table).
Sits inside `data-words-inspector-section` and groups related rows
under a small label. Keeps the visual separation between scopes
(cell-level vs table-level) without the heaviness of a second
accordion item. */
[data-words-inspector-subsection] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-block: var(--space-1);
}
[data-words-inspector-subsection] + [data-words-inspector-subsection] {
border-block-start: 1px dashed var(--words-border);
padding-block-start: var(--space-2);
}
[data-words-inspector-subsection-title] {
font-size: var(--words-font-size-xs, 0.75rem);
color: var(--words-content-color);
opacity: 0.75;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
/* Table insert/delete ops row — 3 small buttons in a horizontal flex
group (above/below/delete for rows; left/right/delete for columns).
Sits below the count display so the user reads "Rows: 3" then sees
the actions, mirroring Notion/Word table panels. */
[data-words-inspector-table-ops] {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
[data-words-inspector-empty] {
padding: var(--space-2) 0;
font-size: var(--words-font-size-sm);
@ -404,6 +676,50 @@
margin-block: var(--words-block-gap) 0;
}
/* Columns block — flex layout for side-by-side content. Each child
`[data-words-column]` is a flex item; the engine sets `flex: <user-
chosen value or 1>` inline so equal-width is the default and the
user can resize via the inspector. Gap reuses the block-gap token
so columns breathe the same vertical rhythm as the surrounding
content. Each column is also a flex container itself (column
direction) so its nested blocks stack vertically. */
[data-words-content] [data-words-block='columns'] {
display: flex;
flex-direction: row;
gap: var(--words-block-gap);
margin-block: var(--words-block-gap) 0;
align-items: stretch;
}
[data-words-content] [data-words-block='columns'] [data-words-column] {
display: flex;
flex-direction: column;
min-inline-size: 0;
/* Visual cue while editing — without this, an empty columns block
collapses to 0 height and the user can't see where the layout is.
The dashed outline + breathing min-height make the structure
discoverable; the soft tone fades into the background once the
user fills the columns with real content. No padding — child
blocks own their own padding via the inspector's Spacing section
so the column dashed border traces exactly the content edge. */
min-block-size: 3em;
padding: 0;
border: 1px dashed var(--words-border);
border-radius: var(--radius-2, 4px);
transition: border-color var(--words-transition-duration) var(--words-transition-ease);
}
[data-words-content] [data-words-block='columns'] [data-words-column]:hover,
[data-words-content] [data-words-block='columns'] [data-words-column]:focus-within {
border-color: var(--words-content-color);
}
/* Reset the universal block-rhythm `margin-block` for the FIRST nested
block of each column — otherwise that extra gap pushes the column
contents down relative to the column's frame, breaking vertical
alignment with siblings. */
[data-words-content] [data-words-block='columns'] [data-words-column] > *:first-child {
margin-block-start: 0;
}
/* ── Paragraph ────────────────────────────────────────────────────────── */
[data-words-block='paragraph'] {
@ -563,18 +879,56 @@ figure[data-words-block='image'] {
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
figure[data-words-block='image'] img {
/* Browser default for <figure> is `margin-inline: 40px` — it eats
~80px from every column / container the figure sits in. Reset to 0
so the image can use the full content area of its parent (the
block-gap-aware margin-block reset above only touched the vertical
axis). */
margin-inline: 0;
}
/* When no explicit width attr is set, cap at the container so the image
doesn't break layout. When the user typed an explicit width in the
inspector (renders as `<img width="X">`), let that win — including
values LARGER than the natural / container size (intentional upscale
for hero use-cases). */
figure[data-words-block='image'] img:not([width]) {
max-inline-size: 100%;
block-size: auto;
border-radius: var(--words-radius-sm);
}
figure[data-words-block='image'] img[width] {
block-size: auto;
border-radius: var(--words-radius-sm);
}
figure[data-words-block='image'][data-words-image-align='left'] {
align-items: flex-start;
}
figure[data-words-block='image'][data-words-image-align='right'] {
align-items: flex-end;
}
/* Fullwidth: drop the figure's flex layout (which sized it to its
content + caused overflow on `inline-size: 100%` chains with the
column's flex parent) and force the figure to fill its container.
The column is `display: flex; align-items: normal` and its child
flex items default to `flex: 0 1 auto` — that auto basis collapses
block-level children to their intrinsic content (= the img's natural
width). Setting `inline-size: 100%` explicitly + `box-sizing:
border-box` is what makes the figure fill the column's content area
without overflowing past its padding. The img inside then stretches
inside the figure. */
figure[data-words-block='image'][data-words-image-full-width] {
display: block;
inline-size: 100%;
max-inline-size: 100%;
box-sizing: border-box;
}
figure[data-words-block='image'][data-words-image-full-width] img {
display: block;
inline-size: 100%;
max-inline-size: 100%;
block-size: auto;
box-sizing: border-box;
}
figure[data-words-block='image'] figcaption {
font-size: 0.85em;
color: var(--words-quote-color);

@ -20,7 +20,9 @@
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';
let {
@ -42,6 +44,14 @@
let mounted = $state(false);
onMount(() => {
mounted = true;
// Register the inspector's localized strings into the active
// langs schema. Without this every label resolves to the inline
// English fallback regardless of the active locale — declaring
// idlangrefs alone never localizes, the bundle must be extended.
// `extend` deep-merges so app-level overrides at the same path
// applied AFTER mount still win (the engine warns on leaf
// overwrites in DEV).
eidos.langs.extend('components.words.inspector', WORDS_INSPECTOR_BUNDLE);
});
let contentEl = $state<HTMLDivElement | null>(null);
@ -62,6 +72,7 @@
<WordsBubble {api} />
{#if contentEl}
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsColumnInserter {api} content={contentEl} {dom} />
{/if}
{#if inspector === 'sidebar'}

@ -118,9 +118,11 @@ export class ContextMenuProvider {
openAt(x: number, y: number) {
this.anchorPoint.current = { x, y };
// Morfo declares `open` on `content` (emerge.open, sequence pre).
// Content may not be mounted yet on first open — pass it as fallback
// when available, otherwise let the runtime fire with no target.
const target = this.contentRef.current;
// Content may not be mounted yet on first open — fall back to the
// trigger element (always mounted at gesture time, and the natural
// anchor for the "about to open" cue). The runtime throws if no
// target resolves, so undefined is not a silent skip.
const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
@ -128,7 +130,8 @@ export class ContextMenuProvider {
handleClose() {
// Morfo declares `close` on `content` (emerge.close, sequence pre).
// Content still in DOM at this point; cascade matches before flip.
const target = this.contentRef.current;
// Trigger fallback for parity with openAt (content unmounted in races).
const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;

@ -113,22 +113,27 @@ export class MenuProvider {
});
}
handleOpen() {
handleOpen(gestureTarget?: HTMLElement | null) {
// Sequence: 'pre' per the morfo — fire before the structural flip
// so the perceptual signal lands at the gesture moment. If the
// content hasn't mounted yet (first open), the runtime falls back
// to no target and the channels still play (sound/haptic don't
// need a DOM target; visual will pick it up on next emit).
const target = this.contentRef.current;
// so the perceptual signal lands at the gesture moment. On first
// open the content part isn't mounted yet, so fall back to the
// gesture origin (the trigger button passed by the click/keydown
// handler, or partRef('trigger') for openers called outside an
// event handler). On subsequent opens the content is registered
// and wins.
const target =
this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
handleClose() {
handleClose(gestureTarget?: HTMLElement | null) {
// Sequence: 'pre' per the morfo — content is still in the DOM here,
// so the cascade can match `[data-dropdown-menu-content]` cleanly
// before the close flip unmounts it.
const target = this.contentRef.current;
// before the close flip unmounts it. Same trigger fallback for
// parity with handleOpen (content unmounted in rare races).
const target =
this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
@ -183,15 +188,17 @@ export class MenuTriggerProvider {
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.provider.opts.open.current) this.provider.handleClose();
else this.provider.handleOpen();
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
const target = (e.currentTarget ?? e.target ?? null) as HTMLElement | null;
if (this.provider.opts.open.current) this.provider.handleClose(target);
else this.provider.handleOpen(target);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.provider.handleOpen();
const target = (e.currentTarget ?? e.target ?? null) as HTMLElement | null;
this.provider.handleOpen(target);
}
};

@ -35,6 +35,8 @@ import type {
import type {
CalloutBlock,
CodeBlock,
Column,
ColumnsBlock,
HeadingBlock,
ImageBlock,
ListBlock,
@ -472,12 +474,23 @@ const imageSpec: WordsBlockSpec = {
}
],
validate(block, ctx) {
if (typeof block.src !== 'string' || block.src.length === 0) {
ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a non-empty string');
// `src` may be an empty string for the placeholder state right
// after insertion — the inspector exposes a URL field for the
// user to fill it in. Renderer shows a placeholder block when
// empty. The string-type check still catches non-string values.
if (typeof block.src !== 'string') {
ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a string');
}
ctx.validateOptionalNumber(block.width, `${ctx.path}/width`);
ctx.validateOptionalNumber(block.height, `${ctx.path}/height`);
ctx.validateOptionalEnum(block.imageAlign, WORDS_IMAGE_ALIGNS, `${ctx.path}/imageAlign`);
if (block.fullWidth !== undefined && typeof block.fullWidth !== 'boolean') {
ctx.error(
`${ctx.path}/fullWidth`,
'invalid-block-shape',
'image.fullWidth must be a boolean'
);
}
},
toHtml(block, ctx) {
const b = block as ImageBlock;
@ -499,21 +512,82 @@ const imageSpec: WordsBlockSpec = {
},
render(block, ctx) {
const b = block as ImageBlock;
const figureChildren: WordsRenderNode[] = [
{
kind: 'element',
tag: 'img',
attrs: {
src: b.src,
alt: b.alt ?? '',
...(b.width !== undefined ? { width: String(b.width) } : {}),
...(b.height !== undefined ? { height: String(b.height) } : {}),
loading: 'lazy',
draggable: 'false'
},
children: []
const isPlaceholder = b.src === '';
// Border belongs to the IMG, not the surrounding figure — when a
// user sets a border in the inspector for an image they expect
// to see it around the image itself. Extract the border-related
// style declarations from the figure's blockStyle and reroute
// them to the img element.
const figureStyleObj = ctx.blockStyle(b);
const imgBorderStyle: Record<string, string> = {};
for (const key of [
'border-color',
'border-width',
'border-style',
'border-radius',
'border-block-start',
'border-block-end',
'border-inline-start',
'border-inline-end'
]) {
if (figureStyleObj[key] !== undefined) {
imgBorderStyle[key] = figureStyleObj[key];
delete (figureStyleObj as Record<string, string>)[key];
}
];
}
const figureStyle = ctx.stringifyStyle(figureStyleObj);
const imgStyle = ctx.stringifyStyle(imgBorderStyle);
const figureChildren: WordsRenderNode[] = isPlaceholder
? [
// Placeholder body — rendered when the image was inserted
// without a URL yet (slash menu / gutter / drag-handle
// "Insert image" all start here). The user fills the URL
// from the inspector's image panel; once `src` is non-empty
// this branch falls back to the regular `<img>`.
{
kind: 'element',
tag: 'div',
attrs: { 'data-words-image-placeholder': '' },
children: [
{
kind: 'element',
tag: 'span',
attrs: { 'data-words-image-placeholder-label': '' },
children: [{ kind: 'text', text: 'No image URL yet' }]
},
{
kind: 'element',
tag: 'span',
attrs: { 'data-words-image-placeholder-hint': '' },
children: [
{ kind: 'text', text: 'Open the inspector and paste a URL.' }
]
}
]
}
]
: [
{
kind: 'element',
tag: 'img',
attrs: {
src: b.src,
alt: b.alt ?? '',
// fullWidth ignores explicit dimensions — CSS stretches
// the img to 100% of the container. Skipping the attrs
// here lets the CSS rule win cleanly (browser would
// otherwise honor the literal width="X" attribute even
// against `inline-size: 100%` due to specificity quirks
// on some legacy paths).
...(!b.fullWidth && b.width !== undefined ? { width: String(b.width) } : {}),
...(!b.fullWidth && b.height !== undefined ? { height: String(b.height) } : {}),
loading: 'lazy',
draggable: 'false',
...(imgStyle ? { style: imgStyle } : {})
},
children: []
}
];
if (b.caption) {
figureChildren.push({
kind: 'element',
@ -534,9 +608,13 @@ const imageSpec: WordsBlockSpec = {
// Faithful to the pre-registry renderer: it reads the BASE `align`
// (not `imageAlign`) for the float attribute. The model carries
// `imageAlign`; reconciling the two is tracked as a follow-up.
...(b.align && b.align !== 'center' ? { 'data-words-image-align': b.align } : {}),
...(!b.fullWidth && b.align && b.align !== 'center'
? { 'data-words-image-align': b.align }
: {}),
...(b.fullWidth ? { 'data-words-image-full-width': '' } : {}),
...(status ? { 'data-words-image-status': status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
...(isPlaceholder ? { 'data-words-image-placeholder-figure': '' } : {}),
contenteditable: 'false'
}),
children: figureChildren
@ -745,7 +823,12 @@ function renderTableCell(
): WordsRenderElement {
const isHeader = asHeader || asHeaderCol;
const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTag;
const cellStyle = ctx.stringifyStyle(ctx.blockStyle(cell));
// Merge `cell.width` into the base block style. Browsers compute a
// column's width from the widest declared cell, so it's enough to
// set it on any one row's cell for the whole column to size.
const styleObj = { ...ctx.blockStyle(cell) };
if (cell.width !== undefined) styleObj['width'] = cell.width;
const cellStyle = ctx.stringifyStyle(styleObj);
return {
kind: 'element',
tag,
@ -811,6 +894,13 @@ function validateTableRowFields(
for (let i = 0; i < row.cells.length; i++) {
validateTableCellFields(row.cells[i], `${path}/cells/${i}`, ctx);
}
if (row.height !== undefined && typeof row.height !== 'string') {
ctx.error(
`${path}/height`,
'invalid-block-shape',
'row.height must be a CSS length string (e.g. "48px")'
);
}
ctx.validateStyle(row, path);
}
@ -839,6 +929,13 @@ function validateTableCellFields(
ctx.error(`${path}/rowspan`, 'invalid-number', 'cell.rowspan must be an integer >= 1');
}
}
if (cell.width !== undefined && typeof cell.width !== 'string') {
ctx.error(
`${path}/width`,
'invalid-block-shape',
'cell.width must be a CSS length string (e.g. "200px", "30%")'
);
}
}
// ── List / table HTML sub-serializers ─────────────────────────────────────
@ -896,6 +993,144 @@ function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): stri
return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
const columnsSpec: WordsBlockSpec = {
type: 'columns',
group: 'structure',
menu: [
{
id: 'columns',
label: 'Columns',
description: 'Side-by-side layout (image + text, two columns of notes, …)',
keywords: ['layout', 'columns', 'side by side', 'col'],
group: 'structure',
// Default: two equal columns, each holding an empty paragraph
// so the user can immediately type into either side.
create: () => ({
type: 'columns',
columns: [
{ children: [{ type: 'paragraph', children: emptyText() }] },
{ children: [{ type: 'paragraph', children: emptyText() }] }
]
})
}
],
validate(block, ctx) {
if (!Array.isArray(block.columns)) {
ctx.error(
`${ctx.path}/columns`,
'invalid-children',
'columns.columns must be an array of Column objects'
);
return;
}
if (block.columns.length < 1) {
ctx.error(
`${ctx.path}/columns`,
'invalid-children',
'columns must contain at least one column'
);
}
for (let i = 0; i < block.columns.length; i++) {
validateColumnFields(block.columns[i], `${ctx.path}/columns/${i}`, ctx);
}
},
toHtml(block, ctx) {
const b = block as ColumnsBlock;
const colsHtml = b.columns
.map((col) => {
const inner = col.children.map((child) => ctx.blockToHtml(child)).join('');
const flex = col.width ?? '1';
const styleObj = { ...ctx.blockStyle(col), flex } as Record<string, string>;
const styled = ctx.stringifyStyle(styleObj);
return `<div data-words-column${styled ? ` style="${ctx.escapeAttr(styled)}"` : ''}>${inner}</div>`;
})
.join('');
return `<div data-words-columns${ctx.styleAttr(b)}>${colsHtml}</div>`;
},
toMarkdown(block, ctx) {
// Markdown has no native column syntax. Lossy export: flatten all
// columns to sequential blocks (left-to-right, top-to-bottom).
// Round-tripping a columns block through markdown re-imports as
// independent stacked blocks. Use HTML / JSON for fidelity.
const b = block as ColumnsBlock;
const out: string[] = [];
for (const col of b.columns) {
for (const child of col.children) {
out.push(ctx.blockToMd(child, ctx.depth));
}
}
return out.join('\n\n');
},
render(block, ctx) {
const b = block as ColumnsBlock;
const colChildren: WordsRenderNode[] = b.columns.map((col, colIdx) => {
// Each column's blocks live at path `[...table-path, colIdx, blockIdx]`.
// `ctx.renderBlock` is recursive — works for nested columns,
// tables, callouts, anything.
const children = col.children.map((child, blockIdx) =>
ctx.renderBlock(child, [...ctx.path, colIdx, blockIdx])
);
const styleObj = { ...ctx.blockStyle(col) } as Record<string, string>;
// `flex` controls the column's share of the row. Defaults to
// `1` (equal share) when the user hasn't set an explicit
// width. Accepts any CSS `flex` shorthand value (`200px`,
// `30%`, `2`, etc.).
styleObj['flex'] = col.width ?? '1';
const colStyle = ctx.stringifyStyle(styleObj);
return {
kind: 'element',
tag: 'div',
attrs: {
[WORDS_NODE_ATTR]: 'column',
'data-words-column': '',
...(col.id ? { 'data-words-id': col.id } : {}),
...(colStyle ? { style: colStyle } : {})
},
children
};
});
return {
kind: 'element',
tag: 'div',
attrs: ctx.composeBlockAttrs(b, 'columns', ctx.path, {
'data-words-column-count': String(b.columns.length)
}),
children: colChildren
};
}
};
function validateColumnFields(
col: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(col)) {
ctx.error(path, 'invalid-block-shape', 'column must be an object');
return;
}
ctx.validateId(col.id, `${path}/id`);
ctx.validateStyle(col, path);
if (!Array.isArray(col.children)) {
ctx.error(
`${path}/children`,
'invalid-children',
'column.children must be an array of blocks'
);
return;
}
for (let i = 0; i < col.children.length; i++) {
ctx.validateBlock(col.children[i], `${path}/children/${i}`);
}
if (col.width !== undefined && typeof col.width !== 'string') {
ctx.error(
`${path}/width`,
'invalid-block-shape',
'column.width must be a CSS length / flex string (e.g. "1fr", "200px", "30%")'
);
}
}
/** Every built-in spec, in document/menu order. */
export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
paragraphSpec,
@ -906,7 +1141,8 @@ export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
tableSpec,
imageSpec,
dividerSpec,
calloutSpec
calloutSpec,
columnsSpec
];
// Register the built-ins into the shared schema (side effect of import).

@ -72,8 +72,59 @@ export function setBlock(
return;
}
// Callout: pass through (recursive setBlock deferred).
// Columns: the caret might be inside one of the column's child
// blocks. Recurse into the column to convert that nested block.
// Selection path shape: [columnsIdx, colIdx, innerBlockIdx, …].
// Callout still passes through (no path-aware recursion yet) —
// add the same pattern when needed.
if (block.type === 'columns') {
const path = range?.start.path;
if (!path || path.length < 3 || path[0] !== index) {
children.push(block);
return;
}
const colIdx = path[1];
const innerIdx = path[2];
if (colIdx === undefined || innerIdx === undefined) {
children.push(block);
return;
}
const col = block.columns[colIdx];
const inner = col?.children[innerIdx];
if (
!col ||
!inner ||
(inner.type !== 'paragraph' &&
inner.type !== 'heading' &&
inner.type !== 'quote' &&
inner.type !== 'code')
) {
children.push(block);
return;
}
const nextInner = blockOfType(
type,
inlinesOfInlineBlock(inner as WordsInlineBlock),
opts.level,
blockTextAlign(inner),
opts.language ?? (inner.type === 'code' ? inner.language : undefined)
);
if (sameBlockShape(inner as WordsInlineBlock, nextInner)) {
children.push(block);
return;
}
mutated = true;
const nextColChildren = col.children.map((c, i) => (i === innerIdx ? nextInner : c));
const nextCol = { ...col, children: nextColChildren };
const nextColumns = block.columns.map((c, i) => (i === colIdx ? nextCol : c));
children.push({ ...block, columns: nextColumns });
return;
}
if (block.type === 'callout') {
// Callout pass-through — recursive setBlock not implemented for
// callouts today; same pattern as the `columns` branch above
// could be applied if a user case emerges.
children.push(block);
return;
}

@ -32,7 +32,7 @@ import {
moveBlockToAt,
updateBlockAt
} from './block';
import { setBlockVisual } from './visual';
import { setBlockVisual, setBlockVisualAtPath, updateBlockAtPath } from './visual';
import { setBlock, setTextAlign, type SetBlockType } from './block-format';
import { decreaseIndent, increaseIndent, toggleCheckItem, toggleList } from './list-ops';
import { insertLink, unlink } from './link-ops';
@ -43,6 +43,8 @@ import {
insertTableRow,
setCellTextAlign,
setCellVerticalAlign,
setCellVisual,
setRowVisual,
toggleTableHeaderCol,
toggleTableHeaderRow
} from './table-ops';
@ -56,6 +58,7 @@ import {
outdentCodeLine
} from './extra-ops';
import {
insertBlockInColumn,
insertCallout,
insertDivider,
insertImage,
@ -143,12 +146,38 @@ export type WordsCommand =
readonly blockIndex: number;
readonly block: Readonly<Record<string, unknown>>;
}
// Caretless insert into a column slot (the `+` overlay in each
// column). Carries the new block AND its post-insert selection in
// one transaction — see `insertBlockInColumn` for the placement
// rules and why a separate `setSelection` would race.
| {
readonly type: 'insertBlockInColumn';
readonly columnsIdx: number;
readonly colIdx: number;
readonly block: Readonly<Record<string, unknown>>;
}
// V2-exclusive: visual sidecar update
| {
readonly type: 'setBlockVisual';
readonly blockIndex: number;
readonly visual: Partial<Block>;
}
// Path-aware variants — same semantics as the top-level commands above,
// but accept a full document path so callers can edit blocks nested
// inside `columns` / `callout`. Used by the inspector when the active
// block is inside a column (e.g. an image dropped into one of the two
// columns: the inspector reads `selectedBlockPath` and routes mutations
// through these path-aware commands).
| {
readonly type: 'updateBlockAtPath';
readonly blockPath: readonly number[];
readonly patch: Readonly<Record<string, unknown>>;
}
| {
readonly type: 'setBlockVisualAtPath';
readonly blockPath: readonly number[];
readonly visual: Partial<Block>;
}
// Tables
| { readonly type: 'insertTableRow'; readonly position?: 'before' | 'after' }
| { readonly type: 'insertTableColumn'; readonly position?: 'before' | 'after' }
@ -162,6 +191,8 @@ export type WordsCommand =
readonly type: 'setCellVerticalAlign';
readonly verticalAlign: WordsVerticalAlign;
}
| { readonly type: 'setCellVisual'; readonly visual: Partial<Block> }
| { readonly type: 'setRowVisual'; readonly visual: Partial<Block> }
// Lists
| { readonly type: 'increaseIndent' }
| { readonly type: 'decreaseIndent' }
@ -251,8 +282,19 @@ export function applyWordsCommand(
command.blockIndex,
command.block as unknown as WordsBlock
);
case 'insertBlockInColumn':
return insertBlockInColumn(
state,
command.columnsIdx,
command.colIdx,
command.block as unknown as WordsBlock
);
case 'setBlockVisual':
return setBlockVisual(state, command.blockIndex, command.visual);
case 'updateBlockAtPath':
return updateBlockAtPath(state, command.blockPath, command.patch);
case 'setBlockVisualAtPath':
return setBlockVisualAtPath(state, command.blockPath, command.visual);
// Tables
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
@ -272,6 +314,10 @@ export function applyWordsCommand(
return setCellTextAlign(state, command.align);
case 'setCellVerticalAlign':
return setCellVerticalAlign(state, command.verticalAlign);
case 'setCellVisual':
return setCellVisual(state, command.visual);
case 'setRowVisual':
return setRowVisual(state, command.visual);
// Lists
case 'increaseIndent':
return increaseIndent(state);

@ -20,6 +20,8 @@ import type {
WordsBlock,
CalloutBlock,
CodeBlock,
Column,
ColumnsBlock,
WordsDocument,
HeadingBlock,
WordsInline,
@ -39,6 +41,7 @@ import type {
export type WordsNode =
| WordsDocument
| WordsBlock
| Column
| ListItem
| TableRow
| TableCell
@ -108,6 +111,8 @@ export function replaceAt<T>(items: readonly T[], index: number, value: T): read
* - table-row → cells[i] → table-cell
* - table-cell → children[i] → inline
* - callout → children[i] → block (recursive)
* - columns → columns[i] → column
* - column → children[i] → block (recursive)
* - link → children[i] → text inline
* - image / divider → (no children)
*/
@ -159,6 +164,13 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
continue;
}
if (t === 'columns') {
const child: Column | undefined = (node as ColumnsBlock).columns[index];
if (!child) return undefined;
node = child;
continue;
}
if (t === 'link') {
const child: WordsText | undefined = (node as WordsLink).children[index];
if (!child) return undefined;
@ -166,8 +178,8 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
continue;
}
// No `type` discriminator → list-item or table-row or table-cell.
// They all use either `children` or `cells`.
// No `type` discriminator → list-item, table-row, table-cell, or column.
// They use either `children` or `cells`.
if ('cells' in node) {
const child: TableCell | undefined = (node as TableRow).cells[index];
if (!child) return undefined;
@ -176,11 +188,15 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
}
if ('children' in node) {
// list-item OR table-cell — both have `children: readonly WordsInline[]`
const child: WordsInline | undefined =
(node as { children: readonly WordsInline[] }).children[index];
// list-item / table-cell carry `children: readonly WordsInline[]`;
// column carries `children: readonly WordsBlock[]`. Both indices
// are accessed positionally — the actual element type is what
// matters to downstream callers, not the static union.
const child = (
node as { children: readonly (WordsInline | WordsBlock)[] }
).children[index];
if (!child) return undefined;
node = child;
node = child as WordsNode;
continue;
}
@ -308,6 +324,17 @@ function updateNode(
children: replaceAt(callout.children, index, newChild as WordsBlock)
};
}
if (t === 'columns') {
const columns = node as ColumnsBlock;
const child: Column | undefined = columns.columns[index];
if (!child) return node;
const newChild = updateNode(child, path, depth + 1, updater);
if (newChild === child) return node;
return {
...columns,
columns: replaceAt(columns.columns, index, newChild as Column)
};
}
if (t === 'link') {
const link = node as WordsLink;
const child: WordsText | undefined = link.children[index];
@ -334,13 +361,18 @@ function updateNode(
return { ...row, cells: replaceAt(row.cells, index, newChild as TableCell) };
}
if ('children' in node) {
// list-item or table-cell
const container = node as { children: readonly WordsInline[] };
const child: WordsInline | undefined = container.children[index];
// list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
// Both are accessed positionally — the children array element type is
// what the caller already knows from the path it walked.
const container = node as { children: readonly (WordsInline | WordsBlock)[] };
const child = container.children[index];
if (!child) return node;
const newChild = updateNode(child, path, depth + 1, updater);
const newChild = updateNode(child as WordsNode, path, depth + 1, updater);
if (newChild === child) return node;
return { ...container, children: replaceAt(container.children, index, newChild as WordsInline) } as WordsNode;
return {
...container,
children: replaceAt(container.children, index, newChild as WordsInline | WordsBlock)
} as WordsNode;
}
return node;
}
@ -539,9 +571,18 @@ function nodeIsInlineContainer(node: WordsNode): boolean {
t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code' || t === 'link'
);
}
// list-item or table-cell — both inline containers.
// list-item, table-cell, table-row, column all lack a `type` discriminator.
// table-row owns `cells`; column owns `children: WordsBlock[]`; list-item /
// table-cell own `children: WordsInline[]`. Disambiguate by sniffing the
// first child — inline (`type: 'text' | 'link'`) means inline-container;
// anything else (paragraph/heading/etc., or empty) is not.
if ('cells' in node) return false; // table-row
return true;
if ('children' in node) {
const first = (node as { children: readonly { type?: string }[] }).children[0];
if (!first) return false;
return first.type === 'text' || first.type === 'link';
}
return false;
}
function stepInto(node: WordsNode, index: number): WordsNode | undefined {
@ -554,12 +595,16 @@ function stepInto(node: WordsNode, index: number): WordsNode | undefined {
if (t === 'list') return (node as ListBlock).items[index];
if (t === 'table') return (node as TableBlock).rows[index];
if (t === 'callout') return (node as CalloutBlock).children[index];
if (t === 'columns') return (node as ColumnsBlock).columns[index];
if (t === 'link') return (node as WordsLink).children[index];
return undefined;
}
if ('cells' in node) return (node as TableRow).cells[index];
if ('children' in node) {
return (node as { children: readonly WordsInline[] }).children[index];
// list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
return (node as { children: readonly (WordsInline | WordsBlock)[] }).children[
index
] as WordsNode | undefined;
}
return undefined;
}

@ -35,6 +35,8 @@ import { changed, noOp, type WordsEditorState, type WordsOperationResult } from
import {
WORDS_VERSION,
type WordsBlock,
type Column,
type ColumnsBlock,
type WordsDocument,
type WordsIntent,
type WordsImageAlign,
@ -104,7 +106,10 @@ export function insertImage(
state: WordsEditorState,
opts: InsertImageOptions
): WordsOperationResult {
if (!opts.src) return noOp(state);
// Empty `src` is a deliberate placeholder per the URL-only image flow:
// the slash menu inserts an empty image so the inspector exposes the
// URL field for the user. Renderer paints a placeholder figure; the
// validator accepts empty string. Do NOT noOp here.
return insertAtomicBlock(state, createImage(opts), { caretAtBlockStart: false });
}
@ -155,6 +160,25 @@ function insertAtomicBlock(
const topIndex = containerPath[0] ?? 0;
const topBlock = state.document.children[topIndex];
// Columns block: the caret sits inside a nested column's child block.
// Insert into THAT column's children array, not at top level — otherwise
// the new block ends up next to the columns wrapper instead of inside it.
if (
topBlock?.type === 'columns' &&
containerPath.length >= 3 &&
containerPath[1] !== undefined &&
containerPath[2] !== undefined
) {
return insertIntoColumn(
state,
block,
topIndex,
containerPath as readonly [number, number, number, ...number[]],
point,
opts
);
}
// Empty paragraph at top level: replace in place.
if (
topBlock?.type === 'paragraph' &&
@ -217,6 +241,116 @@ function finalizeAtCaret(
});
}
/**
* Same atomic-block insertion as `insertAtomicBlock`, but scoped to a
* single `Column.children` array inside a `ColumnsBlock`. The caret is
* known to live inside the column's nested block (path[2]) at the time
* of call. Splits the inner block when the caret isn't on an empty
* paragraph; replaces in place when it is.
*
* Returned selection lands inside the inserted block at
* `[colsIdx, colIdx, newInnerIdx, ...subPath]`.
*/
function insertIntoColumn(
state: WordsEditorState,
block: WordsBlock,
colsIdx: number,
containerPath: readonly [number, number, number, ...number[]],
point: { path: import('../path').WordsPath; offset: number },
opts: InsertAtomicOptions
): WordsOperationResult {
const colsBlock = state.document.children[colsIdx] as ColumnsBlock;
const colIdx = containerPath[1];
const innerIdx = containerPath[2];
const col = colsBlock.columns[colIdx];
if (!col) return noOp(state);
const innerBlock = col.children[innerIdx];
if (!innerBlock) return noOp(state);
// Empty paragraph inside the column → replace in place.
const isEmptyInner =
innerBlock.type === 'paragraph' &&
containerPath.length === 3 &&
innerBlock.children.length === 1 &&
innerBlock.children[0].type === 'text' &&
innerBlock.children[0].text.length === 0;
let nextInner: readonly WordsBlock[];
let newInnerIdx: number;
if (isEmptyInner) {
// Always keep a trailing paragraph after the inserted atomic so the
// user can type below it inside the column (mirror of the top-level
// last-block rule). `block` lands at innerIdx; the trailing paragraph
// at innerIdx + 1.
const isLastInner = innerIdx === col.children.length - 1;
nextInner = isLastInner
? [
...col.children.slice(0, innerIdx),
block,
createParagraph(),
...col.children.slice(innerIdx + 1)
]
: [
...col.children.slice(0, innerIdx),
block,
...col.children.slice(innerIdx + 1)
];
newInnerIdx = innerIdx;
} else {
// Split the inner inline-container at the caret. `before` keeps the
// original block's shape (with the inlines up to the caret); the
// atomic block sits between the two halves; `after` becomes a fresh
// paragraph holding the post-caret inlines.
const split = splitContainerAtPoint(state.document, containerPath, point);
const beforeBlock = mutateInlines(innerBlock, ensureInlineChildren(split.before));
const afterBlock = createParagraph(ensureInlineChildren(split.after));
nextInner = [
...col.children.slice(0, innerIdx),
beforeBlock,
block,
afterBlock,
...col.children.slice(innerIdx + 1)
];
newInnerIdx = innerIdx + 1;
}
const nextCol: Column = { ...col, children: nextInner };
const nextColumns = colsBlock.columns.map((c, i) => (i === colIdx ? nextCol : c));
const nextColsBlock: ColumnsBlock = { ...colsBlock, columns: nextColumns };
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, colsIdx, nextColsBlock)
}).document;
// Atomic blocks (image / divider) have no inline text — placing the
// caret on `[colsIdx, colIdx, newInnerIdx]` makes
// `pointFromInlineTextOffset` fall back to a foreign text node
// (usually doc.children[0]), which then dirty-clears any
// `selectedBlockPath` the caller wants to set. When we just added a
// trailing paragraph for the user to keep typing, prefer that paragraph
// as the caret landing — the model selection stays inside the same
// columns block, no false clearSelectedBlock cascade fires, and the
// inserter can mark the atomic as the visually-selected block.
const isAtomicInserted = block.type === 'image' || block.type === 'divider';
const hasTrailingParagraph =
isEmptyInner &&
newInnerIdx + 1 < (nextCol.children as readonly WordsBlock[]).length &&
(nextCol.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph';
const caretInnerIdx =
isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx;
const targetPath = opts.caretSubPath
? [colsIdx, colIdx, caretInnerIdx, ...opts.caretSubPath]
: [colsIdx, colIdx, caretInnerIdx];
const targetPoint = pointFromInlineTextOffset(normalized, targetPath, 0);
const sel = createCollapsedSelection(targetPoint.path, targetPoint.offset);
return changed({
document: normalized,
selection: sel,
activeMarks: getActiveMarksForSelection(normalized, sel)
});
}
function mutateInlines(
block: WordsBlock,
inlines: readonly import('../types').WordsInline[]
@ -238,6 +372,118 @@ function mutateInlines(
}
}
// ── insertBlockInColumn ─────────────────────────────────────────────────
/**
* Insert a block into a column slot from a caretless trigger (the `+`
* overlay button under each column).
*
* Canonical Tiptap-style: ONE transaction — the returned result carries
* both the new document and the post-insert selection. The provider
* publishes both in one flush, so there's no race against a deferred
* `setSelection` (every such race we tried produced phantom carets and,
* via dropdown-trigger focus retention, phantom repeat-inserts on the
* next keystroke).
*
* Placement:
* - Column is "empty" (only seed empty paragraph) → REPLACE that seed.
* - Else → APPEND to the column's children.
*
* Selection:
* - ATOMIC (image / divider) → caret in a fresh trailing paragraph
* (mirror of the top-level last-block escape-hatch rule).
* - TEXT-BEARING → select any stub text in the new block's first inline
* ("Title" for heading, "List item" for list, etc.) so the next
* keystroke replaces it Notion-style. Empty stub → collapsed caret
* at offset 0.
*/
export function insertBlockInColumn(
state: WordsEditorState,
colsIdx: number,
colIdx: number,
block: WordsBlock
): WordsOperationResult {
const doc = state.document;
const colsBlock = doc.children[colsIdx];
if (!colsBlock || colsBlock.type !== 'columns') return noOp(state);
const col = colsBlock.columns[colIdx];
if (!col) return noOp(state);
const isEmptySeed =
col.children.length === 1 &&
col.children[0].type === 'paragraph' &&
col.children[0].children.length === 1 &&
col.children[0].children[0].type === 'text' &&
col.children[0].children[0].text.length === 0;
const isAtomic = block.type === 'image' || block.type === 'divider';
const trailing: readonly WordsBlock[] = isAtomic ? [createParagraph()] : [];
const baseChildren: readonly WordsBlock[] = isEmptySeed ? [] : col.children;
const nextChildren: readonly WordsBlock[] = [...baseChildren, block, ...trailing];
const newInnerIdx = baseChildren.length;
const nextCol: Column = { ...col, children: nextChildren };
const nextColumns = colsBlock.columns.map((c, i) => (i === colIdx ? nextCol : c));
const nextColsBlock: ColumnsBlock = { ...colsBlock, columns: nextColumns };
const normalized = normalizeDocument({
...doc,
children: replaceAt(doc.children, colsIdx, nextColsBlock)
}).document;
let sel;
if (isAtomic) {
// Caret lands inside the trailing paragraph (offset 0). Selection
// stays INSIDE the columns block — the column-inserter caller can
// then mark the atomic block as visually-selected without the
// caret bouncing out to a foreign text node and dirty-clearing.
const trailingPath = [colsIdx, colIdx, newInnerIdx + 1];
const point = pointFromInlineTextOffset(normalized, trailingPath, 0);
sel = createCollapsedSelection(point.path, point.offset);
} else {
const stub = firstTextOfBlock(block);
const inlinePath =
block.type === 'list'
? [colsIdx, colIdx, newInnerIdx, 0, 0]
: [colsIdx, colIdx, newInnerIdx, 0];
const startPoint = pointFromInlineTextOffset(normalized, inlinePath, 0);
if (stub.length === 0) {
sel = createCollapsedSelection(startPoint.path, startPoint.offset);
} else {
const endPoint = pointFromInlineTextOffset(normalized, inlinePath, stub.length);
sel = {
anchor: { path: startPoint.path, offset: startPoint.offset },
focus: { path: endPoint.path, offset: endPoint.offset }
};
}
}
return changed({
document: normalized,
selection: sel,
activeMarks: getActiveMarksForSelection(normalized, sel)
});
}
/** Best-effort first text leaf — used to compute the stub-selection
* range for newly inserted text-bearing blocks (heading "Title", list
* "List item", etc.). Returns '' when there's no leading text node. */
function firstTextOfBlock(block: WordsBlock): string {
if (block.type === 'list') {
const firstItem = block.items[0];
if (!firstItem) return '';
const firstInline = firstItem.children[0];
return firstInline?.type === 'text' ? firstInline.text : '';
}
if ('children' in block && Array.isArray(block.children)) {
const first = (block.children as readonly unknown[])[0] as
| { type?: string; text?: string }
| undefined;
if (first && first.type === 'text') return first.text ?? '';
}
return '';
}
// ── setCodeLanguage ──────────────────────────────────────────────────────
/**

@ -102,9 +102,33 @@ function normalizeBlock(block: WordsBlock, idGen: () => string): WordsBlock {
case 'callout':
return normalizeCallout(block, baseId, idChanged, idGen);
case 'columns':
return normalizeColumns(block, baseId, idChanged, idGen);
}
}
function normalizeColumns(
block: import('../types').ColumnsBlock,
id: string,
idChanged: boolean,
idGen: () => string
): import('../types').ColumnsBlock {
let mutated = false;
const columns = block.columns.map((col) => {
const colId = col.id ?? idGen();
const colIdChanged = colId !== col.id;
// Each column's children are normal blocks — recurse.
const children = col.children.map((child) => normalizeBlock(child, idGen));
const childrenChanged = children.some((c, i) => c !== col.children[i]);
if (!colIdChanged && !childrenChanged) return col;
mutated = true;
return { ...col, id: colId, children };
});
if (!idChanged && !mutated) return block;
return { ...block, id, columns };
}
function normalizeList(
block: ListBlock,
id: string,

@ -42,6 +42,8 @@ import {
WORDS_VERSION,
type WordsBlock,
type CalloutBlock,
type Column,
type ColumnsBlock,
type WordsDocument,
type HeadingBlock,
type WordsInline,
@ -110,6 +112,18 @@ export function insertParagraph(state: WordsEditorState): WordsOperationResult {
return handleCalloutInsertParagraph(workingState, block, blockIndex);
}
// Columns: same shape as callout but two levels deep
// (`columns.columns[colIdx].children[innerIdx]`). Without this branch
// the top-level block lookup matches `columns`, none of the leaf
// branches fire, and Enter becomes a silent no-op for any caret
// inside a column — that's the user-visible "salto de línea no
// funciona" symptom. Delegates to the column's own insertParagraph
// against the inner block.
if (block.type === 'columns') {
if (containerPath.length < 3) return noOp(state);
return handleColumnsInsertParagraph(workingState, block, blockIndex, containerPath);
}
if (block.type === 'list') {
return handleListInsertParagraph(workingState, block, containerPath, split);
}
@ -425,6 +439,85 @@ function handleCalloutInsertParagraph(
});
}
function handleColumnsInsertParagraph(
state: WordsEditorState,
columns: ColumnsBlock,
columnsIndex: number,
containerPath: WordsPath
): WordsOperationResult {
const selection = state.selection;
if (!selection) return noOp(state);
const colIdx = containerPath[1];
if (colIdx === undefined) return noOp(state);
const column = columns.columns[colIdx];
if (!column) return noOp(state);
// Same trick as `handleCalloutInsertParagraph`: build a virtual
// sub-document whose children are THIS column's blocks, slice the
// `[columnsIndex, colIdx]` prefix off the selection paths, recurse
// into `insertParagraph`, then re-attach the prefix and graft the
// result back into the original columns block. The recursion picks
// up the proper paragraph / heading / list / quote branch inside.
const subDoc: WordsDocument = {
version: WORDS_VERSION,
children: column.children
};
const subSelection: WordsSelection = {
anchor: {
path: selection.anchor.path.slice(2),
offset: selection.anchor.offset
},
focus: {
path: selection.focus.path.slice(2),
offset: selection.focus.offset
}
};
const subState: WordsEditorState = {
document: subDoc,
selection: subSelection,
activeMarks: state.activeMarks
};
const subResult = insertParagraph(subState);
if (!subResult.changed) return noOp(state);
const newColumn: Column = {
...column,
children: subResult.state.document.children
};
const newColumns: ColumnsBlock = {
...columns,
columns: columns.columns.map((c, i) => (i === colIdx ? newColumn : c))
};
const subSel = subResult.state.selection;
const newSelection: WordsSelection | null = subSel
? {
anchor: {
path: [columnsIndex, colIdx, ...subSel.anchor.path],
offset: subSel.anchor.offset
},
focus: {
path: [columnsIndex, colIdx, ...subSel.focus.path],
offset: subSel.focus.offset
}
}
: null;
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, columnsIndex, newColumns)
}).document;
return changed({
document: normalized,
selection: newSelection,
activeMarks: newSelection
? getActiveMarksForSelection(normalized, newSelection)
: state.activeMarks
});
}
// ── Helpers ──────────────────────────────────────────────────────────────
function inlineLengthOf(inlines: readonly WordsInline[]): number {

@ -31,6 +31,7 @@ import type {
CalloutBlock,
CodeBlock,
WordsDocument,
ColumnsBlock,
HeadingBlock,
WordsInline,
WordsLink,
@ -106,6 +107,17 @@ function collectBlockText(
}
return;
}
case 'columns': {
const b = block as ColumnsBlock;
for (let colIdx = 0; colIdx < b.columns.length; colIdx++) {
const col = b.columns[colIdx];
const colPath = [...path, colIdx];
for (let innerIdx = 0; innerIdx < col.children.length; innerIdx++) {
collectBlockText(col.children[innerIdx], [...colPath, innerIdx], out);
}
}
return;
}
// image, divider — no text.
default:
return;

@ -23,6 +23,7 @@ import { normalizeDocument } from './normalize';
import { getActiveMarksForSelection } from './selection-walkers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type {
Block,
WordsBlock,
ParagraphBlock,
TableBlock,
@ -196,6 +197,117 @@ export function setCellVerticalAlign(
});
}
// ── setCellVisual / setRowVisual ─────────────────────────────────────────
//
// Selection-driven generic visual patchers for the cell + row scopes —
// mirror `setBlockVisual` but resolve their target from the cursor's
// table path (no explicit indices). Shallow-merge the patch into the
// active cell / its row, treating `undefined` values as deletions of
// the corresponding key (same semantics as `setBlockVisual`).
//
// Why both: the inspector edits color / background / padding / border
// at whichever scope the user is in. Routing every cell-level edit
// through `setBlockVisual` would mutate the table as a whole and
// propagate via CSS inheritance to every cell — the canonical bug from
// 2026-05-30 that this op fixes.
const VISUAL_KEYS = [
'align',
'margin',
'padding',
'background',
'color',
'border',
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight'
] as const;
function mergeVisual<T extends Block>(target: T, visual: Partial<Block>): T {
const prev = target as unknown as Record<string, unknown>;
const next: Record<string, unknown> = { ...prev };
let touched = false;
for (const key of VISUAL_KEYS) {
if (!(key in visual)) continue;
const value = (visual as Record<string, unknown>)[key];
if (value === undefined) {
if (key in next) {
delete next[key];
touched = true;
}
} else if (!shallowEqual(next[key], value)) {
next[key] = value;
touched = true;
}
}
if (!touched) return target;
return next as unknown as T;
}
function shallowEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const ak = Object.keys(ao);
const bk = Object.keys(bo);
if (ak.length !== bk.length) return false;
return ak.every((k) => ao[k] === bo[k]);
}
return false;
}
export function setCellVisual(
state: WordsEditorState,
visual: Partial<Block>
): WordsOperationResult {
return updateActiveCell(state, (cell) => mergeVisual(cell, visual));
}
export function setRowVisual(
state: WordsEditorState,
visual: Partial<Block>
): WordsOperationResult {
return updateActiveRow(state, (row) => mergeVisual(row, visual));
}
function updateActiveRow(
state: WordsEditorState,
updater: (row: TableRow) => TableRow
): WordsOperationResult {
// Row scope = the row enclosing the user's selection. Works whether
// the selection is at the row level (`[i, r]`) or deeper inside a
// cell (`[i, r, c, ...]`) — we only need the first two indices.
const sel = state.selection;
if (!sel) return noOp(state);
const path = sel.anchor.path;
if (path.length < 2) return noOp(state);
const blockIndex = path[0];
const rowIndex = path[1];
if (blockIndex === undefined || rowIndex === undefined) return noOp(state);
const block = state.document.children[blockIndex];
if (!block || block.type !== 'table') return noOp(state);
const row = block.rows[rowIndex];
if (!row) return noOp(state);
const nextRow = updater(row);
if (nextRow === row) return noOp(state);
const nextTable: TableBlock = {
...block,
rows: replaceAt(block.rows, rowIndex, nextRow)
};
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextTable)
}).document;
return changed({
document: normalized,
selection: sel,
activeMarks: getActiveMarksForSelection(normalized, sel)
});
}
function updateActiveCell(
state: WordsEditorState,
updater: (cell: TableCell) => TableCell

@ -8,7 +8,9 @@
*/
import { normalizeDocument } from './normalize';
import { getNodeAtPath, updateNodeAtPath } from './helpers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type { WordsPath } from '../path';
import type { Block, WordsBlock } from '../types';
const STYLE_KEYS = [
@ -57,6 +59,78 @@ export function setBlockVisual(
return changed({ ...state, document: nextDoc });
}
/**
* Path-aware twin of `setBlockVisual`. Walks `blockPath` to find the
* target block (top-level OR nested inside `columns` / `callout`) and
* applies the same style-merge semantics. Used by the inspector when
* the user has clicked into a block nested inside a column — the
* top-level `blockIndex` would point at the wrapper, so the path
* version is the only way to touch the right block.
*/
export function setBlockVisualAtPath(
state: WordsEditorState,
blockPath: WordsPath,
patch: Partial<Block>
): WordsOperationResult {
if (blockPath.length === 0) return noOp(state);
const target = getNodeAtPath(state.document, blockPath);
if (!target || !('type' in target)) return noOp(state);
const prev = target as unknown as Record<string, unknown>;
const next: Record<string, unknown> = { ...prev };
let touched = false;
for (const key of STYLE_KEYS) {
if (!(key in patch)) continue;
const value = (patch as Record<string, unknown>)[key];
if (value === undefined) {
if (key in next) {
delete next[key];
touched = true;
}
} else if (!styleEqual(next[key], value)) {
next[key] = value;
touched = true;
}
}
if (!touched) return noOp(state);
const nextDoc = updateNodeAtPath(state.document, blockPath, () => next as unknown as WordsBlock);
if (nextDoc === state.document) return noOp(state);
const normalized = normalizeDocument(nextDoc).document;
return changed({ ...state, document: normalized });
}
/**
* Path-aware shallow merge — mirrors `updateBlockAt` but accepts a full
* document path. Used by the inspector's Block panel (alt / caption /
* src / column children…) when the target block sits inside a column.
* Preserves the block's discriminator (`type` is filtered out of the
* patch, same as the top-level op).
*/
export function updateBlockAtPath(
state: WordsEditorState,
blockPath: WordsPath,
patch: Readonly<Record<string, unknown>>
): WordsOperationResult {
if (blockPath.length === 0) return noOp(state);
const target = getNodeAtPath(state.document, blockPath);
if (!target || !('type' in target)) return noOp(state);
const safePatch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'type') continue;
safePatch[k] = v;
}
const nextDoc = updateNodeAtPath(state.document, blockPath, (node) => ({
...(node as Record<string, unknown>),
...safePatch
}) as unknown as WordsBlock);
if (nextDoc === state.document) return noOp(state);
const normalized = normalizeDocument(nextDoc).document;
return changed({ ...state, document: normalized });
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Shallow equality, descending one level into the small style objects

@ -340,18 +340,45 @@ export function blockStyle(
): Readonly<Record<string, string>> {
const out: Record<string, string> = {};
if (block.align !== undefined) out['text-align'] = block.align;
// Per-side keys win over axis keys (`blockStart` overrides `block` on the
// top side, etc.). See `WordsSpacing` jsdoc — inspector exposes 4-side
// editing while old documents using `block`/`inline` still render.
const m = block.margin;
if (m?.block !== undefined) {
out['margin-block-start'] = px(m.block);
out['margin-block-end'] = px(m.block);
}
if (m?.inline !== undefined) {
out['margin-inline-start'] = px(m.inline);
out['margin-inline-end'] = px(m.inline);
if (m) {
const top = m.blockStart ?? m.block;
const bottom = m.blockEnd ?? m.block;
const start = m.inlineStart ?? m.inline;
const end = m.inlineEnd ?? m.inline;
if (top !== undefined) out['margin-block-start'] = px(top);
if (bottom !== undefined) out['margin-block-end'] = px(bottom);
if (start !== undefined) out['margin-inline-start'] = px(start);
if (end !== undefined) out['margin-inline-end'] = px(end);
}
const p = block.padding;
if (p && (p.block !== undefined || p.inline !== undefined)) {
out.padding = `${px(p.block ?? 0)} ${px(p.inline ?? 0)}`;
if (p) {
const top = p.blockStart ?? p.block;
const bottom = p.blockEnd ?? p.block;
const start = p.inlineStart ?? p.inline;
const end = p.inlineEnd ?? p.inline;
const hasPerSide =
p.blockStart !== undefined ||
p.blockEnd !== undefined ||
p.inlineStart !== undefined ||
p.inlineEnd !== undefined;
const anySet =
top !== undefined || bottom !== undefined || start !== undefined || end !== undefined;
if (anySet) {
if (hasPerSide) {
// 4-value shorthand `padding: T R B L` when any side is
// independent. Unset sides fall back to 0.
out.padding = `${px(top ?? 0)} ${px(end ?? 0)} ${px(bottom ?? 0)} ${px(start ?? 0)}`;
} else {
// Legacy axis-only shape — preserve the 2-value `V H`
// shorthand for backward-compat with existing documents
// and tests.
out.padding = `${px(p.block ?? 0)} ${px(p.inline ?? 0)}`;
}
}
}
if (block.background !== undefined) out['background-color'] = block.background;
if (block.color !== undefined) out.color = block.color;

@ -38,10 +38,31 @@ export const WORDS_INTENTS = [
export type WordsAlign = 'left' | 'center' | 'right' | 'justify';
/** Logical box spacing in pixels. `block` = top/bottom, `inline` = start/end. */
/**
* Logical box spacing in pixels.
*
* Two levels of specificity:
*
* - **Axis** — `block` (top + bottom) and `inline` (start + end). Compact
* shape, ideal when both sides on an axis are equal.
* - **Per-side** — `blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`. Used
* when sides need independent values.
*
* Resolution precedence (render + serialize-html consume both):
* `blockStart ?? block`, `blockEnd ?? block`,
* `inlineStart ?? inline`, `inlineEnd ?? inline`.
*
* Inspector UI exposes per-side editing with a "link" toggle (Figma /
* Photoshop convention) — when linked, edits update all 4 sides. When
* unlinked, each side edits its own per-side key.
*/
export interface WordsSpacing {
readonly block?: number;
readonly inline?: number;
readonly blockStart?: number;
readonly blockEnd?: number;
readonly inlineStart?: number;
readonly inlineEnd?: number;
}
export type WordsBorderStyle = 'solid' | 'dashed' | 'dotted';
@ -190,10 +211,25 @@ export interface TableCell extends Block {
readonly verticalAlign?: WordsVerticalAlign;
readonly colspan?: number;
readonly rowspan?: number;
/**
* Column width as a CSS length / percentage (`'200px'`, `'30%'`,
* `'auto'`). The renderer applies it as the cell's `style.width`;
* setting it on any cell in a column makes the browser propagate
* that width to the whole column (HTML tables compute column width
* from the widest cell). Optional — `undefined` means the browser
* auto-sizes.
*/
readonly width?: string;
}
export interface TableRow extends Block {
readonly cells: readonly TableCell[];
/**
* Row height as a CSS length (`'48px'`, `'auto'`). The renderer
* applies it as the row's `style.height`; the browser treats it as
* a minimum (the row grows past it if cell content overflows).
*/
readonly height?: string;
}
export interface TableBlock extends Block {
@ -210,11 +246,20 @@ export interface ImageBlock extends Block {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
/** Intrinsic natural dimensions (px). */
/** Intrinsic natural dimensions (px). Ignored when `fullWidth` is true. */
readonly width?: number;
readonly height?: number;
/** Float intent. */
/** Float intent. Ignored when `fullWidth` is true (the image spans the
* whole container so float has no effect). */
readonly imageAlign?: WordsImageAlign;
/**
* When true, the image stretches to 100% of the container width and
* the renderer ignores `width`/`height`/`imageAlign`. Allows the
* image to upscale beyond its intrinsic size — useful for hero /
* banner images. The inspector greys out the gated fields while
* this is on so the user sees they don't apply.
*/
readonly fullWidth?: boolean;
/** Drop shadow (image-specific). */
readonly shadow?: WordsShadow;
}
@ -223,6 +268,38 @@ export interface DividerBlock extends Block {
readonly type: 'divider';
}
/**
* One column inside a `ColumnsBlock`. Holds any number of nested
* `WordsBlock`s (paragraphs, headings, images, even nested tables /
* columns). Extends `Block` so each column carries its own visual
* props (background, padding, border, color tint) independent of its
* siblings.
*/
export interface Column extends Block {
readonly children: readonly WordsBlock[];
/**
* Column width as a CSS flex / length value (`'1fr'`, `'200px'`,
* `'30%'`, `'auto'`). The renderer applies it as the column's
* `flex-basis`. Empty means `1fr` — equal share with siblings.
*/
readonly width?: string;
}
/**
* Side-by-side layout container. Holds N columns; each column holds
* any blocks. Use for image-with-text layouts, multi-column reading
* sections, side-by-side comparison panes — anywhere a `<table>` would
* be overkill semantically.
*
* Distinct from `TableBlock` (which models tabular DATA with a row /
* cell / header-row grid). Columns model LAYOUT only; there is no
* header row, no spreadsheet semantics.
*/
export interface ColumnsBlock extends Block {
readonly type: 'columns';
readonly columns: readonly Column[];
}
export interface CalloutBlock extends Block {
readonly type: 'callout';
readonly intent: WordsIntent;
@ -258,6 +335,7 @@ export interface WordsBlockMap {
image: ImageBlock;
divider: DividerBlock;
callout: CalloutBlock;
columns: ColumnsBlock;
}
export type WordsBlock = WordsBlockMap[keyof WordsBlockMap];

@ -228,11 +228,21 @@ function validateSpacing(value: unknown, path: string, errors: ValidationError[]
errors.push({
path,
code: 'invalid-visual-value',
message: 'spacing must be an object { block?, inline? }'
message:
'spacing must be an object { block?, inline?, blockStart?, blockEnd?, inlineStart?, inlineEnd? }'
});
return;
}
for (const key of ['block', 'inline'] as const) {
// Both the legacy axis keys (`block`/`inline`) and the per-side keys
// (`blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`) are accepted.
for (const key of [
'block',
'inline',
'blockStart',
'blockEnd',
'inlineStart',
'inlineEnd'
] as const) {
const n = (value as Record<string, unknown>)[key];
if (n !== undefined && (typeof n !== 'number' || !Number.isFinite(n))) {
errors.push({

@ -43,18 +43,27 @@ export type {
WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps,
WordsSlashMenuSnippetProps as SlashMenuSnippetProps,
WordsLinkEditorSnippetProps as LinkEditorSnippetProps,
WordsFindReplaceSnippetProps as FindReplaceSnippetProps
WordsFindReplaceSnippetProps as FindReplaceSnippetProps,
WordsOnUploadImage,
WordsImageUploadResult
} from './types';
export type {
WordsAlign,
WordsBlock,
WordsBlockType,
WordsDocument,
WordsHeadingLevel,
ImageBlock,
WordsImageAlign,
WordsInline,
WordsIntent,
WordsListKind,
WordsMark,
WordsSpacing,
WordsVerticalAlign,
Column,
ColumnsBlock,
TableBlock,
TableCell,
TableRow

@ -111,6 +111,14 @@ export type WordsProviderSnippetProps = {
* selection rarely points at them.
*/
readonly selectedBlockIndex: number | undefined;
/**
* Full document path to the currently-selected atomic block. For
* top-level atomics `selectedBlockPath === [selectedBlockIndex]`;
* for nested atomics (e.g. an image inside a column) it carries the
* `[columnsIdx, colIdx, innerIdx]` chain so consumers can route
* mutations via the path-aware engine commands.
*/
readonly selectedBlockPath: readonly number[] | undefined;
readonly plainText: string;
readonly isEmpty: boolean;
readonly isFocused: boolean;
@ -147,8 +155,19 @@ export type WordsProviderSnippetProps = {
readonly focus: () => void;
readonly selectAll: () => void;
readonly selectBlock: () => void;
readonly selectAtomicBlock: (blockIndex: number) => void;
readonly selectAtomicBlock: (
blockIndex: number,
blockPath?: readonly number[]
) => void;
readonly clearSelectedBlock: () => void;
/**
* Imperative selection setter. Replaces the current selection with
* the supplied range (or clears it). Used by overlays / drag-drop /
* the column inserter when they need to place the caret outside the
* normal beforeInput flow. Returns `true` when the selection actually
* changed.
*/
readonly setSelection: (selection: WordsSelection | null) => boolean;
readonly runCommand: (command: WordsCommandName) => void;
/**
* Apply a command directly without going through the sema runtime

@ -190,8 +190,15 @@ export class WordsProvider {
* an atomic block. Set by the eidos click handler when the user
* clicks an image; cleared by any selectionchange / focus loss /
* text-editing event.
*
* For top-level atomics `selectedBlockIndex === selectedBlockPath[0]`.
* For nested atomics (e.g. an image inside a column) the index points
* at the wrapper block (the columns block) while the PATH walks all
* the way to the actual image — readers that need to act on the image
* itself (inspector, image float bar, …) read `selectedBlockPath`.
*/
selectedBlockIndex = $state<number | undefined>(undefined);
selectedBlockPath = $state<readonly number[] | undefined>(undefined);
/**
* Image-upload status sidecar — keyed by block id. Per V2 doctrine P4,
@ -359,9 +366,13 @@ export class WordsProvider {
* extends the text selection to cover the current block's
* contents).
*/
selectAtomicBlock(blockIndex: number): void {
selectAtomicBlock(blockIndex: number, blockPath?: readonly number[]): void {
if (blockIndex < 0 || blockIndex >= this.document.children.length) return;
this.selectedBlockIndex = blockIndex;
// Track the full path so callers can act on a nested atomic (image
// inside a column). Defaults to a single-segment path equal to the
// top-level index for back-compat with top-level callers.
this.selectedBlockPath = blockPath && blockPath.length > 0 ? blockPath : [blockIndex];
// Focus the editor root so keyboard handlers (esc, delete) work
// on the atomic block.
const root = this.opts.ref.current;
@ -372,6 +383,7 @@ export class WordsProvider {
clearSelectedBlock(): void {
if (this.selectedBlockIndex !== undefined) this.selectedBlockIndex = undefined;
if (this.selectedBlockPath !== undefined) this.selectedBlockPath = undefined;
}
readonly currentHeadingLevel = $derived.by(() => {
@ -519,13 +531,24 @@ export class WordsProvider {
return false;
}
// Clear the atomic-block highlight only when the text selection
// genuinely moved (the user clicked somewhere else / pressed an
// arrow / typed). Synthetic selectionchange echoes that arrive
// genuinely moved AND landed OUTSIDE the wrapper of the currently
// selected atomic. Synthetic selectionchange echoes that arrive
// right after `selectAtomicBlock` (which doesn't actually move
// the DOM caret) would otherwise wipe the highlight a tick
// later, defeating the click.
// later, defeating the click. Same intent for nested atomics
// inside a column: when the engine drops the caret into the
// column's trailing paragraph after `insertImage`, the new
// selection is still inside the SAME top-level wrapper as the
// selected image — don't clear in that case (the inspector needs
// to keep showing the image panel until the user moves to a
// different top-level block).
if (!sameWordsSelection(this.selection, next)) {
this.clearSelectedBlock();
const selectedTopIdx =
this.selectedBlockPath?.[0] ?? this.selectedBlockIndex;
const nextTopIdx = next.anchor.path[0];
if (selectedTopIdx === undefined || nextTopIdx !== selectedTopIdx) {
this.clearSelectedBlock();
}
}
this.updateSelection(next);
return true;
@ -680,6 +703,7 @@ export class WordsProvider {
): boolean {
if (this.isDisabled || this.isReadonly) return false;
this.ensureSelection();
const prevSelection = this.history.present.selection;
const next =
options.batch === 'typing'
? this.typingHistoryCommand(command)
@ -687,6 +711,23 @@ export class WordsProvider {
const changed = next !== this.history;
this.publishHistory(next);
this.typingBatchOpen = options.batch === 'typing' && changed;
// When a command changes the model selection — common for commands
// that originate OUTSIDE the editor (overlay buttons, drag-drop,
// the column inserter `+`, …) — sync the new selection to the DOM
// so the caret actually moves. Without this, the model knows the
// caret should be in the freshly-inserted block, but the DOM caret
// stays wherever the user last clicked, and typing is dropped.
// Typing-batch commands (insertText from beforeInput) already have
// the right DOM selection — the browser placed it before our
// handler ran — so we skip the restore there to avoid a redundant
// tick + potential caret bounce.
if (
changed &&
options.batch !== 'typing' &&
!sameWordsSelection(prevSelection, this.history.present.selection)
) {
void tick().then(() => this.restoreDomSelection());
}
return changed;
}
@ -952,7 +993,12 @@ export class WordsProvider {
const blockIndex = path?.[0];
if (typeof blockIndex === 'number') {
e.preventDefault();
this.selectAtomicBlock(blockIndex);
// Pass the FULL path — for top-level images path = [idx];
// for nested images (inside a column) it's
// [colsIdx, colIdx, innerIdx]. Inspector reads
// `selectedBlockPath` to resolve the actual image even
// when the top-level index points at the wrapper.
this.selectAtomicBlock(blockIndex, path ?? undefined);
return;
}
}
@ -1184,6 +1230,7 @@ export class WordsProvider {
imageStatus: this.imageStatus,
selection: this.selection,
selectedBlockIndex: this.selectedBlockIndex,
selectedBlockPath: this.selectedBlockPath,
plainText: this.plainText,
isEmpty: this.isEmpty,
isFocused: this.focused,
@ -1220,8 +1267,10 @@ export class WordsProvider {
focus: () => this.focus(),
selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(),
selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex),
selectAtomicBlock: (blockIndex: number, blockPath?: readonly number[]) =>
this.selectAtomicBlock(blockIndex, blockPath),
clearSelectedBlock: () => this.clearSelectedBlock(),
setSelection: (selection: WordsSelection | null) => this.setSelectionPublic(selection),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
applyCommand: (command: WordsCommand) => this.applyCommand(command),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language),
@ -1263,6 +1312,24 @@ export class WordsProvider {
this.publishPresent(result.state, { documentChanged: false });
}
/**
* Public selection setter used by overlay tools that need to move the
* caret programmatically before issuing a command (e.g. the column
* inserter clicks a button outside contenteditable and must focus the
* empty paragraph inside the chosen column so the insert op's
* selection-driven routing picks the right scope). Returns `true` if
* the model selection moved; `false` if the path was invalid or the
* selection was already there.
*/
setSelectionPublic(selection: WordsSelection | null): boolean {
const result = setWordsSelection(this.history.present, selection);
if (!result.changed) return false;
this.closeTypingBatch();
this.publishPresent(result.state, { documentChanged: false });
void tick().then(() => this.restoreDomSelection());
return true;
}
private isInsideWordsTool(element: HTMLElement): boolean {
// Internal-tool predicate for the focus scope. Any tool that
// the user clicks WITHOUT intending to leave the editor must
@ -1282,6 +1349,9 @@ export class WordsProvider {
'[data-words-bubble-turn-into]',
'[data-words-code-language-panel]',
'[data-words-code-language-picker]',
'[data-words-column-inserter]',
'[data-words-column-inserter-menu]',
'[data-words-column-inserter-trigger]',
'[data-words-drawer]',
'[data-words-find-replace]',
'[data-words-heading-picker]',
@ -1562,18 +1632,21 @@ export class WordsProvider {
if (!item) return false;
let command: WordsCommand | undefined;
if (item.id === 'image') {
// Image insert needs a runtime URL. The MVP uses the browser's
// native prompt; consumers wanting a custom dialog can hide
// the slash menu's 'image' entry (via `slashCommands` prop
// override) and trigger `applyWordsCommand({ type: 'insertImage',
// src, alt? })` from their own toolbar/dialog instead.
const node = this.opts.ref.current;
const win = node?.ownerDocument?.defaultView ?? undefined;
const src = win?.prompt?.('Image URL:')?.trim();
if (!src) return false;
const altRaw = win?.prompt?.('Alt text (optional):');
const alt = altRaw?.trim() ? altRaw.trim() : undefined;
command = { type: 'insertImage', src, alt };
// Slash-menu 'image' inserts a PLACEHOLDER image block (empty
// `src`) — the inspector exposes a URL field for the user
// to fill in. No prompt, no file picker. Drop / paste stay
// on the FileReader path via `onUploadImage`; that's an
// implicit upload from a binary the user already has. Going
// from "click insert image" to "browse files" was confusing
// — the user explicitly asked for a URL-only flow with an
// inline placeholder.
//
// Consumers wanting a richer dialog (preview, drag-drop in
// modal, alt + caption pre-fill) can hide the slash menu's
// 'image' entry (via `slashCommands` prop override) and
// trigger `applyCommand({ type: 'insertImage', src, alt? })`
// from their own toolbar.
command = { type: 'insertImage', src: '' };
} else {
command = slashInsertionCommand(item.id);
if (!command) {

@ -5,7 +5,36 @@
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type { WordsDocument } from '$soma/components/words';
import type {
WordsDocument,
WordsImageUploadResult,
WordsOnUploadImage
} from '$soma/components/words';
// Local-only image upload — turns the dropped/pasted File into a
// `data:` URL via FileReader. Lets the demo accept drag-drop and
// paste of images without a backend. Real apps would POST the file
// to their storage and resolve with the returned URL instead.
const handleUploadImage: WordsOnUploadImage = (file: File) =>
new Promise<WordsImageUploadResult>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error('FileReader failed'));
reader.onload = () => {
const url = typeof reader.result === 'string' ? reader.result : '';
if (!url) {
reject(new Error('Empty data URL'));
return;
}
resolve({
url,
// Use the filename (sans extension) as a default alt — the
// inspector lets the user refine it afterwards.
alt: file.name.replace(/\.[^/.]+$/, '') || undefined
});
};
reader.readAsDataURL(file);
});
const INSPECTOR_MODES: readonly { v: WordsInspectorMode; label: string }[] = [
{ v: 'none', label: 'None' },
@ -89,6 +118,18 @@
]
},
{ type: 'divider' },
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Columns' }]
},
{
type: 'columns',
columns: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] }
]
},
{
type: 'heading',
level: 2,
@ -151,7 +192,12 @@
</div>
<div class="demo-canvas">
<Words bind:value {inspector} placeholder="Write something…" />
<Words
bind:value
{inspector}
placeholder="Write something…"
onUploadImage={handleUploadImage}
/>
</div>
<details class="demo-trace">

Loading…
Cancel
Save

Powered by TurnKey Linux.