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.
279 lines
11 KiB
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}
|