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
parent
7256f53e0c
commit
fff17c58dc
@ -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>
|
||||
Loading…
Reference in new issue