@ -93,8 +93,11 @@ import type {
} from './engine/types' ;
import type { PalabrasRenderDecorations } from './engine/render' ;
import type {
LangCode ,
LangSpec ,
PalabrasExportFormat ,
PalabrasImportFormat ,
PalabrasLangValues ,
PalabrasRenderedBlock ,
PalabrasTocEntry
} from './types' ;
@ -103,8 +106,9 @@ interface PalabrasOpts
extends
WithRefOpts ,
StateProps < {
value : PalabrasDocument ;
value : PalabrasDocument | PalabrasLangValues ;
selection : PalabrasSelection | null ;
activeLang : LangCode ;
} > ,
ActiveProps < {
contentId : string ;
@ -116,11 +120,14 @@ interface PalabrasOpts
invalid : boolean ;
commitOnBlur : boolean ;
ariaLabel : string | undefined ;
onValueChange : OnChangeFn < PalabrasDocument > | undefined ;
onValueChange : OnChangeFn < PalabrasDocument | PalabrasLangValues > | undefined ;
onSelectionChange : OnChangeFn < PalabrasSelection | null > | undefined ;
onValueCommit : ( ( document : PalabrasDocument , reason : PalabrasCommitReason ) = > void ) | undefined ;
onUploadImage : PalabrasOnUploadImage | undefined ;
insertableBlocks : readonly string [ ] | undefined ;
languages : readonly LangSpec [ ] | undefined ;
requiredLangs : readonly LangCode [ ] | undefined ;
onLangChange : OnChangeFn < LangCode > | undefined ;
} > { }
interface PalabrasContentOpts extends WithRefOpts { }
@ -192,6 +199,93 @@ export class PalabrasProvider {
* recognise our own round - trip in O ( 1 ) WITHOUT a proxy ↔ raw compare ( which
* trips Svelte ' s ` state_proxy_equality_mismatch ` ) . Not ` $ state ` : a cache . * /
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 > ( ) ;
private activeLangState = $state < LangCode > ( '' ) ;
readonly resolvedLanguages = $derived . by < readonly LangCode [ ] > ( ( ) = >
( this . opts . languages . current ? ? [ ] ) . map ( langCodeOf )
) ;
get multilingual ( ) : boolean {
return this . resolvedLanguages . length > 0 ;
}
get activeLang ( ) : LangCode {
const langs = this . resolvedLanguages ;
if ( ! langs . length ) return '' ;
return langs . includes ( this . activeLangState ) ? this . activeLangState : langs [ 0 ] ;
}
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 ) ) ;
}
/ * * T h e a c t i v e l a n g u a g e ' s d o c u m e n t A S T H E S T O R E H O L D S I T ( p r o x i e d u n d e r 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. */
setActiveLang ( code : LangCode ) {
if ( ! this . multilingual ) return ;
const langs = this . resolvedLanguages ;
if ( ! langs . includes ( code ) || code === this . activeLang ) return ;
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 ) ;
this . activeLangState = code ;
this . history = target ;
this . dirty = false ;
this . activeBlockId = null ;
this . writeExternalSelection ( target . present . selection ) ;
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 ) ;
}
isSelecting = $state ( false ) ;
findQuery = $state ( '' ) ;
findCaseSensitive = $state ( false ) ;
@ -289,11 +383,15 @@ 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.
const normalizedInitial = normalizeDocument ( opts . value . current ) . document ;
const initialState = createState ( normalizedInitial , opts . selection . current ) ;
this . history = createPalabrasHistory ( initialState ) ;
this . writeExternalValue ( initialState . document ) ;
this . opts . selection . current = initialState . selection ;
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 ;
}
this . runtime = this . soma . runtime ( palabrasMorfo , {
props : {
@ -334,6 +432,18 @@ export class PalabrasProvider {
this . syncExternalState ( this . opts . value . current , this . opts . selection . current ) ;
} ) ;
$effect ( ( ) = > {
const ext = this . opts . activeLang . current ;
if (
this . multilingual &&
ext &&
ext !== this . activeLang &&
this . resolvedLanguages . includes ( ext )
) {
this . setActiveLang ( ext ) ;
}
} ) ;
$effect ( ( ) = > {
const root = this . contentRef . current ;
if ( ! root ) return ;
@ -461,7 +571,10 @@ export class PalabrasProvider {
( ) = > this . opts . required . current || ( this . field ? . isRequired ? ? false )
) ;
readonly isInvalid = $derived . by (
( ) = > this . opts . invalid . current || ( this . field ? . isInvalid ? ? false )
( ) = >
this . opts . invalid . current ||
( this . field ? . isInvalid ? ? false ) ||
this . missingRequiredLangs . length > 0
) ;
readonly isEditable = $derived . by ( ( ) = > ! this . isDisabled && ! this . isReadonly ) ;
readonly isEmpty = $derived . by ( ( ) = > this . plainText . trim ( ) . length === 0 ) ;
@ -1607,6 +1720,12 @@ export class PalabrasProvider {
} ;
readonly snippetProps = $derived . by ( ( ) = > ( {
multilingual : this.multilingual ,
languages : this.resolvedLanguages ,
activeLang : this.activeLang ,
setActiveLang : ( code : LangCode ) = > this . setActiveLang ( code ) ,
missingRequiredLangs : this.missingRequiredLangs ,
langFilled : ( code : LangCode ) = > this . langFilled ( code ) ,
document : this . document ,
html : this.html ,
blocks : this.blocks ,
@ -2194,9 +2313,12 @@ export class PalabrasProvider {
}
private syncExternalState (
external Document: PalabrasDocument ,
external Value: PalabrasDocument | PalabrasLangValues ,
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
@ -2246,13 +2368,28 @@ 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.
this . opts . value . current = document ;
this . publishedExternal = this . opts . value . current ;
this . publishedExternal = this . opts . value . current as PalabrasDocument ;
}
private writeExternalSelection ( selection : PalabrasSelection | null ) {
@ -3361,6 +3498,14 @@ function samePalabrasSelection(a: PalabrasSelection | null, b: PalabrasSelection
) ;
}
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 ;