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 |
| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement |
| Import/export HTML | not owned | available | available | clean export; defer importer | partial |
| Link editing | not owned | command/plugin UI | extension UI | `Words.LinkEditor` + `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 |
| 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.
- Link editing: implemented as a stable form part over Soma selection state.
Toolbar tool popovers use a reusable `Words.ToolPopover` shell; contextual
formatting uses `Words.BubbleMenu` anchored to the real DOM selection rect.
Contextual formatting uses `Words.BubbleMenu` anchored to the real DOM
selection rect.
- HTML export and conservative safe HTML import are implemented in Soma.
Markdown import/export is implemented in Soma V1 with a conservative
zero-dependency parser.
@ -74,10 +74,11 @@ rich-text ni wrapper visual reutilizable.
| Toolbar | `Words.Toolbar`, `Words.ToolbarGroup` | horizontal/vertical visual treatment |
| Commands | `Words.CommandButton` | active/disabled/commit styling |
| Bubble menu | `Words.BubbleMenu` | fixed selection toolbar with tokenized offset |
| Tool popover | Popover composition | reusable trigger/content/close shell for toolbar tools |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Status | `Words.Status` | idle/error status recipe |
| Heading picker | `Words.HeadingPicker` | dropdown with H1/H2/H3/Paragraph levels |
| Link editor | `Words.LinkEditor` | default URL/title form + invalid feedback |
| Form | `Words.HiddenInput` | passthrough hidden JSON input |
| Status | `Words.Status` | idle/error status recipe |
| Text alignment | `Words.CommandButton` | align-left/center/right/justify via `setTextAlign` command |
## Decisiones
@ -96,17 +97,22 @@ rich-text ni wrapper visual reutilizable.
- `Words.LinkEditor` es una parte compuesta real, no markup de demo: Soma aplica
`insertLink/unlink`, Morfo declara `commit-link` y `commit-unlink`, y Eidos
ofrece una UI por defecto que puede reemplazarse con snippet.
- `Words.ToolPopover` centraliza el patron de herramientas flotantes de toolbar:
trigger iconico, popover intrinseco, boton de cierre/cancelacion y guardia de
cierre. `Words.LinkEditor` lo usa para validar URL al intentar cerrar por
outside/Escape sin confirmar.
- `Words.BubbleMenu` es una parte publica compuesta: solo aparece para
- `Words.BubbleMenu` es una parte publica compuesta y **opcional**: no se
incluye por defecto en `<Words>`; el consumidor debe agregarla explicitamente
como `<Words.BubbleMenu>` dentro del snippet `children`. Solo aparece para
seleccion de rango, toma su ancla del rectangulo real de `Selection API` y
preserva la seleccion en `pointerdown` para que los command buttons operen
sobre el texto seleccionado.
- 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
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
`THEME_BASE_RECIPE_TOKENS.words`.
- 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 |
| Tables | diferir V2 |
| 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 BubbleMenu from './words-bubble-menu.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 HiddenInput from './words-hidden-input.svelte';
import Status from './words-status.svelte';
@ -17,7 +17,7 @@ type WordsNamespace = typeof WordsComponent & {
ToolbarGroup: typeof ToolbarGroup;
BubbleMenu: typeof BubbleMenu;
CommandButton: typeof CommandButton;
ToolPopover: typeof ToolPopover;
HeadingPicker: typeof HeadingPicker;
LinkEditor: typeof LinkEditor;
HiddenInput: typeof HiddenInput;
Status: typeof Status;
@ -30,7 +30,7 @@ Words.Toolbar = Toolbar;
Words.ToolbarGroup = ToolbarGroup;
Words.BubbleMenu = BubbleMenu;
Words.CommandButton = CommandButton;
Words.ToolPopover = ToolPopover;
Words.HeadingPicker = HeadingPicker;
Words.LinkEditor = LinkEditor;
Words.HiddenInput = HiddenInput;
Words.Status = Status;
@ -58,7 +58,5 @@ export type {
WordsToolbarGroupProps as ToolbarGroupProps,
WordsToolbarProps as ToolbarProps,
WordsToolbarPreset,
WordsToolPopoverProps as ToolPopoverProps,
WordsToolPopoverSnippetProps,
WordsVariant
} from './types';

@ -11,7 +11,6 @@ import type {
ToolbarGroupProps,
ToolbarProps
} from '$soma/components/words';
import type { ContentProps as PopoverContentProps } from '$uix/eidos/components/popover';
import type {
BubbleMenuSnippetProps as SomaWordsBubbleMenuSnippetProps,
WordsCommandName,
@ -39,38 +38,18 @@ export type WordsToolbarLinkEditorConfig =
part: 'link-editor';
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 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 & {
/** 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">
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 type { LinkEditorSnippetProps } from '$soma/components/words';
import ToolPopover from './words-tool-popover.svelte';
import type { WordsLinkEditorProps } from './types';
let {
@ -13,8 +13,7 @@
}: WordsLinkEditorProps = $props();
let open = $state(false);
let hrefInput = $state<HTMLInputElement | null>(null);
let shouldFocusHref = $state(false);
let formRef = $state<HTMLFormElement | null>(null);
type LinkEditorChildProps = LinkEditorSnippetProps & {
props: Record<string, unknown>;
@ -24,111 +23,108 @@
const { onsubmit, ...restProps } = props;
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>
<Words.LinkEditor {...rest} bind:href bind:title>
{#snippet child(snippetProps)}
<ToolPopover
bind:open
tool="link"
triggerLabel="Edit link"
closeLabel="Close link editor"
onTriggerPointerDown={() => snippetProps.captureSelection()}
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)}
{#snippet child(props)}
{@const sp = props as LinkEditorChildProps}
<Popover bind:open>
<span role="button" tabindex="-1" onpointerdown={() => sp.captureSelection()}>
<Popover.Trigger
aria-label="Edit link"
disabled={sp.disabled || sp.readonly || undefined}
>
{#if customChildren}
{@render customChildren(snippetProps)}
{:else}
<label data-words-link-field aria-label="Link URL">
<span data-words-link-label>
<Link2 size="sm" decorative />
</span>
<input
bind:this={hrefInput}
type="text"
name="href"
inputmode="url"
bind:value={href}
placeholder="Paste URL or /route"
aria-invalid={snippetProps.error ? 'true' : undefined}
oninput={() => snippetProps.clearError()}
/>
</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={snippetProps.disabled || snippetProps.readonly}
onclick={() => {
if (snippetProps.apply(href, title)) close();
}}
>
<Check size="sm" decorative />
</button>
<button
data-words-link-remove
type="button"
aria-label="Remove link"
disabled={!snippetProps.canUnlink}
onclick={() => {
if (snippetProps.unlink()) close();
}}
>
<Unlink size="sm" decorative />
</button>
</div>
{#if snippetProps.error}
<span data-words-link-error>{snippetProps.error}</span>
<Link2 size="sm" decorative />
</Popover.Trigger>
</span>
<Popover.Portal>
<Popover.Content side="bottom" align="center" sideOffset={4}>
<form
bind:this={formRef}
{...formProps(sp.props)}
data-words-link-editor
data-active-link={sp.selectedLink ? '' : undefined}
data-invalid={sp.error ? '' : undefined}
data-disabled={sp.disabled || sp.readonly ? '' : undefined}
data-readonly={sp.readonly ? '' : undefined}
aria-label="Edit link"
onsubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const nextHref = data.get('href');
const nextTitle = data.get('title');
const submittedHref = typeof nextHref === 'string' ? nextHref : href;
const submittedTitle = typeof nextTitle === 'string' ? nextTitle : title;
if (sp.apply(submittedHref, submittedTitle, event.currentTarget)) {
open = false;
}
}}
>
{#if customChildren}
{@render customChildren(sp)}
{:else}
<label data-words-link-field aria-label="Link URL">
<span data-words-link-label>
<Link2 size="sm" decorative />
</span>
<input
type="text"
name="href"
inputmode="url"
bind:value={href}
placeholder="Paste URL or /route"
aria-label="Link URL"
aria-invalid={sp.error ? 'true' : undefined}
oninput={() => sp.clearError()}
onkeydown={(event) => {
if (event.key !== 'Enter') return;
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}
</form>
{/snippet}
</ToolPopover>
<Popover.Close aria-label="Close link editor">
<X size="sm" decorative />
</Popover.Close>
</form>
</Popover.Content>
</Popover.Portal>
</Popover>
{/snippet}
</Words.LinkEditor>

@ -277,95 +277,6 @@
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] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@ -383,6 +294,17 @@
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] {
display: flex;
box-sizing: border-box;

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

@ -11,8 +11,8 @@ export const wordsMorfo = {
content: '#?components.words.content|Editor content',
placeholder: '#?components.words.placeholder|Start writing...',
toolbar: '#?components.words.toolbar|Editor toolbar',
bubbleMenu: '#?components.words.bubbleMenu|Selection toolbar',
linkEditor: '#?components.words.linkEditor|Edit link',
'bubble-menu': '#?components.words.bubble-menu|Selection toolbar',
'link-editor': '#?components.words.link-editor|Edit link',
status: '#?components.words.status|Editor status'
},
events: [
@ -167,7 +167,23 @@ export const wordsMorfo = {
values: ['paragraph', 'heading', 'quote', 'list'],
value: v.propRef('currentBlock'),
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: [
{ attr: 'contenteditable', value: v.propRef('editable') },
@ -275,7 +291,7 @@ export const wordsMorfo = {
{ attr: 'aria-orientation', value: v.literal('horizontal') },
{
attr: 'aria-label',
value: v.translationRef('#?components.words.bubbleMenu|Selection toolbar'),
value: v.translationRef('#?components.words.bubble-menu|Selection toolbar'),
severity: 'recommended'
}
]
@ -320,7 +336,7 @@ export const wordsMorfo = {
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.linkEditor|Edit link'),
value: v.translationRef('#?components.words.link-editor|Edit link'),
severity: 'recommended'
}
]

@ -12,6 +12,9 @@ const onContent = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>
const onCommandButton = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>[2]) =>
semaSelector(wordsMorfo, 'command-button', matchers);
const onLinkEditor = (matchers?: Parameters<typeof semaSelector<typeof wordsMorfo>>[2]) =>
semaSelector(wordsMorfo, 'link-editor', matchers);
export const wordsSema: Sema = {
name: 'words',
cascade: [
@ -33,6 +36,22 @@ export const wordsSema: Sema = {
sound: soundTuning('form.commit.subtle'),
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' }),
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
evita que el `pointerdown` del toolbar contextual colapse la seleccion antes
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
@ -239,9 +249,10 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
- Importador HTML avanzado con tablas, imagenes y HTML arbitrario completo.
- Drop/clipboard avanzado a traves de servicios ActiveUIX.
- Popover flotante de links anclado directamente al rango seleccionado. V1 usa
`LinkEditor` dentro de toolbar/bubble menu con el popover reutilizable de
Eidos.
- Popover flotante de links resuelto en Eidos como `Words.ToolPopover` local
para no depender del `Soma/Popover` compartido mientras ese componente tiene
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
compuestas.
- 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 bubbleProps = $derived.by(() => {
const { onpointerdown, ...props } = mergedProps;
const { onpointerdown, onpointerdowncapture, ...props } = mergedProps;
return props;
});
@ -62,6 +62,11 @@
const handler = (mergedProps as Record<string, unknown>).onpointerdown;
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>
<svelte:window onresize={schedulePosition} onscroll={schedulePosition} />
@ -69,7 +74,11 @@
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...bubbleProps} onpointerdown={callPointerDown}>
<div
{...bubbleProps}
onpointerdowncapture={callPointerDownCapture}
onpointerdown={callPointerDown}
>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -28,20 +28,22 @@
const mergedProps = $derived(mergeProps(restProps, state.props));
let restoredHtml = '';
const contentProps = $derived.by(() => {
const {
onfocus,
onblur,
onkeydown,
onkeyup,
onpointerup,
onselect,
onbeforeinput,
onpaste,
oncompositionstart,
oncompositionupdate,
oncompositionend,
...props
} = mergedProps;
const {
onfocus,
onblur,
onkeydown,
onkeyup,
onpointerdown,
onpointerup,
onclick,
onselect,
onbeforeinput,
onpaste,
oncompositionstart,
oncompositionupdate,
oncompositionend,
...props
} = mergedProps;
return props;
});
@ -87,7 +89,9 @@
onblur={(event) => callEventHandler('onblur', event)}
onkeydown={(event) => callEventHandler('onkeydown', event)}
onkeyup={(event) => callEventHandler('onkeyup', event)}
onpointerdown={(event) => callEventHandler('onpointerdown', event)}
onpointerup={(event) => callEventHandler('onpointerup', event)}
onclick={(event) => callEventHandler('onclick', event)}
onselect={(event) => callEventHandler('onselect', event)}
onbeforeinput={(event) => callEventHandler('onbeforeinput', event)}
onpaste={(event) => callEventHandler('onpaste', event)}
@ -104,7 +108,9 @@
onblur={(event) => callEventHandler('onblur', event)}
onkeydown={(event) => callEventHandler('onkeydown', event)}
onkeyup={(event) => callEventHandler('onkeyup', event)}
onpointerdown={(event) => callEventHandler('onpointerdown', event)}
onpointerup={(event) => callEventHandler('onpointerup', event)}
onclick={(event) => callEventHandler('onclick', event)}
onselect={(event) => callEventHandler('onselect', event)}
onbeforeinput={(event) => callEventHandler('onbeforeinput', 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 {
clearFormatting,
deleteBackward,
@ -14,6 +14,7 @@ import {
insertText,
replaceDocument,
setBlock,
setTextAlign,
toggleList,
toggleMark,
unlink,
@ -43,6 +44,7 @@ export type WordsCommand =
| { type: 'toggleMark'; mark: WordsMark }
| { type: 'clearFormatting' }
| { type: 'setBlock'; block: Exclude<WordsBlockType, 'list'>; level?: WordsHeadingLevel }
| { type: 'setTextAlign'; align: WordsTextAlign }
| { type: 'toggleList'; kind: WordsListKind }
| { type: 'insertLink'; href: string; title?: string }
| { type: 'unlink' }
@ -81,6 +83,8 @@ export function applyWordsCommand(
return clearFormatting(state);
case 'setBlock':
return setBlock(state, command.block, { level: command.level });
case 'setTextAlign':
return setTextAlign(state, command.align);
case 'toggleList':
return toggleList(state, command.kind);
case 'insertLink':

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

@ -13,10 +13,12 @@ import {
beginWordsComposition,
createCollapsedSelection,
createEmptyWordsDocument,
createHeading,
createLink,
createList,
createListItem,
createParagraph,
createQuote,
createText,
createWordsHistory,
createWordsState,
@ -909,4 +911,306 @@ describe('Words engine', () => {
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,
isWordsListKind,
isWordsMark,
isWordsTextAlign,
normalizeMarks,
sameMarks,
type WordsBlock,
@ -86,18 +87,19 @@ function normalizeBlock(
}
const source = block as Record<string, unknown>;
const textAlign = isWordsTextAlign(source.textAlign) ? source.textAlign : undefined;
switch (source.type) {
case 'paragraph':
return createParagraph(normalizeInlineChildren(source.children, path, issues));
return createParagraph(normalizeInlineChildren(source.children, path, issues), textAlign);
case 'heading': {
const level = isWordsHeadingLevel(source.level) ? source.level : 1;
if (!isWordsHeadingLevel(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':
return createQuote(normalizeInlineChildren(source.children, path, issues));
return createQuote(normalizeInlineChildren(source.children, path, issues), textAlign);
case 'list': {
const kind = isWordsListKind(source.kind) ? source.kind : 'unordered';
if (!isWordsListKind(source.kind)) {

@ -16,7 +16,8 @@ import {
type WordsInline,
type WordsListItem,
type WordsListKind,
type WordsMark
type WordsMark,
type WordsTextAlign
} from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize';
import {
@ -323,12 +324,12 @@ export function setBlock(
changed = true;
for (const [itemIndex, item] of block.children.entries()) {
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;
}
const nextBlock = blockWithChildren(type, block.children, options.level);
const nextBlock = blockWithChildren(type, block.children, options.level, block.textAlign);
blockIndexMap.set(index, children.length);
changed = changed || !sameBlockShape(block, 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 {
const selection = state.selection;
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 {
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));
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;
return index;
}
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));
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;
return index;
}
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(
@ -1262,17 +1332,25 @@ function findAncestorPath(
function blockWithChildren(
type: Exclude<WordsBlockType, 'list'>,
children: readonly WordsInline[],
level: WordsHeadingLevel | undefined
level: WordsHeadingLevel | undefined,
textAlign: WordsTextAlign | undefined
): WordsBlock {
if (type === 'heading') return createHeading(level ?? 1, children);
if (type === 'quote') return createQuote(children);
return createParagraph(children);
if (type === 'heading') return createHeading(level ?? 1, children, textAlign);
if (type === 'quote') return createQuote(children, textAlign);
return createParagraph(children, textAlign);
}
function blockTextAlign(block: WordsBlock): WordsTextAlign | undefined {
return block.type === 'list' ? undefined : block.textAlign;
}
function sameBlockShape(a: WordsBlock, b: WordsBlock): boolean {
if (a.type !== b.type) return false;
if (a.type === 'heading' && b.type === 'heading') return a.level === b.level;
return true;
if (a.type === 'heading' && b.type === 'heading') {
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 {

@ -74,7 +74,8 @@ function renderWordsBlock(block: WordsBlock, path: WordsPath): WordsRenderElemen
attrs: {
[WORDS_NODE_ATTR]: 'block',
[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)
};

@ -13,7 +13,8 @@ import {
type WordsDocument,
type WordsInline,
type WordsListItem,
type WordsMark
type WordsMark,
type WordsTextAlign
} from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize';
@ -102,15 +103,17 @@ function serializeBlockHtml(block: WordsBlock): string {
return `<${tag}>${children}</${tag}>`;
}
const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : '';
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') {
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 {
@ -263,14 +266,30 @@ function htmlNodesToBlocks(nodes: readonly HtmlNode[]): WordsBlock[] {
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[] {
if (node.name === 'h1' || node.name === 'h2' || node.name === 'h3') {
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') {
return [htmlListToBlock(node)];
@ -282,7 +301,7 @@ function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
return htmlNodesToBlocks(node.children);
}
return [createParagraph(htmlNodesToInlines(node.children))];
return [createParagraph(htmlNodesToInlines(node.children), textAlignFromStyle(node))];
}
function htmlListToBlock(node: HtmlElementNode): WordsBlock {

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

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

@ -7,7 +7,7 @@ import { createActiveDom } from '$adom';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.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';

@ -8,7 +8,7 @@ import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { FieldProvider } from '../field/field-provider.svelte';
import { FieldProvider } from '../field';
import {
WordsBubbleMenuProvider,
@ -21,6 +21,7 @@ import {
import type { WordsCommandName } from './types';
import {
createEmptyWordsDocument,
createLink,
createList,
createListItem,
createParagraph,
@ -378,6 +379,84 @@ describe('WordsProvider', () => {
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', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
@ -644,7 +723,15 @@ describe('WordsProvider', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
const toolbarEl = document.createElement('div');
const toolbarButtonEl = document.createElement('button');
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);
const opts = wordsOpts(root, {
version: 1,
@ -705,6 +792,29 @@ describe('WordsProvider', () => {
expect(result.bubble.props.hidden).toBeUndefined();
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();
dom.dispose();
});

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

@ -93,15 +93,18 @@
minimal: [['bold', 'italic', 'underline']],
formatting: [
['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: [
['undo', 'redo'],
['bold', 'italic', 'underline', 'strike', 'code'],
[
'paragraph',
{ command: 'heading', level: 1, label: 'Heading 1' },
'heading-picker',
'quote',
'align-left',
'align-center',
'align-right',
'align-justify',
'unordered-list',
'ordered-list',
'check-list'
@ -111,11 +114,7 @@
};
const toolbarCustomGroups: WordsToolbarGroupConfig[] = [
['undo', 'redo'],
[
{ command: 'heading', level: 1, label: 'Title' },
{ command: 'paragraph', label: 'Body' },
'quote'
],
['heading-picker', 'quote', 'align-left', 'align-center', 'align-right', 'align-justify'],
['bold', 'italic', 'code'],
['link-editor', 'unlink', 'commit']
];
@ -132,7 +131,7 @@
let commitOnBlur = $state(true);
let toolbarChoice = $state<ToolbarChoice>('full');
let showLinkEditor = $state(true);
let showBubbleMenu = $state(true);
let showBubbleMenu = $state(false);
let showStatus = $state(true);
let orientation = $state<WordsToolbarOrientation>('horizontal');
let size = $state<WordsSize>('md');
@ -271,22 +270,10 @@
note: 'selection anchored toolbar'
},
{
selector: '[data-words-tool-popover-trigger]',
source: 'eidos',
kind: 'eidos',
note: 'toolbar tool disclosure'
},
{
selector: '[data-words-tool-popover]',
selector: '[data-words-heading-picker]',
source: 'eidos',
kind: 'eidos',
note: 'shared floating tool shell'
},
{
selector: '[data-words-tool-popover-close]',
source: 'eidos',
kind: 'eidos',
note: 'shared close affordance'
note: 'heading level dropdown'
},
{
selector: '[data-words-link-editor]',
@ -558,9 +545,12 @@
}
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()}`);
}
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 label =
typeof item === 'string' ? commandLabel(command) : (item.label ?? commandLabel(command));
@ -1080,9 +1070,8 @@
></tr
>
<tr
><td class="name">ToolPopover</td><td>eidos</td><td class="default">optional</td><td
>Reusable toolbar tool shell: icon trigger, floating panel, close button and dismiss
guard.</td
><td class="name">HeadingPicker</td><td>eidos</td><td class="default">optional</td><td
>Heading level dropdown: H1, H2, H3, Paragraph.</td
></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
los attrs de Morfo/Soma.
- La seleccion se muestra como `none`, `collapsed` o `range`.
- El cambio de preset remonta el editor para reflejar cambios externos del
documento hasta que Soma incorpore sincronizacion controlada de `value`.
- El cambio de preset/import remonta el editor solo como reset explicito de la
demo; Soma ya sincroniza cambios externos de `value` y `selection` sin
depender de remount.

Loading…
Cancel
Save

Powered by TurnKey Linux.