Orden literal del usuario: fuera todos los ficheros que muestran la parte visual (panel, breadcrumb, handle, activate, drag, inserter, nav, segmented, color-row, spacing, typography, appearance, nav y el runtime de drag). Se CONSERVAN: el motor soma/words, types.ts, palabras-scheme(+parse+test), panel-schema, block-types/actions/anchor/image-file y los .md. index.ts reducido a contrato; la demo queda en stub honesto hasta la fase 1 de la reconstruccion ratificada. Check limpio en el track.menubar-v4-safe
parent
d50a138d52
commit
66b63570e1
@ -1,228 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Click-to-activate bridge.
|
||||
*
|
||||
* A render-less helper (same shape as the gutter): given the editor
|
||||
* content root, it listens for `pointerdown` and sets the provider's
|
||||
* ACTIVE BLOCK to the nearest `[data-words-id]` ancestor of the click
|
||||
* target. This is the single, explicit gesture that drives the
|
||||
* inspector — per the architecture, the active block changes ONLY on a
|
||||
* click on a block (or insert / breadcrumb), never on caret / focus
|
||||
* churn.
|
||||
*
|
||||
* `pointerdown` (not `click`) so it lands BEFORE the browser moves
|
||||
* focus / collapses the selection — the active block is set first and
|
||||
* is immune to any subsequent selectionchange. Clicks that don't land
|
||||
* on a block (whitespace inside the editor frame) leave the current
|
||||
* active block untouched; clicks outside `content` (toolbar, inspector,
|
||||
* gutter) never reach this listener since it's bound to `content`.
|
||||
*
|
||||
* Every block carries `data-words-id` (the engine stamps it from the
|
||||
* block's stable `id`); inline text spans carry only `data-words-path`,
|
||||
* so `closest('[data-words-id]')` climbs from a text click up to its
|
||||
* owning block — exactly the granularity the inspector wants.
|
||||
*/
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom,
|
||||
enabled = true,
|
||||
onActivate,
|
||||
panelOpen = false,
|
||||
onPanelClose
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
/** When false (preview / read-only) block activation is off: no
|
||||
* click-to-select and the active-block outline is cleared. */
|
||||
enabled?: boolean;
|
||||
/** Fired after a CLICK sets the active block (not on caret nav). Receives
|
||||
* the block's id-path + its engine type (`data-words-block`). Lets a host
|
||||
* (palabras) react — e.g. open the inspector when an atomic image is clicked. */
|
||||
onActivate?: (idPath: string, blockType: string | null) => void;
|
||||
/** Whether the host's properties panel is open — Esc precedence
|
||||
* (INTERACCION §3): an open panel wins over the ladder. */
|
||||
panelOpen?: boolean;
|
||||
/** Close the host's panel (first Esc while it's open). */
|
||||
onPanelClose?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Build the MATERIALIZED-PATH id of the clicked block: walk the DOM up
|
||||
// from the target collecting every ancestor's `data-words-id` until the
|
||||
// content root, then join root → block. This IS the id the provider
|
||||
// tracks — its segments encode the full ancestry, so the resolver
|
||||
// descends it directly and the breadcrumb falls out of its prefixes.
|
||||
function idPathOf(target: HTMLElement): string {
|
||||
const nearest: HTMLElement | null = target.closest('[data-words-id]');
|
||||
// A click whose nearest block element is a structural LAYOUT slot (a column
|
||||
// or table cell) landed on the slot's OWN padding / empty area, not on a
|
||||
// child block — those slots are not selectable blocks. Leave the active
|
||||
// block unchanged (an empty column is seeded via its "+" affordance, not by
|
||||
// selecting it; the columns container is selected from the grip menu).
|
||||
const slot = nearest?.getAttribute('data-words-node');
|
||||
if (slot === 'column' || slot === 'table-cell') return '';
|
||||
|
||||
const ids: string[] = [];
|
||||
let el: HTMLElement | null = nearest;
|
||||
while (el && content.contains(el)) {
|
||||
const id = el.getAttribute('data-words-id');
|
||||
// A LIST ITEM is not a selectable block on its own — the LIST is the unit.
|
||||
// Skip it so the active block (and its full-width dashed marker) is the
|
||||
// whole list, not an indented, narrower `<li>`.
|
||||
if (id && el.getAttribute('data-words-node') !== 'list-item') {
|
||||
ids.unshift(id); // prepend → root ends up first
|
||||
}
|
||||
el = el.parentElement?.closest('[data-words-id]') ?? null;
|
||||
}
|
||||
return ids.join('/');
|
||||
}
|
||||
|
||||
// Epoch — timestamp of the last CLICK that activated an ATOMIC block. The
|
||||
// caret-follower ignores selectionchanges in the short window right after, which
|
||||
// is immune to reactive-snapshot timing (`api.activeBlockId` can lag a tick, so
|
||||
// a marker/id read in the handler may still see the previous block).
|
||||
let lastAtomicAt = 0;
|
||||
|
||||
// Whether the CURRENT active block is ATOMIC — read from the COMMITTED DOM
|
||||
// marker (`[data-words-active]`), NOT the possibly-stale api snapshot. An atomic
|
||||
// block (image / divider, contenteditable=false) hosts no caret, so the
|
||||
// caret-follower must never demote it (a stray selectionchange always lands its
|
||||
// anchor in a NEIGHBOURING text block).
|
||||
function isActiveBlockAtomic(): boolean {
|
||||
if (!content) return false;
|
||||
const el = content.querySelector('[data-words-active]');
|
||||
return el instanceof HTMLElement && el.getAttribute('contenteditable') === 'false';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!content || !enabled) return;
|
||||
const onPointerDown = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const idPath = idPathOf(target);
|
||||
if (!idPath) return;
|
||||
api.setActiveBlock(idPath);
|
||||
// Notify the host (palabras opens the inspector for atomic image clicks).
|
||||
const blockEl = target.closest('[data-words-block]');
|
||||
// Stamp the atomic-activation epoch SYNCHRONOUSLY so the trailing
|
||||
// selectionchange (which lands in a neighbour) can't demote this block.
|
||||
if (blockEl?.getAttribute('contenteditable') === 'false') lastAtomicAt = Date.now();
|
||||
onActivate?.(idPath, blockEl?.getAttribute('data-words-block') ?? null);
|
||||
};
|
||||
return dom.listen(content, 'pointerdown', onPointerDown, { capture: true });
|
||||
});
|
||||
|
||||
// The Esc LADDER (INTERACCION §3): Esc climbs the ancestry one level per
|
||||
// press — block → parent (cell / row / column / callout) → … → document
|
||||
// (no active block). This is how cell / row / table are reached from a
|
||||
// block INSIDE them without the breadcrumb. The caret is untouched (the
|
||||
// selection stays where it was — "el caret nunca se pierde"); Esc fires no
|
||||
// selectionchange, so the caret-follower below never demotes the climb.
|
||||
// Precedence (§3, pila única): an OPEN PANEL wins — the first Esc closes
|
||||
// it, the next one climbs.
|
||||
$effect(() => {
|
||||
if (!content || !enabled) return;
|
||||
const onKeydown = (e: Event) => {
|
||||
const ev = e as KeyboardEvent;
|
||||
if (ev.key !== 'Escape') return;
|
||||
if (panelOpen) {
|
||||
onPanelClose?.();
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const id = api.activeBlockId;
|
||||
if (!id) return;
|
||||
const parent = String(id).split('/').filter(Boolean).slice(0, -1).join('/');
|
||||
api.setActiveBlock(parent || null);
|
||||
ev.preventDefault();
|
||||
};
|
||||
return dom.listen(content, 'keydown', onKeydown);
|
||||
});
|
||||
|
||||
// Caret-follows-active — SUBORDINATE to intentional activation. When the caret
|
||||
// moves into a DIFFERENT EDITABLE block (Arrow Up/Down, or crossing a block
|
||||
// boundary) it re-points the active block so grip / breadcrumb / outline follow.
|
||||
// It is gated three ways: (1) intra-block moves resolve to the same id → no-op;
|
||||
// (2) clicks already set the same block via pointerdown (fires first) → the
|
||||
// trailing selectionchange is a no-op; (3) — THE KEY RULE — it NEVER demotes an
|
||||
// ATOMIC active block (image/divider, contenteditable=false), because such a
|
||||
// block holds no caret and the selectionchange anchor always lands in a
|
||||
// NEIGHBOURING text block. Without (3), clicking an image flipped the active
|
||||
// block back to the previous heading ("the panel reverts" bug). The doctrine:
|
||||
// clicks / inserts / grip / breadcrumb SET the active block; the follower only
|
||||
// REFINES it among editable blocks.
|
||||
$effect(() => {
|
||||
if (!content || !enabled) return;
|
||||
const doc = dom.getDocument(content);
|
||||
const onSelectionChange = () => {
|
||||
if (Date.now() - lastAtomicAt < 800) return; // just clicked an atomic block
|
||||
if (isActiveBlockAtomic()) return; // current active is atomic — never demote
|
||||
const sel = doc.getSelection?.();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const node = sel.anchorNode;
|
||||
if (!node || !content.contains(node)) return;
|
||||
const host = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement;
|
||||
const blockEl = host?.closest('[data-words-id]') as HTMLElement | null;
|
||||
if (!blockEl) return;
|
||||
const idPath = idPathOf(blockEl);
|
||||
if (idPath && idPath !== api.activeBlockId) api.setActiveBlock(idPath);
|
||||
};
|
||||
return dom.listen(doc, 'selectionchange', onSelectionChange);
|
||||
});
|
||||
|
||||
// Active-block visual marker. Stamps `data-words-active` + a depth index
|
||||
// on the active block's DOM element so eidos CSS can paint a dotted
|
||||
// outline (colour varies by nesting depth). Done IMPERATIVELY — not via
|
||||
// the document model / `html` derived — so it never re-renders the
|
||||
// contenteditable (which would fight the caret). It runs as an $effect
|
||||
// that depends on BOTH `api.activeBlockId` (re-mark on activation
|
||||
// change) AND `api.html` (re-apply after every render, since `{@html}`
|
||||
// replaces the subtree and wipes imperative attributes).
|
||||
//
|
||||
// The active id is a materialized path (`colsId/colId/childId`); the
|
||||
// element carries only its LOCAL id in `data-words-id`, and local ids
|
||||
// are unique, so we match the LAST segment. The segment count is the
|
||||
// nesting depth → `data-words-depth`.
|
||||
$effect(() => {
|
||||
const on = enabled; // re-run (and clear) when activation is turned off
|
||||
const idPath = api.activeBlockId;
|
||||
// Touch `html` so this effect re-runs after each re-render and
|
||||
// re-stamps the (freshly recreated) element.
|
||||
void api.html;
|
||||
if (!content) return;
|
||||
// Clear any stale marker (activation can change WITHOUT a re-render
|
||||
// — clicking a sibling block — so the previous element keeps its
|
||||
// attribute until we remove it here).
|
||||
for (const prev of content.querySelectorAll('[data-words-active]')) {
|
||||
prev.removeAttribute('data-words-active');
|
||||
prev.removeAttribute('data-words-depth');
|
||||
}
|
||||
// Preview / read-only: stamps cleared above, never re-applied.
|
||||
if (!on || !idPath) return;
|
||||
const segments = idPath.split('/').filter(Boolean);
|
||||
const localId = segments[segments.length - 1];
|
||||
if (!localId) return;
|
||||
const el = content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`);
|
||||
if (el instanceof HTMLElement) {
|
||||
el.setAttribute('data-words-active', '');
|
||||
el.setAttribute('data-words-depth', String(segments.length));
|
||||
}
|
||||
});
|
||||
|
||||
// There is ALWAYS an active block: on first load (no click yet) select the
|
||||
// first top-level block so the inspector + the gutter rail have a target
|
||||
// from the start instead of "nothing selected".
|
||||
let seeded = false;
|
||||
$effect(() => {
|
||||
if (seeded || !enabled) return;
|
||||
const first = api.document.children[0];
|
||||
if (!first?.id) return;
|
||||
if (!api.activeBlockId) api.setActiveBlock(first.id);
|
||||
seeded = true;
|
||||
});
|
||||
</script>
|
||||
@ -1,245 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Appearance popover — opened by the panel's "Editar" button. Edits THIS
|
||||
* block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`.
|
||||
* It's the framework `<FloatPanel>` anchored to the active block on its LEFT
|
||||
* (opposite the main panel); a draggable free window (closes on click-outside /
|
||||
* Esc). Its CONTENT is organised in `<Tabs>` by style group — Tipografía (only
|
||||
* for text blocks), Colores, Disposición — Elementor-style. Colours use our
|
||||
* `<ColorPicker>`, numbers our `<NumberField>`, alignment our `<ToggleGroup>`.
|
||||
*/
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import { NumberField } from '$uix/eidos/components/number-field';
|
||||
import { Tabs } from '$uix/eidos/components/tabs';
|
||||
import PalabrasColorRow from './palabras-color-row.svelte';
|
||||
import { FloatPanel } from '$uix/eidos/components/float-panel';
|
||||
|
||||
const PANEL_W = 284;
|
||||
const PANEL_H = 300;
|
||||
|
||||
// Style-group tabs. Tipografía only shows for text-bearing blocks ("la
|
||||
// tipografía solo aparece en las partes que son tipográficas").
|
||||
const TEXT_TYPES = new Set(['heading', 'paragraph']);
|
||||
// `tab` = the user's explicit pick (null → use the default). See `activeTab`.
|
||||
let tab = $state<string | null>(null);
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
open,
|
||||
onClose
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const loc = $derived(api.activeBlockLocation);
|
||||
const isText = $derived(TEXT_TYPES.has(loc?.node?.type ?? ''));
|
||||
// Active tab: the user's pick if still valid ('type'/Tipografía only on text
|
||||
// blocks), else the default — Tipografía for text, Colores otherwise. A derived
|
||||
// (not an effect) so it's robust to `isText` settling AFTER mount: no transient
|
||||
// 'color' sticks the way an effect-set value would.
|
||||
const activeTab = $derived(
|
||||
tab && (tab !== 'type' || isText) ? tab : isText ? 'type' : 'color'
|
||||
);
|
||||
|
||||
// The active block's element (resolved by its stable `data-words-id`, like the
|
||||
// main panel). FloatPanel seeds its open position from this on the LEFT.
|
||||
const anchorEl = $derived.by(() => {
|
||||
void open;
|
||||
const idPath = api.activeBlockId;
|
||||
if (!content || !idPath) return null;
|
||||
const segments = String(idPath).split('/').filter(Boolean);
|
||||
const localId = segments[segments.length - 1];
|
||||
if (!localId) return null;
|
||||
return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null;
|
||||
});
|
||||
|
||||
// Re-anchor to the LEFT of the block: FloatPanel seeds on the first open; when
|
||||
// the active block changes while open, reposition next to the new block. A drag
|
||||
// within one block is preserved. Cleared on close so the next open re-seeds.
|
||||
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
|
||||
let posBlock: string | null = null;
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
panelPos = undefined;
|
||||
posBlock = null;
|
||||
return;
|
||||
}
|
||||
const el = anchorEl;
|
||||
const win = content?.ownerDocument?.defaultView;
|
||||
const id = api.activeBlockId;
|
||||
if (!el || !win || !id) return;
|
||||
if (posBlock === null) {
|
||||
posBlock = id;
|
||||
return;
|
||||
}
|
||||
if (id === posBlock) return;
|
||||
posBlock = id;
|
||||
const r = el.getBoundingClientRect();
|
||||
const gap = 16;
|
||||
const pad = 16;
|
||||
panelPos = {
|
||||
x: Math.min(Math.max(r.left - PANEL_W - gap, pad), win.innerWidth - PANEL_W - pad),
|
||||
y: Math.min(Math.max(r.top + r.height / 2 - PANEL_H / 2, pad), win.innerHeight - PANEL_H - pad)
|
||||
};
|
||||
});
|
||||
|
||||
type Align = 'left' | 'center' | 'right' | 'justify';
|
||||
type VisualPatch = {
|
||||
color?: string;
|
||||
background?: string;
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
align?: Align;
|
||||
};
|
||||
function setVisual(patch: VisualPatch) {
|
||||
const path = loc?.path;
|
||||
if (!path) return;
|
||||
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch });
|
||||
}
|
||||
function activeEl(): HTMLElement | null {
|
||||
return (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
|
||||
}
|
||||
function effectiveColor(prop: 'color' | 'background'): string | undefined {
|
||||
const o = loc?.node?.[prop];
|
||||
if (typeof o === 'string') return o;
|
||||
const el = activeEl();
|
||||
if (!el) return undefined;
|
||||
const cs = getComputedStyle(el);
|
||||
return prop === 'color' ? cs.color : cs.backgroundColor;
|
||||
}
|
||||
function fontSizeNum(): number | undefined {
|
||||
const o = loc?.node?.fontSize;
|
||||
if (typeof o === 'number') return o;
|
||||
const el = activeEl();
|
||||
if (!el) return undefined;
|
||||
const px = parseFloat(getComputedStyle(el).fontSize);
|
||||
return Number.isFinite(px) ? Math.round(px) : undefined;
|
||||
}
|
||||
function lineHeightNum(): number | undefined {
|
||||
const o = loc?.node?.lineHeight;
|
||||
return typeof o === 'number' ? o : undefined;
|
||||
}
|
||||
function effectiveAlign(): Align {
|
||||
const o = loc?.node?.align;
|
||||
if (typeof o === 'string') return o as Align;
|
||||
const el = activeEl();
|
||||
let ta = el ? getComputedStyle(el).textAlign : 'left';
|
||||
if (ta === 'start') ta = 'left';
|
||||
if (ta === 'end') ta = 'right';
|
||||
return (['left', 'center', 'right', 'justify'].includes(ta) ? ta : 'left') as Align;
|
||||
}
|
||||
|
||||
const ALIGNS: readonly { v: Align; label: string }[] = [
|
||||
{ v: 'left', label: 'Izq.' },
|
||||
{ v: 'center', label: 'Centro' },
|
||||
{ v: 'right', label: 'Der.' },
|
||||
{ v: 'justify', label: 'Just.' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<FloatPanel
|
||||
{open}
|
||||
onOpenChange={(v) => !v && onClose()}
|
||||
bind:position={panelPos}
|
||||
anchor={anchorEl}
|
||||
side="left"
|
||||
align="center"
|
||||
offset={16}
|
||||
draggable
|
||||
closeOnInteractOutside
|
||||
variant="surface"
|
||||
color="neutral"
|
||||
defaultSize={{ width: PANEL_W, height: PANEL_H }}
|
||||
>
|
||||
<FloatPanel.Content data-palabras-appearance data-words-external-tool>
|
||||
<FloatPanel.Header closable={false}>
|
||||
<FloatPanel.Title>Apariencia</FloatPanel.Title>
|
||||
</FloatPanel.Header>
|
||||
|
||||
<FloatPanel.Body>
|
||||
<Tabs value={activeTab} onValueChange={(v) => (tab = v)} variant="line" size="sm" scrollable>
|
||||
<Tabs.List aria-label="Grupos de estilo" data-palabras-style-tabs>
|
||||
{#if isText}
|
||||
<Tabs.Trigger value="type">Tipografía</Tabs.Trigger>
|
||||
{/if}
|
||||
<Tabs.Trigger value="color">Colores</Tabs.Trigger>
|
||||
<Tabs.Trigger value="layout">Disposición</Tabs.Trigger>
|
||||
<Tabs.Indicator />
|
||||
</Tabs.List>
|
||||
|
||||
{#if isText}
|
||||
<Tabs.Content value="type">
|
||||
<div data-palabras-panel-fields>
|
||||
<div data-palabras-field data-inline>
|
||||
<span data-palabras-field-label>Tamaño (px)</span>
|
||||
<NumberField
|
||||
size="sm"
|
||||
min={8}
|
||||
value={fontSizeNum()}
|
||||
onValueChange={(v) =>
|
||||
setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })}
|
||||
>
|
||||
<NumberField.Input />
|
||||
</NumberField>
|
||||
</div>
|
||||
<div data-palabras-field data-inline>
|
||||
<span data-palabras-field-label>Interlineado</span>
|
||||
<NumberField
|
||||
size="sm"
|
||||
min={1}
|
||||
step={0.1}
|
||||
value={lineHeightNum()}
|
||||
onValueChange={(v) =>
|
||||
setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })}
|
||||
>
|
||||
<NumberField.Input />
|
||||
</NumberField>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
|
||||
<Tabs.Content value="color">
|
||||
<div data-palabras-panel-fields>
|
||||
<PalabrasColorRow
|
||||
label="Color de texto"
|
||||
current={effectiveColor('color')}
|
||||
onPick={(hex) => setVisual({ color: hex })}
|
||||
/>
|
||||
<PalabrasColorRow
|
||||
label="Fondo"
|
||||
current={effectiveColor('background')}
|
||||
onPick={(hex) => setVisual({ background: hex })}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="layout">
|
||||
<div data-palabras-panel-fields>
|
||||
<div data-palabras-field>
|
||||
<span data-palabras-field-label>Alineación</span>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
value={[effectiveAlign()]}
|
||||
onValueChange={(v) => {
|
||||
const a = v[0] as Align;
|
||||
if (a) setVisual({ align: a });
|
||||
}}
|
||||
>
|
||||
{#each ALIGNS as a (a.v)}
|
||||
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</FloatPanel.Body>
|
||||
</FloatPanel.Content>
|
||||
</FloatPanel>
|
||||
@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Block drag-reorder controller (the drop sink).
|
||||
*
|
||||
* The left-gutter grip is `draggable`; grabbing it starts an HTML5 drag
|
||||
* that carries the block's top-level index on a private MIME type
|
||||
* (`WORDS_BLOCK_DRAG_TYPE`). This controller listens on the contenteditable,
|
||||
* paints a horizontal indicator at the nearest inter-block seam during the
|
||||
* drag, and on drop repositions the block via the engine's `moveBlockTo`
|
||||
* command.
|
||||
*
|
||||
* Native text drag-drop is untouched: every handler bails unless the drag
|
||||
* carries our private type, so selecting text and dragging it still goes
|
||||
* through the browser's default path.
|
||||
*
|
||||
* Block detection mirrors the gutter exactly — top-level blocks are the
|
||||
* content's direct `[data-words-node='block']` children, and `fromIndex`
|
||||
* (written by the grip) indexes the same array. Reorder is therefore
|
||||
* top-level only; nested blocks (inside columns / cells) are not draggable.
|
||||
*/
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom,
|
||||
dropZone
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
/**
|
||||
* Element to listen on for `dragover` / `drop`. Defaults to `content`
|
||||
* (Words' own layout, where the grip floats over the content's left
|
||||
* padding). When the grip lives in a SEPARATE column to the side of the
|
||||
* content (palabras' rail), pass the wrapper row that spans BOTH columns
|
||||
* so a straight-down drag over the rail still fires `dragover` — `dropAt`
|
||||
* uses only `clientY`, so the seam geometry is unaffected by where we
|
||||
* listen. Block geometry always comes from `content`.
|
||||
*/
|
||||
dropZone?: HTMLElement | null;
|
||||
} = $props();
|
||||
|
||||
// The `[data-words]` frame is a positioned ancestor; the indicator is
|
||||
// placed relative to it, same as the gutter.
|
||||
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
|
||||
|
||||
let line = $state<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
function topLevelBlocks(): HTMLElement[] {
|
||||
return Array.from(content.children).filter(
|
||||
(el): el is HTMLElement =>
|
||||
el instanceof HTMLElement && el.matches("[data-words-node='block']")
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockDrag(e: DragEvent): boolean {
|
||||
return !!e.dataTransfer && Array.from(e.dataTransfer.types).includes(WORDS_BLOCK_DRAG_TYPE);
|
||||
}
|
||||
|
||||
// Nearest inter-block seam for a viewport-Y cursor. `n` is the insertion
|
||||
// point in 0..length (n === length ⇒ after the last block). Coords come
|
||||
// back frame-relative for the indicator.
|
||||
function dropAt(
|
||||
clientY: number
|
||||
): { n: number; top: number; left: number; width: number } | null {
|
||||
if (!frame) return null;
|
||||
const blocks = topLevelBlocks();
|
||||
if (blocks.length === 0) return null;
|
||||
const fr = frame.getBoundingClientRect();
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const r = blocks[i].getBoundingClientRect();
|
||||
if (clientY < r.top + r.height / 2) {
|
||||
return { n: i, top: r.top - fr.top, left: r.left - fr.left, width: r.width };
|
||||
}
|
||||
}
|
||||
const last = blocks[blocks.length - 1].getBoundingClientRect();
|
||||
return {
|
||||
n: blocks.length,
|
||||
top: last.bottom - fr.top,
|
||||
left: last.left - fr.left,
|
||||
width: last.width
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const doc = dom.getDocument(content) ?? content;
|
||||
// Listen on the drop zone (the rail+canvas row in palabras) so a
|
||||
// straight-down drag over the gutter still fires dragover. Defaults to
|
||||
// `content` for Words' own gutter-over-content layout.
|
||||
const zone = dropZone ?? content;
|
||||
const disposers = [
|
||||
dom.listen(zone, 'dragover', (event) => {
|
||||
const e = event as DragEvent;
|
||||
if (!isBlockDrag(e)) return; // leave native text drag alone
|
||||
e.preventDefault(); // a drop only fires when dragover is prevented
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
const d = dropAt(e.clientY);
|
||||
line = d ? { top: d.top, left: d.left, width: d.width } : null;
|
||||
}),
|
||||
dom.listen(zone, 'drop', (event) => {
|
||||
const e = event as DragEvent;
|
||||
if (!isBlockDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const from = Number(e.dataTransfer?.getData(WORDS_BLOCK_DRAG_TYPE));
|
||||
const d = dropAt(e.clientY);
|
||||
line = null;
|
||||
if (!Number.isInteger(from) || from < 0 || !d) return;
|
||||
// `moveBlockTo`'s target is post-removal: dropping at seam `n`
|
||||
// when the source sits before it shifts everything down one.
|
||||
const to = from < d.n ? d.n - 1 : d.n;
|
||||
api.applyCommand({ type: 'moveBlockTo', fromIndex: from, toIndex: to });
|
||||
}),
|
||||
// `dragend` fires on the SOURCE (the grip) and does not bubble, but
|
||||
// the capture phase still reaches a document-level listener — clears
|
||||
// the indicator wherever the drag ends, including a drop cancelled
|
||||
// outside the content.
|
||||
dom.listen(doc, 'dragend', () => (line = null), { capture: true })
|
||||
];
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if line}
|
||||
<div
|
||||
data-words-block-drop-line
|
||||
style="top: {line.top}px; left: {line.left}px; width: {line.width}px;"
|
||||
></div>
|
||||
{/if}
|
||||
@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Breadcrumb of the active node's ancestry, built from
|
||||
* `api.activeBlockLocation.ancestry` with our `<Breadcrumb>` compound.
|
||||
*
|
||||
* Clicking a crumb activates that node AND opens its properties panel (the
|
||||
* second way to open the panel, besides the block grip). Click handling is
|
||||
* DELEGATED on a wrapper via each crumb's `data-idpath` so it works whether
|
||||
* the crumb renders as an `<a>` (ancestor) or a `<span>` (the current page).
|
||||
* The document crumb only clears the active block (no panel at doc level).
|
||||
*/
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { crumbLabel } from './panel-schema';
|
||||
|
||||
let { api, onOpen }: { api: ProviderSnippetProps; onOpen: () => void } = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const ts = (ref: string) => eidos.langs.ts(ref);
|
||||
const ancestry = $derived(api.activeBlockLocation?.ancestry ?? []);
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const el = (e.target as HTMLElement).closest('[data-idpath]') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
const idPath = el.getAttribute('data-idpath') ?? '';
|
||||
if (el.dataset.kind === 'document') {
|
||||
api.setActiveBlock(null);
|
||||
return;
|
||||
}
|
||||
api.setActiveBlock(idPath || null);
|
||||
onOpen();
|
||||
}
|
||||
|
||||
// A crumb press must NOT count as "outside" for the open panel: the
|
||||
// FloatPanel's interact-outside dismiss honors `defaultPrevented`, so
|
||||
// preventing the pointerdown keeps the panel open and it FOLLOWS the crumb
|
||||
// navigation (the grip survives the same way via its drag preventDefault).
|
||||
// `click` still fires — only focus/selection side effects are suppressed.
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const el = (e.target as HTMLElement).closest('[data-idpath]');
|
||||
if (el) e.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ancestry.length > 0}
|
||||
<!-- Keyboard path: the crumbs are real <a> links (Enter fires click, which
|
||||
bubbles to this delegated handler) — the wrapper itself needs no key handler. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div data-palabras-breadcrumb-wrap onclick={onClick} onpointerdown={onPointerDown}>
|
||||
<Breadcrumb size="sm" data-palabras-breadcrumb>
|
||||
<Breadcrumb.List>
|
||||
{#each ancestry as node, i (node.idPath)}
|
||||
{#if i > 0}
|
||||
<Breadcrumb.Separator />
|
||||
{/if}
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link
|
||||
href="#"
|
||||
current={i === ancestry.length - 1}
|
||||
data-idpath={node.idPath}
|
||||
data-kind={node.kind}>{crumbLabel(node.kind, node.index, ts)}</Breadcrumb.Link
|
||||
>
|
||||
</Breadcrumb.Item>
|
||||
{/each}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{/if}
|
||||
@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Colour row — labelled eidos `<ColorPicker>` bound to a block's hex (our
|
||||
* component, NOT a native input). Same draft/`onValueChangeEnd` pattern as the
|
||||
* Words inspector: a local `ColorValue` draft synced from `current` (the
|
||||
* effective hex — override or inherited) so external edits propagate without
|
||||
* clobbering a mid-drag value.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ColorPicker } from '$uix/eidos/components/color-picker';
|
||||
import { PickerShell } from '$uix/eidos/components/picker-shell';
|
||||
import { parseColor, colorValueFromHsv, type ColorValue } from '$libs/color';
|
||||
|
||||
let {
|
||||
label,
|
||||
current,
|
||||
presets = [],
|
||||
onPick
|
||||
}: {
|
||||
label: string;
|
||||
current: string | undefined;
|
||||
presets?: readonly string[];
|
||||
onPick: (hex: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
let draft = $state<ColorValue | undefined>(undefined);
|
||||
$effect(() => {
|
||||
const hexIn = current;
|
||||
untrack(() => {
|
||||
if (!hexIn) {
|
||||
if (draft !== undefined) draft = undefined;
|
||||
return;
|
||||
}
|
||||
const hsv = parseColor(hexIn);
|
||||
const next = hsv ? colorValueFromHsv(hsv) : undefined;
|
||||
if (next?.hex !== draft?.hex) draft = next;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div data-palabras-field data-inline>
|
||||
<span data-palabras-field-label>{label}</span>
|
||||
<ColorPicker
|
||||
size="sm"
|
||||
bind:value={draft}
|
||||
onValueChange={(cv: ColorValue | undefined) => {
|
||||
if (cv === undefined && current !== undefined) onPick(undefined);
|
||||
}}
|
||||
onValueChangeEnd={(cv: ColorValue | undefined) => onPick(cv?.hex)}
|
||||
>
|
||||
<ColorPicker.Control>
|
||||
<!-- The Trigger already paints ONE swatch (showSwatch); no inner ValueSwatch. -->
|
||||
<ColorPicker.Trigger />
|
||||
</ColorPicker.Control>
|
||||
<ColorPicker.Portal>
|
||||
<ColorPicker.Content>
|
||||
<PickerShell.Body>
|
||||
<ColorPicker.Area />
|
||||
<ColorPicker.ChannelSlider channel="hue" />
|
||||
<ColorPicker.ChannelSlider channel="alpha" />
|
||||
<ColorPicker.ChannelInput />
|
||||
{#if presets.length}
|
||||
<ColorPicker.SwatchGroup>
|
||||
{#each presets as c (c)}
|
||||
<ColorPicker.SwatchTrigger color={c}>
|
||||
<ColorPicker.Swatch color={c} />
|
||||
<ColorPicker.SwatchIndicator />
|
||||
</ColorPicker.SwatchTrigger>
|
||||
{/each}
|
||||
</ColorPicker.SwatchGroup>
|
||||
{/if}
|
||||
</PickerShell.Body>
|
||||
<ColorPicker.Footer>
|
||||
<ColorPicker.Clear />
|
||||
<ColorPicker.Cancel />
|
||||
<ColorPicker.Close />
|
||||
</ColorPicker.Footer>
|
||||
</ColorPicker.Content>
|
||||
</ColorPicker.Portal>
|
||||
</ColorPicker>
|
||||
</div>
|
||||
@ -1,283 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Per-column insert affordance — a round "+" overlay on each column's BOTTOM
|
||||
* border, shown ONLY while the cursor is over that column. Click → the same
|
||||
* block-type grid the grip uses → APPENDS into that column via `insertBlockInColumn`
|
||||
* (now path-aware: `insertBlockAtPath` at the column's end).
|
||||
*
|
||||
* Positioning: an absolutely-positioned overlay measured FRAME-relative
|
||||
* (`[data-words]`), like Words' drop-line seam — so it never makes the canvas a
|
||||
* containing block (which would break the seam's frame-relative anchor). The
|
||||
* menu is PORTALED (`DropdownMenu.Portal`) so the trigger's centring transform
|
||||
* doesn't become the menu's containing block (the grip's positioning bug).
|
||||
*
|
||||
* `data-words-external-tool` marks the "+" + menu as internal to the editor's
|
||||
* focus scope, so opening them doesn't trip the blur→refocus loop.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
import { Plus } from '$uix/eidos/components/icon';
|
||||
import { blockToInsert, insertableBlockTypes, type PalabrasBlockType } from './palabras-block-types';
|
||||
import { insertBlockInColumn } from './palabras-block-actions';
|
||||
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
} = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const uid = $props.id();
|
||||
const ts = (ref: string) => eidos.langs.ts(ref);
|
||||
const insertLabel = $derived(
|
||||
ts('#?components.palabras.action.insert-in-column|Insertar bloque en la columna')
|
||||
);
|
||||
|
||||
// Types offered inside a column: everything EXCEPT columns (no columns-in-columns).
|
||||
const colTypes = insertableBlockTypes().filter((t) => t.id !== 'columns');
|
||||
|
||||
type Inserter = {
|
||||
key: string;
|
||||
left: number;
|
||||
top: number;
|
||||
columnsIdx: number;
|
||||
colIdx: number;
|
||||
};
|
||||
let inserters = $state<Inserter[]>([]);
|
||||
// The button is shown ONLY for the column the cursor is currently over.
|
||||
let hoveredKey = $state<string | null>(null);
|
||||
// Top-level index of the columns block that is the ACTIVE block, else -1. When
|
||||
// the columns block itself is SELECTED (you clicked it to move/delete the whole
|
||||
// thing), its empty columns' "+" must NOT show — "no debería aparecer aún".
|
||||
const activeColumnsIdx = $derived.by(() => {
|
||||
const loc = api.activeBlockLocation;
|
||||
if (loc && loc.path.length === 1 && (loc.node as { type?: string }).type === 'columns') {
|
||||
return loc.path[0];
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
// Grace timer: a short delay before HIDING, so a brief overshoot while reaching
|
||||
// for the button (which hangs off the column's bottom edge) doesn't snatch it
|
||||
// away — "desaparece antes de que pueda pulsarlo".
|
||||
// Grace timer via the ActiveUix Timer service (key-addressed: re-scheduling
|
||||
// REPLACES the pending hide via `{ replace: true }` — the engine throws on a
|
||||
// duplicate key otherwise; cancel is by key) — never a raw `setTimeout`.
|
||||
const HIDE_KEY = `palabras-col-inserter-hide-${uid}`;
|
||||
function scheduleHide() {
|
||||
eidos.timers.schedule(
|
||||
HIDE_KEY,
|
||||
400,
|
||||
() => {
|
||||
hoveredKey = null;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
function cancelHide() {
|
||||
eidos.timers.cancel(HIDE_KEY);
|
||||
}
|
||||
|
||||
function frame(): HTMLElement | null {
|
||||
return (content?.closest('[data-words]') as HTMLElement | null) ?? null;
|
||||
}
|
||||
|
||||
// The inserter key (`<columnsIdx>-<colIdx>`) for a column ELEMENT — re-derived
|
||||
// from its live position, NOT a stored element reference (which goes stale when
|
||||
// the engine re-renders the columns). Mirrors `remeasure`'s key, so the hovered
|
||||
// column resolves to the SAME key the rendered "+" carries.
|
||||
function columnKey(colEl: HTMLElement): string | null {
|
||||
const colsBlock = colEl.closest('[data-words-block="columns"]') as HTMLElement | null;
|
||||
if (!colsBlock) return null;
|
||||
const tops = Array.from(content.children).filter(
|
||||
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
|
||||
);
|
||||
const columnsIdx = tops.indexOf(colsBlock);
|
||||
const colSibs = Array.from(colsBlock.children).filter(
|
||||
(c): c is HTMLElement =>
|
||||
c instanceof HTMLElement && c.getAttribute('data-words-node') === 'column'
|
||||
);
|
||||
const colIdx = colSibs.indexOf(colEl);
|
||||
return columnsIdx >= 0 && colIdx >= 0 ? `${columnsIdx}-${colIdx}` : null;
|
||||
}
|
||||
|
||||
function remeasure() {
|
||||
const fr = frame();
|
||||
if (!fr) {
|
||||
inserters = [];
|
||||
return;
|
||||
}
|
||||
const frameRect = fr.getBoundingClientRect();
|
||||
const tops = Array.from(content.children).filter(
|
||||
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
|
||||
);
|
||||
const out: Inserter[] = [];
|
||||
content.querySelectorAll('[data-words-block="columns"]').forEach((cb) => {
|
||||
const colsBlock = cb as HTMLElement;
|
||||
const columnsIdx = tops.indexOf(colsBlock);
|
||||
if (columnsIdx < 0) return;
|
||||
const cols = Array.from(colsBlock.children).filter(
|
||||
(c): c is HTMLElement =>
|
||||
c instanceof HTMLElement && c.getAttribute('data-words-node') === 'column'
|
||||
);
|
||||
cols.forEach((col, colIdx) => {
|
||||
// EMPTY columns ONLY. Once a column has blocks, each block carries its
|
||||
// own grip whose menu handles everything (insert / move / delete / …),
|
||||
// so a second column-level button would be redundant — "no puede haber
|
||||
// dos botones al mismo tiempo activos". The "+" is purely the affordance
|
||||
// to seed the FIRST block into an empty column.
|
||||
const hasBlock = Array.from(col.children).some(
|
||||
(c) => c instanceof HTMLElement && c.hasAttribute('data-words-id')
|
||||
);
|
||||
if (hasBlock) return;
|
||||
const cr = col.getBoundingClientRect();
|
||||
// CENTRED in the empty column — NOT on the bottom border. The bottom
|
||||
// position forced the cursor all the way down to reach it, dragging the
|
||||
// gutter grip down with it; a centred "+" is reached with little travel.
|
||||
const left = Math.round(cr.left - frameRect.left + cr.width / 2);
|
||||
const top = Math.round(cr.top - frameRect.top + cr.height / 2);
|
||||
out.push({ key: `${columnsIdx}-${colIdx}`, columnsIdx, colIdx, left, top });
|
||||
});
|
||||
});
|
||||
inserters = out;
|
||||
}
|
||||
|
||||
// Re-measure when the document changes...
|
||||
$effect(() => {
|
||||
void api.html;
|
||||
untrack(() => remeasure());
|
||||
});
|
||||
// ...and on scroll / resize (positions are viewport-derived).
|
||||
$effect(() => {
|
||||
const offScroll = dom.listen(dom.getDocument(content), 'scroll', () => remeasure(), {
|
||||
capture: true
|
||||
});
|
||||
const offResize = dom.listen(dom.getWindow(content), 'resize', () => remeasure());
|
||||
return () => {
|
||||
offScroll?.();
|
||||
offResize?.();
|
||||
};
|
||||
});
|
||||
|
||||
// Show a column's "+" ONLY while the cursor is over that column (the deferred
|
||||
// "los + deben desaparecer al no estar activos"). The menu is portaled +
|
||||
// independent, so hiding the trigger while its menu is open is harmless.
|
||||
$effect(() => {
|
||||
// Listen on `content` (the contenteditable) — a stable prop that is always
|
||||
// an ancestor of the columns, so pointermove over a column bubbles here. (An
|
||||
// earlier version listened on `frame()` = `[data-words]`, which could be null
|
||||
// at effect-setup time and then never re-attached.)
|
||||
if (!content) return;
|
||||
// Listen on the CANVAS (parent of both the contenteditable AND the button
|
||||
// overlays), so pointermove over the button itself also fires here — letting
|
||||
// us KEEP it shown while the cursor is on it (the button is a sibling of
|
||||
// `content`, so a `content` listener never sees those moves).
|
||||
const canvas = (content.closest('[data-palabras-canvas]') as HTMLElement | null) ?? content;
|
||||
const onMove = (e: Event) => {
|
||||
const t = (e as PointerEvent).target as HTMLElement | null;
|
||||
// Over the button itself → keep it shown (cancel any pending hide).
|
||||
if (t?.closest('[data-palabras-column-inserter]')) {
|
||||
cancelHide();
|
||||
return;
|
||||
}
|
||||
const x = (e as PointerEvent).clientX;
|
||||
const y = (e as PointerEvent).clientY;
|
||||
// GEOMETRY, not `closest()`: the round button straddles the column's
|
||||
// BOTTOM border, half of it hanging below. A `closest()` test loses the
|
||||
// column the moment the cursor crosses that border. Treat the column rect
|
||||
// EXPANDED downward by the button's reach as "still hovering the column".
|
||||
let key: string | null = null;
|
||||
for (const el of content.querySelectorAll('[data-words-column]')) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom + 18) {
|
||||
key = columnKey(el);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (key) {
|
||||
cancelHide();
|
||||
hoveredKey = key;
|
||||
} else {
|
||||
// Grace period before hiding (don't snatch the button mid-reach).
|
||||
scheduleHide();
|
||||
}
|
||||
};
|
||||
const onLeave = () => scheduleHide();
|
||||
const offMove = dom.listen(canvas, 'pointermove', onMove);
|
||||
const offLeave = dom.listen(canvas, 'pointerleave', onLeave);
|
||||
return () => {
|
||||
offMove?.();
|
||||
offLeave?.();
|
||||
cancelHide();
|
||||
};
|
||||
});
|
||||
|
||||
// On close, land the caret in the NEW block. NOT `content.focus()` — that
|
||||
// restores the STALE pre-insert DOM caret (often the block AFTER the columns),
|
||||
// and the caret-follower then bounces the active block there ("se va a la
|
||||
// siguiente fila"). `restoreCaret()` writes the MODEL selection — which the
|
||||
// engine's `insertBlockInColumn` landed inside the new block — so the caret,
|
||||
// the active block and the grip all agree on the new block. Deferred one frame
|
||||
// so the freshly-inserted block is mounted before we write to it.
|
||||
function onCloseAutoFocus(e: Event) {
|
||||
e.preventDefault();
|
||||
dom.requestFrame(() => api.restoreCaret(), content);
|
||||
}
|
||||
|
||||
function pick(ins: Inserter, bt: PalabrasBlockType) {
|
||||
// Image inserts a PLACEHOLDER (`{ type: 'image', src: '' }`); the URL / alt are
|
||||
// set later from the properties panel. No file picker on insert.
|
||||
insertBlockInColumn(api, ins.columnsIdx, ins.colIdx, blockToInsert(bt, ts));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each inserters as ins (ins.key)}
|
||||
<Box
|
||||
data-palabras-column-inserter
|
||||
data-visible={ins.key === hoveredKey && ins.columnsIdx !== activeColumnsIdx ? '' : undefined}
|
||||
style="left: {ins.left}px; top: {ins.top}px"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="full"
|
||||
data-palabras-column-inserter-button
|
||||
data-words-external-tool
|
||||
aria-label={insertLabel}
|
||||
>
|
||||
{#snippet icon()}<Plus />{/snippet}
|
||||
{insertLabel}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
preventScroll={false}
|
||||
trapFocus={false}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
data-palabras-handle-menu
|
||||
data-words-external-tool
|
||||
data-palabras-insert-grid
|
||||
>
|
||||
{#each colTypes as bt (bt.id)}
|
||||
{@const Icon = bt.icon}
|
||||
<DropdownMenu.Item onSelect={() => pick(ins, bt)}>
|
||||
<Icon />
|
||||
<span>{ts(bt.label)}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</Box>
|
||||
{/each}
|
||||
@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Reusable drag state for the floating popovers. Returns a reactive `transform`
|
||||
* (apply to the popover root) + pointer handlers (wire to the drag handle, e.g.
|
||||
* the header). Buttons inside the handle opt out so they stay clickable. Each
|
||||
* call owns independent state, so panel + appearance drag separately; remounting
|
||||
* the popover resets the position.
|
||||
*/
|
||||
export function createDrag() {
|
||||
const pos = $state({ x: 0, y: 0 });
|
||||
let from: { px: number; py: number; ox: number; oy: number } | null = null;
|
||||
|
||||
return {
|
||||
get transform() {
|
||||
return `translate(${pos.x}px, ${pos.y}px)`;
|
||||
},
|
||||
down(e: PointerEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* synthetic / unsupported pointer — still tracks via move */
|
||||
}
|
||||
from = { px: e.clientX, py: e.clientY, ox: pos.x, oy: pos.y };
|
||||
},
|
||||
move(e: PointerEvent) {
|
||||
if (!from) return;
|
||||
pos.x = from.ox + (e.clientX - from.px);
|
||||
pos.y = from.oy + (e.clientY - from.py);
|
||||
},
|
||||
up(e: PointerEvent) {
|
||||
if (!from) return;
|
||||
from = null;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Block grip — a drag-handle that opens the properties panel.
|
||||
*
|
||||
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at ANY
|
||||
* nesting depth. A top-level block gets its grip in the left RAIL; a block INSIDE a
|
||||
* column gets its grip at the block's own left edge (floating out of the rail into
|
||||
* the canvas).
|
||||
*
|
||||
* Two behaviours from the same trigger, by the browser's click-vs-drag:
|
||||
* - CLICK → opens the properties PANEL directly. Every per-block action — move /
|
||||
* insert / delete — now lives in the panel itself (its footer + header), so the
|
||||
* grip no longer opens an intermediate menu: one click instead of two.
|
||||
* - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink). Drag
|
||||
* is enabled ONLY for top-level blocks — the drop sink is top-level.
|
||||
*
|
||||
* The trigger carries `data-words-external-tool` so the provider's
|
||||
* `isInsideWordsTool` treats focus landing here as internal (no editor churn).
|
||||
*
|
||||
* Grip placement: PINNED to the active block (always ⋮). It moves only when the
|
||||
* active block changes (click / Arrow nav / breadcrumb) — never on hover, which
|
||||
* was erratic.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import type { ActiveDom } from '$adom';
|
||||
import { findBlockById, type ProviderSnippetProps } from '$soma/components/words';
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { EllipsisVertical } from '$uix/eidos/components/icon';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
|
||||
|
||||
// `panelOpen` / `onClose` stay in the props TYPE (the parent's call is
|
||||
// unchanged) but are deliberately not destructured — the grip no longer
|
||||
// needs them: every action moved into the panel. (`dom` IS used — the
|
||||
// grip re-measures via `dom.observeResize`.)
|
||||
let {
|
||||
api,
|
||||
content,
|
||||
dom,
|
||||
onOpen
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
content: HTMLElement;
|
||||
dom: ActiveDom;
|
||||
panelOpen?: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks keep
|
||||
// their handle flush in the rail at left:0). Must be > the handle's own width so
|
||||
// it clears the block's text, and the columns `gap` (palabras.css) must exceed it.
|
||||
const HANDLE_REACH = 30;
|
||||
|
||||
// The grip carries its block's full id-path + numeric path + position-in-parent,
|
||||
// captured at placement. `draggable` is true only for top-level blocks (drag sink
|
||||
// is top-level). `top`/`left` are host-relative (the slot lives in the rail).
|
||||
type Grip = {
|
||||
top: number;
|
||||
left: number;
|
||||
id: string;
|
||||
path: readonly number[];
|
||||
draggable: boolean;
|
||||
};
|
||||
let grip = $state<Grip | null>(null);
|
||||
|
||||
/** Full materialized id-path of a block element: the `/`-joined chain of every
|
||||
* `data-words-id` ancestor (root → block). Matches `findBlockById`'s addressing. */
|
||||
function idPathOf(target: HTMLElement, host: HTMLElement): string {
|
||||
const ids: string[] = [];
|
||||
let el: HTMLElement | null = target.closest('[data-words-id]');
|
||||
while (el && host.contains(el)) {
|
||||
const id = el.getAttribute('data-words-id');
|
||||
if (id) ids.unshift(id);
|
||||
el = el.parentElement?.closest('[data-words-id]') ?? null;
|
||||
}
|
||||
return ids.join('/');
|
||||
}
|
||||
function host(): HTMLElement | null {
|
||||
return (content?.closest('[data-palabras-doc]') as HTMLElement | null) ?? null;
|
||||
}
|
||||
function topLevelBlocks(): HTMLElement[] {
|
||||
return Array.from(content.children).filter(
|
||||
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
|
||||
);
|
||||
}
|
||||
/** Find a block element (top-level OR nested) by its full id-path. */
|
||||
function findElByIdPath(idPath: string): HTMLElement | null {
|
||||
const h = host();
|
||||
if (!h || !idPath) return null;
|
||||
for (const el of content.querySelectorAll('[data-words-id]')) {
|
||||
if (el instanceof HTMLElement && idPathOf(el, h) === idPath) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** Top-level index of a block (or of its top-level ancestor) — `path` fallback. */
|
||||
function topIndexOf(block: HTMLElement): number {
|
||||
let el: HTMLElement = block;
|
||||
while (el.parentElement && el.parentElement !== content) el = el.parentElement;
|
||||
return topLevelBlocks().indexOf(el);
|
||||
}
|
||||
// Build the grip for a block: position (host-relative), full id-path, numeric path,
|
||||
// and whether it's draggable (top-level only).
|
||||
function gripFor(block: HTMLElement, h: HTMLElement): Grip {
|
||||
// A LIST is gripped as ONE unit — never an individual item. Climb from a list
|
||||
// item to its list block (the grip targets the whole list, at its left edge).
|
||||
if (block.getAttribute('data-words-node') === 'list-item') {
|
||||
const listBlock = block.closest('[data-words-block]');
|
||||
if (listBlock instanceof HTMLElement) block = listBlock;
|
||||
}
|
||||
const hr = h.getBoundingClientRect();
|
||||
const br = block.getBoundingClientRect();
|
||||
const id = idPathOf(block, h);
|
||||
const loc = findBlockById(api.document, id);
|
||||
const path = loc?.path ?? [topIndexOf(block)];
|
||||
const isTop = path.length <= 1;
|
||||
return {
|
||||
top: br.top - hr.top + br.height / 2,
|
||||
// Top-level → flush in the rail. Nested → just left of the block, floating
|
||||
// out of the rail into the canvas.
|
||||
left: isTop ? 0 : Math.round(br.left - hr.left - HANDLE_REACH),
|
||||
id,
|
||||
path,
|
||||
draggable: isTop
|
||||
};
|
||||
}
|
||||
|
||||
// PINNED to the active block — the grip shows ⋮ and tracks the active block. It
|
||||
// re-measures on active-change / doc-change (the reactive deps below) AND on layout
|
||||
// shifts those deps DON'T capture: a content RESIZE (a block growing, or — the
|
||||
// recurring culprit — the heading FONT swapping in after first paint and changing
|
||||
// its height) and the webfonts settling. Without these the grip is pinned to a
|
||||
// STALE rect and drifts off the block until the next active-change.
|
||||
$effect(() => {
|
||||
const h = host();
|
||||
const activeId = api.activeBlockId;
|
||||
void api.html;
|
||||
if (!h || !content) return;
|
||||
let disposed = false;
|
||||
const place = () => {
|
||||
if (disposed) return;
|
||||
const el = activeId ? findElByIdPath(activeId) : null;
|
||||
grip = el ? gripFor(el, h) : null;
|
||||
};
|
||||
untrack(place);
|
||||
// Re-place whenever the editor content reflows (block height / font swap) and
|
||||
// once the webfonts have settled (the swap lands after the first measure).
|
||||
const offResize = dom.observeResize(content, () => untrack(place));
|
||||
dom.getDocument(h).fonts?.ready?.then(() => untrack(place));
|
||||
return () => {
|
||||
disposed = true;
|
||||
offResize?.();
|
||||
};
|
||||
});
|
||||
|
||||
// CLICK → open the properties panel on this block.
|
||||
function openProperties() {
|
||||
if (!grip) return;
|
||||
api.setActiveBlock(grip.id);
|
||||
onOpen(grip.id);
|
||||
}
|
||||
const eidos = ActiveEidos.require();
|
||||
const propertiesLabel = $derived(
|
||||
eidos.langs.ts('#?components.palabras.action.properties|Propiedades del bloque')
|
||||
);
|
||||
// HTML5 drag — top-level blocks ONLY. Sets the canonical MIME + effectAllowed.
|
||||
function onDragStart(e: DragEvent) {
|
||||
if (!grip || !grip.draggable || !e.dataTransfer) return;
|
||||
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if grip}
|
||||
<!-- Positioned slot: owns the gutter/inline placement (host-relative inline). -->
|
||||
<Box data-palabras-handle-slot style="top: {grip.top}px; left: {grip.left}px">
|
||||
<!-- The grip = a draggable framework <Button>. CLICK opens the properties panel
|
||||
directly (its footer + header hold every per-block action — no menu). DRAG
|
||||
(top-level only) reorders. `data-words-external-tool` keeps focus landing
|
||||
here inside the editor's focus scope. -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
rounded="full"
|
||||
data-palabras-handle
|
||||
data-nested={grip.path.length > 1 ? '' : undefined}
|
||||
data-words-external-tool
|
||||
aria-label={propertiesLabel}
|
||||
title={propertiesLabel}
|
||||
draggable={grip.draggable}
|
||||
ondragstart={onDragStart}
|
||||
onclick={openProperties}
|
||||
>
|
||||
{#snippet icon()}<EllipsisVertical />{/snippet}
|
||||
</Button>
|
||||
</Box>
|
||||
{/if}
|
||||
@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Panel dismissal. Render-less. While the properties panel is open this
|
||||
* listens for `Escape` or a pointer-down OUTSIDE the panel (and outside a
|
||||
* block grip) and calls `onClose`. Mounted only while the panel is open, so
|
||||
* the listeners exist exactly while they're needed.
|
||||
*/
|
||||
import type { ActiveDom } from '$adom';
|
||||
|
||||
let { dom, onClose }: { dom: ActiveDom; onClose: () => void } = $props();
|
||||
|
||||
$effect(() => {
|
||||
const doc = dom.getDocument();
|
||||
const onKey = (e: Event) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: Event) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (
|
||||
t?.closest('[data-palabras-panel-card]') ||
|
||||
t?.closest('[data-palabras-appearance]') ||
|
||||
t?.closest('[data-palabras-handle]') ||
|
||||
// The ColorPicker portals its picker outside the popover; clicking
|
||||
// inside it must NOT dismiss the inspector.
|
||||
t?.closest('[data-color-picker-content]')
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
const offKey = dom.listen(doc, 'keydown', onKey);
|
||||
const offDown = dom.listen(doc, 'pointerdown', onDown, { capture: true });
|
||||
return () => {
|
||||
offKey?.();
|
||||
offDown?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Reusable segmented control for the palabras panel — a single-select
|
||||
* `ToggleGroup` with the demo's appearance (`solid` + `attached`, `sm`).
|
||||
* Each option renders as an ICON (icon-only, the label becomes the accessible
|
||||
* name + tooltip) when it carries an `icon`, otherwise as its text label.
|
||||
*
|
||||
* Every `pills` field routes through here (alignment with icons, fit with
|
||||
* text, …). Palabras-scoped on purpose: it bundles panel-specific defaults,
|
||||
* it is NOT a new framework primitive — the framework `ToggleGroup` stays
|
||||
* untouched and we just compose it.
|
||||
*/
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import type { PalabrasFieldOption } from './types';
|
||||
|
||||
let {
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
deselectable = true,
|
||||
ariaLabel,
|
||||
onValueChange
|
||||
}: {
|
||||
/** Currently selected option value (stringified). */
|
||||
value: string | undefined;
|
||||
readonly options: readonly PalabrasFieldOption[];
|
||||
disabled?: boolean;
|
||||
/** When `false`, a selection is REQUIRED: re-pressing the active option does
|
||||
* not clear it (radio-like). @default true */
|
||||
deselectable?: boolean;
|
||||
ariaLabel?: string;
|
||||
/** Fired with the picked option's stringified value, or `undefined` when the
|
||||
* active item is re-clicked (deselected, only when `deselectable`) — lets the
|
||||
* field clear / restore. */
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="xs"
|
||||
attached
|
||||
{disabled}
|
||||
{deselectable}
|
||||
aria-label={ariaLabel}
|
||||
value={value == null ? [] : [value]}
|
||||
onValueChange={(v) => onValueChange(v[0])}
|
||||
data-palabras-segmented
|
||||
>
|
||||
{#each options as opt (opt.value)}
|
||||
{@const Icon = opt.icon}
|
||||
<ToggleGroup.Item
|
||||
value={String(opt.value)}
|
||||
iconOnly={!!Icon || !!opt.glyph}
|
||||
aria-label={opt.label}
|
||||
title={opt.label}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon />
|
||||
{:else if opt.glyph}
|
||||
<span data-palabras-glyph style={opt.glyphStyle}>{opt.glyph}</span>
|
||||
{:else}
|
||||
{opt.label}
|
||||
{/if}
|
||||
</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
@ -1,158 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Margin / box-spacing control for the palabras panel — ONE attached group of
|
||||
* four px cells (Arriba · Derecha · Abajo · Izquierda, the CSS order) with
|
||||
* their captions BELOW and a single LINK toggle at the end (the reference
|
||||
* inspector pattern: Elementor / Figma).
|
||||
*
|
||||
* LINKED (default while no per-side key is set and both axis keys agree):
|
||||
* editing any cell writes BOTH axis keys (`block` + `inline`) so the four
|
||||
* sides share the value. UNLINKED: each cell writes its own per-side key
|
||||
* (`blockStart` / `blockEnd` / `inlineStart` / `inlineEnd`). The engine
|
||||
* resolves `blockStart ?? block`, etc.
|
||||
*
|
||||
* Link state is DATA-DERIVED — toggling writes data to flip it, so switching
|
||||
* blocks resets it automatically (no stale local state).
|
||||
*
|
||||
* Palabras-scoped: NOT a framework primitive. The cells are native inputs on
|
||||
* the panel's own input tokens (same precedent as `data-palabras-text`);
|
||||
* values are raw px numbers (the engine's spacing model).
|
||||
*/
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Link2, Link2Off } from '$uix/eidos/components/icon';
|
||||
import type { WordsSpacing } from '$soma/components/words';
|
||||
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
disabled = false,
|
||||
onValueChange
|
||||
}: {
|
||||
/** Field label — the control OWNS its header row (label + link toggle). */
|
||||
label: string;
|
||||
/** Current spacing (raw px numbers, per axis and/or per side). */
|
||||
value: WordsSpacing | undefined;
|
||||
disabled?: boolean;
|
||||
/** Fired with the FULL next spacing, or `undefined` when every side is cleared. */
|
||||
onValueChange: (value: WordsSpacing | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
// Linked while NO per-side key is set and the two axis keys agree (both may
|
||||
// be unset). Purely data-derived so block switches reset it for free.
|
||||
const linked = $derived(
|
||||
value?.blockStart == null &&
|
||||
value?.blockEnd == null &&
|
||||
value?.inlineStart == null &&
|
||||
value?.inlineEnd == null &&
|
||||
(value?.block ?? null) === (value?.inline ?? null)
|
||||
);
|
||||
|
||||
// Resolved per-side values shown in each cell: per-side ?? axis.
|
||||
const top = $derived(value?.blockStart ?? value?.block);
|
||||
const right = $derived(value?.inlineEnd ?? value?.inline);
|
||||
const bottom = $derived(value?.blockEnd ?? value?.block);
|
||||
const left = $derived(value?.inlineStart ?? value?.inline);
|
||||
|
||||
function parse(raw: string): number | undefined {
|
||||
const n = parseFloat(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** Merge a patch into the current spacing, drop cleared (non-number) keys, and
|
||||
* emit `undefined` when nothing remains (so the prop clears). */
|
||||
function emit(patch: Partial<Record<keyof WordsSpacing, number | undefined>>) {
|
||||
const merged: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries({ ...value, ...patch })) {
|
||||
if (typeof v === 'number') merged[k] = v;
|
||||
}
|
||||
onValueChange(Object.keys(merged).length ? (merged as WordsSpacing) : undefined);
|
||||
}
|
||||
|
||||
const SIDE_KEYS = ['blockStart', 'inlineEnd', 'blockEnd', 'inlineStart'] as const;
|
||||
function setSide(key: (typeof SIDE_KEYS)[number], raw: string) {
|
||||
const n = parse(raw);
|
||||
if (linked) {
|
||||
emit({
|
||||
block: n,
|
||||
inline: n,
|
||||
blockStart: undefined,
|
||||
blockEnd: undefined,
|
||||
inlineStart: undefined,
|
||||
inlineEnd: undefined
|
||||
});
|
||||
} else {
|
||||
emit({ [key]: n });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLink() {
|
||||
if (linked) {
|
||||
// Freeze the current resolved sides into per-side keys.
|
||||
emit({
|
||||
blockStart: top,
|
||||
blockEnd: bottom,
|
||||
inlineStart: left,
|
||||
inlineEnd: right,
|
||||
block: undefined,
|
||||
inline: undefined
|
||||
});
|
||||
} else {
|
||||
// Re-link on the first defined side (reference inspectors' convention).
|
||||
const v = top ?? right ?? bottom ?? left;
|
||||
emit({
|
||||
block: v,
|
||||
inline: v,
|
||||
blockStart: undefined,
|
||||
blockEnd: undefined,
|
||||
inlineStart: undefined,
|
||||
inlineEnd: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const CELLS = $derived([
|
||||
{ key: 'blockStart' as const, caption: 'Arriba', value: top },
|
||||
{ key: 'inlineEnd' as const, caption: 'Derecha', value: right },
|
||||
{ key: 'blockEnd' as const, caption: 'Abajo', value: bottom },
|
||||
{ key: 'inlineStart' as const, caption: 'Izquierda', value: left }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div data-palabras-spacing data-disabled={disabled ? '' : undefined} role="group">
|
||||
<!-- Header row: the field label + the SINGLE link toggle on the trailing
|
||||
edge (the ratified mockup). -->
|
||||
<div data-palabras-spacing-head>
|
||||
<span data-palabras-field-label>{label}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
iconOnly
|
||||
{disabled}
|
||||
data-palabras-spacing-link
|
||||
aria-pressed={linked}
|
||||
aria-label={linked ? 'Desvincular lados' : 'Vincular lados'}
|
||||
title={linked ? 'Vinculado — pulsa para separar' : 'Separado — pulsa para vincular'}
|
||||
onclick={toggleLink}
|
||||
>
|
||||
{#snippet icon()}{#if linked}<Link2 />{:else}<Link2Off />{/if}{/snippet}
|
||||
</Button>
|
||||
</div>
|
||||
{#each CELLS as cell (cell.key)}
|
||||
<input
|
||||
data-palabras-spacing-cell
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
{disabled}
|
||||
value={cell.value == null ? '' : String(cell.value)}
|
||||
aria-label={cell.caption}
|
||||
onchange={(e) => setSide(cell.key, e.currentTarget.value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#each CELLS as cell (cell.key)}
|
||||
<span data-palabras-spacing-side>{cell.caption}</span>
|
||||
{/each}
|
||||
</div>
|
||||
@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<palabras>` — the semantic-panel editor (eidos).
|
||||
*
|
||||
* Reuses the Words block engine + soma `Words.Provider` (document model,
|
||||
* selection, contenteditable). The editor is a clean FULL-WIDTH surface; the
|
||||
* properties panel is a floating window TRIGGERED by a block grip (⋮⋮) and
|
||||
* dismissed with Esc / click-away — never a permanent docked sidebar. Chrome
|
||||
* is composed from our UIX (Flex · Card · Breadcrumb · ToggleGroup · Switch).
|
||||
*
|
||||
* `schemeStyle` (the look) and `saveas` (export format) are the two palabras
|
||||
* knobs; everything else flows through to the provider. Pilot block: heading.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as Words from '$soma/components/words';
|
||||
import { Card } from '$uix/eidos/components/card';
|
||||
import { Flex } from '$uix/eidos/components/flex';
|
||||
import WordsActivate from './palabras-activate.svelte';
|
||||
import WordsBlockDrag from './palabras-block-drag.svelte';
|
||||
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
|
||||
import PalabrasColumnInserter from './palabras-column-inserter.svelte';
|
||||
import PalabrasHandle from './palabras-handle.svelte';
|
||||
import PalabrasPanel from './palabras-panel.svelte';
|
||||
import { resolvePalabrasScheme, schemeCssVars } from './palabras-scheme';
|
||||
import type { PalabrasProps } from './types';
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
schemeStyle = 'default',
|
||||
saveas = 'json',
|
||||
...headlessProps
|
||||
}: PalabrasProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const dom = eidos.dom;
|
||||
|
||||
// The document's contract (fonts / scale / palette / editable / styles),
|
||||
// from a registered name or an inline object. Projected as CSS vars on
|
||||
// the doc root — the document renders from the SCHEME, never from the
|
||||
// host page's theme (back-office doctrine).
|
||||
const scheme = $derived(resolvePalabrasScheme(schemeStyle));
|
||||
const schemeVars = $derived(schemeCssVars(scheme));
|
||||
const schemeName = $derived(typeof schemeStyle === 'string' ? schemeStyle : 'custom');
|
||||
|
||||
// Rich-text contenteditable is client-only (the engine stamps content ids with
|
||||
// `crypto.randomUUID()` → SSR ≠ client markup). Defer to the client.
|
||||
let mounted = $state(false);
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
mounted = true;
|
||||
});
|
||||
});
|
||||
|
||||
let contentEl = $state<HTMLDivElement | null>(null);
|
||||
// The doc row (rail + canvas). Passed to WordsBlockDrag as its drop zone so a
|
||||
// straight-down drag over the rail still fires dragover (the rail is a
|
||||
// separate column from content; listening only on content forced a rightward
|
||||
// veer to land the drop).
|
||||
let docEl = $state<HTMLDivElement | null>(null);
|
||||
// The properties panel is TRIGGERED (block grip) and dismissed (Esc /
|
||||
// click-away) — hidden by default so the editor is a clean full-width surface.
|
||||
let panelOpen = $state(false);
|
||||
</script>
|
||||
|
||||
{#if mounted}
|
||||
<Words.Provider
|
||||
{...headlessProps}
|
||||
bind:value
|
||||
data-palabras
|
||||
data-scheme-style={schemeName}
|
||||
data-saveas={saveas}
|
||||
>
|
||||
{#snippet children(api)}
|
||||
<Flex direction="column" rowGap={4} data-palabras-shell>
|
||||
<PalabrasBreadcrumb {api} onOpen={() => (panelOpen = true)} />
|
||||
<div data-palabras-stage>
|
||||
<!-- `data-no-emerge` opts out of the Card's entrance animation. The
|
||||
residual transform from `card-emerge` would otherwise make the
|
||||
Card a containing block, breaking the Words drop-line seam's
|
||||
frame-relative anchor. -->
|
||||
<Card size="lg" data-palabras-doc-card data-no-emerge>
|
||||
<div data-palabras-doc bind:this={docEl} style={schemeVars}>
|
||||
<!-- Left RAIL — a real column (its OWN div with width), not padding.
|
||||
Holds the block grip; the canvas (content) sits to its right. -->
|
||||
<div data-palabras-rail>
|
||||
{#if contentEl}
|
||||
<PalabrasHandle
|
||||
{api}
|
||||
content={contentEl}
|
||||
{dom}
|
||||
{panelOpen}
|
||||
onOpen={(id) => {
|
||||
api.setActiveBlock(id);
|
||||
panelOpen = true;
|
||||
}}
|
||||
onClose={() => {
|
||||
panelOpen = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div data-palabras-canvas>
|
||||
<Words.Content bind:ref={contentEl} />
|
||||
<Words.Placeholder />
|
||||
{#if contentEl}
|
||||
<WordsActivate
|
||||
{api}
|
||||
content={contentEl}
|
||||
{dom}
|
||||
{panelOpen}
|
||||
onPanelClose={() => (panelOpen = false)}
|
||||
onActivate={(_id, type) => {
|
||||
// An atomic image can't be edited inline — the inspector IS
|
||||
// its interaction, so a click opens it.
|
||||
if (type === 'image') {
|
||||
panelOpen = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<!-- Drop sink for block-reorder drags. `dropZone={docEl}` makes
|
||||
it listen on the whole row (rail + canvas) so a straight-down
|
||||
drag over the rail still fires dragover; geometry comes from
|
||||
`content`. Source is the grip in PalabrasHandle. -->
|
||||
<WordsBlockDrag {api} content={contentEl} {dom} dropZone={docEl} />
|
||||
<!-- A "+" over each EMPTY column → the block-type grid → inserts into
|
||||
that column (Words' `insertBlockInColumn`). -->
|
||||
<PalabrasColumnInserter {api} content={contentEl} {dom} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- The SINGLE properties panel — a floating <FloatPanel> anchored to
|
||||
the active block. Structure + style live in ONE panel (Tipografía
|
||||
direct for text, the rest in the accordion). Owns its show/hide,
|
||||
portal and dismissal (click-outside / Esc). -->
|
||||
<PalabrasPanel
|
||||
{api}
|
||||
{scheme}
|
||||
content={contentEl}
|
||||
open={panelOpen}
|
||||
onClose={() => (panelOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
<p data-palabras-help>
|
||||
haz clic en un bloque y pulsa su tirador <kbd>⋮</kbd> para ver sus propiedades · arrastra
|
||||
la cabecera para moverlo · clic fuera o <kbd>Esc</kbd> para cerrar
|
||||
</p>
|
||||
</Flex>
|
||||
{/snippet}
|
||||
</Words.Provider>
|
||||
{:else}
|
||||
<div data-palabras data-scheme-style={schemeName} aria-busy="true"></div>
|
||||
{/if}
|
||||
@ -1,66 +1,14 @@
|
||||
<script lang="ts">
|
||||
// palabras — pilot demo. Proves the spine: a clean-slate editor on the Words
|
||||
// block engine, chrome composed from our UIX (Flex · Card · Breadcrumb ·
|
||||
// ToggleGroup). Click a heading, then change its level from the panel.
|
||||
import Palabras from '$uix/eidos/components/palabras';
|
||||
import type { PalabrasScheme } from '$uix/eidos/components/palabras';
|
||||
import type { WordsDocument } from '$soma/components/words';
|
||||
import { defaultWordsSchema } from '$soma/components/words';
|
||||
import { createWordsEmbedSpec } from '$soma/components/words/plugins/embed';
|
||||
|
||||
// App-boot plugin registration (F4.5 open-plugin proof): pasting a
|
||||
// YouTube/Vimeo/X/CodePen URL creates an embed block instead of a link.
|
||||
if (!defaultWordsSchema.has('embed')) defaultWordsSchema.register(createWordsEmbedSpec());
|
||||
|
||||
// The document's SCHEME (F3.2) — an inline example of the full contract:
|
||||
// admitted fonts, type scale, palette (ColorPicker presets), per-element
|
||||
// editable whitelist (image offers ONLY these fields), element base styles.
|
||||
// Partial: everything else fills from the solid default. The document
|
||||
// renders from THIS — not from the docs-shell theme around it.
|
||||
// The demo shows the FULL panel (no `editable` whitelist here — that
|
||||
// capability is covered by tests; a curtailed demo reads as broken).
|
||||
const esquema: PalabrasScheme = {
|
||||
sizes: { h1: 34, h2: 27 },
|
||||
palette: ['#1a1a2e', '#e94560', '#0f3460', '#16c79a', '#f5f5f5'],
|
||||
styles: { quote: { color: '#0f3460', border: '3px solid #e94560', padding: 12 } },
|
||||
// Designer-declared extra field (no code): persists as a free block prop.
|
||||
fields: {
|
||||
heading: [{ key: 'urgente', type: 'toggle', label: 'Urgente', tab: 'contenido' }]
|
||||
}
|
||||
};
|
||||
|
||||
let value = $state<WordsDocument>({
|
||||
version: '2.0.0',
|
||||
children: [
|
||||
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Cómo migrar sin downtime' }] },
|
||||
{
|
||||
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: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Haz clic en un encabezado y cambia su nivel desde el panel de la derecha. Pulsa Esc para subir un nivel; haz clic en una miga del breadcrumb para saltar.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
// palabras — the visual layer was DELETED by user order (2026-07-15) to be
|
||||
// rebuilt from scratch in ratified phases. The engine (soma/words), the
|
||||
// scheme contract and the panel registry are intact; this route hosts the
|
||||
// component again as soon as phase 1 of the rebuild lands.
|
||||
</script>
|
||||
|
||||
<main style="padding: 2rem; max-inline-size: 64rem; margin-inline: auto;">
|
||||
<h1 style="margin-block-end: 0.5rem;">palabras — piloto</h1>
|
||||
<p style="margin-block-end: 1.5rem; color: var(--color-content-secondary);">
|
||||
Fase 1: panel generado desde esquema sobre el bloque <code>heading</code>, montado con nuestras
|
||||
piezas (Card · Breadcrumb · Flex · ToggleGroup).
|
||||
<h1 style="margin-block-end: 0.5rem;">palabras — en reconstrucción</h1>
|
||||
<p style="color: var(--color-content-secondary);">
|
||||
La capa visual del componente fue eliminada para reconstruirse desde cero por fases
|
||||
ratificadas. El motor, el contrato del esquema y el registro de paneles siguen intactos.
|
||||
</p>
|
||||
<Palabras bind:value schemeStyle={esquema} />
|
||||
</main>
|
||||
|
||||
Loading…
Reference in new issue