i18n(words): localize the block-handle menu + mark rich paste in the doctrine

Two loose ends from the rich-paste work:

- The block drag-handle menu was hard-coded English (Inspect / Move up /
  Duplicate / Insert below / Paste below / Delete) inside a Spanish editor.
  Route every label through the langs system like the inspector already
  does: new langs-menu.ts (idlangref catalog + en/es bundle), registered by
  the eidos <Words> wrapper (`components.words.menu`); the gutter reads them
  via ActiveEidos.require().langs.ts(). Verified the menu renders in Spanish
  (Inspeccionar / Subir / Bajar / Duplicar / Insertar debajo / Pegar debajo
  / Eliminar) under the ES locale.

- README serialization doctrine: the "Si algun dia hace falta pegar HTML
  externo" section ANTICIPATED exactly this feature (per-block parseHtml,
  clipboard best-effort, persistence stays JSON). Mark it IMPLEMENTED
  (2026-06-02) with the actual modules, fix the table's HTML Import cell,
  and correct the now-stale "paste HTML is rejected" line — HTML now parses
  to blocks; only content-free HTML logs unsupported-html. importContent
  unchanged (JSON + plain text); HTML enters only via paste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 75556338db
commit 3a845f0454

@ -0,0 +1,30 @@
/**
* Translation refs for the block-handle action menu (`WordsBlockGutter`).
*
* Same `idlangref` pattern as `langs-inspector.ts`: each entry is
* `#?components.words.menu.{key}|fallback`. `eidos.langs.ts(ref)` returns
* the localized string when `WORDS_MENU_BUNDLE` is registered (the eidos
* `<Words>` wrapper extends it on mount), else the English fallback.
*/
export const WORDS_MENU_LANGS = {
ACTIONS: '#?components.words.menu.actions|Block actions',
INSPECT: '#?components.words.menu.inspect|Inspect',
MOVE_UP: '#?components.words.menu.move-up|Move up',
MOVE_DOWN: '#?components.words.menu.move-down|Move down',
DUPLICATE: '#?components.words.menu.duplicate|Duplicate',
INSERT_BELOW: '#?components.words.menu.insert-below|Insert below',
PASTE_BELOW: '#?components.words.menu.paste-below|Paste below',
DELETE: '#?components.words.menu.delete|Delete'
} as const;
export const WORDS_MENU_BUNDLE = {
actions: { en: 'Block actions', es: 'Acciones del bloque' },
inspect: { en: 'Inspect', es: 'Inspeccionar' },
'move-up': { en: 'Move up', es: 'Subir' },
'move-down': { en: 'Move down', es: 'Bajar' },
duplicate: { en: 'Duplicate', es: 'Duplicar' },
'insert-below': { en: 'Insert below', es: 'Insertar debajo' },
'paste-below': { en: 'Paste below', es: 'Pegar debajo' },
delete: { en: 'Delete', es: 'Eliminar' }
};

@ -19,6 +19,7 @@
* blur / commit / refocus cycle.
*/
import type { ActiveDom } from '$adom';
import { ActiveEidos } from '$uix/eidos';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { GripVertical } from '$uix/eidos/components/icon';
import {
@ -27,6 +28,7 @@
type WordsBlockMenuEntry
} from '$soma/components/words';
import { pickImageFile } from './words-image-file';
import { WORDS_MENU_LANGS as L } from './langs-menu';
let {
api,
@ -38,6 +40,11 @@
dom: ActiveDom;
} = $props();
// Localized labels for the block-action menu (the bundle is registered by
// the eidos `<Words>` wrapper on mount).
const eidos = ActiveEidos.require();
const t = (ref: string) => eidos.langs.ts(ref);
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let index = $state(-1);
@ -330,26 +337,26 @@
<circle cx="10.5" cy="12.5" r="1.35" />
</svg>
{/snippet}
Block actions
{t(L.ACTIONS)}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
<DropdownMenu.Item onSelect={inspect}>Inspect</DropdownMenu.Item>
<DropdownMenu.Item onSelect={inspect}>{t(L.INSPECT)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>Move up</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveDown}>Move down</DropdownMenu.Item>
<DropdownMenu.Item onSelect={duplicate}>Duplicate</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>{t(L.MOVE_UP)}</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveDown}>{t(L.MOVE_DOWN)}</DropdownMenu.Item>
<DropdownMenu.Item onSelect={duplicate}>{t(L.DUPLICATE)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Insert below</DropdownMenu.SubTrigger>
<DropdownMenu.SubTrigger>{t(L.INSERT_BELOW)}</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent data-words-block-handle-menu>
{#each inserts as ins (ins.id)}
<DropdownMenu.Item onSelect={() => handleInsert(ins)}>{ins.label}</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item onSelect={paste}>Paste below</DropdownMenu.Item>
<DropdownMenu.Item onSelect={paste}>{t(L.PASTE_BELOW)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={remove}>Delete</DropdownMenu.Item>
<DropdownMenu.Item onSelect={remove}>{t(L.DELETE)}</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>

@ -23,6 +23,7 @@
import WordsBubble from './words-bubble.svelte';
import WordsInspector from './words-inspector.svelte';
import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
import { WORDS_MENU_BUNDLE } from './langs-menu';
import type { WordsProps } from './types';
let {
@ -52,6 +53,7 @@
// applied AFTER mount still win (the engine warns on leaf
// overwrites in DEV).
eidos.langs.extend('components.words.inspector', WORDS_INSPECTOR_BUNDLE);
eidos.langs.extend('components.words.menu', WORDS_MENU_BUNDLE);
});
let contentEl = $state<HTMLDivElement | null>(null);

@ -38,7 +38,7 @@ empezar hasta cerrar contrato Morfo y alcance V1.
| ------------ | ---------------------------------- | ----------------------------------------- | ----------------- |
| **JSON** | `exportContent('json')` | `importContent` + `validateWordsDocument` | si (es el modelo) |
| Texto plano | si | `parseWordsPlainText` | no (solo texto) |
| **HTML** | `serializeHtml` (semantico limpio) | — | n/a |
| **HTML** | `serializeHtml` (semantico limpio) | **paste** (`parseWordsHtml`, best-effort) | n/a |
| **Markdown** | `serializeMarkdown` (GFM) | — | n/a |
### Por que export-only (no roundtrip)
@ -58,17 +58,26 @@ Consecuencias deliberadas:
- `serializeMarkdown` es lo mas lossy: sin color ni visual props, y las celdas
GFM son inline-only — desde P5m las celdas llevan bloques, que markdown no
puede representar (se aplanan con `<br>`). MD es proyeccion, no roundtrip.
- El import solo acepta **JSON** (via `validateWordsDocument`, que protege los
`$derived` de un doc invalido) y **texto plano** (`parseWordsPlainText`).
### Si algun dia hace falta _pegar_ HTML externo
No se construye "roundtrip de persistencia". Se anade un `parseHtml`
**por-bloque, simetrico al `toHtml` de cada spec** (el patron `parseDOM` /
`importDOM` de ProseMirror/Lexical), declarado como **interop best-effort de
portapapeles**, explicitamente lossy fuera del schema. La persistencia sigue
siendo JSON; el HTML solo entra por el paste. Markdown import: no merece la pena
(es lo mas lossy).
- `importContent` solo acepta **JSON** (via `validateWordsDocument`, que protege
los `$derived` de un doc invalido) y **texto plano** (`parseWordsPlainText`).
El **paste** del portapapeles SI acepta HTML (camino aparte — ver abajo).
### Pegar HTML externo — IMPLEMENTADO (2026-06-02)
Lo anticipado aqui ya existe. `parseWordsHtml` (`engine/operations/parse-html.ts`)
es el inverso de `serializeHtml`: un parser **best-effort de portapapeles**,
explicitamente lossy fuera del schema (el patron `parseDOM` / `importDOM` de
ProseMirror/Lexical). Mapea `p` / `h1-6` / `blockquote` / `pre` / listas / `hr` /
`img` / `table` + marcas (negrita/cursiva/subrayado/tachado/codigo, color y
fondo) y enlaces; URLs saneadas. `insertBlocks`
(`engine/operations/insert-blocks.ts`) los inserta en el caret — o, con indice
explicito, debajo de un bloque (el "Paste below" del menu del handle). El
provider `onpaste` parsea el HTML en un documento **INERTE**
(`implementation.createHTMLDocument` del active-dom: no ejecuta scripts ni carga
recursos).
La persistencia sigue siendo JSON; el HTML SOLO entra por el paste — **NO** por
`importContent`. Markdown import: sigue sin merecer la pena (es lo mas lossy).
## Submodulos propuestos
@ -230,8 +239,9 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
- `FindReplace` mantiene query, matches, regex validada, navegacion,
find-in-selection, replace-current, replace-all y `replacePreserveCase` para
adaptar el replacement al casing del match (`word`, `Word`, `WORD`).
- Paste HTML sin texto plano se rechaza y se reporta por el logger UIX como
`soma.words`.
- Paste HTML se parsea a bloques (`parseWordsHtml`); solo el HTML sin contenido
extraible Y sin texto plano se reporta como `unsupported-html` por el logger
UIX `soma.words`.
- IME/composition queda conectado al Provider: `compositionend` aplica el
comando final.
- `deleteWord*` y `deleteLine*` tienen comandos propios; los `beforeinput`

Loading…
Cancel
Save

Powered by TurnKey Linux.