|
|
<script lang="ts">
|
|
|
/**
|
|
|
* Generated block panel — a floating inspector built from the block's schema
|
|
|
* (`PALABRAS_PANEL_REGISTRY`). It's a framework `<Popover>` anchored to the
|
|
|
* active block (`customAnchor`, `side='right'` + `align='center'` → centred on
|
|
|
* the block's mid-height); the Popover owns positioning (flip / shift / fit),
|
|
|
* the portal and dismissal. Field edits write the active block's prop via the
|
|
|
* path-aware `updateBlockAtPath` command.
|
|
|
*
|
|
|
* Controls are FRAMEWORK components (coherence): `ToggleGroup` (pills),
|
|
|
* `Switch` (toggle), `NumberField` (number), `Slider` (slider — photo
|
|
|
* adjustments), `CssField` (length — unit-aware, px/%/auto), native input
|
|
|
* (text). Collapsible groups use the framework
|
|
|
* `Accordion` (animated). PINNED sections (url / alt / caption) sit above the
|
|
|
* accordion, always visible. The header shows the block's DEFINING icon.
|
|
|
* Control DEFAULTS come from the field schema (`field.default`).
|
|
|
*/
|
|
|
import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words';
|
|
|
import { CssField } from '$uix/eidos/components/css-field';
|
|
|
import { Switch } from '$uix/eidos/components/switch';
|
|
|
import { Slider } from '$uix/eidos/components/slider';
|
|
|
import { NumberField } from '$uix/eidos/components/number-field';
|
|
|
import { Accordion } from '$uix/eidos/components/accordion';
|
|
|
import { Button } from '$uix/eidos/components/button';
|
|
|
import { Trash2, Upload } from '$uix/eidos/components/icon';
|
|
|
import { getPanelSchema } from './panel-schema';
|
|
|
import { blockTypeIcon } from './palabras-block-types';
|
|
|
import PalabrasSegmented from './palabras-segmented.svelte';
|
|
|
import { pickImageFile } from '$uix/eidos/components/words/words-image-file';
|
|
|
import { Popover } from '$uix/eidos/components/popover';
|
|
|
import { deleteBlockAndReanchor } from './palabras-block-actions';
|
|
|
import { clampedBlockAnchor } from './palabras-anchor';
|
|
|
import type { PalabrasFieldDef } from './types';
|
|
|
|
|
|
// Panel width — kept in sync with `<Popover.Content width>` AND the anchor clamp.
|
|
|
const PANEL_WIDTH = 272;
|
|
|
|
|
|
let {
|
|
|
api,
|
|
|
content,
|
|
|
open,
|
|
|
onEditAppearance,
|
|
|
onClose
|
|
|
}: {
|
|
|
api: ProviderSnippetProps;
|
|
|
content: HTMLElement | null;
|
|
|
open: boolean;
|
|
|
onEditAppearance: () => void;
|
|
|
onClose: () => void;
|
|
|
} = $props();
|
|
|
|
|
|
const loc = $derived(api.activeBlockLocation);
|
|
|
const schema = $derived(getPanelSchema(loc?.node.type));
|
|
|
// The block's DEFINING icon (per engine type) for the header — for every block.
|
|
|
const HeadIcon = $derived(blockTypeIcon(loc?.node.type));
|
|
|
const canDelete = $derived(!!loc && loc.path.length === 1);
|
|
|
|
|
|
// The active block element drives the Popover's `customAnchor`. The Popover
|
|
|
// owns positioning (flip / shift / fit), the portal + dismissal.
|
|
|
// Reactive to `activeBlockId` so the panel re-anchors when another block opens.
|
|
|
let anchorEl = $state<HTMLElement | null>(null);
|
|
|
$effect(() => {
|
|
|
// Re-resolve on `open` too: the block may already be active (marker stamped)
|
|
|
// before the panel opens, so `activeBlockId` doesn't change to re-trigger us.
|
|
|
void api.activeBlockId;
|
|
|
void open;
|
|
|
anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
|
|
|
});
|
|
|
// A CLAMPED virtual anchor instead of the bare block: keeps the panel to the
|
|
|
// RIGHT and stops it flipping over the doc sidebar when the gutter is narrow
|
|
|
// (it slides inward to overlap the content's edge). `align='center'` centres
|
|
|
// it on the block's mid-height. See palabras-anchor.ts for the why + the cast.
|
|
|
const anchor = $derived.by(() => {
|
|
|
const win = content?.ownerDocument?.defaultView;
|
|
|
if (!anchorEl || !win) return null;
|
|
|
return clampedBlockAnchor(anchorEl, win, {
|
|
|
side: 'right',
|
|
|
panelWidth: PANEL_WIDTH,
|
|
|
gap: 8,
|
|
|
padding: 12
|
|
|
});
|
|
|
});
|
|
|
|
|
|
const pinnedSections = $derived((schema?.sections ?? []).filter((s) => s.pinned));
|
|
|
const accordionSections = $derived((schema?.sections ?? []).filter((s) => !s.pinned));
|
|
|
|
|
|
// Open accordion items (section titles). Seeded per schema: `defaultCollapsed`
|
|
|
// groups start closed, the rest open. Re-seeded when the block TYPE changes.
|
|
|
let openSections = $state<string[]>([]);
|
|
|
let seededFor: unknown;
|
|
|
$effect(() => {
|
|
|
const s = schema;
|
|
|
if (s === seededFor) return;
|
|
|
seededFor = s;
|
|
|
openSections = (s?.sections ?? [])
|
|
|
.filter((sec) => !sec.pinned && !sec.defaultCollapsed)
|
|
|
.map((sec) => sec.title);
|
|
|
});
|
|
|
|
|
|
const VISUAL_KEYS = [
|
|
|
'color',
|
|
|
'background',
|
|
|
'fontSize',
|
|
|
'fontFamily',
|
|
|
'fontWeight',
|
|
|
'lineHeight',
|
|
|
'align',
|
|
|
'margin',
|
|
|
'padding',
|
|
|
'border'
|
|
|
] as const;
|
|
|
const hasOverride = $derived(!!loc && VISUAL_KEYS.some((k) => loc?.node?.[k] != null));
|
|
|
|
|
|
/** Raw stored value of a prop, or undefined. */
|
|
|
function valueOf(key: string): unknown {
|
|
|
return loc?.node?.[key];
|
|
|
}
|
|
|
/** Effective value for display: stored value, else the field's schema default. */
|
|
|
function effective(field: PalabrasFieldDef): unknown {
|
|
|
return loc?.node?.[field.key] ?? field.default;
|
|
|
}
|
|
|
/** Numeric value (or default) for a `number` field — `undefined` when unset. */
|
|
|
function numOrUndef(field: PalabrasFieldDef): number | undefined {
|
|
|
const v = loc?.node?.[field.key];
|
|
|
if (typeof v === 'number') return v;
|
|
|
return typeof field.default === 'number' ? field.default : undefined;
|
|
|
}
|
|
|
/** Numeric value for a `slider` field (defaults applied; 0 fallback). */
|
|
|
function sliderValue(field: PalabrasFieldDef): number {
|
|
|
const v = numOrUndef(field);
|
|
|
return typeof v === 'number' ? v : 0;
|
|
|
}
|
|
|
function write(key: string, value: string | number | boolean | undefined) {
|
|
|
const path = loc?.path;
|
|
|
if (!path) return;
|
|
|
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [key]: value } });
|
|
|
}
|
|
|
function onPills(field: PalabrasFieldDef, v: string | undefined) {
|
|
|
// Deselecting (re-click the active item) clears the prop → restores the default.
|
|
|
if (v == null) {
|
|
|
write(field.key, undefined);
|
|
|
return;
|
|
|
}
|
|
|
const opt = field.options?.find((o) => String(o.value) === v);
|
|
|
if (opt) write(field.key, opt.value);
|
|
|
}
|
|
|
function onNumberChange(field: PalabrasFieldDef, v: number | undefined) {
|
|
|
// clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store
|
|
|
// any finite number INCLUDING 0 (filters: 0 is a real value, e.g. greyscale).
|
|
|
let out: number | undefined;
|
|
|
if (v == null || !Number.isFinite(v)) out = undefined;
|
|
|
else if (field.clearWhenZero && v <= 0) out = undefined;
|
|
|
else out = v;
|
|
|
write(field.key, out);
|
|
|
}
|
|
|
function onUpload() {
|
|
|
const doc = content?.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
|
|
|
if (!doc) return;
|
|
|
pickImageFile(doc, (src, altGuess) => {
|
|
|
write('src', src);
|
|
|
if (altGuess && valueOf('alt') == null) write('alt', altGuess);
|
|
|
});
|
|
|
}
|
|
|
/** Display value for a `length` field: a number renders as `Npx`, a string
|
|
|
* verbatim, undefined as empty (placeholder shows). CssField owns parsing, the
|
|
|
* unit whitelist (`field.units` → `allowedUnits`), `auto` keyword + invalid UI. */
|
|
|
function lengthValue(field: PalabrasFieldDef): string | undefined {
|
|
|
const v = loc?.node?.[field.key];
|
|
|
if (typeof v === 'number') return `${v}px`;
|
|
|
if (typeof v === 'string') return v;
|
|
|
return undefined;
|
|
|
}
|
|
|
/** Image-src sanitizer: allows inline `data:image/…` (uploads/SVG) + delegates
|
|
|
* the rest to `sanitizeWordsUrl` (http/https/relative; rejects javascript: etc).
|
|
|
* Returns the safe string (`''` clears), or `undefined` when rejected. */
|
|
|
function sanitizeImageSrc(raw: string): string | undefined {
|
|
|
const t = raw.trim();
|
|
|
if (t === '') return '';
|
|
|
if (/^data:image\//i.test(t)) return t;
|
|
|
return sanitizeWordsUrl(t);
|
|
|
}
|
|
|
/** `src` field commit (on blur): sanitize, then write or flag invalid. */
|
|
|
function onSrcCommit(field: PalabrasFieldDef, input: HTMLInputElement) {
|
|
|
const safe = sanitizeImageSrc(input.value);
|
|
|
if (safe !== undefined) {
|
|
|
input.removeAttribute('data-invalid');
|
|
|
write(field.key, safe);
|
|
|
} else {
|
|
|
input.setAttribute('data-invalid', '');
|
|
|
}
|
|
|
}
|
|
|
function deleteActive() {
|
|
|
const path = loc?.path;
|
|
|
if (!path || path.length !== 1 || !content) return;
|
|
|
void deleteBlockAndReanchor(api, content, path[0]);
|
|
|
// Close the panel on delete — the block it was inspecting is gone.
|
|
|
onClose();
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
{#snippet fieldControl(field: PalabrasFieldDef)}
|
|
|
{@const disabled = field.gatedBy ? !!valueOf(field.gatedBy) : false}
|
|
|
{#if field.type === 'pills'}
|
|
|
{@const pv = effective(field)}
|
|
|
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
<PalabrasSegmented
|
|
|
value={pv == null ? undefined : String(pv)}
|
|
|
options={field.options ?? []}
|
|
|
{disabled}
|
|
|
ariaLabel={field.label}
|
|
|
onValueChange={(s) => onPills(field, s)}
|
|
|
/>
|
|
|
</div>
|
|
|
{:else if field.type === 'toggle'}
|
|
|
<div data-palabras-field data-inline>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
<Switch
|
|
|
size="xs"
|
|
|
{disabled}
|
|
|
checked={!!effective(field)}
|
|
|
onCheckedChange={(c) => write(field.key, c)}
|
|
|
/>
|
|
|
</div>
|
|
|
{:else if field.type === 'number'}
|
|
|
<div data-palabras-field data-inline data-disabled={disabled ? '' : undefined}>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
<NumberField
|
|
|
size="xs"
|
|
|
value={numOrUndef(field)}
|
|
|
min={field.min}
|
|
|
max={field.max}
|
|
|
step={field.step ?? 1}
|
|
|
{disabled}
|
|
|
onValueChange={(v) => onNumberChange(field, v)}
|
|
|
>
|
|
|
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
|
|
|
<NumberField.Input />
|
|
|
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
|
|
|
</NumberField>
|
|
|
</div>
|
|
|
{:else if field.type === 'slider'}
|
|
|
<div data-palabras-field data-slider data-disabled={disabled ? '' : undefined}>
|
|
|
<div data-palabras-slider-head>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
<span data-palabras-slider-value>{sliderValue(field)}{field.unit ?? ''}</span>
|
|
|
</div>
|
|
|
<Slider
|
|
|
size="xs"
|
|
|
{disabled}
|
|
|
value={[sliderValue(field)]}
|
|
|
min={field.min ?? 0}
|
|
|
max={field.max ?? 100}
|
|
|
step={field.step ?? 1}
|
|
|
onValueChange={(v) => write(field.key, v[0])}
|
|
|
aria-label={field.label}
|
|
|
>
|
|
|
<Slider.Range />
|
|
|
<Slider.Thumb />
|
|
|
</Slider>
|
|
|
</div>
|
|
|
{:else if field.type === 'length'}
|
|
|
<div data-palabras-field data-inline data-disabled={disabled ? '' : undefined}>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
<CssField
|
|
|
size="xs"
|
|
|
value={lengthValue(field)}
|
|
|
allowedUnits={field.units ? [...field.units] : undefined}
|
|
|
keywords={['auto']}
|
|
|
allowNegative={false}
|
|
|
min={field.min}
|
|
|
max={field.max}
|
|
|
step={field.step}
|
|
|
{disabled}
|
|
|
onValueChange={(v) => write(field.key, v)}
|
|
|
>
|
|
|
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
|
|
|
<CssField.Input placeholder={field.placeholder ?? 'auto'} />
|
|
|
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
|
|
|
</CssField>
|
|
|
</div>
|
|
|
{:else if field.type === 'text'}
|
|
|
<div data-palabras-field data-inline={field.wide ? undefined : ''}>
|
|
|
<span data-palabras-field-label>{field.label}</span>
|
|
|
{#if field.upload}
|
|
|
<span data-palabras-src-group>
|
|
|
<input
|
|
|
data-palabras-text
|
|
|
data-wide
|
|
|
type="text"
|
|
|
value={String(valueOf(field.key) ?? '')}
|
|
|
placeholder={field.placeholder ?? '#…'}
|
|
|
oninput={(e) => e.currentTarget.removeAttribute('data-invalid')}
|
|
|
onchange={(e) => onSrcCommit(field, e.currentTarget)}
|
|
|
/>
|
|
|
<Button
|
|
|
variant="ghost"
|
|
|
size="xs"
|
|
|
iconOnly
|
|
|
aria-label="Subir imagen"
|
|
|
title="Subir imagen"
|
|
|
onclick={onUpload}
|
|
|
>
|
|
|
{#snippet icon()}<Upload />{/snippet}
|
|
|
</Button>
|
|
|
</span>
|
|
|
{:else}
|
|
|
<input
|
|
|
data-palabras-text
|
|
|
data-wide={field.wide ? '' : undefined}
|
|
|
type="text"
|
|
|
value={String(valueOf(field.key) ?? '')}
|
|
|
placeholder={field.placeholder ?? '#…'}
|
|
|
oninput={(e) => write(field.key, e.currentTarget.value)}
|
|
|
/>
|
|
|
{/if}
|
|
|
</div>
|
|
|
{/if}
|
|
|
{/snippet}
|
|
|
|
|
|
<Popover {open} onOpenChange={(v) => !v && onClose()} modal={false}>
|
|
|
<Popover.Portal>
|
|
|
<Popover.Content
|
|
|
customAnchor={anchor as unknown as HTMLElement | null}
|
|
|
side="right"
|
|
|
align="center"
|
|
|
sideOffset={8}
|
|
|
collisionPadding={12}
|
|
|
hideWhenDetached={false}
|
|
|
width={PANEL_WIDTH}
|
|
|
data-palabras-panel-card
|
|
|
data-words-external-tool
|
|
|
>
|
|
|
{#if loc && schema}
|
|
|
<header data-palabras-panel-head>
|
|
|
<span data-palabras-panel-icon style:color={schema.dotToken}><HeadIcon /></span>
|
|
|
<span data-palabras-panel-title>{schema.label}</span>
|
|
|
<Button
|
|
|
variant="ghost"
|
|
|
size="xs"
|
|
|
iconOnly
|
|
|
aria-label="Eliminar bloque"
|
|
|
disabled={!canDelete}
|
|
|
onclick={deleteActive}
|
|
|
>
|
|
|
{#snippet icon()}<Trash2 />{/snippet}
|
|
|
</Button>
|
|
|
</header>
|
|
|
|
|
|
<div data-palabras-panel-body>
|
|
|
{#each pinnedSections as section (section.title)}
|
|
|
<div data-palabras-panel-fields>
|
|
|
{#each section.fields as field (field.key)}
|
|
|
{@render fieldControl(field)}
|
|
|
{/each}
|
|
|
</div>
|
|
|
{/each}
|
|
|
|
|
|
{#if accordionSections.length}
|
|
|
<Accordion
|
|
|
type="single"
|
|
|
collapsible
|
|
|
bind:value={openSections}
|
|
|
variant="ghost"
|
|
|
size="sm"
|
|
|
data-palabras-panel-accordion
|
|
|
>
|
|
|
{#each accordionSections as section (section.title)}
|
|
|
<Accordion.Item value={section.title}>
|
|
|
<Accordion.Header>
|
|
|
<Accordion.Trigger>{section.title}</Accordion.Trigger>
|
|
|
</Accordion.Header>
|
|
|
<Accordion.Content>
|
|
|
<div data-palabras-panel-fields>
|
|
|
{#each section.fields as field (field.key)}
|
|
|
{@render fieldControl(field)}
|
|
|
{/each}
|
|
|
</div>
|
|
|
</Accordion.Content>
|
|
|
</Accordion.Item>
|
|
|
{/each}
|
|
|
</Accordion>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
<footer data-palabras-panel-foot>
|
|
|
<span>Apariencia · {hasOverride ? 'personalizada' : 'heredada'}</span>
|
|
|
<button type="button" data-palabras-panel-edit onclick={onEditAppearance}>Editar</button>
|
|
|
</footer>
|
|
|
{:else}
|
|
|
<p data-palabras-panel-empty>Selecciona un bloque</p>
|
|
|
{/if}
|
|
|
</Popover.Content>
|
|
|
</Popover.Portal>
|
|
|
</Popover>
|