uix(palabras): F-A rework · estructura COMPARTIDA (Camino A)

El modelo anterior (N documentos independientes por idioma) permitia que la
ESTRUCTURA divergiera: borrar un bloque en un idioma no lo borraba en los
demas. Requisito del usuario: la estructura es unica; solo el TEXTO es por
idioma. Rehecho a UN solo documento con el contenido por-idioma en el bloque.

Modelo (correcto por construccion):
- `block.i18n?: Record<LangCode, Inline[]>` guarda el contenido de los idiomas
  INACTIVOS; `block.children` = el activo. Hay UN arbol → las ops estructurales
  (borrar/insertar/mover/tipo/tabla) aplican a TODOS los idiomas gratis, y el
  undo es automatico (el i18n vive en el doc). `doc.activeLang` registra cual
  esta en `children` (normalize lo preserva como `scheme`). El `i18n` sobrevive
  normalize + ops via spread (validate no rechaza props extra).
- Cambiar de idioma = swap `children <-> i18n` en el arbol (switchDocumentLanguage).
- Solo el texto INLINE es por idioma; alt/caption de imagen, code.language y
  callout.title quedan COMPARTIDOS en v1.

Contrato: `value` vuelve a `PalabrasDocument` (uno solo, no un mapa). Helpers
`foldPalabrasLanguages` (construye el doc desde docs-gemelos por idioma) y
`projectPalabrasLanguage` (extrae un idioma para render/export). El switcher y
la ubicacion (chunks 2-3) intactos.

engine/i18n.ts NUEVO. Provider reworkeado (fuera langHistories/map). Tests
reescritos: incluye el test CLAVE (borrar aplica a todos los idiomas) + insertar
= vacio-a-traducir en los demas + project round-trip. 60/60 provider · 570/570
motor · 0 errores de tipo. Verificado Playwright: borrar 1 bloque en es (4->3) →
en tambien 3, contenido es<->en distinto, 0 errores de consola.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 9a7a438a96
commit 461be12d91

@ -0,0 +1,239 @@
/**
* Multilingual content model (F-A) — ONE document, per-language inline content.
*
* The block STRUCTURE is shared across languages by construction (there is a
* single tree). Only the inline content of text-leaf blocks varies per language:
* `block.children` holds the ACTIVE language, `block.i18n[lang]` holds the rest.
* Structural edits (insert / delete / move / type / table) therefore apply to
* ALL languages for free (the `i18n` bag rides with the block through the engine's
* spread-based ops), and history is automatic (the `i18n` bag lives in the doc).
*
* v1 scope: only INLINE text is per-language. Block-level string props that are
* also text (image `alt` / `caption`, `code.language`, `callout.title`) stay
* SHARED across languages.
*/
import type { PalabrasBlock, PalabrasDocument, PalabrasInline } from './types';
/** A block whose `children` is inline text — the unit that carries per-language content. */
type TextLeaf = {
readonly children: readonly PalabrasInline[];
readonly i18n?: Record<string, readonly PalabrasInline[]>;
};
const EMPTY_INLINE: readonly PalabrasInline[] = [{ type: 'text', text: '' }];
/** Whether inline content has any non-whitespace text. */
function inlinesHaveText(inlines: readonly PalabrasInline[]): boolean {
for (const node of inlines) {
if (node.type === 'text' && node.text.trim().length > 0) return true;
if (node.type === 'link' && inlinesHaveText(node.children)) return true;
}
return false;
}
/** Recurse the tree, applying `fn` to every text-leaf block; containers recurse. */
function mapLeaves(block: PalabrasBlock, fn: (leaf: TextLeaf) => TextLeaf): PalabrasBlock {
switch (block.type) {
case 'list':
return { ...block, items: block.items.map((it) => fn(it) as typeof it) };
case 'table':
return {
...block,
rows: block.rows.map((row) => ({
...row,
cells: row.cells.map((cell) => ({
...cell,
children: cell.children.map((b) => mapLeaves(b, fn))
}))
}))
};
case 'columns':
return {
...block,
columns: block.columns.map((col) => ({
...col,
children: col.children.map((b) => mapLeaves(b, fn))
}))
};
case 'callout':
return { ...block, children: block.children.map((b) => mapLeaves(b, fn)) };
case 'paragraph':
case 'heading':
case 'quote':
case 'code':
return fn(block) as PalabrasBlock;
default:
return block; // image / divider / plugin atoms — no per-language inline content
}
}
/** Parallel variant of `mapLeaves`: recurses `block` alongside structural `twins`
* (same tree shape), handing each leaf its twin leaves. For `foldPalabrasLanguages`. */
function mapLeavesZip(
block: PalabrasBlock,
twins: readonly (PalabrasBlock | undefined)[],
fn: (leaf: TextLeaf, twinLeaves: readonly (TextLeaf | undefined)[]) => TextLeaf
): PalabrasBlock {
switch (block.type) {
case 'list':
return {
...block,
items: block.items.map(
(it, i) =>
fn(
it,
twins.map((t) => (t as typeof block | undefined)?.items?.[i] as TextLeaf | undefined)
) as typeof it
)
};
case 'table':
return {
...block,
rows: block.rows.map((row, ri) => ({
...row,
cells: row.cells.map((cell, ci) => ({
...cell,
children: cell.children.map((b, bi) =>
mapLeavesZip(
b,
twins.map(
(t) => (t as typeof block | undefined)?.rows?.[ri]?.cells?.[ci]?.children?.[bi]
),
fn
)
)
}))
}))
};
case 'columns':
return {
...block,
columns: block.columns.map((col, ci) => ({
...col,
children: col.children.map((b, bi) =>
mapLeavesZip(
b,
twins.map((t) => (t as typeof block | undefined)?.columns?.[ci]?.children?.[bi]),
fn
)
)
}))
};
case 'callout':
return {
...block,
children: block.children.map((b, bi) =>
mapLeavesZip(
b,
twins.map((t) => (t as typeof block | undefined)?.children?.[bi]),
fn
)
)
};
case 'paragraph':
case 'heading':
case 'quote':
case 'code':
return fn(block, twins as readonly (TextLeaf | undefined)[]) as PalabrasBlock;
default:
return block;
}
}
/**
* Swap the active language of a document: stash the current active content
* (`from`) into each leaf's `i18n`, load the `to` language's content into
* `children` (empty when untranslated). Updates `doc.activeLang`.
*/
export function switchDocumentLanguage(
doc: PalabrasDocument,
from: string,
to: string
): PalabrasDocument {
if (from === to) return doc.activeLang === to ? doc : { ...doc, activeLang: to };
const swap = (leaf: TextLeaf): TextLeaf => {
const i18n: Record<string, readonly PalabrasInline[]> = { ...(leaf.i18n ?? {}) };
i18n[from] = leaf.children;
const next = i18n[to] ?? EMPTY_INLINE;
delete i18n[to];
const out: Record<string, unknown> = { ...leaf, children: next };
if (Object.keys(i18n).length) out.i18n = i18n;
else delete out.i18n;
return out as TextLeaf;
};
return {
...doc,
children: doc.children.map((b) => mapLeaves(b, swap)),
activeLang: to
};
}
/**
* Extract a single language as a clean monolingual `PalabrasDocument` (strips
* `i18n` / `activeLang`). Use to render or export ONE language of a multilingual
* document. Untranslated leaves project to an empty paragraph body.
*/
export function projectPalabrasLanguage(doc: PalabrasDocument, lang: string): PalabrasDocument {
const active = doc.activeLang ?? lang;
const project = (leaf: TextLeaf): TextLeaf => {
const rest: Record<string, unknown> = { ...leaf };
delete rest.i18n;
rest.children = lang === active ? leaf.children : (leaf.i18n?.[lang] ?? EMPTY_INLINE);
return rest as TextLeaf;
};
const doc2: Record<string, unknown> = { ...doc };
delete doc2.activeLang;
doc2.children = doc.children.map((b) => mapLeaves(b, project));
return doc2 as unknown as PalabrasDocument;
}
/**
* Build a single multilingual document from per-language STRUCTURAL TWINS (docs
* with identical block structure, differing only in inline text). `primary`
* becomes the active language (its content lives in `children`); the others fold
* into each leaf's `i18n`. The caller guarantees the twins share structure.
*/
export function foldPalabrasLanguages(
values: Record<string, PalabrasDocument>,
primary: string
): PalabrasDocument {
const base = values[primary];
if (!base)
throw new Error(`foldPalabrasLanguages: no document for primary language "${primary}"`);
const others = Object.keys(values).filter((l) => l !== primary);
const foldLeaf = (leaf: TextLeaf, twinLeaves: readonly (TextLeaf | undefined)[]): TextLeaf => {
const i18n: Record<string, readonly PalabrasInline[]> = {};
others.forEach((lang, i) => {
const twin = twinLeaves[i];
if (twin?.children) i18n[lang] = twin.children;
});
return Object.keys(i18n).length ? ({ ...leaf, i18n } as TextLeaf) : leaf;
};
return {
...base,
activeLang: primary,
children: base.children.map((block, i) =>
mapLeavesZip(
block,
others.map((l) => values[l].children[i]),
foldLeaf
)
)
};
}
/** Whether a language carries any content in the document (completeness dot). */
export function documentLangHasContent(doc: PalabrasDocument, lang: string): boolean {
const active = doc.activeLang ?? lang;
let found = false;
const check = (leaf: TextLeaf): TextLeaf => {
if (!found) {
const content = lang === active ? leaf.children : leaf.i18n?.[lang];
if (content && inlinesHaveText(content)) found = true;
}
return leaf;
};
doc.children.forEach((b) => mapLeaves(b, check));
return found;
}

@ -67,7 +67,8 @@ export function normalizeDocument(
document: {
version: PALABRAS_VERSION,
children: nextChildren,
...(doc.scheme !== undefined ? { scheme: doc.scheme } : {})
...(doc.scheme !== undefined ? { scheme: doc.scheme } : {}),
...(doc.activeLang !== undefined ? { activeLang: doc.activeLang } : {})
},
mutated: true
};

@ -94,6 +94,12 @@ export interface PalabrasShadow {
export interface Block {
/** Stable identity. Autogenerated at load if absent. */
readonly id?: string;
/** Per-language inline content for the INACTIVE languages of a multilingual
* document (F-A). `children` holds the ACTIVE language; `i18n[lang]` holds the
* rest, keyed by language code. Only text-leaf blocks (paragraph / heading /
* quote / code / list-item) carry it — the block STRUCTURE is shared across
* languages by construction (one tree). Rides through normalize / ops via spread. */
readonly i18n?: Record<string, readonly PalabrasInline[]>;
/** Text alignment. */
readonly align?: PalabrasAlign;
/** Outer margins (px). */
@ -503,6 +509,13 @@ export interface PalabrasDocument {
* JSON export, never interpreted — the consumer (eidos) owns its shape.
*/
readonly scheme?: Readonly<Record<string, unknown>>;
/**
* Multilingual (F-A): the language code whose content currently lives in each
* block's `children` (the rest are in `block.i18n`). Records which language is
* "active" so the doc round-trips — `normalize` preserves it like `scheme`.
* Absent for monolingual documents.
*/
readonly activeLang?: string;
}
// ── Type guards ───────────────────────────────────────────────────────────────

@ -100,3 +100,14 @@ export { sanitizePalabrasUrl } from './engine/normalize';
export { serializeHtml } from './engine/serialize-html';
export { serializeMarkdown } from './engine/serialize-markdown';
export { renderPalabrasPlainText } from './engine/operations/serialize-text';
// Multilingual (F-A) — one document, per-language inline content (`block.i18n`).
// `foldPalabrasLanguages` builds it from structural-twin per-language docs;
// `projectPalabrasLanguage` extracts one language as a clean monolingual doc
// (render / export). `switchDocumentLanguage` is used internally by the provider.
export {
foldPalabrasLanguages,
projectPalabrasLanguage,
switchDocumentLanguage,
documentLangHasContent
} from './engine/i18n';

@ -46,6 +46,7 @@ import {
} from './engine/types';
import { defaultPalabrasSchema } from './engine/blocks';
import { createPalabrasEmbedSpec } from './plugins/embed';
import { foldPalabrasLanguages, projectPalabrasLanguage } from './engine/i18n';
import type { PalabrasSelection } from './engine/selection';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
@ -2665,21 +2666,21 @@ describe('PalabrasProvider · multilingual (F-A)', () => {
document.body.innerHTML = '';
});
const docWith = (text: string) => ({
const doc = (...texts: string[]) => ({
version: PALABRAS_VERSION,
children: [createParagraph([createText(text)])]
children: texts.map((t) => createParagraph([createText(t)]))
});
/** Build a multilingual provider from per-language STRUCTURAL-TWIN docs. */
function multiProvider(
values: Record<string, ReturnType<typeof docWith>>,
values: Record<string, ReturnType<typeof doc>>,
languages: string[],
requiredLangs?: string[]
) {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = palabrasOpts(root, createEmptyDocument());
opts.value.current = values as never;
const opts = palabrasOpts(root, foldPalabrasLanguages(values, languages[0]));
opts.languages.current = languages;
if (requiredLangs) opts.requiredLangs.current = requiredLangs;
const { result, cleanup } = withEffectRoot(() => {
@ -2691,10 +2692,10 @@ describe('PalabrasProvider · multilingual (F-A)', () => {
}
it('languages presence enables multilingual mode; first language is default active', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
['es', 'en']
);
const { provider, cleanup, dom } = multiProvider({ es: doc('Hola'), en: doc('Hello') }, [
'es',
'en'
]);
expect(provider.multilingual).toBe(true);
expect(provider.resolvedLanguages).toEqual(['es', 'en']);
expect(provider.activeLang).toBe('es');
@ -2703,44 +2704,66 @@ describe('PalabrasProvider · multilingual (F-A)', () => {
dom.dispose();
});
it('setActiveLang swaps the active document; the value map keeps every language', () => {
const { provider, opts, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
['es', 'en']
);
it('setActiveLang swaps the active content; ONE doc carries both languages', () => {
const { provider, opts, cleanup, dom } = multiProvider({ es: doc('Hola'), en: doc('Hello') }, [
'es',
'en'
]);
provider.setActiveLang('en');
expect(provider.activeLang).toBe('en');
expect(provider.plainText).toBe('Hello');
const map = opts.value.current as unknown as Record<string, unknown>;
expect(Object.keys(map).sort()).toEqual(['en', 'es']);
const value = opts.value.current;
expect(value.activeLang).toBe('en'); // the doc records the active language
// es content folded into the block's i18n (a single doc, no per-language map)
expect((value.children[0] as { i18n?: Record<string, unknown> }).i18n?.es).toBeDefined();
cleanup();
dom.dispose();
});
it('each language keeps its own document and undo history across switches', () => {
it('SHARED STRUCTURE: a structural edit (delete) applies to EVERY language', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: docWith('Hello') },
{ es: doc('Uno', 'Dos'), en: doc('One', 'Two') },
['es', 'en']
);
provider.applyCommand({ type: 'replaceDocument', document: docWith('Hola editado') });
expect(provider.plainText).toBe('Hola editado');
expect(provider.document.children.length).toBe(2);
// Delete the first block while editing 'es'.
provider.applyCommand({ type: 'deleteBlockAtPath', blockPath: [0] });
expect(provider.document.children.length).toBe(1);
expect(provider.plainText).toBe('Dos');
// Switch to 'en' — the block is gone THERE TOO (one shared tree).
provider.setActiveLang('en');
expect(provider.plainText).toBe('Hello'); // en untouched by the es edit
provider.applyCommand({ type: 'replaceDocument', document: docWith('Hello edited') });
expect(provider.plainText).toBe('Hello edited');
expect(provider.document.children.length).toBe(1);
expect(provider.plainText).toBe('Two');
cleanup();
dom.dispose();
});
provider.setActiveLang('es');
expect(provider.plainText).toBe('Hola editado'); // es edit preserved
expect(provider.canUndo).toBe(true); // es carries its OWN history stack
it('inserting a block adds it EMPTY in the other languages (to translate)', () => {
const { provider, cleanup, dom } = multiProvider({ es: doc('Hola'), en: doc('Hello') }, [
'es',
'en'
]);
// Insert a new block while editing 'es'.
provider.applyCommand({
type: 'insertBlock',
blockIndex: 1,
block: createParagraph([createText('Nuevo')]) as unknown as Readonly<Record<string, unknown>>
});
expect(provider.document.children.length).toBe(2);
expect(provider.plainText).toContain('Nuevo');
// Switch to 'en': the block EXISTS (shared structure) but is EMPTY there —
// untranslated. The original 'Hello' is intact; 'Nuevo' is not shown.
provider.setActiveLang('en');
expect(provider.document.children.length).toBe(2);
expect(provider.plainText).toContain('Hello');
expect(provider.plainText).not.toContain('Nuevo');
cleanup();
dom.dispose();
});
it('requiredLangs feed missingRequiredLangs, langFilled and isInvalid', () => {
const { provider, cleanup, dom } = multiProvider(
{ es: docWith('Hola'), en: createEmptyDocument() as never },
{ es: doc('Hola'), en: doc('') }, // en is a structural twin with EMPTY text
['es', 'en'],
['es', 'en']
);
@ -2752,11 +2775,17 @@ describe('PalabrasProvider · multilingual (F-A)', () => {
dom.dispose();
});
it('projectPalabrasLanguage round-trips each language from the folded doc', () => {
const folded = foldPalabrasLanguages({ es: doc('Hola'), en: doc('Hello') }, 'es');
expect(renderPalabrasPlainText(projectPalabrasLanguage(folded, 'es'))).toBe('Hola');
expect(renderPalabrasPlainText(projectPalabrasLanguage(folded, 'en'))).toBe('Hello');
});
it('mono mode (no languages) stays single-document and backward-compatible', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = palabrasOpts(root, docWith('Solo'));
const opts = palabrasOpts(root, doc('Solo'));
const { result, cleanup } = withEffectRoot(() => {
const provider = PalabrasProvider.create(opts);
vi.spyOn(PalabrasProvider, 'require').mockReturnValue(provider);

@ -97,16 +97,16 @@ import type {
LangSpec,
PalabrasExportFormat,
PalabrasImportFormat,
PalabrasLangValues,
PalabrasRenderedBlock,
PalabrasTocEntry
} from './types';
import { documentLangHasContent, switchDocumentLanguage } from './engine/i18n';
interface PalabrasOpts
extends
WithRefOpts,
StateProps<{
value: PalabrasDocument | PalabrasLangValues;
value: PalabrasDocument;
selection: PalabrasSelection | null;
activeLang: LangCode;
}>,
@ -120,7 +120,7 @@ interface PalabrasOpts
invalid: boolean;
commitOnBlur: boolean;
ariaLabel: string | undefined;
onValueChange: OnChangeFn<PalabrasDocument | PalabrasLangValues> | undefined;
onValueChange: OnChangeFn<PalabrasDocument> | undefined;
onSelectionChange: OnChangeFn<PalabrasSelection | null> | undefined;
onValueCommit: ((document: PalabrasDocument, reason: PalabrasCommitReason) => void) | undefined;
onUploadImage: PalabrasOnUploadImage | undefined;
@ -201,12 +201,12 @@ export class PalabrasProvider {
publishedExternal: PalabrasDocument | null = null;
// ── Multilingual (F-A) ───────────────────────────────────────────────
// `languages` present → the editor holds ONE document PER language, edits the
// ACTIVE language's doc and writes the whole map back through `value`. Each
// language keeps its OWN undo history (switching never crosses stacks):
// `langHistories` caches the INACTIVE languages' histories; the active one is
// `this.history`. Seeded once at construction from the initial value map.
private langHistories = new Map<LangCode, PalabrasHistory>();
// ONE document, per-language inline content: `block.children` holds the ACTIVE
// language, `block.i18n[lang]` the rest. The block STRUCTURE is shared by
// construction (a single tree), so structural edits (insert / delete / move /
// type / table) apply to EVERY language for free, and undo/redo is automatic
// (the `i18n` bag lives in the doc). Switching swaps `children ↔ i18n` across
// the tree (`switchDocumentLanguage`). `doc.activeLang` records the current one.
private activeLangState = $state<LangCode>('');
readonly resolvedLanguages = $derived.by<readonly LangCode[]>(() =>
@ -220,48 +220,16 @@ export class PalabrasProvider {
if (!langs.length) return '';
return langs.includes(this.activeLangState) ? this.activeLangState : langs[0];
}
langFilled(code: LangCode): boolean {
return this.multilingual && documentLangHasContent(this.document, code);
}
readonly missingRequiredLangs = $derived.by<readonly LangCode[]>(() => {
if (!this.multilingual) return [];
return (this.opts.requiredLangs.current ?? []).filter((code) => !this.langFilled(code));
});
private langDocFor(code: LangCode): PalabrasDocument {
if (code === this.activeLang) return this.document;
return this.langHistories.get(code)?.present.document ?? createEmptyDocument();
}
langFilled(code: LangCode): boolean {
return documentHasContent(this.langDocFor(code));
}
/** The active language's document AS THE STORE HOLDS IT (proxied under a
* `$state` binding) — the reference `syncExternalState` compares against. */
private activeExternalDocument(): PalabrasDocument {
const v = this.opts.value.current;
return this.multilingual
? ((v as PalabrasLangValues)[this.activeLang] ?? createEmptyDocument())
: (v as PalabrasDocument);
}
/** Seed a history per language from the initial value map (multilingual init). */
private seedLanguages(opts: PalabrasOpts) {
const langs = this.resolvedLanguages;
const rawMap = ($state.snapshot(opts.value.current) ?? {}) as PalabrasLangValues;
const requested = opts.activeLang.current;
const active = requested && langs.includes(requested) ? requested : langs[0];
this.activeLangState = active;
for (const code of langs) {
const doc = normalizeDocument(rawMap[code] ?? createEmptyDocument()).document;
const hist = createPalabrasHistory(
createState(doc, code === active ? opts.selection.current : null)
);
if (code === active) this.history = hist;
else this.langHistories.set(code, hist);
}
this.opts.activeLang.current = active;
this.writeExternalValue(this.document);
this.opts.selection.current = this.history.present.selection;
}
/** Switch the active editing language, persisting the current one's history. */
/** Switch the active editing language: swaps each block's `children ↔ i18n`
* (the shared structure is untouched) and resets undo (it can't cross a switch). */
setActiveLang(code: LangCode) {
if (!this.multilingual) return;
const langs = this.resolvedLanguages;
@ -269,19 +237,14 @@ export class PalabrasProvider {
const from = this.activeLang;
untrack(() => {
this.closeTypingBatch();
this.langHistories.set(from, this.history);
const target =
this.langHistories.get(code) ?? createPalabrasHistory(createState(createEmptyDocument()));
this.langHistories.delete(code);
const swapped = switchDocumentLanguage(this.document, from, code);
this.history = createPalabrasHistory(createState(swapped, null));
this.activeLangState = code;
this.history = target;
this.dirty = false;
this.activeBlockId = null;
this.writeExternalSelection(target.present.selection);
this.writeExternalValue(swapped);
this.writeExternalSelection(null);
this.opts.activeLang.current = code;
// A pure switch leaves the value MAP unchanged; refresh publishedExternal
// so a later EXTERNAL edit to the now-active language is still detected.
this.publishedExternal = this.activeExternalDocument();
});
this.opts.onLangChange.current?.(code);
}
@ -383,15 +346,31 @@ export class PalabrasProvider {
// their materialized-path id, has nothing to resolve. Normalizing
// here (and writing the id-stamped doc back to `value`) is the single
// point that guarantees ids exist before the first paint.
let initialDoc = normalizeDocument(opts.value.current).document;
if (this.multilingual) {
this.seedLanguages(opts);
} else {
const normalizedInitial = normalizeDocument(opts.value.current as PalabrasDocument).document;
const initialState = createState(normalizedInitial, opts.selection.current);
this.history = createPalabrasHistory(initialState);
this.writeExternalValue(initialState.document);
this.opts.selection.current = initialState.selection;
}
// One document carries every language (`block.i18n`); `doc.activeLang`
// records which one lives in `children`. Reconcile the requested active
// language with the doc's, swapping content into place if they differ.
const langs = this.resolvedLanguages;
const childrenLang = initialDoc.activeLang ?? langs[0];
const requested = opts.activeLang.current;
const desired =
requested && langs.includes(requested)
? requested
: langs.includes(childrenLang)
? childrenLang
: langs[0];
if (desired !== childrenLang)
initialDoc = switchDocumentLanguage(initialDoc, childrenLang, desired);
else if (initialDoc.activeLang !== desired)
initialDoc = { ...initialDoc, activeLang: desired };
this.activeLangState = desired;
this.opts.activeLang.current = desired;
}
const initialState = createState(initialDoc, opts.selection.current);
this.history = createPalabrasHistory(initialState);
this.writeExternalValue(initialDoc);
this.opts.selection.current = initialState.selection;
this.runtime = this.soma.runtime(palabrasMorfo, {
props: {
@ -2314,12 +2293,9 @@ export class PalabrasProvider {
}
private syncExternalState(
externalValue: PalabrasDocument | PalabrasLangValues,
externalDocument: PalabrasDocument,
externalSelection: PalabrasSelection | null
) {
const externalDocument = this.multilingual
? ((externalValue as PalabrasLangValues)[this.activeLang] ?? createEmptyDocument())
: (externalValue as PalabrasDocument);
const current = this.history.present;
// Fast path: our OWN edits round-trip back through the bound `value`, which
@ -2335,6 +2311,10 @@ export class PalabrasProvider {
const limit = this.history.limit;
untrack(() => {
this.closeTypingBatch();
// An external write may carry a different active language (its
// `children` are for `doc.activeLang`); track it so the switcher syncs.
if (this.multilingual && nextState.document.activeLang)
this.activeLangState = nextState.document.activeLang;
this.history = createPalabrasHistory(nextState, { limit });
this.dirty = false;
this.writeExternalValue(nextState.document);
@ -2369,28 +2349,14 @@ export class PalabrasProvider {
}
private writeExternalValue(document: PalabrasDocument) {
if (this.multilingual) {
// Write the WHOLE per-language map (active edit + the other languages'
// current docs, all raw) so a bound `$state` re-proxies it once, cleanly.
const active = this.activeLang;
const map: PalabrasLangValues = {};
for (const code of this.resolvedLanguages) {
map[code] =
code === active
? document
: (this.langHistories.get(code)?.present.document ?? createEmptyDocument());
}
this.opts.value.current = map;
this.publishedExternal = (this.opts.value.current as PalabrasLangValues)[active] ?? null;
return;
}
// Assigning the same object is a Svelte no-op, so the old `!==` guard — which
// compared the $state PROXY against our raw doc and tripped
// `state_proxy_equality_mismatch` — is unnecessary. Cache the store's view of
// the doc (proxied under a `$state` binding) so `syncExternalState` recognises
// our own round-trip with a proxy↔proxy check, never proxy↔raw.
// our own round-trip with a proxy↔proxy check, never proxy↔raw. (Multilingual
// rides inside the doc via `block.i18n` — a single doc, no map.)
this.opts.value.current = document;
this.publishedExternal = this.opts.value.current as PalabrasDocument;
this.publishedExternal = this.opts.value.current;
}
private writeExternalSelection(selection: PalabrasSelection | null) {
@ -3503,10 +3469,6 @@ function langCodeOf(spec: LangSpec): LangCode {
return typeof spec === 'string' ? spec : spec.code;
}
function documentHasContent(doc: PalabrasDocument): boolean {
return renderPalabrasPlainText(doc).trim().length > 0;
}
function samePalabrasDocument(a: PalabrasDocument, b: PalabrasDocument): boolean {
if (a.version !== b.version) return false;
if (a.children.length !== b.children.length) return false;

@ -400,8 +400,8 @@ export type PalabrasProps = WithChild<
{
id?: string;
contentId?: string;
value?: PalabrasDocument | PalabrasLangValues;
defaultValue?: PalabrasDocument | PalabrasLangValues;
value?: PalabrasDocument;
defaultValue?: PalabrasDocument;
selection?: PalabrasSelection | null;
/** Content languages. PRESENCE switches the editor to multilingual mode:
* `value` becomes a `Record<LangCode, PalabrasDocument>` and a language
@ -429,7 +429,7 @@ export type PalabrasProps = WithChild<
* every registered type. */
insertableBlocks?: readonly string[];
'aria-label'?: string;
onValueChange?: OnChangeFn<PalabrasDocument | PalabrasLangValues>;
onValueChange?: OnChangeFn<PalabrasDocument>;
onSelectionChange?: OnChangeFn<PalabrasSelection | null>;
onValueCommit?: (document: PalabrasDocument, reason: PalabrasCommitReason) => void;
children?: Snippet<[PalabrasProviderSnippetProps]>;

@ -7,11 +7,12 @@
import type { PalabrasPanelMode } from '$uix/eidos/components/palabras';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import type { PalabrasDocument } from '$soma/components/palabras';
import { defaultPalabrasSchema } from '$soma/components/palabras';
import { defaultPalabrasSchema, foldPalabrasLanguages } from '$soma/components/palabras';
import { createPalabrasEmbedSpec } from '$soma/components/palabras/plugins/embed';
// Pasting a YouTube/Vimeo/X/CodePen URL creates an embed block (open plugin).
if (!defaultPalabrasSchema.has('embed')) defaultPalabrasSchema.register(createPalabrasEmbedSpec());
if (!defaultPalabrasSchema.has('embed'))
defaultPalabrasSchema.register(createPalabrasEmbedSpec());
// The plugin's PANEL, registered app-side (ESQUEMA §8a) — the embed block
// re-derives its iframe from `url` on every render, so editing it here is
// live (an unrecognised URL renders as an inert paragraph, never an iframe).
@ -40,60 +41,75 @@
});
}
// Multilingual (F-A): `value` is a document PER language; passing `languages`
// switches the editor to multilingual mode and shows the content-language switcher.
let value = $state<Record<string, PalabrasDocument>>({
es: {
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Cómo migrar sin downtime' }] },
{
type: 'paragraph',
// Multilingual (F-A): ONE document carries every language (`block.i18n`); the
// STRUCTURE is shared, only the text is per-language. `foldPalabrasLanguages`
// builds it from per-language structural-twin docs. Passing `languages` turns on
// multilingual mode + the content-language switcher.
let value = $state<PalabrasDocument>(
foldPalabrasLanguages(
{
es: {
version: '2.0.0',
children: [
{
type: 'text',
text: 'La estrategia depende del volumen de datos y de la ventana de mantenimiento disponible.'
}
]
},
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Estrategias' }] },
{
type: 'paragraph',
children: [
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Cómo migrar sin downtime' }]
},
{
type: 'text',
text: 'Haz clic en un bloque y pulsa su tirador para ver sus propiedades. Pulsa Esc para subir un nivel; haz clic en una miga para saltar.'
}
]
}
]
},
en: {
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Zero-downtime migration' }] },
{
type: 'paragraph',
children: [
type: 'paragraph',
children: [
{
type: 'text',
text: 'La estrategia depende del volumen de datos y de la ventana de mantenimiento disponible.'
}
]
},
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Estrategias' }] },
{
type: 'text',
text: 'The strategy depends on the data volume and the available maintenance window.'
type: 'paragraph',
children: [
{
type: 'text',
text: 'Haz clic en un bloque y pulsa su tirador para ver sus propiedades. Pulsa Esc para subir un nivel; haz clic en una miga para saltar.'
}
]
}
]
},
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Strategies' }] },
{
type: 'paragraph',
en: {
version: '2.0.0',
children: [
{
type: 'text',
text: 'Click a block and press its handle to see its properties. Press Esc to go up a level; click a crumb to jump.'
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Zero-downtime migration' }]
},
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'The strategy depends on the data volume and the available maintenance window.'
}
]
},
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Strategies' }] },
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Click a block and press its handle to see its properties. Press Esc to go up a level; click a crumb to jump.'
}
]
}
]
}
]
}
});
},
'es'
)
);
// Demo control — `panelMode` is an author-set prop (no live toggle in the
// component itself); the demo IS the developer surface, so it exercises it.
@ -103,8 +119,8 @@
<main style="padding: 2rem; max-inline-size: 64rem; margin-inline: auto;">
<h1 style="margin-block-end: 0.5rem;">palabras</h1>
<p style="margin-block-end: 1rem; color: var(--color-content-secondary);">
Editor sobre el motor de bloques Palabras. Haz clic en un bloque y pulsa su tirador para abrir el
panel de propiedades.
Editor sobre el motor de bloques Palabras. Haz clic en un bloque y pulsa su tirador para abrir
el panel de propiedades.
</p>
<div
style="margin-block-end: 1.5rem; display: flex; align-items: center; gap: 0.75rem;"

Loading…
Cancel
Save

Powered by TurnKey Linux.