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 |
| Import/export HTML | not owned | available | available | clean export; defer importer | partial |
| Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` | implement |
| Find/replace | search landmark + live status | plugin panel | extension command UI | `Words.FindReplace` | implement |
| Bubble menu | contextual toolbar pattern | plugins | extension UI | `Words.BubbleMenu` anchored to text selection | implement |
| Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer |
@ -74,11 +75,12 @@ rich-text ni wrapper visual reutilizable.
| Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment |
| Commands | `Words.CommandButton` | active/disabled/commit styling |
| Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset |
| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Status | `Words.Status` | idle/error status recipe |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command |
| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Find/replace | `Words.FindReplace` | default search/replacement panel + match highlights |
| Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Status | `Words.Status` | idle/error status recipe |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command |
## Decisiones
@ -97,6 +99,10 @@ rich-text ni wrapper visual reutilizable.
- `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica
`insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos
ofrece una UI por defecto que puede reemplazarse con snippet.
- `Words.FindReplace` es una parte compuesta real y opcional: Soma mantiene la
query, los matches y la seleccion activa; Morfo declara `find-replace` y el
evento canonico `commit-set-replace`; Eidos lo presenta como popover centrado
con flecha y pinta los highlights emitidos por el motor.
- `Words.BubbleMenu` es una parte publica compuesta y **opcional**: no se
incluye por defecto en `<Words>`; el consumidor debe agregarla explicitamente
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.
- **Word boundary robusto**: algoritmo naive reemplazado por `Intl.Segmenter` + fallback para puntuacion/CJK.
Tests:
- Engine: 42/42 pasan (incluye 5 nuevos de `setTextAlign`).
- Build: pasa (`npm run build` exitoso).
- Nota: `words-content.svelte.test.ts` tiene 2 fallos pre-existentes de montaje Svelte en entorno jsdom (no relacionados con alignment).
### Fase 0 — Deuda tecnica cerrada (2026-05-26)
- **Contrato CSS**: `--words-selection-color` ahora se consume correctamente en `words.css`; test `recipe-css-contract` verde.
- **Traducciones**: normalizadas claves `bubbleMenu` y `linkEditor` a camelCase en Morfo, Soma y catalogo de idiomas; `translations:check` verde.
- **Tests de montaje**: corregidos 2 fallos en `words-content.svelte.test.ts` (selector `data-testid` cambiado a `data-words-content`).
### Fase 1 — Features V1.1 (2026-05-26)
- **Atajos de teclado para alignment**: `Ctrl/Cmd+L/R/E/J` mapeados a `align-left/center/right/justify` en `words-provider.svelte.ts`.
- **Toolbar tooltips**: `title` derivado de `aria-label` en `words-command-button.svelte`.
- **HeadingPicker keyboard navigation**: `ArrowUp/Down` ciclan foco, `Home/End` van a extremos, `Escape` cierra dropdown.
- **Indentacion de listas**: nueva propiedad `indent?: number` en `WordsListItem` (0-8). `Tab`/`Shift+Tab` dentro de listas ajusta indent. Comandos `increase-indent`/`decrease-indent` en preset `full`. Render via `data-words-indent` + CSS.
- **Seleccion de bloque entero**: triple-click (`e.detail === 3`) en `Words.Content` selecciona todo el bloque via `selectBlock()`.
- **Soft breaks**: `Shift+Enter` interceptado en `onkeydown` y aplica `insertLineBreak` (`\n` dentro del mismo bloque).
### Fase 2 — V1.1 formal (2026-05-26)
## Next steps (para continuar manana)
- **Sincronizacion de `value` externo sin remount**: validado; demo elimino `{#key documentRevision}` y el Provider sincroniza via `syncExternalState` sin perder foco ni estado interno.
- **Bubble menu scroll/resize**: ya estaba implementado con `<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 |
| ------- | ----------- | ----------- | -------- |
| **Indentacion de listas** (increase/decrease indent) | `operations.ts` (nueva operacion), `commands.ts`, `types.ts` (nuevos `CommandName`), toolbar UI | Media | Ninguno |
| **Atajos de teclado para alignment** (Ctrl/Cmd+L/R/E/J) | `words-provider.svelte.ts` (`onkeydown`), `words-content.svelte` (event forwarding) | Baja | Ninguno |
| **HeadingPicker keyboard navigation** (arrow keys + Enter en dropdown) | `words-heading-picker.svelte` | Baja | Ninguno |
| **Toolbar tooltips** (aria-label como tooltip nativo o title) | `words.svelte` + CSS | Baja | Ninguno |
| **Seleccion de bloque entero** (triple-click o margin-click) | `words-provider.svelte.ts` + `operations.ts` | Media | Ninguno |
| **Soft breaks** (Shift+Enter dentro de paragraph sin crear nuevo bloque) | `operations.ts` (nuevo `insertSoftBreak`), `input.ts` | Media | Disenar: como representar `<br>` en el modelo JSON |
### Fase 3 — Find/replace cerrado (2026-05-26)
- **Find/replace transversal**: `FindReplace` existe en Morfo, Soma y Eidos.
El panel usa `role="search"`, propaga `disabled`/`readonly`, muestra contador
live, navega matches moviendo la seleccion real y ejecuta replace-all con el
evento `commit-set-replace`.
- **Toolbar config segura**: el `toolbar` de Eidos permite `find-replace` como
parte opt-in dentro del `ToolbarGroup`; el item abre un popover centrado con
flecha en lugar de insertar una banda fija bajo la barra. Los comandos
`find-text`/`replace-all-text` quedan reservados para Soma/snippets y no se
exponen como botones visuales mudos.
- **Demo visible**: el demo permite activar/desactivar `FindReplace`, refleja la
parte en snippets Soma/Eidos y documenta los selectors de match.
Tests:
- Engine: 47/47 pasan (incluye 5 nuevos: 3 indentacion, 2 markdown/html indent).
- Provider: 21/21 pasan.
- Content: 2/2 pasan.
- CSS Contract: 6/6 pasan.
- Build: pasa (`npm run build` exitoso).
- Translations: 0 errores, 0 warnings.
## Next steps (V2)
| Feature | Donde tocar | Complejidad | Bloqueos |
| ----------------------- | ------------------------------------------ | ----------- | -------- |
| **Imagenes y archivos** | Servicios ActiveApp/ActiveUIX | Alta | Defer V2 |
| **Tablas** | Modelo de nodo + render + operaciones | Alta | Defer V2 |
| **Slash commands** | Autocomplete + comandos de insercion | Alta | Defer V2 |
| **Code blocks** | Nuevo tipo de bloque + syntax highlighting | Media | Ninguno |

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

@ -3,6 +3,7 @@ import type {
BubbleMenuProps,
CommandButtonProps,
ContentProps,
FindReplaceProps,
HiddenInputProps,
LinkEditorProps,
PlaceholderProps,
@ -18,16 +19,21 @@ import type {
WordsListKind,
WordsToolbarOrientation
} from '$soma/components/words';
import type { ButtonRounded, ButtonSize, ButtonVariant } from '$uix/eidos/components/button';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type WordsVariant = ControlVariant;
export type WordsColor = ColorRole;
export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full';
export type WordsToolbarButtonCommandName = Exclude<
WordsCommandName,
'find-text' | 'replace-all-text'
>;
export type WordsToolbarCommandConfig =
| WordsCommandName
| WordsToolbarButtonCommandName
| {
command: WordsCommandName;
command: WordsToolbarButtonCommandName;
label?: string;
level?: WordsHeadingLevel;
listKind?: WordsListKind;
@ -44,10 +50,17 @@ export type WordsToolbarHeadingPickerConfig =
part: 'heading-picker';
label?: string;
};
export type WordsToolbarFindReplaceConfig =
| 'find-replace'
| {
part: 'find-replace';
label?: string;
};
export type WordsToolbarItemConfig =
| WordsToolbarCommandConfig
| WordsToolbarLinkEditorConfig
| WordsToolbarHeadingPickerConfig;
| WordsToolbarHeadingPickerConfig
| WordsToolbarFindReplaceConfig;
export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[];
export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[];
@ -70,7 +83,22 @@ export type WordsToolbarProps = ToolbarProps;
export type WordsToolbarGroupProps = ToolbarGroupProps;
export type WordsBubbleMenuProps = BubbleMenuProps;
export type WordsBubbleMenuSnippetProps = SomaWordsBubbleMenuSnippetProps;
export type WordsCommandButtonProps = CommandButtonProps;
export type WordsCommandButtonProps = CommandButtonProps & {
/** Visual treatment forwarded to Eidos Button. @default 'ghost' */
variant?: ButtonVariant;
/** Visual size forwarded to Eidos Button. @default 'xs' */
size?: ResponsiveProp<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 WordsHiddenInputProps = HiddenInputProps;
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">
import { Button } from '$uix/eidos/components/button';
import * as Words from '$soma/components/words';
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>
{#if children}
<Words.CommandButton {...rest}>
{@render children()}
</Words.CommandButton>
{:else}
<Words.CommandButton {...rest} />
{/if}
<Words.CommandButton {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({
props: {
...props,
'data-toolbar-button': toolbarItem ? '' : undefined,
'data-toolbar-group-item': toolbarItem ? '' : undefined
}
})}
{:else}
{@const label = props['aria-label']}
{#if children}
<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">
import { Heading1, Heading2, Heading3, Pilcrow } from '$uix/eidos/components/icon';
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';
let {
@ -14,16 +14,19 @@
let open = $state(false);
const headingLabel = $derived.by(() => {
if (currentBlock === 'heading' && currentHeadingLevel === 1) return 'Heading 1';
if (currentBlock === 'heading' && currentHeadingLevel === 2) return 'Heading 2';
if (currentBlock === 'heading' && currentHeadingLevel === 3) return 'Heading 3';
return 'Paragraph';
});
const triggerLabel = $derived(
currentBlock === 'heading' ? `Heading ${currentHeadingLevel ?? 1}` : 'Paragraph'
);
</script>
<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}
<Heading1 size="sm" decorative />
{:else if currentBlock === 'heading' && currentHeadingLevel === 2}
@ -35,39 +38,53 @@
{/if}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="center" sideOffset={4}>
<div data-words-heading-picker>
<Words.CommandButton
<Popover.Content
data-words-floating
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"
level={1}
aria-label="Heading 1"
toolbarItem={false}
onclick={() => (open = false)}
>
<Heading1 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
</CommandButton>
<CommandButton
command="heading"
level={2}
aria-label="Heading 2"
toolbarItem={false}
onclick={() => (open = false)}
>
<Heading2 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
</CommandButton>
<CommandButton
command="heading"
level={3}
aria-label="Heading 3"
toolbarItem={false}
onclick={() => (open = false)}
>
<Heading3 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
</CommandButton>
<CommandButton
command="paragraph"
aria-label="Paragraph"
toolbarItem={false}
onclick={() => (open = false)}
>
<Pilcrow size="sm" decorative />
</Words.CommandButton>
</CommandButton>
</div>
</Popover.Content>
</Popover.Portal>

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

@ -1,10 +1,33 @@
<script lang="ts">
import { mergeProps } from '$uix/soma/props';
import { Toolbar } from '$uix/eidos/components/toolbar';
import * as Words from '$soma/components/words';
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>
<Words.Toolbar {...rest}>
{@render children?.()}
<Words.Toolbar {...rest} {orientation} aria-label={toolbarAriaLabel}>
{#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>

@ -14,7 +14,42 @@
--_words-accent-border: var(--words-color-primary-border);
--_words-accent-text: var(--words-color-primary-text);
--_words-selection-bg: color-mix(in srgb, var(--_words-accent-solid) 78%, transparent);
--_words-selection-color: var(--words-command-solid-color);
--_words-selection-color: var(--words-selection-color);
--toolbar-gap-md: var(--words-toolbar-gap);
--toolbar-padding-inline-sm: var(--_words-toolbar-px);
--toolbar-padding-inline-md: var(--_words-toolbar-px);
--toolbar-padding-block-sm: var(--_words-toolbar-py);
--toolbar-padding-block-md: var(--_words-toolbar-py);
--toolbar-control-height-sm: var(--_words-button-size);
--toolbar-control-height-md: var(--_words-button-size);
--toolbar-control-px-sm: var(--words-command-px);
--toolbar-control-px-md: var(--words-command-px);
--toolbar-control-gap-sm: var(--words-command-gap);
--toolbar-control-gap-md: var(--words-command-gap);
--toolbar-font-size-sm: var(--_words-font-size);
--toolbar-font-size-md: var(--_words-font-size);
--toolbar-bg: var(--words-toolbar-bg);
--toolbar-border: transparent;
--toolbar-shadow: none;
--toolbar-color: var(--words-command-color);
--toolbar-font-family: var(--words-font-family);
--toolbar-line-height: var(--words-line-height);
--toolbar-group-gap: var(--words-command-gap);
--toolbar-control-border-width: var(--words-border-width);
--toolbar-control-border: transparent;
--toolbar-control-radius: var(--words-command-radius);
--toolbar-control-bg: transparent;
--toolbar-control-color: var(--words-command-color);
--toolbar-control-border-hover: var(--words-command-border-hover);
--toolbar-control-bg-hover: var(--words-command-bg-hover);
--toolbar-control-color-hover: var(--words-command-color-hover);
--toolbar-control-border-active: var(--_words-accent-border);
--toolbar-control-bg-active: var(--_words-accent-track);
--toolbar-control-color-active: var(--_words-accent-text);
--toolbar-focus-shadow: var(--words-focus-shadow);
--toolbar-disabled-opacity: var(--words-disabled-opacity);
--toolbar-transition-duration: var(--words-transition-duration);
--toolbar-transition-ease: var(--words-transition-ease);
position: relative;
display: flex;
@ -151,15 +186,23 @@
grid-template-rows: auto auto;
}
[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace]) {
grid-template-rows: auto auto auto;
}
[data-words-toolbar] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--words-toolbar-gap);
inline-size: 100%;
padding-block: var(--_words-toolbar-py);
padding-inline: var(--_words-toolbar-px);
border: 0;
border-block-end: var(--words-border-width) solid var(--words-toolbar-border);
border-radius: 0;
background: var(--words-toolbar-bg);
box-shadow: none;
}
[data-words-toolbar][data-orientation='vertical'] {
@ -221,35 +264,8 @@
}
[data-words-command-button] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--words-command-gap);
min-inline-size: var(--_words-button-size);
block-size: var(--_words-button-size);
padding-inline: var(--words-command-px);
border: var(--words-border-width) solid transparent;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-command-color);
font: inherit;
cursor: pointer;
outline: none;
transition:
background var(--words-transition-duration) var(--words-transition-ease),
border-color var(--words-transition-duration) var(--words-transition-ease),
color var(--words-transition-duration) var(--words-transition-ease),
box-shadow var(--words-transition-duration) var(--words-transition-ease);
}
[data-words-command-button]:hover:not(:disabled):not([data-disabled]) {
border-color: var(--words-command-border-hover);
background: var(--words-command-bg-hover);
color: var(--words-command-color-hover);
}
[data-words-command-button]:focus-visible {
box-shadow: var(--words-focus-shadow);
}
[data-words-command-button][data-active],
@ -277,6 +293,20 @@
opacity: var(--words-disabled-opacity);
}
[data-words-floating] {
--_words-toolbar-px: var(--words-toolbar-px-md);
--_words-toolbar-py: var(--words-toolbar-py-md);
--_words-button-size: var(--button-height-xs);
--_words-font-size: var(--words-font-size-md);
--_words-accent-solid: var(--words-color-primary-solid);
--_words-accent-solid-hover: var(--words-color-primary-solid-hover);
--_words-accent-track: var(--words-color-primary-track);
--_words-accent-border: var(--words-color-primary-border);
--_words-accent-text: var(--words-color-primary-text);
--toolbar-focus-shadow: var(--words-focus-shadow);
--toolbar-disabled-opacity: var(--words-disabled-opacity);
}
[data-words-link-editor] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@ -295,14 +325,53 @@
}
[data-words-heading-picker] {
display: flex;
min-inline-size: var(--_words-button-size);
block-size: var(--_words-button-size);
}
[data-words-find-trigger] {
display: inline-flex;
align-items: center;
gap: var(--words-command-gap);
padding: var(--words-command-gap);
justify-content: center;
align-self: flex-start;
inline-size: var(--_words-button-size);
min-inline-size: var(--_words-button-size);
block-size: var(--_words-button-size);
padding: 0;
border: var(--words-border-width) solid transparent;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-command-color);
}
[data-words-heading-picker] > [data-words-command-button] {
flex: 0 0 auto;
[data-words-find-trigger]:hover:not(:disabled):not([data-disabled]) {
border-color: var(--words-command-border-hover);
background: var(--words-command-bg-hover);
color: var(--words-command-color-hover);
}
[data-words] > [data-words-find-trigger] {
margin-block-start: var(--_words-toolbar-py);
margin-inline-start: var(--_words-toolbar-px);
}
[data-words-find-trigger][data-state='open'],
[data-words-find-trigger][data-has-matches] {
border-color: var(--_words-accent-border);
background: var(--_words-accent-track);
color: var(--_words-accent-text);
}
[data-words-heading-picker-panel] {
display: grid;
grid-template-columns: repeat(4, var(--_words-button-size));
gap: var(--button-gap-xs);
}
[data-words-heading-picker-panel] [data-words-command-button] {
inline-size: var(--_words-button-size);
block-size: var(--_words-button-size);
justify-self: stretch;
}
[data-words-link-field] {
@ -467,6 +536,18 @@
line-height: var(--words-heading-line-height);
}
[data-words-content] :where(h1) {
font-size: var(--words-heading-h1-font-size);
}
[data-words-content] :where(h2) {
font-size: var(--words-heading-h2-font-size);
}
[data-words-content] :where(h3) {
font-size: var(--words-heading-h3-font-size);
}
[data-words-content] :where(strong) {
font-weight: var(--words-strong-font-weight);
}
@ -519,6 +600,42 @@
padding-inline-start: var(--words-list-padding-inline-start);
}
[data-words-content] [data-words-indent] {
margin-inline-start: calc(var(--words-list-padding-inline-start) * var(--_words-indent, 1));
}
[data-words-content] [data-words-indent='1'] {
--_words-indent: 1;
}
[data-words-content] [data-words-indent='2'] {
--_words-indent: 2;
}
[data-words-content] [data-words-indent='3'] {
--_words-indent: 3;
}
[data-words-content] [data-words-indent='4'] {
--_words-indent: 4;
}
[data-words-content] [data-words-indent='5'] {
--_words-indent: 5;
}
[data-words-content] [data-words-indent='6'] {
--_words-indent: 6;
}
[data-words-content] [data-words-indent='7'] {
--_words-indent: 7;
}
[data-words-content] [data-words-indent='8'] {
--_words-indent: 8;
}
[data-words-content] :where(a) {
color: var(--_words-accent-text);
text-decoration-color: var(--_words-accent-border);
@ -541,6 +658,150 @@
color: var(--words-status-error-color);
}
[data-words-find-replace] {
display: flex;
flex-direction: column;
gap: var(--words-command-gap);
inline-size: 100%;
padding: 0;
border: 0;
background: transparent;
}
[data-words-find-replace][data-disabled],
[data-words-find-replace][data-readonly] {
color: var(--words-status-color);
}
[data-words-find-replace][data-disabled] {
cursor: not-allowed;
opacity: var(--words-disabled-opacity);
}
[data-words-find-replace][data-has-matches] [data-words-find-counter] {
color: var(--_words-accent-text);
}
[data-words]:has([data-words-toolbar][data-orientation='vertical']) [data-words-find-replace] {
grid-column: 1 / -1;
grid-row: 2;
}
[data-words]:has([data-words-toolbar][data-orientation='vertical']):has([data-words-find-replace])
[data-words-status] {
grid-row: 3;
}
[data-words-find-row],
[data-words-replace-row] {
display: flex;
align-items: center;
gap: var(--words-command-gap);
}
[data-words-find-label],
[data-words-replace-label] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--words-status-color);
font-size: var(--words-status-font-size);
}
[data-words-find-input],
[data-words-replace-input] {
flex: 1 1 auto;
min-inline-size: 0;
block-size: var(--_words-button-size);
padding-inline: var(--words-command-px);
border: var(--words-border-width) solid var(--words-toolbar-border);
border-radius: var(--words-command-radius);
background: var(--words-bg);
color: var(--words-content-color);
font: inherit;
outline: none;
}
[data-words-find-input]:focus,
[data-words-replace-input]:focus {
border-color: var(--_words-accent-border);
box-shadow: var(--words-focus-shadow);
}
[data-words-find-counter] {
flex: 0 0 auto;
min-inline-size: 3.5ch;
color: var(--words-status-color);
font-size: var(--words-status-font-size);
font-variant-numeric: tabular-nums;
text-align: end;
}
[data-words-find-prev],
[data-words-find-next],
[data-words-replace-all] {
display: inline-flex;
flex: 0 0 var(--_words-button-size);
align-items: center;
justify-content: center;
inline-size: var(--_words-button-size);
block-size: var(--_words-button-size);
padding: 0;
border: var(--words-border-width) solid var(--words-command-border-hover);
border-radius: var(--words-command-radius);
background: var(--words-bg);
color: var(--words-command-color);
font: inherit;
cursor: pointer;
outline: none;
}
[data-words-find-prev]:hover:not(:disabled):not([data-disabled]),
[data-words-find-next]:hover:not(:disabled):not([data-disabled]),
[data-words-replace-all]:hover:not(:disabled):not([data-disabled]) {
border-color: var(--words-command-border-hover);
background: var(--words-command-bg-hover);
color: var(--words-command-color-hover);
}
[data-words-find-prev]:focus-visible,
[data-words-find-next]:focus-visible,
[data-words-replace-all]:focus-visible {
box-shadow: var(--words-focus-shadow);
}
[data-words-find-prev]:disabled,
[data-words-find-next]:disabled,
[data-words-replace-all]:disabled {
cursor: not-allowed;
opacity: var(--words-disabled-opacity);
}
[data-words-replace-all] {
border-color: var(--_words-accent-solid);
background: var(--_words-accent-solid);
color: var(--words-command-solid-color);
}
[data-words-replace-all]:hover:not(:disabled):not([data-disabled]) {
border-color: var(--_words-accent-solid-hover);
background: var(--_words-accent-solid-hover);
color: var(--words-command-solid-color);
}
[data-words-content] mark[data-words-find-match] {
border-radius: var(--words-code-radius);
background: color-mix(in srgb, var(--_words-accent-solid) 22%, transparent);
color: var(--_words-accent-text);
padding-block: var(--words-code-py);
padding-inline: var(--words-code-px);
}
[data-words-content] mark[data-words-find-match][data-words-find-active] {
background: color-mix(in srgb, var(--_words-accent-solid) 55%, transparent);
outline: 1px solid var(--_words-accent-border);
}
@media (prefers-reduced-motion: reduce) {
[data-words],
[data-words-command-button] {

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

@ -2160,6 +2160,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'block-gap': 'var(--space-2)',
'heading-color': 'var(--color-content-primary)',
'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-line-height': 'var(--leading-heading)',
'strong-font-weight': 'var(--font-weight-semibold)',

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

@ -35,6 +35,11 @@
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) {
const handler = (mergedProps as Record<string, unknown>).onpointerdown;
if (typeof handler === 'function') handler(event);
@ -49,7 +54,7 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...buttonProps} onpointerdown={callPointerDown} onclick={callClick}>
<button {...buttonProps} {title} onpointerdown={callPointerDown} onclick={callClick}>
{#if children}
{@render children()}
{: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 {
clearFormatting,
decreaseIndent,
deleteBackward,
deleteForward,
deleteLineBackward,
@ -8,6 +9,7 @@ import {
deleteRange,
deleteWordBackward,
deleteWordForward,
increaseIndent,
insertLink,
insertLineBreak,
insertParagraph,
@ -26,6 +28,7 @@ import { parseWordsHtml, serializeWordsHtml } from './serialize-html';
import { parseWordsJson, serializeWordsJson } from './serialize-json';
import { parseWordsMarkdown, serializeWordsMarkdown } from './serialize-markdown';
import { parseWordsPlainText } from './serialize-text';
import { replaceAllTextInDocument } from './find-replace';
export type WordsImportFormat = 'json' | 'text' | 'html' | 'markdown';
export type WordsExportFormat = 'json' | 'text' | 'html' | 'markdown';
@ -45,10 +48,13 @@ export type WordsCommand =
| { type: 'clearFormatting' }
| { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel }
| { type: 'setTextAlign'; align: WordsTextAlign }
| { type: 'increaseIndent' }
| { type: 'decreaseIndent' }
| { type: 'toggleList'; kind: WordsListKind }
| { type: 'insertLink'; href: string; title?: string }
| { type: 'unlink' }
| { type: 'replaceDocument'; document: unknown }
| { type: 'replaceAllText'; query: string; replacement: string }
| { type: 'importContent'; content: string; format: WordsImportFormat }
| { type: 'exportContent'; format: WordsExportFormat };
@ -85,6 +91,10 @@ export function applyWordsCommand(
return setBlock(state, command.block, { level: command.level });
case 'setTextAlign':
return setTextAlign(state, command.align);
case 'increaseIndent':
return increaseIndent(state);
case 'decreaseIndent':
return decreaseIndent(state);
case 'toggleList':
return toggleList(state, command.kind);
case 'insertLink':
@ -93,6 +103,8 @@ export function applyWordsCommand(
return unlink(state);
case 'replaceDocument':
return replaceDocument(state, command.document);
case 'replaceAllText':
return replaceAllText(state, command.query, command.replacement);
case 'importContent':
return importWordsContent(state, command.content, command.format);
case 'exportContent':
@ -135,3 +147,21 @@ function exportWordsContent(
: renderWordsPlainText(state.document);
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 {
readonly type: 'list-item';
readonly checked?: boolean;
readonly indent?: number;
readonly children: readonly WordsInline[];
}
@ -112,9 +113,15 @@ export function createList(
export function createListItem(
children: readonly WordsInline[] = [createText('')],
checked = false
checked = false,
indent?: number
): 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 {

@ -52,11 +52,22 @@ export function domPointToEncodedTarget(
if (isTextNode(node)) {
const textElement = closestWordsTextElement(root, node.parentElement);
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 {
path: textElement.getAttribute(WORDS_PATH_ATTR) ?? '',
offset: textElement.hasAttribute(WORDS_EMPTY_TEXT_ATTR)
? 0
: Math.max(0, Math.min(offset, node.data.length))
offset: Math.max(0, Math.min(totalOffset + offset, totalOffset + node.data.length))
};
}
@ -113,14 +124,26 @@ export function resolveDomPointFromTarget(
);
if (!element) return undefined;
const textNode = firstTextChild(element);
if (!textNode) return { node: element, offset: 0 };
if (element.hasAttribute(WORDS_EMPTY_TEXT_ATTR)) return { node: textNode, offset: 0 };
if (element.hasAttribute(WORDS_EMPTY_TEXT_ATTR)) {
const textNode = firstTextChild(element);
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 {
node: textNode,
offset: Math.max(0, Math.min(target.offset, textNode.data.length))
};
const lastText = lastTextChild(element);
if (lastText) return { node: lastText, offset: lastText.data.length };
return { node: element, offset: 0 };
}
function encodedTargetFromElementBoundary(
@ -205,6 +228,38 @@ function isElementInside(root: Element, element: Element): boolean {
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 {
return node.nodeType === 1;
}

@ -45,6 +45,9 @@ import {
selectionApiToWordsSelection,
updateWordsComposition,
undoWords,
findTextInDocument,
replaceTextAtMatch,
replaceAllTextInDocument,
type WordsDocument,
type WordsEditorState
} 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', () => {
it('normalizes invalid and empty documents into one paragraph', () => {
const result = normalizeDocument({
@ -1080,10 +1097,7 @@ describe('Words engine', () => {
});
expect(toggled.state.document.children[0]).toMatchObject({
type: 'list',
children: [
{ type: 'list-item' },
{ type: 'list-item', checked: true }
]
children: [{ type: 'list-item' }, { type: 'list-item', checked: true }]
});
});
@ -1213,4 +1227,231 @@ describe('Words engine', () => {
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'left' });
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 './document';
export * from './find-replace';
export * from './dom';
export * from './dom-selection';
export * from './history';

@ -130,9 +130,14 @@ function normalizeListItem(
}
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(
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 {
const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;

@ -13,6 +13,20 @@ import {
encodeWordsPath
} from './dom';
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 =
| 'blockquote'
@ -24,7 +38,8 @@ export type WordsRenderTag =
| 'p'
| 'span'
| 'a'
| 'ul';
| 'ul'
| 'mark';
export type WordsRenderAttrs = Readonly<Record<string, string | undefined>>;
@ -42,11 +57,20 @@ export interface WordsRenderText {
export type WordsRenderNode = WordsRenderElement | WordsRenderText;
export function renderWordsDocument(document: WordsDocument): readonly WordsRenderElement[] {
return document.children.map((block, blockIndex) => renderWordsBlock(block, [blockIndex]));
export function renderWordsDocument(
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') {
return {
kind: 'element',
@ -57,7 +81,7 @@ function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElemen
'data-words-list-kind': block.kind
},
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_PATH_ATTR]: encodeWordsPath(path),
'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(
item: WordsListItem,
path: WordsPath,
kind: 'ordered' | 'unordered' | 'check'
kind: 'ordered' | 'unordered' | 'check',
findOpts?: WordsRenderFindOptions
): WordsRenderElement {
return {
kind: 'element',
@ -94,22 +121,32 @@ function renderWordsListItem(
[WORDS_PATH_ATTR]: encodeWordsPath(path),
...(kind === 'check'
? { '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(
inlines: readonly WordsInline[],
parentPath: WordsPath
parentPath: WordsPath,
findOpts?: WordsRenderFindOptions
): readonly WordsRenderNode[] {
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') {
return {
kind: 'element',
@ -120,24 +157,73 @@ function renderWordsInline(inline: WordsInline, path: WordsPath): WordsRenderEle
href: inline.href,
...(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 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 {
kind: 'element',
tag: 'span',
attrs,
children: [
{
kind: 'text',
text: inline.text.length === 0 ? '\u200b' : inline.text
}
]
children
};
}
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 {
return document.children.map(renderBlockPlainText).join('\n');
}
@ -153,8 +239,11 @@ export function isRenderedEmptyText(node: WordsRenderElement): boolean {
return node.attrs[WORDS_EMPTY_TEXT_ATTR] === '';
}
export function renderWordsDomHtml(document: WordsDocument): string {
return renderWordsDocument(document).map(renderWordsNodeHtml).join('');
export function renderWordsDomHtml(
document: WordsDocument,
findOpts?: WordsRenderFindOptions
): string {
return renderWordsDocument(document, findOpts).map(renderWordsNodeHtml).join('');
}
export const renderWordsHtml = renderWordsDomHtml;
@ -163,7 +252,7 @@ function renderWordsNodeHtml(node: WordsRenderNode): string {
if (node.kind === 'text') return escapeHtml(node.text);
const attrs = Object.entries(node.attrs)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([name, value]) => ` ${name}="${escapeHtmlAttr(value)}"`)
.map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${escapeHtmlAttr(value)}"`))
.join('');
const children = node.children.map(renderWordsNodeHtml).join('');
return `<${node.tag}${attrs}>${children}</${node.tag}>`;

@ -97,7 +97,8 @@ function serializeBlockHtml(block: WordsBlock): string {
block.kind === 'check'
? `<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('');
return `<${tag}>${children}</${tag}>`;
@ -319,12 +320,24 @@ function htmlListToBlock(node: HtmlElementNode): WordsBlock {
function htmlListItemToWords(node: HtmlElementNode): WordsListItem {
const hasCheckbox = hasCheckboxInput(node);
const inlines = htmlNodesToInlines(node.children);
const indent = indentFromStyle(node);
return createListItem(
hasCheckbox ? trimLeadingInlineWhitespace(inlines) : inlines,
hasCheckedInput(node)
hasCheckedInput(node),
indent
);
}
function indentFromStyle(node: HtmlElementNode): number | undefined {
const style = typeof node.attrs.style === 'string' ? node.attrs.style : '';
const match = style.match(/margin-left\s*:\s*([\d.]+)em/);
if (!match) return undefined;
const em = Number.parseFloat(match[1] ?? '');
if (!Number.isFinite(em) || em <= 0) return undefined;
const level = Math.round(em / 1.5);
return level > 0 && level <= 8 ? level : undefined;
}
function htmlNodesToInlines(
nodes: readonly HtmlNode[],
marks: readonly WordsMark[] = [],

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

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

@ -14,6 +14,7 @@ import type {
WordsImportFormat,
WordsListKind,
WordsMark,
WordsTextMatch,
WordsSelectedLink,
WordsSelection
} from './engine';
@ -48,12 +49,16 @@ export type WordsCommandName =
| 'align-center'
| 'align-right'
| 'align-justify'
| 'increase-indent'
| 'decrease-indent'
| 'unordered-list'
| 'ordered-list'
| 'check-list'
| 'unlink'
| 'clear-formatting'
| 'clear-document'
| 'find-text'
| 'replace-all-text'
| 'commit';
export type WordsProviderSnippetProps = {
@ -73,8 +78,13 @@ export type WordsProviderSnippetProps = {
readonly canUndo: boolean;
readonly canRedo: boolean;
readonly canUnlink: boolean;
readonly findQuery: string;
readonly findMatches: readonly WordsTextMatch[];
readonly currentMatchIndex: number;
readonly hasFindMatches: boolean;
readonly focus: () => void;
readonly selectAll: () => void;
readonly selectBlock: () => void;
readonly runCommand: (command: WordsCommandName) => void;
readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean;
@ -83,6 +93,10 @@ export type WordsProviderSnippetProps = {
readonly commit: (reason?: WordsCommitReason) => void;
readonly undo: () => void;
readonly redo: () => void;
readonly setFindQuery: (query: string) => void;
readonly findNext: () => void;
readonly findPrev: () => void;
readonly replaceAllText: (replacement: string) => void;
};
export type WordsContentSnippetProps = {
@ -219,3 +233,26 @@ export type WordsHiddenInputProps = WithChild<{ id?: string }> &
export type WordsStatusProps = WithChild<{ id?: string }> &
Without<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 });
await settled();
const content = target.querySelector('[data-testid="words-content"]') as HTMLElement | null;
const content = target.querySelector('[data-words-content]') as HTMLElement | null;
expect(content).toBeTruthy();
if (!content) throw new Error('Words content did not mount');
expect(textLeaf(content).textContent).toBe('Hello');
@ -109,7 +109,7 @@ describe('Words.Content component', () => {
});
await settled();
const content = target.querySelector('[data-testid="words-content"]') as HTMLElement | null;
const content = target.querySelector('[data-words-content]') as HTMLElement | null;
expect(content).toBeTruthy();
if (!content) throw new Error('Words content did not mount');
expect(textLeaf(content).textContent).toBe('Hello');

@ -674,6 +674,123 @@ describe('WordsProvider', () => {
dom.dispose();
});
it('converts a paragraph to quote via toolbar command', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const quoteEl = document.createElement('button');
document.body.append(root, quoteEl);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createText('Hello')])]
});
opts.selection.current = {
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 0 }
};
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const quote = WordsCommandButtonProvider.create({
id: state('words-quote'),
ref: state<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', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');

@ -34,6 +34,7 @@ import {
createWordsState,
encodeWordsDomSelectionTarget,
endWordsComposition,
findTextInDocument,
getTextEntries,
getLinkAtSelection,
isCollapsedSelection,
@ -85,8 +86,7 @@ interface WordsToolbarOpts
extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {}
interface WordsToolbarGroupOpts
extends WithRefOpts, ActiveProps<{ orientation: WordsToolbarOrientation }> {}
interface WordsBubbleMenuOpts
extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {}
interface WordsBubbleMenuOpts extends WithRefOpts, ActiveProps<{ side: WordsBubbleMenuSide }> {}
interface WordsCommandButtonOpts
extends
WithRefOpts,
@ -102,6 +102,7 @@ interface WordsLinkEditorOpts
href: string;
title: string | undefined;
}> {}
interface WordsFindReplaceOpts extends WithRefOpts {}
interface WordsHiddenInputOpts extends WithRefOpts {}
interface WordsStatusOpts extends WithRefOpts {}
type WordsSelectionSyncSource =
@ -140,6 +141,8 @@ export class WordsProvider {
focused = $state(false);
dirty = $state(false);
isSelecting = $state(false);
findQuery = $state('');
currentMatchIndex = $state(-1);
private pendingCommitReason: WordsCommitReason = 'programmatic';
private typingBatchOpen = false;
@ -227,7 +230,12 @@ export class WordsProvider {
readonly selection = $derived.by(() => this.history.present.selection);
readonly activeMarks = $derived.by(() => this.history.present.activeMarks);
readonly plainText = $derived.by(() => renderWordsPlainText(this.document));
readonly html = $derived.by(() => renderWordsDomHtml(this.document));
readonly html = $derived.by(() =>
renderWordsDomHtml(this.document, {
matches: this.findMatches,
activeMatchIndex: this.currentMatchIndex
})
);
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
@ -273,9 +281,15 @@ export class WordsProvider {
readonly currentTextAlign = $derived.by(() => {
const index = this.selection?.anchor.path[0] ?? 0;
const block = this.document.children[index];
return block && block.type !== 'list' ? block.textAlign ?? 'left' : 'left';
return block && block.type !== 'list' ? (block.textAlign ?? 'left') : 'left';
});
readonly findMatches = $derived.by(() =>
this.findQuery ? findTextInDocument(this.document, this.findQuery) : []
);
readonly hasFindMatches = $derived.by(() => this.findMatches.length > 0);
readonly canUnlink = $derived.by(
() => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly
);
@ -370,7 +384,10 @@ export class WordsProvider {
});
}
runCommandName(name: WordsCommandName, opts: { target?: HTMLElement; level?: 1 | 2 | 3 } = {}) {
runCommandName(
name: WordsCommandName,
opts: { target?: HTMLElement; level?: 1 | 2 | 3; replacement?: string } = {}
) {
if (name === 'undo') {
this.undo(opts.target);
return;
@ -383,6 +400,24 @@ export class WordsProvider {
this.commit('button', opts.target);
return;
}
if (name === 'find-text') {
return;
}
if (name === 'replace-all-text') {
if (!this.findQuery || opts.replacement === undefined) return;
const command: WordsCommand = {
type: 'replaceAllText',
query: this.findQuery,
replacement: opts.replacement
};
const changed = this.applyCommand(command);
if (!changed) return;
this.currentMatchIndex = -1;
void this.runtime.trigger('commit-set-replace', {
fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined
});
return;
}
const command = this.commandFromName(name, opts.level);
if (!command) return;
@ -455,9 +490,12 @@ export class WordsProvider {
void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement });
};
readonly onblur = (_e: SomaFocusEvent<HTMLElement>) => {
readonly onblur = (e: SomaFocusEvent<HTMLElement>) => {
this.isSelecting = false;
this.syncSelectionFromDom({ source: 'blur' });
const related = e.relatedTarget as HTMLElement | null;
if (!related || !this.isInsideWordsTool(related)) {
this.syncSelectionFromDom({ source: 'blur' });
}
this.closeTypingBatch();
this.focused = false;
if (this.opts.commitOnBlur.current) this.commit('blur');
@ -479,6 +517,26 @@ export class WordsProvider {
this.applyCommand({ type: 'deleteForward' });
return;
}
if (key === 'enter' && e.shiftKey) {
e.preventDefault();
this.applyCommand({ type: 'insertLineBreak' });
return;
}
if (key === 'tab') {
if (this.currentBlock === 'list') {
e.preventDefault();
if (e.shiftKey) {
this.runCommandName('decrease-indent', {
target: e.currentTarget as HTMLElement
});
} else {
this.runCommandName('increase-indent', {
target: e.currentTarget as HTMLElement
});
}
}
return;
}
return;
}
@ -502,6 +560,26 @@ export class WordsProvider {
this.runCommandName('underline', { target: e.currentTarget as HTMLElement });
return;
}
if (key === 'l') {
e.preventDefault();
this.runCommandName('align-left', { target: e.currentTarget as HTMLElement });
return;
}
if (key === 'e') {
e.preventDefault();
this.runCommandName('align-center', { target: e.currentTarget as HTMLElement });
return;
}
if (key === 'r') {
e.preventDefault();
this.runCommandName('align-right', { target: e.currentTarget as HTMLElement });
return;
}
if (key === 'j') {
e.preventDefault();
this.runCommandName('align-justify', { target: e.currentTarget as HTMLElement });
return;
}
if (key === 'z') {
e.preventDefault();
if (e.shiftKey) this.redo(e.currentTarget as HTMLElement);
@ -530,14 +608,14 @@ export class WordsProvider {
readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => {
this.isSelecting = false;
if (e.detail === 3) {
this.selectBlock();
return;
}
if (!this.isEditable) return;
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!targetElement) return;
const link = targetElement.closest('a[href]');
if (!link || !e.currentTarget.contains(link)) return;
@ -633,8 +711,13 @@ export class WordsProvider {
canUndo: this.canUndo,
canRedo: this.canRedo,
canUnlink: this.canUnlink,
findQuery: this.findQuery,
findMatches: this.findMatches,
currentMatchIndex: this.currentMatchIndex,
hasFindMatches: this.hasFindMatches,
focus: () => this.focus(),
selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
insertLink: (href: string, title?: string) => this.insertLink(href, title),
unlink: () => this.unlink(),
@ -643,7 +726,11 @@ export class WordsProvider {
exportContent: (format: WordsExportFormat) => this.exportContent(format),
commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason),
undo: () => this.undo(),
redo: () => this.redo()
redo: () => this.redo(),
setFindQuery: (query: string) => this.setFindQuery(query),
findNext: () => this.findNext(),
findPrev: () => this.findPrev(),
replaceAllText: (replacement: string) => this.replaceAllText(replacement)
}));
readonly props = $derived.by(() =>
@ -661,6 +748,12 @@ export class WordsProvider {
this.publishPresent(result.state, { documentChanged: false });
}
private isInsideWordsTool(element: HTMLElement): boolean {
return !!element.closest(
'[data-words-bubble-menu], [data-words-toolbar], [data-words-find-replace], [data-words-heading-picker], [data-words-link-editor]'
);
}
private shouldIgnoreTransientSelectionCollapse(
next: WordsSelection,
root: HTMLElement,
@ -671,10 +764,7 @@ export class WordsProvider {
const active = root.ownerDocument.activeElement;
if (!(active instanceof HTMLElement)) return false;
if (active === root) return false;
const toolElement = active.closest(
'[data-words-bubble-menu], [data-words-toolbar], [data-words-tool-popover]'
);
if (toolElement) return true;
if (this.isInsideWordsTool(active)) return true;
if (root.contains(active)) return false;
return false;
}
@ -693,6 +783,48 @@ export class WordsProvider {
void tick().then(() => this.restoreDomSelection());
}
private selectBlock() {
if (this.isDisabled) return;
const selection = this.selection;
if (!selection) return;
const blockIndex = selection.anchor.path[0] ?? -1;
if (blockIndex < 0) return;
const entries = getTextEntries(this.document).filter((entry) => entry.path[0] === blockIndex);
if (!entries.length) return;
const first = entries[0];
const last = entries.at(-1);
if (!first || !last) return;
this.closeTypingBatch();
this.updateSelection({
anchor: { path: first.path, offset: 0 },
focus: { path: last.path, offset: last.text.text.length }
});
void tick().then(() => this.restoreDomSelection());
}
readonly setFindQuery = (query: string) => {
this.findQuery = query;
this.currentMatchIndex = -1;
};
readonly findNext = () => {
if (!this.hasFindMatches) return;
const nextIndex =
this.currentMatchIndex + 1 < this.findMatches.length ? this.currentMatchIndex + 1 : 0;
this.activateFindMatch(nextIndex);
};
readonly findPrev = () => {
if (!this.hasFindMatches) return;
const nextIndex =
this.currentMatchIndex > 0 ? this.currentMatchIndex - 1 : this.findMatches.length - 1;
this.activateFindMatch(nextIndex);
};
readonly replaceAllText = (replacement: string) => {
this.runCommandName('replace-all-text', { replacement });
};
private ensureSelection() {
if (this.selection) return;
const first = getTextEntries(this.document)[0];
@ -703,6 +835,17 @@ export class WordsProvider {
});
}
private activateFindMatch(index: number) {
const match = this.findMatches[index];
if (!match) return;
this.currentMatchIndex = index;
this.updateSelection({
anchor: { path: match.path, offset: match.start },
focus: { path: match.path, offset: match.end }
});
void tick().then(() => this.restoreDomSelection());
}
private historyCommand(command: WordsCommand): WordsHistory {
return applyHistoryCommand(this.history, command);
}
@ -748,7 +891,7 @@ export class WordsProvider {
) {
const current = this.history.present;
if (externalDocument !== current.document) {
if (!sameWordsDocument(externalDocument, current.document)) {
const nextState = createWordsState(externalDocument, externalSelection);
if (!sameWordsDocument(nextState.document, current.document)) {
const limit = this.history.limit;
@ -814,6 +957,7 @@ export class WordsProvider {
return { type: 'setBlock', block: 'heading', level: level ?? 1 };
case 'quote':
if (this.currentBlock === 'quote') return { type: 'setBlock', block: 'paragraph' };
return { type: 'setBlock', block: 'quote' };
case 'align-left':
return { type: 'setTextAlign', align: 'left' };
@ -823,6 +967,10 @@ export class WordsProvider {
return { type: 'setTextAlign', align: 'right' };
case 'align-justify':
return { type: 'setTextAlign', align: 'justify' };
case 'increase-indent':
return { type: 'increaseIndent' };
case 'decrease-indent':
return { type: 'decreaseIndent' };
case 'unordered-list':
return { type: 'toggleList', kind: 'unordered' };
case 'ordered-list':
@ -1074,9 +1222,7 @@ export class WordsBubbleMenuProvider {
...this.runtimePart.props,
role: 'toolbar' as const,
'aria-orientation': 'horizontal' as const,
'aria-label': this.provider.soma.langs.ts(
'#?components.words.bubble-menu|Selection toolbar'
),
'aria-label': this.provider.soma.langs.ts('#?components.words.bubbleMenu|Selection toolbar'),
'data-open': boolToEmptyStrOrUndef(this.open),
'data-positioned': boolToEmptyStrOrUndef(this.positioned),
'data-side': this.opts.side.current,
@ -1208,6 +1354,9 @@ export class WordsCommandButtonProvider {
if (command === 'redo') return this.provider.isReadonly || !this.provider.canRedo;
if (command === 'unlink') return this.provider.isReadonly || !this.provider.canUnlink;
if (command === 'commit') return false;
if (command === 'increase-indent' || command === 'decrease-indent') {
return this.provider.isReadonly || this.provider.currentBlock !== 'list';
}
return this.provider.isReadonly;
}
}
@ -1253,8 +1402,7 @@ export class WordsLinkEditorProvider {
readonly: this.provider.isReadonly,
captureSelection: () => this.captureSelection(),
validate: (href?: string) => this.validate(href),
apply: (href?: string, title?: string, target?: HTMLElement) =>
this.apply(href, title, target),
apply: (href?: string, title?: string, target?: HTMLElement) => this.apply(href, title, target),
unlink: (target?: HTMLElement) => this.unlink(target),
clearError: () => this.clearError()
}));
@ -1274,7 +1422,7 @@ export class WordsLinkEditorProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-label': this.provider.soma.langs.ts('#?components.words.link-editor|Edit link'),
'aria-label': this.provider.soma.langs.ts('#?components.words.linkEditor|Edit link'),
'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)),
@ -1320,6 +1468,56 @@ export class WordsLinkEditorProvider {
}
}
export class WordsFindReplaceProvider {
readonly opts: WordsFindReplaceOpts;
readonly provider: WordsProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: WordsFindReplaceOpts) {
return new WordsFindReplaceProvider(opts);
}
private constructor(opts: WordsFindReplaceOpts) {
this.opts = opts;
this.provider = WordsProvider.require();
this.runtimePart = this.provider.runtime.part('find-replace', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
disabled: () => this.provider.isDisabled,
readonly: () => this.provider.isReadonly,
hasMatches: () => this.provider.hasFindMatches
},
syncAttrs: true
});
}
readonly snippetProps = $derived.by(() => ({
findQuery: this.provider.findQuery,
findMatches: this.provider.findMatches,
currentMatchIndex: this.provider.currentMatchIndex,
hasFindMatches: this.provider.hasFindMatches,
disabled: this.provider.isDisabled,
readonly: this.provider.isReadonly,
setFindQuery: (query: string) => this.provider.setFindQuery(query),
findNext: () => this.provider.findNext(),
findPrev: () => this.provider.findPrev(),
replaceAllText: (replacement: string) => this.provider.replaceAllText(replacement)
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'search' as const,
'aria-label': this.provider.soma.langs.ts('#?components.words.findReplace|Find and replace'),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'data-has-matches': boolToEmptyStrOrUndef(this.provider.hasFindMatches)
} as const)
);
}
export class WordsHiddenInputProvider {
readonly opts: WordsHiddenInputOpts;
readonly provider: WordsProvider;
@ -1405,7 +1603,8 @@ function sameWordsSelection(a: WordsSelection | null, b: WordsSelection | null):
}
function sameWordsDocument(a: WordsDocument, b: WordsDocument): boolean {
if (a === b) return true;
if (a.version !== b.version) return false;
if (a.children.length !== b.children.length) return false;
return serializeWordsJson(a) === serializeWordsJson(b);
}

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

Loading…
Cancel
Save

Powered by TurnKey Linux.