palabras: bubble F2.4 — tipografía de rango (sub/sup/tamaño/familia)

Completa el bubble (INTERACCION.md §4.3, «tipografía de rango tras un overflow»). Botón
«Aa» → conmuta a una fila de tipografía (como el editor de enlace) con Subíndice /
Superíndice + Tamaño (NumberField px) + Familia (Select de las fuentes del esquema). Se
aplican como MARCAS del motor sobre la selección vía `applyCommand({type:'toggleMark',
mark})`: booleanas `sub`/`sup`, paramétricas `{type:'fontSize',value}` /
`{type:'fontFamily',value}` (mismo valor apaga, valor nuevo reemplaza). El Select
portalizado lleva `data-words-external-tool` (patrón de PalabrasTypography) → la selección
sobrevive al dropdown. `scheme` cableado al bubble para las fuentes.

Verificado Chrome real: «Aa» → fila con los 4 controles; sub → `data-words-marks="sub"`;
tamaño 24 → `style="font-size:24px"` (apilado con sub); familia → `font-family` (3 fuentes
en el dropdown); **selección CONSERVADA** tras la Familia. check sin errores nuevos en palabras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent fee4213464
commit 38452a3900

@ -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}

@ -1098,6 +1098,22 @@ button[data-palabras-panel-crumb]:hover {
background: var(--color-surface-default);
border-color: var(--focus-ring-color);
}
/* Range-typography row (F2.4) — sub/sup + size + family behind the «Aa» overflow. */
[data-palabras-bubble-typo] {
display: flex;
align-items: center;
gap: var(--space-0-5);
}
[data-palabras-bubble-size] {
inline-size: 5.5rem;
}
[data-palabras-bubble-family] {
inline-size: 9rem;
}
[data-palabras-bubble-size] [data-number-field],
[data-palabras-bubble-family] [data-select-trigger] {
inline-size: 100%;
}
/* ── Slash menu «/» — quick insert / turn-into (INTERACCION.md §4.2). soma
Words.SlashMenu positions the container below the caret via `--_words-slash-x/y`

@ -212,7 +212,7 @@
<!-- Selection bubble — inline mark/link formatting over a text range;
in EVERY mode (selection-based, positions itself over the range). -->
{#if contentEl}
<PalabrasBubble {api} />
<PalabrasBubble {api} {scheme} />
<PalabrasSlash {api} />
<PalabrasFind content={contentEl} {dom} />
{/if}

Loading…
Cancel
Save

Powered by TurnKey Linux.