words: add the editor menu bar (formatting toolbar + Edit/Preview + inspector)

The editor had no persistent formatting bar — commands lived only in the
bubble (selection), slash (/) and drawer. Add a docked menu bar at the top
of `<Words>`, built from the existing (previously unused) soma
`Words.Toolbar` / `Words.CommandButton` primitives, the same way the bubble
composes them.

- words-menubar.svelte (new): role=toolbar with six groups — history
  (undo/redo) · block style (Text/H1-3/Quote/Code via a DropdownMenu) ·
  marks (B/I/U/S/code) · lists · align · insert (image/table/divider via
  `api.insertBlockOfType`). Marks/align are `Words.CommandButton`s rendered
  as eidos `Button`s (range restored before the command runs, active state
  reflected). Right side: an Edit/Preview toggle + an inspector toggle.
- words.svelte: mounts the bar; owns `mode` ('edit'|'preview') mapped to the
  provider's `readonly` (preview = contenteditable off, editing chrome —
  bubble/gutter/drag — suppressed, bar stays visible). Unifies the inspector
  open state (drawer open / sidebar visibility) behind one toggle the bar
  drives; the floating drawer button is gone (folded into the bar).
- types.ts: `toolbar?: boolean` prop (default true).
- words.css: `[data-words-menubar]` fixed-height top bar (`--_words-bar-h`),
  group dividers, active-control accent (`data-active` + `aria-pressed`), and
  offsets the docked inspector panels below the bar.
- demo: drop the now-duplicate Edit/Preview toggle + serialized-HTML preview
  swap (the bar owns preview as read-only); keep the inspector picker.

Verified in-browser: bar renders (role=toolbar, 44px, 6 groups, 18 controls);
Edit→Preview flips contenteditable to false, disables format buttons and hides
the chrome while the bar persists; Bold applies to a selection from the bar
(onpointerdown range-restore holds across the out-of-editor click); the
inspector toggle hides/shows the sidebar; align-left reads active for a
left-aligned block; no console errors; check clean for the touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7256f53e0c
commit fff17c58dc

@ -31,4 +31,10 @@ export type WordsProps = Omit<ProviderProps, 'children' | 'child'> & {
size?: ResponsiveProp<WordsSize>;
/** Where the block inspector lives. @default 'sidebar' */
inspector?: WordsInspectorMode;
/**
* Show the formatting menu bar at the top of the editor (history / block
* style / marks / lists / align / insert + Edit·Preview + inspector
* toggle). @default true
*/
toolbar?: boolean;
};

@ -0,0 +1,311 @@
<script lang="ts">
/**
* Editor menu bar — the persistent formatting toolbar at the top of
* `<Words>`.
*
* Composition mirrors the selection bubble (the canonical consumer
* pattern), just docked instead of floating:
* - soma `Words.Toolbar` is the role=toolbar container.
* - inline marks are soma `Words.CommandButton`s rendered through their
* `child` snippet as eidos `Button`s — the CommandButton restores the
* DOM range before the command runs and reflects active/disabled state.
* - block transforms (turn-into, lists) go through `api.applyCommand`,
* with active state derived from the current block (same `effectiveBlock`
* descent the bubble uses, so it's correct inside columns).
* - history / insert / view controls are plain eidos `Button`s wired to
* the `api`.
*
* View controls on the right: an Edit/Preview toggle (binds `mode`, which
* the parent maps to the provider's `readonly`) and an inspector toggle.
*/
import * as Words from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import {
Undo2,
Redo2,
ChevronDown,
Bold,
Italic,
Underline,
Strikethrough,
Code,
List,
ListOrdered,
ListChecks,
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
Image,
Table,
Minus,
Plus,
Check,
SlidersHorizontal
} from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlock,
WordsHeadingLevel,
WordsListKind
} from '$soma/components/words';
import type { WordsInspectorMode } from './types';
let {
api,
mode = $bindable('edit'),
inspector,
inspectorOpen,
onToggleInspector
}: {
api: ProviderSnippetProps;
mode: 'edit' | 'preview';
inspector: WordsInspectorMode;
inspectorOpen: boolean;
onToggleInspector: () => void;
} = $props();
const preview = $derived(mode === 'preview');
// Convertible blocks — turn-into / lists no-op on table / image / divider /
// callout, so disable those controls there instead of offering inert ops.
const CONVERTIBLE = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
// Effective block under the caret. When inside a column the top-level
// `api.currentBlock` is `'columns'`, but the user edits the NESTED block —
// descend the selection path to resolve the one turn-into can convert
// (same logic as the bubble; the engine's setBlock is columns-recursive).
const block = $derived.by<{ type: string; level?: WordsHeadingLevel; kind?: WordsListKind }>(
() => {
const path = api.selection?.anchor.path;
if (path && path.length > 0) {
const top = api.document.children[path[0]] as WordsBlock | undefined;
if (top?.type === 'columns' && path.length >= 3) {
const inner = top.columns[path[1] ?? 0]?.children[path[2] ?? 0];
if (inner) {
return {
type: inner.type,
level: inner.type === 'heading' ? inner.level : undefined,
kind: inner.type === 'list' ? inner.kind : undefined
};
}
}
return {
type: api.currentBlock,
level: api.currentHeadingLevel,
kind: top?.type === 'list' ? top.kind : undefined
};
}
return { type: api.currentBlock, level: api.currentHeadingLevel };
}
);
const canTurnInto = $derived(CONVERTIBLE.has(block.type));
const blockLabel = $derived.by(() => {
switch (block.type) {
case 'heading':
return `Heading ${block.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return block.kind === 'ordered'
? 'Numbered list'
: block.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
return 'Text';
default:
return blockLabelFallback(block.type);
}
});
function blockLabelFallback(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
const TURN_INTO = $derived.by(() => [
{ id: 'paragraph', label: 'Text', active: block.type === 'paragraph', run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' }) },
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: block.type === 'heading' && (block.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{ id: 'quote', label: 'Quote', active: block.type === 'quote', run: () => api.applyCommand({ type: 'setBlock', block: 'quote' }) },
{ id: 'code', label: 'Code', active: block.type === 'code', run: () => api.applyCommand({ type: 'setBlock', block: 'code' }) }
]);
const MARKS = [
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'underline', label: 'Underline', icon: Underline },
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
{ command: 'code', label: 'Inline code', icon: Code }
] as const;
const LISTS = $derived.by(() => [
{ kind: 'unordered' as WordsListKind, label: 'Bulleted list', icon: List },
{ kind: 'ordered' as WordsListKind, label: 'Numbered list', icon: ListOrdered },
{ kind: 'check' as WordsListKind, label: 'Check list', icon: ListChecks }
]);
const ALIGN = [
{ command: 'align-left', label: 'Align left', icon: TextAlignStart },
{ command: 'align-center', label: 'Align center', icon: TextAlignCenter },
{ command: 'align-right', label: 'Align right', icon: TextAlignEnd }
] as const;
const INSERTS = [
{ id: 'image', label: 'Image', icon: Image },
{ id: 'table', label: 'Table', icon: Table },
{ id: 'divider', label: 'Divider', icon: Minus }
] as const;
let insertOpen = $state(false);
let blockOpen = $state(false);
const showInspectorToggle = $derived(inspector === 'drawer' || inspector === 'sidebar');
</script>
<Words.Toolbar data-words-menubar data-mode={mode}>
<!-- History -->
<div data-words-menubar-group>
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Undo"
disabled={preview || !api.canUndo}
onclick={() => api.undo()}
>
{#snippet icon()}<Undo2 />{/snippet}
</Button>
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Redo"
disabled={preview || !api.canRedo}
onclick={() => api.redo()}
>
{#snippet icon()}<Redo2 />{/snippet}
</Button>
</div>
<!-- Block style -->
<div data-words-menubar-group>
<DropdownMenu bind:open={blockOpen}>
<DropdownMenu.Trigger variant="ghost" size="xs" disabled={preview || !canTurnInto}>
{blockLabel}
{#snippet endIcon()}<ChevronDown />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start">
{#each TURN_INTO as item (item.id)}
<DropdownMenu.Item onSelect={item.run}>
<span>{item.label}</span>
{#if item.active}<Check size="sm" />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
</div>
<!-- Inline marks -->
<div data-words-menubar-group>
{#each MARKS as mark (mark.command)}
{@const Glyph = mark.icon}
<Words.CommandButton command={mark.command} aria-label={mark.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
<!-- Lists -->
<div data-words-menubar-group>
{#each LISTS as list (list.kind)}
{@const Glyph = list.icon}
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label={list.label}
data-active={block.type === 'list' && block.kind === list.kind ? '' : undefined}
disabled={preview}
onclick={() => api.applyCommand({ type: 'toggleList', kind: list.kind })}
>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/each}
</div>
<!-- Align -->
<div data-words-menubar-group>
{#each ALIGN as a (a.command)}
{@const Glyph = a.icon}
<Words.CommandButton command={a.command} aria-label={a.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
<!-- Insert -->
<div data-words-menubar-group>
<DropdownMenu bind:open={insertOpen}>
<DropdownMenu.Trigger variant="ghost" size="xs" iconOnly rounded="md" aria-label="Insert" disabled={preview}>
{#snippet icon()}<Plus />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start">
{#each INSERTS as ins (ins.id)}
{@const Glyph = ins.icon}
<DropdownMenu.Item onSelect={() => api.insertBlockOfType(ins.id)}>
<Glyph size="sm" />
<span>{ins.label}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
</div>
<span data-words-menubar-spacer></span>
<!-- View controls -->
{#if showInspectorToggle}
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Toggle inspector"
data-active={inspectorOpen ? '' : undefined}
onclick={onToggleInspector}
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
{/if}
<ToggleGroup
size="xs"
value={[mode]}
onValueChange={(v: string[]) => {
if (v[0] === 'edit' || v[0] === 'preview') mode = v[0];
}}
aria-label="Edit or preview"
>
<ToggleGroup.Item value="edit">Edit</ToggleGroup.Item>
<ToggleGroup.Item value="preview">Preview</ToggleGroup.Item>
</ToggleGroup>
</Words.Toolbar>

@ -26,6 +26,9 @@
--_words-content-max: var(--words-content-max-block-size-md);
--_words-content-font-size: var(--words-font-size-lg);
--_words-radius: var(--words-radius-md);
/* Fixed height of the top menu bar — shared so the absolute inspector
panels (sidebar / drawer) can offset themselves below it. */
--_words-bar-h: 2.75rem;
position: relative;
display: flex;
@ -80,6 +83,53 @@
min-block-size: calc(var(--_words-content-min) + 2 * var(--_words-content-py));
}
/* ── Menu bar (formatting toolbar) ────────────────────────────────────── */
/* A fixed-height top row of formatting controls + view toggles. The frame
is `flex-direction: column`, so the bar sits above the content naturally;
the absolute inspector panels offset below it via `--_words-bar-h`. */
[data-words-menubar] {
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: nowrap;
block-size: var(--_words-bar-h);
padding-inline: var(--space-2);
overflow-x: auto;
overflow-y: hidden;
border-block-end: var(--words-border-width) solid var(--words-border);
background: var(--words-bg);
border-start-start-radius: var(--_words-radius);
border-start-end-radius: var(--_words-radius);
scrollbar-width: thin;
}
/* Groups read as clusters separated by hairline dividers. */
[data-words-menubar-group] {
display: flex;
align-items: center;
gap: 1px;
padding-inline: var(--space-1);
border-inline-end: 1px solid var(--words-border);
}
[data-words-menubar-group]:last-of-type {
border-inline-end: none;
}
[data-words-menubar-spacer] {
flex: 1 1 auto;
}
/* Active formatting button — marks reflect via `aria-pressed` (CommandButton),
list / align / inspector via `data-active`. Both read as the same accent. */
[data-words-menubar] [data-active],
[data-words-menubar] [aria-pressed='true'] {
color: var(--color-primary-solid);
background: color-mix(in srgb, var(--color-primary-solid) 12%, transparent);
}
/* Park the docked inspector panels below the bar so they don't cover it. */
[data-words]:has([data-words-menubar]) [data-words-sidebar],
[data-words]:has([data-words-menubar]) [data-words-drawer-panel] {
inset-block-start: var(--_words-bar-h);
}
/* ── Left gutter — block handle ───────────────────────────────────────── */
[data-words-block-gutter] {

@ -23,6 +23,7 @@
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
import WordsInspector from './words-inspector.svelte';
import WordsMenubar from './words-menubar.svelte';
import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
import { WORDS_MENU_BUNDLE } from './langs-menu';
import type { WordsProps } from './types';
@ -32,6 +33,7 @@
value = $bindable(),
selection = $bindable(null),
inspector = 'sidebar',
toolbar = true,
...headlessProps
}: WordsProps = $props();
@ -59,6 +61,23 @@
let contentEl = $state<HTMLDivElement | null>(null);
let drawerOpen = $state(false);
let sidebarOpen = $state(true);
// Edit vs preview. Preview maps to the provider's `readonly` — the surface
// becomes non-editable (contenteditable=false) and the editing chrome
// (gutter / drag / bubble) is suppressed; the menu bar stays visible so the
// toggle is reachable. The menu bar owns the toggle via `bind:mode`.
let mode = $state<'edit' | 'preview'>('edit');
// One "is the inspector open" concept the menu bar's toggle drives:
// the drawer's open state, or the sidebar's visibility.
const inspectorOpen = $derived(
inspector === 'drawer' ? drawerOpen : inspector === 'sidebar' ? sidebarOpen : false
);
function toggleInspector() {
if (inspector === 'drawer') drawerOpen = !drawerOpen;
else if (inspector === 'sidebar') sidebarOpen = !sidebarOpen;
}
</script>
{#if mounted}
@ -66,17 +85,35 @@
{...headlessProps}
bind:value
bind:selection
readonly={mode === 'preview'}
data-size={resolvedSize}
data-inspector={inspector}
data-mode={mode}
>
{#snippet children(api)}
{#if toolbar}
<WordsMenubar
{api}
bind:mode
{inspector}
{inspectorOpen}
onToggleInspector={toggleInspector}
/>
{/if}
<Words.Content bind:ref={contentEl} />
<Words.Placeholder />
<WordsBubble {api} />
<!-- Editing chrome (bubble / gutter / drag) is edit-only: preview is
read-only, so these affordances are suppressed while the menu bar
stays visible to toggle back. -->
{#if mode === 'edit'}
<WordsBubble {api} />
{/if}
{#if contentEl}
<WordsActivate {api} content={contentEl} {dom} />
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsBlockDrag {api} content={contentEl} {dom} />
{#if mode === 'edit'}
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsBlockDrag {api} content={contentEl} {dom} />
{/if}
<!-- Column-content insertion lives in the Inspector's Columns
panel ("Añadir bloque" dropdown per column), React-Bricks-
style. Floating in-canvas overlay was removed: its
@ -86,27 +123,13 @@
meant for typing. Sidebar-driven insertion sidesteps both. -->
{/if}
{#if inspector === 'sidebar'}
{#if inspector === 'sidebar' && sidebarOpen}
<aside data-words-sidebar>
<WordsInspector {api} />
</aside>
{:else if inspector === 'drawer'}
<!-- Open affordance only while CLOSED. The open panel carries its
own X close in the header; keeping the toggle visible too made
both buttons collide in the same top-right corner. -->
{#if !drawerOpen}
<Button
data-words-drawer-toggle
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block settings"
onclick={() => (drawerOpen = true)}
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
{/if}
<!-- The menu bar's inspector toggle opens/closes the drawer; the
open panel also carries its own X close in the header. -->
<aside data-words-drawer-panel data-open={drawerOpen ? '' : undefined}>
<header data-words-drawer-panel-head>
<span>Block</span>

@ -4,8 +4,7 @@
// settings drawer) lands in F3.
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Eye, Pencil, FileText, Check } from '$uix/eidos/components/icon';
import { serializeHtml } from '$soma/components/words';
import { FileText, Check } from '$uix/eidos/components/icon';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type {
WordsDocument,
@ -195,14 +194,9 @@
});
// ── Topbar state ──────────────────────────────────────────────────────
// Edit / Preview. Preview renders the document as clean read-only HTML
// via the export-only `serializeHtml` serializer.
let mode = $state<'edit' | 'preview'>('edit');
const EDIT_MODES: readonly { v: 'edit' | 'preview'; label: string }[] = [
{ v: 'edit', label: 'Edit' },
{ v: 'preview', label: 'Preview' }
];
const previewHtml = $derived(mode === 'preview' ? serializeHtml(value) : '');
// Edit / Preview now lives INSIDE the editor's menu bar (the eidos
// `<Words>` owns the read-only `preview` mode), so the demo no longer
// toggles it here — it just keeps the breadcrumb + Saved + inspector picker.
// Breadcrumb title — the first heading's text, else a placeholder.
const docTitle = $derived.by(() => {
@ -271,42 +265,13 @@
<ToggleGroup.Item value={m.v}>{m.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
<ToggleGroup
type="single"
size="xs"
variant="outline"
attached
value={[mode]}
onValueChange={(v: string[]) => {
if (v[0]) mode = v[0] as 'edit' | 'preview';
}}
aria-label="Edit or preview"
>
{#each EDIT_MODES as m (m.v)}
<ToggleGroup.Item value={m.v}>
{#if m.v === 'edit'}<Pencil size="sm" />{:else}<Eye size="sm" />{/if}
{m.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
<div class="demo-canvas" data-mode={mode}>
{#if mode === 'edit'}
<Words
bind:value
{inspector}
placeholder="Write something…"
onUploadImage={handleUploadImage}
/>
{:else}
<!-- Preview: the document rendered as clean, read-only HTML (the
export-only `serializeHtml`). Content is the user's own document,
escaped + URL-sanitized by the serializer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<article class="demo-preview">{@html previewHtml}</article>
{/if}
<div class="demo-canvas">
<!-- Edit / Preview lives in the editor's own menu bar now (read-only
preview). The demo just hosts the live editor. -->
<Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
</div>
<details class="demo-trace">
@ -396,103 +361,4 @@
border-radius: var(--radius-md);
}
/* Preview mode — the document as a clean reading view. The preview is
bare serialized HTML, so it carries its own typography (the editor's
`[data-words]` CSS does not apply here). */
.demo-canvas[data-mode='preview'] {
justify-content: flex-start;
}
.demo-preview {
inline-size: 100%;
max-inline-size: 48rem;
margin-inline: auto;
color: var(--color-content-primary);
font-size: var(--font-size-md);
line-height: 1.7;
}
/* Headings mirror the editor recipe tokens (words `heading-*`): same
sizes (h1 = xl, h2 = lg+4, h3 = lg), semibold weight, heading
line-height + primary color, top-only rhythm — so Preview reads
like Edit mode rather than a louder, bolder variant. */
.demo-preview :global(:first-child) {
margin-block-start: 0;
}
.demo-preview :global(h1),
.demo-preview :global(h2),
.demo-preview :global(h3) {
font-weight: var(--font-weight-semibold);
line-height: var(--leading-heading);
color: var(--color-content-primary);
margin-block: calc(var(--space-3) * 1.5) 0;
}
.demo-preview :global(h1) {
font-size: var(--font-size-xl);
}
.demo-preview :global(h2) {
font-size: calc(var(--font-size-lg) + 4px);
}
.demo-preview :global(h3) {
font-size: var(--font-size-lg);
}
/* Columns serialize to `<div data-words-columns><div data-words-column>` —
plain divs, so without this they stack. Mirror the editor's row flex. */
.demo-preview :global([data-words-columns]) {
display: flex;
flex-direction: row;
gap: var(--space-3);
align-items: stretch;
margin-block: var(--space-3);
}
.demo-preview :global([data-words-column]) {
flex: 1 1 0;
min-inline-size: 0;
}
.demo-preview :global(p) {
margin-block: var(--space-3);
}
.demo-preview :global(ul),
.demo-preview :global(ol) {
margin-block: var(--space-3);
padding-inline-start: var(--space-6);
}
.demo-preview :global(blockquote) {
margin-block: var(--space-3);
padding-inline-start: var(--space-4);
border-inline-start: 3px solid var(--color-border-default);
color: var(--color-content-secondary);
}
.demo-preview :global(pre) {
margin-block: var(--space-3);
padding: var(--space-3);
overflow: auto;
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.demo-preview :global(table) {
inline-size: 100%;
margin-block: var(--space-4);
border-collapse: collapse;
}
.demo-preview :global(th),
.demo-preview :global(td) {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-subtle);
text-align: start;
vertical-align: top;
}
.demo-preview :global(th) {
background: var(--color-surface-raised);
font-weight: 600;
}
.demo-preview :global(img) {
max-inline-size: 100%;
height: auto;
border-radius: var(--radius-md);
}
.demo-preview :global(a) {
color: var(--color-primary-text, var(--color-primary-element));
text-decoration: underline;
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.