Words: text alignment, heading picker, ToolPopover cleanup, word boundary fixes

- Add textAlign to document model (paragraph, heading, quote)
- Add setTextAlign engine operation with left/center/right/justify
- Add alignment toolbar buttons with icons (TextAlignStart, Center, End, Justify)
- Update formatting and full presets to include alignment
- Fix sameBlockShape to compare textAlign correctly
- Add heading levels h2/h3 with Words.HeadingPicker dropdown (Popover)
- Remove Words.ToolPopover component; migrate LinkEditor to native Popover
- Fix word boundary algorithm: Intl.Segmenter + CJK/punctuation fallback
- Fix bubble menu isSelecting logic and pointer event forwarding
- Add 5 engine tests for setTextAlign
- Update demo presets for alignment
- Words engine tests: 42/42 passing
active-uix
dev 5 months ago
parent cc0d38ec52
commit 0804749147

@ -23,7 +23,7 @@ rich-text ni wrapper visual reutilizable.
| Placeholder | Input hint pattern | Plugin/placeholder | Placeholder extension | `Words.Placeholder` | implement | | Placeholder | Input hint pattern | Plugin/placeholder | Placeholder extension | `Words.Placeholder` | implement |
| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | | Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement |
| Import/export HTML | not owned | available | available | clean export; defer importer | partial | | Import/export HTML | not owned | available | available | clean export; defer importer | partial |
| Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` + `Words.ToolPopover` | implement | | Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` | implement |
| Bubble menu | contextual toolbar pattern | plugins | extension UI | `Words.BubbleMenu` anchored to text selection | implement | | Bubble menu | contextual toolbar pattern | plugins | extension UI | `Words.BubbleMenu` anchored to text selection | implement |
| Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer | | Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer |
@ -55,8 +55,8 @@ rich-text ni wrapper visual reutilizable.
- Air parity: no Air component existed; implement from UIX architecture. - Air parity: no Air component existed; implement from UIX architecture.
- Link editing: implemented as a stable form part over Soma selection state. - Link editing: implemented as a stable form part over Soma selection state.
Toolbar tool popovers use a reusable `Words.ToolPopover` shell; contextual Contextual formatting uses `Words.BubbleMenu` anchored to the real DOM
formatting uses `Words.BubbleMenu` anchored to the real DOM selection rect. selection rect.
- HTML export and conservative safe HTML import are implemented in Soma. - HTML export and conservative safe HTML import are implemented in Soma.
Markdown import/export is implemented in Soma V1 with a conservative Markdown import/export is implemented in Soma V1 with a conservative
zero-dependency parser. zero-dependency parser.
@ -74,10 +74,11 @@ rich-text ni wrapper visual reutilizable.
| Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment | | Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment |
| Commands | `Words.CommandButton` | active/disabled/commit styling | | Commands | `Words.CommandButton` | active/disabled/commit styling |
| Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset | | Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset |
| Tool popover | Popover composition | reusable trigger/content/close shell for toolbar tools | | Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback | | Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Form | `Words.HiddenInput` | passthrough hidden JSON input | | Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Status | `Words.Status` | idle/error status recipe | | Status | `Words.Status` | idle/error status recipe |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command |
## Decisiones ## Decisiones
@ -96,17 +97,22 @@ rich-text ni wrapper visual reutilizable.
- `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica - `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica
`insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos `insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos
ofrece una UI por defecto que puede reemplazarse con snippet. ofrece una UI por defecto que puede reemplazarse con snippet.
- `Words.ToolPopover` centraliza el patron de herramientas flotantes de toolbar: - `Words.BubbleMenu` es una parte publica compuesta y **opcional**: no se
trigger iconico, popover intrinseco, boton de cierre/cancelacion y guardia de incluye por defecto en `<Words>`; el consumidor debe agregarla explicitamente
cierre. `Words.LinkEditor` lo usa para validar URL al intentar cerrar por como `<Words.BubbleMenu>` dentro del snippet `children`. Solo aparece para
outside/Escape sin confirmar.
- `Words.BubbleMenu` es una parte publica compuesta: solo aparece para
seleccion de rango, toma su ancla del rectangulo real de `Selection API` y seleccion de rango, toma su ancla del rectangulo real de `Selection API` y
preserva la seleccion en `pointerdown` para que los command buttons operen preserva la seleccion en `pointerdown` para que los command buttons operen
sobre el texto seleccionado. sobre el texto seleccionado.
- El trigger del popover captura la seleccion DOM de `Words.Content` antes de - El trigger del popover captura la seleccion DOM de `Words.Content` antes de
mover foco al formulario. Esto evita que aplicar un enlace dependa del timing mover foco al formulario. Esto evita que aplicar un enlace dependa del timing
de `selectionchange` del navegador. de `selectionchange` del navegador.
- En modo editable, los enlaces renderizados son contenido del editor: el click
no navega fuera de la demo y permite que `LinkEditor`/`unlink` operen sobre
la seleccion actual. En `readonly`, el enlace conserva comportamiento
navegable.
- El trigger de `Words.LinkEditor` se deshabilita junto con la parte cuando el
editor esta `disabled` o `readonly`; no abre una herramienta que luego no
puede aplicar cambios.
- `words.css` usa solo tokens publicos `--words-*` declarados en - `words.css` usa solo tokens publicos `--words-*` declarados en
`THEME_BASE_RECIPE_TOKENS.words`. `THEME_BASE_RECIPE_TOKENS.words`.
- Las marcas inline se pintan desde `data-words-marks`, emitido por el motor - Las marcas inline se pintan desde `data-words-marks`, emitido por el motor
@ -126,3 +132,30 @@ rich-text ni wrapper visual reutilizable.
| Images/files | diferir a ActiveApp storage/upload | | Images/files | diferir a ActiveApp storage/upload |
| Tables | diferir V2 | | Tables | diferir V2 |
| Collaboration | diferir; no core | | Collaboration | diferir; no core |
## Estado actual (2026-05-25)
Features implementadas en esta sesion:
- **Text alignment**: `setTextAlign` operacion de motor aplicable a `paragraph`, `heading` y `quote`. Renderiza `style="text-align: ..."` en DOM y HTML export. Se omite cuando es `left` (default). Comandos UI: `align-left`, `align-center`, `align-right`, `align-justify` con iconos `TextAlignStart`, `TextAlignCenter`, `TextAlignEnd`, `TextAlignJustify`. Presets `formatting` y `full` incluyen los 4 botones.
- **Heading levels h2/h3**: motor ya soportaba `level: 1|2|3`; se expusieron comandos y se creo `Words.HeadingPicker` (dropdown con `Popover` de Eidos) para seleccionar H1/H2/H3/Paragraph desde un solo boton en toolbar.
- **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).
## Next steps (para continuar manana)
| 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 |

@ -5,7 +5,7 @@ import Toolbar from './words-toolbar.svelte';
import ToolbarGroup from './words-toolbar-group.svelte'; import ToolbarGroup from './words-toolbar-group.svelte';
import BubbleMenu from './words-bubble-menu.svelte'; import BubbleMenu from './words-bubble-menu.svelte';
import CommandButton from './words-command-button.svelte'; import CommandButton from './words-command-button.svelte';
import ToolPopover from './words-tool-popover.svelte'; import HeadingPicker from './words-heading-picker.svelte';
import LinkEditor from './words-link-editor.svelte'; import LinkEditor from './words-link-editor.svelte';
import HiddenInput from './words-hidden-input.svelte'; import HiddenInput from './words-hidden-input.svelte';
import Status from './words-status.svelte'; import Status from './words-status.svelte';
@ -17,7 +17,7 @@ type WordsNamespace = typeof WordsComponent & {
ToolbarGroup: typeof ToolbarGroup; ToolbarGroup: typeof ToolbarGroup;
BubbleMenu: typeof BubbleMenu; BubbleMenu: typeof BubbleMenu;
CommandButton: typeof CommandButton; CommandButton: typeof CommandButton;
ToolPopover: typeof ToolPopover; HeadingPicker: typeof HeadingPicker;
LinkEditor: typeof LinkEditor; LinkEditor: typeof LinkEditor;
HiddenInput: typeof HiddenInput; HiddenInput: typeof HiddenInput;
Status: typeof Status; Status: typeof Status;
@ -30,7 +30,7 @@ Words.Toolbar = Toolbar;
Words.ToolbarGroup = ToolbarGroup; Words.ToolbarGroup = ToolbarGroup;
Words.BubbleMenu = BubbleMenu; Words.BubbleMenu = BubbleMenu;
Words.CommandButton = CommandButton; Words.CommandButton = CommandButton;
Words.ToolPopover = ToolPopover; Words.HeadingPicker = HeadingPicker;
Words.LinkEditor = LinkEditor; Words.LinkEditor = LinkEditor;
Words.HiddenInput = HiddenInput; Words.HiddenInput = HiddenInput;
Words.Status = Status; Words.Status = Status;
@ -58,7 +58,5 @@ export type {
WordsToolbarGroupProps as ToolbarGroupProps, WordsToolbarGroupProps as ToolbarGroupProps,
WordsToolbarProps as ToolbarProps, WordsToolbarProps as ToolbarProps,
WordsToolbarPreset, WordsToolbarPreset,
WordsToolPopoverProps as ToolPopoverProps,
WordsToolPopoverSnippetProps,
WordsVariant WordsVariant
} from './types'; } from './types';

@ -11,7 +11,6 @@ import type {
ToolbarGroupProps, ToolbarGroupProps,
ToolbarProps ToolbarProps
} from '$soma/components/words'; } from '$soma/components/words';
import type { ContentProps as PopoverContentProps } from '$uix/eidos/components/popover';
import type { import type {
BubbleMenuSnippetProps as SomaWordsBubbleMenuSnippetProps, BubbleMenuSnippetProps as SomaWordsBubbleMenuSnippetProps,
WordsCommandName, WordsCommandName,
@ -39,38 +38,18 @@ export type WordsToolbarLinkEditorConfig =
part: 'link-editor'; part: 'link-editor';
label?: string; label?: string;
}; };
export type WordsToolbarItemConfig = WordsToolbarCommandConfig | WordsToolbarLinkEditorConfig; export type WordsToolbarHeadingPickerConfig =
| 'heading-picker'
| {
part: 'heading-picker';
label?: string;
};
export type WordsToolbarItemConfig =
| WordsToolbarCommandConfig
| WordsToolbarLinkEditorConfig
| WordsToolbarHeadingPickerConfig;
export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[]; export type WordsToolbarGroupConfig = readonly WordsToolbarItemConfig[];
export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[]; export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[];
export type WordsToolPopoverSnippetProps = {
readonly open: boolean;
readonly close: () => void;
};
export type WordsToolPopoverProps = Partial<
Pick<
PopoverContentProps,
| 'side'
| 'sideOffset'
| 'align'
| 'alignOffset'
| 'width'
| 'minWidth'
| 'maxWidth'
| 'collisionPadding'
| 'avoidCollisions'
>
> & {
open?: boolean;
tool: string;
triggerLabel: string;
triggerTitle?: string;
closeLabel?: string;
trigger?: Snippet;
children?: Snippet<[WordsToolPopoverSnippetProps]>;
onTriggerPointerDown?: (event: PointerEvent) => void;
onBeforeDismiss?: () => boolean;
onCancel?: () => void;
};
export type WordsProps = ProviderProps & { export type WordsProps = ProviderProps & {
/** Composite editor density. @default 'md' */ /** Composite editor density. @default 'md' */

@ -0,0 +1,74 @@
<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 type { WordsHeadingLevel } from '$soma/components/words';
let {
currentBlock = 'paragraph',
currentHeadingLevel = undefined
}: {
currentBlock?: string;
currentHeadingLevel?: WordsHeadingLevel;
} = $props();
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';
});
</script>
<Popover bind:open>
<Popover.Trigger aria-label={headingLabel}>
{#if currentBlock === 'heading' && currentHeadingLevel === 1}
<Heading1 size="sm" decorative />
{:else if currentBlock === 'heading' && currentHeadingLevel === 2}
<Heading2 size="sm" decorative />
{:else if currentBlock === 'heading' && currentHeadingLevel === 3}
<Heading3 size="sm" decorative />
{:else}
<Pilcrow size="sm" decorative />
{/if}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="center" sideOffset={4}>
<div data-words-heading-picker>
<Words.CommandButton
command="heading"
level={1}
aria-label="Heading 1"
onclick={() => (open = false)}
>
<Heading1 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
command="heading"
level={2}
aria-label="Heading 2"
onclick={() => (open = false)}
>
<Heading2 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
command="heading"
level={3}
aria-label="Heading 3"
onclick={() => (open = false)}
>
<Heading3 size="sm" decorative />
</Words.CommandButton>
<Words.CommandButton
command="paragraph"
aria-label="Paragraph"
onclick={() => (open = false)}
>
<Pilcrow size="sm" decorative />
</Words.CommandButton>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Check, Link2, Unlink } from '$uix/eidos/components/icon'; import { Check, Link2, Unlink, X } from '$uix/eidos/components/icon';
import { Popover } from '$uix/eidos/components/popover';
import * as Words from '$soma/components/words'; import * as Words from '$soma/components/words';
import type { LinkEditorSnippetProps } from '$soma/components/words'; import type { LinkEditorSnippetProps } from '$soma/components/words';
import ToolPopover from './words-tool-popover.svelte';
import type { WordsLinkEditorProps } from './types'; import type { WordsLinkEditorProps } from './types';
let { let {
@ -13,8 +13,7 @@
}: WordsLinkEditorProps = $props(); }: WordsLinkEditorProps = $props();
let open = $state(false); let open = $state(false);
let hrefInput = $state<HTMLInputElement | null>(null); let formRef = $state<HTMLFormElement | null>(null);
let shouldFocusHref = $state(false);
type LinkEditorChildProps = LinkEditorSnippetProps & { type LinkEditorChildProps = LinkEditorSnippetProps & {
props: Record<string, unknown>; props: Record<string, unknown>;
@ -24,111 +23,108 @@
const { onsubmit, ...restProps } = props; const { onsubmit, ...restProps } = props;
return restProps; return restProps;
} }
function callSubmit(props: Record<string, unknown>, event: SubmitEvent) {
const handler = props.onsubmit;
if (typeof handler === 'function') handler(event);
}
function focusHrefInput() {
const focus = () => hrefInput?.focus({ preventScroll: true });
queueMicrotask(focus);
setTimeout(focus, 0);
setTimeout(focus, 60);
setTimeout(focus, 180);
}
function validateBeforeDismiss(snippetProps: LinkEditorChildProps): boolean {
const valid = snippetProps.validate(href);
if (!valid) {
shouldFocusHref = true;
focusHrefInput();
}
return valid;
}
function resetDraft(snippetProps: LinkEditorChildProps) {
href = snippetProps.selectedLink?.href ?? '';
title = snippetProps.selectedLink?.title ?? '';
snippetProps.clearError();
shouldFocusHref = false;
}
$effect(() => {
if (!open || !hrefInput || !shouldFocusHref) return;
focusHrefInput();
shouldFocusHref = false;
});
</script> </script>
<Words.LinkEditor {...rest} bind:href bind:title> <Words.LinkEditor {...rest} bind:href bind:title>
{#snippet child(snippetProps)} {#snippet child(props)}
<ToolPopover {@const sp = props as LinkEditorChildProps}
bind:open <Popover bind:open>
tool="link" <span role="button" tabindex="-1" onpointerdown={() => sp.captureSelection()}>
triggerLabel="Edit link" <Popover.Trigger
closeLabel="Close link editor" aria-label="Edit link"
onTriggerPointerDown={() => snippetProps.captureSelection()} disabled={sp.disabled || sp.readonly || undefined}
onBeforeDismiss={() => validateBeforeDismiss(snippetProps)}
onCancel={() => resetDraft(snippetProps)}
>
{#snippet trigger()}
<Link2 size="sm" decorative />
{/snippet}
{#snippet children({ close })}
<form
{...formProps(snippetProps.props)}
onsubmit={(event) => callSubmit(snippetProps.props, event)}
> >
{#if customChildren} <Link2 size="sm" decorative />
{@render customChildren(snippetProps)} </Popover.Trigger>
{:else} </span>
<label data-words-link-field aria-label="Link URL"> <Popover.Portal>
<span data-words-link-label> <Popover.Content side="bottom" align="center" sideOffset={4}>
<Link2 size="sm" decorative /> <form
</span> bind:this={formRef}
<input {...formProps(sp.props)}
bind:this={hrefInput} data-words-link-editor
type="text" data-active-link={sp.selectedLink ? '' : undefined}
name="href" data-invalid={sp.error ? '' : undefined}
inputmode="url" data-disabled={sp.disabled || sp.readonly ? '' : undefined}
bind:value={href} data-readonly={sp.readonly ? '' : undefined}
placeholder="Paste URL or /route" aria-label="Edit link"
aria-invalid={snippetProps.error ? 'true' : undefined} onsubmit={(event) => {
oninput={() => snippetProps.clearError()} event.preventDefault();
/> const data = new FormData(event.currentTarget);
</label> const nextHref = data.get('href');
<input type="hidden" name="title" value={title} /> const nextTitle = data.get('title');
<div data-words-link-actions> const submittedHref = typeof nextHref === 'string' ? nextHref : href;
<button const submittedTitle = typeof nextTitle === 'string' ? nextTitle : title;
data-words-link-apply if (sp.apply(submittedHref, submittedTitle, event.currentTarget)) {
type="button" open = false;
aria-label="Apply link" }
disabled={snippetProps.disabled || snippetProps.readonly} }}
onclick={() => { >
if (snippetProps.apply(href, title)) close(); {#if customChildren}
}} {@render customChildren(sp)}
> {:else}
<Check size="sm" decorative /> <label data-words-link-field aria-label="Link URL">
</button> <span data-words-link-label>
<button <Link2 size="sm" decorative />
data-words-link-remove </span>
type="button" <input
aria-label="Remove link" type="text"
disabled={!snippetProps.canUnlink} name="href"
onclick={() => { inputmode="url"
if (snippetProps.unlink()) close(); bind:value={href}
}} placeholder="Paste URL or /route"
> aria-label="Link URL"
<Unlink size="sm" decorative /> aria-invalid={sp.error ? 'true' : undefined}
</button> oninput={() => sp.clearError()}
</div> onkeydown={(event) => {
{#if snippetProps.error} if (event.key !== 'Enter') return;
<span data-words-link-error>{snippetProps.error}</span> event.preventDefault();
const target = event.currentTarget.form ?? undefined;
if (sp.apply(event.currentTarget.value, title, target)) {
open = false;
}
}}
/>
</label>
<input type="hidden" name="title" value={title} />
<div data-words-link-actions>
<button
data-words-link-apply
type="button"
aria-label="Apply link"
disabled={sp.disabled || sp.readonly || undefined}
onclick={() => {
if (sp.apply(href, title, formRef ?? undefined)) {
open = false;
}
}}
>
<Check size="sm" decorative />
</button>
<button
data-words-link-remove
type="button"
aria-label="Remove link"
disabled={!sp.canUnlink}
onclick={() => {
if (sp.unlink(formRef ?? undefined)) {
open = false;
}
}}
>
<Unlink size="sm" decorative />
</button>
</div>
{#if sp.error}
<span data-words-link-error>{sp.error}</span>
{/if}
{/if} {/if}
{/if} <Popover.Close aria-label="Close link editor">
</form> <X size="sm" decorative />
{/snippet} </Popover.Close>
</ToolPopover> </form>
</Popover.Content>
</Popover.Portal>
</Popover>
{/snippet} {/snippet}
</Words.LinkEditor> </Words.LinkEditor>

@ -277,95 +277,6 @@
opacity: var(--words-disabled-opacity); opacity: var(--words-disabled-opacity);
} }
[data-words-tool-popover-trigger],
[data-words-link-trigger] {
display: inline-flex;
align-items: center;
justify-content: center;
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-tool-popover-trigger]:hover:not(:disabled),
[data-words-link-trigger]:hover:not(:disabled) {
border-color: var(--words-command-border-hover);
background: var(--words-command-bg-hover);
color: var(--words-command-color-hover);
}
[data-words-tool-popover-trigger]:focus-visible,
[data-words-link-trigger]:focus-visible {
box-shadow: var(--words-focus-shadow);
}
[data-words-tool-popover-trigger][data-state='open'],
[data-words-link-trigger][data-state='open'] {
border-color: var(--_words-accent-border);
background: var(--_words-accent-track);
color: var(--_words-accent-text);
}
[data-words-tool-popover],
[data-words-link-popover] {
--_words-button-size: var(--words-button-size-sm);
--_words-accent-solid: var(--words-color-primary-solid);
--_words-accent-border: var(--words-color-primary-border);
--_words-accent-text: var(--words-color-primary-text);
--popover-content-radius: var(--words-radius);
}
[data-words-tool-popover-row] {
display: flex;
align-items: start;
gap: var(--words-command-gap);
min-inline-size: 0;
}
[data-words-tool-popover-body] {
flex: 1 1 auto;
min-inline-size: 0;
}
[data-words-tool-popover-close] {
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-tool-popover-close]:hover:not(:disabled) {
border-color: var(--_words-accent-border);
background: var(--words-command-bg-hover);
color: var(--words-command-color-hover);
}
[data-words-tool-popover-close]:focus-visible {
box-shadow: var(--words-focus-shadow);
}
[data-words-link-editor] { [data-words-link-editor] {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@ -383,6 +294,17 @@
opacity: var(--words-disabled-opacity); opacity: var(--words-disabled-opacity);
} }
[data-words-heading-picker] {
display: flex;
align-items: center;
gap: var(--words-command-gap);
padding: var(--words-command-gap);
}
[data-words-heading-picker] > [data-words-command-button] {
flex: 0 0 auto;
}
[data-words-link-field] { [data-words-link-field] {
display: flex; display: flex;
box-sizing: border-box; box-sizing: border-box;

@ -6,6 +6,8 @@
Code, Code,
Eraser, Eraser,
Heading1, Heading1,
Heading2,
Heading3,
Italic, Italic,
List, List,
ListChecks, ListChecks,
@ -15,16 +17,22 @@
Redo2, Redo2,
Save, Save,
Strikethrough, Strikethrough,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify,
TextAlignStart,
Underline, Underline,
Unlink, Unlink,
Undo2 Undo2
} from '$uix/eidos/components/icon'; } from '$uix/eidos/components/icon';
import HeadingPicker from './words-heading-picker.svelte';
import LinkEditor from './words-link-editor.svelte'; import LinkEditor from './words-link-editor.svelte';
import type { import type {
WordsProps, WordsProps,
WordsToolbarCommandConfig, WordsToolbarCommandConfig,
WordsToolbarConfig, WordsToolbarConfig,
WordsToolbarGroupConfig, WordsToolbarGroupConfig,
WordsToolbarHeadingPickerConfig,
WordsToolbarLinkEditorConfig, WordsToolbarLinkEditorConfig,
WordsToolbarPreset WordsToolbarPreset
} from './types'; } from './types';
@ -57,15 +65,18 @@
minimal: [['bold', 'italic', 'underline']], minimal: [['bold', 'italic', 'underline']],
formatting: [ formatting: [
['bold', 'italic', 'underline', 'strike', 'code'], ['bold', 'italic', 'underline', 'strike', 'code'],
['paragraph', { command: 'heading', level: 1 }, 'quote'] ['heading-picker', 'quote', 'align-left', 'align-center', 'align-right', 'align-justify']
], ],
full: [ full: [
['undo', 'redo'], ['undo', 'redo'],
['bold', 'italic', 'underline', 'strike', 'code'], ['bold', 'italic', 'underline', 'strike', 'code'],
[ [
'paragraph', 'heading-picker',
{ command: 'heading', level: 1 },
'quote', 'quote',
'align-left',
'align-center',
'align-right',
'align-justify',
'unordered-list', 'unordered-list',
'ordered-list', 'ordered-list',
'check-list' 'check-list'
@ -85,6 +96,10 @@
paragraph: 'Paragraph', paragraph: 'Paragraph',
heading: 'Heading', heading: 'Heading',
quote: 'Quote', quote: 'Quote',
'align-left': 'Align left',
'align-center': 'Align center',
'align-right': 'Align right',
'align-justify': 'Justify',
'unordered-list': 'Unordered list', 'unordered-list': 'Unordered list',
'ordered-list': 'Ordered list', 'ordered-list': 'Ordered list',
'check-list': 'Check list', 'check-list': 'Check list',
@ -116,7 +131,13 @@
function isLinkEditorConfig( function isLinkEditorConfig(
item: WordsToolbarGroupConfig[number] item: WordsToolbarGroupConfig[number]
): item is WordsToolbarLinkEditorConfig { ): item is WordsToolbarLinkEditorConfig {
return item === 'link-editor' || (typeof item === 'object' && 'part' in item); 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');
} }
</script> </script>
@ -134,10 +155,15 @@
{#each toolbarGroups as group} {#each toolbarGroups as group}
<Words.ToolbarGroup orientation={toolbarOrientation}> <Words.ToolbarGroup orientation={toolbarOrientation}>
{#each group as item} {#each group as item}
{#if isLinkEditorConfig(item)} {#if isLinkEditorConfig(item)}
<LinkEditor /> <LinkEditor />
{:else} {:else if isHeadingPickerConfig(item)}
{@const command = normalizeCommand(item)} <HeadingPicker
currentBlock={snippetProps.currentBlock}
currentHeadingLevel={snippetProps.currentHeadingLevel}
/>
{:else}
{@const command = normalizeCommand(item)}
<Words.CommandButton <Words.CommandButton
command={command.command} command={command.command}
level={command.level} level={command.level}
@ -160,23 +186,31 @@
<Code size="sm" decorative /> <Code size="sm" decorative />
{:else if command.command === 'paragraph'} {:else if command.command === 'paragraph'}
<Pilcrow size="sm" decorative /> <Pilcrow size="sm" decorative />
{:else if command.command === 'heading'} {:else if command.command === 'heading'}
<Heading1 size="sm" decorative /> <Heading1 size="sm" decorative />
{:else if command.command === 'quote'} {:else if command.command === 'quote'}
<Quote size="sm" decorative /> <Quote size="sm" decorative />
{:else if command.command === 'unordered-list'} {:else if command.command === 'unordered-list'}
<List size="sm" decorative /> <List size="sm" decorative />
{:else if command.command === 'ordered-list'} {:else if command.command === 'ordered-list'}
<ListOrdered size="sm" decorative /> <ListOrdered size="sm" decorative />
{:else if command.command === 'check-list'} {:else if command.command === 'check-list'}
<ListChecks size="sm" decorative /> <ListChecks size="sm" decorative />
{:else if command.command === 'unlink'} {:else if command.command === 'align-left'}
<Unlink size="sm" decorative /> <TextAlignStart size="sm" decorative />
{:else if command.command === 'commit'} {:else if command.command === 'align-center'}
<Save size="sm" decorative /> <TextAlignCenter size="sm" decorative />
{:else} {:else if command.command === 'align-right'}
<Eraser size="sm" decorative /> <TextAlignEnd size="sm" decorative />
{/if} {: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> </Words.CommandButton>
{/if} {/if}
{/each} {/each}

@ -11,8 +11,8 @@ export const wordsMorfo = {
content: '#?components.words.content|Editor content', content: '#?components.words.content|Editor content',
placeholder: '#?components.words.placeholder|Start writing...', placeholder: '#?components.words.placeholder|Start writing...',
toolbar: '#?components.words.toolbar|Editor toolbar', toolbar: '#?components.words.toolbar|Editor toolbar',
bubbleMenu: '#?components.words.bubbleMenu|Selection toolbar', 'bubble-menu': '#?components.words.bubble-menu|Selection toolbar',
linkEditor: '#?components.words.linkEditor|Edit link', 'link-editor': '#?components.words.link-editor|Edit link',
status: '#?components.words.status|Editor status' status: '#?components.words.status|Editor status'
}, },
events: [ events: [
@ -167,7 +167,23 @@ export const wordsMorfo = {
values: ['paragraph', 'heading', 'quote', 'list'], values: ['paragraph', 'heading', 'quote', 'list'],
value: v.propRef('currentBlock'), value: v.propRef('currentBlock'),
severity: 'optional' severity: 'optional'
} },
{
attr: 'data-words-node',
values: ['text', 'list', 'list-item', 'link', 'paragraph', 'heading', 'quote'],
severity: 'optional'
},
{ attr: 'data-words-path', severity: 'optional' },
{ attr: 'data-words-marks', severity: 'optional' },
{ attr: 'data-words-empty-text', severity: 'optional' },
{
attr: 'data-words-list-kind',
values: ['ordered', 'unordered', 'check'],
severity: 'optional'
},
{ attr: 'data-words-block', severity: 'optional' },
{ attr: 'data-words-checked', severity: 'optional' },
{ attr: 'data-words-tool-popover', severity: 'optional' }
], ],
aria: [ aria: [
{ attr: 'contenteditable', value: v.propRef('editable') }, { attr: 'contenteditable', value: v.propRef('editable') },
@ -275,7 +291,7 @@ export const wordsMorfo = {
{ attr: 'aria-orientation', value: v.literal('horizontal') }, { attr: 'aria-orientation', value: v.literal('horizontal') },
{ {
attr: 'aria-label', attr: 'aria-label',
value: v.translationRef('#?components.words.bubbleMenu|Selection toolbar'), value: v.translationRef('#?components.words.bubble-menu|Selection toolbar'),
severity: 'recommended' severity: 'recommended'
} }
] ]
@ -320,7 +336,7 @@ export const wordsMorfo = {
aria: [ aria: [
{ {
attr: 'aria-label', attr: 'aria-label',
value: v.translationRef('#?components.words.linkEditor|Edit link'), value: v.translationRef('#?components.words.link-editor|Edit link'),
severity: 'recommended' severity: 'recommended'
} }
] ]

@ -12,6 +12,9 @@ const onContent = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>
const onCommandButton = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>[2]) => const onCommandButton = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>[2]) =>
semaSelector(wordsMorfo, 'command-button', matchers); semaSelector(wordsMorfo, 'command-button', matchers);
const onLinkEditor = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>[2]) =>
semaSelector(wordsMorfo, 'link-editor', matchers);
export const wordsSema: Sema = { export const wordsSema: Sema = {
name: 'words', name: 'words',
cascade: [ cascade: [
@ -33,6 +36,22 @@ export const wordsSema: Sema = {
sound: soundTuning('form.commit.subtle'), sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' } haptic: { kind: 'tap' }
}, },
{
selector: onLinkEditor({ eventName: 'commit-link' }),
sound: soundTuning('form.commit.soft', {
gain: { op: 'multiply', factor: 0.9 },
pitch: { op: 'add', value: 20 }
}),
haptic: { kind: 'tap' }
},
{
selector: onLinkEditor({ eventName: 'commit-unlink' }),
sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.8 },
contour: 'descending'
}),
haptic: { kind: 'tap' }
},
{ {
selector: onProvider({ eventName: 'commit-history' }), selector: onProvider({ eventName: 'commit-history' }),
sound: soundTuning('form.commit.subtle', { sound: soundTuning('form.commit.subtle', {

@ -199,6 +199,16 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
expone `data-open`, `data-positioned`, `data-side` y `data-selection`, y expone `data-open`, `data-positioned`, `data-side` y `data-selection`, y
evita que el `pointerdown` del toolbar contextual colapse la seleccion antes evita que el `pointerdown` del toolbar contextual colapse la seleccion antes
de ejecutar comandos. de ejecutar comandos.
- La sincronizacion de seleccion distingue `selectionchange` automatico de
senales explicitas (`pointerup`, teclado, input). Un colapso transitorio
durante el cruce del contenido al `BubbleMenu` no sobrescribe el rango del
modelo; los botones restauran la seleccion DOM antes de aplicar comandos.
- `Words.Content` intercepta clicks sobre links en modo editable para evitar
navegacion accidental fuera del editor; en modo `readonly` no cancela el
comportamiento nativo.
- `Words.LinkEditor` recibe el formulario real como target de `commit-link` y
`commit-unlink`, de forma que Sema puede proyectar los eventos sobre la parte
declarada en Morfo.
## Correcciones cerradas en slice 2 ## Correcciones cerradas en slice 2
@ -239,9 +249,10 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
- Importador HTML avanzado con tablas, imagenes y HTML arbitrario completo. - Importador HTML avanzado con tablas, imagenes y HTML arbitrario completo.
- Drop/clipboard avanzado a traves de servicios ActiveUIX. - Drop/clipboard avanzado a traves de servicios ActiveUIX.
- Popover flotante de links anclado directamente al rango seleccionado. V1 usa - Popover flotante de links resuelto en Eidos como `Words.ToolPopover` local
`LinkEditor` dentro de toolbar/bubble menu con el popover reutilizable de para no depender del `Soma/Popover` compartido mientras ese componente tiene
Eidos. bloqueo propio documentado en `src/uix/PENDIENTES.md`. Soma mantiene la
seleccion estable y la validacion/comando de `LinkEditor`.
- Cobertura de componentes Svelte renderizados para el resto de partes - Cobertura de componentes Svelte renderizados para el resto de partes
compuestas. compuestas.
- Eidos recipe y demo bajo `web/routes/uix/components/words` ya existen; queda - Eidos recipe y demo bajo `web/routes/uix/components/words` ya existen; queda

@ -28,7 +28,7 @@
const mergedProps = $derived(mergeProps(restProps, state.props)); const mergedProps = $derived(mergeProps(restProps, state.props));
const bubbleProps = $derived.by(() => { const bubbleProps = $derived.by(() => {
const { onpointerdown, ...props } = mergedProps; const { onpointerdown, onpointerdowncapture, ...props } = mergedProps;
return props; return props;
}); });
@ -62,6 +62,11 @@
const handler = (mergedProps as Record<string, unknown>).onpointerdown; const handler = (mergedProps as Record<string, unknown>).onpointerdown;
if (typeof handler === 'function') handler(event); if (typeof handler === 'function') handler(event);
} }
function callPointerDownCapture(event: PointerEvent) {
const handler = (mergedProps as Record<string, unknown>).onpointerdowncapture;
if (typeof handler === 'function') handler(event);
}
</script> </script>
<svelte:window onresize={schedulePosition} onscroll={schedulePosition} /> <svelte:window onresize={schedulePosition} onscroll={schedulePosition} />
@ -69,7 +74,11 @@
{#if child} {#if child}
{@render child({ ...state.snippetProps, props: mergedProps })} {@render child({ ...state.snippetProps, props: mergedProps })}
{:else} {:else}
<div {...bubbleProps} onpointerdown={callPointerDown}> <div
{...bubbleProps}
onpointerdowncapture={callPointerDownCapture}
onpointerdown={callPointerDown}
>
{@render children?.(state.snippetProps)} {@render children?.(state.snippetProps)}
</div> </div>
{/if} {/if}

@ -28,20 +28,22 @@
const mergedProps = $derived(mergeProps(restProps, state.props)); const mergedProps = $derived(mergeProps(restProps, state.props));
let restoredHtml = ''; let restoredHtml = '';
const contentProps = $derived.by(() => { const contentProps = $derived.by(() => {
const { const {
onfocus, onfocus,
onblur, onblur,
onkeydown, onkeydown,
onkeyup, onkeyup,
onpointerup, onpointerdown,
onselect, onpointerup,
onbeforeinput, onclick,
onpaste, onselect,
oncompositionstart, onbeforeinput,
oncompositionupdate, onpaste,
oncompositionend, oncompositionstart,
...props oncompositionupdate,
} = mergedProps; oncompositionend,
...props
} = mergedProps;
return props; return props;
}); });
@ -87,7 +89,9 @@
onblur={(event) => callEventHandler('onblur', event)} onblur={(event) => callEventHandler('onblur', event)}
onkeydown={(event) => callEventHandler('onkeydown', event)} onkeydown={(event) => callEventHandler('onkeydown', event)}
onkeyup={(event) => callEventHandler('onkeyup', event)} onkeyup={(event) => callEventHandler('onkeyup', event)}
onpointerdown={(event) => callEventHandler('onpointerdown', event)}
onpointerup={(event) => callEventHandler('onpointerup', event)} onpointerup={(event) => callEventHandler('onpointerup', event)}
onclick={(event) => callEventHandler('onclick', event)}
onselect={(event) => callEventHandler('onselect', event)} onselect={(event) => callEventHandler('onselect', event)}
onbeforeinput={(event) => callEventHandler('onbeforeinput', event)} onbeforeinput={(event) => callEventHandler('onbeforeinput', event)}
onpaste={(event) => callEventHandler('onpaste', event)} onpaste={(event) => callEventHandler('onpaste', event)}
@ -104,7 +108,9 @@
onblur={(event) => callEventHandler('onblur', event)} onblur={(event) => callEventHandler('onblur', event)}
onkeydown={(event) => callEventHandler('onkeydown', event)} onkeydown={(event) => callEventHandler('onkeydown', event)}
onkeyup={(event) => callEventHandler('onkeyup', event)} onkeyup={(event) => callEventHandler('onkeyup', event)}
onpointerdown={(event) => callEventHandler('onpointerdown', event)}
onpointerup={(event) => callEventHandler('onpointerup', event)} onpointerup={(event) => callEventHandler('onpointerup', event)}
onclick={(event) => callEventHandler('onclick', event)}
onselect={(event) => callEventHandler('onselect', event)} onselect={(event) => callEventHandler('onselect', event)}
onbeforeinput={(event) => callEventHandler('onbeforeinput', event)} onbeforeinput={(event) => callEventHandler('onbeforeinput', event)}
onpaste={(event) => callEventHandler('onpaste', event)} onpaste={(event) => callEventHandler('onpaste', event)}

@ -1,4 +1,4 @@
import type { WordsBlockType, WordsHeadingLevel, WordsListKind, WordsMark } from './document'; import type { WordsBlockType, WordsHeadingLevel, WordsListKind, WordsMark, WordsTextAlign } from './document';
import { import {
clearFormatting, clearFormatting,
deleteBackward, deleteBackward,
@ -14,6 +14,7 @@ import {
insertText, insertText,
replaceDocument, replaceDocument,
setBlock, setBlock,
setTextAlign,
toggleList, toggleList,
toggleMark, toggleMark,
unlink, unlink,
@ -43,6 +44,7 @@ export type WordsCommand =
| { type: 'toggleMark'; mark: WordsMark } | { type: 'toggleMark'; mark: WordsMark }
| { type: 'clearFormatting' } | { type: 'clearFormatting' }
| { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel } | { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel }
| { type: 'setTextAlign'; align: WordsTextAlign }
| { type: 'toggleList'; kind: WordsListKind } | { type: 'toggleList'; kind: WordsListKind }
| { type: 'insertLink'; href: string; title?: string } | { type: 'insertLink'; href: string; title?: string }
| { type: 'unlink' } | { type: 'unlink' }
@ -81,6 +83,8 @@ export function applyWordsCommand(
return clearFormatting(state); return clearFormatting(state);
case 'setBlock': case 'setBlock':
return setBlock(state, command.block, { level: command.level }); return setBlock(state, command.block, { level: command.level });
case 'setTextAlign':
return setTextAlign(state, command.align);
case 'toggleList': case 'toggleList':
return toggleList(state, command.kind); return toggleList(state, command.kind);
case 'insertLink': case 'insertLink':

@ -4,6 +4,7 @@ export type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
export type WordsBlockType = 'paragraph' | 'heading' | 'quote' | 'list'; export type WordsBlockType = 'paragraph' | 'heading' | 'quote' | 'list';
export type WordsListKind = 'ordered' | 'unordered' | 'check'; export type WordsListKind = 'ordered' | 'unordered' | 'check';
export type WordsHeadingLevel = 1 | 2 | 3; export type WordsHeadingLevel = 1 | 2 | 3;
export type WordsTextAlign = 'left' | 'center' | 'right' | 'justify';
export interface WordsDocument { export interface WordsDocument {
readonly version: typeof WORDS_DOCUMENT_VERSION; readonly version: typeof WORDS_DOCUMENT_VERSION;
@ -15,17 +16,20 @@ export type WordsBlock = WordsParagraphBlock | WordsHeadingBlock | WordsQuoteBlo
export interface WordsParagraphBlock { export interface WordsParagraphBlock {
readonly type: 'paragraph'; readonly type: 'paragraph';
readonly children: readonly WordsInline[]; readonly children: readonly WordsInline[];
readonly textAlign?: WordsTextAlign;
} }
export interface WordsHeadingBlock { export interface WordsHeadingBlock {
readonly type: 'heading'; readonly type: 'heading';
readonly level: WordsHeadingLevel; readonly level: WordsHeadingLevel;
readonly children: readonly WordsInline[]; readonly children: readonly WordsInline[];
readonly textAlign?: WordsTextAlign;
} }
export interface WordsQuoteBlock { export interface WordsQuoteBlock {
readonly type: 'quote'; readonly type: 'quote';
readonly children: readonly WordsInline[]; readonly children: readonly WordsInline[];
readonly textAlign?: WordsTextAlign;
} }
export interface WordsListBlock { export interface WordsListBlock {
@ -68,6 +72,7 @@ export const WORDS_LIST_KINDS = [
'check' 'check'
] as const satisfies readonly WordsListKind[]; ] as const satisfies readonly WordsListKind[];
export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[]; export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[];
export const WORDS_TEXT_ALIGNS = ['left', 'center', 'right', 'justify'] as const satisfies readonly WordsTextAlign[];
export function createEmptyWordsDocument(): WordsDocument { export function createEmptyWordsDocument(): WordsDocument {
return { return {
@ -77,20 +82,25 @@ export function createEmptyWordsDocument(): WordsDocument {
} }
export function createParagraph( export function createParagraph(
children: readonly WordsInline[] = [createText('')] children: readonly WordsInline[] = [createText('')],
textAlign?: WordsTextAlign
): WordsParagraphBlock { ): WordsParagraphBlock {
return { type: 'paragraph', children }; return { type: 'paragraph', children, ...(textAlign ? { textAlign } : {}) };
} }
export function createHeading( export function createHeading(
level: WordsHeadingLevel, level: WordsHeadingLevel,
children: readonly WordsInline[] = [createText('')] children: readonly WordsInline[] = [createText('')],
textAlign?: WordsTextAlign
): WordsHeadingBlock { ): WordsHeadingBlock {
return { type: 'heading', level, children }; return { type: 'heading', level, children, ...(textAlign ? { textAlign } : {}) };
} }
export function createQuote(children: readonly WordsInline[] = [createText('')]): WordsQuoteBlock { export function createQuote(
return { type: 'quote', children }; children: readonly WordsInline[] = [createText('')],
textAlign?: WordsTextAlign
): WordsQuoteBlock {
return { type: 'quote', children, ...(textAlign ? { textAlign } : {}) };
} }
export function createList( export function createList(
@ -157,6 +167,10 @@ export function isWordsListKind(value: unknown): value is WordsListKind {
return typeof value === 'string' && (WORDS_LIST_KINDS as readonly string[]).includes(value); return typeof value === 'string' && (WORDS_LIST_KINDS as readonly string[]).includes(value);
} }
export function isWordsTextAlign(value: unknown): value is WordsTextAlign {
return typeof value === 'string' && (WORDS_TEXT_ALIGNS as readonly string[]).includes(value);
}
export function isTextInline(inline: WordsInline): inline is WordsText { export function isTextInline(inline: WordsInline): inline is WordsText {
return inline.type === 'text'; return inline.type === 'text';
} }

@ -13,10 +13,12 @@ import {
beginWordsComposition, beginWordsComposition,
createCollapsedSelection, createCollapsedSelection,
createEmptyWordsDocument, createEmptyWordsDocument,
createHeading,
createLink, createLink,
createList, createList,
createListItem, createListItem,
createParagraph, createParagraph,
createQuote,
createText, createText,
createWordsHistory, createWordsHistory,
createWordsState, createWordsState,
@ -909,4 +911,306 @@ describe('Words engine', () => {
command: { type: 'insertText', text: 'á' } command: { type: 'insertText', text: 'á' }
}); });
}); });
it('clears active marks on collapsed selection and removes marks from ranges', () => {
let state = createWordsState(
{
version: 1,
children: [createParagraph([createText('Alpha beta', ['bold', 'italic'])])]
},
createCollapsedSelection([0, 0], 5)
);
let result = applyWordsCommand(state, { type: 'clearFormatting' });
expect(result.changed).toBe(true);
expect(result.state.activeMarks).toEqual([]);
state = {
...result.state,
selection: {
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 10 }
}
};
result = applyWordsCommand(state, { type: 'clearFormatting' });
expect(result.changed).toBe(true);
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
children: [{ type: 'text', text: 'Alpha beta' }]
});
});
it('deletes forward inside text and merges blocks at boundary', () => {
let state = stateWithText('Alpha');
let result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 2) },
{ type: 'deleteForward' }
);
expect(documentPlainText(result.state.document)).toBe('Alha');
state = createWordsState(
{
version: 1,
children: [createParagraph([createText('Alpha')]), createParagraph([createText('Beta')])]
},
createCollapsedSelection([0, 0], 5)
);
result = applyWordsCommand(state, { type: 'deleteForward' });
expect(documentPlainText(result.state.document)).toBe('AlphaBeta');
expect(result.state.document.children).toHaveLength(1);
});
it('is a no-op when deleting forward at the end of the last block', () => {
const state = stateWithText('Alpha');
const result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 5) },
{ type: 'deleteForward' }
);
expect(result.changed).toBe(false);
expect(documentPlainText(result.state.document)).toBe('Alpha');
});
it('deletes to end of line with deleteLineForward', () => {
const state = stateWithText('Alpha beta');
const result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 6) },
{ type: 'deleteLineForward' }
);
expect(documentPlainText(result.state.document)).toBe('Alpha ');
});
it('inserts a line break inside text', () => {
const state = stateWithText('Alpha beta');
const result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 5) },
{ type: 'insertLineBreak' }
);
expect(documentPlainText(result.state.document)).toBe('Alpha\n beta');
});
it('changes heading level with setBlock', () => {
const state = createWordsState(
{
version: 1,
children: [{ type: 'heading', level: 1, children: [createText('Title')] }]
},
createCollapsedSelection([0, 0], 0)
);
const result = applyWordsCommand(state, { type: 'setBlock', block: 'heading', level: 2 });
expect(result.state.document.children[0]).toMatchObject({
type: 'heading',
level: 2
});
});
it('toggles ordered and unordered lists', () => {
let state = stateWithText('Alpha');
let result = applyWordsCommand(state, { type: 'toggleList', kind: 'ordered' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
kind: 'ordered',
children: [{ type: 'list-item', children: [{ type: 'text', text: 'Alpha' }] }]
});
result = applyWordsCommand(result.state, { type: 'toggleList', kind: 'unordered' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
kind: 'unordered',
children: [{ type: 'list-item', children: [{ type: 'text', text: 'Alpha' }] }]
});
});
it('replaces the entire document and resets selection', () => {
const state = stateWithText('Alpha');
const result = applyWordsCommand(state, {
type: 'replaceDocument',
document: {
version: 1,
children: [createParagraph([createText('Beta')])]
}
});
expect(result.changed).toBe(true);
expect(documentPlainText(result.state.document)).toBe('Beta');
expect(result.state.selection).toBeNull();
expect(result.state.activeMarks).toEqual([]);
});
it('serializes headings and ordered lists to clean HTML', () => {
const doc: WordsDocument = {
version: 1,
children: [
{ type: 'heading', level: 2, children: [createText('Title')] },
createParagraph([createText('Body')]),
createList('ordered', [
createListItem([createText('One')]),
createListItem([createText('Two')])
])
]
};
expect(serializeWordsHtml(doc)).toBe(
'<h2>Title</h2><p>Body</p><ol><li>One</li><li>Two</li></ol>'
);
});
it('preserves checked state through document replacement', () => {
const state = createWordsState(
{
version: 1,
children: [
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('Todo')])
])
]
},
createCollapsedSelection([0, 0, 0], 0)
);
const toggled = applyWordsCommand(state, {
type: 'replaceDocument',
document: {
version: 1,
children: [
createList('check', [
createListItem([createText('Done')]),
createListItem([createText('Todo')], true)
])
]
}
});
expect(toggled.state.document.children[0]).toMatchObject({
type: 'list',
children: [
{ type: 'list-item' },
{ type: 'list-item', checked: true }
]
});
});
it('deletes word boundaries around punctuation and CJK characters', () => {
const state = createWordsState(
{
version: 1,
children: [createParagraph([createText('hello, world')])]
},
createCollapsedSelection([0, 0], 6)
);
let result = applyWordsCommand(state, { type: 'deleteWordBackward' });
expect(documentPlainText(result.state.document)).toBe(' world');
const state2 = createWordsState(
{
version: 1,
children: [createParagraph([createText('hello, world')])]
},
createCollapsedSelection([0, 0], 0)
);
result = applyWordsCommand(state2, { type: 'deleteWordForward' });
expect(documentPlainText(result.state.document)).toBe(', world');
const cjk = createWordsState(
{
version: 1,
children: [createParagraph([createText('你好世界')])]
},
createCollapsedSelection([0, 0], 4)
);
result = applyWordsCommand(cjk, { type: 'deleteWordBackward' });
expect(documentPlainText(result.state.document)).toBe('你好');
const cjkForward = createWordsState(
{
version: 1,
children: [createParagraph([createText('你好世界')])]
},
createCollapsedSelection([0, 0], 0)
);
result = applyWordsCommand(cjkForward, { type: 'deleteWordForward' });
expect(documentPlainText(result.state.document)).toBe('世界');
});
it('setTextAlign applies alignment to paragraph', () => {
const state = createWordsState(
{
version: 1,
children: [createParagraph([createText('hello')])]
},
createCollapsedSelection([0, 0], 0)
);
let result = applyWordsCommand(state, { type: 'setTextAlign', align: 'center' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
textAlign: 'center'
});
result = applyWordsCommand(result.state, { type: 'setTextAlign', align: 'right' });
expect(result.state.document.children[0]).toMatchObject({
type: 'paragraph',
textAlign: 'right'
});
});
it('setTextAlign applies alignment to heading', () => {
const state = createWordsState(
{
version: 1,
children: [createHeading(1, [createText('Title')])]
},
createCollapsedSelection([0, 0], 0)
);
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'center' });
expect(result.state.document.children[0]).toMatchObject({
type: 'heading',
level: 1,
textAlign: 'center'
});
});
it('setTextAlign applies alignment to quote', () => {
const state = createWordsState(
{
version: 1,
children: [createQuote([createText('Quote')])]
},
createCollapsedSelection([0, 0], 0)
);
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'justify' });
expect(result.state.document.children[0]).toMatchObject({
type: 'quote',
textAlign: 'justify'
});
});
it('setTextAlign is no-op on list blocks', () => {
const state = createWordsState(
{
version: 1,
children: [createList('unordered', [createListItem([createText('item')])])]
},
createCollapsedSelection([0, 0], 0)
);
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'center' });
expect(result.state.document.children[0]).toMatchObject({
type: 'list',
kind: 'unordered'
});
expect((result.state.document.children[0] as { textAlign?: string }).textAlign).toBeUndefined();
});
it('setTextAlign is no-op when already at target alignment', () => {
const state = createWordsState(
{
version: 1,
children: [createParagraph([createText('hello')])]
},
createCollapsedSelection([0, 0], 0)
);
const result = applyWordsCommand(state, { type: 'setTextAlign', align: 'left' });
expect(result).toEqual({ state, changed: false });
});
}); });

@ -12,6 +12,7 @@ import {
isWordsHeadingLevel, isWordsHeadingLevel,
isWordsListKind, isWordsListKind,
isWordsMark, isWordsMark,
isWordsTextAlign,
normalizeMarks, normalizeMarks,
sameMarks, sameMarks,
type WordsBlock, type WordsBlock,
@ -86,18 +87,19 @@ function normalizeBlock(
} }
const source = block as Record<string, unknown>; const source = block as Record<string, unknown>;
const textAlign = isWordsTextAlign(source.textAlign) ? source.textAlign : undefined;
switch (source.type) { switch (source.type) {
case 'paragraph': case 'paragraph':
return createParagraph(normalizeInlineChildren(source.children, path, issues)); return createParagraph(normalizeInlineChildren(source.children, path, issues), textAlign);
case 'heading': { case 'heading': {
const level = isWordsHeadingLevel(source.level) ? source.level : 1; const level = isWordsHeadingLevel(source.level) ? source.level : 1;
if (!isWordsHeadingLevel(source.level)) { if (!isWordsHeadingLevel(source.level)) {
issues.push({ reason: 'invalid-heading-level', path, value: source.level }); issues.push({ reason: 'invalid-heading-level', path, value: source.level });
} }
return createHeading(level, normalizeInlineChildren(source.children, path, issues)); return createHeading(level, normalizeInlineChildren(source.children, path, issues), textAlign);
} }
case 'quote': case 'quote':
return createQuote(normalizeInlineChildren(source.children, path, issues)); return createQuote(normalizeInlineChildren(source.children, path, issues), textAlign);
case 'list': { case 'list': {
const kind = isWordsListKind(source.kind) ? source.kind : 'unordered'; const kind = isWordsListKind(source.kind) ? source.kind : 'unordered';
if (!isWordsListKind(source.kind)) { if (!isWordsListKind(source.kind)) {

@ -16,7 +16,8 @@ import {
type WordsInline, type WordsInline,
type WordsListItem, type WordsListItem,
type WordsListKind, type WordsListKind,
type WordsMark type WordsMark,
type WordsTextAlign
} from './document'; } from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize'; import { normalizeDocument, sanitizeWordsUrl } from './normalize';
import { import {
@ -323,12 +324,12 @@ export function setBlock(
changed = true; changed = true;
for (const [itemIndex, item] of block.children.entries()) { for (const [itemIndex, item] of block.children.entries()) {
listItemIndexMap.set(listItemMapKey(index, itemIndex), children.length); listItemIndexMap.set(listItemMapKey(index, itemIndex), children.length);
children.push(blockWithChildren(type, item.children, options.level)); children.push(blockWithChildren(type, item.children, options.level, undefined));
} }
return; return;
} }
const nextBlock = blockWithChildren(type, block.children, options.level); const nextBlock = blockWithChildren(type, block.children, options.level, block.textAlign);
blockIndexMap.set(index, children.length); blockIndexMap.set(index, children.length);
changed = changed || !sameBlockShape(block, nextBlock); changed = changed || !sameBlockShape(block, nextBlock);
children.push(nextBlock); children.push(nextBlock);
@ -356,6 +357,49 @@ export function setBlock(
}; };
} }
export function setTextAlign(
state: WordsEditorState,
align: WordsTextAlign
): WordsOperationResult {
const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;
const startIndex = range?.start.path[0] ?? 0;
const endIndex = range?.end.path[0] ?? startIndex;
let changed = false;
const children: WordsBlock[] = [];
for (const [index, block] of state.document.children.entries()) {
if (index < startIndex || index > endIndex || block.type === 'list') {
children.push(block);
continue;
}
const currentAlign = block.textAlign ?? 'left';
if (currentAlign === align) {
children.push(block);
continue;
}
const nextBlock =
block.type === 'heading'
? createHeading(block.level, block.children, align)
: block.type === 'quote'
? createQuote(block.children, align)
: createParagraph(block.children, align);
changed = true;
children.push(nextBlock);
}
if (!changed) return { state, changed: false };
const normalized = normalizeDocument({ ...state.document, children }).document;
return {
state: {
document: normalized,
selection,
activeMarks: state.activeMarks
},
changed: true
};
}
export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsOperationResult { export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsOperationResult {
const selection = state.selection; const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined; const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;
@ -684,22 +728,48 @@ function deleteTextBoundary(
}); });
} }
const wordSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'word' })
: undefined;
function previousWordBoundary(text: string, offset: number): number { function previousWordBoundary(text: string, offset: number): number {
if (wordSegmenter) {
const segments = Array.from(wordSegmenter.segment(text.slice(0, offset)));
let i = segments.length - 1;
while (i >= 0 && !segments[i].isWordLike) i--;
return i >= 0 ? segments[i].index : 0;
}
let index = Math.max(0, Math.min(offset, text.length)); let index = Math.max(0, Math.min(offset, text.length));
if (index > 0 && isCJKCharacter(text[index - 1] ?? '')) return index - 1;
while (index > 0 && isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1; while (index > 0 && isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
while (index > 0 && !isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1; while (index > 0 && !isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
return index; return index;
} }
function nextWordBoundary(text: string, offset: number): number { function nextWordBoundary(text: string, offset: number): number {
if (wordSegmenter) {
const segments = Array.from(wordSegmenter.segment(text.slice(offset)));
let i = 0;
while (i < segments.length && !segments[i].isWordLike) i++;
if (i < segments.length) i++;
return offset + (segments[i - 1] ? segments[i - 1].index + segments[i - 1].segment.length : 0);
}
let index = Math.max(0, Math.min(offset, text.length)); let index = Math.max(0, Math.min(offset, text.length));
if (index < text.length && isCJKCharacter(text[index] ?? '')) return index + 1;
while (index < text.length && isWordBoundarySpacer(text[index] ?? '')) index += 1; while (index < text.length && isWordBoundarySpacer(text[index] ?? '')) index += 1;
while (index < text.length && !isWordBoundarySpacer(text[index] ?? '')) index += 1; while (index < text.length && !isWordBoundarySpacer(text[index] ?? '')) index += 1;
return index; return index;
} }
function isWordBoundarySpacer(char: string): boolean { function isWordBoundarySpacer(char: string): boolean {
return /\s/u.test(char); return /\s|\p{P}|\p{S}/u.test(char);
}
function isCJKCharacter(char: string): boolean {
return /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}|\p{Script=Bopomofo}/u.test(
char
);
} }
function deleteRangeFromDocument( function deleteRangeFromDocument(
@ -1262,17 +1332,25 @@ function findAncestorPath(
function blockWithChildren( function blockWithChildren(
type: Exclude<WordsBlockType, 'list'>, type: Exclude<WordsBlockType, 'list'>,
children: readonly WordsInline[], children: readonly WordsInline[],
level: WordsHeadingLevel | undefined level: WordsHeadingLevel | undefined,
textAlign: WordsTextAlign | undefined
): WordsBlock { ): WordsBlock {
if (type === 'heading') return createHeading(level ?? 1, children); if (type === 'heading') return createHeading(level ?? 1, children, textAlign);
if (type === 'quote') return createQuote(children); if (type === 'quote') return createQuote(children, textAlign);
return createParagraph(children); return createParagraph(children, textAlign);
}
function blockTextAlign(block: WordsBlock): WordsTextAlign | undefined {
return block.type === 'list' ? undefined : block.textAlign;
} }
function sameBlockShape(a: WordsBlock, b: WordsBlock): boolean { function sameBlockShape(a: WordsBlock, b: WordsBlock): boolean {
if (a.type !== b.type) return false; if (a.type !== b.type) return false;
if (a.type === 'heading' && b.type === 'heading') return a.level === b.level; if (a.type === 'heading' && b.type === 'heading') {
return true; return a.level === b.level && a.textAlign === b.textAlign;
}
if (a.type === 'list' && b.type === 'list') return true;
return blockTextAlign(a) === blockTextAlign(b);
} }
function sameSelection(a: WordsSelection | null, b: WordsSelection | null): boolean { function sameSelection(a: WordsSelection | null, b: WordsSelection | null): boolean {

@ -74,7 +74,8 @@ function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElemen
attrs: { attrs: {
[WORDS_NODE_ATTR]: 'block', [WORDS_NODE_ATTR]: 'block',
[WORDS_PATH_ATTR]: encodeWordsPath(path), [WORDS_PATH_ATTR]: encodeWordsPath(path),
'data-words-block': block.type 'data-words-block': block.type,
...(block.textAlign && block.textAlign !== 'left' ? { style: `text-align: ${block.textAlign}` } : {})
}, },
children: renderWordsInlines(block.children, path) children: renderWordsInlines(block.children, path)
}; };

@ -13,7 +13,8 @@ import {
type WordsDocument, type WordsDocument,
type WordsInline, type WordsInline,
type WordsListItem, type WordsListItem,
type WordsMark type WordsMark,
type WordsTextAlign
} from './document'; } from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize'; import { normalizeDocument, sanitizeWordsUrl } from './normalize';
@ -102,15 +103,17 @@ function serializeBlockHtml(block: WordsBlock): string {
return `<${tag}>${children}</${tag}>`; return `<${tag}>${children}</${tag}>`;
} }
const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : '';
if (block.type === 'heading') { if (block.type === 'heading') {
return `<h${block.level}>${serializeInlinesHtml(block.children)}</h${block.level}>`; return `<h${block.level}${align}>${serializeInlinesHtml(block.children)}</h${block.level}>`;
} }
if (block.type === 'quote') { if (block.type === 'quote') {
return `<blockquote>${serializeInlinesHtml(block.children)}</blockquote>`; return `<blockquote${align}>${serializeInlinesHtml(block.children)}</blockquote>`;
} }
return `<p>${serializeInlinesHtml(block.children)}</p>`; return `<p${align}>${serializeInlinesHtml(block.children)}</p>`;
} }
function serializeInlinesHtml(inlines: readonly WordsInline[]): string { function serializeInlinesHtml(inlines: readonly WordsInline[]): string {
@ -263,14 +266,30 @@ function htmlNodesToBlocks(nodes: readonly HtmlNode[]): WordsBlock[] {
return blocks; return blocks;
} }
function textAlignFromStyle(node: HtmlElementNode): WordsTextAlign | undefined {
const style = typeof node.attrs.style === 'string' ? node.attrs.style : '';
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
const align = match?.[1]?.toLowerCase();
if (align === 'left' || align === 'center' || align === 'right' || align === 'justify') {
return align;
}
return undefined;
}
function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] { function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
if (node.name === 'h1' || node.name === 'h2' || node.name === 'h3') { if (node.name === 'h1' || node.name === 'h2' || node.name === 'h3') {
return [ return [
createHeading(Number(node.name.slice(1)) as 1 | 2 | 3, htmlNodesToInlines(node.children)) createHeading(
Number(node.name.slice(1)) as 1 | 2 | 3,
htmlNodesToInlines(node.children),
textAlignFromStyle(node)
)
]; ];
} }
if (node.name === 'blockquote') return [createQuote(htmlNodesToInlines(node.children))]; if (node.name === 'blockquote') {
return [createQuote(htmlNodesToInlines(node.children), textAlignFromStyle(node))];
}
if (node.name === 'ul' || node.name === 'ol') { if (node.name === 'ul' || node.name === 'ol') {
return [htmlListToBlock(node)]; return [htmlListToBlock(node)];
@ -282,7 +301,7 @@ function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
return htmlNodesToBlocks(node.children); return htmlNodesToBlocks(node.children);
} }
return [createParagraph(htmlNodesToInlines(node.children))]; return [createParagraph(htmlNodesToInlines(node.children), textAlignFromStyle(node))];
} }
function htmlListToBlock(node: HtmlElementNode): WordsBlock { function htmlListToBlock(node: HtmlElementNode): WordsBlock {

@ -22,8 +22,8 @@
<Words.Provider bind:value bind:selection> <Words.Provider bind:value bind:selection>
{#if emptyChildren} {#if emptyChildren}
<Words.Content data-testid="words-content"></Words.Content> <Words.Content></Words.Content>
{:else} {:else}
<Words.Content data-testid="words-content" /> <Words.Content />
{/if} {/if}
</Words.Provider> </Words.Provider>

@ -44,6 +44,10 @@ export type WordsCommandName =
| 'paragraph' | 'paragraph'
| 'heading' | 'heading'
| 'quote' | 'quote'
| 'align-left'
| 'align-center'
| 'align-right'
| 'align-justify'
| 'unordered-list' | 'unordered-list'
| 'ordered-list' | 'ordered-list'
| 'check-list' | 'check-list'
@ -63,6 +67,9 @@ export type WordsProviderSnippetProps = {
readonly isInvalid: boolean; readonly isInvalid: boolean;
readonly activeMarks: readonly WordsMark[]; readonly activeMarks: readonly WordsMark[];
readonly selectedLink: WordsSelectedLink | null; readonly selectedLink: WordsSelectedLink | null;
readonly currentBlock: string;
readonly currentHeadingLevel: WordsHeadingLevel | undefined;
readonly currentTextAlign: string;
readonly canUndo: boolean; readonly canUndo: boolean;
readonly canRedo: boolean; readonly canRedo: boolean;
readonly canUnlink: boolean; readonly canUnlink: boolean;
@ -101,8 +108,8 @@ export type WordsLinkEditorSnippetProps = {
readonly readonly: boolean; readonly readonly: boolean;
readonly captureSelection: () => boolean; readonly captureSelection: () => boolean;
readonly validate: (href?: string) => boolean; readonly validate: (href?: string) => boolean;
readonly apply: (href?: string, title?: string) => boolean; readonly apply: (href?: string, title?: string, target?: HTMLElement) => boolean;
readonly unlink: () => boolean; readonly unlink: (target?: HTMLElement) => boolean;
readonly clearError: () => void; readonly clearError: () => void;
}; };
@ -150,6 +157,7 @@ export type WordsContentProps = WithChild<
onfocus?: unknown; onfocus?: unknown;
onblur?: unknown; onblur?: unknown;
onpointerup?: unknown; onpointerup?: unknown;
onclick?: unknown;
onselect?: unknown; onselect?: unknown;
oncompositionstart?: unknown; oncompositionstart?: unknown;
oncompositionupdate?: unknown; oncompositionupdate?: unknown;
@ -191,7 +199,7 @@ export type WordsCommandButtonProps = WithChild<{
}> & }> &
Without< Without<
PrimitiveButtonAttributes, PrimitiveButtonAttributes,
{ type?: unknown; onclick?: unknown; onpointerdown?: unknown; disabled?: unknown } { type?: unknown; onpointerdown?: unknown; disabled?: unknown }
>; >;
export type WordsLinkEditorProps = WithChild< export type WordsLinkEditorProps = WithChild<

@ -7,7 +7,7 @@ import { createActiveDom } from '$adom';
import type { Morfo } from '$uix/morfo'; import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte'; import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field';
import WordsContentHarness from './test/words-content-harness.svelte'; import WordsContentHarness from './test/words-content-harness.svelte';

@ -8,7 +8,7 @@ import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo'; import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte'; import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field';
import { import {
WordsBubbleMenuProvider, WordsBubbleMenuProvider,
@ -21,6 +21,7 @@ import {
import type { WordsCommandName } from './types'; import type { WordsCommandName } from './types';
import { import {
createEmptyWordsDocument, createEmptyWordsDocument,
createLink,
createList, createList,
createListItem, createListItem,
createParagraph, createParagraph,
@ -378,6 +379,84 @@ describe('WordsProvider', () => {
dom.dispose(); dom.dispose();
}); });
it('prevents rendered link navigation while content is editable', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createLink('/docs', [createText('Docs')])])]
});
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { content };
});
contentEl.innerHTML = result.content.snippetProps.html;
const link = contentEl.querySelector('a[href]');
const linkText = link?.firstChild;
if (!link || !linkText) throw new Error('Words link did not render');
const preventDefault = vi.fn();
result.content.props.onclick({
target: linkText,
currentTarget: contentEl,
preventDefault
} as never);
expect(preventDefault).toHaveBeenCalledOnce();
cleanup();
dom.dispose();
});
it('allows rendered link navigation when content is readonly', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root, {
version: 1,
children: [createParagraph([createLink('/docs', [createText('Docs')])])]
});
opts.readonly.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { content };
});
contentEl.innerHTML = result.content.snippetProps.html;
const link = contentEl.querySelector('a[href]');
if (!link) throw new Error('Words link did not render');
const preventDefault = vi.fn();
result.content.props.onclick({
target: link,
currentTarget: contentEl,
preventDefault
} as never);
expect(preventDefault).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('lets the link editor capture a DOM range before toolbar focus moves', () => { it('lets the link editor capture a DOM range before toolbar focus moves', () => {
const { dom } = installSomaHarness(); const { dom } = installSomaHarness();
const root = document.createElement('div'); const root = document.createElement('div');
@ -644,7 +723,15 @@ describe('WordsProvider', () => {
const { dom } = installSomaHarness(); const { dom } = installSomaHarness();
const root = document.createElement('div'); const root = document.createElement('div');
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');
const toolbarEl = document.createElement('div');
const toolbarButtonEl = document.createElement('button');
const menuEl = document.createElement('div'); const menuEl = document.createElement('div');
const menuButtonEl = document.createElement('button');
toolbarEl.setAttribute('data-words-toolbar', '');
toolbarEl.append(toolbarButtonEl);
menuEl.setAttribute('data-words-bubble-menu', '');
menuEl.append(menuButtonEl);
root.append(toolbarEl);
document.body.append(root, contentEl, menuEl); document.body.append(root, contentEl, menuEl);
const opts = wordsOpts(root, { const opts = wordsOpts(root, {
version: 1, version: 1,
@ -705,6 +792,29 @@ describe('WordsProvider', () => {
expect(result.bubble.props.hidden).toBeUndefined(); expect(result.bubble.props.hidden).toBeUndefined();
expect(result.bubble.props.style).toContain('--_words-bubble-x: 42px'); expect(result.bubble.props.style).toContain('--_words-bubble-x: 42px');
mountRenderedSelection(contentEl, 0);
expect(result.provider.syncSelectionFromDom({ source: 'selectionchange' })).toBe(false);
expect(result.provider.selection).toEqual({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 }
});
toolbarButtonEl.focus();
mountRenderedSelection(contentEl, 0);
expect(result.provider.syncSelectionFromDom()).toBe(false);
expect(result.provider.selection).toEqual({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 }
});
menuButtonEl.focus();
mountRenderedSelection(contentEl, 0);
expect(result.provider.syncSelectionFromDom()).toBe(false);
expect(result.provider.selection).toEqual({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 }
});
cleanup(); cleanup();
dom.dispose(); dom.dispose();
}); });

@ -10,7 +10,7 @@ import {
} from '$libs/reactive'; } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import type { OnChangeFn, SomaFocusEvent, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import type { OnChangeFn, SomaFocusEvent, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field';
import { wordsMorfo } from '../../../morfo/components/words'; import { wordsMorfo } from '../../../morfo/components/words';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { import type {
@ -104,6 +104,14 @@ interface WordsLinkEditorOpts
}> {} }> {}
interface WordsHiddenInputOpts extends WithRefOpts {} interface WordsHiddenInputOpts extends WithRefOpts {}
interface WordsStatusOpts extends WithRefOpts {} interface WordsStatusOpts extends WithRefOpts {}
type WordsSelectionSyncSource =
| 'selectionchange'
| 'pointer'
| 'keyboard'
| 'select'
| 'input'
| 'blur'
| 'direct';
export class WordsProvider { export class WordsProvider {
readonly opts: WordsOpts; readonly opts: WordsOpts;
@ -131,6 +139,7 @@ export class WordsProvider {
composition = $state(createWordsCompositionState()); composition = $state(createWordsCompositionState());
focused = $state(false); focused = $state(false);
dirty = $state(false); dirty = $state(false);
isSelecting = $state(false);
private pendingCommitReason: WordsCommitReason = 'programmatic'; private pendingCommitReason: WordsCommitReason = 'programmatic';
private typingBatchOpen = false; private typingBatchOpen = false;
@ -190,10 +199,27 @@ export class WordsProvider {
const handleSelectionChange = () => { const handleSelectionChange = () => {
const selection = doc.getSelection(); const selection = doc.getSelection();
if (!domSelectionTouchesRoot(selection, root)) return; if (!domSelectionTouchesRoot(selection, root)) return;
this.syncSelectionFromDom(); this.syncSelectionFromDom({ source: 'selectionchange' });
};
return this.soma.dom.listen(doc, 'selectionchange', handleSelectionChange);
});
$effect(() => {
const root = this.contentRef.current;
if (!root) return;
const doc = root.ownerDocument;
const handlePointerUp = () => {
this.isSelecting = false;
};
const handlePointerCancel = () => {
this.isSelecting = false;
};
const cleanupUp = this.soma.dom.listen(doc, 'pointerup', handlePointerUp);
const cleanupCancel = this.soma.dom.listen(doc, 'pointercancel', handlePointerCancel);
return () => {
cleanupUp?.();
cleanupCancel?.();
}; };
doc.addEventListener('selectionchange', handleSelectionChange);
return () => doc.removeEventListener('selectionchange', handleSelectionChange);
}); });
} }
@ -244,6 +270,12 @@ export class WordsProvider {
return block?.type === 'list' ? block.kind : undefined; return block?.type === 'list' ? block.kind : undefined;
}); });
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';
});
readonly canUnlink = $derived.by( readonly canUnlink = $derived.by(
() => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly () => Boolean(this.selectedLink) && !this.isDisabled && !this.isReadonly
); );
@ -265,12 +297,15 @@ export class WordsProvider {
this.contentRef.current = el; this.contentRef.current = el;
} }
syncSelectionFromDom(): boolean { syncSelectionFromDom(options: { source?: WordsSelectionSyncSource } = {}): boolean {
const root = this.contentRef.current; const root = this.contentRef.current;
if (!root) return false; if (!root) return false;
const selection = root.ownerDocument.defaultView?.getSelection() ?? null; const selection = root.ownerDocument.defaultView?.getSelection() ?? null;
const next = selectionApiToWordsSelection(this.document, root, selection); const next = selectionApiToWordsSelection(this.document, root, selection);
if (!next) return false; if (!next) return false;
if (this.shouldIgnoreTransientSelectionCollapse(next, root, options.source ?? 'direct')) {
return false;
}
this.updateSelection(next); this.updateSelection(next);
return true; return true;
} }
@ -285,6 +320,12 @@ export class WordsProvider {
); );
} }
restoreModelSelection(selection: WordsSelection | null): boolean {
if (!selection) return false;
this.updateSelection(selection);
return this.restoreDomSelection();
}
getSelectionClientRect(): WordsSelectionRect | null { getSelectionClientRect(): WordsSelectionRect | null {
const root = this.contentRef.current; const root = this.contentRef.current;
if (!root || this.selectionKind !== 'range') return null; if (!root || this.selectionKind !== 'range') return null;
@ -415,16 +456,31 @@ export class WordsProvider {
}; };
readonly onblur = (_e: SomaFocusEvent<HTMLElement>) => { readonly onblur = (_e: SomaFocusEvent<HTMLElement>) => {
this.syncSelectionFromDom(); this.isSelecting = false;
this.syncSelectionFromDom({ source: 'blur' });
this.closeTypingBatch(); this.closeTypingBatch();
this.focused = false; this.focused = false;
if (this.opts.commitOnBlur.current) this.commit('blur'); if (this.opts.commitOnBlur.current) this.commit('blur');
}; };
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => { readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
this.isSelecting = false;
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
if (!mod) {
if (key === 'backspace') {
e.preventDefault();
this.applyCommand({ type: 'deleteBackward' });
return;
}
if (key === 'delete') {
e.preventDefault();
this.applyCommand({ type: 'deleteForward' });
return;
}
return;
}
if (key === 'a') { if (key === 'a') {
e.preventDefault(); e.preventDefault();
@ -459,15 +515,38 @@ export class WordsProvider {
}; };
readonly onkeyup = (_e: KeyboardEvent) => { readonly onkeyup = (_e: KeyboardEvent) => {
this.syncSelectionFromDom(); this.isSelecting = false;
this.syncSelectionFromDom({ source: 'keyboard' });
};
readonly onpointerdown = (_e: PointerEvent) => {
this.isSelecting = true;
}; };
readonly onpointerup = (_e: PointerEvent) => { readonly onpointerup = (_e: PointerEvent) => {
this.syncSelectionFromDom(); this.isSelecting = false;
this.syncSelectionFromDom({ source: 'pointer' });
};
readonly onclick = (e: MouseEvent & { currentTarget: HTMLElement }) => {
this.isSelecting = false;
if (!this.isEditable) return;
const target = e.target;
const targetElement =
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;
e.preventDefault();
this.syncSelectionFromDom({ source: 'pointer' });
}; };
readonly onselect = (_e: Event) => { readonly onselect = (_e: Event) => {
this.syncSelectionFromDom(); this.syncSelectionFromDom({ source: 'select' });
}; };
readonly onbeforeinput = (e: InputEvent & { currentTarget: HTMLElement }) => { readonly onbeforeinput = (e: InputEvent & { currentTarget: HTMLElement }) => {
@ -478,7 +557,10 @@ export class WordsProvider {
const action = actionFromBeforeInput(e); const action = actionFromBeforeInput(e);
if (action.type === 'ignore') { if (action.type === 'ignore') {
if (action.reason === 'composition') return; if (action.reason === 'composition') {
e.preventDefault();
return;
}
if (action.reason === 'unsupported') return; if (action.reason === 'unsupported') return;
e.preventDefault(); e.preventDefault();
this.signalInvalidInput(action.reason, e.currentTarget); this.signalInvalidInput(action.reason, e.currentTarget);
@ -486,7 +568,7 @@ export class WordsProvider {
} }
e.preventDefault(); e.preventDefault();
this.syncSelectionFromDom(); this.syncSelectionFromDom({ source: 'input' });
if (action.type === 'history') { if (action.type === 'history') {
if (action.direction === 'undo') this.undo(e.currentTarget); if (action.direction === 'undo') this.undo(e.currentTarget);
else this.redo(e.currentTarget); else this.redo(e.currentTarget);
@ -506,7 +588,7 @@ export class WordsProvider {
html: e.clipboardData?.getData('text/html') ?? '' html: e.clipboardData?.getData('text/html') ?? ''
}); });
e.preventDefault(); e.preventDefault();
this.syncSelectionFromDom(); this.syncSelectionFromDom({ source: 'input' });
if (action.type === 'ignore') { if (action.type === 'ignore') {
this.signalInvalidInput(action.reason, e.currentTarget); this.signalInvalidInput(action.reason, e.currentTarget);
return; return;
@ -530,7 +612,7 @@ export class WordsProvider {
const action = endWordsComposition(finalComposition); const action = endWordsComposition(finalComposition);
this.composition = createWordsCompositionState(); this.composition = createWordsCompositionState();
if (action.type === 'ignore') return; if (action.type === 'ignore') return;
this.syncSelectionFromDom(); this.syncSelectionFromDom({ source: 'input' });
this.applyCommandWithOptions(action.command, { batch: 'typing' }); this.applyCommandWithOptions(action.command, { batch: 'typing' });
}; };
@ -545,6 +627,9 @@ export class WordsProvider {
isInvalid: this.isInvalid, isInvalid: this.isInvalid,
activeMarks: this.activeMarks, activeMarks: this.activeMarks,
selectedLink: this.selectedLink, selectedLink: this.selectedLink,
currentBlock: this.currentBlock,
currentHeadingLevel: this.currentHeadingLevel,
currentTextAlign: this.currentTextAlign,
canUndo: this.canUndo, canUndo: this.canUndo,
canRedo: this.canRedo, canRedo: this.canRedo,
canUnlink: this.canUnlink, canUnlink: this.canUnlink,
@ -576,6 +661,24 @@ export class WordsProvider {
this.publishPresent(result.state, { documentChanged: false }); this.publishPresent(result.state, { documentChanged: false });
} }
private shouldIgnoreTransientSelectionCollapse(
next: WordsSelection,
root: HTMLElement,
source: WordsSelectionSyncSource
): boolean {
if (this.selectionKind !== 'range' || !isCollapsedSelection(next)) return false;
if (source === 'selectionchange') return true;
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 (root.contains(active)) return false;
return false;
}
private selectAll() { private selectAll() {
if (this.isDisabled) return; if (this.isDisabled) return;
const entries = getTextEntries(this.document); const entries = getTextEntries(this.document);
@ -709,8 +812,17 @@ export class WordsProvider {
return { type: 'setBlock', block: 'paragraph' }; return { type: 'setBlock', block: 'paragraph' };
} }
return { type: 'setBlock', block: 'heading', level: level ?? 1 }; return { type: 'setBlock', block: 'heading', level: level ?? 1 };
case 'quote': case 'quote':
return { type: 'setBlock', block: 'quote' }; return { type: 'setBlock', block: 'quote' };
case 'align-left':
return { type: 'setTextAlign', align: 'left' };
case 'align-center':
return { type: 'setTextAlign', align: 'center' };
case 'align-right':
return { type: 'setTextAlign', align: 'right' };
case 'align-justify':
return { type: 'setTextAlign', align: 'justify' };
case 'unordered-list': case 'unordered-list':
return { type: 'toggleList', kind: 'unordered' }; return { type: 'toggleList', kind: 'unordered' };
case 'ordered-list': case 'ordered-list':
@ -786,7 +898,9 @@ export class WordsContentProvider {
onblur: this.provider.onblur, onblur: this.provider.onblur,
onkeydown: this.provider.onkeydown, onkeydown: this.provider.onkeydown,
onkeyup: this.provider.onkeyup, onkeyup: this.provider.onkeyup,
onpointerdown: this.provider.onpointerdown,
onpointerup: this.provider.onpointerup, onpointerup: this.provider.onpointerup,
onclick: this.provider.onclick,
onselect: this.provider.onselect, onselect: this.provider.onselect,
onbeforeinput: this.provider.onbeforeinput, onbeforeinput: this.provider.onbeforeinput,
onpaste: this.provider.onpaste, onpaste: this.provider.onpaste,
@ -922,7 +1036,10 @@ export class WordsBubbleMenuProvider {
} }
readonly open = $derived.by( readonly open = $derived.by(
() => this.provider.selectionKind === 'range' && this.provider.isEditable () =>
this.provider.selectionKind === 'range' &&
this.provider.isEditable &&
!this.provider.isSelecting
); );
readonly positioned = $derived.by(() => Boolean(this.rect)); readonly positioned = $derived.by(() => Boolean(this.rect));
readonly style = $derived.by(() => { readonly style = $derived.by(() => {
@ -946,19 +1063,26 @@ export class WordsBubbleMenuProvider {
this.provider.restoreDomSelection(); this.provider.restoreDomSelection();
}; };
readonly onpointerdowncapture = (e: PointerEvent) => {
if (!this.open) return;
e.preventDefault();
this.provider.restoreDomSelection();
};
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, ...this.runtimePart.props,
role: 'toolbar' as const, role: 'toolbar' as const,
'aria-orientation': 'horizontal' as const, 'aria-orientation': 'horizontal' as const,
'aria-label': this.provider.soma.langs.ts( 'aria-label': this.provider.soma.langs.ts(
'#?components.words.bubbleMenu|Selection toolbar' '#?components.words.bubble-menu|Selection toolbar'
), ),
'data-open': boolToEmptyStrOrUndef(this.open), 'data-open': boolToEmptyStrOrUndef(this.open),
'data-positioned': boolToEmptyStrOrUndef(this.positioned), 'data-positioned': boolToEmptyStrOrUndef(this.positioned),
'data-side': this.opts.side.current, 'data-side': this.opts.side.current,
style: this.style, style: this.style,
hidden: !this.open || !this.positioned || undefined, hidden: !this.open || !this.positioned || undefined,
onpointerdowncapture: this.onpointerdowncapture,
onpointerdown: this.onpointerdown onpointerdown: this.onpointerdown
} as const) } as const)
); );
@ -1008,7 +1132,8 @@ export class WordsCommandButtonProvider {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => { readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return; if (this.isDisabled) return;
this.provider.syncSelectionFromDom(); if (this.provider.selectionKind === 'range') this.provider.restoreDomSelection();
else this.provider.syncSelectionFromDom();
this.provider.runCommandName(this.opts.command.current, { this.provider.runCommandName(this.opts.command.current, {
target: e.currentTarget as HTMLElement, target: e.currentTarget as HTMLElement,
level: this.opts.level.current level: this.opts.level.current
@ -1018,7 +1143,8 @@ export class WordsCommandButtonProvider {
readonly onpointerdown = (e: PointerEvent) => { readonly onpointerdown = (e: PointerEvent) => {
if (this.isDisabled) return; if (this.isDisabled) return;
e.preventDefault(); e.preventDefault();
this.provider.syncSelectionFromDom(); if (this.provider.selectionKind === 'range') this.provider.restoreDomSelection();
else this.provider.syncSelectionFromDom();
}; };
readonly props = $derived.by(() => readonly props = $derived.by(() =>
@ -1054,6 +1180,14 @@ export class WordsCommandButtonProvider {
); );
case 'quote': case 'quote':
return provider.currentBlock === 'quote'; return provider.currentBlock === 'quote';
case 'align-left':
return provider.currentTextAlign === 'left';
case 'align-center':
return provider.currentTextAlign === 'center';
case 'align-right':
return provider.currentTextAlign === 'right';
case 'align-justify':
return provider.currentTextAlign === 'justify';
case 'unordered-list': case 'unordered-list':
return provider.currentBlock === 'list' && provider.currentListKind === 'unordered'; return provider.currentBlock === 'list' && provider.currentListKind === 'unordered';
case 'ordered-list': case 'ordered-list':
@ -1119,8 +1253,9 @@ export class WordsLinkEditorProvider {
readonly: this.provider.isReadonly, readonly: this.provider.isReadonly,
captureSelection: () => this.captureSelection(), captureSelection: () => this.captureSelection(),
validate: (href?: string) => this.validate(href), validate: (href?: string) => this.validate(href),
apply: (href?: string, title?: string) => this.apply(href, title), apply: (href?: string, title?: string, target?: HTMLElement) =>
unlink: () => this.unlink(), this.apply(href, title, target),
unlink: (target?: HTMLElement) => this.unlink(target),
clearError: () => this.clearError() clearError: () => this.clearError()
})); }));
@ -1139,7 +1274,7 @@ export class WordsLinkEditorProvider {
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
...this.runtimePart.props, ...this.runtimePart.props,
'aria-label': this.provider.soma.langs.ts('#?components.words.linkEditor|Edit link'), 'aria-label': this.provider.soma.langs.ts('#?components.words.link-editor|Edit link'),
'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)), 'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly), 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)), 'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)),

@ -93,15 +93,18 @@
minimal: [['bold', 'italic', 'underline']], minimal: [['bold', 'italic', 'underline']],
formatting: [ formatting: [
['bold', 'italic', 'underline', 'strike', 'code'], ['bold', 'italic', 'underline', 'strike', 'code'],
['paragraph', { command: 'heading', level: 1, label: 'Heading 1' }, 'quote'] ['heading-picker', 'quote', 'align-left', 'align-center', 'align-right', 'align-justify']
], ],
full: [ full: [
['undo', 'redo'], ['undo', 'redo'],
['bold', 'italic', 'underline', 'strike', 'code'], ['bold', 'italic', 'underline', 'strike', 'code'],
[ [
'paragraph', 'heading-picker',
{ command: 'heading', level: 1, label: 'Heading 1' },
'quote', 'quote',
'align-left',
'align-center',
'align-right',
'align-justify',
'unordered-list', 'unordered-list',
'ordered-list', 'ordered-list',
'check-list' 'check-list'
@ -111,11 +114,7 @@
}; };
const toolbarCustomGroups: WordsToolbarGroupConfig[] = [ const toolbarCustomGroups: WordsToolbarGroupConfig[] = [
['undo', 'redo'], ['undo', 'redo'],
[ ['heading-picker', 'quote', 'align-left', 'align-center', 'align-right', 'align-justify'],
{ command: 'heading', level: 1, label: 'Title' },
{ command: 'paragraph', label: 'Body' },
'quote'
],
['bold', 'italic', 'code'], ['bold', 'italic', 'code'],
['link-editor', 'unlink', 'commit'] ['link-editor', 'unlink', 'commit']
]; ];
@ -132,7 +131,7 @@
let commitOnBlur = $state(true); let commitOnBlur = $state(true);
let toolbarChoice = $state<ToolbarChoice>('full'); let toolbarChoice = $state<ToolbarChoice>('full');
let showLinkEditor = $state(true); let showLinkEditor = $state(true);
let showBubbleMenu = $state(true); let showBubbleMenu = $state(false);
let showStatus = $state(true); let showStatus = $state(true);
let orientation = $state<WordsToolbarOrientation>('horizontal'); let orientation = $state<WordsToolbarOrientation>('horizontal');
let size = $state<WordsSize>('md'); let size = $state<WordsSize>('md');
@ -271,22 +270,10 @@
note: 'selection anchored toolbar' note: 'selection anchored toolbar'
}, },
{ {
selector: '[data-words-tool-popover-trigger]', selector: '[data-words-heading-picker]',
source: 'eidos',
kind: 'eidos',
note: 'toolbar tool disclosure'
},
{
selector: '[data-words-tool-popover]',
source: 'eidos', source: 'eidos',
kind: 'eidos', kind: 'eidos',
note: 'shared floating tool shell' note: 'heading level dropdown'
},
{
selector: '[data-words-tool-popover-close]',
source: 'eidos',
kind: 'eidos',
note: 'shared close affordance'
}, },
{ {
selector: '[data-words-link-editor]', selector: '[data-words-link-editor]',
@ -558,9 +545,12 @@
} }
function somaCommandSnippet(item: WordsToolbarGroupConfig[number]): string[] { function somaCommandSnippet(item: WordsToolbarGroupConfig[number]): string[] {
if (item === 'link-editor' || (typeof item === 'object' && 'part' in item)) { if (item === 'link-editor' || (typeof item === 'object' && 'part' in item && item.part === 'link-editor')) {
return somaLinkEditorSnippet().map((line) => ` ${line.trimStart()}`); return somaLinkEditorSnippet().map((line) => ` ${line.trimStart()}`);
} }
if (item === 'heading-picker' || (typeof item === 'object' && 'part' in item && item.part === 'heading-picker')) {
return [' <Words.HeadingPicker />'];
}
const command = typeof item === 'string' ? item : item.command; const command = typeof item === 'string' ? item : item.command;
const label = const label =
typeof item === 'string' ? commandLabel(command) : (item.label ?? commandLabel(command)); typeof item === 'string' ? commandLabel(command) : (item.label ?? commandLabel(command));
@ -1080,9 +1070,8 @@
></tr ></tr
> >
<tr <tr
><td class="name">ToolPopover</td><td>eidos</td><td class="default">optional</td><td ><td class="name">HeadingPicker</td><td>eidos</td><td class="default">optional</td><td
>Reusable toolbar tool shell: icon trigger, floating panel, close button and dismiss >Heading level dropdown: H1, H2, H3, Paragraph.</td
guard.</td
></tr ></tr
> >
<tr <tr

@ -29,5 +29,6 @@ la plantilla bloqueada de UIX:
- No hay `data-*` inventados por la demo para pintar el editor; la receta lee - No hay `data-*` inventados por la demo para pintar el editor; la receta lee
los attrs de Morfo/Soma. los attrs de Morfo/Soma.
- La seleccion se muestra como `none`, `collapsed` o `range`. - La seleccion se muestra como `none`, `collapsed` o `range`.
- El cambio de preset remonta el editor para reflejar cambios externos del - El cambio de preset/import remonta el editor solo como reset explicito de la
documento hasta que Soma incorpore sincronizacion controlada de `value`. demo; Soma ya sincroniza cambios externos de `value` y `selection` sin
depender de remount.

Loading…
Cancel
Save

Powered by TurnKey Linux.