From e6eee766eefa5f775e7363eaaae41e6804c63706 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 03:42:53 +0200 Subject: [PATCH] Improve words editor tooling --- src/uix/eidos/components/words/README.md | 78 ++- src/uix/eidos/components/words/continue.md | 178 +++++++ src/uix/eidos/components/words/index.ts | 7 + src/uix/eidos/components/words/types.ts | 36 +- .../words/words-command-button.svelte | 66 ++- .../words/words-find-replace.svelte | 149 ++++++ .../words/words-heading-picker.svelte | 53 +- .../components/words/words-link-editor.svelte | 40 +- .../words/words-tool-popover.svelte | 117 ----- .../words/words-toolbar-group.svelte | 17 +- .../components/words/words-toolbar.svelte | 29 +- src/uix/eidos/components/words/words.css | 327 ++++++++++-- src/uix/eidos/components/words/words.svelte | 139 +++-- src/uix/eidos/eidos-motion.md | 483 ++++++++++++++++++ src/uix/eidos/generated/base.css | 3 + src/uix/eidos/lib/recipes/base.ts | 3 + src/uix/morfo/components/words.ts | 43 +- .../components/words-command-button.svelte | 7 +- .../components/words-find-replace.svelte | 39 ++ .../soma/components/words/engine/commands.ts | 30 ++ .../soma/components/words/engine/document.ts | 11 +- .../components/words/engine/dom-selection.ts | 75 ++- .../components/words/engine/engine.test.ts | 249 ++++++++- .../components/words/engine/find-replace.ts | 91 ++++ src/uix/soma/components/words/engine/index.ts | 1 + .../soma/components/words/engine/normalize.ts | 7 +- .../components/words/engine/operations.ts | 50 ++ .../soma/components/words/engine/render.ts | 135 ++++- .../components/words/engine/serialize-html.ts | 17 +- .../words/engine/serialize-markdown.ts | 29 +- src/uix/soma/components/words/exports.ts | 8 +- src/uix/soma/components/words/types.ts | 37 ++ .../words/words-content.svelte.test.ts | 4 +- .../words/words-provider.svelte.test.ts | 117 +++++ .../components/words/words-provider.svelte.ts | 249 ++++++++- web/routes/uix/components/words/+page.svelte | 248 ++++++--- 36 files changed, 2752 insertions(+), 420 deletions(-) create mode 100644 src/uix/eidos/components/words/continue.md create mode 100644 src/uix/eidos/components/words/words-find-replace.svelte delete mode 100644 src/uix/eidos/components/words/words-tool-popover.svelte create mode 100644 src/uix/eidos/eidos-motion.md create mode 100644 src/uix/soma/components/words/components/words-find-replace.svelte create mode 100644 src/uix/soma/components/words/engine/find-replace.ts diff --git a/src/uix/eidos/components/words/README.md b/src/uix/eidos/components/words/README.md index 3ffe49963..02a7869bf 100644 --- a/src/uix/eidos/components/words/README.md +++ b/src/uix/eidos/components/words/README.md @@ -24,6 +24,7 @@ rich-text ni wrapper visual reutilizable. | Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | | Import/export HTML | not owned | available | available | clean export; defer importer | partial | | Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` | implement | +| Find/replace | search landmark + live status | plugin panel | extension command UI | `Words.FindReplace` | implement | | Bubble menu | contextual toolbar pattern | plugins | extension UI | `Words.BubbleMenu` anchored to text selection | implement | | Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer | @@ -74,11 +75,12 @@ rich-text ni wrapper visual reutilizable. | Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment | | Commands | `Words.CommandButton` | active/disabled/commit styling | | Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset | -| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels | -| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback | -| Form | `Words.HiddenInput` | passthrough hidden JSON input | -| Status | `Words.Status` | idle/error status recipe | -| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command | +| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels | +| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback | +| Find/replace | `Words.FindReplace` | default search/replacement panel + match highlights | +| Form | `Words.HiddenInput` | passthrough hidden JSON input | +| Status | `Words.Status` | idle/error status recipe | +| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command | ## Decisiones @@ -97,6 +99,10 @@ rich-text ni wrapper visual reutilizable. - `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica `insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos ofrece una UI por defecto que puede reemplazarse con snippet. +- `Words.FindReplace` es una parte compuesta real y opcional: Soma mantiene la + query, los matches y la seleccion activa; Morfo declara `find-replace` y el + evento canonico `commit-set-replace`; Eidos lo presenta como popover centrado + con flecha y pinta los highlights emitidos por el motor. - `Words.BubbleMenu` es una parte publica compuesta y **opcional**: no se incluye por defecto en ``; el consumidor debe agregarla explicitamente como `` dentro del snippet `children`. Solo aparece para @@ -142,20 +148,56 @@ Features implementadas en esta sesion: - **Eliminacion de `ToolPopover`**: componente borrado, `LinkEditor` migrado a `Popover` nativo de Eidos. Limpieza de types, CSS (~150 lineas) y README. - **Word boundary robusto**: algoritmo naive reemplazado por `Intl.Segmenter` + fallback para puntuacion/CJK. -Tests: -- Engine: 42/42 pasan (incluye 5 nuevos de `setTextAlign`). -- Build: pasa (`npm run build` exitoso). -- Nota: `words-content.svelte.test.ts` tiene 2 fallos pre-existentes de montaje Svelte en entorno jsdom (no relacionados con alignment). +### Fase 0 — Deuda tecnica cerrada (2026-05-26) + +- **Contrato CSS**: `--words-selection-color` ahora se consume correctamente en `words.css`; test `recipe-css-contract` verde. +- **Traducciones**: normalizadas claves `bubbleMenu` y `linkEditor` a camelCase en Morfo, Soma y catalogo de idiomas; `translations:check` verde. +- **Tests de montaje**: corregidos 2 fallos en `words-content.svelte.test.ts` (selector `data-testid` cambiado a `data-words-content`). + +### Fase 1 — Features V1.1 (2026-05-26) + +- **Atajos de teclado para alignment**: `Ctrl/Cmd+L/R/E/J` mapeados a `align-left/center/right/justify` en `words-provider.svelte.ts`. +- **Toolbar tooltips**: `title` derivado de `aria-label` en `words-command-button.svelte`. +- **HeadingPicker keyboard navigation**: `ArrowUp/Down` ciclan foco, `Home/End` van a extremos, `Escape` cierra dropdown. +- **Indentacion de listas**: nueva propiedad `indent?: number` en `WordsListItem` (0-8). `Tab`/`Shift+Tab` dentro de listas ajusta indent. Comandos `increase-indent`/`decrease-indent` en preset `full`. Render via `data-words-indent` + CSS. +- **Seleccion de bloque entero**: triple-click (`e.detail === 3`) en `Words.Content` selecciona todo el bloque via `selectBlock()`. +- **Soft breaks**: `Shift+Enter` interceptado en `onkeydown` y aplica `insertLineBreak` (`\n` dentro del mismo bloque). + +### Fase 2 — V1.1 formal (2026-05-26) -## Next steps (para continuar manana) +- **Sincronizacion de `value` externo sin remount**: validado; demo elimino `{#key documentRevision}` y el Provider sincroniza via `syncExternalState` sin perder foco ni estado interno. +- **Bubble menu scroll/resize**: ya estaba implementado con `` en `words-bubble-menu.svelte`; verificado en demo. +- **HTML import/export avanzado**: serializa `indent` como `style="padding-left: N*1.5em"` en `
  • `; parser extrae el nivel via regex y reconstruye `WordsListItem.indent`. Test verde. +- **Markdown import/export avanzado**: serializa `indent` como prefijo de 2 espacios por nivel; parser detecta espacios iniciales y asigna `indent` a cada item. Test verde. -| Feature | Donde tocar | Complejidad | Bloqueos | -| ------- | ----------- | ----------- | -------- | -| **Indentacion de listas** (increase/decrease indent) | `operations.ts` (nueva operacion), `commands.ts`, `types.ts` (nuevos `CommandName`), toolbar UI | Media | Ninguno | -| **Atajos de teclado para alignment** (Ctrl/Cmd+L/R/E/J) | `words-provider.svelte.ts` (`onkeydown`), `words-content.svelte` (event forwarding) | Baja | Ninguno | -| **HeadingPicker keyboard navigation** (arrow keys + Enter en dropdown) | `words-heading-picker.svelte` | Baja | Ninguno | -| **Toolbar tooltips** (aria-label como tooltip nativo o title) | `words.svelte` + CSS | Baja | Ninguno | -| **Seleccion de bloque entero** (triple-click o margin-click) | `words-provider.svelte.ts` + `operations.ts` | Media | Ninguno | -| **Soft breaks** (Shift+Enter dentro de paragraph sin crear nuevo bloque) | `operations.ts` (nuevo `insertSoftBreak`), `input.ts` | Media | Disenar: como representar `
    ` en el modelo JSON | +### Fase 3 — Find/replace cerrado (2026-05-26) + +- **Find/replace transversal**: `FindReplace` existe en Morfo, Soma y Eidos. + El panel usa `role="search"`, propaga `disabled`/`readonly`, muestra contador + live, navega matches moviendo la seleccion real y ejecuta replace-all con el + evento `commit-set-replace`. +- **Toolbar config segura**: el `toolbar` de Eidos permite `find-replace` como + parte opt-in dentro del `ToolbarGroup`; el item abre un popover centrado con + flecha en lugar de insertar una banda fija bajo la barra. Los comandos + `find-text`/`replace-all-text` quedan reservados para Soma/snippets y no se + exponen como botones visuales mudos. +- **Demo visible**: el demo permite activar/desactivar `FindReplace`, refleja la + parte en snippets Soma/Eidos y documenta los selectors de match. + +Tests: + +- Engine: 47/47 pasan (incluye 5 nuevos: 3 indentacion, 2 markdown/html indent). +- Provider: 21/21 pasan. +- Content: 2/2 pasan. +- CSS Contract: 6/6 pasan. +- Build: pasa (`npm run build` exitoso). +- Translations: 0 errores, 0 warnings. +## Next steps (V2) +| Feature | Donde tocar | Complejidad | Bloqueos | +| ----------------------- | ------------------------------------------ | ----------- | -------- | +| **Imagenes y archivos** | Servicios ActiveApp/ActiveUIX | Alta | Defer V2 | +| **Tablas** | Modelo de nodo + render + operaciones | Alta | Defer V2 | +| **Slash commands** | Autocomplete + comandos de insercion | Alta | Defer V2 | +| **Code blocks** | Nuevo tipo de bloque + syntax highlighting | Media | Ninguno | diff --git a/src/uix/eidos/components/words/continue.md b/src/uix/eidos/components/words/continue.md new file mode 100644 index 000000000..713b99691 --- /dev/null +++ b/src/uix/eidos/components/words/continue.md @@ -0,0 +1,178 @@ +# Words - continuation notes + +Fecha: 2026-05-26 + +## Contexto + +Trabajo acotado al ecosistema `words`, salvo el documento previo +`src/uix/eidos/eidos-motion.md` de la arquitectura de motion. El usuario pidio +mejorar `Words` porque el toolbar y los popovers no estaban aprovechando bien +Eidos. + +Antes de tocar componente se releyeron: + +- `web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md` +- `web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md` +- `src/uix/eidos/components/README.md` + +## Hecho + +### Toolbar y componentes Eidos + +- `Words` root renderiza el toolbar automatico con wrappers locales de Eidos: + `Toolbar`, `ToolbarGroup`, `CommandButton`, `HeadingPicker`, `LinkEditor` y + `FindReplace`. +- `WordsCommandButton` envuelve `Button` de Eidos y soporta: + - `variant`, `size`, `rounded`, `iconOnly` + - `toolbarItem` para evitar marcadores de toolbar cuando se usa dentro de + paneles flotantes. +- Se corrigio el bug por el que los iconos no llegaban al slot `icon` de + `Button`; los botones icon-only ya renderizan SVG. +- Se elimino `words-tool-popover.svelte`; `Words` usa `Popover` real de Eidos. + +### Heading picker y link editor + +- `Words.HeadingPicker` usa `Popover.Content` portaled con: + - `side="top"` + - `align="center"` + - `sideOffset={8}` + - `arrowPadding={8}` + - `Popover.Arrow` +- El panel de headings usa `CommandButton toolbarItem={false}` para no heredar + estilos de toolbar dentro del panel. +- `Words.LinkEditor` usa `Popover.Content` centrado, con flecha y acciones con + `Button` de Eidos. +- Se agrego `[data-words-floating]` como puente de tokens para contenido + portaled. Esto evita que los popovers pierdan variables CSS internas de + `Words`. + +### Find/replace + +- Se implemento `FindReplace` transversal en Morfo/Soma/Eidos: + - Morfo declara la parte `find-replace` y el evento `commit-set-replace`. + - Soma mantiene `findQuery`, matches, indice activo y operaciones de + reemplazo. + - Eidos pinta el panel, inputs, contador, botones e highlights. +- El motor soporta: + - busqueda case-insensitive por defecto + - opcion case-sensitive a nivel engine + - highlights en DOM con `mark[data-words-find-match]` + - highlight activo con `data-words-find-active` + - reemplazo de todas las coincidencias de forma estable. +- `Words.FindReplace` paso de banda fija bajo el toolbar a popover: + - trigger compacto `[data-words-find-trigger]` + - `Popover.Content` centrado con `Popover.Arrow` + - panel `role="search"` dentro del popover + - `toolbarItem={false}` para uso manual fuera de toolbar. +- La demo permite activar/desactivar `FindReplace` y refleja la parte en los + snippets. + +### Editor features + +- Text alignment: `align-left`, `align-center`, `align-right`, + `align-justify`. +- Heading picker: H1/H2/H3/Paragraph. +- Indentacion de listas: + - `Tab` aumenta indent + - `Shift+Tab` reduce indent + - `increase-indent` / `decrease-indent` en toolbar full. +- Soft breaks con `Shift+Enter`. +- Triple click selecciona bloque entero. +- Import/export: + - HTML preserva indent de listas. + - Markdown preserva indent de listas. +- Sincronizacion externa de `value` sin remount del editor. + +### Documentacion + +- `README.md` de `words` actualizado con decisiones, fases V1.1 y estado de + find/replace. +- Demo `web/routes/uix/components/words/+page.svelte` actualizada con controles, + snippets y tablas API/A11y/Recipe. + +## Verificado + +Comandos ejecutados y estado: + +- `npx prettier --check ...words... web/routes/uix/components/words/+page.svelte` + - OK. +- `npx vitest run src/uix/soma/components/words/engine/engine.test.ts src/uix/soma/components/words/words-provider.svelte.test.ts src/uix/soma/components/words/words-content.svelte.test.ts src/uix/eidos/recipe-css-contract.test.ts src/uix/eidos/component-api-contract.test.ts src/uix/eidos/component-visual-attrs.test.ts` + - 6 files passed, 96 tests passed. +- `node --import tsx/esm scripts/component-audit.ts --only words` + - PASS. +- `node --import tsx/esm scripts/eidos-lint.ts words` + - invalid: 0. + +Browser: + +- Dev server activo en `http://127.0.0.1:5174/uix/components/words`. +- Verificado que el trigger de find/replace abre popover: + - `data-state="open"` + - `side="top"` + - `alignDelta: 0` + - `hasArrow: true` + - `inputCount: 2` + - `buttonCount: 3` +- Verificado previamente que el heading picker renderiza 4 botones con icono y + no se solapa con find/replace. + +## Bloqueos conocidos + +`npm run check` global no queda verde por un error fuera del ambito `words`: + +```txt +src/uix/soma/components/command/command-provider.svelte.ts:384:8 +Error: Cannot find name 'attrs'. Did you mean 'Attr'? +``` + +Ese fichero estaba modificado fuera del scope de `words`; no se toco para +respetar el limite pedido por el usuario. Tambien aparecen 24 warnings +preexistentes en demos/componentes no relacionados y errores de carga en +`tmp/lexical` por dependencias ausentes. + +## Pendiente para manana + +1. Reabrir la demo de `Words` y hacer una pasada visual completa: + - heading picker + - link editor + - find/replace + - toolbar horizontal y vertical + - mobile/narrow viewport si aplica. +2. Confirmar con browser que los tres popovers de `Words` tienen: + - `align="center"` + - `Popover.Arrow` + - `side="top"` + - sin solape visual incoherente. +3. Revisar si `side="top"` debe quedar fijo en `Words` o si algunos paneles + deberian permitir flip visual manteniendo flecha. +4. Si se autoriza salir del ambito `words`, corregir el error externo de + `src/uix/soma/components/command/command-provider.svelte.ts` (`attrs` fuera + de scope) y repetir `npm run check`. +5. Revisar posibles siguientes features del editor: + - code blocks + - slash commands + - tablas + - imagenes/archivos + - find options UI: case sensitive, whole word, regex si se decide. +6. Antes de nuevas features, repetir el pre-flight obligatorio y preparar una + mini matriz contra Radix/Bits/Ark/React Aria/Chakra/MUI segun corresponda. + +## Archivos tocados principales + +- `src/uix/eidos/components/words/words.svelte` +- `src/uix/eidos/components/words/words-command-button.svelte` +- `src/uix/eidos/components/words/words-heading-picker.svelte` +- `src/uix/eidos/components/words/words-link-editor.svelte` +- `src/uix/eidos/components/words/words-find-replace.svelte` +- `src/uix/eidos/components/words/words.css` +- `src/uix/eidos/components/words/types.ts` +- `src/uix/eidos/components/words/README.md` +- `src/uix/soma/components/words/**` +- `src/uix/morfo/components/words.ts` +- `web/routes/uix/components/words/+page.svelte` + +## Nota de continuidad + +No hacer `git reset`, `stash` ni `checkout` de ficheros: el worktree tiene +cambios previos del usuario y cambios de esta sesion. Trabajar por diffs +acotados y respetar el scope que indique el usuario al retomar. diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index cd05c6d64..06f37100f 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -9,6 +9,7 @@ import HeadingPicker from './words-heading-picker.svelte'; import LinkEditor from './words-link-editor.svelte'; import HiddenInput from './words-hidden-input.svelte'; import Status from './words-status.svelte'; +import FindReplace from './words-find-replace.svelte'; type WordsNamespace = typeof WordsComponent & { Content: typeof Content; @@ -21,6 +22,7 @@ type WordsNamespace = typeof WordsComponent & { LinkEditor: typeof LinkEditor; HiddenInput: typeof HiddenInput; Status: typeof Status; + FindReplace: typeof FindReplace; }; const Words = WordsComponent as WordsNamespace; @@ -34,6 +36,7 @@ Words.HeadingPicker = HeadingPicker; Words.LinkEditor = LinkEditor; Words.HiddenInput = HiddenInput; Words.Status = Status; +Words.FindReplace = FindReplace; export { Words }; export default Words; @@ -42,6 +45,7 @@ export type { WordsCommandButtonProps as CommandButtonProps, WordsColor, WordsContentProps as ContentProps, + WordsFindReplaceProps as FindReplaceProps, WordsHiddenInputProps as HiddenInputProps, WordsLinkEditorProps as LinkEditorProps, WordsPlaceholderProps as PlaceholderProps, @@ -50,9 +54,12 @@ export type { WordsStatusProps as StatusProps, WordsBubbleMenuProps as BubbleMenuProps, WordsBubbleMenuSnippetProps, + WordsToolbarButtonCommandName, WordsToolbarCommandConfig, WordsToolbarConfig, + WordsToolbarFindReplaceConfig, WordsToolbarGroupConfig, + WordsToolbarHeadingPickerConfig, WordsToolbarItemConfig, WordsToolbarLinkEditorConfig, WordsToolbarGroupProps as ToolbarGroupProps, diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts index 405b6d501..ec82e6705 100644 --- a/src/uix/eidos/components/words/types.ts +++ b/src/uix/eidos/components/words/types.ts @@ -3,6 +3,7 @@ import type { BubbleMenuProps, CommandButtonProps, ContentProps, + FindReplaceProps, HiddenInputProps, LinkEditorProps, PlaceholderProps, @@ -18,16 +19,21 @@ import type { WordsListKind, WordsToolbarOrientation } from '$soma/components/words'; +import type { ButtonRounded, ButtonSize, ButtonVariant } from '$uix/eidos/components/button'; import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; export type WordsSize = Extract; export type WordsVariant = ControlVariant; export type WordsColor = ColorRole; export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full'; +export type WordsToolbarButtonCommandName = Exclude< + WordsCommandName, + 'find-text' | 'replace-all-text' +>; export type WordsToolbarCommandConfig = - | WordsCommandName + | WordsToolbarButtonCommandName | { - command: WordsCommandName; + command: WordsToolbarButtonCommandName; label?: string; level?: WordsHeadingLevel; listKind?: WordsListKind; @@ -44,10 +50,17 @@ export type WordsToolbarHeadingPickerConfig = part: 'heading-picker'; label?: string; }; +export type WordsToolbarFindReplaceConfig = + | 'find-replace' + | { + part: 'find-replace'; + label?: string; + }; export type WordsToolbarItemConfig = | WordsToolbarCommandConfig | WordsToolbarLinkEditorConfig - | WordsToolbarHeadingPickerConfig; + | WordsToolbarHeadingPickerConfig + | WordsToolbarFindReplaceConfig; export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[]; export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[]; @@ -70,7 +83,22 @@ export type WordsToolbarProps = ToolbarProps; export type WordsToolbarGroupProps = ToolbarGroupProps; export type WordsBubbleMenuProps = BubbleMenuProps; export type WordsBubbleMenuSnippetProps = SomaWordsBubbleMenuSnippetProps; -export type WordsCommandButtonProps = CommandButtonProps; +export type WordsCommandButtonProps = CommandButtonProps & { + /** Visual treatment forwarded to Eidos Button. @default 'ghost' */ + variant?: ButtonVariant; + /** Visual size forwarded to Eidos Button. @default 'xs' */ + size?: ResponsiveProp; + /** Radius forwarded to Eidos Button. @default 'md' */ + rounded?: ResponsiveProp; + /** Render as a square icon button while keeping the label accessible. @default true */ + iconOnly?: boolean; + /** Stamp toolbar item markers when rendered directly inside Words.Toolbar. @default true */ + toolbarItem?: boolean; +}; export type WordsLinkEditorProps = LinkEditorProps; export type WordsHiddenInputProps = HiddenInputProps; export type WordsStatusProps = StatusProps; +export type WordsFindReplaceProps = FindReplaceProps & { + /** Stamp toolbar item markers when rendered directly inside Words.Toolbar. @default true */ + toolbarItem?: boolean; +}; diff --git a/src/uix/eidos/components/words/words-command-button.svelte b/src/uix/eidos/components/words/words-command-button.svelte index d9d219448..f845b02ff 100644 --- a/src/uix/eidos/components/words/words-command-button.svelte +++ b/src/uix/eidos/components/words/words-command-button.svelte @@ -1,14 +1,64 @@ -{#if children} - - {@render children()} - -{:else} - -{/if} + + {#snippet child({ props })} + {#if outerChild} + {@render outerChild({ + props: { + ...props, + 'data-toolbar-button': toolbarItem ? '' : undefined, + 'data-toolbar-group-item': toolbarItem ? '' : undefined + } + })} + {:else} + {@const label = props['aria-label']} + {#if children} + + {:else} + + {/if} + {/if} + {/snippet} + diff --git a/src/uix/eidos/components/words/words-find-replace.svelte b/src/uix/eidos/components/words/words-find-replace.svelte new file mode 100644 index 000000000..6b65cb6aa --- /dev/null +++ b/src/uix/eidos/components/words/words-find-replace.svelte @@ -0,0 +1,149 @@ + + + + {#snippet child(props)} + {@const sp = props as FindReplaceChildProps} + + + + + + + +
    + {#if customChildren} + {@render customChildren(sp)} + {:else} +
    + + { + sp.setFindQuery(e.currentTarget.value); + }} + /> + + {#if sp.hasFindMatches} + {sp.currentMatchIndex + 1} / {sp.findMatches.length} + {:else} + 0 / 0 + {/if} + + + +
    +
    + + { + replacement = e.currentTarget.value; + }} + /> + +
    + {/if} +
    +
    +
    +
    + {/snippet} +
    diff --git a/src/uix/eidos/components/words/words-heading-picker.svelte b/src/uix/eidos/components/words/words-heading-picker.svelte index 93fffef6e..e5be0215b 100644 --- a/src/uix/eidos/components/words/words-heading-picker.svelte +++ b/src/uix/eidos/components/words/words-heading-picker.svelte @@ -1,7 +1,7 @@ - + {#if currentBlock === 'heading' && currentHeadingLevel === 1} {:else if currentBlock === 'heading' && currentHeadingLevel === 2} @@ -35,39 +38,53 @@ {/if} - -
    - + +
    + (open = false)} > - - + (open = false)} > - - + (open = false)} > - - + (open = false)} > - +
    diff --git a/src/uix/eidos/components/words/words-link-editor.svelte b/src/uix/eidos/components/words/words-link-editor.svelte index 9abe82493..cc89fa22e 100644 --- a/src/uix/eidos/components/words/words-link-editor.svelte +++ b/src/uix/eidos/components/words/words-link-editor.svelte @@ -1,5 +1,6 @@ - - - - {@render trigger?.()} - - - - -
    -
    - {@render children?.({ open, close })} -
    - -
    -
    -
    -
    diff --git a/src/uix/eidos/components/words/words-toolbar-group.svelte b/src/uix/eidos/components/words/words-toolbar-group.svelte index 7a47afbca..5154069e0 100644 --- a/src/uix/eidos/components/words/words-toolbar-group.svelte +++ b/src/uix/eidos/components/words/words-toolbar-group.svelte @@ -2,9 +2,22 @@ import * as Words from '$soma/components/words'; import type { WordsToolbarGroupProps } from './types'; - let { children, ...rest }: WordsToolbarGroupProps = $props(); + let { children, child: outerChild, ...rest }: WordsToolbarGroupProps = $props(); - {@render children?.()} + {#snippet child({ props })} + {#if outerChild} + {@render outerChild({ + props: { + ...props, + 'data-toolbar-group': '' + } + })} + {:else} +
    + {@render children?.()} +
    + {/if} + {/snippet}
    diff --git a/src/uix/eidos/components/words/words-toolbar.svelte b/src/uix/eidos/components/words/words-toolbar.svelte index 6c7c9ab6f..60772fd6d 100644 --- a/src/uix/eidos/components/words/words-toolbar.svelte +++ b/src/uix/eidos/components/words/words-toolbar.svelte @@ -1,10 +1,33 @@ - - {@render children?.()} + + {#snippet child({ props: wordsProps })} + + {#snippet child({ props: toolbarProps })} + {@const props = mergeProps(toolbarProps, wordsProps)} + {#if outerChild} + {@render outerChild({ props })} + {:else} +
    + {@render children?.()} +
    + {/if} + {/snippet} +
    + {/snippet}
    diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index c4fea0732..e1d5e5984 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -14,7 +14,42 @@ --_words-accent-border: var(--words-color-primary-border); --_words-accent-text: var(--words-color-primary-text); --_words-selection-bg: color-mix(in srgb, var(--_words-accent-solid) 78%, transparent); - --_words-selection-color: var(--words-command-solid-color); + --_words-selection-color: var(--words-selection-color); + --toolbar-gap-md: var(--words-toolbar-gap); + --toolbar-padding-inline-sm: var(--_words-toolbar-px); + --toolbar-padding-inline-md: var(--_words-toolbar-px); + --toolbar-padding-block-sm: var(--_words-toolbar-py); + --toolbar-padding-block-md: var(--_words-toolbar-py); + --toolbar-control-height-sm: var(--_words-button-size); + --toolbar-control-height-md: var(--_words-button-size); + --toolbar-control-px-sm: var(--words-command-px); + --toolbar-control-px-md: var(--words-command-px); + --toolbar-control-gap-sm: var(--words-command-gap); + --toolbar-control-gap-md: var(--words-command-gap); + --toolbar-font-size-sm: var(--_words-font-size); + --toolbar-font-size-md: var(--_words-font-size); + --toolbar-bg: var(--words-toolbar-bg); + --toolbar-border: transparent; + --toolbar-shadow: none; + --toolbar-color: var(--words-command-color); + --toolbar-font-family: var(--words-font-family); + --toolbar-line-height: var(--words-line-height); + --toolbar-group-gap: var(--words-command-gap); + --toolbar-control-border-width: var(--words-border-width); + --toolbar-control-border: transparent; + --toolbar-control-radius: var(--words-command-radius); + --toolbar-control-bg: transparent; + --toolbar-control-color: var(--words-command-color); + --toolbar-control-border-hover: var(--words-command-border-hover); + --toolbar-control-bg-hover: var(--words-command-bg-hover); + --toolbar-control-color-hover: var(--words-command-color-hover); + --toolbar-control-border-active: var(--_words-accent-border); + --toolbar-control-bg-active: var(--_words-accent-track); + --toolbar-control-color-active: var(--_words-accent-text); + --toolbar-focus-shadow: var(--words-focus-shadow); + --toolbar-disabled-opacity: var(--words-disabled-opacity); + --toolbar-transition-duration: var(--words-transition-duration); + --toolbar-transition-ease: var(--words-transition-ease); position: relative; display: flex; @@ -151,15 +186,23 @@ grid-template-rows: auto auto; } +[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) { + grid-template-rows: auto auto auto; +} + [data-words-toolbar] { display: flex; flex-wrap: wrap; align-items: center; gap: var(--words-toolbar-gap); + inline-size: 100%; padding-block: var(--_words-toolbar-py); padding-inline: var(--_words-toolbar-px); + border: 0; border-block-end: var(--words-border-width) solid var(--words-toolbar-border); + border-radius: 0; background: var(--words-toolbar-bg); + box-shadow: none; } [data-words-toolbar][data-orientation='vertical'] { @@ -221,35 +264,8 @@ } [data-words-command-button] { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--words-command-gap); min-inline-size: var(--_words-button-size); block-size: var(--_words-button-size); - padding-inline: var(--words-command-px); - border: var(--words-border-width) solid transparent; - border-radius: var(--words-command-radius); - background: transparent; - color: var(--words-command-color); - font: inherit; - cursor: pointer; - outline: none; - transition: - background var(--words-transition-duration) var(--words-transition-ease), - border-color var(--words-transition-duration) var(--words-transition-ease), - color var(--words-transition-duration) var(--words-transition-ease), - box-shadow var(--words-transition-duration) var(--words-transition-ease); -} - -[data-words-command-button]:hover:not(:disabled):not([data-disabled]) { - border-color: var(--words-command-border-hover); - background: var(--words-command-bg-hover); - color: var(--words-command-color-hover); -} - -[data-words-command-button]:focus-visible { - box-shadow: var(--words-focus-shadow); } [data-words-command-button][data-active], @@ -277,6 +293,20 @@ opacity: var(--words-disabled-opacity); } +[data-words-floating] { + --_words-toolbar-px: var(--words-toolbar-px-md); + --_words-toolbar-py: var(--words-toolbar-py-md); + --_words-button-size: var(--button-height-xs); + --_words-font-size: var(--words-font-size-md); + --_words-accent-solid: var(--words-color-primary-solid); + --_words-accent-solid-hover: var(--words-color-primary-solid-hover); + --_words-accent-track: var(--words-color-primary-track); + --_words-accent-border: var(--words-color-primary-border); + --_words-accent-text: var(--words-color-primary-text); + --toolbar-focus-shadow: var(--words-focus-shadow); + --toolbar-disabled-opacity: var(--words-disabled-opacity); +} + [data-words-link-editor] { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -295,14 +325,53 @@ } [data-words-heading-picker] { - display: flex; + min-inline-size: var(--_words-button-size); + block-size: var(--_words-button-size); +} + +[data-words-find-trigger] { + display: inline-flex; align-items: center; - gap: var(--words-command-gap); - padding: var(--words-command-gap); + justify-content: center; + align-self: flex-start; + inline-size: var(--_words-button-size); + min-inline-size: var(--_words-button-size); + block-size: var(--_words-button-size); + padding: 0; + border: var(--words-border-width) solid transparent; + border-radius: var(--words-command-radius); + background: transparent; + color: var(--words-command-color); } -[data-words-heading-picker] > [data-words-command-button] { - flex: 0 0 auto; +[data-words-find-trigger]:hover:not(:disabled):not([data-disabled]) { + border-color: var(--words-command-border-hover); + background: var(--words-command-bg-hover); + color: var(--words-command-color-hover); +} + +[data-words] > [data-words-find-trigger] { + margin-block-start: var(--_words-toolbar-py); + margin-inline-start: var(--_words-toolbar-px); +} + +[data-words-find-trigger][data-state='open'], +[data-words-find-trigger][data-has-matches] { + border-color: var(--_words-accent-border); + background: var(--_words-accent-track); + color: var(--_words-accent-text); +} + +[data-words-heading-picker-panel] { + display: grid; + grid-template-columns: repeat(4, var(--_words-button-size)); + gap: var(--button-gap-xs); +} + +[data-words-heading-picker-panel] [data-words-command-button] { + inline-size: var(--_words-button-size); + block-size: var(--_words-button-size); + justify-self: stretch; } [data-words-link-field] { @@ -467,6 +536,18 @@ line-height: var(--words-heading-line-height); } +[data-words-content] :where(h1) { + font-size: var(--words-heading-h1-font-size); +} + +[data-words-content] :where(h2) { + font-size: var(--words-heading-h2-font-size); +} + +[data-words-content] :where(h3) { + font-size: var(--words-heading-h3-font-size); +} + [data-words-content] :where(strong) { font-weight: var(--words-strong-font-weight); } @@ -519,6 +600,42 @@ padding-inline-start: var(--words-list-padding-inline-start); } +[data-words-content] [data-words-indent] { + margin-inline-start: calc(var(--words-list-padding-inline-start) * var(--_words-indent, 1)); +} + +[data-words-content] [data-words-indent='1'] { + --_words-indent: 1; +} + +[data-words-content] [data-words-indent='2'] { + --_words-indent: 2; +} + +[data-words-content] [data-words-indent='3'] { + --_words-indent: 3; +} + +[data-words-content] [data-words-indent='4'] { + --_words-indent: 4; +} + +[data-words-content] [data-words-indent='5'] { + --_words-indent: 5; +} + +[data-words-content] [data-words-indent='6'] { + --_words-indent: 6; +} + +[data-words-content] [data-words-indent='7'] { + --_words-indent: 7; +} + +[data-words-content] [data-words-indent='8'] { + --_words-indent: 8; +} + [data-words-content] :where(a) { color: var(--_words-accent-text); text-decoration-color: var(--_words-accent-border); @@ -541,6 +658,150 @@ color: var(--words-status-error-color); } +[data-words-find-replace] { + display: flex; + flex-direction: column; + gap: var(--words-command-gap); + inline-size: 100%; + padding: 0; + border: 0; + background: transparent; +} + +[data-words-find-replace][data-disabled], +[data-words-find-replace][data-readonly] { + color: var(--words-status-color); +} + +[data-words-find-replace][data-disabled] { + cursor: not-allowed; + opacity: var(--words-disabled-opacity); +} + +[data-words-find-replace][data-has-matches] [data-words-find-counter] { + color: var(--_words-accent-text); +} + +[data-words]:has([data-words-toolbar][data-orientation='vertical']) [data-words-find-replace] { + grid-column: 1 / -1; + grid-row: 2; +} + +[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) + [data-words-status] { + grid-row: 3; +} + +[data-words-find-row], +[data-words-replace-row] { + display: flex; + align-items: center; + gap: var(--words-command-gap); +} + +[data-words-find-label], +[data-words-replace-label] { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + color: var(--words-status-color); + font-size: var(--words-status-font-size); +} + +[data-words-find-input], +[data-words-replace-input] { + flex: 1 1 auto; + min-inline-size: 0; + block-size: var(--_words-button-size); + padding-inline: var(--words-command-px); + border: var(--words-border-width) solid var(--words-toolbar-border); + border-radius: var(--words-command-radius); + background: var(--words-bg); + color: var(--words-content-color); + font: inherit; + outline: none; +} + +[data-words-find-input]:focus, +[data-words-replace-input]:focus { + border-color: var(--_words-accent-border); + box-shadow: var(--words-focus-shadow); +} + +[data-words-find-counter] { + flex: 0 0 auto; + min-inline-size: 3.5ch; + color: var(--words-status-color); + font-size: var(--words-status-font-size); + font-variant-numeric: tabular-nums; + text-align: end; +} + +[data-words-find-prev], +[data-words-find-next], +[data-words-replace-all] { + display: inline-flex; + flex: 0 0 var(--_words-button-size); + align-items: center; + justify-content: center; + inline-size: var(--_words-button-size); + block-size: var(--_words-button-size); + padding: 0; + border: var(--words-border-width) solid var(--words-command-border-hover); + border-radius: var(--words-command-radius); + background: var(--words-bg); + color: var(--words-command-color); + font: inherit; + cursor: pointer; + outline: none; +} + +[data-words-find-prev]:hover:not(:disabled):not([data-disabled]), +[data-words-find-next]:hover:not(:disabled):not([data-disabled]), +[data-words-replace-all]:hover:not(:disabled):not([data-disabled]) { + border-color: var(--words-command-border-hover); + background: var(--words-command-bg-hover); + color: var(--words-command-color-hover); +} + +[data-words-find-prev]:focus-visible, +[data-words-find-next]:focus-visible, +[data-words-replace-all]:focus-visible { + box-shadow: var(--words-focus-shadow); +} + +[data-words-find-prev]:disabled, +[data-words-find-next]:disabled, +[data-words-replace-all]:disabled { + cursor: not-allowed; + opacity: var(--words-disabled-opacity); +} + +[data-words-replace-all] { + border-color: var(--_words-accent-solid); + background: var(--_words-accent-solid); + color: var(--words-command-solid-color); +} + +[data-words-replace-all]:hover:not(:disabled):not([data-disabled]) { + border-color: var(--_words-accent-solid-hover); + background: var(--_words-accent-solid-hover); + color: var(--words-command-solid-color); +} + +[data-words-content] mark[data-words-find-match] { + border-radius: var(--words-code-radius); + background: color-mix(in srgb, var(--_words-accent-solid) 22%, transparent); + color: var(--_words-accent-text); + padding-block: var(--words-code-py); + padding-inline: var(--words-code-px); +} + +[data-words-content] mark[data-words-find-match][data-words-find-active] { + background: color-mix(in srgb, var(--_words-accent-solid) 55%, transparent); + outline: 1px solid var(--_words-accent-border); +} + @media (prefers-reduced-motion: reduce) { [data-words], [data-words-command-button] { diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index a49690891..dcf387e3e 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -1,6 +1,6 @@ - {#snippet children(snippetProps)} - {#if toolbarGroups.length} - - {#each toolbarGroups as group} - + {#if visibleToolbarGroups.length} + + {#each visibleToolbarGroups as group} + {#each group as item} - {#if isLinkEditorConfig(item)} - - {:else if isHeadingPickerConfig(item)} - - {:else} - {@const command = normalizeCommand(item)} - + {:else if isHeadingPickerConfig(item)} + + {:else if isFindReplaceConfig(item)} + + {:else} + {@const command = normalizeCommand(item)} + {:else if command.command === 'paragraph'} - {:else if command.command === 'heading'} - - {:else if command.command === 'quote'} + {:else if command.command === 'heading'} + + {:else if command.command === 'quote'} {:else if command.command === 'unordered-list'} {:else if command.command === 'ordered-list'} - {:else if command.command === 'check-list'} - - {:else if command.command === 'align-left'} - - {:else if command.command === 'align-center'} - - {:else if command.command === 'align-right'} - - {:else if command.command === 'align-justify'} - - {:else if command.command === 'unlink'} - - {:else if command.command === 'commit'} - - {:else} - - {/if} - + {:else if command.command === 'check-list'} + + {:else if command.command === 'increase-indent'} + + {:else if command.command === 'decrease-indent'} + + {:else if command.command === 'align-left'} + + {:else if command.command === 'align-center'} + + {:else if command.command === 'align-right'} + + {:else if command.command === 'align-justify'} + + {:else if command.command === 'unlink'} + + {:else if command.command === 'commit'} + + {:else if command.command === 'clear-document'} + + {:else} + + {/if} + {/if} {/each} - + {/each} - + {/if} {@render bodyContent?.(snippetProps)} {/snippet} - + diff --git a/src/uix/eidos/eidos-motion.md b/src/uix/eidos/eidos-motion.md new file mode 100644 index 000000000..ededc1505 --- /dev/null +++ b/src/uix/eidos/eidos-motion.md @@ -0,0 +1,483 @@ +# Eidos Motion + +Documento de arquitectura propuesto para dotar a la capa visual de UIX de un +sistema transversal de animaciones. + +## Tesis + +UIX tiene dos familias de animacion: + +1. **Motion de senal**: Sema escribe una ocurrencia transitoria en el DOM + mediante `data-event-*`. Eidos gestiona el canal visual de Sema: lee esa + ocurrencia y la convierte en animacion, color, intensidad y politica de + reduced motion. +2. **Motion visual**: Eidos anima estado, presencia, interaccion y layout a + partir de atributos como `data-state`, `data-starting-style`, + `data-ending-style`, `data-archetype` y referencias visuales propias. + +Sema no decide keyframes, easing ni duraciones CSS. Soma no decide visualidad. +Eidos es el runtime visual que resuelve ambos mundos. + +```text +Sema visual channel + -> data-event, data-event-family, data-event-intent, data-event-phase + -> ventana semantica / hold de la senal + +Eidos signal motion + -> lee data-event-* + -> resuelve motion registrada por event/family/intent + -> aplica CSS, WAAPI o driver visual + +Eidos visual motion + -> lee data-state, data-starting-style, data-ending-style, data-archetype... + -> aplica presets de presencia, interaccion y layout +``` + +## Objetivos + +- Registrar animaciones reutilizables en Eidos. +- Permitir que los componentes Eidos referencien una motion por nombre. +- Mantener las primitivas existentes de motion como fuente atomica: + duracion, easing, distancia, escala y stagger. +- Centralizar reduced motion en Eidos usando la preferencia global proyectada + en DOM. +- Mantener el canal visual de Sema como entrada de senales, no como motor CSS. +- Soportar animaciones avanzadas como `genie`, `shared-element` o + `fly-to-target` mediante drivers visuales de Eidos. + +## No objetivos + +- No introducir una dependencia obligatoria de Framer Motion, Motion One ni + ningun motor JS externo en el core. +- No mover presencia visual a Soma. +- No hacer que Sema conozca nombres visuales como `genie`, `scale-fade` o + `drawer`. +- No reutilizar `data-motion` para presets visuales. Ese atributo ya representa + la preferencia global `allow` / `reduce`. + +## Ownership + +| Capa | Responsabilidad | +| --- | --- | +| Morfo | Declara atributos estructurales y estados compartidos por capas. | +| Soma | Sincroniza estado, ARIA, presence lifecycle y attrs como `data-state`, `data-starting-style`, `data-ending-style`. | +| Sema | Emite senales perceptivas y escribe `data-event-*` durante el hold del canal visual. | +| Eidos | Gestiona el canal visual de Sema, registra motions, resuelve tokens, aplica CSS/WAAPI/drivers y reduced motion. | +| ActivePrefs DOM projection | Proyecta preferencia global: `data-motion="allow"` o `data-motion="reduce"`. | + +## Registro de motion + +Eidos deberia tener un registro tipado de animaciones. Las primitivas actuales +siguen siendo atomicas; el registro compone esas primitivas en recetas +semanticas. + +```ts +type EidosMotionKind = 'presence' | 'event' | 'interaction' | 'layout'; + +type EidosMotionDriver = 'css' | 'eidos-rect' | 'waapi'; + +type EidosReducedMotionPolicy = 'instant' | 'opacity-only' | 'none'; + +interface EidosMotionDefinition { + kind: EidosMotionKind; + driver?: EidosMotionDriver; + duration?: DurationKey; + ease?: EaseKey; + distance?: MotionDistanceKey; + scale?: MotionScaleKey; + stagger?: boolean; + reduce?: EidosReducedMotionPolicy; + requires?: Array<'sourceRect' | 'targetRect' | 'placement' | 'direction'>; +} +``` + +La configuracion podria quedar asi: + +```ts +interface EidosConfig { + motion?: { + registry?: Record; + }; +} +``` + +Las primitivas actuales no deberian moverse dentro de `registry`. El registro +usa claves de primitivas ya definidas por Eidos. + +## Nombres de referencia + +Usar nombres con prefijo semantico: + +```ts +const motionRegistry = { + 'event.announce': { + kind: 'event', + duration: 'normal', + ease: 'out', + reduce: 'opacity-only' + }, + 'event.dismiss': { + kind: 'event', + duration: 'fast', + ease: 'in', + reduce: 'instant' + }, + 'presence.fade': { + kind: 'presence', + duration: 'normal', + ease: 'out', + reduce: 'opacity-only' + }, + 'presence.scale-fade': { + kind: 'presence', + duration: 'moderate', + ease: 'spring', + scale: 'enter', + reduce: 'opacity-only' + }, + 'presence.genie': { + kind: 'presence', + driver: 'eidos-rect', + duration: 'slow', + ease: 'spring', + reduce: 'opacity-only', + requires: ['sourceRect', 'targetRect'] + }, + 'interaction.press': { + kind: 'interaction', + duration: 'fast', + ease: 'out', + scale: 'press', + reduce: 'instant' + }, + 'layout.indicator': { + kind: 'layout', + duration: 'fast', + ease: 'default', + reduce: 'instant' + } +} satisfies Record; +``` + +## API publica de componentes + +Los wrappers Eidos pueden exponer una prop visual `motion`. + +```svelte + + + + +
  • `; }) .join(''); return `<${tag}>${children}`; @@ -319,12 +320,24 @@ function htmlListToBlock(node: HtmlElementNode): WordsBlock { function htmlListItemToWords(node: HtmlElementNode): WordsListItem { const hasCheckbox = hasCheckboxInput(node); const inlines = htmlNodesToInlines(node.children); + const indent = indentFromStyle(node); return createListItem( hasCheckbox ? trimLeadingInlineWhitespace(inlines) : inlines, - hasCheckedInput(node) + hasCheckedInput(node), + indent ); } +function indentFromStyle(node: HtmlElementNode): number | undefined { + const style = typeof node.attrs.style === 'string' ? node.attrs.style : ''; + const match = style.match(/margin-left\s*:\s*([\d.]+)em/); + if (!match) return undefined; + const em = Number.parseFloat(match[1] ?? ''); + if (!Number.isFinite(em) || em <= 0) return undefined; + const level = Math.round(em / 1.5); + return level > 0 && level <= 8 ? level : undefined; +} + function htmlNodesToInlines( nodes: readonly HtmlNode[], marks: readonly WordsMark[] = [], diff --git a/src/uix/soma/components/words/engine/serialize-markdown.ts b/src/uix/soma/components/words/engine/serialize-markdown.ts index fa1ade058..3d595f103 100644 --- a/src/uix/soma/components/words/engine/serialize-markdown.ts +++ b/src/uix/soma/components/words/engine/serialize-markdown.ts @@ -27,6 +27,7 @@ const MARK_DELIMITERS = { type LineMatch = { readonly text: string; readonly checked?: boolean; + readonly indent?: number; }; export function serializeWordsMarkdown(document: WordsDocument): string { @@ -70,7 +71,13 @@ export function parseWordsMarkdown(markdown: string): WordsDocument { children.push( createList( list.kind, - list.items.map((item) => createListItem(parseMarkdownInlines(item.text), item.checked)) + list.items.map((item) => + createListItem( + parseMarkdownInlines(item.text), + item.checked, + item.indent + ) + ) ) ); index = list.nextIndex; @@ -109,8 +116,9 @@ function serializeBlockMarkdown(block: WordsBlock): string { return block.children .map((item, index) => { const marker = listMarker(block.kind, index, item.checked === true); + const indentStr = item.indent ? ' '.repeat(item.indent) : ''; const content = serializeInlinesMarkdown(item.children); - return `${marker} ${content.replace(/\n/g, '\n ')}`; + return `${indentStr}${marker} ${content.replace(/\n/g, `\n${indentStr} `)}`; }) .join('\n'); } @@ -163,19 +171,24 @@ function collectList( } function parseListLine(line: string): { kind: WordsListKind; item: LineMatch } | undefined { - const check = line.match(/^[-*+]\s+\[([ xX])\]\s*(.*)$/); + const indentMatch = line.match(/^(\s*)/); + const leadingSpaces = indentMatch ? indentMatch[1].length : 0; + const indent = Math.floor(leadingSpaces / 2); + const content = line.slice(leadingSpaces); + + const check = content.match(/^[-*+]\s+\[([ xX])\]\s*(.*)$/); if (check) { return { kind: 'check', - item: { text: check[2] ?? '', checked: (check[1] ?? '').toLowerCase() === 'x' } + item: { text: check[2] ?? '', checked: (check[1] ?? '').toLowerCase() === 'x', indent } }; } - const unordered = line.match(/^[-*+]\s+(.*)$/); - if (unordered) return { kind: 'unordered', item: { text: unordered[1] ?? '' } }; + const unordered = content.match(/^[-*+]\s+(.*)$/); + if (unordered) return { kind: 'unordered', item: { text: unordered[1] ?? '', indent } }; - const ordered = line.match(/^\d+[.)]\s+(.*)$/); - if (ordered) return { kind: 'ordered', item: { text: ordered[1] ?? '' } }; + const ordered = content.match(/^\d+[.)]\s+(.*)$/); + if (ordered) return { kind: 'ordered', item: { text: ordered[1] ?? '', indent } }; return undefined; } diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts index e52ae4a23..42b9751eb 100644 --- a/src/uix/soma/components/words/exports.ts +++ b/src/uix/soma/components/words/exports.ts @@ -8,6 +8,7 @@ export { default as CommandButton } from './components/words-command-button.svel export { default as LinkEditor } from './components/words-link-editor.svelte'; export { default as HiddenInput } from './components/words-hidden-input.svelte'; export { default as Status } from './components/words-status.svelte'; +export { default as FindReplace } from './components/words-find-replace.svelte'; export type { WordsCommandName, @@ -27,10 +28,12 @@ export type { WordsLinkEditorProps as LinkEditorProps, WordsHiddenInputProps as HiddenInputProps, WordsStatusProps as StatusProps, + WordsFindReplaceProps as FindReplaceProps, WordsProviderSnippetProps as ProviderSnippetProps, WordsContentSnippetProps as ContentSnippetProps, WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps, - WordsLinkEditorSnippetProps as LinkEditorSnippetProps + WordsLinkEditorSnippetProps as LinkEditorSnippetProps, + WordsFindReplaceSnippetProps as FindReplaceSnippetProps } from './types'; export type { @@ -44,5 +47,6 @@ export type { WordsListKind, WordsMark, WordsSelectedLink, - WordsSelection + WordsSelection, + WordsTextMatch } from './engine'; diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 11b0f61b8..6bccff6cf 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -14,6 +14,7 @@ import type { WordsImportFormat, WordsListKind, WordsMark, + WordsTextMatch, WordsSelectedLink, WordsSelection } from './engine'; @@ -48,12 +49,16 @@ export type WordsCommandName = | 'align-center' | 'align-right' | 'align-justify' + | 'increase-indent' + | 'decrease-indent' | 'unordered-list' | 'ordered-list' | 'check-list' | 'unlink' | 'clear-formatting' | 'clear-document' + | 'find-text' + | 'replace-all-text' | 'commit'; export type WordsProviderSnippetProps = { @@ -73,8 +78,13 @@ export type WordsProviderSnippetProps = { readonly canUndo: boolean; readonly canRedo: boolean; readonly canUnlink: boolean; + readonly findQuery: string; + readonly findMatches: readonly WordsTextMatch[]; + readonly currentMatchIndex: number; + readonly hasFindMatches: boolean; readonly focus: () => void; readonly selectAll: () => void; + readonly selectBlock: () => void; readonly runCommand: (command: WordsCommandName) => void; readonly insertLink: (href: string, title?: string) => boolean; readonly unlink: () => boolean; @@ -83,6 +93,10 @@ export type WordsProviderSnippetProps = { readonly commit: (reason?: WordsCommitReason) => void; readonly undo: () => void; readonly redo: () => void; + readonly setFindQuery: (query: string) => void; + readonly findNext: () => void; + readonly findPrev: () => void; + readonly replaceAllText: (replacement: string) => void; }; export type WordsContentSnippetProps = { @@ -219,3 +233,26 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> & export type WordsStatusProps = WithChild<{ id?: string }> & Without; + +export type WordsFindReplaceSnippetProps = { + readonly findQuery: string; + readonly findMatches: readonly WordsTextMatch[]; + readonly currentMatchIndex: number; + readonly hasFindMatches: boolean; + readonly disabled: boolean; + readonly readonly: boolean; + readonly setFindQuery: (query: string) => void; + readonly findNext: () => void; + readonly findPrev: () => void; + readonly replaceAllText: (replacement: string) => void; +}; + +export type WordsFindReplaceProps = WithChild< + { + id?: string; + children?: Snippet<[WordsFindReplaceSnippetProps]>; + }, + WordsFindReplaceSnippetProps, + HTMLDivElement +> & + Without; diff --git a/src/uix/soma/components/words/words-content.svelte.test.ts b/src/uix/soma/components/words/words-content.svelte.test.ts index e4549baa2..ff2ba804b 100644 --- a/src/uix/soma/components/words/words-content.svelte.test.ts +++ b/src/uix/soma/components/words/words-content.svelte.test.ts @@ -71,7 +71,7 @@ describe('Words.Content component', () => { const component = mount(WordsContentHarness, { target }); await settled(); - const content = target.querySelector('[data-testid="words-content"]') as HTMLElement | null; + const content = target.querySelector('[data-words-content]') as HTMLElement | null; expect(content).toBeTruthy(); if (!content) throw new Error('Words content did not mount'); expect(textLeaf(content).textContent).toBe('Hello'); @@ -109,7 +109,7 @@ describe('Words.Content component', () => { }); await settled(); - const content = target.querySelector('[data-testid="words-content"]') as HTMLElement | null; + const content = target.querySelector('[data-words-content]') as HTMLElement | null; expect(content).toBeTruthy(); if (!content) throw new Error('Words content did not mount'); expect(textLeaf(content).textContent).toBe('Hello'); diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index 640c6508c..6f9219d29 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -674,6 +674,123 @@ describe('WordsProvider', () => { dom.dispose(); }); + it('converts a paragraph to quote via toolbar command', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const quoteEl = document.createElement('button'); + document.body.append(root, quoteEl); + const opts = wordsOpts(root, { + version: 1, + children: [createParagraph([createText('Hello')])] + }); + opts.selection.current = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 } + }; + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + const quote = WordsCommandButtonProvider.create({ + id: state('words-quote'), + ref: state(quoteEl), + command: state('quote'), + level: state<1 | 2 | 3 | undefined>(undefined), + listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined) + }); + return { provider, quote }; + }); + + expect(result.quote.props['aria-pressed']).toBe(false); + result.quote.props.onclick({ currentTarget: quoteEl } as never); + expect(result.provider.exportContent('html')).toBe('
    Hello
    '); + expect(result.quote.props['aria-pressed']).toBe(true); + + cleanup(); + dom.dispose(); + }); + + it('toggles an active quote command back to paragraph', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const quoteEl = document.createElement('button'); + document.body.append(root, quoteEl); + const opts = wordsOpts(root, { + version: 1, + children: [ + { + type: 'quote', + children: [createText('Said')] + } + ] + }); + opts.selection.current = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 } + }; + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + const quote = WordsCommandButtonProvider.create({ + id: state('words-quote'), + ref: state(quoteEl), + command: state('quote'), + level: state<1 | 2 | 3 | undefined>(undefined), + listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined) + }); + return { provider, quote }; + }); + + expect(result.quote.props['aria-pressed']).toBe(true); + result.quote.props.onclick({ currentTarget: quoteEl } as never); + expect(result.provider.exportContent('html')).toBe('

    Said

    '); + expect(result.quote.props['aria-pressed']).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('increases list indent from a toolbar command', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const indentEl = document.createElement('button'); + document.body.append(root, indentEl); + const opts = wordsOpts(root, { + version: 1, + children: [ + createList('unordered', [ + createListItem([createText('Item')]) + ]) + ] + }); + opts.selection.current = { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0], offset: 0 } + }; + + const { result, cleanup } = withEffectRoot(() => { + const provider = WordsProvider.create(opts); + vi.spyOn(WordsProvider, 'require').mockReturnValue(provider); + const indent = WordsCommandButtonProvider.create({ + id: state('words-indent'), + ref: state(indentEl), + command: state('increase-indent'), + level: state<1 | 2 | 3 | undefined>(undefined), + listKind: state<'ordered' | 'unordered' | 'check' | undefined>(undefined) + }); + return { provider, indent }; + }); + + result.indent.props.onclick({ currentTarget: indentEl } as never); + expect(result.provider.exportContent('html')).toBe( + '
    • Item
    ' + ); + + cleanup(); + dom.dispose(); + }); + it('syncs a DOM range selection and formats that range from a toolbar command', () => { const { dom } = installSomaHarness(); const root = document.createElement('div'); diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index fd5567d2b..d4abe8950 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -34,6 +34,7 @@ import { createWordsState, encodeWordsDomSelectionTarget, endWordsComposition, + findTextInDocument, getTextEntries, getLinkAtSelection, isCollapsedSelection, @@ -85,8 +86,7 @@ interface WordsToolbarOpts extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} interface WordsToolbarGroupOpts extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} -interface WordsBubbleMenuOpts - extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {} +interface WordsBubbleMenuOpts extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {} interface WordsCommandButtonOpts extends WithRefOpts, @@ -102,6 +102,7 @@ interface WordsLinkEditorOpts href: string; title: string | undefined; }> {} +interface WordsFindReplaceOpts extends WithRefOpts {} interface WordsHiddenInputOpts extends WithRefOpts {} interface WordsStatusOpts extends WithRefOpts {} type WordsSelectionSyncSource = @@ -140,6 +141,8 @@ export class WordsProvider { focused = $state(false); dirty = $state(false); isSelecting = $state(false); + findQuery = $state(''); + currentMatchIndex = $state(-1); private pendingCommitReason: WordsCommitReason = 'programmatic'; private typingBatchOpen = false; @@ -227,7 +230,12 @@ export class WordsProvider { readonly selection = $derived.by(() => this.history.present.selection); readonly activeMarks = $derived.by(() => this.history.present.activeMarks); readonly plainText = $derived.by(() => renderWordsPlainText(this.document)); - readonly html = $derived.by(() => renderWordsDomHtml(this.document)); + readonly html = $derived.by(() => + renderWordsDomHtml(this.document, { + matches: this.findMatches, + activeMatchIndex: this.currentMatchIndex + }) + ); readonly isDisabled = $derived.by( () => this.opts.disabled.current || (this.field?.isDisabled ?? false) @@ -273,9 +281,15 @@ export class WordsProvider { readonly currentTextAlign = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; - return block && block.type !== 'list' ? block.textAlign ?? 'left' : 'left'; + return block && block.type !== 'list' ? (block.textAlign ?? 'left') : 'left'; }); + readonly findMatches = $derived.by(() => + this.findQuery ? findTextInDocument(this.document, this.findQuery) : [] + ); + + readonly hasFindMatches = $derived.by(() => this.findMatches.length > 0); + readonly canUnlink = $derived.by( () => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly ); @@ -370,7 +384,10 @@ export class WordsProvider { }); } - runCommandName(name: WordsCommandName, opts: { target?: HTMLElement; level?: 1 | 2 | 3 } = {}) { + runCommandName( + name: WordsCommandName, + opts: { target?: HTMLElement; level?: 1 | 2 | 3; replacement?: string } = {} + ) { if (name === 'undo') { this.undo(opts.target); return; @@ -383,6 +400,24 @@ export class WordsProvider { this.commit('button', opts.target); return; } + if (name === 'find-text') { + return; + } + if (name === 'replace-all-text') { + if (!this.findQuery || opts.replacement === undefined) return; + const command: WordsCommand = { + type: 'replaceAllText', + query: this.findQuery, + replacement: opts.replacement + }; + const changed = this.applyCommand(command); + if (!changed) return; + this.currentMatchIndex = -1; + void this.runtime.trigger('commit-set-replace', { + fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined + }); + return; + } const command = this.commandFromName(name, opts.level); if (!command) return; @@ -455,9 +490,12 @@ export class WordsProvider { void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement }); }; - readonly onblur = (_e: SomaFocusEvent) => { + readonly onblur = (e: SomaFocusEvent) => { this.isSelecting = false; - this.syncSelectionFromDom({ source: 'blur' }); + const related = e.relatedTarget as HTMLElement | null; + if (!related || !this.isInsideWordsTool(related)) { + this.syncSelectionFromDom({ source: 'blur' }); + } this.closeTypingBatch(); this.focused = false; if (this.opts.commitOnBlur.current) this.commit('blur'); @@ -479,6 +517,26 @@ export class WordsProvider { this.applyCommand({ type: 'deleteForward' }); return; } + if (key === 'enter' && e.shiftKey) { + e.preventDefault(); + this.applyCommand({ type: 'insertLineBreak' }); + return; + } + if (key === 'tab') { + if (this.currentBlock === 'list') { + e.preventDefault(); + if (e.shiftKey) { + this.runCommandName('decrease-indent', { + target: e.currentTarget as HTMLElement + }); + } else { + this.runCommandName('increase-indent', { + target: e.currentTarget as HTMLElement + }); + } + } + return; + } return; } @@ -502,6 +560,26 @@ export class WordsProvider { this.runCommandName('underline', { target: e.currentTarget as HTMLElement }); return; } + if (key === 'l') { + e.preventDefault(); + this.runCommandName('align-left', { target: e.currentTarget as HTMLElement }); + return; + } + if (key === 'e') { + e.preventDefault(); + this.runCommandName('align-center', { target: e.currentTarget as HTMLElement }); + return; + } + if (key === 'r') { + e.preventDefault(); + this.runCommandName('align-right', { target: e.currentTarget as HTMLElement }); + return; + } + if (key === 'j') { + e.preventDefault(); + this.runCommandName('align-justify', { target: e.currentTarget as HTMLElement }); + return; + } if (key === 'z') { e.preventDefault(); if (e.shiftKey) this.redo(e.currentTarget as HTMLElement); @@ -530,14 +608,14 @@ export class WordsProvider { readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => { this.isSelecting = false; + if (e.detail === 3) { + this.selectBlock(); + return; + } if (!this.isEditable) return; const target = e.target; const targetElement = - target instanceof Element - ? target - : target instanceof Node - ? target.parentElement - : null; + target instanceof Element ? target : target instanceof Node ? target.parentElement : null; if (!targetElement) return; const link = targetElement.closest('a[href]'); if (!link || !e.currentTarget.contains(link)) return; @@ -633,8 +711,13 @@ export class WordsProvider { canUndo: this.canUndo, canRedo: this.canRedo, canUnlink: this.canUnlink, + findQuery: this.findQuery, + findMatches: this.findMatches, + currentMatchIndex: this.currentMatchIndex, + hasFindMatches: this.hasFindMatches, focus: () => this.focus(), selectAll: () => this.selectAll(), + selectBlock: () => this.selectBlock(), runCommand: (command: WordsCommandName) => this.runCommandName(command), insertLink: (href: string, title?: string) => this.insertLink(href, title), unlink: () => this.unlink(), @@ -643,7 +726,11 @@ export class WordsProvider { exportContent: (format: WordsExportFormat) => this.exportContent(format), commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason), undo: () => this.undo(), - redo: () => this.redo() + redo: () => this.redo(), + setFindQuery: (query: string) => this.setFindQuery(query), + findNext: () => this.findNext(), + findPrev: () => this.findPrev(), + replaceAllText: (replacement: string) => this.replaceAllText(replacement) })); readonly props = $derived.by(() => @@ -661,6 +748,12 @@ export class WordsProvider { this.publishPresent(result.state, { documentChanged: false }); } + private isInsideWordsTool(element: HTMLElement): boolean { + return !!element.closest( + '[data-words-bubble-menu], [data-words-toolbar], [data-words-find-replace], [data-words-heading-picker], [data-words-link-editor]' + ); + } + private shouldIgnoreTransientSelectionCollapse( next: WordsSelection, root: HTMLElement, @@ -671,10 +764,7 @@ export class WordsProvider { const active = root.ownerDocument.activeElement; if (!(active instanceof HTMLElement)) return false; if (active === root) return false; - const toolElement = active.closest( - '[data-words-bubble-menu], [data-words-toolbar], [data-words-tool-popover]' - ); - if (toolElement) return true; + if (this.isInsideWordsTool(active)) return true; if (root.contains(active)) return false; return false; } @@ -693,6 +783,48 @@ export class WordsProvider { void tick().then(() => this.restoreDomSelection()); } + private selectBlock() { + if (this.isDisabled) return; + const selection = this.selection; + if (!selection) return; + const blockIndex = selection.anchor.path[0] ?? -1; + if (blockIndex < 0) return; + const entries = getTextEntries(this.document).filter((entry) => entry.path[0] === blockIndex); + if (!entries.length) return; + const first = entries[0]; + const last = entries.at(-1); + if (!first || !last) return; + this.closeTypingBatch(); + this.updateSelection({ + anchor: { path: first.path, offset: 0 }, + focus: { path: last.path, offset: last.text.text.length } + }); + void tick().then(() => this.restoreDomSelection()); + } + + readonly setFindQuery = (query: string) => { + this.findQuery = query; + this.currentMatchIndex = -1; + }; + + readonly findNext = () => { + if (!this.hasFindMatches) return; + const nextIndex = + this.currentMatchIndex + 1 < this.findMatches.length ? this.currentMatchIndex + 1 : 0; + this.activateFindMatch(nextIndex); + }; + + readonly findPrev = () => { + if (!this.hasFindMatches) return; + const nextIndex = + this.currentMatchIndex > 0 ? this.currentMatchIndex - 1 : this.findMatches.length - 1; + this.activateFindMatch(nextIndex); + }; + + readonly replaceAllText = (replacement: string) => { + this.runCommandName('replace-all-text', { replacement }); + }; + private ensureSelection() { if (this.selection) return; const first = getTextEntries(this.document)[0]; @@ -703,6 +835,17 @@ export class WordsProvider { }); } + private activateFindMatch(index: number) { + const match = this.findMatches[index]; + if (!match) return; + this.currentMatchIndex = index; + this.updateSelection({ + anchor: { path: match.path, offset: match.start }, + focus: { path: match.path, offset: match.end } + }); + void tick().then(() => this.restoreDomSelection()); + } + private historyCommand(command: WordsCommand): WordsHistory { return applyHistoryCommand(this.history, command); } @@ -748,7 +891,7 @@ export class WordsProvider { ) { const current = this.history.present; - if (externalDocument !== current.document) { + if (!sameWordsDocument(externalDocument, current.document)) { const nextState = createWordsState(externalDocument, externalSelection); if (!sameWordsDocument(nextState.document, current.document)) { const limit = this.history.limit; @@ -814,6 +957,7 @@ export class WordsProvider { return { type: 'setBlock', block: 'heading', level: level ?? 1 }; case 'quote': + if (this.currentBlock === 'quote') return { type: 'setBlock', block: 'paragraph' }; return { type: 'setBlock', block: 'quote' }; case 'align-left': return { type: 'setTextAlign', align: 'left' }; @@ -823,6 +967,10 @@ export class WordsProvider { return { type: 'setTextAlign', align: 'right' }; case 'align-justify': return { type: 'setTextAlign', align: 'justify' }; + case 'increase-indent': + return { type: 'increaseIndent' }; + case 'decrease-indent': + return { type: 'decreaseIndent' }; case 'unordered-list': return { type: 'toggleList', kind: 'unordered' }; case 'ordered-list': @@ -1074,9 +1222,7 @@ export class WordsBubbleMenuProvider { ...this.runtimePart.props, role: 'toolbar' as const, 'aria-orientation': 'horizontal' as const, - 'aria-label': this.provider.soma.langs.ts( - '#?components.words.bubble-menu|Selection toolbar' - ), + 'aria-label': this.provider.soma.langs.ts('#?components.words.bubbleMenu|Selection toolbar'), 'data-open': boolToEmptyStrOrUndef(this.open), 'data-positioned': boolToEmptyStrOrUndef(this.positioned), 'data-side': this.opts.side.current, @@ -1208,6 +1354,9 @@ export class WordsCommandButtonProvider { if (command === 'redo') return this.provider.isReadonly || !this.provider.canRedo; if (command === 'unlink') return this.provider.isReadonly || !this.provider.canUnlink; if (command === 'commit') return false; + if (command === 'increase-indent' || command === 'decrease-indent') { + return this.provider.isReadonly || this.provider.currentBlock !== 'list'; + } return this.provider.isReadonly; } } @@ -1253,8 +1402,7 @@ export class WordsLinkEditorProvider { readonly: this.provider.isReadonly, captureSelection: () => this.captureSelection(), validate: (href?: string) => this.validate(href), - apply: (href?: string, title?: string, target?: HTMLElement) => - this.apply(href, title, target), + apply: (href?: string, title?: string, target?: HTMLElement) => this.apply(href, title, target), unlink: (target?: HTMLElement) => this.unlink(target), clearError: () => this.clearError() })); @@ -1274,7 +1422,7 @@ export class WordsLinkEditorProvider { readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, - 'aria-label': this.provider.soma.langs.ts('#?components.words.link-editor|Edit link'), + 'aria-label': this.provider.soma.langs.ts('#?components.words.linkEditor|Edit link'), 'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), 'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)), @@ -1320,6 +1468,56 @@ export class WordsLinkEditorProvider { } } +export class WordsFindReplaceProvider { + readonly opts: WordsFindReplaceOpts; + readonly provider: WordsProvider; + readonly runtimePart: SomaRuntimePart; + + static create(opts: WordsFindReplaceOpts) { + return new WordsFindReplaceProvider(opts); + } + + private constructor(opts: WordsFindReplaceOpts) { + this.opts = opts; + this.provider = WordsProvider.require(); + this.runtimePart = this.provider.runtime.part('find-replace', { + id: opts.id, + ref: opts.ref, + owner: this, + props: { + disabled: () => this.provider.isDisabled, + readonly: () => this.provider.isReadonly, + hasMatches: () => this.provider.hasFindMatches + }, + syncAttrs: true + }); + } + + readonly snippetProps = $derived.by(() => ({ + findQuery: this.provider.findQuery, + findMatches: this.provider.findMatches, + currentMatchIndex: this.provider.currentMatchIndex, + hasFindMatches: this.provider.hasFindMatches, + disabled: this.provider.isDisabled, + readonly: this.provider.isReadonly, + setFindQuery: (query: string) => this.provider.setFindQuery(query), + findNext: () => this.provider.findNext(), + findPrev: () => this.provider.findPrev(), + replaceAllText: (replacement: string) => this.provider.replaceAllText(replacement) + })); + + readonly props = $derived.by(() => + this.runtimePart.assert({ + ...this.runtimePart.props, + role: 'search' as const, + 'aria-label': this.provider.soma.langs.ts('#?components.words.findReplace|Find and replace'), + 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly), + 'data-has-matches': boolToEmptyStrOrUndef(this.provider.hasFindMatches) + } as const) + ); +} + export class WordsHiddenInputProvider { readonly opts: WordsHiddenInputOpts; readonly provider: WordsProvider; @@ -1405,7 +1603,8 @@ function sameWordsSelection(a: WordsSelection | null, b: WordsSelection | null): } function sameWordsDocument(a: WordsDocument, b: WordsDocument): boolean { - if (a === b) return true; + if (a.version !== b.version) return false; + if (a.children.length !== b.children.length) return false; return serializeWordsJson(a) === serializeWordsJson(b); } diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 9ce7d13ea..284bac8f9 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -36,6 +36,10 @@ type DocumentPreset = 'sample' | 'empty' | 'long'; type ToolbarChoice = 'none' | WordsToolbarPreset | 'custom'; type TraceEntry = { event: string; family: string; intent?: string; at: number }; + type FindReplaceToolbarItem = Extract< + WordsToolbarGroupConfig[number], + 'find-replace' | { part: 'find-replace' } + >; type RawValue = { kind?: string; value?: unknown; prop?: string; part?: string }; type RawPart = { @@ -107,7 +111,9 @@ 'align-justify', 'unordered-list', 'ordered-list', - 'check-list' + 'check-list', + 'increase-indent', + 'decrease-indent' ], ['link-editor', 'unlink', 'clear-formatting', 'clear-document', 'commit'] ] @@ -131,6 +137,7 @@ let commitOnBlur = $state(true); let toolbarChoice = $state('full'); let showLinkEditor = $state(true); + let showFindReplace = $state(true); let showBubbleMenu = $state(false); let showStatus = $state(true); let orientation = $state('horizontal'); @@ -151,7 +158,9 @@ const selectionState = $derived(selectionLabel(selection)); const documentJson = $derived(JSON.stringify(value, null, 2)); const exportPreview = $derived(exportWordsDocument(value, exportFormat)); - const toolbarSnippetGroups = $derived(resolveDemoToolbarGroups(toolbarChoice, showLinkEditor)); + const toolbarSnippetGroups = $derived( + resolveDemoToolbarGroups(toolbarChoice, showLinkEditor, showFindReplace) + ); const toolbarConfig = $derived( toolbarChoice === 'none' ? false : toolbarSnippetGroups ); @@ -161,7 +170,9 @@ ? 'disabled' : `${toolbarChoice} · ${toolbarSnippetGroups.length} groups · ${toolbarCommandCount} items` ); - const bubbleState = $derived(showBubbleMenu ? 'selection toolbar enabled' : 'selection toolbar off'); + const bubbleState = $derived( + showBubbleMenu ? 'selection toolbar enabled' : 'selection toolbar off' + ); const somaSnippet = $derived( [ @@ -184,6 +195,7 @@ '>', ...somaToolbarSnippet(toolbarSnippetGroups, orientation), ...(showLinkEditor && toolbarChoice === 'none' ? somaLinkEditorSnippet() : []), + showFindReplace && toolbarChoice === 'none' && ' ', ' ', ' ', ...(showBubbleMenu ? somaBubbleMenuSnippet() : []), @@ -219,6 +231,7 @@ !commitOnBlur && ' commitOnBlur={false}', '>', showLinkEditor && toolbarChoice === 'none' && ' ', + showFindReplace && toolbarChoice === 'none' && ' ', ' ', ' ', showBubbleMenu && ' ', @@ -287,6 +300,18 @@ kind: 'soma', note: 'invalid link feedback' }, + { + selector: '[data-words-find-replace]', + source: 'morfo', + kind: 'soma', + note: 'find and replace panel' + }, + { + selector: '[data-words-content] mark[data-words-find-match]', + source: 'soma engine', + kind: 'soma', + note: 'rendered search matches' + }, { selector: '[data-words-placeholder][hidden]', source: 'morfo', @@ -305,6 +330,12 @@ kind: 'soma', note: 'rendered marks' }, + { + selector: '[data-words-content] [data-words-indent]', + source: 'soma engine', + kind: 'soma', + note: 'list item indent levels' + }, { selector: '[data-words-status][data-status]', source: 'morfo', @@ -372,6 +403,16 @@ type: 'list-item', children: [{ type: 'text', text: 'HiddenInput serializes the JSON payload.' }] }, + { + type: 'list-item', + indent: 1, + children: [ + { + type: 'text', + text: 'Indented list item: Shift+Tab to decrease, Tab to increase.' + } + ] + }, { type: 'list-item', children: [ @@ -436,6 +477,11 @@ type: 'list-item', children: [{ type: 'text', text: 'Inspect the Morfo tab for parts and attributes.' }] }, + { + type: 'list-item', + indent: 2, + children: [{ type: 'text', text: 'Nested view of indent level 2 (max 8).' }] + }, { type: 'list-item', children: [{ type: 'text', text: 'Use Sema play buttons against the mounted stage.' }] @@ -533,22 +579,38 @@ toolbarOrientation: WordsToolbarOrientation ): string[] { if (groups.length === 0) return []; + const toolbarGroups = groups + .map((group) => group.filter((item) => !isFindReplaceItem(item))) + .filter((group) => group.length > 0); + const hasFindReplace = groups.some((group) => group.some(isFindReplaceItem)); return [ - ` `, - ...groups.flatMap((group) => [ - ' ', - ...group.flatMap((command) => somaCommandSnippet(command)), - ' ' - ]), - ' ' - ]; + ...(toolbarGroups.length + ? [ + ` `, + ...toolbarGroups.flatMap((group) => [ + ' ', + ...group.flatMap((command) => somaCommandSnippet(command)), + ' ' + ]), + ' ' + ] + : []), + hasFindReplace && ' ' + ].filter((line): line is string => Boolean(line)); } function somaCommandSnippet(item: WordsToolbarGroupConfig[number]): string[] { - if (item === 'link-editor' || (typeof item === 'object' && 'part' in item && item.part === 'link-editor')) { + if (isFindReplaceItem(item)) return []; + if ( + item === 'link-editor' || + (typeof item === 'object' && 'part' in item && item.part === 'link-editor') + ) { return somaLinkEditorSnippet().map((line) => ` ${line.trimStart()}`); } - if (item === 'heading-picker' || (typeof item === 'object' && 'part' in item && item.part === 'heading-picker')) { + if ( + item === 'heading-picker' || + (typeof item === 'object' && 'part' in item && item.part === 'heading-picker') + ) { return [' ']; } const command = typeof item === 'string' ? item : item.command; @@ -592,21 +654,33 @@ function resolveDemoToolbarGroups( choice: ToolbarChoice, - includeLinkEditor: boolean + includeLinkEditor: boolean, + includeFindReplace: boolean ): WordsToolbarGroupConfig[] { if (choice === 'none') return []; const source = choice === 'custom' ? toolbarCustomGroups : toolbarPresetGroups[choice]; const groups = source.map((group) => group.filter( - (item) => item !== 'link-editor' && !(typeof item === 'object' && 'part' in item) + (item) => + item !== 'link-editor' && + !(typeof item === 'object' && 'part' in item && item.part === 'link-editor') ) ); - if (!includeLinkEditor) return groups; const lastIndex = Math.max(0, groups.length - 1); - groups[lastIndex] = ['link-editor', ...groups[lastIndex]]; + if (includeLinkEditor) groups[lastIndex] = ['link-editor', ...groups[lastIndex]]; + if (includeFindReplace) groups[lastIndex] = [...groups[lastIndex], 'find-replace']; return groups; } + function isFindReplaceItem( + item: WordsToolbarGroupConfig[number] + ): item is FindReplaceToolbarItem { + return ( + item === 'find-replace' || + (typeof item === 'object' && 'part' in item && item.part === 'find-replace') + ); + } + function countToolbarItems(groups: readonly WordsToolbarGroupConfig[]): number { return groups.reduce((total, group) => total + group.length, 0); } @@ -658,8 +732,9 @@

    Words

    Rich-text editor primitive with canonical JSON, contenteditable bridge, command toolbar, form - serialization and semantic events. Morfo declares the editor parts and semantic events; Soma - owns state, selection, input, links and history; Eidos only paints the emitted data contract. + serialization, find/replace and semantic events. Morfo declares the editor parts and semantic + events; Soma owns state, selection, input, links, search and history; Eidos only paints the + emitted data contract.

    - {#key documentRevision} - (lastCommit = reason)} - > - {#snippet children({ selectedLink })} - {#if showLinkEditor && toolbarChoice === 'none'} - - {/if} - {placeholder} - - {#if showBubbleMenu} - - - - - - - - - - - {#if showLinkEditor} - - {/if} - - {/if} - - {#if showStatus} - - {invalid - ? 'Invalid content' - : `${required ? 'Required editor' : 'Optional editor'} · ${wordCount} words · ${selectedLink ? selectedLink.href : lastCommit}`} - - {/if} - {/snippet} - - {/key} + (lastCommit = reason)} + > + {#snippet children({ selectedLink })} + {#if showLinkEditor && toolbarChoice === 'none'} + + {/if} + {#if showFindReplace && toolbarChoice === 'none'} + + {/if} + {placeholder} + + {#if showBubbleMenu} + + + + + + + + + + + {#if showLinkEditor} + + {/if} + + {/if} + + {#if showStatus} + + {invalid + ? 'Invalid content' + : `${required ? 'Required editor' : 'Optional editor'} · ${wordCount} words · ${selectedLink ? selectedLink.href : lastCommit}`} + + {/if} + {/snippet} +
    trace @@ -859,7 +935,7 @@ {/each} - {#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'bubbleMenu', 'status'] as key} + {#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'findReplace', 'bubbleMenu', 'status'] as key}