From 0804749147bce58a30e2a26aaa871060b17a6d6f Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 25 May 2026 03:58:35 +0200 Subject: [PATCH] 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 --- src/uix/eidos/components/words/README.md | 57 +++- src/uix/eidos/components/words/index.ts | 8 +- src/uix/eidos/components/words/types.ts | 41 +-- .../words/words-heading-picker.svelte | 74 +++++ .../components/words/words-link-editor.svelte | 206 ++++++------ src/uix/eidos/components/words/words.css | 100 +----- src/uix/eidos/components/words/words.svelte | 74 +++-- src/uix/morfo/components/words.ts | 26 +- src/uix/sema/components/words.ts | 19 ++ src/uix/soma/components/words/README.md | 17 +- .../words/components/words-bubble-menu.svelte | 13 +- .../words/components/words-content.svelte | 34 +- .../soma/components/words/engine/commands.ts | 6 +- .../soma/components/words/engine/document.ts | 26 +- .../components/words/engine/engine.test.ts | 304 ++++++++++++++++++ .../soma/components/words/engine/normalize.ts | 8 +- .../components/words/engine/operations.ts | 98 +++++- .../soma/components/words/engine/render.ts | 3 +- .../components/words/engine/serialize-html.ts | 33 +- .../words/test/words-content-harness.svelte | 4 +- src/uix/soma/components/words/types.ts | 14 +- .../words/words-content.svelte.test.ts | 2 +- .../words/words-provider.svelte.test.ts | 112 ++++++- .../components/words/words-provider.svelte.ts | 177 ++++++++-- web/routes/uix/components/words/+page.svelte | 43 +-- web/routes/uix/components/words/README.md | 5 +- 26 files changed, 1133 insertions(+), 371 deletions(-) create mode 100644 src/uix/eidos/components/words/words-heading-picker.svelte diff --git a/src/uix/eidos/components/words/README.md b/src/uix/eidos/components/words/README.md index 2c5f1d559..3ffe49963 100644 --- a/src/uix/eidos/components/words/README.md +++ b/src/uix/eidos/components/words/README.md @@ -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 ``; el consumidor debe agregarla explicitamente + como `` 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 `
` en el modelo JSON | + + diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index 6bfc00027..cd05c6d64 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -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'; diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts index d79d97f8e..405b6d501 100644 --- a/src/uix/eidos/components/words/types.ts +++ b/src/uix/eidos/components/words/types.ts @@ -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' */ diff --git a/src/uix/eidos/components/words/words-heading-picker.svelte b/src/uix/eidos/components/words/words-heading-picker.svelte new file mode 100644 index 000000000..93fffef6e --- /dev/null +++ b/src/uix/eidos/components/words/words-heading-picker.svelte @@ -0,0 +1,74 @@ + + + + + {#if currentBlock === 'heading' && currentHeadingLevel === 1} + + {:else if currentBlock === 'heading' && currentHeadingLevel === 2} + + {:else if currentBlock === 'heading' && currentHeadingLevel === 3} + + {:else} + + {/if} + + + +
+ (open = false)} + > + + + (open = false)} + > + + + (open = false)} + > + + + (open = false)} + > + + +
+
+
+
diff --git a/src/uix/eidos/components/words/words-link-editor.svelte b/src/uix/eidos/components/words/words-link-editor.svelte index 78346fb4c..9abe82493 100644 --- a/src/uix/eidos/components/words/words-link-editor.svelte +++ b/src/uix/eidos/components/words/words-link-editor.svelte @@ -1,8 +1,8 @@ - {#snippet child(snippetProps)} - snippetProps.captureSelection()} - onBeforeDismiss={() => validateBeforeDismiss(snippetProps)} - onCancel={() => resetDraft(snippetProps)} - > - {#snippet trigger()} - - {/snippet} - {#snippet children({ close })} -
callSubmit(snippetProps.props, event)} + {#snippet child(props)} + {@const sp = props as LinkEditorChildProps} + + sp.captureSelection()}> + - {#if customChildren} - {@render customChildren(snippetProps)} - {:else} - - -
- - -
- {#if snippetProps.error} - {snippetProps.error} + +
+
+ + + { + 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} + + +
+ + +
+ {#if sp.error} + {sp.error} + {/if} {/if} - {/if} - - {/snippet} -
+ + + + + + + {/snippet}
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 3f86ef301..c4fea0732 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -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; diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index 8a513e106..a49690891 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -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'); } @@ -134,10 +155,15 @@ {#each toolbarGroups as group} {#each group as item} - {#if isLinkEditorConfig(item)} - - {:else} - {@const command = normalizeCommand(item)} + {#if isLinkEditorConfig(item)} + + {:else if isHeadingPickerConfig(item)} + + {:else} + {@const command = normalizeCommand(item)} {:else if command.command === 'paragraph'} - {:else if command.command === 'heading'} - - {:else if command.command === 'quote'} + {:else if command.command === 'heading'} + + {:else if command.command === 'quote'} {:else if command.command === 'unordered-list'} {:else if command.command === 'ordered-list'} - {:else if command.command === 'check-list'} - - {:else if command.command === 'unlink'} - - {:else if command.command === 'commit'} - - {:else} - - {/if} + {:else if command.command === 'check-list'} + + {:else if command.command === 'align-left'} + + {:else if command.command === 'align-center'} + + {:else if command.command === 'align-right'} + + {:else if command.command === 'align-justify'} + + {:else if command.command === 'unlink'} + + {:else if command.command === 'commit'} + + {:else} + + {/if} {/if} {/each} diff --git a/src/uix/morfo/components/words.ts b/src/uix/morfo/components/words.ts index 7be385ebb..ca1c33954 100644 --- a/src/uix/morfo/components/words.ts +++ b/src/uix/morfo/components/words.ts @@ -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' } ] diff --git a/src/uix/sema/components/words.ts b/src/uix/sema/components/words.ts index cd474790e..7c61ab378 100644 --- a/src/uix/sema/components/words.ts +++ b/src/uix/sema/components/words.ts @@ -12,6 +12,9 @@ const onContent = (matchers?: Parameters> const onCommandButton = (matchers?: Parameters>[2]) => semaSelector(wordsMorfo, 'command-button', matchers); +const onLinkEditor = (matchers?: Parameters>[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', { diff --git a/src/uix/soma/components/words/README.md b/src/uix/soma/components/words/README.md index d7892a5cf..0b9404612 100644 --- a/src/uix/soma/components/words/README.md +++ b/src/uix/soma/components/words/README.md @@ -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 diff --git a/src/uix/soma/components/words/components/words-bubble-menu.svelte b/src/uix/soma/components/words/components/words-bubble-menu.svelte index d1ffefd08..7e2208e9a 100644 --- a/src/uix/soma/components/words/components/words-bubble-menu.svelte +++ b/src/uix/soma/components/words/components/words-bubble-menu.svelte @@ -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).onpointerdown; if (typeof handler === 'function') handler(event); } + + function callPointerDownCapture(event: PointerEvent) { + const handler = (mergedProps as Record).onpointerdowncapture; + if (typeof handler === 'function') handler(event); + } @@ -69,7 +74,11 @@ {#if child} {@render child({ ...state.snippetProps, props: mergedProps })} {:else} -
+
{@render children?.(state.snippetProps)}
{/if} diff --git a/src/uix/soma/components/words/components/words-content.svelte b/src/uix/soma/components/words/components/words-content.svelte index 936b655de..5de95528c 100644 --- a/src/uix/soma/components/words/components/words-content.svelte +++ b/src/uix/soma/components/words/components/words-content.svelte @@ -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)} diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index 4d64e79b2..bbd3251ab 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -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; 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': diff --git a/src/uix/soma/components/words/engine/document.ts b/src/uix/soma/components/words/engine/document.ts index f97f9968e..6f22c4f3e 100644 --- a/src/uix/soma/components/words/engine/document.ts +++ b/src/uix/soma/components/words/engine/document.ts @@ -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'; } diff --git a/src/uix/soma/components/words/engine/engine.test.ts b/src/uix/soma/components/words/engine/engine.test.ts index a0ffceec1..1122e0a29 100644 --- a/src/uix/soma/components/words/engine/engine.test.ts +++ b/src/uix/soma/components/words/engine/engine.test.ts @@ -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( + '

Title

Body

  1. One
  2. Two
' + ); + }); + + 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 }); + }); }); diff --git a/src/uix/soma/components/words/engine/normalize.ts b/src/uix/soma/components/words/engine/normalize.ts index 9488d9144..fa2f7db3f 100644 --- a/src/uix/soma/components/words/engine/normalize.ts +++ b/src/uix/soma/components/words/engine/normalize.ts @@ -12,6 +12,7 @@ import { isWordsHeadingLevel, isWordsListKind, isWordsMark, + isWordsTextAlign, normalizeMarks, sameMarks, type WordsBlock, @@ -86,18 +87,19 @@ function normalizeBlock( } const source = block as Record; + 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)) { diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index 686d15be7..5512708f8 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -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, 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 { diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index aa5c1dbe3..715d5104d 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -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) }; diff --git a/src/uix/soma/components/words/engine/serialize-html.ts b/src/uix/soma/components/words/engine/serialize-html.ts index 9697628f7..c4c7dc551 100644 --- a/src/uix/soma/components/words/engine/serialize-html.ts +++ b/src/uix/soma/components/words/engine/serialize-html.ts @@ -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}`; } + const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : ''; + if (block.type === 'heading') { - return `${serializeInlinesHtml(block.children)}`; + return `${serializeInlinesHtml(block.children)}`; } if (block.type === 'quote') { - return `
${serializeInlinesHtml(block.children)}
`; + return `${serializeInlinesHtml(block.children)}`; } - return `

${serializeInlinesHtml(block.children)}

`; + return `${serializeInlinesHtml(block.children)}

`; } 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 { diff --git a/src/uix/soma/components/words/test/words-content-harness.svelte b/src/uix/soma/components/words/test/words-content-harness.svelte index b00221cd1..c0b5f8e4e 100644 --- a/src/uix/soma/components/words/test/words-content-harness.svelte +++ b/src/uix/soma/components/words/test/words-content-harness.svelte @@ -22,8 +22,8 @@ {#if emptyChildren} - + {:else} - + {/if} diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index dcfdbae46..11b0f61b8 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -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< diff --git a/src/uix/soma/components/words/words-content.svelte.test.ts b/src/uix/soma/components/words/words-content.svelte.test.ts index b5eda6487..e4549baa2 100644 --- a/src/uix/soma/components/words/words-content.svelte.test.ts +++ b/src/uix/soma/components/words/words-content.svelte.test.ts @@ -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'; diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index 4c2a4cedf..640c6508c 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -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(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(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(); }); diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 5d4bc01c6..fd5567d2b 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -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) => { - 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) => { + 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) => { 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)), diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index e5f1d0c5d..9ce7d13ea 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -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('full'); let showLinkEditor = $state(true); - let showBubbleMenu = $state(true); + let showBubbleMenu = $state(false); let showStatus = $state(true); let orientation = $state('horizontal'); let size = $state('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 [' ']; + } const command = typeof item === 'string' ? item : item.command; const label = typeof item === 'string' ? commandLabel(command) : (item.label ?? commandLabel(command)); @@ -1080,9 +1070,8 @@ > ToolPopovereidosoptionalReusable toolbar tool shell: icon trigger, floating panel, close button and dismiss - guard.HeadingPickereidosoptionalHeading level dropdown: H1, H2, H3, Paragraph.