@ -3,29 +3,46 @@
* Selection bubble — inline formatting over a text RANGE (INTERACCION.md §4.3).
*
* soma `Words.BubbleMenu` owns the hard parts: it floats above the STABLE
* selection (anti-flicker ~150ms), positions itself (`--_words-bubble-x/y` +
* `data-side`), and holds the don't-steal-the-selection invariant (pointerdown
* inside `[data-words-bubble-menu]` never collapses the range — the provider's
* focus scope lists it). This renders its CONTENT, composing framework
* primitives — no bespoke widgets:
* - marks ride `Words.CommandButton` (restores the DOM range before the command,
* reflects the active state via `data-active`), drawn as eidos `Button`s;
* - link is an inline draft input (Enter applies via `insertLink`, Escape cancels),
* with an unlink `CommandButton` when the caret sits on a link.
* selection, positions itself, and holds the don't-steal-the-selection invariant
* (pointerdown inside `[data-words-bubble-menu]` never collapses the range). This
* renders its CONTENT, composing framework primitives:
* - marks (B/I/U/S/code) ride `Words.CommandButton` (restores the range, reflects
* `data-active`);
* - link is an inline draft input;
* - «Aa» opens a RANGE-TYPOGRAPHY row (F2.4, §4.3's "tipografía de rango behind an
* overflow"): sub/sup + size + family, applied as engine marks via
* `applyCommand({ type : 'toggleMark' , mark } )` (same value toggles off, a new value
* replaces). The typography controls stay INSIDE the bubble / the Select's portal
* carries `data-words-external-tool`, so the range survives.
*
* Per §4.3 the bubble is the POINTER path; ⌘B/I/U/⌘K work without it (L3 parity,
* not dependency). Turn-into lives in «/», colour in the panel (§4.4) — not here.
* Range typography (F2.4: size/family/sub/sup) + «→ propiedades» land next, behind
* an overflow.
* Per §4.3 the bubble is the POINTER path; ⌘B/I/U/⌘K work without it. Turn-into
* lives in «/», colour in the panel (§4.4) — not here.
*/
import { tick } from 'svelte';
import * as Words from '$soma/components/words';
import { ActiveEidos } from '$uix/eidos';
import { Button } from '$uix/eidos/components/button';
import { Bold , Italic , Underline , Strikethrough , Code , Link , Unlink , Check } from '$uix/eidos/components/icon';
import type { ProviderSnippetProps } from '$soma/components/words';
import { NumberField } from '$uix/eidos/components/number-field';
import { Select } from '$uix/eidos/components/select';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Link,
Unlink,
Check,
Type,
Subscript,
Superscript,
ArrowLeft
} from '$uix/eidos/components/icon';
import { PALABRAS_FONTS } from './panel-schema';
import type { ProviderSnippetProps , WordsMark } from '$soma/components/words';
import type { PalabrasScheme } from './types';
let { api } : { api : ProviderSnippetProps } = $props();
let { api , scheme } : { api : ProviderSnippetProps ; scheme : PalabrasScheme } = $props();
const eidos = ActiveEidos.require();
const ts = (ref: string) => eidos.langs.ts(ref);
@ -38,9 +55,33 @@
{ command : 'code' , label : '#?components.palabras.mark.code|Código' , icon : Code }
] as const;
// Link — inline draft. `insertLink` applies to the STABLE range the bubble keeps
// alive; focusing the input doesn't collapse it (it lives inside the focus scope).
// ── Range typography (F2.4) — engine marks over the live selection. ──
const fontGroups = $derived(scheme.fonts ?? PALABRAS_FONTS);
const allFonts = $derived(fontGroups.flatMap((g) => g.fonts));
const marks = $derived(api.activeMarks);
const hasBool = (m: 'sub' | 'sup') => marks.includes(m);
const paramValue = < T , > (type: 'fontSize' | 'fontFamily'): T | undefined => {
const m = marks.find((x): x is Extract< WordsMark , { type : string } > => typeof x === 'object' && x.type === type);
return m ? (m.value as T) : undefined;
};
const currentSize = $derived(paramValue< number > ('fontSize'));
const currentFamily = $derived(paramValue< string > ('fontFamily'));
function toggleBool(m: 'sub' | 'sup') {
api.applyCommand({ type : 'toggleMark' , mark : m } );
}
function setSize(n: number | undefined) {
if (!n || n < = 0) return;
api.applyCommand({ type : 'toggleMark' , mark : { type : 'fontSize' , value : n } } );
}
function setFamily(v: string | undefined) {
if (!v) return;
api.applyCommand({ type : 'toggleMark' , mark : { type : 'fontFamily' , value : v } } );
}
// Link — inline draft over the stable range.
let linkOpen = $state(false);
let typoOpen = $state(false);
let linkValue = $state('');
let linkInput = $state< HTMLInputElement | null > (null);
@ -66,6 +107,7 @@
linkOpen = false;
}
}
const keepSelection = (e: PointerEvent) => e.preventDefault();
< / script >
< Words.BubbleMenu side = "top" >
@ -89,12 +131,95 @@
size="sm"
iconOnly
aria-label={ ts ( '#?components.palabras.action.apply|Aplicar' )}
onpointerdown={ (e : PointerEvent ) => e . preventDefault () }
onpointerdown={ keepSelection }
onclick={ applyLink }
>
{ # snippet icon ()} < Check /> { /snippet }
< / Button >
< / div >
{ :else if typoOpen }
< div data-palabras-bubble-typo >
< Button
variant="ghost"
size="sm"
iconOnly
aria-label={ ts ( '#?components.palabras.action.back|Volver' )}
onpointerdown={ keepSelection }
onclick={() => ( typoOpen = false )}
>
{ # snippet icon ()} < ArrowLeft /> { /snippet }
< / Button >
< span data-palabras-bubble-divider > < / span >
< Button
variant="ghost"
size="sm"
iconOnly
data-active={ hasBool ( 'sub' ) ? '' : undefined }
aria-pressed={ hasBool ( 'sub' )}
aria-label={ ts ( '#?components.palabras.mark.sub|Subíndice' )}
onpointerdown={ keepSelection }
onclick={() => toggleBool ( 'sub' )}
>
{ # snippet icon ()} < Subscript /> { /snippet }
< / Button >
< Button
variant="ghost"
size="sm"
iconOnly
data-active={ hasBool ( 'sup' ) ? '' : undefined }
aria-pressed={ hasBool ( 'sup' )}
aria-label={ ts ( '#?components.palabras.mark.sup|Superíndice' )}
onpointerdown={ keepSelection }
onclick={() => toggleBool ( 'sup' )}
>
{ # snippet icon ()} < Superscript /> { /snippet }
< / Button >
< span data-palabras-bubble-divider > < / span >
< div data-palabras-bubble-size >
< NumberField
size="sm"
value={ currentSize }
min={ 8 }
max={ 200 }
step={ 1 }
aria-label={ ts ( '#?components.palabras.mark.size|Tamaño' )}
onValueChange={ setSize }
>
< NumberField.DecrementTrigger > − < / NumberField.DecrementTrigger >
< NumberField.Input placeholder = "px" / >
< NumberField.IncrementTrigger > +< / NumberField.IncrementTrigger >
< / NumberField >
< / div >
< div data-palabras-bubble-family >
< Select
selectionMode="single"
size="sm"
variant="surface"
value={ currentFamily ? [ currentFamily ] : []}
onValueChange={( v ) => setFamily ( v [ 0 ])}
>
< Select.Trigger >
< span data-palabras-select-text data-empty = { currentFamily ? undefined : '' } >
{ allFonts . find (( o ) => o . value === currentFamily ) ? . label ??
ts('#?components.palabras.mark.family|Fuente')}
< / span >
< Select.Indicator / >
< / Select.Trigger >
< Select.Portal >
< Select.Content data-palabras-type-select data-words-external-tool >
< Select.Viewport >
{ #each allFonts as o ( o . value )}
< Select.Item value = { String ( o . value )} label= { o . label } >
< span > { o . label } </ span >
< Select.ItemIndicator / >
< / Select.Item >
{ /each }
< / Select.Viewport >
< / Select.Content >
< / Select.Portal >
< / Select >
< / div >
< / div >
{ : else }
{ #each MARKS as mark ( mark . command )}
{ @const Glyph = mark . icon }
@ -109,13 +234,23 @@
< span data-palabras-bubble-divider > < / span >
< Button
variant="ghost"
size="sm"
iconOnly
aria-label={ ts ( '#?components.palabras.mark.typography|Tipografía' )}
onpointerdown={ keepSelection }
onclick={() => ( typoOpen = true )}
>
{ # snippet icon ()} < Type /> { /snippet }
< / Button >
< Button
variant="ghost"
size="sm"
iconOnly
aria-label={ ts ( '#?components.palabras.action.link|Enlace' )}
data-active={ api . selectedLink ? '' : undefined }
onpointerdown={( e : PointerEvent ) => e . preventDefault ()}
onpointerdown={ keepSelection }
onclick={ openLink }
>
{ # snippet icon ()} < Link /> { /snippet }