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

279 lines
11 KiB

<script lang="ts">
import './palabras.css';
import './palabras-chrome.css';
/**
* `<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 { Popover } from '$uix/eidos/components/popover';
import { Flex } from '$uix/eidos/components/flex';
import type { PalabrasFieldDef } from './types';
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 PalabrasMenubar from './palabras-menubar.svelte';
import PalabrasPanel from './palabras-panel.svelte';
import { mergePalabrasScheme, resolvePalabrasScheme, schemeCssVars } from './palabras-scheme';
import type { PalabrasProps, PalabrasScheme } from './types';
let {
value = $bindable(),
schemeStyle = 'default',
saveas = 'json',
// Author-set panel mode: 'floating' (grip-triggered FloatPanel) or
// 'docked' (always-visible non-modal Drawer at the viewport edge, knobs
// in `panelDock`). Destructured OUT of `headlessProps` so they never
// reach the Words.Provider spread below.
panelMode = 'floating',
panelDock = {},
...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 baseScheme = $derived(resolvePalabrasScheme(schemeStyle));
// The doc panel's edits persist IN the document (`value.scheme`, an opaque
// bag to the engine) as DELTAS over the app scheme — the document-level
// mirror of block overrides: reversible deviations, ↺ back to the app value.
const docDeltas = $derived(value?.scheme as Partial<PalabrasScheme> | undefined);
const scheme = $derived(docDeltas ? mergePalabrasScheme(baseScheme, docDeltas) : baseScheme);
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.
// In DOCKED mode the panel is always visible instead: `panelOpen` only
// matters to the floating window and to Esc precedence (a docked panel
// never closes, so Esc must climb the ladder directly).
let panelOpen = $state(false);
const floating = $derived(panelMode === 'floating');
// menubar: the open MENU is controlled here so the grip / crumbs can drop the
// element menu. `editField` drives the focused one-field escape Dialog.
let menubarValue = $state('');
let editField = $state<PalabrasFieldDef | null>(null);
</script>
{#if mounted}
<Words.Provider
{...headlessProps}
bind:value
data-palabras
data-scheme-style={schemeName}
data-saveas={saveas}
data-panel-mode={panelMode}
>
{#snippet children(api)}
<Flex direction="column" rowGap={4} data-palabras-shell>
{#if panelMode === 'menubar'}
<!-- menubar (study 2026-07-17): the bar drops REAL menus of the
element's options; Documento keeps its curated panel surface.
Typed/composite controls escalate to a focused one-field dialog. -->
<PalabrasMenubar
{api}
content={contentEl}
{dom}
{scheme}
bind:value={menubarValue}
onEditField={(field) => (editField = field)}
>
{#snippet docPanel()}
<PalabrasPanel
{api}
{scheme}
{baseScheme}
{schemeName}
mode="menubar"
view="doc"
content={contentEl}
open={true}
onClose={() => (menubarValue = '')}
/>
{/snippet}
</PalabrasMenubar>
<!-- The focused one-field editor: a NON-MODAL popover anchored under the
bar, hosting the ONE real control the element menu escalated. Not a
modal, not the panel — one field, a second focused step in place. -->
<Popover
open={editField != null}
onOpenChange={(o) => {
if (!o) editField = null;
}}
modal
>
<!-- Transparent overlay: BLOCKS interaction with the bar/menu behind
(no click-through, no hover-open underneath) WITHOUT dimming — you
still see the editor. Clicking it closes the popover. -->
<Popover.Overlay motion="none" data-palabras-menu-overlay />
<Popover.Content
side="bottom"
align="start"
customAnchor="[data-palabras-menubar]"
interactOutsideBehavior="close"
data-palabras-menu-popover
data-words-external-tool
>
{#if editField}
<span data-palabras-menu-pop-title>{eidos.langs.ts(editField.label)}</span>
<!-- Enter COMMITS + closes (single-line controls commit live on input;
blur first flushes the change-based ones — number / length / select).
A textarea keeps Enter for newlines; Esc / outside also close. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onkeydown={(e) => {
const t = e.target as HTMLElement;
if (e.key === 'Enter' && !e.shiftKey && t.tagName !== 'TEXTAREA') {
e.preventDefault();
t.blur?.();
editField = null;
}
}}
>
<PalabrasPanel
{api}
{scheme}
{baseScheme}
{schemeName}
mode="menubar"
view="block"
menuField={editField.key}
content={contentEl}
open={true}
onClose={() => (editField = null)}
/>
</div>
{/if}
</Popover.Content>
</Popover>
{/if}
<!-- Crumbs NAVIGATE (activate the node). Only floating mode also opens
its window — in docked the panel is already visible and in menubar
the bar triggers are the ONE door (user decision 2026-07-17: no
programmatic panel-opening outside the bar). -->
<PalabrasBreadcrumb {api} onOpen={() => floating && (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 (gutter + grip) — FLOATING MODE ONLY (user decision
2026-07-17): in docked/menubar the panel access lives in the
drawer / the bar, so the floating grip is redundant chrome. -->
{#if floating}
<div data-palabras-rail>
{#if contentEl}
<PalabrasHandle
{api}
content={contentEl}
{dom}
{panelOpen}
onOpen={(id) => {
api.setActiveBlock(id);
panelOpen = true;
}}
onClose={() => {
panelOpen = false;
}}
/>
{/if}
</div>
{/if}
<div data-palabras-canvas>
<Words.Content bind:ref={contentEl} />
<Words.Placeholder />
{#if contentEl}
<WordsActivate
{api}
content={contentEl}
{dom}
panelOpen={floating ? panelOpen : false}
onPanelClose={() => (panelOpen = false)}
onActivate={(_id, type) => {
// An atomic image can't be edited inline — in floating mode a
// click opens its inspector window. Docked/menubar only activate:
// the drawer is already visible / the bar trigger is the door.
if (type === 'image' && floating) {
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, so it only
mounts alongside it (floating mode). -->
{#if floating}
<WordsBlockDrag {api} content={contentEl} {dom} dropZone={docEl} />
{/if}
<!-- 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 — floating <FloatPanel> anchored to the
active block, or a docked Drawer. In menubar mode the panels render
INSIDE the bar's Menubar.Panel surfaces instead (see above). -->
{#if panelMode !== 'menubar'}
<PalabrasPanel
{api}
{scheme}
{baseScheme}
{schemeName}
mode={panelMode}
dock={panelDock}
content={contentEl}
open={panelOpen}
onClose={() => (panelOpen = false)}
/>
{/if}
</div>
{#if floating}
<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>
{/if}
</Flex>
{/snippet}
</Words.Provider>
{:else}
<div data-palabras data-scheme-style={schemeName} aria-busy="true"></div>
{/if}

Powered by TurnKey Linux.