Decision de usuario 2026-07-14: words (chrome eidos + demo) quedo deprecado en pos de palabras; existia como referencia historica. - Migradas las 4 piezas que palabras importaba (git mv, historia conservada, sin shims): words-activate -> palabras-activate, words-block-drag -> palabras-block-drag, words-block-drag-type -> palabras-block-drag-type, words-image-file -> palabras-image-file; imports actualizados a rutas locales. - Borrados src/uix/eidos/components/words/** (wrapper, menubar, bubble, inspector, gutter, rail, words.css, langs-menu/inspector, AUDIT visual) y la demo web/routes/uix/components/words/. - Sidebar: fuera el link Words, entra Palabras (la demo era inalcanzable desde la nav). - Podado el bloque `words:` de lib/recipes/base.ts + regeneracion (generated/base.css: -84 lineas, solo bajas --words-*). - Docs del track: referencias al AUDIT visual de words apuntan a git history. NO muere: soma/components/words (motor + provider + tests), morfo y langs de words — el fundamento vivo de palabras. Verificacion: engine 493/493 - guards eidos (recipe-css-contract + api-contract + visual-attrs) 35/35 - grep eidos/components/words en codigo = 0 - navegador (server propio): demo palabras monta sin errores de consola, activate fija bloque, grip abre panel (tabs + pills H1-H6/HC + texto/enlace). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>menubar-v4-safe
parent
b5adaeee07
commit
326a25ec4d
@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Native image-file picker → data URL.
|
||||
*
|
||||
* Shared by the two Words consumers that let the user insert an image
|
||||
* from disk: the gutter inserter (`words-block-gutter.svelte`) and the
|
||||
* inspector's image panel upload button (`words-block-panel.svelte`).
|
||||
* Both need the identical "open file dialog → FileReader → data URL"
|
||||
* flow, so it lives here once instead of being copy-pasted.
|
||||
* Shared by the palabras surfaces that let the user insert an image
|
||||
* from disk: the block-type grids (grip menu + column inserter) and the
|
||||
* panel's image upload button (`palabras-panel.svelte`). All need the
|
||||
* identical "open file dialog → FileReader → data URL" flow, so it
|
||||
* lives here once instead of being copy-pasted.
|
||||
*
|
||||
* Kept as a plain helper (NOT routed through ActiveDom): a transient
|
||||
* `<input type="file">` plus `FileReader` is not instrumented by the
|
||||
@ -1,9 +0,0 @@
|
||||
// Eidos `<Words>` — the editor surface (provider + contenteditable +
|
||||
// placeholder), styled by words.css. Chrome (gutters / bubble toolbar /
|
||||
// settings drawer) is composed on top in later pieces.
|
||||
//
|
||||
// import Words from '$uix/eidos/components/words';
|
||||
// <Words bind:value placeholder="Write something…" />
|
||||
export { default } from './words.svelte';
|
||||
|
||||
export type { WordsProps, WordsSize } from './types';
|
||||
@ -1,434 +0,0 @@
|
||||
/**
|
||||
* 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_GAP: '#?components.words.inspector.label.column-gap|Gap',
|
||||
PLACEHOLDER_COLUMN_GAP: '#?components.words.inspector.placeholder.column-gap|1rem, 24px, 0',
|
||||
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}',
|
||||
ARIA_ADD_BLOCK_TO_COLUMN_N:
|
||||
'#?components.words.inspector.aria.add-block-to-column-n|Add block to column {n}',
|
||||
LABEL_ADD_BLOCK: '#?components.words.inspector.label.add-block|Add block',
|
||||
LABEL_COLUMN_BLOCKS: '#?components.words.inspector.label.column-blocks|Blocks',
|
||||
ARIA_SELECT_BLOCK_IN_COLUMN:
|
||||
'#?components.words.inspector.aria.select-block-in-column|Select this block',
|
||||
ARIA_DELETE_BLOCK_IN_COLUMN:
|
||||
'#?components.words.inspector.aria.delete-block-in-column|Delete this block',
|
||||
ARIA_MOVE_BLOCK_UP: '#?components.words.inspector.aria.move-block-up|Move block up',
|
||||
ARIA_MOVE_BLOCK_DOWN: '#?components.words.inspector.aria.move-block-down|Move block down',
|
||||
BREADCRUMB_DOCUMENT: '#?components.words.inspector.breadcrumb.document|Document',
|
||||
BREADCRUMB_LIST_ITEM: '#?components.words.inspector.breadcrumb.list-item|Item {n}',
|
||||
BREADCRUMB_TABLE_ROW: '#?components.words.inspector.breadcrumb.table-row|Row {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|Auto',
|
||||
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|Semi',
|
||||
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_NONE: '#?components.words.inspector.border.none|None',
|
||||
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}' },
|
||||
'column-blocks': { en: 'Blocks', es: 'Bloques' },
|
||||
'add-block': { en: 'Add block', es: 'Añadir bloque' },
|
||||
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: 'Auto', es: 'Auto' },
|
||||
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' },
|
||||
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: {
|
||||
none: { en: 'None', es: 'Ninguno' },
|
||||
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}' },
|
||||
'add-block-to-column-n': {
|
||||
en: 'Add block to column {n}',
|
||||
es: 'Añadir bloque a la columna {n}'
|
||||
},
|
||||
'select-block-in-column': { en: 'Select this block', es: 'Seleccionar este bloque' },
|
||||
'delete-block-in-column': { en: 'Delete this block', es: 'Eliminar este bloque' },
|
||||
'move-block-up': { en: 'Move block up', es: 'Subir el bloque' },
|
||||
'move-block-down': { en: 'Move block down', es: 'Bajar el bloque' },
|
||||
'header-row': { en: 'Toggle header row', es: 'Alternar fila de cabecera' },
|
||||
'header-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' },
|
||||
'cell-align': {
|
||||
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;
|
||||
@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Translation refs for the block-handle action menu (`WordsBlockGutter`).
|
||||
*
|
||||
* Same `idlangref` pattern as `langs-inspector.ts`: each entry is
|
||||
* `#?components.words.menu.{key}|fallback`. `eidos.langs.ts(ref)` returns
|
||||
* the localized string when `WORDS_MENU_BUNDLE` is registered (the eidos
|
||||
* `<Words>` wrapper extends it on mount), else the English fallback.
|
||||
*/
|
||||
|
||||
export const WORDS_MENU_LANGS = {
|
||||
ACTIONS: '#?components.words.menu.actions|Block actions',
|
||||
INSPECT: '#?components.words.menu.inspect|Inspect',
|
||||
MOVE_UP: '#?components.words.menu.move-up|Move up',
|
||||
MOVE_DOWN: '#?components.words.menu.move-down|Move down',
|
||||
DUPLICATE: '#?components.words.menu.duplicate|Duplicate',
|
||||
INSERT_BELOW: '#?components.words.menu.insert-below|Insert below',
|
||||
PASTE_BELOW: '#?components.words.menu.paste-below|Paste below',
|
||||
DELETE: '#?components.words.menu.delete|Delete'
|
||||
} as const;
|
||||
|
||||
export const WORDS_MENU_BUNDLE = {
|
||||
actions: { en: 'Block actions', es: 'Acciones del bloque' },
|
||||
inspect: { en: 'Inspect', es: 'Inspeccionar' },
|
||||
'move-up': { en: 'Move up', es: 'Subir' },
|
||||
'move-down': { en: 'Move down', es: 'Bajar' },
|
||||
duplicate: { en: 'Duplicate', es: 'Duplicar' },
|
||||
'insert-below': { en: 'Insert below', es: 'Insertar debajo' },
|
||||
'paste-below': { en: 'Paste below', es: 'Pegar debajo' },
|
||||
delete: { en: 'Delete', es: 'Eliminar' }
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
import type { ProviderProps } from '$soma/components/words';
|
||||
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
|
||||
/**
|
||||
* Sizing subset Words exposes. The recipe maps three steps (sm / md / lg)
|
||||
* to content padding, min/max block-size and the reading font-size.
|
||||
*/
|
||||
export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
|
||||
/**
|
||||
* Where the block inspector lives. `'sidebar'` keeps it docked and always
|
||||
* visible (builder-style); `'drawer'` slides it in from the edge;
|
||||
* `'popover'` floats it from a toggle; `'none'` hides it entirely.
|
||||
*/
|
||||
export type WordsInspectorMode = 'none' | 'sidebar' | 'drawer' | 'popover';
|
||||
|
||||
/**
|
||||
* Props for the eidos `<Words>` editor.
|
||||
*
|
||||
* Extends the headless provider's API with the single visual knob the
|
||||
* recipe needs — `size`. Eidos owns the editor's internal structure
|
||||
* (the contenteditable surface + placeholder), so the headless
|
||||
* `children` / `child` snippets are not surfaced here.
|
||||
*
|
||||
* Everything else (value, selection, placeholder, readonly, disabled,
|
||||
* upload pipeline, change/commit callbacks, native div attrs) flows
|
||||
* straight through to the soma `Words.Provider`.
|
||||
*/
|
||||
export type WordsProps = Omit<ProviderProps, 'children' | 'child'> & {
|
||||
/** Sizing scale — drives padding, height and reading size. @default 'md' */
|
||||
size?: ResponsiveProp<WordsSize>;
|
||||
/** Where the block inspector lives. @default 'sidebar' */
|
||||
inspector?: WordsInspectorMode;
|
||||
/**
|
||||
* Show the formatting menu bar at the top of the editor (history / block
|
||||
* style / marks / lists / align / insert + Edit·Preview + inspector
|
||||
* toggle). @default true
|
||||
*/
|
||||
toolbar?: boolean;
|
||||
};
|
||||
@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Active-block rail — ONE vertical bar in the left gutter that tracks the
|
||||
* CLICKED (active) block. Frame-relative + a fixed gutter column (CSS), so
|
||||
* it never shifts right for nested blocks, and a single fixed colour. Its
|
||||
* top/height follow the active block, re-measured on activation / render /
|
||||
* scroll. Hidden when the active block is scrolled out of view.
|
||||
*/
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
} = $props();
|
||||
|
||||
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
|
||||
let rail = $state<{ top: number; height: number } | null>(null);
|
||||
|
||||
function measure() {
|
||||
const el = frame ? content.querySelector('[data-words-active]') : null;
|
||||
if (!frame || !(el instanceof HTMLElement)) {
|
||||
rail = null;
|
||||
return;
|
||||
}
|
||||
// Climb to the TOP-LEVEL block (a direct child of content) so the rail
|
||||
// spans the whole block — e.g. a full table, not the clicked cell.
|
||||
let top: HTMLElement = el;
|
||||
while (top.parentElement && top.parentElement !== content) {
|
||||
top = top.parentElement;
|
||||
}
|
||||
const br = top.getBoundingClientRect();
|
||||
const fr = frame.getBoundingClientRect();
|
||||
const cr = content.getBoundingClientRect();
|
||||
// Hide when the active block is scrolled out of the content viewport.
|
||||
if (br.bottom < cr.top + 2 || br.top > cr.bottom - 2) {
|
||||
rail = null;
|
||||
return;
|
||||
}
|
||||
rail = { top: br.top - fr.top, height: br.height };
|
||||
}
|
||||
|
||||
// Re-measure when the active block changes or the doc re-renders (typing
|
||||
// resizes a block; `{@html}` recreates its element).
|
||||
$effect(() => {
|
||||
void api.activeBlockId;
|
||||
void api.html;
|
||||
dom.raf(measure, content);
|
||||
});
|
||||
// Re-measure on internal content scroll (the rail is frame-relative).
|
||||
$effect(() => dom.listen(content, 'scroll', measure, { capture: true }));
|
||||
</script>
|
||||
|
||||
{#if rail}
|
||||
<div data-words-active-rail style="top: {rail.top}px; height: {rail.height}px;"></div>
|
||||
{/if}
|
||||
@ -1,377 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Left gutter — the block handle.
|
||||
*
|
||||
* A grip button that tracks the block under the cursor (in the left
|
||||
* margin) and, on click, opens a menu: Move up / Move down / Duplicate /
|
||||
* Delete + an Insert-below submenu of block types. Built entirely from
|
||||
* the eidos `DropdownMenu` (Trigger renders a `Button`; `Sub` drives the
|
||||
* submenu) — no bespoke widgets.
|
||||
*
|
||||
* Positioning: the gutter is a sibling of the contenteditable inside the
|
||||
* `[data-words]` frame (a positioned ancestor). It measures the hovered
|
||||
* top-level block's viewport rect and places itself frame-relative, so
|
||||
* internal content scroll is handled by re-measuring on `scroll`.
|
||||
*
|
||||
* The handle + portalled menu carry `data-words-block-handle` /
|
||||
* `data-words-block-handle-menu` so the provider's focus-scope predicate
|
||||
* (`isInsideWordsTool`) treats interactions as internal — no spurious
|
||||
* blur / commit / refocus cycle.
|
||||
*/
|
||||
import type { ActiveDom } from '$adom';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
import { GripVertical } from '$uix/eidos/components/icon';
|
||||
import {
|
||||
defaultWordsSchema,
|
||||
type ProviderSnippetProps,
|
||||
type WordsBlockMenuEntry
|
||||
} from '$soma/components/words';
|
||||
import { pickImageFile } from './words-image-file';
|
||||
import { WORDS_MENU_LANGS as L } from './langs-menu';
|
||||
import { WORDS_BLOCK_DRAG_TYPE } from './words-block-drag-type';
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
} = $props();
|
||||
|
||||
// Localized labels for the block-action menu (the bundle is registered by
|
||||
// the eidos `<Words>` wrapper on mount).
|
||||
const eidos = ActiveEidos.require();
|
||||
const t = (ref: string) => eidos.langs.ts(ref);
|
||||
|
||||
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
|
||||
|
||||
let index = $state(-1);
|
||||
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
|
||||
let show = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
// True while the cursor is in the left gutter band (the margin column),
|
||||
// not over the text. Drives the dashed outline that emphasises the
|
||||
// target block — showing it for the whole gutter (not just the small
|
||||
// grip) makes it a forgiving target and keeps the grip clear of the line.
|
||||
let inGutter = $state(false);
|
||||
|
||||
// Disposer for the pending pointer-move frame (null when none scheduled).
|
||||
// Doubles as the throttle guard: non-null ⇒ a frame is already queued.
|
||||
let cancelFrame: (() => void) | null = null;
|
||||
|
||||
function topLevelBlocks(): HTMLElement[] {
|
||||
return Array.from(content.children).filter(
|
||||
(el): el is HTMLElement =>
|
||||
el instanceof HTMLElement && el.matches("[data-words-node='block']")
|
||||
);
|
||||
}
|
||||
|
||||
function place(el: HTMLElement, i: number) {
|
||||
if (!frame) return;
|
||||
const br = el.getBoundingClientRect();
|
||||
const fr = frame.getBoundingClientRect();
|
||||
const cr = content.getBoundingClientRect();
|
||||
// Hide when the block is scrolled out of the content viewport.
|
||||
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) {
|
||||
show = false;
|
||||
return;
|
||||
}
|
||||
index = i;
|
||||
rect = {
|
||||
top: br.top - fr.top,
|
||||
left: br.left - fr.left,
|
||||
width: br.width,
|
||||
height: br.height
|
||||
};
|
||||
show = true;
|
||||
}
|
||||
|
||||
function locate(clientX: number, clientY: number) {
|
||||
const blocks = topLevelBlocks();
|
||||
if (blocks.length === 0) {
|
||||
show = false;
|
||||
inGutter = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Voronoi-on-Y: each block claims a `[from, to)` zone where the
|
||||
// boundary with a neighbour is the midpoint of the inter-block
|
||||
// gap. Every vertical pixel belongs to exactly one block. This is
|
||||
// the only way thin blocks (`<hr data-words-block='divider'>` has
|
||||
// `block-size: 0` — the visible line is a border, the element's
|
||||
// rect is ~1px tall) become hover-targetable. The old code
|
||||
// matched "cursor inside the block's rect OR the last block whose
|
||||
// top is ≤ cursor.y", which left dividers with a single-pixel
|
||||
// hit zone.
|
||||
const rects = blocks.map((el) => el.getBoundingClientRect());
|
||||
let target: HTMLElement | null = null;
|
||||
let targetIndex = -1;
|
||||
let targetRect: DOMRect | null = null;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const r = 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 (target && targetRect) {
|
||||
place(target, targetIndex);
|
||||
// The outline shows only while the cursor is in the gutter band —
|
||||
// i.e. left of the block's text column, where the grip lives.
|
||||
inGutter = clientX < targetRect.left;
|
||||
} else {
|
||||
show = false;
|
||||
inGutter = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition() {
|
||||
if (index < 0) return;
|
||||
const el = topLevelBlocks()[index];
|
||||
if (el) place(el, index);
|
||||
else show = false;
|
||||
}
|
||||
|
||||
function onPointerMove(event: Event) {
|
||||
if (menuOpen) return;
|
||||
if (cancelFrame) return; // a frame is already pending — throttle to 1/frame
|
||||
const e = event as PointerEvent;
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
// `dom.raf` resolves the content's window (iframe / popup safe) and
|
||||
// returns a disposer; we keep it so teardown can cancel a pending frame.
|
||||
cancelFrame = dom.raf(() => {
|
||||
cancelFrame = null;
|
||||
locate(x, y);
|
||||
}, content);
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
if (!menuOpen) {
|
||||
show = false;
|
||||
inGutter = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const disposers = [
|
||||
dom.listen(content, 'pointermove', onPointerMove),
|
||||
dom.listen(content, 'pointerleave', onPointerLeave),
|
||||
dom.listen(content, 'scroll', reposition, { capture: true })
|
||||
];
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose?.();
|
||||
cancelFrame?.();
|
||||
cancelFrame = null;
|
||||
};
|
||||
});
|
||||
|
||||
// Re-measure after edits change the block layout. The one-shot frame is
|
||||
// fire-and-forget (reposition is idempotent + cheap); we still route it
|
||||
// through `dom.raf` so it targets the content's window, not the global.
|
||||
$effect(() => {
|
||||
void api.document;
|
||||
if (show || menuOpen) dom.raf(reposition, content);
|
||||
});
|
||||
|
||||
// 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]);
|
||||
}
|
||||
// Start an HTML5 drag carrying this block's top-level index. The drop
|
||||
// controller (`words-block-drag.svelte`) reads it on drop and repositions
|
||||
// the block. The grip stays a click target too (the menu opens on click,
|
||||
// the drag on a press-and-move) — they don't collide.
|
||||
function onGripDragStart(event: DragEvent) {
|
||||
if (index < 0 || !event.dataTransfer) return;
|
||||
event.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(index));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
function moveUp() {
|
||||
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
|
||||
}
|
||||
function moveDown() {
|
||||
api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'down' });
|
||||
}
|
||||
function duplicate() {
|
||||
api.applyCommand({ type: 'duplicateBlock', blockIndex: index });
|
||||
}
|
||||
// Paste the clipboard (rich HTML → blocks + marks, else plain text) as new
|
||||
// blocks right below this one — a discoverable, click-driven alternative to
|
||||
// Ctrl+V. Async (reads the Clipboard API); the menu closes immediately.
|
||||
function paste() {
|
||||
void api.pasteAfterBlock(index);
|
||||
show = false;
|
||||
}
|
||||
function remove() {
|
||||
// Capture the target BEFORE the delete (reading the doc after returns
|
||||
// the wrong block); move the active to the previous block.
|
||||
const kids = api.document.children;
|
||||
const prevId = (index > 0 ? kids[index - 1]?.id : kids[1]?.id) ?? null;
|
||||
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
|
||||
api.setActiveBlock(prevId);
|
||||
show = false;
|
||||
}
|
||||
function insert(block: Record<string, unknown>) {
|
||||
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. The picker
|
||||
// flow is shared with the inspector's image panel (`pickImageFile`).
|
||||
function insertImageInteractive() {
|
||||
const doc =
|
||||
(typeof document !== 'undefined' ? document : null) ?? content?.ownerDocument ?? null;
|
||||
if (!doc) return;
|
||||
pickImageFile(doc, (src, alt) => insert({ type: 'image', src, alt }));
|
||||
}
|
||||
|
||||
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.
|
||||
// Image is `insertable: false` because its `create()` returns an empty
|
||||
// `src`; we surface it anyway and route through the file picker above
|
||||
// so the user can actually pick an image from the gutter menu.
|
||||
//
|
||||
// Collapse multi-variant entries into ONE canonical chip in the
|
||||
// gutter (creates the default variant — the inspector then exposes
|
||||
// a level/kind toggle to switch to the others). The slash menu keeps
|
||||
// every variant because typing `/h2` or `/check` is the natural way
|
||||
// to pick the variant there; a click-pick menu doesn't get that
|
||||
// ergonomic, so a flat "Heading" / "List" reads cleaner.
|
||||
//
|
||||
// Today the registry exposes:
|
||||
// heading-1 / heading-2 / heading-3 → "Heading" (creates H1)
|
||||
// unordered-list / ordered-list / check-list → "List" (creates bullets)
|
||||
const COLLAPSE_GROUPS: Record<string, { id: string; label: string }> = {
|
||||
'heading-1': { id: 'heading', label: 'Heading' },
|
||||
'unordered-list': { id: 'list', label: 'List' }
|
||||
};
|
||||
const COLLAPSED_DROPS = new Set(['heading-2', 'heading-3', 'ordered-list', 'check-list']);
|
||||
const inserts = $derived.by(() => {
|
||||
const all = defaultWordsSchema.insertable();
|
||||
return all
|
||||
.filter((entry) => !COLLAPSED_DROPS.has(entry.id))
|
||||
.map((entry) => {
|
||||
const replacement = COLLAPSE_GROUPS[entry.id];
|
||||
if (replacement) return { ...entry, ...replacement };
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if (show || menuOpen) && rect}
|
||||
<!-- No hover overlay on the block: the active-block marker is a gutter
|
||||
rail driven by `[data-words-active]` (see words.css), so it tracks the
|
||||
CLICKED block, not the hovered one. -->
|
||||
|
||||
<!-- Center the handle vertically against the block's height: position
|
||||
it at the block midpoint and let `transform: translateY(-50%)`
|
||||
in CSS pull it up by half its own height. The previous
|
||||
`margin-block-start` hack only worked for the FIRST line of
|
||||
short blocks (paragraph, heading) — tall blocks (code, table,
|
||||
image) had the handle pinned to the top, far from the cursor's
|
||||
natural target. -->
|
||||
<div data-words-block-gutter style="top: {rect.top + rect.height / 2}px;">
|
||||
<DropdownMenu bind:open={menuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label="Block actions"
|
||||
data-words-block-handle
|
||||
draggable={true}
|
||||
ondragstart={onGripDragStart}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="5.5" cy="3.5" r="1.35" />
|
||||
<circle cx="10.5" cy="3.5" r="1.35" />
|
||||
<circle cx="5.5" cy="8" r="1.35" />
|
||||
<circle cx="10.5" cy="8" r="1.35" />
|
||||
<circle cx="5.5" cy="12.5" r="1.35" />
|
||||
<circle cx="10.5" cy="12.5" r="1.35" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
{t(L.ACTIONS)}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
|
||||
<DropdownMenu.Item onSelect={inspect}>{t(L.INSPECT)}</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>{t(L.MOVE_UP)}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={moveDown}>{t(L.MOVE_DOWN)}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={duplicate}>{t(L.DUPLICATE)}</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>{t(L.INSERT_BELOW)}</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent data-words-block-handle-menu>
|
||||
{#each inserts as ins (ins.id)}
|
||||
<DropdownMenu.Item onSelect={() => handleInsert(ins)}>{ins.label}</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Item onSelect={paste}>{t(L.PASTE_BELOW)}</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={remove}>{t(L.DELETE)}</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/if}
|
||||
@ -1,377 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Selection bubble toolbar.
|
||||
*
|
||||
* Floats above the current text selection (TipTap / Notion-style). A
|
||||
* "Turn into" dropdown converts the current block (when convertible)
|
||||
* plus a row of inline-mark toggles and a link control.
|
||||
*
|
||||
* Composition — every control is an ecosystem component, no bespoke
|
||||
* widgets or hand-drawn glyphs:
|
||||
* - soma `Words.BubbleMenu` owns open/close + viewport positioning +
|
||||
* the single-focus-scope contract.
|
||||
* - eidos `DropdownMenu` drives turn-into; its content carries
|
||||
* `data-words-bubble-turn-into` so the focus scope recognises it.
|
||||
* - marks are soma `Words.CommandButton`s rendered through their `child`
|
||||
* snippet as eidos `Button`s (the canonical consumer pattern) — the
|
||||
* CommandButton restores the DOM range before the command runs.
|
||||
* - glyphs come from the eidos `Icon` set.
|
||||
*
|
||||
* Block transforms go through `api.applyCommand` (direct, no sema).
|
||||
*/
|
||||
import { tick } from 'svelte';
|
||||
import * as Words from '$soma/components/words';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Link,
|
||||
Unlink,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Baseline,
|
||||
Highlighter
|
||||
} from '$uix/eidos/components/icon';
|
||||
import type {
|
||||
ProviderSnippetProps,
|
||||
WordsBlock,
|
||||
WordsHeadingLevel,
|
||||
WordsListKind
|
||||
} from '$soma/components/words';
|
||||
|
||||
let { api }: { api: ProviderSnippetProps } = $props();
|
||||
|
||||
let turnIntoOpen = $state(false);
|
||||
let colorOpen = $state(false);
|
||||
let linkOpen = $state(false);
|
||||
let linkValue = $state('');
|
||||
let linkInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Swatch palette for the bubble's text / background color marks — the
|
||||
// SAME hues the inspector's color rows offer, minus pure white (invisible
|
||||
// as a text color on a light surface; the "Default" chip clears instead).
|
||||
// Each swatch applies via `Words.CommandButton` (command `color:#hex` /
|
||||
// `bgcolor:#hex`) which the provider parses to a parametric `toggleMark`
|
||||
// on the live selection — exactly how bold / italic apply, so a recolor
|
||||
// rides the same range-restoring path with no bespoke wiring.
|
||||
const COLOR_SWATCHES = [
|
||||
'#e5484d',
|
||||
'#f76b15',
|
||||
'#ffc53d',
|
||||
'#46a758',
|
||||
'#0091ff',
|
||||
'#8e4ec6',
|
||||
'#e93d82',
|
||||
'#646a73',
|
||||
'#1a1a1a'
|
||||
] as const;
|
||||
|
||||
// Blocks whose content the engine can reinterpret (setBlock / toggleList
|
||||
// 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']);
|
||||
|
||||
// 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 (effectiveBlock.type) {
|
||||
case 'heading':
|
||||
return `Heading ${effectiveBlock.level ?? 1}`;
|
||||
case 'quote':
|
||||
return 'Quote';
|
||||
case 'code':
|
||||
return 'Code';
|
||||
case 'list':
|
||||
return effectiveBlock.kind === 'ordered'
|
||||
? 'Numbered list'
|
||||
: effectiveBlock.kind === 'check'
|
||||
? 'Check list'
|
||||
: 'Bulleted list';
|
||||
case 'paragraph':
|
||||
return 'Text';
|
||||
case 'table':
|
||||
return 'Table';
|
||||
case 'callout':
|
||||
return 'Callout';
|
||||
case 'image':
|
||||
return 'Image';
|
||||
case 'divider':
|
||||
return 'Divider';
|
||||
case 'columns':
|
||||
return 'Columns';
|
||||
default:
|
||||
return 'Turn into';
|
||||
}
|
||||
});
|
||||
|
||||
const turnInto = $derived.by(() => [
|
||||
{
|
||||
id: 'paragraph',
|
||||
label: 'Text',
|
||||
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: effectiveBlock.type === 'heading' && (effectiveBlock.level ?? 1) === level,
|
||||
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
|
||||
})),
|
||||
{
|
||||
id: 'unordered-list',
|
||||
label: 'Bulleted list',
|
||||
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'unordered',
|
||||
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
|
||||
},
|
||||
{
|
||||
id: 'ordered-list',
|
||||
label: 'Numbered list',
|
||||
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'ordered',
|
||||
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
|
||||
},
|
||||
{
|
||||
id: 'check-list',
|
||||
label: 'Check list',
|
||||
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'check',
|
||||
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
label: 'Quote',
|
||||
active: effectiveBlock.type === 'quote',
|
||||
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
label: 'Code',
|
||||
active: effectiveBlock.type === 'code',
|
||||
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
|
||||
}
|
||||
]);
|
||||
|
||||
const MARKS = [
|
||||
{ command: 'bold', label: 'Bold', icon: Bold },
|
||||
{ command: 'italic', label: 'Italic', icon: Italic },
|
||||
{ command: 'underline', label: 'Underline', icon: Underline },
|
||||
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
|
||||
{ command: 'code', label: 'Inline code', icon: Code }
|
||||
] as const;
|
||||
|
||||
function openLinkEditor() {
|
||||
linkValue = api.selectedLink?.href ?? '';
|
||||
linkOpen = true;
|
||||
void tick().then(() => {
|
||||
linkInput?.focus();
|
||||
linkInput?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
const href = linkValue.trim();
|
||||
if (href) api.insertLink(href);
|
||||
linkOpen = false;
|
||||
}
|
||||
|
||||
function onLinkKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
applyLink();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
linkOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Words.BubbleMenu side="top">
|
||||
{#snippet children(bubble)}
|
||||
{#if bubble.open}
|
||||
{#if linkOpen}
|
||||
<div data-words-bubble-link>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
bind:value={linkValue}
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="Paste or type a link…"
|
||||
aria-label="Link URL"
|
||||
onkeydown={onLinkKeydown}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
aria-label="Apply link"
|
||||
onpointerdown={(e: PointerEvent) => e.preventDefault()}
|
||||
onclick={applyLink}
|
||||
>
|
||||
{#snippet icon()}<Check />{/snippet}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<DropdownMenu bind:open={turnIntoOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!canTurnInto}
|
||||
data-words-bubble-trigger
|
||||
>
|
||||
{blockLabel}
|
||||
{#snippet endIcon()}<ChevronDown />{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-turn-into>
|
||||
{#each turnInto as item (item.id)}
|
||||
<DropdownMenu.Item onSelect={item.run} data-words-bubble-into-item>
|
||||
<span>{item.label}</span>
|
||||
{#if item.active}<Check size="sm" />{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
|
||||
<span data-words-bubble-divider></span>
|
||||
|
||||
{#each MARKS as mark (mark.command)}
|
||||
{@const Glyph = mark.icon}
|
||||
<Words.CommandButton command={mark.command} aria-label={mark.label}>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="xs" iconOnly>
|
||||
{#snippet icon()}<Glyph />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/each}
|
||||
|
||||
<span data-words-bubble-divider></span>
|
||||
|
||||
<!-- Text + background colour. Each swatch is a `Words.CommandButton`
|
||||
whose `color:#hex` / `bgcolor:#hex` command the provider parses
|
||||
into a parametric `toggleMark` on the live selection — the same
|
||||
range-restoring path bold / italic use. The "Default" chip clears
|
||||
the mark (empty value). The dropdown carries `data-words-bubble-color`
|
||||
so the bubble's focus scope keeps the selection alive. -->
|
||||
<DropdownMenu bind:open={colorOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
aria-label="Text and background colour"
|
||||
data-words-bubble-trigger
|
||||
>
|
||||
{#snippet icon()}<Baseline />{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-color>
|
||||
<div data-words-bubble-color-section>
|
||||
<span data-words-bubble-color-label><Baseline size="sm" />Text</span>
|
||||
<div data-words-bubble-swatches>
|
||||
<Words.CommandButton command="color:" aria-label="Default text colour">
|
||||
{#snippet child({ props })}
|
||||
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{#each COLOR_SWATCHES as hex (hex)}
|
||||
<Words.CommandButton command={`color:${hex}`} aria-label={`Text colour ${hex}`}>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
data-words-bubble-swatch
|
||||
style="--words-swatch: {hex}"
|
||||
></button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div data-words-bubble-color-section>
|
||||
<span data-words-bubble-color-label><Highlighter size="sm" />Background</span>
|
||||
<div data-words-bubble-swatches>
|
||||
<Words.CommandButton command="bgcolor:" aria-label="No background">
|
||||
{#snippet child({ props })}
|
||||
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{#each COLOR_SWATCHES as hex (hex)}
|
||||
<Words.CommandButton command={`bgcolor:${hex}`} aria-label={`Background colour ${hex}`}>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
data-words-bubble-swatch
|
||||
style="--words-swatch: {hex}"
|
||||
></button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
|
||||
<span data-words-bubble-divider></span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
aria-label="Link"
|
||||
data-active={api.selectedLink ? '' : undefined}
|
||||
onpointerdown={(e: PointerEvent) => e.preventDefault()}
|
||||
onclick={openLinkEditor}
|
||||
>
|
||||
{#snippet icon()}<Link />{/snippet}
|
||||
</Button>
|
||||
{#if api.selectedLink}
|
||||
<Words.CommandButton command="unlink" aria-label="Remove link">
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="xs" iconOnly>
|
||||
{#snippet icon()}<Unlink />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Words.BubbleMenu>
|
||||
@ -1,98 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Inspector colour row — labelled `ColorPicker` bound to a block's hex.
|
||||
*
|
||||
* Pattern: local `$state` draft + `bind:value` + `onValueChangeEnd`.
|
||||
* The `$effect` syncs `draft` ← `current` only when their hexes differ
|
||||
* so an external edit propagates in without clobbering a mid-drag draft.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ColorPicker } from '$uix/eidos/components/color-picker';
|
||||
import { PickerShell } from '$uix/eidos/components/picker-shell';
|
||||
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
|
||||
|
||||
let {
|
||||
label,
|
||||
current,
|
||||
presets,
|
||||
onPick
|
||||
}: {
|
||||
label: string;
|
||||
current: string | undefined;
|
||||
presets: readonly string[];
|
||||
onPick: (hex: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
let draft = $state<ColorValue | undefined>(undefined);
|
||||
$effect(() => {
|
||||
// Track ONLY `current` — read draft inside `untrack` so the effect
|
||||
// doesn't re-run when the picker writes to draft mid-drag (the
|
||||
// picker mutates draft via the bind chain on every pointermove).
|
||||
// Without untrack, every drag tick would re-run this effect and
|
||||
// overwrite the user's value with the still-committed `current`
|
||||
// hex, locking the thumb in place.
|
||||
const hexIn = current;
|
||||
untrack(() => {
|
||||
if (!hexIn) {
|
||||
// External cleared (Clear button / programmatic). Mirror
|
||||
// it into draft so the picker shows empty state.
|
||||
if (draft !== undefined) draft = undefined;
|
||||
return;
|
||||
}
|
||||
const hsv = parseColor(hexIn);
|
||||
const next = hsv ? colorValueFromHsv(hsv) : undefined;
|
||||
if (next?.hex !== draft?.hex) draft = next;
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div data-words-inspector-row data-inline>
|
||||
<span data-words-inspector-label>{label}</span>
|
||||
<ColorPicker
|
||||
size="sm"
|
||||
bind:value={draft}
|
||||
placeholder={DEFAULT_COLOR}
|
||||
onValueChange={(cv: ColorValue | undefined) => {
|
||||
// `onValueChange` fires on EVERY value mutation — including
|
||||
// programmatic ones like `Clear` / `Cancel` (which don't fire
|
||||
// `onValueChangeEnd`). Only commit to the engine when the
|
||||
// value transitions to undefined (clear) — for normal value
|
||||
// changes during drag, wait for `onValueChangeEnd` to avoid
|
||||
// per-pointermove engine churn.
|
||||
if (cv === undefined && current !== undefined) onPick(undefined);
|
||||
}}
|
||||
onValueChangeEnd={(cv: ColorValue | undefined) => onPick(cv?.hex)}
|
||||
>
|
||||
<ColorPicker.Control>
|
||||
<ColorPicker.Trigger><ColorPicker.ValueText /></ColorPicker.Trigger>
|
||||
</ColorPicker.Control>
|
||||
<ColorPicker.Portal>
|
||||
<ColorPicker.Content>
|
||||
<PickerShell.Body>
|
||||
<ColorPicker.Area />
|
||||
<ColorPicker.ChannelSlider channel="hue" />
|
||||
<ColorPicker.ChannelSlider channel="alpha" />
|
||||
<!-- Manual hex/RGB editing — default rendering gives a
|
||||
value swatch + segmented inputs + format select.
|
||||
User can type the hex directly (committed on
|
||||
blur / Enter by the soma channel-input). -->
|
||||
<ColorPicker.ChannelInput />
|
||||
<ColorPicker.SwatchGroup>
|
||||
{#each presets as c (c)}
|
||||
<ColorPicker.SwatchTrigger color={c}>
|
||||
<ColorPicker.Swatch color={c} />
|
||||
<ColorPicker.SwatchIndicator />
|
||||
</ColorPicker.SwatchTrigger>
|
||||
{/each}
|
||||
</ColorPicker.SwatchGroup>
|
||||
</PickerShell.Body>
|
||||
<ColorPicker.Footer>
|
||||
<ColorPicker.Clear />
|
||||
<ColorPicker.Cancel />
|
||||
<ColorPicker.Close />
|
||||
</ColorPicker.Footer>
|
||||
</ColorPicker.Content>
|
||||
</ColorPicker.Portal>
|
||||
</ColorPicker>
|
||||
</div>
|
||||
@ -1,311 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Editor menu bar — the persistent formatting toolbar at the top of
|
||||
* `<Words>`.
|
||||
*
|
||||
* Composition mirrors the selection bubble (the canonical consumer
|
||||
* pattern), just docked instead of floating:
|
||||
* - soma `Words.Toolbar` is the role=toolbar container.
|
||||
* - inline marks are soma `Words.CommandButton`s rendered through their
|
||||
* `child` snippet as eidos `Button`s — the CommandButton restores the
|
||||
* DOM range before the command runs and reflects active/disabled state.
|
||||
* - block transforms (turn-into, lists) go through `api.applyCommand`,
|
||||
* with active state derived from the current block (same `effectiveBlock`
|
||||
* descent the bubble uses, so it's correct inside columns).
|
||||
* - history / insert / view controls are plain eidos `Button`s wired to
|
||||
* the `api`.
|
||||
*
|
||||
* View controls on the right: an Edit/Preview toggle (binds `mode`, which
|
||||
* the parent maps to the provider's `readonly`) and an inspector toggle.
|
||||
*/
|
||||
import * as Words from '$soma/components/words';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
ChevronDown,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Code,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextAlignStart,
|
||||
TextAlignCenter,
|
||||
TextAlignEnd,
|
||||
Image,
|
||||
Table,
|
||||
Minus,
|
||||
Plus,
|
||||
Check,
|
||||
SlidersHorizontal
|
||||
} from '$uix/eidos/components/icon';
|
||||
import type {
|
||||
ProviderSnippetProps,
|
||||
WordsBlock,
|
||||
WordsHeadingLevel,
|
||||
WordsListKind
|
||||
} from '$soma/components/words';
|
||||
import type { WordsInspectorMode } from './types';
|
||||
|
||||
let {
|
||||
api,
|
||||
mode = $bindable('edit'),
|
||||
inspector,
|
||||
inspectorOpen,
|
||||
onToggleInspector
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
mode: 'edit' | 'preview';
|
||||
inspector: WordsInspectorMode;
|
||||
inspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
} = $props();
|
||||
|
||||
const preview = $derived(mode === 'preview');
|
||||
|
||||
// Convertible blocks — turn-into / lists no-op on table / image / divider /
|
||||
// callout, so disable those controls there instead of offering inert ops.
|
||||
const CONVERTIBLE = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
|
||||
|
||||
// Effective block under the caret. When inside a column the top-level
|
||||
// `api.currentBlock` is `'columns'`, but the user edits the NESTED block —
|
||||
// descend the selection path to resolve the one turn-into can convert
|
||||
// (same logic as the bubble; the engine's setBlock is columns-recursive).
|
||||
const block = $derived.by<{ type: string; level?: WordsHeadingLevel; kind?: WordsListKind }>(
|
||||
() => {
|
||||
const path = api.selection?.anchor.path;
|
||||
if (path && path.length > 0) {
|
||||
const top = api.document.children[path[0]] as WordsBlock | undefined;
|
||||
if (top?.type === 'columns' && path.length >= 3) {
|
||||
const inner = top.columns[path[1] ?? 0]?.children[path[2] ?? 0];
|
||||
if (inner) {
|
||||
return {
|
||||
type: inner.type,
|
||||
level: inner.type === 'heading' ? inner.level : undefined,
|
||||
kind: inner.type === 'list' ? inner.kind : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: api.currentBlock,
|
||||
level: api.currentHeadingLevel,
|
||||
kind: top?.type === 'list' ? top.kind : undefined
|
||||
};
|
||||
}
|
||||
return { type: api.currentBlock, level: api.currentHeadingLevel };
|
||||
}
|
||||
);
|
||||
const canTurnInto = $derived(CONVERTIBLE.has(block.type));
|
||||
|
||||
const blockLabel = $derived.by(() => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return `Heading ${block.level ?? 1}`;
|
||||
case 'quote':
|
||||
return 'Quote';
|
||||
case 'code':
|
||||
return 'Code';
|
||||
case 'list':
|
||||
return block.kind === 'ordered'
|
||||
? 'Numbered list'
|
||||
: block.kind === 'check'
|
||||
? 'Check list'
|
||||
: 'Bulleted list';
|
||||
case 'paragraph':
|
||||
return 'Text';
|
||||
default:
|
||||
return blockLabelFallback(block.type);
|
||||
}
|
||||
});
|
||||
function blockLabelFallback(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
const TURN_INTO = $derived.by(() => [
|
||||
{ id: 'paragraph', label: 'Text', active: block.type === 'paragraph', run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' }) },
|
||||
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
|
||||
id: `heading-${level}`,
|
||||
label: `Heading ${level}`,
|
||||
active: block.type === 'heading' && (block.level ?? 1) === level,
|
||||
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
|
||||
})),
|
||||
{ id: 'quote', label: 'Quote', active: block.type === 'quote', run: () => api.applyCommand({ type: 'setBlock', block: 'quote' }) },
|
||||
{ id: 'code', label: 'Code', active: block.type === 'code', run: () => api.applyCommand({ type: 'setBlock', block: 'code' }) }
|
||||
]);
|
||||
|
||||
const MARKS = [
|
||||
{ command: 'bold', label: 'Bold', icon: Bold },
|
||||
{ command: 'italic', label: 'Italic', icon: Italic },
|
||||
{ command: 'underline', label: 'Underline', icon: Underline },
|
||||
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
|
||||
{ command: 'code', label: 'Inline code', icon: Code }
|
||||
] as const;
|
||||
|
||||
const LISTS = $derived.by(() => [
|
||||
{ kind: 'unordered' as WordsListKind, label: 'Bulleted list', icon: List },
|
||||
{ kind: 'ordered' as WordsListKind, label: 'Numbered list', icon: ListOrdered },
|
||||
{ kind: 'check' as WordsListKind, label: 'Check list', icon: ListChecks }
|
||||
]);
|
||||
|
||||
const ALIGN = [
|
||||
{ command: 'align-left', label: 'Align left', icon: TextAlignStart },
|
||||
{ command: 'align-center', label: 'Align center', icon: TextAlignCenter },
|
||||
{ command: 'align-right', label: 'Align right', icon: TextAlignEnd }
|
||||
] as const;
|
||||
|
||||
const INSERTS = [
|
||||
{ id: 'image', label: 'Image', icon: Image },
|
||||
{ id: 'table', label: 'Table', icon: Table },
|
||||
{ id: 'divider', label: 'Divider', icon: Minus }
|
||||
] as const;
|
||||
|
||||
let insertOpen = $state(false);
|
||||
let blockOpen = $state(false);
|
||||
|
||||
const showInspectorToggle = $derived(inspector === 'drawer' || inspector === 'sidebar');
|
||||
</script>
|
||||
|
||||
<Words.Toolbar data-words-menubar data-mode={mode}>
|
||||
<!-- History -->
|
||||
<div data-words-menubar-group>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label="Undo"
|
||||
disabled={preview || !api.canUndo}
|
||||
onclick={() => api.undo()}
|
||||
>
|
||||
{#snippet icon()}<Undo2 />{/snippet}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label="Redo"
|
||||
disabled={preview || !api.canRedo}
|
||||
onclick={() => api.redo()}
|
||||
>
|
||||
{#snippet icon()}<Redo2 />{/snippet}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Block style -->
|
||||
<div data-words-menubar-group>
|
||||
<DropdownMenu bind:open={blockOpen}>
|
||||
<DropdownMenu.Trigger variant="ghost" size="xs" disabled={preview || !canTurnInto}>
|
||||
{blockLabel}
|
||||
{#snippet endIcon()}<ChevronDown />{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start">
|
||||
{#each TURN_INTO as item (item.id)}
|
||||
<DropdownMenu.Item onSelect={item.run}>
|
||||
<span>{item.label}</span>
|
||||
{#if item.active}<Check size="sm" />{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- Inline marks -->
|
||||
<div data-words-menubar-group>
|
||||
{#each MARKS as mark (mark.command)}
|
||||
{@const Glyph = mark.icon}
|
||||
<Words.CommandButton command={mark.command} aria-label={mark.label}>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
|
||||
{#snippet icon()}<Glyph />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Lists -->
|
||||
<div data-words-menubar-group>
|
||||
{#each LISTS as list (list.kind)}
|
||||
{@const Glyph = list.icon}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label={list.label}
|
||||
data-active={block.type === 'list' && block.kind === list.kind ? '' : undefined}
|
||||
disabled={preview}
|
||||
onclick={() => api.applyCommand({ type: 'toggleList', kind: list.kind })}
|
||||
>
|
||||
{#snippet icon()}<Glyph />{/snippet}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Align -->
|
||||
<div data-words-menubar-group>
|
||||
{#each ALIGN as a (a.command)}
|
||||
{@const Glyph = a.icon}
|
||||
<Words.CommandButton command={a.command} aria-label={a.label}>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
|
||||
{#snippet icon()}<Glyph />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Words.CommandButton>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Insert -->
|
||||
<div data-words-menubar-group>
|
||||
<DropdownMenu bind:open={insertOpen}>
|
||||
<DropdownMenu.Trigger variant="ghost" size="xs" iconOnly rounded="md" aria-label="Insert" disabled={preview}>
|
||||
{#snippet icon()}<Plus />{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content side="bottom" align="start">
|
||||
{#each INSERTS as ins (ins.id)}
|
||||
{@const Glyph = ins.icon}
|
||||
<DropdownMenu.Item onSelect={() => api.insertBlockOfType(ins.id)}>
|
||||
<Glyph size="sm" />
|
||||
<span>{ins.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<span data-words-menubar-spacer></span>
|
||||
|
||||
<!-- View controls -->
|
||||
{#if showInspectorToggle}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label="Toggle inspector"
|
||||
data-active={inspectorOpen ? '' : undefined}
|
||||
onclick={onToggleInspector}
|
||||
>
|
||||
{#snippet icon()}<SlidersHorizontal />{/snippet}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<ToggleGroup
|
||||
size="xs"
|
||||
value={[mode]}
|
||||
onValueChange={(v: string[]) => {
|
||||
if (v[0] === 'edit' || v[0] === 'preview') mode = v[0];
|
||||
}}
|
||||
aria-label="Edit or preview"
|
||||
>
|
||||
<ToggleGroup.Item value="edit">Edit</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="preview">Preview</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Words.Toolbar>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,182 +0,0 @@
|
||||
<script lang="ts">
|
||||
import './words.css';
|
||||
/**
|
||||
* Eidos `<Words>` — visual wrapper over the headless Words editor.
|
||||
*
|
||||
* The soma `Words.Provider` owns the document model, selection,
|
||||
* contenteditable wiring and the `data-words*` attributes the morfo
|
||||
* runtime emits. Eidos owns the look: the contenteditable surface +
|
||||
* placeholder, the left block gutter, the selection bubble, and the
|
||||
* block inspector.
|
||||
*
|
||||
* The inspector's presentation is a prop (`inspector`): a fixed sidebar
|
||||
* (builder-style, always visible), a slide-in drawer, a popover, or
|
||||
* none. The same `<WordsInspector>` body is reused across all of them.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as Words from '$soma/components/words';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Popover } from '$uix/eidos/components/popover';
|
||||
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
|
||||
import WordsActivate from './words-activate.svelte';
|
||||
import WordsActiveRail from './words-active-rail.svelte';
|
||||
import WordsBlockDrag from './words-block-drag.svelte';
|
||||
import WordsBlockGutter from './words-block-gutter.svelte';
|
||||
import WordsBubble from './words-bubble.svelte';
|
||||
import WordsInspector from './words-inspector.svelte';
|
||||
import WordsMenubar from './words-menubar.svelte';
|
||||
import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
|
||||
import { WORDS_MENU_BUNDLE } from './langs-menu';
|
||||
import type { WordsProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
value = $bindable(),
|
||||
selection = $bindable(null),
|
||||
inspector = 'sidebar',
|
||||
toolbar = true,
|
||||
...headlessProps
|
||||
}: WordsProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const dom = eidos.dom;
|
||||
|
||||
// Rich-text contenteditable is inherently client-only: the engine
|
||||
// stamps content ids with `crypto.randomUUID()`, so an SSR pass and the
|
||||
// client pass emit different markup and hydration bails. Defer the
|
||||
// editor to the client; SSR + first paint render the empty frame.
|
||||
let mounted = $state(false);
|
||||
// Client-only activation + one-time langs registration. `langs.extend` BOTH
|
||||
// reads and mutates the reactive langs schema, so it MUST run untracked —
|
||||
// otherwise this $effect tracks that read, the mutation retriggers it, and it
|
||||
// loops, re-extending the bundle thousands of times (each logging a DEV
|
||||
// leaf-overwrite warning). untrack ⇒ the effect has no deps ⇒ runs once on
|
||||
// mount. Registers the inspector's localized strings so labels localize
|
||||
// instead of falling back to the inline English.
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
mounted = true;
|
||||
eidos.langs.extend('components.words.inspector', WORDS_INSPECTOR_BUNDLE);
|
||||
eidos.langs.extend('components.words.menu', WORDS_MENU_BUNDLE);
|
||||
});
|
||||
});
|
||||
|
||||
let contentEl = $state<HTMLDivElement | null>(null);
|
||||
let drawerOpen = $state(false);
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
// Edit vs preview. Preview maps to the provider's `readonly` — the surface
|
||||
// becomes non-editable (contenteditable=false) and the editing chrome
|
||||
// (gutter / drag / bubble) is suppressed; the menu bar stays visible so the
|
||||
// toggle is reachable. The menu bar owns the toggle via `bind:mode`.
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
|
||||
// One "is the inspector open" concept the menu bar's toggle drives:
|
||||
// the drawer's open state, or the sidebar's visibility.
|
||||
const inspectorOpen = $derived(
|
||||
inspector === 'drawer' ? drawerOpen : inspector === 'sidebar' ? sidebarOpen : false
|
||||
);
|
||||
function toggleInspector() {
|
||||
if (inspector === 'drawer') drawerOpen = !drawerOpen;
|
||||
else if (inspector === 'sidebar') sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mounted}
|
||||
<Words.Provider
|
||||
{...headlessProps}
|
||||
bind:value
|
||||
bind:selection
|
||||
readonly={mode === 'preview'}
|
||||
data-size={resolvedSize}
|
||||
data-inspector={inspector}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#snippet children(api)}
|
||||
{#if toolbar}
|
||||
<WordsMenubar
|
||||
{api}
|
||||
bind:mode
|
||||
{inspector}
|
||||
{inspectorOpen}
|
||||
onToggleInspector={toggleInspector}
|
||||
/>
|
||||
{/if}
|
||||
<Words.Content bind:ref={contentEl} />
|
||||
<Words.Placeholder />
|
||||
<!-- Editing chrome (bubble / gutter / drag) is edit-only: preview is
|
||||
read-only, so these affordances are suppressed while the menu bar
|
||||
stays visible to toggle back. -->
|
||||
{#if mode === 'edit'}
|
||||
<WordsBubble {api} />
|
||||
{/if}
|
||||
{#if contentEl}
|
||||
<WordsActivate {api} content={contentEl} {dom} enabled={mode === 'edit'} />
|
||||
{#if mode === 'edit'}
|
||||
<WordsActiveRail {api} content={contentEl} {dom} />
|
||||
<WordsBlockGutter {api} content={contentEl} {dom} />
|
||||
<WordsBlockDrag {api} content={contentEl} {dom} />
|
||||
{/if}
|
||||
<!-- Column-content insertion lives in the Inspector's Columns
|
||||
panel ("Añadir bloque" dropdown per column), React-Bricks-
|
||||
style. Floating in-canvas overlay was removed: its
|
||||
pointer-events:none required for caret pass-through ALSO
|
||||
disabled `:hover` reveal, and any visible variant covered
|
||||
the column's auto-trailing paragraph, hijacking clicks
|
||||
meant for typing. Sidebar-driven insertion sidesteps both. -->
|
||||
{/if}
|
||||
|
||||
{#if inspector === 'sidebar' && sidebarOpen}
|
||||
<aside data-words-sidebar>
|
||||
<WordsInspector {api} />
|
||||
</aside>
|
||||
{:else if inspector === 'drawer'}
|
||||
<!-- The menu bar's inspector toggle opens/closes the drawer; the
|
||||
open panel also carries its own X close in the header. -->
|
||||
<aside data-words-drawer-panel data-open={drawerOpen ? '' : undefined}>
|
||||
<header data-words-drawer-panel-head>
|
||||
<span>Block</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
aria-label="Close settings"
|
||||
onclick={() => (drawerOpen = false)}
|
||||
>
|
||||
{#snippet icon()}<X />{/snippet}
|
||||
</Button>
|
||||
</header>
|
||||
<WordsInspector {api} />
|
||||
</aside>
|
||||
{:else if inspector === 'popover'}
|
||||
<div data-words-popover-anchor>
|
||||
<Popover>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props }: { props: Record<string, unknown> })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="md"
|
||||
aria-label="Block settings"
|
||||
>
|
||||
{#snippet icon()}<SlidersHorizontal />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content data-words-inspector-popover side="bottom" align="end">
|
||||
<WordsInspector {api} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Words.Provider>
|
||||
{:else}
|
||||
<div data-words data-size={resolvedSize} data-inspector={inspector} aria-busy="true"></div>
|
||||
{/if}
|
||||
@ -1,364 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Words editor — F2 foundation: the editor surface renders + edits on
|
||||
// the new base-`Block` model. Chrome (gutters / bubble toolbar /
|
||||
// settings drawer) lands in F3.
|
||||
import Words from '$uix/eidos/components/words';
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import { FileText, Check } from '$uix/eidos/components/icon';
|
||||
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
|
||||
import type {
|
||||
WordsDocument,
|
||||
WordsImageUploadResult,
|
||||
WordsOnUploadImage
|
||||
} from '$soma/components/words';
|
||||
|
||||
// Local-only image upload — turns the dropped/pasted File into a
|
||||
// `data:` URL via FileReader. Lets the demo accept drag-drop and
|
||||
// paste of images without a backend. Real apps would POST the file
|
||||
// to their storage and resolve with the returned URL instead.
|
||||
const handleUploadImage: WordsOnUploadImage = (file: File) =>
|
||||
new Promise<WordsImageUploadResult>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
|
||||
reader.onload = () => {
|
||||
const url = typeof reader.result === 'string' ? reader.result : '';
|
||||
if (!url) {
|
||||
reject(new Error('Empty data URL'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
url,
|
||||
// Use the filename (sans extension) as a default alt — the
|
||||
// inspector lets the user refine it afterwards.
|
||||
alt: file.name.replace(/\.[^/.]+$/, '') || undefined
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const INSPECTOR_MODES: readonly { v: WordsInspectorMode; label: string }[] = [
|
||||
{ v: 'none', label: 'None' },
|
||||
{ v: 'sidebar', label: 'Sidebar' },
|
||||
{ v: 'drawer', label: 'Drawer' },
|
||||
{ v: 'popover', label: 'Popover' }
|
||||
];
|
||||
let inspector = $state<WordsInspectorMode>('sidebar');
|
||||
|
||||
let value = $state<WordsDocument>({
|
||||
version: '2.0.0',
|
||||
children: [
|
||||
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Words' }] },
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{ type: 'text', text: 'A clean editor rebuilt on a base ' },
|
||||
{ type: 'text', text: 'Block', marks: ['code'] },
|
||||
{
|
||||
type: 'text',
|
||||
text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and '
|
||||
},
|
||||
{ type: 'text', text: 'extended', marks: ['italic'] },
|
||||
{ type: 'text', text: ' per type.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{ type: 'text', text: 'Inline marks: ' },
|
||||
{ type: 'text', text: 'bold', marks: ['bold'] },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'text', text: 'italic', marks: ['italic'] },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'text', text: 'underline', marks: ['underline'] },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'text', text: 'strike', marks: ['strike'] },
|
||||
{ type: 'text', text: ', and a ' },
|
||||
{
|
||||
type: 'link',
|
||||
href: 'https://tiptap.dev',
|
||||
target: '_blank',
|
||||
children: [{ type: 'text', text: 'link' }]
|
||||
},
|
||||
{ type: 'text', text: '.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
children: [{ type: 'text', text: 'Quote & code' }]
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Design is not just what it looks like and feels like. Design is how it works.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'code',
|
||||
language: 'ts',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
children: [{ type: 'text', text: 'Lists' }]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
kind: 'unordered',
|
||||
items: [
|
||||
{ children: [{ type: 'text', text: 'Bulleted item one' }] },
|
||||
{ children: [{ type: 'text', text: 'Bulleted item two' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
kind: 'check',
|
||||
items: [
|
||||
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] },
|
||||
{
|
||||
checked: false,
|
||||
children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
intent: 'affirm',
|
||||
title: 'Foundation',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This surface renders directly from the model. Editing round-trips through the engine.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 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,
|
||||
children: [{ type: 'text', text: 'Table' }]
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
headerRow: true,
|
||||
rows: [
|
||||
{
|
||||
cells: [
|
||||
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Phase' }] }] },
|
||||
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Status' }] }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
cells: [
|
||||
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Engine' }] }] },
|
||||
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Done' }] }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
cells: [
|
||||
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Visual' }] }] },
|
||||
{
|
||||
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'In progress' }] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// ── Topbar state ──────────────────────────────────────────────────────
|
||||
// Edit / Preview now lives INSIDE the editor's menu bar (the eidos
|
||||
// `<Words>` owns the read-only `preview` mode), so the demo no longer
|
||||
// toggles it here — it just keeps the breadcrumb + Saved + inspector picker.
|
||||
|
||||
// Breadcrumb title — the first heading's text, else a placeholder.
|
||||
const docTitle = $derived.by(() => {
|
||||
for (const b of value.children) {
|
||||
if (b.type === 'heading') {
|
||||
const text = b.children
|
||||
.map((c) => (c.type === 'text' ? c.text : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return 'Untitled document';
|
||||
});
|
||||
|
||||
// "Saved" indicator — flips to "Editing…" on any document change, settles
|
||||
// back to "Saved" after a short idle. Demo-only chrome (a real app would
|
||||
// debounce a network save here).
|
||||
let saveState = $state<'saved' | 'editing'>('saved');
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let trackedInitial = false;
|
||||
$effect(() => {
|
||||
void value; // re-run whenever the bound document changes
|
||||
if (!trackedInitial) {
|
||||
trackedInitial = true;
|
||||
return;
|
||||
}
|
||||
saveState = 'editing';
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => (saveState = 'saved'), 600);
|
||||
return () => clearTimeout(saveTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<p data-uix-eyebrow>— editors · words</p>
|
||||
<h1>Words</h1>
|
||||
<p>
|
||||
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface renders
|
||||
and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="demo-topbar">
|
||||
<div class="demo-doc">
|
||||
<FileText size="sm" />
|
||||
<span class="demo-doc-title">{docTitle}</span>
|
||||
<span class="demo-save" data-state={saveState}>
|
||||
{#if saveState === 'saved'}<Check size="sm" />Saved{:else}Editing…{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="demo-topbar-actions">
|
||||
<span class="demo-controls-label">Inspector</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
attached
|
||||
value={[inspector]}
|
||||
onValueChange={(v: string[]) => {
|
||||
if (v[0]) inspector = v[0] as WordsInspectorMode;
|
||||
}}
|
||||
aria-label="Inspector mode"
|
||||
>
|
||||
{#each INSPECTOR_MODES as m (m.v)}
|
||||
<ToggleGroup.Item value={m.v}>{m.label}</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-canvas">
|
||||
<!-- Edit / Preview lives in the editor's own menu bar now (read-only
|
||||
preview). The demo just hosts the live editor. -->
|
||||
<Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
|
||||
</div>
|
||||
|
||||
<details class="demo-trace">
|
||||
<summary>document value</summary>
|
||||
<pre>{JSON.stringify(value, null, 2)}</pre>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.demo-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
/* Theme-aware surface — `--color-surface-sunken` does not exist, so
|
||||
the old fallback painted a light panel on the dark theme. */
|
||||
background: var(--color-surface-default);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.demo-canvas :global([data-words]) {
|
||||
inline-size: 100%;
|
||||
max-inline-size: 62rem;
|
||||
}
|
||||
.demo-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
.demo-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
.demo-doc-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-content-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.demo-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-inline-start: var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.demo-save[data-state='saved'] {
|
||||
color: var(--color-affirm-text, var(--color-content-secondary));
|
||||
}
|
||||
.demo-save[data-state='editing'] {
|
||||
color: var(--color-content-muted);
|
||||
}
|
||||
.demo-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.demo-controls-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
.demo-trace {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-content-secondary);
|
||||
}
|
||||
.demo-trace summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.demo-trace pre {
|
||||
max-block-size: 24rem;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -1,58 +0,0 @@
|
||||
# Words Demo
|
||||
|
||||
La demo publica vive en `web/routes/uix/components/words/+page.svelte` y sigue
|
||||
la plantilla bloqueada de UIX:
|
||||
|
||||
- Live, API, Morfo, Sema, Recipe y A11y.
|
||||
- Stage permanente entre header y tabs.
|
||||
- `MutationObserver` sobre `data-event`.
|
||||
- Snippets Soma y Eidos reactivos.
|
||||
- Controles visibles para documento, toolbar, estados Soma, size, variant y
|
||||
color.
|
||||
- Toolbar Eidos agrupado por familias/submenus en presets `minimal`,
|
||||
`formatting`, `full` y `custom`.
|
||||
- Import/export visible para JSON, texto, HTML limpio/import seguro y Markdown
|
||||
con listas indentadas, find/replace regex/preserve-case/current-match/selection,
|
||||
code blocks con highlighting runtime y tablas con formato de celda/tabla.
|
||||
- API documentada para leer/actualizar `language` de code blocks desde snippets
|
||||
(`currentCodeLanguage` / `setCodeLanguage`).
|
||||
- Selector visual `Words.CodeLanguagePicker` dentro de los presets de toolbar
|
||||
para cambiar el lenguaje del bloque `code` seleccionado.
|
||||
- Tokens de syntax highlighting visibles desde `data-words-code-token` sin
|
||||
contaminar el export HTML limpio.
|
||||
- `SlashMenu` opcional visible en snippets Soma/Eidos: `/query` abre comandos
|
||||
de bloque/lista/tabla anclados al caret y navegables por teclado.
|
||||
- Tablas editables visibles en el preset `sample`, con toolbar y slash command
|
||||
para insertar una tabla 2x2 canonica, botones de fila/columna, navegacion
|
||||
por celdas con `Tab`/`Shift+Tab` y formato desde el submenu `Table`: header
|
||||
row/column, alineacion horizontal, vertical align, tonos de celda, striped
|
||||
rows y compact table. La demo tambien verifica formato inline dentro de
|
||||
celdas desde el submenu de texto. El submenu `Table` se renderiza por
|
||||
secciones rotuladas y con ancho acotado para evitar una matriz opaca de
|
||||
iconos.
|
||||
- Checklist interactiva: el marcador de cada item se puede clicar y
|
||||
`Ctrl/Cmd+Enter` alterna el item actual.
|
||||
- El snippet Soma refleja la misma capacidad con un `<select>` manual usando
|
||||
`currentCodeLanguage` y `setCodeLanguage`, porque el picker visual pertenece a
|
||||
Eidos.
|
||||
- `BubbleMenu` contextual visible cuando hay seleccion de rango, compuesto con
|
||||
los mismos `CommandButton` y `LinkEditor` del toolbar principal.
|
||||
- Sema tab con reproduccion de los eventos Morfo sobre el stage real.
|
||||
|
||||
## Cobertura Live
|
||||
|
||||
| Area | Controles |
|
||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Documento | `sample`, `empty`, `long` |
|
||||
| Soma | placeholder, toolbar, orientation, bubble menu, slash menu, import/export, disabled, readonly, required, invalid, commitOnBlur, status |
|
||||
| Eidos | `size: sm/md/lg`, `variant: surface/outline/ghost`, `color: full ColorRole` |
|
||||
| Estado visible | blocks, words, selection, export, import, last commit |
|
||||
|
||||
## Notas
|
||||
|
||||
- No hay `data-*` inventados por la demo para pintar el editor; la receta lee
|
||||
los attrs de Morfo/Soma.
|
||||
- La seleccion se muestra como `none`, `collapsed` o `range`.
|
||||
- El cambio de preset/import remonta el editor solo como reset explicito de la
|
||||
demo; Soma ya sincroniza cambios externos de `value` y `selection` sin
|
||||
depender de remount.
|
||||
Loading…
Reference in new issue