From bcad6647a7b75f3af60f29be21c9110098f8dec7 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 31 May 2026 04:04:54 +0200 Subject: [PATCH] feat(words): column inserter + provider DOM-selection sync after commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../eidos/components/words/langs-inspector.ts | 421 ++++++++++ .../words/words-block-gutter.svelte | 159 +++- .../components/words/words-block-panel.svelte | 780 ++++++++++++++++++ .../components/words/words-bubble.svelte | 66 +- .../components/words/words-color-row.svelte | 26 +- .../words/words-column-inserter.svelte | 391 +++++++++ .../components/words/words-inspector.svelte | 429 +++++++--- .../components/words/words-num-row.svelte | 104 +++ .../components/words/words-spacing-row.svelte | 250 ++++++ src/uix/eidos/components/words/words.css | 370 ++++++++- src/uix/eidos/components/words/words.svelte | 11 + .../context-menu-provider.svelte.ts | 11 +- .../dropdown-menu-provider.svelte.ts | 33 +- .../words/engine/blocks/built-ins.ts | 274 +++++- .../words/engine/operations/block-format.ts | 53 +- .../words/engine/operations/commands.ts | 48 +- .../words/engine/operations/helpers.ts | 73 +- .../engine/operations/insert-block-types.ts | 248 +++++- .../words/engine/operations/normalize.ts | 24 + .../words/engine/operations/paragraph-ops.ts | 93 +++ .../engine/operations/selection-walkers.ts | 12 + .../words/engine/operations/table-ops.ts | 112 +++ .../words/engine/operations/visual.ts | 74 ++ .../soma/components/words/engine/render.ts | 45 +- src/uix/soma/components/words/engine/types.ts | 84 +- .../soma/components/words/engine/validate.ts | 14 +- src/uix/soma/components/words/exports.ts | 11 +- src/uix/soma/components/words/types.ts | 21 +- .../components/words/words-provider.svelte.ts | 111 ++- web/routes/uix/components/words/+page.svelte | 50 +- 30 files changed, 4141 insertions(+), 257 deletions(-) create mode 100644 src/uix/eidos/components/words/langs-inspector.ts create mode 100644 src/uix/eidos/components/words/words-block-panel.svelte create mode 100644 src/uix/eidos/components/words/words-column-inserter.svelte create mode 100644 src/uix/eidos/components/words/words-num-row.svelte create mode 100644 src/uix/eidos/components/words/words-spacing-row.svelte diff --git a/src/uix/eidos/components/words/langs-inspector.ts b/src/uix/eidos/components/words/langs-inspector.ts new file mode 100644 index 000000000..035ed859d --- /dev/null +++ b/src/uix/eidos/components/words/langs-inspector.ts @@ -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; + diff --git a/src/uix/eidos/components/words/words-block-gutter.svelte b/src/uix/eidos/components/words/words-block-gutter.svelte index f9799df4e..84dcd4180 100644 --- a/src/uix/eidos/components/words/words-block-gutter.svelte +++ b/src/uix/eidos/components/words/words-block-gutter.svelte @@ -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 (`
` 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 = { + '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; + }); + }); {#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;" > {/if} -
+ +
+ Inspect + Move up Move down Duplicate @@ -213,7 +346,7 @@ Insert below {#each inserts as ins (ins.id)} - insert(ins.create())}>{ins.label} + handleInsert(ins)}>{ins.label} {/each} diff --git a/src/uix/eidos/components/words/words-block-panel.svelte b/src/uix/eidos/components/words/words-block-panel.svelte new file mode 100644 index 000000000..256c11c9e --- /dev/null +++ b/src/uix/eidos/components/words/words-block-panel.svelte @@ -0,0 +1,780 @@ + + +{#if block.type === 'heading'} +
+ {t(L.LABEL_HEADING_LEVEL)} + { + 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)} + H{lvl} + {/each} + +
+{:else if block.type === 'list'} +
+ {t(L.LABEL_LIST_KIND)} + { + 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)} + {k.label} + {/each} + +
+{:else if block.type === 'code'} +
+ {t(L.LABEL_CODE_LANGUAGE)} + + { + const next = (e.currentTarget as HTMLInputElement).value.trim(); + applyCommand({ type: 'setCodeLanguage', language: next || undefined }); + }} + /> + + {#each CODE_LANGUAGE_SUGGESTIONS as lang (lang)} + + {/each} + +
+{:else if block.type === 'image'} + + {@const imgFullWidth = block.fullWidth === true} +
+ {t(L.LABEL_SRC)} +
+ { + const next = (e.currentTarget as HTMLInputElement).value.trim(); + patchBlock({ src: next }); + }} + /> + +
+
+
+ + {t(L.LABEL_ALT)} + +
+
+ {t(L.LABEL_CAPTION)} + { + const next = (e.currentTarget as HTMLInputElement).value; + patchBlock({ caption: next || undefined }); + }} + /> +
+
+ {t(L.LABEL_IMAGE_FULL_WIDTH)} + 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)} + +
+
+ {t(L.LABEL_WIDTH)} + patchBlock({ width: n > 0 ? n : undefined })} + > + − + + + + +
+
+ {t(L.LABEL_HEIGHT)} + patchBlock({ height: n > 0 ? n : undefined })} + > + − + + + + +
+
+ {t(L.LABEL_IMAGE_ALIGN)} + + 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} + + + + {/each} + +
+{:else if block.type === 'callout'} + +
+ {t(L.LABEL_INTENT)} + { + const intent = v[0] as WordsIntent | undefined; + if (!intent) return; + patchBlock({ intent }); + }} + aria-label={t(L.ARIA_INTENT)} + > + {#each INTENTS as i (i.v)} + + + {i.label} + + {/each} + +
+
+ {t(L.LABEL_TITLE)} + { + const next = (e.currentTarget as HTMLInputElement).value; + patchBlock({ title: next || undefined }); + }} + /> +
+{:else if block.type === 'table'} + + + {#if activeCell} +
+
{t(L.SECTION_CELL)}
+
+ {t(L.LABEL_CELL_ALIGN)} + { + 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} + + + + {/each} + +
+
+ {t(L.LABEL_CELL_VALIGN)} + { + const verticalAlign = v[0] as WordsVerticalAlign | undefined; + if (!verticalAlign) return; + applyCommand({ type: 'setCellVerticalAlign', verticalAlign }); + }} + aria-label={t(L.ARIA_CELL_VALIGN)} + > + {t(L.VALIGN_TOP)} + {t(L.VALIGN_MIDDLE)} + {t(L.VALIGN_BOTTOM)} + +
+ applyCommand({ type: 'setCellVisual', visual: { color: hex } })} + /> + + applyCommand({ type: 'setCellVisual', visual: { background: hex } })} + /> +
+ {/if} + + {#if activeRow} +
+
{t(L.SECTION_ROW)}
+ + applyCommand({ type: 'setRowVisual', visual: { background: hex } })} + /> +
+ {/if} + +
+
{t(L.SECTION_TABLE)}
+ +
+ {t(L.LABEL_ROWS)} + {block.rows.length} +
+
+ + + +
+
+ {t(L.LABEL_COLUMNS)} + {block.rows[0]?.cells.length ?? 0} +
+
+ + + +
+
+ {t(L.LABEL_HEADER_ROW)} + applyCommand({ type: 'toggleTableHeaderRow' })} + aria-label={t(L.ARIA_HEADER_ROW)} + > + {block.headerRow ? t(L.PRESET_FULL) : t(L.PRESET_NONE)} + +
+
+ {t(L.LABEL_HEADER_COLUMN)} + applyCommand({ type: 'toggleTableHeaderColumn' })} + aria-label={t(L.ARIA_HEADER_COLUMN)} + > + {block.headerCol ? t(L.PRESET_FULL) : t(L.PRESET_NONE)} + +
+
+{:else if block.type === 'columns'} + +
+ {t(L.LABEL_COLUMNS)} + {block.columns.length} + +
+ {#each block.columns as col, idx (idx)} +
+
+ {t(L.LABEL_COLUMN_N).replace('{n}', String(idx + 1))} +
+
+ {t(L.LABEL_COLUMN_WIDTH)} + { + const next = (e.currentTarget as HTMLInputElement).value; + setColumnWidth(idx, next); + }} + /> +
+ {#if block.columns.length > 1} +
+ +
+ {/if} +
+ {/each} +{/if} diff --git a/src/uix/eidos/components/words/words-bubble.svelte b/src/uix/eidos/components/words/words-bubble.svelte index 6f3a8224b..633e2cdfb 100644 --- a/src/uix/eidos/components/words/words-bubble.svelte +++ b/src/uix/eidos/components/words/words-bubble.svelte @@ -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(() => { - 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' }) } ]); diff --git a/src/uix/eidos/components/words/words-color-row.svelte b/src/uix/eidos/components/words/words-color-row.svelte index 4ceefab24..78e1a4f35 100644 --- a/src/uix/eidos/components/words/words-color-row.svelte +++ b/src/uix/eidos/components/words/words-color-row.svelte @@ -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 @@ -
- -
- {label} - {#if current} - - {/if} -
- +
+ {label} + + {#each presets as c (c)} diff --git a/src/uix/eidos/components/words/words-column-inserter.svelte b/src/uix/eidos/components/words/words-column-inserter.svelte new file mode 100644 index 000000000..e30f68f94 --- /dev/null +++ b/src/uix/eidos/components/words/words-column-inserter.svelte @@ -0,0 +1,391 @@ + + +{#if frame} + {#each slots as slot (keyFor(slot))} + {@const k = keyFor(slot)} + +
+ { + openColumnKey = v ? k : openColumnKey === k ? null : openColumnKey; + }} + > + + {#snippet icon()}{/snippet} + + + { + // 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)} + handleInsert(slot, entry)}> + {entry.label} + + {/each} + + + +
+ {/each} +{/if} + + diff --git a/src/uix/eidos/components/words/words-inspector.svelte b/src/uix/eidos/components/words/words-inspector.svelte index 6e97241d1..8665f2d76 100644 --- a/src/uix/eidos/components/words/words-inspector.svelte +++ b/src/uix/eidos/components/words/words-inspector.svelte @@ -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; + 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)) { + 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(() => { + const path = api.selectedBlockPath; + if (path && path.length > 1) return path; + return activeIndex >= 0 ? [activeIndex] : undefined; + }); function edit(visual: Partial) { - 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(['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(['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> = { + 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(['spacing', 'border']), + divider: new Set(['color', 'spacing']), + table: new Set(), // none — Block panel owns the scopes + columns: new Set(['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([ + { 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([ + { v: 'solid', label: t(L.BORDER_SOLID) }, + { v: 'dashed', label: t(L.BORDER_DASHED) }, + { v: 'dotted', label: t(L.BORDER_DOTTED) } + ]); + + const FONTS = $derived([ + { 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([ + { 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); } } -{#snippet numRow(label: string, value: number, min: number, max: number, step: number, onChange: (n: number) => void)} -
-
- {label} - {value} -
- onChange(v[0] ?? min)} - > - - - -
-{/snippet} -
{#if activeBlock} {@const block = activeBlock} @@ -141,15 +267,35 @@ - + + {#if blockHasPanelProps(block)} + + + {t(L.SECTION_BLOCK)} + + +
+ +
+
+
+ {/if} + + {#if shows('typography')} - Typography + {t(L.SECTION_TYPOGRAPHY)}
- Font + {t(L.LABEL_FONT)} edit({ fontFamily: v[0] ? v[0] : undefined })} - aria-label="Font family" + aria-label={t(L.ARIA_FONT_FAMILY)} > {#each FONTS as f (f.v)} {f.label} {/each}
- {@render numRow('Font size', block.fontSize ?? 16, 12, 40, 1, (n) => - edit({ fontSize: n }) - )} + edit({ fontSize: n })} + />
- Weight + {t(L.LABEL_WEIGHT)} 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)} {w.label} {/each}
- {@render numRow('Line height', block.lineHeight ?? 1.6, 1, 2.5, 0.1, (n) => - edit({ lineHeight: Math.round(n * 10) / 10 }) - )} + edit({ lineHeight: Math.round(n * 10) / 10 })} + />
+ {/if} + {#if shows('color')} - Color + {t(L.SECTION_COLOR)}
edit({ color: hex })} /> edit({ background: hex })} /> - +
+ {/if} + {#if shows('layout')} - Layout + {t(L.SECTION_LAYOUT)}
- Align + {t(L.LABEL_ALIGN)} edit({ align: v[0] as AlignValue | undefined })} - aria-label="Align" + aria-label={t(L.ARIA_ALIGN)} > {#each ALIGNS as a (a.v)} - {a.label} + {@const Icon = a.icon} + + + {/each}
+ {/if} + {#if shows('spacing')} - Spacing + {t(L.SECTION_SPACING)}
- {@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 } }) - )} + edit({ margin: next })} + /> + edit({ padding: next })} + />
+ {/if} + {#if shows('border')} - Border + {t(L.SECTION_BORDER)}
- Style + {t(L.LABEL_STYLE)} {#each BORDER_STYLES as b (b.v)} - {b.label} + + + + {/each}
- {@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 } }) - )} + edit({ border: { ...block.border, width: n } })} + /> + edit({ border: { ...block.border, color: hex } })} + /> + edit({ border: { ...block.border, radius: n } })} + /> +
+ {t(L.LABEL_PRESET)} + { + 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)} + > + {t(L.PRESET_NONE)} + {t(L.PRESET_FULL)} + +
+ {/if} -
{:else} -

Select a block to edit its style.

+

{t(L.EMPTY)}

{/if}
diff --git a/src/uix/eidos/components/words/words-num-row.svelte b/src/uix/eidos/components/words/words-num-row.svelte new file mode 100644 index 000000000..1793ef970 --- /dev/null +++ b/src/uix/eidos/components/words/words-num-row.svelte @@ -0,0 +1,104 @@ + + +
+
+ {label} + + onCommit(n)} + > + − + + + + +
+ { + const n = v[0]; + if (n !== undefined) draft = n; + }} + onValueCommit={(v: number[]) => { + const n = v[0]; + if (n !== undefined) onCommit(n); + }} + > + + + +
diff --git a/src/uix/eidos/components/words/words-spacing-row.svelte b/src/uix/eidos/components/words/words-spacing-row.svelte new file mode 100644 index 000000000..314e42765 --- /dev/null +++ b/src/uix/eidos/components/words/words-spacing-row.svelte @@ -0,0 +1,250 @@ + + +
+
+ {label} +
+
+ +
+
+ {t(L.SPACING_TOP)} + setSide('top', n)} + > + − + + + + +
+
+ {t(L.SPACING_BOTTOM)} + setSide('bottom', n)} + > + − + + + + +
+ (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} + + {:else} + + {/if} + +
+ +
+
+ {t(L.SPACING_LEFT)} + setSide('left', n)} + > + − + + + + +
+
+ {t(L.SPACING_RIGHT)} + setSide('right', n)} + > + − + + + + +
+ (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} + + {:else} + + {/if} + +
+
+
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index a655dc4bc..49373a18f 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -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. + `` 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: ` 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
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 ``), 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); diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index 78c31f002..1a15bb511 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -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(null); @@ -62,6 +72,7 @@ {#if contentEl} + {/if} {#if inspector === 'sidebar'} diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index d75ebd09a..ce7d26be4 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -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; diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index 2d4bca42c..8bc90a68f 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -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) => { - if (this.provider.opts.open.current) this.provider.handleClose(); - else this.provider.handleOpen(); + readonly onclick = (e: SomaMouseEvent) => { + 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) => { 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); } }; diff --git a/src/uix/soma/components/words/engine/blocks/built-ins.ts b/src/uix/soma/components/words/engine/blocks/built-ins.ts index bf527ff51..c8d30a597 100644 --- a/src/uix/soma/components/words/engine/blocks/built-ins.ts +++ b/src/uix/soma/components/words/engine/blocks/built-ins.ts @@ -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 = {}; + 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)[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 ``. + { + 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; + const styled = ctx.stringifyStyle(styleObj); + return `
${inner}
`; + }) + .join(''); + return `
${colsHtml}
`; + }, + 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; + // `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). diff --git a/src/uix/soma/components/words/engine/operations/block-format.ts b/src/uix/soma/components/words/engine/operations/block-format.ts index 4896bd4d0..cefa1ce94 100644 --- a/src/uix/soma/components/words/engine/operations/block-format.ts +++ b/src/uix/soma/components/words/engine/operations/block-format.ts @@ -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; } diff --git a/src/uix/soma/components/words/engine/operations/commands.ts b/src/uix/soma/components/words/engine/operations/commands.ts index 5715a2a9f..16118ee96 100644 --- a/src/uix/soma/components/words/engine/operations/commands.ts +++ b/src/uix/soma/components/words/engine/operations/commands.ts @@ -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>; } + // 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>; + } // V2-exclusive: visual sidecar update | { readonly type: 'setBlockVisual'; readonly blockIndex: number; readonly visual: Partial; } + // 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>; + } + | { + readonly type: 'setBlockVisualAtPath'; + readonly blockPath: readonly number[]; + readonly visual: Partial; + } // 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 } + | { readonly type: 'setRowVisual'; readonly visual: Partial } // 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); diff --git a/src/uix/soma/components/words/engine/operations/helpers.ts b/src/uix/soma/components/words/engine/operations/helpers.ts index c15b5e94e..e29f50e4c 100644 --- a/src/uix/soma/components/words/engine/operations/helpers.ts +++ b/src/uix/soma/components/words/engine/operations/helpers.ts @@ -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(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; } diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.ts index 73f8a44f0..0fded95c9 100644 --- a/src/uix/soma/components/words/engine/operations/insert-block-types.ts +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.ts @@ -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 ────────────────────────────────────────────────────── /** diff --git a/src/uix/soma/components/words/engine/operations/normalize.ts b/src/uix/soma/components/words/engine/operations/normalize.ts index 68a7e8ba4..46c0639fc 100644 --- a/src/uix/soma/components/words/engine/operations/normalize.ts +++ b/src/uix/soma/components/words/engine/operations/normalize.ts @@ -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, diff --git a/src/uix/soma/components/words/engine/operations/paragraph-ops.ts b/src/uix/soma/components/words/engine/operations/paragraph-ops.ts index 02d905efa..22af850f1 100644 --- a/src/uix/soma/components/words/engine/operations/paragraph-ops.ts +++ b/src/uix/soma/components/words/engine/operations/paragraph-ops.ts @@ -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 { diff --git a/src/uix/soma/components/words/engine/operations/selection-walkers.ts b/src/uix/soma/components/words/engine/operations/selection-walkers.ts index 2df313769..4bc65c5fc 100644 --- a/src/uix/soma/components/words/engine/operations/selection-walkers.ts +++ b/src/uix/soma/components/words/engine/operations/selection-walkers.ts @@ -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; diff --git a/src/uix/soma/components/words/engine/operations/table-ops.ts b/src/uix/soma/components/words/engine/operations/table-ops.ts index c4c66b9ea..d9a3017e6 100644 --- a/src/uix/soma/components/words/engine/operations/table-ops.ts +++ b/src/uix/soma/components/words/engine/operations/table-ops.ts @@ -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(target: T, visual: Partial): T { + const prev = target as unknown as Record; + const next: Record = { ...prev }; + let touched = false; + for (const key of VISUAL_KEYS) { + if (!(key in visual)) continue; + const value = (visual as Record)[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; + const bo = b as Record; + 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 +): WordsOperationResult { + return updateActiveCell(state, (cell) => mergeVisual(cell, visual)); +} + +export function setRowVisual( + state: WordsEditorState, + visual: Partial +): 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 diff --git a/src/uix/soma/components/words/engine/operations/visual.ts b/src/uix/soma/components/words/engine/operations/visual.ts index c7d81e21d..24626abb3 100644 --- a/src/uix/soma/components/words/engine/operations/visual.ts +++ b/src/uix/soma/components/words/engine/operations/visual.ts @@ -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 +): 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; + const next: Record = { ...prev }; + let touched = false; + for (const key of STYLE_KEYS) { + if (!(key in patch)) continue; + const value = (patch as Record)[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> +): 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 = {}; + 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), + ...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 diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index 38022ff52..80bcea972 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -340,18 +340,45 @@ export function blockStyle( ): Readonly> { const out: Record = {}; 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; diff --git a/src/uix/soma/components/words/engine/types.ts b/src/uix/soma/components/words/engine/types.ts index 74e537140..7b6203db9 100644 --- a/src/uix/soma/components/words/engine/types.ts +++ b/src/uix/soma/components/words/engine/types.ts @@ -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 `` 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]; diff --git a/src/uix/soma/components/words/engine/validate.ts b/src/uix/soma/components/words/engine/validate.ts index ded1f92a1..5af0f5325 100644 --- a/src/uix/soma/components/words/engine/validate.ts +++ b/src/uix/soma/components/words/engine/validate.ts @@ -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)[key]; if (n !== undefined && (typeof n !== 'number' || !Number.isFinite(n))) { errors.push({ diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts index 7c26501cc..2294012eb 100644 --- a/src/uix/soma/components/words/exports.ts +++ b/src/uix/soma/components/words/exports.ts @@ -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 diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index c89a269eb..eb0fba8c2 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -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 diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 8b80f31e5..e7c4c5a04 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -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(undefined); + selectedBlockPath = $state(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) { diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 6ae846db2..713bc43c1 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -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((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 @@
- +