You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/palabras/palabras-panel.svelte

396 lines
14 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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

Powered by TurnKey Linux.