Improve words editor tooling

active-uix
dev 5 months ago
parent 5f7b2354a8
commit e6eee766ee

@ -24,6 +24,7 @@ rich-text ni wrapper visual reutilizable.
| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | | Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement |
| Import/export HTML | not owned | available | available | clean export; defer importer | partial | | Import/export HTML | not owned | available | available | clean export; defer importer | partial |
| Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` | implement | | 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 | | 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 | | 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 | | Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment |
| Commands | `Words.CommandButton` | active/disabled/commit styling | | Commands | `Words.CommandButton` | active/disabled/commit styling |
| Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset | | Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset |
| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels | | Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback | | Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Form | `Words.HiddenInput` | passthrough hidden JSON input | | Find/replace | `Words.FindReplace` | default search/replacement panel + match highlights |
| Status | `Words.Status` | idle/error status recipe | | Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command | | Status | `Words.Status` | idle/error status recipe |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command |
## Decisiones ## Decisiones
@ -97,6 +99,10 @@ rich-text ni wrapper visual reutilizable.
- `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica - `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica
`insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos `insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos
ofrece una UI por defecto que puede reemplazarse con snippet. 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 - `Words.BubbleMenu` es una parte publica compuesta y **opcional**: no se
incluye por defecto en `<Words>`; el consumidor debe agregarla explicitamente incluye por defecto en `<Words>`; el consumidor debe agregarla explicitamente
como `<Words.BubbleMenu>` dentro del snippet `children`. Solo aparece para como `<Words.BubbleMenu>` 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. - **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. - **Word boundary robusto**: algoritmo naive reemplazado por `Intl.Segmenter` + fallback para puntuacion/CJK.
Tests: ### Fase 0 — Deuda tecnica cerrada (2026-05-26)
- Engine: 42/42 pasan (incluye 5 nuevos de `setTextAlign`).
- Build: pasa (`npm run build` exitoso). - **Contrato CSS**: `--words-selection-color` ahora se consume correctamente en `words.css`; test `recipe-css-contract` verde.
- Nota: `words-content.svelte.test.ts` tiene 2 fallos pre-existentes de montaje Svelte en entorno jsdom (no relacionados con alignment). - **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 `<svelte:window onresize onscroll>` en `words-bubble-menu.svelte`; verificado en demo.
- **HTML import/export avanzado**: serializa `indent` como `style="padding-left: N*1.5em"` en `<li>`; 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 | ### Fase 3 — Find/replace cerrado (2026-05-26)
| ------- | ----------- | ----------- | -------- |
| **Indentacion de listas** (increase/decrease indent) | `operations.ts` (nueva operacion), `commands.ts`, `types.ts` (nuevos `CommandName`), toolbar UI | Media | Ninguno | - **Find/replace transversal**: `FindReplace` existe en Morfo, Soma y Eidos.
| **Atajos de teclado para alignment** (Ctrl/Cmd+L/R/E/J) | `words-provider.svelte.ts` (`onkeydown`), `words-content.svelte` (event forwarding) | Baja | Ninguno | El panel usa `role="search"`, propaga `disabled`/`readonly`, muestra contador
| **HeadingPicker keyboard navigation** (arrow keys + Enter en dropdown) | `words-heading-picker.svelte` | Baja | Ninguno | live, navega matches moviendo la seleccion real y ejecuta replace-all con el
| **Toolbar tooltips** (aria-label como tooltip nativo o title) | `words.svelte` + CSS | Baja | Ninguno | evento `commit-set-replace`.
| **Seleccion de bloque entero** (triple-click o margin-click) | `words-provider.svelte.ts` + `operations.ts` | Media | Ninguno | - **Toolbar config segura**: el `toolbar` de Eidos permite `find-replace` como
| **Soft breaks** (Shift+Enter dentro de paragraph sin crear nuevo bloque) | `operations.ts` (nuevo `insertSoftBreak`), `input.ts` | Media | Disenar: como representar `<br>` en el modelo JSON | 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 |

@ -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.

@ -9,6 +9,7 @@ import HeadingPicker from './words-heading-picker.svelte';
import LinkEditor from './words-link-editor.svelte'; import LinkEditor from './words-link-editor.svelte';
import HiddenInput from './words-hidden-input.svelte'; import HiddenInput from './words-hidden-input.svelte';
import Status from './words-status.svelte'; import Status from './words-status.svelte';
import FindReplace from './words-find-replace.svelte';
type WordsNamespace = typeof WordsComponent & { type WordsNamespace = typeof WordsComponent & {
Content: typeof Content; Content: typeof Content;
@ -21,6 +22,7 @@ type WordsNamespace = typeof WordsComponent & {
LinkEditor: typeof LinkEditor; LinkEditor: typeof LinkEditor;
HiddenInput: typeof HiddenInput; HiddenInput: typeof HiddenInput;
Status: typeof Status; Status: typeof Status;
FindReplace: typeof FindReplace;
}; };
const Words = WordsComponent as WordsNamespace; const Words = WordsComponent as WordsNamespace;
@ -34,6 +36,7 @@ Words.HeadingPicker = HeadingPicker;
Words.LinkEditor = LinkEditor; Words.LinkEditor = LinkEditor;
Words.HiddenInput = HiddenInput; Words.HiddenInput = HiddenInput;
Words.Status = Status; Words.Status = Status;
Words.FindReplace = FindReplace;
export { Words }; export { Words };
export default Words; export default Words;
@ -42,6 +45,7 @@ export type {
WordsCommandButtonProps as CommandButtonProps, WordsCommandButtonProps as CommandButtonProps,
WordsColor, WordsColor,
WordsContentProps as ContentProps, WordsContentProps as ContentProps,
WordsFindReplaceProps as FindReplaceProps,
WordsHiddenInputProps as HiddenInputProps, WordsHiddenInputProps as HiddenInputProps,
WordsLinkEditorProps as LinkEditorProps, WordsLinkEditorProps as LinkEditorProps,
WordsPlaceholderProps as PlaceholderProps, WordsPlaceholderProps as PlaceholderProps,
@ -50,9 +54,12 @@ export type {
WordsStatusProps as StatusProps, WordsStatusProps as StatusProps,
WordsBubbleMenuProps as BubbleMenuProps, WordsBubbleMenuProps as BubbleMenuProps,
WordsBubbleMenuSnippetProps, WordsBubbleMenuSnippetProps,
WordsToolbarButtonCommandName,
WordsToolbarCommandConfig, WordsToolbarCommandConfig,
WordsToolbarConfig, WordsToolbarConfig,
WordsToolbarFindReplaceConfig,
WordsToolbarGroupConfig, WordsToolbarGroupConfig,
WordsToolbarHeadingPickerConfig,
WordsToolbarItemConfig, WordsToolbarItemConfig,
WordsToolbarLinkEditorConfig, WordsToolbarLinkEditorConfig,
WordsToolbarGroupProps as ToolbarGroupProps, WordsToolbarGroupProps as ToolbarGroupProps,

@ -3,6 +3,7 @@ import type {
BubbleMenuProps, BubbleMenuProps,
CommandButtonProps, CommandButtonProps,
ContentProps, ContentProps,
FindReplaceProps,
HiddenInputProps, HiddenInputProps,
LinkEditorProps, LinkEditorProps,
PlaceholderProps, PlaceholderProps,
@ -18,16 +19,21 @@ import type {
WordsListKind, WordsListKind,
WordsToolbarOrientation WordsToolbarOrientation
} from '$soma/components/words'; } 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'; import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>; export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type WordsVariant = ControlVariant; export type WordsVariant = ControlVariant;
export type WordsColor = ColorRole; export type WordsColor = ColorRole;
export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full'; export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full';
export type WordsToolbarButtonCommandName = Exclude<
WordsCommandName,
'find-text' | 'replace-all-text'
>;
export type WordsToolbarCommandConfig = export type WordsToolbarCommandConfig =
| WordsCommandName | WordsToolbarButtonCommandName
| { | {
command: WordsCommandName; command: WordsToolbarButtonCommandName;
label?: string; label?: string;
level?: WordsHeadingLevel; level?: WordsHeadingLevel;
listKind?: WordsListKind; listKind?: WordsListKind;
@ -44,10 +50,17 @@ export type WordsToolbarHeadingPickerConfig =
part: 'heading-picker'; part: 'heading-picker';
label?: string; label?: string;
}; };
export type WordsToolbarFindReplaceConfig =
| 'find-replace'
| {
part: 'find-replace';
label?: string;
};
export type WordsToolbarItemConfig = export type WordsToolbarItemConfig =
| WordsToolbarCommandConfig | WordsToolbarCommandConfig
| WordsToolbarLinkEditorConfig | WordsToolbarLinkEditorConfig
| WordsToolbarHeadingPickerConfig; | WordsToolbarHeadingPickerConfig
| WordsToolbarFindReplaceConfig;
export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[]; export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[];
export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[]; export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[];
@ -70,7 +83,22 @@ export type WordsToolbarProps = ToolbarProps;
export type WordsToolbarGroupProps = ToolbarGroupProps; export type WordsToolbarGroupProps = ToolbarGroupProps;
export type WordsBubbleMenuProps = BubbleMenuProps; export type WordsBubbleMenuProps = BubbleMenuProps;
export type WordsBubbleMenuSnippetProps = SomaWordsBubbleMenuSnippetProps; 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<ButtonSize>;
/** Radius forwarded to Eidos Button. @default 'md' */
rounded?: ResponsiveProp<ButtonRounded>;
/** 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 WordsLinkEditorProps = LinkEditorProps;
export type WordsHiddenInputProps = HiddenInputProps; export type WordsHiddenInputProps = HiddenInputProps;
export type WordsStatusProps = StatusProps; export type WordsStatusProps = StatusProps;
export type WordsFindReplaceProps = FindReplaceProps & {
/** Stamp toolbar item markers when rendered directly inside Words.Toolbar. @default true */
toolbarItem?: boolean;
};

@ -1,14 +1,64 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$uix/eidos/components/button';
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { WordsCommandButtonProps } from './types'; import type { WordsCommandButtonProps } from './types';
let { children, ...rest }: WordsCommandButtonProps = $props(); let {
children,
child: outerChild,
variant = 'ghost',
size = 'xs',
rounded = 'md',
iconOnly = true,
toolbarItem = true,
...rest
}: WordsCommandButtonProps = $props();
</script> </script>
{#if children} <Words.CommandButton {...rest}>
<Words.CommandButton {...rest}> {#snippet child({ props })}
{@render children()} {#if outerChild}
</Words.CommandButton> {@render outerChild({
{:else} props: {
<Words.CommandButton {...rest} /> ...props,
{/if} 'data-toolbar-button': toolbarItem ? '' : undefined,
'data-toolbar-group-item': toolbarItem ? '' : undefined
}
})}
{:else}
{@const label = props['aria-label']}
{#if children}
<Button
{...props}
{variant}
{size}
{rounded}
{iconOnly}
intent="neutral"
color="neutral"
data-toolbar-button={toolbarItem ? '' : undefined}
data-toolbar-group-item={toolbarItem ? '' : undefined}
>
{#snippet icon()}
{@render children()}
{/snippet}
{typeof label === 'string' ? label : rest.command}
</Button>
{:else}
<Button
{...props}
{variant}
{size}
{rounded}
iconOnly={false}
intent="neutral"
color="neutral"
data-toolbar-button={toolbarItem ? '' : undefined}
data-toolbar-group-item={toolbarItem ? '' : undefined}
>
{rest.command}
</Button>
{/if}
{/if}
{/snippet}
</Words.CommandButton>

@ -0,0 +1,149 @@
<script lang="ts">
import { Search, Replace, ChevronUp, ChevronDown } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { Popover } from '$uix/eidos/components/popover';
import * as Words from '$soma/components/words';
import type { FindReplaceSnippetProps } from '$soma/components/words';
import type { WordsFindReplaceProps } from './types';
let { children: customChildren, toolbarItem = true, ...rest }: WordsFindReplaceProps = $props();
let open = $state(false);
let replacement = $state('');
type FindReplaceChildProps = FindReplaceSnippetProps & {
props: Record<string, unknown>;
};
</script>
<Words.FindReplace {...rest}>
{#snippet child(props)}
{@const sp = props as FindReplaceChildProps}
<Popover bind:open>
<Popover.Trigger
data-words-find-trigger
data-toolbar-button={toolbarItem ? '' : undefined}
data-toolbar-group-item={toolbarItem ? '' : undefined}
data-has-matches={sp.hasFindMatches ? '' : undefined}
aria-label="Find and replace"
title="Find and replace"
disabled={sp.disabled || undefined}
>
<Search size="sm" decorative />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-words-floating
side="top"
align="center"
sideOffset={8}
arrowPadding={8}
size="sm"
width="min(24rem, calc(100vw - var(--space-4)))"
>
<Popover.Arrow />
<div {...sp.props}>
{#if customChildren}
{@render customChildren(sp)}
{:else}
<div data-words-find-row>
<span data-words-find-label aria-hidden="true">
<Search size="sm" decorative />
</span>
<input
type="text"
data-words-find-input
value={sp.findQuery}
aria-label="Find text"
placeholder="Find..."
disabled={sp.disabled || undefined}
oninput={(e) => {
sp.setFindQuery(e.currentTarget.value);
}}
/>
<span data-words-find-counter aria-live="polite">
{#if sp.hasFindMatches}
{sp.currentMatchIndex + 1} / {sp.findMatches.length}
{:else}
0 / 0
{/if}
</span>
<Button
type="button"
data-words-find-prev
aria-label="Previous match"
variant="ghost"
size="xs"
rounded="md"
iconOnly
intent="neutral"
color="neutral"
disabled={sp.disabled || !sp.hasFindMatches}
onclick={() => sp.findPrev()}
>
{#snippet icon()}
<ChevronUp size="sm" decorative />
{/snippet}
Previous match
</Button>
<Button
type="button"
data-words-find-next
aria-label="Next match"
variant="ghost"
size="xs"
rounded="md"
iconOnly
intent="neutral"
color="neutral"
disabled={sp.disabled || !sp.hasFindMatches}
onclick={() => sp.findNext()}
>
{#snippet icon()}
<ChevronDown size="sm" decorative />
{/snippet}
Next match
</Button>
</div>
<div data-words-replace-row>
<span data-words-replace-label aria-hidden="true">
<Replace size="sm" decorative />
</span>
<input
type="text"
data-words-replace-input
value={replacement}
aria-label="Replace with"
placeholder="Replace..."
disabled={sp.disabled || sp.readonly || undefined}
oninput={(e) => {
replacement = e.currentTarget.value;
}}
/>
<Button
type="button"
data-words-replace-all
aria-label="Replace all"
variant="solid"
size="xs"
rounded="md"
iconOnly
disabled={sp.disabled || sp.readonly || !sp.hasFindMatches}
onclick={() => {
sp.replaceAllText(replacement);
replacement = '';
}}
>
{#snippet icon()}
<Replace size="sm" decorative />
{/snippet}
Replace all
</Button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
{/snippet}
</Words.FindReplace>

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Heading1, Heading2, Heading3, Pilcrow } from '$uix/eidos/components/icon'; import { Heading1, Heading2, Heading3, Pilcrow } from '$uix/eidos/components/icon';
import { Popover } from '$uix/eidos/components/popover'; import { Popover } from '$uix/eidos/components/popover';
import * as Words from '$soma/components/words'; import CommandButton from './words-command-button.svelte';
import type { WordsHeadingLevel } from '$soma/components/words'; import type { WordsHeadingLevel } from '$soma/components/words';
let { let {
@ -14,16 +14,19 @@
let open = $state(false); let open = $state(false);
const headingLabel = $derived.by(() => { const triggerLabel = $derived(
if (currentBlock === 'heading' && currentHeadingLevel === 1) return 'Heading 1'; currentBlock === 'heading' ? `Heading ${currentHeadingLevel ?? 1}` : 'Paragraph'
if (currentBlock === 'heading' && currentHeadingLevel === 2) return 'Heading 2'; );
if (currentBlock === 'heading' && currentHeadingLevel === 3) return 'Heading 3';
return 'Paragraph';
});
</script> </script>
<Popover bind:open> <Popover bind:open>
<Popover.Trigger aria-label={headingLabel}> <Popover.Trigger
data-words-heading-picker
data-toolbar-button
data-toolbar-group-item
aria-label="Choose heading level"
title={triggerLabel}
>
{#if currentBlock === 'heading' && currentHeadingLevel === 1} {#if currentBlock === 'heading' && currentHeadingLevel === 1}
<Heading1 size="sm" decorative /> <Heading1 size="sm" decorative />
{:else if currentBlock === 'heading' && currentHeadingLevel === 2} {:else if currentBlock === 'heading' && currentHeadingLevel === 2}
@ -35,39 +38,53 @@
{/if} {/if}
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
<Popover.Content side="bottom" align="center" sideOffset={4}> <Popover.Content
<div data-words-heading-picker> data-words-floating
<Words.CommandButton side="top"
align="center"
sideOffset={8}
arrowPadding={8}
size="sm"
width="max-content"
minWidth="max-content"
>
<Popover.Arrow />
<div data-words-heading-picker-panel>
<CommandButton
command="heading" command="heading"
level={1} level={1}
aria-label="Heading 1" aria-label="Heading 1"
toolbarItem={false}
onclick={() => (open = false)} onclick={() => (open = false)}
> >
<Heading1 size="sm" decorative /> <Heading1 size="sm" decorative />
</Words.CommandButton> </CommandButton>
<Words.CommandButton <CommandButton
command="heading" command="heading"
level={2} level={2}
aria-label="Heading 2" aria-label="Heading 2"
toolbarItem={false}
onclick={() => (open = false)} onclick={() => (open = false)}
> >
<Heading2 size="sm" decorative /> <Heading2 size="sm" decorative />
</Words.CommandButton> </CommandButton>
<Words.CommandButton <CommandButton
command="heading" command="heading"
level={3} level={3}
aria-label="Heading 3" aria-label="Heading 3"
toolbarItem={false}
onclick={() => (open = false)} onclick={() => (open = false)}
> >
<Heading3 size="sm" decorative /> <Heading3 size="sm" decorative />
</Words.CommandButton> </CommandButton>
<Words.CommandButton <CommandButton
command="paragraph" command="paragraph"
aria-label="Paragraph" aria-label="Paragraph"
toolbarItem={false}
onclick={() => (open = false)} onclick={() => (open = false)}
> >
<Pilcrow size="sm" decorative /> <Pilcrow size="sm" decorative />
</Words.CommandButton> </CommandButton>
</div> </div>
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, Link2, Unlink, X } from '$uix/eidos/components/icon'; import { Check, Link2, Unlink, X } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { Popover } from '$uix/eidos/components/popover'; import { Popover } from '$uix/eidos/components/popover';
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { LinkEditorSnippetProps } from '$soma/components/words'; import type { LinkEditorSnippetProps } from '$soma/components/words';
@ -31,6 +32,8 @@
<Popover bind:open> <Popover bind:open>
<span role="button" tabindex="-1" onpointerdown={() => sp.captureSelection()}> <span role="button" tabindex="-1" onpointerdown={() => sp.captureSelection()}>
<Popover.Trigger <Popover.Trigger
data-toolbar-button
data-toolbar-group-item
aria-label="Edit link" aria-label="Edit link"
disabled={sp.disabled || sp.readonly || undefined} disabled={sp.disabled || sp.readonly || undefined}
> >
@ -38,7 +41,14 @@
</Popover.Trigger> </Popover.Trigger>
</span> </span>
<Popover.Portal> <Popover.Portal>
<Popover.Content side="bottom" align="center" sideOffset={4}> <Popover.Content
data-words-floating
side="top"
align="center"
sideOffset={8}
arrowPadding={8}
>
<Popover.Arrow />
<form <form
bind:this={formRef} bind:this={formRef}
{...formProps(sp.props)} {...formProps(sp.props)}
@ -88,10 +98,14 @@
</label> </label>
<input type="hidden" name="title" value={title} /> <input type="hidden" name="title" value={title} />
<div data-words-link-actions> <div data-words-link-actions>
<button <Button
data-words-link-apply data-words-link-apply
type="button" type="button"
aria-label="Apply link" aria-label="Apply link"
variant="solid"
size="xs"
rounded="md"
iconOnly
disabled={sp.disabled || sp.readonly || undefined} disabled={sp.disabled || sp.readonly || undefined}
onclick={() => { onclick={() => {
if (sp.apply(href, title, formRef ?? undefined)) { if (sp.apply(href, title, formRef ?? undefined)) {
@ -99,12 +113,21 @@
} }
}} }}
> >
<Check size="sm" decorative /> {#snippet icon()}
</button> <Check size="sm" decorative />
<button {/snippet}
Apply link
</Button>
<Button
data-words-link-remove data-words-link-remove
type="button" type="button"
aria-label="Remove link" aria-label="Remove link"
variant="ghost"
size="xs"
rounded="md"
iconOnly
intent="neutral"
color="neutral"
disabled={!sp.canUnlink} disabled={!sp.canUnlink}
onclick={() => { onclick={() => {
if (sp.unlink(formRef ?? undefined)) { if (sp.unlink(formRef ?? undefined)) {
@ -112,8 +135,11 @@
} }
}} }}
> >
<Unlink size="sm" decorative /> {#snippet icon()}
</button> <Unlink size="sm" decorative />
{/snippet}
Remove link
</Button>
</div> </div>
{#if sp.error} {#if sp.error}
<span data-words-link-error>{sp.error}</span> <span data-words-link-error>{sp.error}</span>

@ -1,117 +0,0 @@
<script lang="ts">
import { X } from '$uix/eidos/components/icon';
import { Popover } from '$uix/eidos/components/popover';
import type { WordsToolPopoverProps } from './types';
let {
open = $bindable(false),
tool,
triggerLabel,
triggerTitle = triggerLabel,
closeLabel = 'Close',
side = 'bottom',
sideOffset = 6,
align = 'start',
alignOffset,
width = 'min(92vw, 32em)',
minWidth = 'min(92vw, 24em)',
maxWidth = 'min(92vw, 32em)',
collisionPadding,
avoidCollisions,
trigger,
children,
onTriggerPointerDown,
onBeforeDismiss,
onCancel
}: WordsToolPopoverProps = $props();
let closeBlocked = false;
function canDismiss(): boolean {
const valid = onBeforeDismiss?.() ?? true;
closeBlocked = !valid;
return valid;
}
function guardDismiss(event: Event) {
if (canDismiss()) return;
event.preventDefault();
}
function preserveEditorSelection(event: PointerEvent) {
onTriggerPointerDown?.(event);
event.preventDefault();
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && !canDismiss()) {
open = true;
return;
}
closeBlocked = false;
open = nextOpen;
}
function handleCloseAutoFocus(event: Event) {
if (!closeBlocked) return;
event.preventDefault();
}
function close() {
closeBlocked = false;
open = false;
}
function cancel() {
onCancel?.();
close();
}
</script>
<Popover bind:open onOpenChange={handleOpenChange}>
<Popover.Trigger
data-words-tool-popover-trigger
data-words-link-trigger={tool === 'link' || undefined}
data-tool={tool}
aria-label={triggerLabel}
title={triggerTitle}
onpointerdown={preserveEditorSelection}
>
{@render trigger?.()}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-words-tool-popover
data-words-link-popover={tool === 'link' || undefined}
data-tool={tool}
size="sm"
{side}
{align}
{sideOffset}
{alignOffset}
{width}
{minWidth}
{maxWidth}
{collisionPadding}
{avoidCollisions}
onCloseAutoFocus={handleCloseAutoFocus}
onInteractOutside={guardDismiss}
onEscapeKeydown={guardDismiss}
>
<Popover.Arrow />
<div data-words-tool-popover-row>
<div data-words-tool-popover-body>
{@render children?.({ open, close })}
</div>
<button
data-words-tool-popover-close
type="button"
aria-label={closeLabel}
onclick={cancel}
>
<X size="sm" decorative />
</button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>

@ -2,9 +2,22 @@
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { WordsToolbarGroupProps } from './types'; import type { WordsToolbarGroupProps } from './types';
let { children, ...rest }: WordsToolbarGroupProps = $props(); let { children, child: outerChild, ...rest }: WordsToolbarGroupProps = $props();
</script> </script>
<Words.ToolbarGroup {...rest}> <Words.ToolbarGroup {...rest}>
{@render children?.()} {#snippet child({ props })}
{#if outerChild}
{@render outerChild({
props: {
...props,
'data-toolbar-group': ''
}
})}
{:else}
<div {...props} data-toolbar-group>
{@render children?.()}
</div>
{/if}
{/snippet}
</Words.ToolbarGroup> </Words.ToolbarGroup>

@ -1,10 +1,33 @@
<script lang="ts"> <script lang="ts">
import { mergeProps } from '$uix/soma/props';
import { Toolbar } from '$uix/eidos/components/toolbar';
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { WordsToolbarProps } from './types'; import type { WordsToolbarProps } from './types';
let { children, ...rest }: WordsToolbarProps = $props(); let {
orientation = 'horizontal',
'aria-label': ariaLabel = 'Formatting toolbar',
children,
child: outerChild,
...rest
}: WordsToolbarProps = $props();
const toolbarAriaLabel = $derived(ariaLabel ?? undefined);
</script> </script>
<Words.Toolbar {...rest}> <Words.Toolbar {...rest} {orientation} aria-label={toolbarAriaLabel}>
{@render children?.()} {#snippet child({ props: wordsProps })}
<Toolbar {orientation} aria-label={toolbarAriaLabel} size="sm" variant="ghost">
{#snippet child({ props: toolbarProps })}
{@const props = mergeProps(toolbarProps, wordsProps)}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<div {...props}>
{@render children?.()}
</div>
{/if}
{/snippet}
</Toolbar>
{/snippet}
</Words.Toolbar> </Words.Toolbar>

@ -14,7 +14,42 @@
--_words-accent-border: var(--words-color-primary-border); --_words-accent-border: var(--words-color-primary-border);
--_words-accent-text: var(--words-color-primary-text); --_words-accent-text: var(--words-color-primary-text);
--_words-selection-bg: color-mix(in srgb, var(--_words-accent-solid) 78%, transparent); --_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; position: relative;
display: flex; display: flex;
@ -151,15 +186,23 @@
grid-template-rows: auto auto; 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] { [data-words-toolbar] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: var(--words-toolbar-gap); gap: var(--words-toolbar-gap);
inline-size: 100%;
padding-block: var(--_words-toolbar-py); padding-block: var(--_words-toolbar-py);
padding-inline: var(--_words-toolbar-px); padding-inline: var(--_words-toolbar-px);
border: 0;
border-block-end: var(--words-border-width) solid var(--words-toolbar-border); border-block-end: var(--words-border-width) solid var(--words-toolbar-border);
border-radius: 0;
background: var(--words-toolbar-bg); background: var(--words-toolbar-bg);
box-shadow: none;
} }
[data-words-toolbar][data-orientation='vertical'] { [data-words-toolbar][data-orientation='vertical'] {
@ -221,35 +264,8 @@
} }
[data-words-command-button] { [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); min-inline-size: var(--_words-button-size);
block-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], [data-words-command-button][data-active],
@ -277,6 +293,20 @@
opacity: var(--words-disabled-opacity); 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] { [data-words-link-editor] {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@ -295,14 +325,53 @@
} }
[data-words-heading-picker] { [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; align-items: center;
gap: var(--words-command-gap); justify-content: center;
padding: var(--words-command-gap); 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] { [data-words-find-trigger]:hover:not(:disabled):not([data-disabled]) {
flex: 0 0 auto; 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] { [data-words-link-field] {
@ -467,6 +536,18 @@
line-height: var(--words-heading-line-height); 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) { [data-words-content] :where(strong) {
font-weight: var(--words-strong-font-weight); font-weight: var(--words-strong-font-weight);
} }
@ -519,6 +600,42 @@
padding-inline-start: var(--words-list-padding-inline-start); 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) { [data-words-content] :where(a) {
color: var(--_words-accent-text); color: var(--_words-accent-text);
text-decoration-color: var(--_words-accent-border); text-decoration-color: var(--_words-accent-border);
@ -541,6 +658,150 @@
color: var(--words-status-error-color); 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) { @media (prefers-reduced-motion: reduce) {
[data-words], [data-words],
[data-words-command-button] { [data-words-command-button] {

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActiveEidos } from '$uix/eidos'; import { ActiveEidos } from '$uix/eidos';
import * as Words from '$soma/components/words'; import * as SomaWords from '$soma/components/words';
import { import {
Bold, Bold,
Code, Code,
@ -11,12 +11,15 @@
Italic, Italic,
List, List,
ListChecks, ListChecks,
ListIndentDecrease,
ListIndentIncrease,
ListOrdered, ListOrdered,
Pilcrow, Pilcrow,
Quote, Quote,
Redo2, Redo2,
Save, Save,
Strikethrough, Strikethrough,
Trash2,
TextAlignCenter, TextAlignCenter,
TextAlignEnd, TextAlignEnd,
TextAlignJustify, TextAlignJustify,
@ -25,18 +28,24 @@
Unlink, Unlink,
Undo2 Undo2
} from '$uix/eidos/components/icon'; } from '$uix/eidos/components/icon';
import CommandButton from './words-command-button.svelte';
import FindReplace from './words-find-replace.svelte';
import HeadingPicker from './words-heading-picker.svelte'; import HeadingPicker from './words-heading-picker.svelte';
import LinkEditor from './words-link-editor.svelte'; import LinkEditor from './words-link-editor.svelte';
import Toolbar from './words-toolbar.svelte';
import ToolbarGroup from './words-toolbar-group.svelte';
import type { import type {
WordsProps, WordsProps,
WordsToolbarButtonCommandName,
WordsToolbarCommandConfig, WordsToolbarCommandConfig,
WordsToolbarConfig, WordsToolbarConfig,
WordsToolbarGroupConfig, WordsToolbarGroupConfig,
WordsToolbarFindReplaceConfig,
WordsToolbarHeadingPickerConfig, WordsToolbarHeadingPickerConfig,
WordsToolbarLinkEditorConfig, WordsToolbarLinkEditorConfig,
WordsToolbarPreset WordsToolbarPreset
} from './types'; } from './types';
import type { WordsCommandName, WordsHeadingLevel, WordsListKind } from '$soma/components/words'; import type { WordsHeadingLevel, WordsListKind } from '$soma/components/words';
let { let {
size = 'md', size = 'md',
@ -53,13 +62,18 @@
const eidos = ActiveEidos.require(); const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md')); const resolvedSize = $derived(eidos.resolve(size, 'md'));
const toolbarGroups = $derived(resolveToolbarGroups(toolbar)); const toolbarGroups = $derived(resolveToolbarGroups(toolbar));
const visibleToolbarGroups = $derived.by((): readonly WordsVisibleToolbarGroupConfig[] =>
toolbarGroups.map((group) => group.filter(isToolbarItem)).filter((group) => group.length > 0)
);
type NormalizedToolbarCommand = { type NormalizedToolbarCommand = {
command: WordsCommandName; command: WordsToolbarButtonCommandName;
label: string; label: string;
level?: WordsHeadingLevel; level?: WordsHeadingLevel;
listKind?: WordsListKind; listKind?: WordsListKind;
}; };
type WordsVisibleToolbarItemConfig = WordsToolbarGroupConfig[number];
type WordsVisibleToolbarGroupConfig = readonly WordsVisibleToolbarItemConfig[];
const TOOLBAR_PRESETS: Record<WordsToolbarPreset, readonly WordsToolbarGroupConfig[]> = { const TOOLBAR_PRESETS: Record<WordsToolbarPreset, readonly WordsToolbarGroupConfig[]> = {
minimal: [['bold', 'italic', 'underline']], minimal: [['bold', 'italic', 'underline']],
@ -79,13 +93,15 @@
'align-justify', 'align-justify',
'unordered-list', 'unordered-list',
'ordered-list', 'ordered-list',
'check-list' 'check-list',
'increase-indent',
'decrease-indent'
], ],
['link-editor', 'unlink', 'clear-formatting', 'clear-document', 'commit'] ['link-editor', 'unlink', 'clear-formatting', 'clear-document', 'commit']
] ]
}; };
const COMMAND_LABELS: Record<WordsCommandName, string> = { const COMMAND_LABELS: Record<WordsToolbarButtonCommandName, string> = {
undo: 'Undo', undo: 'Undo',
redo: 'Redo', redo: 'Redo',
bold: 'Bold', bold: 'Bold',
@ -100,6 +116,8 @@
'align-center': 'Align center', 'align-center': 'Align center',
'align-right': 'Align right', 'align-right': 'Align right',
'align-justify': 'Justify', 'align-justify': 'Justify',
'increase-indent': 'Increase indent',
'decrease-indent': 'Decrease indent',
'unordered-list': 'Unordered list', 'unordered-list': 'Unordered list',
'ordered-list': 'Ordered list', 'ordered-list': 'Ordered list',
'check-list': 'Check list', 'check-list': 'Check list',
@ -131,17 +149,38 @@
function isLinkEditorConfig( function isLinkEditorConfig(
item: WordsToolbarGroupConfig[number] item: WordsToolbarGroupConfig[number]
): item is WordsToolbarLinkEditorConfig { ): item is WordsToolbarLinkEditorConfig {
return item === 'link-editor' || (typeof item === 'object' && 'part' in item && item.part === 'link-editor'); return (
item === 'link-editor' ||
(typeof item === 'object' && 'part' in item && item.part === 'link-editor')
);
} }
function isHeadingPickerConfig( function isHeadingPickerConfig(
item: WordsToolbarGroupConfig[number] item: WordsToolbarGroupConfig[number]
): item is WordsToolbarHeadingPickerConfig { ): item is WordsToolbarHeadingPickerConfig {
return item === 'heading-picker' || (typeof item === 'object' && 'part' in item && item.part === 'heading-picker'); return (
item === 'heading-picker' ||
(typeof item === 'object' && 'part' in item && item.part === 'heading-picker')
);
}
function isFindReplaceConfig(
item: WordsToolbarGroupConfig[number]
): item is WordsToolbarFindReplaceConfig {
return (
item === 'find-replace' ||
(typeof item === 'object' && 'part' in item && item.part === 'find-replace')
);
}
function isToolbarItem(
item: WordsToolbarGroupConfig[number]
): item is WordsVisibleToolbarItemConfig {
return Boolean(item);
} }
</script> </script>
<Words.Provider <SomaWords.Provider
{...rest} {...rest}
bind:value bind:value
bind:selection bind:selection
@ -150,21 +189,23 @@
data-color={color} data-color={color}
> >
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{#if toolbarGroups.length} {#if visibleToolbarGroups.length}
<Words.Toolbar orientation={toolbarOrientation}> <Toolbar orientation={toolbarOrientation} aria-label="Formatting toolbar">
{#each toolbarGroups as group} {#each visibleToolbarGroups as group}
<Words.ToolbarGroup orientation={toolbarOrientation}> <ToolbarGroup orientation={toolbarOrientation}>
{#each group as item} {#each group as item}
{#if isLinkEditorConfig(item)} {#if isLinkEditorConfig(item)}
<LinkEditor /> <LinkEditor />
{:else if isHeadingPickerConfig(item)} {:else if isHeadingPickerConfig(item)}
<HeadingPicker <HeadingPicker
currentBlock={snippetProps.currentBlock} currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel} currentHeadingLevel={snippetProps.currentHeadingLevel}
/> />
{:else} {:else if isFindReplaceConfig(item)}
{@const command = normalizeCommand(item)} <FindReplace />
<Words.CommandButton {:else}
{@const command = normalizeCommand(item)}
<CommandButton
command={command.command} command={command.command}
level={command.level} level={command.level}
listKind={command.listKind} listKind={command.listKind}
@ -186,38 +227,44 @@
<Code size="sm" decorative /> <Code size="sm" decorative />
{:else if command.command === 'paragraph'} {:else if command.command === 'paragraph'}
<Pilcrow size="sm" decorative /> <Pilcrow size="sm" decorative />
{:else if command.command === 'heading'} {:else if command.command === 'heading'}
<Heading1 size="sm" decorative /> <Heading1 size="sm" decorative />
{:else if command.command === 'quote'} {:else if command.command === 'quote'}
<Quote size="sm" decorative /> <Quote size="sm" decorative />
{:else if command.command === 'unordered-list'} {:else if command.command === 'unordered-list'}
<List size="sm" decorative /> <List size="sm" decorative />
{:else if command.command === 'ordered-list'} {:else if command.command === 'ordered-list'}
<ListOrdered size="sm" decorative /> <ListOrdered size="sm" decorative />
{:else if command.command === 'check-list'} {:else if command.command === 'check-list'}
<ListChecks size="sm" decorative /> <ListChecks size="sm" decorative />
{:else if command.command === 'align-left'} {:else if command.command === 'increase-indent'}
<TextAlignStart size="sm" decorative /> <ListIndentIncrease size="sm" decorative />
{:else if command.command === 'align-center'} {:else if command.command === 'decrease-indent'}
<TextAlignCenter size="sm" decorative /> <ListIndentDecrease size="sm" decorative />
{:else if command.command === 'align-right'} {:else if command.command === 'align-left'}
<TextAlignEnd size="sm" decorative /> <TextAlignStart size="sm" decorative />
{:else if command.command === 'align-justify'} {:else if command.command === 'align-center'}
<TextAlignJustify size="sm" decorative /> <TextAlignCenter size="sm" decorative />
{:else if command.command === 'unlink'} {:else if command.command === 'align-right'}
<Unlink size="sm" decorative /> <TextAlignEnd size="sm" decorative />
{:else if command.command === 'commit'} {:else if command.command === 'align-justify'}
<Save size="sm" decorative /> <TextAlignJustify size="sm" decorative />
{:else} {:else if command.command === 'unlink'}
<Eraser size="sm" decorative /> <Unlink size="sm" decorative />
{/if} {:else if command.command === 'commit'}
</Words.CommandButton> <Save size="sm" decorative />
{:else if command.command === 'clear-document'}
<Trash2 size="sm" decorative />
{:else}
<Eraser size="sm" decorative />
{/if}
</CommandButton>
{/if} {/if}
{/each} {/each}
</Words.ToolbarGroup> </ToolbarGroup>
{/each} {/each}
</Words.Toolbar> </Toolbar>
{/if} {/if}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}
</Words.Provider> </SomaWords.Provider>

@ -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<string, EidosMotionDefinition>;
};
}
```
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<string, EidosMotionDefinition>;
```
## API publica de componentes
Los wrappers Eidos pueden exponer una prop visual `motion`.
```svelte
<Dialog motion="presence.scale-fade" />
<Popover motion="presence.scale-fade" />
<Drawer motion="presence.drawer-end" />
<Tabs motion="presence.slide-inline" />
<Button motion="interaction.press" />
```
Para componentes multiparte, aceptar un mapa por parte:
```svelte
<Dialog
motion={{
overlay: 'presence.fade',
content: 'presence.scale-fade'
}}
/>
```
La API publica usa `motion`; el DOM no debe usar `data-motion` para esa
referencia. En DOM se proyecta como referencia visual de Eidos:
```html
<div data-dialog-content data-motion-ref="presence.scale-fade">
```
Motivo: `data-motion` ya pertenece a la preferencia global de motion.
## Contrato DOM
Atributos propuestos:
| Atributo | Owner | Uso |
| --- | --- | --- |
| `data-motion="allow\|reduce"` | ActivePrefs DOM projection | Preferencia global transversal. |
| `data-motion-ref="presence.scale-fade"` | Eidos | Referencia a una motion registrada. |
| `data-motion-origin="top\|right\|bottom\|left\|center"` | Eidos | Origen visual para transforms. |
| `data-motion-direction="from-start\|from-end\|to-start\|to-end"` | Soma/Morfo cuando sea estado real | Direccion de transicion cuando afecta a comportamiento o layout. |
| `data-motion-target` | Eidos | Marcador opcional para drivers con rect target. |
| `data-starting-style` | Soma Presence | Hook de entrada. |
| `data-ending-style` | Soma Presence | Hook de salida. |
| `data-event-*` | Sema VisualChannel | Senal perceptiva transitoria. |
Regla: un atributo visual puro puede vivir solo en Eidos. Si el atributo afecta
a semantica, accesibilidad, lifecycle o comportamiento, debe declararse en
Morfo/Soma.
## CSS transversal
Crear un modulo `motion.css` importado por `index.css` cerca de
`archetypes.css` y `events.css`.
```css
[data-motion-ref='presence.fade'] {
transition:
opacity var(--motion-presence-fade-duration)
var(--motion-presence-fade-ease);
}
[data-motion-ref='presence.fade'][data-starting-style],
[data-motion-ref='presence.fade'][data-ending-style] {
opacity: 0;
}
[data-motion-ref='presence.scale-fade'] {
transition:
opacity var(--motion-presence-scale-fade-duration)
var(--motion-presence-scale-fade-ease),
transform var(--motion-presence-scale-fade-duration)
var(--motion-presence-scale-fade-ease);
transform-origin: var(--motion-origin, center);
}
[data-motion-ref='presence.scale-fade'][data-starting-style],
[data-motion-ref='presence.scale-fade'][data-ending-style] {
opacity: 0;
transform: scale(var(--motion-scale-enter));
}
```
`events.css` puede migrar gradualmente para consumir el mismo registro:
```css
[data-event-family='commit'][data-event-phase='active'] {
animation: var(--motion-event-commit-animation);
}
```
## Reduced motion
La preferencia global ya se proyecta como `data-motion`. Eidos debe convertirla
en politica visual.
```css
[data-motion='reduce'] [data-motion-ref] {
animation-duration: 1ms;
transition-duration: 1ms;
}
[data-motion='reduce'] [data-motion-ref][data-motion-reduce='opacity-only'] {
transform: none;
}
```
Tambien conviene mantener fallback por media query:
```css
@media (prefers-reduced-motion: reduce) {
[data-motion-ref] {
animation-duration: 1ms;
transition-duration: 1ms;
}
}
```
La politica por motion debe venir del registro:
- `instant`: reduce a cambio inmediato.
- `opacity-only`: conserva fades breves y elimina transformaciones.
- `none`: mantiene la animacion porque representa feedback critico y ya es
segura.
## Driver `css`
El driver por defecto. No requiere medicion ni JavaScript. Sirve para:
- fade
- scale-fade
- slide-inline
- slide-block
- collapse-block con CSS vars conocidas
- press
- indicator transitions
- signal reactions basicas
Debe cubrir la mayoria de componentes.
## Driver `eidos-rect`
Driver visual para animaciones que necesitan geometria. Eidos mide rects y
escribe variables CSS o invoca WAAPI.
Casos:
- `presence.genie`
- `layout.shared-element`
- `layout.fly-to-target`
- `presence.expand-from-trigger`
Entrada esperada:
```svelte
<Dialog motion="presence.genie" motionTarget={dockButton} />
<Toast motion="presence.genie" motionTarget="#notification-center" />
```
Variables que puede escribir Eidos:
```html
<div
data-motion-ref="presence.genie"
style="
--motion-source-x: 120px;
--motion-source-y: 80px;
--motion-source-w: 480px;
--motion-source-h: 320px;
--motion-target-x: 32px;
--motion-target-y: 720px;
--motion-target-w: 48px;
--motion-target-h: 48px;
"
>
```
Para `genie`, el efecto puede empezar como CSS aproximado y evolucionar a
WAAPI si el calculo de keyframes necesita interpolacion mas precisa.
## Driver `waapi`
Driver reservado para animaciones que no se expresan bien con CSS estatico.
Debe seguir siendo opt-in por motion registrada, no dependencia general de los
componentes.
Responsabilidades:
- Construir keyframes desde rects y tokens.
- Respetar reduced motion.
- Cancelar animaciones obsoletas cuando el nodo se desmonta o cambia de estado.
- No bloquear a Soma salvo por el lifecycle que Soma ya obtiene con
`getAnimations()`.
## Gestion del canal visual de Sema
Sema escribe la senal:
```html
data-event="dismiss"
data-event-family="commit"
data-event-intent="fulfill"
data-event-phase="active"
```
Eidos resuelve esa entrada contra el registro. La resolucion puede seguir este
orden:
1. `event.{data-event}`
2. `event.{data-event-family}.{data-event-intent}`
3. `event.{data-event-family}`
4. fallback global de `event.default`
Ejemplo:
```ts
resolveSignalMotion({
event: 'announce',
family: 'commit',
intent: 'fulfill'
});
```
Resultado posible:
```ts
'event.commit.fulfill'
```
El `hold` de Sema no debe usarse como duracion CSS. El hold define cuanto vive
la senal en DOM; la duracion visual pertenece a Eidos.
## Defaults por componente
Cada componente puede tener default visual sin obligar a la persona usuaria a
pasar `motion`.
Ejemplos iniciales:
| Componente | Parte | Motion default |
| --- | --- | --- |
| Dialog | overlay | `presence.fade` |
| Dialog | content | `presence.scale-fade` |
| Popover | content | `presence.scale-fade` |
| Drawer | content | `presence.drawer` |
| Accordion | content | `presence.collapse-block` |
| Tabs | content | `presence.fade` o `presence.slide-inline` |
| NavigationMenu | content | `presence.slide-inline` con direccion |
| Button | root | `interaction.press` |
| Switch/Slider | thumb | `layout.indicator` o `interaction.press` |
La prop `motion` debe permitir anular el default:
```svelte
<Dialog motion="none" />
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
```
## Migracion
### Fase 0: documentacion
- Corregir la deriva documental donde Sema parezca propietario de motion
visual.
- Dejar claro que Eidos gestiona el canal visual de Sema.
### Fase 1: contrato base
- Agregar tipos del registro.
- Agregar validacion de nombres y definiciones.
- Generar CSS vars por motion registrada.
- Crear `motion.css`.
- Mantener `events.css` y `archetypes.css` funcionando como hoy.
### Fase 2: canarios
Migrar pocos componentes para probar todos los casos:
- Dialog o Popover: overlay + content + presence lifecycle.
- Drawer: origen/direccion.
- Accordion: collapse.
- Tabs: reemplazar `motionPreset` por `motion`.
- NavigationMenu: aislar o renombrar el uso actual de `data-motion` como
direccion.
### Fase 3: canal visual de Sema
- Migrar `events.css` para resolver motions de tipo `event`.
- Mantener compatibilidad con selectores actuales `data-event-*`.
- Probar intents: `neutral`, `affirm`, `fulfill`, `risk`, `threat`.
### Fase 4: drivers avanzados
- Introducir `eidos-rect` para `presence.genie`.
- Agregar pruebas con target rect y reduced motion.
- Evaluar si WAAPI aporta valor para shared element y fly-to-target.
## Pruebas recomendadas
- Test de contrato: las claves del registro producen CSS vars estables.
- Test de validacion: `motion="unknown"` falla temprano o queda en fallback
controlado.
- Test de DOM attrs: permitir `data-motion-ref`, `data-motion-origin` y
`data-motion-direction` donde aplique.
- Test anti-colision: no introducir nuevos usos de `data-motion` para presets.
- Test reduced motion: `data-motion="reduce"` reduce transforms y duraciones.
- Test presence: Soma espera las animaciones resultantes antes de desmontar.
## Decision propuesta
Adoptar `motion` como prop publica de Eidos y `data-motion-ref` como contrato
DOM interno.
```svelte
<Dialog motion="presence.scale-fade" />
<Dialog motion="presence.genie" motionTarget="#dock-settings" />
```
Eidos registra y resuelve motions. Sema emite senales. Soma conserva lifecycle.
Morfo declara los atributos que cruzan capas. La animacion queda centralizada
en el runtime visual sin romper la separacion actual de UIX.

@ -2331,6 +2331,9 @@
--words-block-gap: var(--space-2); --words-block-gap: var(--space-2);
--words-heading-color: var(--color-content-primary); --words-heading-color: var(--color-content-primary);
--words-heading-font-size: var(--font-size-lg); --words-heading-font-size: var(--font-size-lg);
--words-heading-h1-font-size: var(--font-size-xl);
--words-heading-h2-font-size: calc(var(--font-size-lg) + 4px);
--words-heading-h3-font-size: var(--font-size-lg);
--words-heading-font-weight: var(--font-weight-semibold); --words-heading-font-weight: var(--font-weight-semibold);
--words-heading-line-height: var(--leading-heading); --words-heading-line-height: var(--leading-heading);
--words-strong-font-weight: var(--font-weight-semibold); --words-strong-font-weight: var(--font-weight-semibold);

@ -2160,6 +2160,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'block-gap': 'var(--space-2)', 'block-gap': 'var(--space-2)',
'heading-color': 'var(--color-content-primary)', 'heading-color': 'var(--color-content-primary)',
'heading-font-size': 'var(--font-size-lg)', 'heading-font-size': 'var(--font-size-lg)',
'heading-h1-font-size': 'var(--font-size-xl)',
'heading-h2-font-size': 'calc(var(--font-size-lg) + 4px)',
'heading-h3-font-size': 'var(--font-size-lg)',
'heading-font-weight': 'var(--font-weight-semibold)', 'heading-font-weight': 'var(--font-weight-semibold)',
'heading-line-height': 'var(--leading-heading)', 'heading-line-height': 'var(--leading-heading)',
'strong-font-weight': 'var(--font-weight-semibold)', 'strong-font-weight': 'var(--font-weight-semibold)',

@ -11,8 +11,9 @@ export const wordsMorfo = {
content: '#?components.words.content|Editor content', content: '#?components.words.content|Editor content',
placeholder: '#?components.words.placeholder|Start writing...', placeholder: '#?components.words.placeholder|Start writing...',
toolbar: '#?components.words.toolbar|Editor toolbar', toolbar: '#?components.words.toolbar|Editor toolbar',
'bubble-menu': '#?components.words.bubble-menu|Selection toolbar', bubbleMenu: '#?components.words.bubbleMenu|Selection toolbar',
'link-editor': '#?components.words.link-editor|Edit link', linkEditor: '#?components.words.linkEditor|Edit link',
findReplace: '#?components.words.findReplace|Find and replace',
status: '#?components.words.status|Editor status' status: '#?components.words.status|Editor status'
}, },
events: [ events: [
@ -65,6 +66,16 @@ export const wordsMorfo = {
sequence: 'post' sequence: 'post'
} }
}, },
{
name: 'commit-set-replace',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('find-replace'),
intent: 'neutral',
sequence: 'post'
}
},
{ {
name: 'commit-history', name: 'commit-history',
semantic: { semantic: {
@ -182,8 +193,7 @@ export const wordsMorfo = {
severity: 'optional' severity: 'optional'
}, },
{ attr: 'data-words-block', severity: 'optional' }, { attr: 'data-words-block', severity: 'optional' },
{ attr: 'data-words-checked', severity: 'optional' }, { attr: 'data-words-checked', severity: 'optional' }
{ attr: 'data-words-tool-popover', severity: 'optional' }
], ],
aria: [ aria: [
{ attr: 'contenteditable', value: v.propRef('editable') }, { attr: 'contenteditable', value: v.propRef('editable') },
@ -291,7 +301,7 @@ export const wordsMorfo = {
{ attr: 'aria-orientation', value: v.literal('horizontal') }, { attr: 'aria-orientation', value: v.literal('horizontal') },
{ {
attr: 'aria-label', attr: 'aria-label',
value: v.translationRef('#?components.words.bubble-menu|Selection toolbar'), value: v.translationRef('#?components.words.bubbleMenu|Selection toolbar'),
severity: 'recommended' severity: 'recommended'
} }
] ]
@ -336,7 +346,28 @@ export const wordsMorfo = {
aria: [ aria: [
{ {
attr: 'aria-label', attr: 'aria-label',
value: v.translationRef('#?components.words.link-editor|Edit link'), value: v.translationRef('#?components.words.linkEditor|Edit link'),
severity: 'recommended'
}
]
},
{
name: 'FindReplace',
kebab: 'find-replace',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'search',
optional: true,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-has-matches', value: v.propRef('hasMatches'), severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.findReplace|Find and replace'),
severity: 'recommended' severity: 'recommended'
} }
] ]

@ -35,6 +35,11 @@
return props; return props;
}); });
const title = $derived.by(() => {
const label = (mergedProps as Record<string, unknown>)['aria-label'];
return typeof label === 'string' ? label : undefined;
});
function callPointerDown(event: PointerEvent) { function callPointerDown(event: PointerEvent) {
const handler = (mergedProps as Record<string, unknown>).onpointerdown; const handler = (mergedProps as Record<string, unknown>).onpointerdown;
if (typeof handler === 'function') handler(event); if (typeof handler === 'function') handler(event);
@ -49,7 +54,7 @@
{#if child} {#if child}
{@render child({ props: mergedProps })} {@render child({ props: mergedProps })}
{:else} {:else}
<button {...buttonProps} onpointerdown={callPointerDown} onclick={callClick}> <button {...buttonProps} {title} onpointerdown={callPointerDown} onclick={callClick}>
{#if children} {#if children}
{@render children()} {@render children()}
{:else} {:else}

@ -0,0 +1,39 @@
<script lang="ts">
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { readableActive, writableActive } from '$libs/reactive';
import { WordsFindReplaceProvider } from '../words-provider.svelte';
import type { WordsFindReplaceProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'words-find-replace'),
children,
child,
...restProps
}: WordsFindReplaceProps = $props();
const state = WordsFindReplaceProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLDivElement | null)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const divProps = $derived.by(() => {
const { ...props } = mergedProps;
return props;
});
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...divProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -1,6 +1,7 @@
import type { WordsBlockType, WordsHeadingLevel, WordsListKind, WordsMark, WordsTextAlign } from './document'; import type { WordsBlockType, WordsHeadingLevel, WordsListKind, WordsMark, WordsTextAlign } from './document';
import { import {
clearFormatting, clearFormatting,
decreaseIndent,
deleteBackward, deleteBackward,
deleteForward, deleteForward,
deleteLineBackward, deleteLineBackward,
@ -8,6 +9,7 @@ import {
deleteRange, deleteRange,
deleteWordBackward, deleteWordBackward,
deleteWordForward, deleteWordForward,
increaseIndent,
insertLink, insertLink,
insertLineBreak, insertLineBreak,
insertParagraph, insertParagraph,
@ -26,6 +28,7 @@ import { parseWordsHtml, serializeWordsHtml } from './serialize-html';
import { parseWordsJson, serializeWordsJson } from './serialize-json'; import { parseWordsJson, serializeWordsJson } from './serialize-json';
import { parseWordsMarkdown, serializeWordsMarkdown } from './serialize-markdown'; import { parseWordsMarkdown, serializeWordsMarkdown } from './serialize-markdown';
import { parseWordsPlainText } from './serialize-text'; import { parseWordsPlainText } from './serialize-text';
import { replaceAllTextInDocument } from './find-replace';
export type WordsImportFormat = 'json' | 'text' | 'html' | 'markdown'; export type WordsImportFormat = 'json' | 'text' | 'html' | 'markdown';
export type WordsExportFormat = 'json' | 'text' | 'html' | 'markdown'; export type WordsExportFormat = 'json' | 'text' | 'html' | 'markdown';
@ -45,10 +48,13 @@ export type WordsCommand =
| { type: 'clearFormatting' } | { type: 'clearFormatting' }
| { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel } | { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel }
| { type: 'setTextAlign'; align: WordsTextAlign } | { type: 'setTextAlign'; align: WordsTextAlign }
| { type: 'increaseIndent' }
| { type: 'decreaseIndent' }
| { type: 'toggleList'; kind: WordsListKind } | { type: 'toggleList'; kind: WordsListKind }
| { type: 'insertLink'; href: string; title?: string } | { type: 'insertLink'; href: string; title?: string }
| { type: 'unlink' } | { type: 'unlink' }
| { type: 'replaceDocument'; document: unknown } | { type: 'replaceDocument'; document: unknown }
| { type: 'replaceAllText'; query: string; replacement: string }
| { type: 'importContent'; content: string; format: WordsImportFormat } | { type: 'importContent'; content: string; format: WordsImportFormat }
| { type: 'exportContent'; format: WordsExportFormat }; | { type: 'exportContent'; format: WordsExportFormat };
@ -85,6 +91,10 @@ export function applyWordsCommand(
return setBlock(state, command.block, { level: command.level }); return setBlock(state, command.block, { level: command.level });
case 'setTextAlign': case 'setTextAlign':
return setTextAlign(state, command.align); return setTextAlign(state, command.align);
case 'increaseIndent':
return increaseIndent(state);
case 'decreaseIndent':
return decreaseIndent(state);
case 'toggleList': case 'toggleList':
return toggleList(state, command.kind); return toggleList(state, command.kind);
case 'insertLink': case 'insertLink':
@ -93,6 +103,8 @@ export function applyWordsCommand(
return unlink(state); return unlink(state);
case 'replaceDocument': case 'replaceDocument':
return replaceDocument(state, command.document); return replaceDocument(state, command.document);
case 'replaceAllText':
return replaceAllText(state, command.query, command.replacement);
case 'importContent': case 'importContent':
return importWordsContent(state, command.content, command.format); return importWordsContent(state, command.content, command.format);
case 'exportContent': case 'exportContent':
@ -135,3 +147,21 @@ function exportWordsContent(
: renderWordsPlainText(state.document); : renderWordsPlainText(state.document);
return { state, changed: false, exportedContent }; return { state, changed: false, exportedContent };
} }
function replaceAllText(
state: WordsEditorState,
query: string,
replacement: string
): WordsOperationResult {
if (!query) return { state, changed: false };
const nextDocument = replaceAllTextInDocument(state.document, query, replacement);
if (nextDocument === state.document) return { state, changed: false };
return {
state: {
document: nextDocument,
selection: state.selection,
activeMarks: state.activeMarks
},
changed: true
};
}

@ -41,6 +41,7 @@ export interface WordsListBlock {
export interface WordsListItem { export interface WordsListItem {
readonly type: 'list-item'; readonly type: 'list-item';
readonly checked?: boolean; readonly checked?: boolean;
readonly indent?: number;
readonly children: readonly WordsInline[]; readonly children: readonly WordsInline[];
} }
@ -112,9 +113,15 @@ export function createList(
export function createListItem( export function createListItem(
children: readonly WordsInline[] = [createText('')], children: readonly WordsInline[] = [createText('')],
checked = false checked = false,
indent?: number
): WordsListItem { ): WordsListItem {
return { type: 'list-item', ...(checked ? { checked } : {}), children }; return {
type: 'list-item',
...(checked ? { checked } : {}),
...(indent ? { indent } : {}),
children
};
} }
export function createText(text: string, marks: readonly WordsMark[] = []): WordsText { export function createText(text: string, marks: readonly WordsMark[] = []): WordsText {

@ -52,11 +52,22 @@ export function domPointToEncodedTarget(
if (isTextNode(node)) { if (isTextNode(node)) {
const textElement = closestWordsTextElement(root, node.parentElement); const textElement = closestWordsTextElement(root, node.parentElement);
if (!textElement) return undefined; if (!textElement) return undefined;
if (textElement.hasAttribute(WORDS_EMPTY_TEXT_ATTR)) {
return { path: textElement.getAttribute(WORDS_PATH_ATTR) ?? '', offset: 0 };
}
let totalOffset = 0;
let found = false;
for (const child of textElement.childNodes) {
if (child === node) {
found = true;
break;
}
totalOffset += getTextLength(child);
}
if (!found) totalOffset = 0;
return { return {
path: textElement.getAttribute(WORDS_PATH_ATTR) ?? '', path: textElement.getAttribute(WORDS_PATH_ATTR) ?? '',
offset: textElement.hasAttribute(WORDS_EMPTY_TEXT_ATTR) offset: Math.max(0, Math.min(totalOffset + offset, totalOffset + node.data.length))
? 0
: Math.max(0, Math.min(offset, node.data.length))
}; };
} }
@ -113,14 +124,26 @@ export function resolveDomPointFromTarget(
); );
if (!element) return undefined; if (!element) return undefined;
const textNode = firstTextChild(element); if (element.hasAttribute(WORDS_EMPTY_TEXT_ATTR)) {
if (!textNode) return { node: element, offset: 0 }; const textNode = firstTextChild(element);
if (element.hasAttribute(WORDS_EMPTY_TEXT_ATTR)) return { node: textNode, offset: 0 }; return textNode ? { node: textNode, offset: 0 } : { node: element, offset: 0 };
}
let currentOffset = 0;
for (const child of element.childNodes) {
const childLength = getTextLength(child);
if (target.offset <= currentOffset + childLength) {
const textNode = findTextNodeAt(child);
if (textNode) {
return { node: textNode, offset: Math.max(0, target.offset - currentOffset) };
}
}
currentOffset += childLength;
}
return { const lastText = lastTextChild(element);
node: textNode, if (lastText) return { node: lastText, offset: lastText.data.length };
offset: Math.max(0, Math.min(target.offset, textNode.data.length)) return { node: element, offset: 0 };
};
} }
function encodedTargetFromElementBoundary( function encodedTargetFromElementBoundary(
@ -205,6 +228,38 @@ function isElementInside(root: Element, element: Element): boolean {
return element === root || root.contains(element); return element === root || root.contains(element);
} }
function getTextLength(node: Node): number {
if (isTextNode(node)) return node.data.length;
if (node.nodeType === Node.ELEMENT_NODE) {
let length = 0;
for (const child of (node as Element).childNodes) {
length += getTextLength(child);
}
return length;
}
return 0;
}
function findTextNodeAt(node: Node): Text | undefined {
if (isTextNode(node)) return node;
if (node.nodeType === Node.ELEMENT_NODE) {
for (const child of (node as Element).childNodes) {
const found = findTextNodeAt(child);
if (found) return found;
}
}
return undefined;
}
function lastTextChild(element: Element): Text | undefined {
const children = Array.from(element.childNodes);
for (let i = children.length - 1; i >= 0; i--) {
const found = findTextNodeAt(children[i]!);
if (found) return found;
}
return undefined;
}
function isElementNode(node: Node): node is Element { function isElementNode(node: Node): node is Element {
return node.nodeType === 1; return node.nodeType === 1;
} }

@ -45,6 +45,9 @@ import {
selectionApiToWordsSelection, selectionApiToWordsSelection,
updateWordsComposition, updateWordsComposition,
undoWords, undoWords,
findTextInDocument,
replaceTextAtMatch,
replaceAllTextInDocument,
type WordsDocument, type WordsDocument,
type WordsEditorState type WordsEditorState
} from './index'; } from './index';
@ -64,6 +67,20 @@ function stateWithText(text: string): WordsEditorState {
); );
} }
function documentWithChildren(children: WordsDocument['children']): WordsDocument {
return {
version: 1,
children
};
}
function firstParagraphText(document: WordsDocument, index = 0): string | undefined {
const block = document.children[index];
if (block?.type !== 'paragraph') return undefined;
const inline = block.children[0];
return inline?.type === 'text' ? inline.text : undefined;
}
describe('Words engine', () => { describe('Words engine', () => {
it('normalizes invalid and empty documents into one paragraph', () => { it('normalizes invalid and empty documents into one paragraph', () => {
const result = normalizeDocument({ const result = normalizeDocument({
@ -1080,10 +1097,7 @@ describe('Words engine', () => {
}); });
expect(toggled.state.document.children[0]).toMatchObject({ expect(toggled.state.document.children[0]).toMatchObject({
type: 'list', type: 'list',
children: [ children: [{ type: 'list-item' }, { type: 'list-item', checked: true }]
{ type: 'list-item' },
{ type: 'list-item', checked: true }
]
}); });
}); });
@ -1213,4 +1227,231 @@ describe('Words engine', () => {
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'left' }); const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'left' });
expect(result).toEqual({ state, changed: false }); expect(result).toEqual({ state, changed: false });
}); });
it('increases and decreases list item indent', () => {
const state = createWordsState(
{
version: 1,
children: [
createList('unordered', [
createListItem([createText('A')]),
createListItem([createText('B')]),
createListItem([createText('C')])
])
]
},
createCollapsedSelection([0, 1, 0], 0)
);
let result = applyWordsCommand(state, { type: 'increaseIndent' });
expect(result.changed).toBe(true);
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
children: [{ type: 'list-item' }, { type: 'list-item', indent: 1 }, { type: 'list-item' }]
});
result = applyWordsCommand(result.state, { type: 'increaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item' }, { type: 'list-item', indent: 2 }, { type: 'list-item' }]
});
result = applyWordsCommand(result.state, { type: 'decreaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item' }, { type: 'list-item', indent: 1 }, { type: 'list-item' }]
});
result = applyWordsCommand(result.state, { type: 'decreaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item' }, { type: 'list-item' }, { type: 'list-item' }]
});
});
it('clamps list indent at 0 and 8', () => {
const state = createWordsState(
{
version: 1,
children: [createList('unordered', [createListItem([createText('A')], false, 8)])]
},
createCollapsedSelection([0, 0, 0], 0)
);
let result = applyWordsCommand(state, { type: 'increaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item', indent: 8 }]
});
result = applyWordsCommand(state, { type: 'decreaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item', indent: 7 }]
});
const zero = createWordsState(
{
version: 1,
children: [createList('unordered', [createListItem([createText('A')])])]
},
createCollapsedSelection([0, 0, 0], 0)
);
result = applyWordsCommand(zero, { type: 'decreaseIndent' });
expect(result.state.document.children[0]).toMatchObject({
children: [{ type: 'list-item' }]
});
});
it('is no-op for indent outside lists', () => {
const state = createWordsState(
{
version: 1,
children: [createParagraph([createText('hello')])]
},
createCollapsedSelection([0, 0], 0)
);
let result = applyWordsCommand(state, { type: 'increaseIndent' });
expect(result).toEqual({ state, changed: false });
result = applyWordsCommand(state, { type: 'decreaseIndent' });
expect(result).toEqual({ state, changed: false });
});
it('serializes and parses list indent in html', () => {
const document = {
version: 1,
children: [
createList('unordered', [
createListItem([createText('A')]),
createListItem([createText('B')], false, 1),
createListItem([createText('C')], false, 2)
])
]
} as WordsDocument;
const html = serializeWordsHtml(document);
expect(html).toContain('<li>');
expect(html).toContain('<li style="margin-left: 1.5em">');
expect(html).toContain('<li style="margin-left: 3em">');
const parsed = parseWordsHtml(html);
expect(parsed.children[0]).toMatchObject({
type: 'list',
kind: 'unordered',
children: [
{ type: 'list-item' },
{ type: 'list-item', indent: 1 },
{ type: 'list-item', indent: 2 }
]
});
});
it('serializes and parses list indent in markdown', () => {
const document = {
version: 1,
children: [
createList('unordered', [
createListItem([createText('A')]),
createListItem([createText('B')], false, 1),
createListItem([createText('C')], false, 2)
])
]
} as WordsDocument;
const md = serializeWordsMarkdown(document);
expect(md).toContain('- A');
expect(md).toContain(' - B');
expect(md).toContain(' - C');
const parsed = parseWordsMarkdown(md);
expect(parsed.children[0]).toMatchObject({
type: 'list',
kind: 'unordered',
children: [
{ type: 'list-item' },
{ type: 'list-item', indent: 1 },
{ type: 'list-item', indent: 2 }
]
});
});
describe('find/replace', () => {
it('finds text in document', () => {
const doc = documentWithChildren([
createParagraph([createText('Hello world')]),
createParagraph([createText('Hello again')])
]);
const matches = findTextInDocument(doc, 'Hello');
expect(matches).toHaveLength(2);
expect(matches[0]).toMatchObject({ path: [0, 0], start: 0, end: 5 });
expect(matches[1]).toMatchObject({ path: [1, 0], start: 0, end: 5 });
});
it('finds text case-insensitive by default', () => {
const doc = documentWithChildren([createParagraph([createText('HELLO')])]);
const matches = findTextInDocument(doc, 'hello');
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({ start: 0, end: 5 });
});
it('finds text case-sensitive when requested', () => {
const doc = documentWithChildren([createParagraph([createText('HELLO')])]);
const matches = findTextInDocument(doc, 'hello', { caseSensitive: true });
expect(matches).toHaveLength(0);
});
it('escapes regex special characters in query', () => {
const doc = documentWithChildren([createParagraph([createText('Price: $5.00')])]);
const matches = findTextInDocument(doc, '$5.00');
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({ start: 7, end: 12 });
});
it('replaces text at a single match', () => {
const doc = documentWithChildren([createParagraph([createText('Hello world')])]);
const match = { path: [0, 0], start: 6, end: 11, text: 'world' };
const result = replaceTextAtMatch(doc, match, 'universe');
expect(firstParagraphText(result)).toBe('Hello universe');
});
it('replaces all occurrences across blocks', () => {
const doc = documentWithChildren([
createParagraph([createText('Hello world')]),
createParagraph([createText('Hello again')])
]);
const result = replaceAllTextInDocument(doc, 'Hello', 'Hi');
expect(firstParagraphText(result, 0)).toBe('Hi world');
expect(firstParagraphText(result, 1)).toBe('Hi again');
});
it('replaces overlapping-safe from end to start', () => {
const doc = documentWithChildren([createParagraph([createText('aaa aaa aaa')])]);
const result = replaceAllTextInDocument(doc, 'aaa', 'b');
expect(firstParagraphText(result)).toBe('b b b');
});
it('renders find highlights in dom html', () => {
const doc = documentWithChildren([createParagraph([createText('Hello world')])]);
const matches = findTextInDocument(doc, 'world');
const html = renderWordsDomHtml(doc, { matches, activeMatchIndex: 0 });
expect(html).toContain('<mark data-words-find-match data-words-find-active>world</mark>');
});
it('renders find highlights without active when index mismatch', () => {
const doc = documentWithChildren([createParagraph([createText('Hello world')])]);
const matches = findTextInDocument(doc, 'world');
const html = renderWordsDomHtml(doc, { matches, activeMatchIndex: -1 });
expect(html).toContain('<mark data-words-find-match>world</mark>');
expect(html).not.toContain('data-words-find-active');
});
it('renders the active find highlight by global match index', () => {
const doc = documentWithChildren([
createParagraph([createText('alpha hit')]),
createParagraph([createText('beta hit')])
]);
const matches = findTextInDocument(doc, 'hit');
const html = renderWordsDomHtml(doc, { matches, activeMatchIndex: 1 });
expect(html).toContain('alpha <mark data-words-find-match>hit</mark>');
expect(html).toContain('beta <mark data-words-find-match data-words-find-active>hit</mark>');
});
});
}); });

@ -0,0 +1,91 @@
import { createText, type WordsDocument, type WordsText } from './document';
import { getTextEntries } from './selection';
import { updateNodeAtPath, type WordsPath } from './path';
export interface WordsTextMatch {
readonly path: WordsPath;
readonly start: number;
readonly end: number;
readonly text: string;
}
export interface FindOptions {
readonly caseSensitive?: boolean;
}
export function findTextInDocument(
document: WordsDocument,
query: string,
options: FindOptions = {}
): readonly WordsTextMatch[] {
if (!query) return [];
const matches: WordsTextMatch[] = [];
const entries = getTextEntries(document);
const flags = options.caseSensitive ? 'g' : 'gi';
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escaped, flags);
for (const entry of entries) {
const text = entry.text.text;
const entryMatches = [...text.matchAll(regex)];
for (const match of entryMatches) {
const start = match.index!;
const end = start + match[0].length;
matches.push({
path: entry.path,
start,
end,
text: match[0]
});
}
}
return matches;
}
export function replaceTextAtMatch(
document: WordsDocument,
match: WordsTextMatch,
replacement: string
): WordsDocument {
return updateNodeAtPath(document, match.path, (node) => {
if (!isWordsText(node)) return node;
const before = node.text.slice(0, match.start);
const after = node.text.slice(match.end);
return createText(before + replacement + after, node.marks);
});
}
export function replaceAllTextInDocument(
document: WordsDocument,
query: string,
replacement: string,
options: FindOptions = {}
): WordsDocument {
const matches = findTextInDocument(document, query, options);
if (!matches.length) return document;
// Group matches by their text node path so we can process each node back-to-front
const byPath = new Map<string, WordsTextMatch[]>();
for (const match of matches) {
const key = match.path.join(',');
const list = byPath.get(key);
if (list) list.push(match);
else byPath.set(key, [match]);
}
let nextDocument = document;
for (const pathMatches of byPath.values()) {
// Sort descending by start so offsets remain valid as we mutate
pathMatches.sort((a, b) => b.start - a.start);
for (const match of pathMatches) {
nextDocument = replaceTextAtMatch(nextDocument, match, replacement);
}
}
return nextDocument;
}
function isWordsText(node: unknown): node is WordsText {
return typeof node === 'object' && node !== null && 'type' in node && (node as { type: string }).type === 'text';
}

@ -1,5 +1,6 @@
export * from './commands'; export * from './commands';
export * from './document'; export * from './document';
export * from './find-replace';
export * from './dom'; export * from './dom';
export * from './dom-selection'; export * from './dom-selection';
export * from './history'; export * from './history';

@ -130,9 +130,14 @@ function normalizeListItem(
} }
const source = item as Record<string, unknown>; const source = item as Record<string, unknown>;
const indent =
typeof source.indent === 'number' && Number.isInteger(source.indent) && source.indent > 0
? Math.min(source.indent, 8)
: undefined;
return createListItem( return createListItem(
normalizeInlineChildren(source.children, path, issues), normalizeInlineChildren(source.children, path, issues),
source.checked === true source.checked === true,
indent
); );
} }

@ -400,6 +400,56 @@ export function setTextAlign(
}; };
} }
export function increaseIndent(state: WordsEditorState): WordsOperationResult {
return adjustListIndent(state, 1);
}
export function decreaseIndent(state: WordsEditorState): WordsOperationResult {
return adjustListIndent(state, -1);
}
function adjustListIndent(
state: WordsEditorState,
delta: number
): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
const blockIndex = range.start.path[0] ?? -1;
const block = state.document.children[blockIndex];
if (!block || block.type !== 'list') return { state, changed: false };
const startItem = range.start.path[1] ?? -1;
const endItem = range.end.path[1] ?? startItem;
if (startItem < 0 || endItem < 0) return { state, changed: false };
const items = block.children.map((item, index) => {
if (index < startItem || index > endItem) return item;
const current = item.indent ?? 0;
const next = Math.max(0, Math.min(current + delta, 8));
if (next === current) return item;
return createListItem(item.children, item.checked === true, next || undefined);
});
if (items.every((item, index) => item === block.children[index])) {
return { state, changed: false };
}
const nextList = createList(block.kind, items);
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextList)
}).document;
return {
state: {
document: normalized,
selection,
activeMarks: state.activeMarks
},
changed: true
};
}
export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsOperationResult { export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsOperationResult {
const selection = state.selection; const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined; const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;

@ -13,6 +13,20 @@ import {
encodeWordsPath encodeWordsPath
} from './dom'; } from './dom';
import type { WordsPath } from './path'; import type { WordsPath } from './path';
import type { WordsTextMatch } from './find-replace';
export interface WordsRenderFindOptions {
readonly matches?: readonly WordsTextMatch[];
readonly activeMatchIndex?: number;
}
interface IndexedWordsTextMatch {
readonly match: WordsTextMatch;
readonly index: number;
}
const FIND_MATCH_ATTR = 'data-words-find-match';
const FIND_ACTIVE_ATTR = 'data-words-find-active';
export type WordsRenderTag = export type WordsRenderTag =
| 'blockquote' | 'blockquote'
@ -24,7 +38,8 @@ export type WordsRenderTag =
| 'p' | 'p'
| 'span' | 'span'
| 'a' | 'a'
| 'ul'; | 'ul'
| 'mark';
export type WordsRenderAttrs = Readonly<Record<string, string | undefined>>; export type WordsRenderAttrs = Readonly<Record<string, string | undefined>>;
@ -42,11 +57,20 @@ export interface WordsRenderText {
export type WordsRenderNode = WordsRenderElement | WordsRenderText; export type WordsRenderNode = WordsRenderElement | WordsRenderText;
export function renderWordsDocument(document: WordsDocument): readonly WordsRenderElement[] { export function renderWordsDocument(
return document.children.map((block, blockIndex) => renderWordsBlock(block, [blockIndex])); document: WordsDocument,
findOpts?: WordsRenderFindOptions
): readonly WordsRenderElement[] {
return document.children.map((block, blockIndex) =>
renderWordsBlock(block, [blockIndex], findOpts)
);
} }
function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElement { function renderWordsBlock(
block: WordsBlock,
path: WordsPath,
findOpts?: WordsRenderFindOptions
): WordsRenderElement {
if (block.type === 'list') { if (block.type === 'list') {
return { return {
kind: 'element', kind: 'element',
@ -57,7 +81,7 @@ function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElemen
'data-words-list-kind': block.kind 'data-words-list-kind': block.kind
}, },
children: block.children.map((item, itemIndex) => children: block.children.map((item, itemIndex) =>
renderWordsListItem(item, [...path, itemIndex], block.kind) renderWordsListItem(item, [...path, itemIndex], block.kind, findOpts)
) )
}; };
} }
@ -75,16 +99,19 @@ function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElemen
[WORDS_NODE_ATTR]: 'block', [WORDS_NODE_ATTR]: 'block',
[WORDS_PATH_ATTR]: encodeWordsPath(path), [WORDS_PATH_ATTR]: encodeWordsPath(path),
'data-words-block': block.type, 'data-words-block': block.type,
...(block.textAlign && block.textAlign !== 'left' ? { style: `text-align: ${block.textAlign}` } : {}) ...(block.textAlign && block.textAlign !== 'left'
? { style: `text-align: ${block.textAlign}` }
: {})
}, },
children: renderWordsInlines(block.children, path) children: renderWordsInlines(block.children, path, findOpts)
}; };
} }
function renderWordsListItem( function renderWordsListItem(
item: WordsListItem, item: WordsListItem,
path: WordsPath, path: WordsPath,
kind: 'ordered' | 'unordered' | 'check' kind: 'ordered' | 'unordered' | 'check',
findOpts?: WordsRenderFindOptions
): WordsRenderElement { ): WordsRenderElement {
return { return {
kind: 'element', kind: 'element',
@ -94,22 +121,32 @@ function renderWordsListItem(
[WORDS_PATH_ATTR]: encodeWordsPath(path), [WORDS_PATH_ATTR]: encodeWordsPath(path),
...(kind === 'check' ...(kind === 'check'
? { 'data-words-checked': item.checked === true ? 'true' : 'false' } ? { 'data-words-checked': item.checked === true ? 'true' : 'false' }
: {}) : {}),
...(item.indent ? { 'data-words-indent': String(item.indent) } : {})
}, },
children: renderWordsInlines(item.children, path) children: renderWordsInlines(item.children, path, findOpts)
}; };
} }
function renderWordsInlines( function renderWordsInlines(
inlines: readonly WordsInline[], inlines: readonly WordsInline[],
parentPath: WordsPath parentPath: WordsPath,
findOpts?: WordsRenderFindOptions
): readonly WordsRenderNode[] { ): readonly WordsRenderNode[] {
return inlines.map((inline, inlineIndex) => return inlines.map((inline, inlineIndex) =>
renderWordsInline(inline, [...parentPath, inlineIndex]) renderWordsInline(inline, [...parentPath, inlineIndex], findOpts)
); );
} }
function renderWordsInline(inline: WordsInline, path: WordsPath): WordsRenderElement { function pathsEqual(a: WordsPath, b: WordsPath): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function renderWordsInline(
inline: WordsInline,
path: WordsPath,
findOpts?: WordsRenderFindOptions
): WordsRenderElement {
if (inline.type === 'link') { if (inline.type === 'link') {
return { return {
kind: 'element', kind: 'element',
@ -120,24 +157,73 @@ function renderWordsInline(inline: WordsInline, path: WordsPath): WordsRenderEle
href: inline.href, href: inline.href,
...(inline.title ? { title: inline.title } : {}) ...(inline.title ? { title: inline.title } : {})
}, },
children: renderWordsInlines(inline.children, path) children: renderWordsInlines(inline.children, path, findOpts)
}; };
} }
const attrs = createWordsTextAttrs(path, inline.marks, inline.text); const attrs = createWordsTextAttrs(path, inline.marks, inline.text);
const inlineMatches =
findOpts?.matches
?.map((match, index) => ({ match, index }))
.filter(({ match }) => pathsEqual(match.path, path)) ?? [];
if (inlineMatches.length === 0) {
return {
kind: 'element',
tag: 'span',
attrs,
children: [
{
kind: 'text',
text: inline.text.length === 0 ? '\u200b' : inline.text
}
]
};
}
const sortedMatches = [...inlineMatches].sort((a, b) => a.match.start - b.match.start);
const children = splitTextWithMatches(
inline.text,
sortedMatches,
findOpts?.activeMatchIndex ?? -1
);
return { return {
kind: 'element', kind: 'element',
tag: 'span', tag: 'span',
attrs, attrs,
children: [ children
{
kind: 'text',
text: inline.text.length === 0 ? '\u200b' : inline.text
}
]
}; };
} }
function splitTextWithMatches(
text: string,
matches: readonly IndexedWordsTextMatch[],
activeMatchIndex: number
): readonly WordsRenderNode[] {
const nodes: WordsRenderNode[] = [];
let lastEnd = 0;
for (const { match, index } of matches) {
if (match.start > lastEnd) {
nodes.push({ kind: 'text', text: text.slice(lastEnd, match.start) });
}
nodes.push({
kind: 'element',
tag: 'mark',
attrs: {
[FIND_MATCH_ATTR]: '',
...(activeMatchIndex === index ? { [FIND_ACTIVE_ATTR]: '' } : {})
},
children: [{ kind: 'text', text: text.slice(match.start, match.end) }]
});
lastEnd = match.end;
}
if (lastEnd < text.length) {
nodes.push({ kind: 'text', text: text.slice(lastEnd) });
}
return nodes;
}
export function renderWordsPlainText(document: WordsDocument): string { export function renderWordsPlainText(document: WordsDocument): string {
return document.children.map(renderBlockPlainText).join('\n'); return document.children.map(renderBlockPlainText).join('\n');
} }
@ -153,8 +239,11 @@ export function isRenderedEmptyText(node: WordsRenderElement): boolean {
return node.attrs[WORDS_EMPTY_TEXT_ATTR] === ''; return node.attrs[WORDS_EMPTY_TEXT_ATTR] === '';
} }
export function renderWordsDomHtml(document: WordsDocument): string { export function renderWordsDomHtml(
return renderWordsDocument(document).map(renderWordsNodeHtml).join(''); document: WordsDocument,
findOpts?: WordsRenderFindOptions
): string {
return renderWordsDocument(document, findOpts).map(renderWordsNodeHtml).join('');
} }
export const renderWordsHtml = renderWordsDomHtml; export const renderWordsHtml = renderWordsDomHtml;
@ -163,7 +252,7 @@ function renderWordsNodeHtml(node: WordsRenderNode): string {
if (node.kind === 'text') return escapeHtml(node.text); if (node.kind === 'text') return escapeHtml(node.text);
const attrs = Object.entries(node.attrs) const attrs = Object.entries(node.attrs)
.filter((entry): entry is [string, string] => entry[1] !== undefined) .filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([name, value]) => ` ${name}="${escapeHtmlAttr(value)}"`) .map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${escapeHtmlAttr(value)}"`))
.join(''); .join('');
const children = node.children.map(renderWordsNodeHtml).join(''); const children = node.children.map(renderWordsNodeHtml).join('');
return `<${node.tag}${attrs}>${children}</${node.tag}>`; return `<${node.tag}${attrs}>${children}</${node.tag}>`;

@ -97,7 +97,8 @@ function serializeBlockHtml(block: WordsBlock): string {
block.kind === 'check' block.kind === 'check'
? `<input type="checkbox"${item.checked === true ? ' checked' : ''} disabled> ` ? `<input type="checkbox"${item.checked === true ? ' checked' : ''} disabled> `
: ''; : '';
return `<li>${checkbox}${serializeInlinesHtml(item.children)}</li>`; const indentStyle = item.indent ? ` style="margin-left: ${item.indent * 1.5}em"` : '';
return `<li${indentStyle}>${checkbox}${serializeInlinesHtml(item.children)}</li>`;
}) })
.join(''); .join('');
return `<${tag}>${children}</${tag}>`; return `<${tag}>${children}</${tag}>`;
@ -319,12 +320,24 @@ function htmlListToBlock(node: HtmlElementNode): WordsBlock {
function htmlListItemToWords(node: HtmlElementNode): WordsListItem { function htmlListItemToWords(node: HtmlElementNode): WordsListItem {
const hasCheckbox = hasCheckboxInput(node); const hasCheckbox = hasCheckboxInput(node);
const inlines = htmlNodesToInlines(node.children); const inlines = htmlNodesToInlines(node.children);
const indent = indentFromStyle(node);
return createListItem( return createListItem(
hasCheckbox ? trimLeadingInlineWhitespace(inlines) : inlines, 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( function htmlNodesToInlines(
nodes: readonly HtmlNode[], nodes: readonly HtmlNode[],
marks: readonly WordsMark[] = [], marks: readonly WordsMark[] = [],

@ -27,6 +27,7 @@ const MARK_DELIMITERS = {
type LineMatch = { type LineMatch = {
readonly text: string; readonly text: string;
readonly checked?: boolean; readonly checked?: boolean;
readonly indent?: number;
}; };
export function serializeWordsMarkdown(document: WordsDocument): string { export function serializeWordsMarkdown(document: WordsDocument): string {
@ -70,7 +71,13 @@ export function parseWordsMarkdown(markdown: string): WordsDocument {
children.push( children.push(
createList( createList(
list.kind, 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; index = list.nextIndex;
@ -109,8 +116,9 @@ function serializeBlockMarkdown(block: WordsBlock): string {
return block.children return block.children
.map((item, index) => { .map((item, index) => {
const marker = listMarker(block.kind, index, item.checked === true); const marker = listMarker(block.kind, index, item.checked === true);
const indentStr = item.indent ? ' '.repeat(item.indent) : '';
const content = serializeInlinesMarkdown(item.children); const content = serializeInlinesMarkdown(item.children);
return `${marker} ${content.replace(/\n/g, '\n ')}`; return `${indentStr}${marker} ${content.replace(/\n/g, `\n${indentStr} `)}`;
}) })
.join('\n'); .join('\n');
} }
@ -163,19 +171,24 @@ function collectList(
} }
function parseListLine(line: string): { kind: WordsListKind; item: LineMatch } | undefined { 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) { if (check) {
return { return {
kind: 'check', 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+(.*)$/); const unordered = content.match(/^[-*+]\s+(.*)$/);
if (unordered) return { kind: 'unordered', item: { text: unordered[1] ?? '' } }; if (unordered) return { kind: 'unordered', item: { text: unordered[1] ?? '', indent } };
const ordered = line.match(/^\d+[.)]\s+(.*)$/); const ordered = content.match(/^\d+[.)]\s+(.*)$/);
if (ordered) return { kind: 'ordered', item: { text: ordered[1] ?? '' } }; if (ordered) return { kind: 'ordered', item: { text: ordered[1] ?? '', indent } };
return undefined; return undefined;
} }

@ -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 LinkEditor } from './components/words-link-editor.svelte';
export { default as HiddenInput } from './components/words-hidden-input.svelte'; export { default as HiddenInput } from './components/words-hidden-input.svelte';
export { default as Status } from './components/words-status.svelte'; export { default as Status } from './components/words-status.svelte';
export { default as FindReplace } from './components/words-find-replace.svelte';
export type { export type {
WordsCommandName, WordsCommandName,
@ -27,10 +28,12 @@ export type {
WordsLinkEditorProps as LinkEditorProps, WordsLinkEditorProps as LinkEditorProps,
WordsHiddenInputProps as HiddenInputProps, WordsHiddenInputProps as HiddenInputProps,
WordsStatusProps as StatusProps, WordsStatusProps as StatusProps,
WordsFindReplaceProps as FindReplaceProps,
WordsProviderSnippetProps as ProviderSnippetProps, WordsProviderSnippetProps as ProviderSnippetProps,
WordsContentSnippetProps as ContentSnippetProps, WordsContentSnippetProps as ContentSnippetProps,
WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps, WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps,
WordsLinkEditorSnippetProps as LinkEditorSnippetProps WordsLinkEditorSnippetProps as LinkEditorSnippetProps,
WordsFindReplaceSnippetProps as FindReplaceSnippetProps
} from './types'; } from './types';
export type { export type {
@ -44,5 +47,6 @@ export type {
WordsListKind, WordsListKind,
WordsMark, WordsMark,
WordsSelectedLink, WordsSelectedLink,
WordsSelection WordsSelection,
WordsTextMatch
} from './engine'; } from './engine';

@ -14,6 +14,7 @@ import type {
WordsImportFormat, WordsImportFormat,
WordsListKind, WordsListKind,
WordsMark, WordsMark,
WordsTextMatch,
WordsSelectedLink, WordsSelectedLink,
WordsSelection WordsSelection
} from './engine'; } from './engine';
@ -48,12 +49,16 @@ export type WordsCommandName =
| 'align-center' | 'align-center'
| 'align-right' | 'align-right'
| 'align-justify' | 'align-justify'
| 'increase-indent'
| 'decrease-indent'
| 'unordered-list' | 'unordered-list'
| 'ordered-list' | 'ordered-list'
| 'check-list' | 'check-list'
| 'unlink' | 'unlink'
| 'clear-formatting' | 'clear-formatting'
| 'clear-document' | 'clear-document'
| 'find-text'
| 'replace-all-text'
| 'commit'; | 'commit';
export type WordsProviderSnippetProps = { export type WordsProviderSnippetProps = {
@ -73,8 +78,13 @@ export type WordsProviderSnippetProps = {
readonly canUndo: boolean; readonly canUndo: boolean;
readonly canRedo: boolean; readonly canRedo: boolean;
readonly canUnlink: boolean; readonly canUnlink: boolean;
readonly findQuery: string;
readonly findMatches: readonly WordsTextMatch[];
readonly currentMatchIndex: number;
readonly hasFindMatches: boolean;
readonly focus: () => void; readonly focus: () => void;
readonly selectAll: () => void; readonly selectAll: () => void;
readonly selectBlock: () => void;
readonly runCommand: (command: WordsCommandName) => void; readonly runCommand: (command: WordsCommandName) => void;
readonly insertLink: (href: string, title?: string) => boolean; readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean; readonly unlink: () => boolean;
@ -83,6 +93,10 @@ export type WordsProviderSnippetProps = {
readonly commit: (reason?: WordsCommitReason) => void; readonly commit: (reason?: WordsCommitReason) => void;
readonly undo: () => void; readonly undo: () => void;
readonly redo: () => void; readonly redo: () => void;
readonly setFindQuery: (query: string) => void;
readonly findNext: () => void;
readonly findPrev: () => void;
readonly replaceAllText: (replacement: string) => void;
}; };
export type WordsContentSnippetProps = { export type WordsContentSnippetProps = {
@ -219,3 +233,26 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> &
export type WordsStatusProps = WithChild<{ id?: string }> & export type WordsStatusProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown }>; Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown }>;
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<PrimitiveDivAttributes, { role?: unknown }>;

@ -71,7 +71,7 @@ describe('Words.Content component', () => {
const component = mount(WordsContentHarness, { target }); const component = mount(WordsContentHarness, { target });
await settled(); 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(); expect(content).toBeTruthy();
if (!content) throw new Error('Words content did not mount'); if (!content) throw new Error('Words content did not mount');
expect(textLeaf(content).textContent).toBe('Hello'); expect(textLeaf(content).textContent).toBe('Hello');
@ -109,7 +109,7 @@ describe('Words.Content component', () => {
}); });
await settled(); 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(); expect(content).toBeTruthy();
if (!content) throw new Error('Words content did not mount'); if (!content) throw new Error('Words content did not mount');
expect(textLeaf(content).textContent).toBe('Hello'); expect(textLeaf(content).textContent).toBe('Hello');

@ -674,6 +674,123 @@ describe('WordsProvider', () => {
dom.dispose(); 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<HTMLElement | null>(quoteEl),
command: state<WordsCommandName>('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('<blockquote>Hello</blockquote>');
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<HTMLElement | null>(quoteEl),
command: state<WordsCommandName>('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('<p>Said</p>');
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<HTMLElement | null>(indentEl),
command: state<WordsCommandName>('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(
'<ul><li style="margin-left: 1.5em">Item</li></ul>'
);
cleanup();
dom.dispose();
});
it('syncs a DOM range selection and formats that range from a toolbar command', () => { it('syncs a DOM range selection and formats that range from a toolbar command', () => {
const { dom } = installSomaHarness(); const { dom } = installSomaHarness();
const root = document.createElement('div'); const root = document.createElement('div');

@ -34,6 +34,7 @@ import {
createWordsState, createWordsState,
encodeWordsDomSelectionTarget, encodeWordsDomSelectionTarget,
endWordsComposition, endWordsComposition,
findTextInDocument,
getTextEntries, getTextEntries,
getLinkAtSelection, getLinkAtSelection,
isCollapsedSelection, isCollapsedSelection,
@ -85,8 +86,7 @@ interface WordsToolbarOpts
extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {}
interface WordsToolbarGroupOpts interface WordsToolbarGroupOpts
extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {} extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {}
interface WordsBubbleMenuOpts interface WordsBubbleMenuOpts extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {}
extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {}
interface WordsCommandButtonOpts interface WordsCommandButtonOpts
extends extends
WithRefOpts, WithRefOpts,
@ -102,6 +102,7 @@ interface WordsLinkEditorOpts
href: string; href: string;
title: string | undefined; title: string | undefined;
}> {} }> {}
interface WordsFindReplaceOpts extends WithRefOpts {}
interface WordsHiddenInputOpts extends WithRefOpts {} interface WordsHiddenInputOpts extends WithRefOpts {}
interface WordsStatusOpts extends WithRefOpts {} interface WordsStatusOpts extends WithRefOpts {}
type WordsSelectionSyncSource = type WordsSelectionSyncSource =
@ -140,6 +141,8 @@ export class WordsProvider {
focused = $state(false); focused = $state(false);
dirty = $state(false); dirty = $state(false);
isSelecting = $state(false); isSelecting = $state(false);
findQuery = $state('');
currentMatchIndex = $state(-1);
private pendingCommitReason: WordsCommitReason = 'programmatic'; private pendingCommitReason: WordsCommitReason = 'programmatic';
private typingBatchOpen = false; private typingBatchOpen = false;
@ -227,7 +230,12 @@ export class WordsProvider {
readonly selection = $derived.by(() => this.history.present.selection); readonly selection = $derived.by(() => this.history.present.selection);
readonly activeMarks = $derived.by(() => this.history.present.activeMarks); readonly activeMarks = $derived.by(() => this.history.present.activeMarks);
readonly plainText = $derived.by(() => renderWordsPlainText(this.document)); 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( readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false) () => this.opts.disabled.current || (this.field?.isDisabled ?? false)
@ -273,9 +281,15 @@ export class WordsProvider {
readonly currentTextAlign = $derived.by(() => { readonly currentTextAlign = $derived.by(() => {
const index = this.selection?.anchor.path[0] ?? 0; const index = this.selection?.anchor.path[0] ?? 0;
const block = this.document.children[index]; 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( readonly canUnlink = $derived.by(
() => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly () => 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') { if (name === 'undo') {
this.undo(opts.target); this.undo(opts.target);
return; return;
@ -383,6 +400,24 @@ export class WordsProvider {
this.commit('button', opts.target); this.commit('button', opts.target);
return; 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); const command = this.commandFromName(name, opts.level);
if (!command) return; if (!command) return;
@ -455,9 +490,12 @@ export class WordsProvider {
void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement }); void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement });
}; };
readonly onblur = (_e: SomaFocusEvent<HTMLElement>) => { readonly onblur = (e: SomaFocusEvent<HTMLElement>) => {
this.isSelecting = false; 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.closeTypingBatch();
this.focused = false; this.focused = false;
if (this.opts.commitOnBlur.current) this.commit('blur'); if (this.opts.commitOnBlur.current) this.commit('blur');
@ -479,6 +517,26 @@ export class WordsProvider {
this.applyCommand({ type: 'deleteForward' }); this.applyCommand({ type: 'deleteForward' });
return; 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; return;
} }
@ -502,6 +560,26 @@ export class WordsProvider {
this.runCommandName('underline', { target: e.currentTarget as HTMLElement }); this.runCommandName('underline', { target: e.currentTarget as HTMLElement });
return; 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') { if (key === 'z') {
e.preventDefault(); e.preventDefault();
if (e.shiftKey) this.redo(e.currentTarget as HTMLElement); if (e.shiftKey) this.redo(e.currentTarget as HTMLElement);
@ -530,14 +608,14 @@ export class WordsProvider {
readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => { readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => {
this.isSelecting = false; this.isSelecting = false;
if (e.detail === 3) {
this.selectBlock();
return;
}
if (!this.isEditable) return; if (!this.isEditable) return;
const target = e.target; const target = e.target;
const targetElement = const targetElement =
target instanceof Element target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
? target
: target instanceof Node
? target.parentElement
: null;
if (!targetElement) return; if (!targetElement) return;
const link = targetElement.closest('a[href]'); const link = targetElement.closest('a[href]');
if (!link || !e.currentTarget.contains(link)) return; if (!link || !e.currentTarget.contains(link)) return;
@ -633,8 +711,13 @@ export class WordsProvider {
canUndo: this.canUndo, canUndo: this.canUndo,
canRedo: this.canRedo, canRedo: this.canRedo,
canUnlink: this.canUnlink, canUnlink: this.canUnlink,
findQuery: this.findQuery,
findMatches: this.findMatches,
currentMatchIndex: this.currentMatchIndex,
hasFindMatches: this.hasFindMatches,
focus: () => this.focus(), focus: () => this.focus(),
selectAll: () => this.selectAll(), selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(),
runCommand: (command: WordsCommandName) => this.runCommandName(command), runCommand: (command: WordsCommandName) => this.runCommandName(command),
insertLink: (href: string, title?: string) => this.insertLink(href, title), insertLink: (href: string, title?: string) => this.insertLink(href, title),
unlink: () => this.unlink(), unlink: () => this.unlink(),
@ -643,7 +726,11 @@ export class WordsProvider {
exportContent: (format: WordsExportFormat) => this.exportContent(format), exportContent: (format: WordsExportFormat) => this.exportContent(format),
commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason), commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason),
undo: () => this.undo(), 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(() => readonly props = $derived.by(() =>
@ -661,6 +748,12 @@ export class WordsProvider {
this.publishPresent(result.state, { documentChanged: false }); 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( private shouldIgnoreTransientSelectionCollapse(
next: WordsSelection, next: WordsSelection,
root: HTMLElement, root: HTMLElement,
@ -671,10 +764,7 @@ export class WordsProvider {
const active = root.ownerDocument.activeElement; const active = root.ownerDocument.activeElement;
if (!(active instanceof HTMLElement)) return false; if (!(active instanceof HTMLElement)) return false;
if (active === root) return false; if (active === root) return false;
const toolElement = active.closest( if (this.isInsideWordsTool(active)) return true;
'[data-words-bubble-menu], [data-words-toolbar], [data-words-tool-popover]'
);
if (toolElement) return true;
if (root.contains(active)) return false; if (root.contains(active)) return false;
return false; return false;
} }
@ -693,6 +783,48 @@ export class WordsProvider {
void tick().then(() => this.restoreDomSelection()); 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() { private ensureSelection() {
if (this.selection) return; if (this.selection) return;
const first = getTextEntries(this.document)[0]; 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 { private historyCommand(command: WordsCommand): WordsHistory {
return applyHistoryCommand(this.history, command); return applyHistoryCommand(this.history, command);
} }
@ -748,7 +891,7 @@ export class WordsProvider {
) { ) {
const current = this.history.present; const current = this.history.present;
if (externalDocument !== current.document) { if (!sameWordsDocument(externalDocument, current.document)) {
const nextState = createWordsState(externalDocument, externalSelection); const nextState = createWordsState(externalDocument, externalSelection);
if (!sameWordsDocument(nextState.document, current.document)) { if (!sameWordsDocument(nextState.document, current.document)) {
const limit = this.history.limit; const limit = this.history.limit;
@ -814,6 +957,7 @@ export class WordsProvider {
return { type: 'setBlock', block: 'heading', level: level ?? 1 }; return { type: 'setBlock', block: 'heading', level: level ?? 1 };
case 'quote': case 'quote':
if (this.currentBlock === 'quote') return { type: 'setBlock', block: 'paragraph' };
return { type: 'setBlock', block: 'quote' }; return { type: 'setBlock', block: 'quote' };
case 'align-left': case 'align-left':
return { type: 'setTextAlign', align: 'left' }; return { type: 'setTextAlign', align: 'left' };
@ -823,6 +967,10 @@ export class WordsProvider {
return { type: 'setTextAlign', align: 'right' }; return { type: 'setTextAlign', align: 'right' };
case 'align-justify': case 'align-justify':
return { type: 'setTextAlign', align: 'justify' }; return { type: 'setTextAlign', align: 'justify' };
case 'increase-indent':
return { type: 'increaseIndent' };
case 'decrease-indent':
return { type: 'decreaseIndent' };
case 'unordered-list': case 'unordered-list':
return { type: 'toggleList', kind: 'unordered' }; return { type: 'toggleList', kind: 'unordered' };
case 'ordered-list': case 'ordered-list':
@ -1074,9 +1222,7 @@ export class WordsBubbleMenuProvider {
...this.runtimePart.props, ...this.runtimePart.props,
role: 'toolbar' as const, role: 'toolbar' as const,
'aria-orientation': 'horizontal' as const, 'aria-orientation': 'horizontal' as const,
'aria-label': this.provider.soma.langs.ts( 'aria-label': this.provider.soma.langs.ts('#?components.words.bubbleMenu|Selection toolbar'),
'#?components.words.bubble-menu|Selection toolbar'
),
'data-open': boolToEmptyStrOrUndef(this.open), 'data-open': boolToEmptyStrOrUndef(this.open),
'data-positioned': boolToEmptyStrOrUndef(this.positioned), 'data-positioned': boolToEmptyStrOrUndef(this.positioned),
'data-side': this.opts.side.current, '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 === 'redo') return this.provider.isReadonly || !this.provider.canRedo;
if (command === 'unlink') return this.provider.isReadonly || !this.provider.canUnlink; if (command === 'unlink') return this.provider.isReadonly || !this.provider.canUnlink;
if (command === 'commit') return false; if (command === 'commit') return false;
if (command === 'increase-indent' || command === 'decrease-indent') {
return this.provider.isReadonly || this.provider.currentBlock !== 'list';
}
return this.provider.isReadonly; return this.provider.isReadonly;
} }
} }
@ -1253,8 +1402,7 @@ export class WordsLinkEditorProvider {
readonly: this.provider.isReadonly, readonly: this.provider.isReadonly,
captureSelection: () => this.captureSelection(), captureSelection: () => this.captureSelection(),
validate: (href?: string) => this.validate(href), validate: (href?: string) => this.validate(href),
apply: (href?: string, title?: string, target?: HTMLElement) => apply: (href?: string, title?: string, target?: HTMLElement) => this.apply(href, title, target),
this.apply(href, title, target),
unlink: (target?: HTMLElement) => this.unlink(target), unlink: (target?: HTMLElement) => this.unlink(target),
clearError: () => this.clearError() clearError: () => this.clearError()
})); }));
@ -1274,7 +1422,7 @@ export class WordsLinkEditorProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, ...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-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)), '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 { export class WordsHiddenInputProvider {
readonly opts: WordsHiddenInputOpts; readonly opts: WordsHiddenInputOpts;
readonly provider: WordsProvider; readonly provider: WordsProvider;
@ -1405,7 +1603,8 @@ function sameWordsSelection(a: WordsSelection | null, b: WordsSelection | null):
} }
function sameWordsDocument(a: WordsDocument, b: WordsDocument): boolean { 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); return serializeWordsJson(a) === serializeWordsJson(b);
} }

@ -36,6 +36,10 @@
type DocumentPreset = 'sample' | 'empty' | 'long'; type DocumentPreset = 'sample' | 'empty' | 'long';
type ToolbarChoice = 'none' | WordsToolbarPreset | 'custom'; type ToolbarChoice = 'none' | WordsToolbarPreset | 'custom';
type TraceEntry = { event: string; family: string; intent?: string; at: number }; 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 RawValue = { kind?: string; value?: unknown; prop?: string; part?: string };
type RawPart = { type RawPart = {
@ -107,7 +111,9 @@
'align-justify', 'align-justify',
'unordered-list', 'unordered-list',
'ordered-list', 'ordered-list',
'check-list' 'check-list',
'increase-indent',
'decrease-indent'
], ],
['link-editor', 'unlink', 'clear-formatting', 'clear-document', 'commit'] ['link-editor', 'unlink', 'clear-formatting', 'clear-document', 'commit']
] ]
@ -131,6 +137,7 @@
let commitOnBlur = $state(true); let commitOnBlur = $state(true);
let toolbarChoice = $state<ToolbarChoice>('full'); let toolbarChoice = $state<ToolbarChoice>('full');
let showLinkEditor = $state(true); let showLinkEditor = $state(true);
let showFindReplace = $state(true);
let showBubbleMenu = $state(false); let showBubbleMenu = $state(false);
let showStatus = $state(true); let showStatus = $state(true);
let orientation = $state<WordsToolbarOrientation>('horizontal'); let orientation = $state<WordsToolbarOrientation>('horizontal');
@ -151,7 +158,9 @@
const selectionState = $derived(selectionLabel(selection)); const selectionState = $derived(selectionLabel(selection));
const documentJson = $derived(JSON.stringify(value, null, 2)); const documentJson = $derived(JSON.stringify(value, null, 2));
const exportPreview = $derived(exportWordsDocument(value, exportFormat)); const exportPreview = $derived(exportWordsDocument(value, exportFormat));
const toolbarSnippetGroups = $derived(resolveDemoToolbarGroups(toolbarChoice, showLinkEditor)); const toolbarSnippetGroups = $derived(
resolveDemoToolbarGroups(toolbarChoice, showLinkEditor, showFindReplace)
);
const toolbarConfig = $derived<WordsToolbarConfig>( const toolbarConfig = $derived<WordsToolbarConfig>(
toolbarChoice === 'none' ? false : toolbarSnippetGroups toolbarChoice === 'none' ? false : toolbarSnippetGroups
); );
@ -161,7 +170,9 @@
? 'disabled' ? 'disabled'
: `${toolbarChoice} · ${toolbarSnippetGroups.length} groups · ${toolbarCommandCount} items` : `${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( const somaSnippet = $derived(
[ [
@ -184,6 +195,7 @@
'>', '>',
...somaToolbarSnippet(toolbarSnippetGroups, orientation), ...somaToolbarSnippet(toolbarSnippetGroups, orientation),
...(showLinkEditor && toolbarChoice === 'none' ? somaLinkEditorSnippet() : []), ...(showLinkEditor && toolbarChoice === 'none' ? somaLinkEditorSnippet() : []),
showFindReplace && toolbarChoice === 'none' && ' <Words.FindReplace />',
' <Words.Placeholder />', ' <Words.Placeholder />',
' <Words.Content />', ' <Words.Content />',
...(showBubbleMenu ? somaBubbleMenuSnippet() : []), ...(showBubbleMenu ? somaBubbleMenuSnippet() : []),
@ -219,6 +231,7 @@
!commitOnBlur && ' commitOnBlur={false}', !commitOnBlur && ' commitOnBlur={false}',
'>', '>',
showLinkEditor && toolbarChoice === 'none' && ' <Words.LinkEditor />', showLinkEditor && toolbarChoice === 'none' && ' <Words.LinkEditor />',
showFindReplace && toolbarChoice === 'none' && ' <Words.FindReplace toolbarItem={false} />',
' <Words.Placeholder />', ' <Words.Placeholder />',
' <Words.Content />', ' <Words.Content />',
showBubbleMenu && ' <Words.BubbleMenu>', showBubbleMenu && ' <Words.BubbleMenu>',
@ -287,6 +300,18 @@
kind: 'soma', kind: 'soma',
note: 'invalid link feedback' 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]', selector: '[data-words-placeholder][hidden]',
source: 'morfo', source: 'morfo',
@ -305,6 +330,12 @@
kind: 'soma', kind: 'soma',
note: 'rendered marks' 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]', selector: '[data-words-status][data-status]',
source: 'morfo', source: 'morfo',
@ -372,6 +403,16 @@
type: 'list-item', type: 'list-item',
children: [{ type: 'text', text: 'HiddenInput serializes the JSON payload.' }] 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', type: 'list-item',
children: [ children: [
@ -436,6 +477,11 @@
type: 'list-item', type: 'list-item',
children: [{ type: 'text', text: 'Inspect the Morfo tab for parts and attributes.' }] 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', type: 'list-item',
children: [{ type: 'text', text: 'Use Sema play buttons against the mounted stage.' }] children: [{ type: 'text', text: 'Use Sema play buttons against the mounted stage.' }]
@ -533,22 +579,38 @@
toolbarOrientation: WordsToolbarOrientation toolbarOrientation: WordsToolbarOrientation
): string[] { ): string[] {
if (groups.length === 0) return []; 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 [ return [
` <Words.Toolbar orientation="${toolbarOrientation}">`, ...(toolbarGroups.length
...groups.flatMap((group) => [ ? [
' <Words.ToolbarGroup>', ` <Words.Toolbar orientation="${toolbarOrientation}">`,
...group.flatMap((command) => somaCommandSnippet(command)), ...toolbarGroups.flatMap((group) => [
' </Words.ToolbarGroup>' ' <Words.ToolbarGroup>',
]), ...group.flatMap((command) => somaCommandSnippet(command)),
' </Words.Toolbar>' ' </Words.ToolbarGroup>'
]; ]),
' </Words.Toolbar>'
]
: []),
hasFindReplace && ' <Words.FindReplace />'
].filter((line): line is string => Boolean(line));
} }
function somaCommandSnippet(item: WordsToolbarGroupConfig[number]): string[] { 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()}`); 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 [' <Words.HeadingPicker />']; return [' <Words.HeadingPicker />'];
} }
const command = typeof item === 'string' ? item : item.command; const command = typeof item === 'string' ? item : item.command;
@ -592,21 +654,33 @@
function resolveDemoToolbarGroups( function resolveDemoToolbarGroups(
choice: ToolbarChoice, choice: ToolbarChoice,
includeLinkEditor: boolean includeLinkEditor: boolean,
includeFindReplace: boolean
): WordsToolbarGroupConfig[] { ): WordsToolbarGroupConfig[] {
if (choice === 'none') return []; if (choice === 'none') return [];
const source = choice === 'custom' ? toolbarCustomGroups : toolbarPresetGroups[choice]; const source = choice === 'custom' ? toolbarCustomGroups : toolbarPresetGroups[choice];
const groups = source.map((group) => const groups = source.map((group) =>
group.filter( 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); 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; 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 { function countToolbarItems(groups: readonly WordsToolbarGroupConfig[]): number {
return groups.reduce((total, group) => total + group.length, 0); return groups.reduce((total, group) => total + group.length, 0);
} }
@ -658,8 +732,9 @@
<h1 data-uix-page-title>Words</h1> <h1 data-uix-page-title>Words</h1>
<p data-uix-page-lede> <p data-uix-page-lede>
Rich-text editor primitive with canonical JSON, contenteditable bridge, command toolbar, form 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 serialization, find/replace and semantic events. Morfo declares the editor parts and semantic
owns state, selection, input, links and history; Eidos only paints the emitted data contract. events; Soma owns state, selection, input, links, search and history; Eidos only paints the
emitted data contract.
</p> </p>
<div data-uix-page-meta> <div data-uix-page-meta>
<span data-uix-meta-pill <span data-uix-meta-pill
@ -681,60 +756,61 @@
bind:this={stageRef} bind:this={stageRef}
style="display: flex; align-items: stretch; justify-content: center; padding: var(--uix-space-5);" style="display: flex; align-items: stretch; justify-content: center; padding: var(--uix-space-5);"
> >
{#key documentRevision} <Words
<Words bind:value
bind:value bind:selection
bind:selection {placeholder}
{placeholder} name="body"
name="body" {disabled}
{disabled} {readonly}
{readonly} {required}
{required} {invalid}
{invalid} {commitOnBlur}
{commitOnBlur} {size}
{size} {variant}
{variant} {color}
{color} toolbar={toolbarConfig}
toolbar={toolbarConfig} toolbarOrientation={orientation}
toolbarOrientation={orientation} onValueCommit={(_, reason) => (lastCommit = reason)}
onValueCommit={(_, reason) => (lastCommit = reason)} >
> {#snippet children({ selectedLink })}
{#snippet children({ selectedLink })} {#if showLinkEditor && toolbarChoice === 'none'}
{#if showLinkEditor && toolbarChoice === 'none'} <Words.LinkEditor />
<Words.LinkEditor /> {/if}
{/if} {#if showFindReplace && toolbarChoice === 'none'}
<Words.Placeholder>{placeholder}</Words.Placeholder> <Words.FindReplace toolbarItem={false} />
<Words.Content {/if}
data-perm-step={!disabled && !readonly ? '0' : undefined} <Words.Placeholder>{placeholder}</Words.Placeholder>
data-perm-label={!disabled && !readonly ? 'edit words content' : undefined} <Words.Content
/> data-perm-step={!disabled && !readonly ? '0' : undefined}
{#if showBubbleMenu} data-perm-label={!disabled && !readonly ? 'edit words content' : undefined}
<Words.BubbleMenu> />
<Words.CommandButton command="bold" aria-label="Bold"> {#if showBubbleMenu}
<Bold size="sm" decorative /> <Words.BubbleMenu>
</Words.CommandButton> <Words.CommandButton command="bold" aria-label="Bold">
<Words.CommandButton command="italic" aria-label="Italic"> <Bold size="sm" decorative />
<Italic size="sm" decorative /> </Words.CommandButton>
</Words.CommandButton> <Words.CommandButton command="italic" aria-label="Italic">
<Words.CommandButton command="underline" aria-label="Underline"> <Italic size="sm" decorative />
<Underline size="sm" decorative /> </Words.CommandButton>
</Words.CommandButton> <Words.CommandButton command="underline" aria-label="Underline">
{#if showLinkEditor} <Underline size="sm" decorative />
<Words.LinkEditor /> </Words.CommandButton>
{/if} {#if showLinkEditor}
</Words.BubbleMenu> <Words.LinkEditor />
{/if} {/if}
<Words.HiddenInput /> </Words.BubbleMenu>
{#if showStatus} {/if}
<Words.Status> <Words.HiddenInput />
{invalid {#if showStatus}
? 'Invalid content' <Words.Status>
: `${required ? 'Required editor' : 'Optional editor'} · ${wordCount} words · ${selectedLink ? selectedLink.href : lastCommit}`} {invalid
</Words.Status> ? 'Invalid content'
{/if} : `${required ? 'Required editor' : 'Optional editor'} · ${wordCount} words · ${selectedLink ? selectedLink.href : lastCommit}`}
{/snippet} </Words.Status>
</Words> {/if}
{/key} {/snippet}
</Words>
</div> </div>
<div data-uix-stage-trace> <div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span> <span data-uix-stage-trace-key>trace</span>
@ -859,7 +935,7 @@
{/each} {/each}
</span> </span>
</label> </label>
{#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'bubbleMenu', 'status'] as key} {#each ['disabled', 'readonly', 'required', 'invalid', 'commitOnBlur', 'linkEditor', 'findReplace', 'bubbleMenu', 'status'] as key}
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>{key}</span> <span data-uix-control-label>{key}</span>
<span data-uix-switch> <span data-uix-switch>
@ -881,6 +957,9 @@
{:else if key === 'linkEditor'} {:else if key === 'linkEditor'}
<input type="checkbox" bind:checked={showLinkEditor} /> <input type="checkbox" bind:checked={showLinkEditor} />
<span data-uix-switch-label>{showLinkEditor ? 'shown' : 'hidden'}</span> <span data-uix-switch-label>{showLinkEditor ? 'shown' : 'hidden'}</span>
{:else if key === 'findReplace'}
<input type="checkbox" bind:checked={showFindReplace} />
<span data-uix-switch-label>{showFindReplace ? 'shown' : 'hidden'}</span>
{:else if key === 'bubbleMenu'} {:else if key === 'bubbleMenu'}
<input type="checkbox" bind:checked={showBubbleMenu} /> <input type="checkbox" bind:checked={showBubbleMenu} />
<span data-uix-switch-label>{showBubbleMenu ? 'shown' : 'hidden'}</span> <span data-uix-switch-label>{showBubbleMenu ? 'shown' : 'hidden'}</span>
@ -941,6 +1020,9 @@
<tr><td class="name">export</td><td>{exportFormat}</td></tr> <tr><td class="name">export</td><td>{exportFormat}</td></tr>
<tr><td class="name">import</td><td>{importFeedback}</td></tr> <tr><td class="name">import</td><td>{importFeedback}</td></tr>
<tr><td class="name">link editor</td><td>{showLinkEditor ? 'shown' : 'hidden'}</td></tr> <tr><td class="name">link editor</td><td>{showLinkEditor ? 'shown' : 'hidden'}</td></tr>
<tr
><td class="name">find/replace</td><td>{showFindReplace ? 'shown' : 'hidden'}</td></tr
>
<tr><td class="name">last commit</td><td>{lastCommit}</td></tr> <tr><td class="name">last commit</td><td>{lastCommit}</td></tr>
</tbody> </tbody>
</table> </table>
@ -1062,8 +1144,7 @@
></tr ></tr
> >
<tr <tr
><td class="name">BubbleMenu</td><td>soma/eidos</td><td class="default" ><td class="name">BubbleMenu</td><td>soma/eidos</td><td class="default">optional</td
>optional</td
><td ><td
>Selection-anchored toolbar that appears only for text ranges and reuses command >Selection-anchored toolbar that appears only for text ranges and reuses command
buttons.</td buttons.</td
@ -1078,6 +1159,11 @@
><td class="name">LinkEditor</td><td>soma/eidos</td><td class="default">optional</td ><td class="name">LinkEditor</td><td>soma/eidos</td><td class="default">optional</td
><td>Form part that applies or removes links against the current selection.</td></tr ><td>Form part that applies or removes links against the current selection.</td></tr
> >
<tr
><td class="name">FindReplace</td><td>soma/eidos</td><td class="default">optional</td
><td>Search panel that marks matches, moves selection and replaces all matches.</td
></tr
>
<tr <tr
><td class="name">selectedLink</td><td>soma</td><td class="default">null</td><td ><td class="name">selectedLink</td><td>soma</td><td class="default">null</td><td
>Snippet state for the link under the current document selection.</td >Snippet state for the link under the current document selection.</td
@ -1370,8 +1456,14 @@
> >
<tr <tr
><td class="name">Link editor</td><td ><td class="name">Link editor</td><td
>Link editing is a native <code>form</code> inside a reusable tool popover; URL, apply, >Link editing is a native <code>form</code> inside Popover; URL, apply, unlink and close
unlink and close controls stay keyboard reachable and operate on Soma selection state.</td controls stay keyboard reachable and operate on Soma selection state.</td
></tr
>
<tr
><td class="name">Find/replace</td><td
>Find/replace uses <code>role="search"</code>, native inputs, live match counts and
disabled/readonly state from Soma.</td
></tr ></tr
> >
<tr <tr

Loading…
Cancel
Save

Powered by TurnKey Linux.