refactor(words): drawer reinvented as Insert ↔ Inspect

The right drawer's default panel is reframed: instead of an outline +
stats overview that doesn't compose well with the editing surface,
the default mode now shows an "Insert" catalogue of block constructors
(Paragraph, Headings 1-3, Quote, Code, three list types, Image,
Table). When there's contextual scope (image / table / code / format),
those panels stack above with Insert collapsed at the bottom as a
secondary affordance. Same Figma / Linear "right inspector" pattern
but Notion-aware on the empty-context default.

Architecture:

- New `WordsDrawerMode` value: `'document'` (outline + stats +
  suggestions, opt-in via a header toggle). The old default content
  moved there verbatim. `'default'` was renamed (label-side) to
  "Insert".

- `WordsDrawerProvider.modes` now ALWAYS appends `'default'` to the
  end of the stack. So:
    - No selection → `['default']`
    - Caret on paragraph → `['block', 'default']`
    - Inside table cell → `['cell', 'row', 'table', 'default']`
    - With doc panel toggled on → `['document', ...above]`
  Eidos auto-collapses the trailing `'default'` whenever any scope
  is also active, so it sits as a footer chip the user can expand
  to drop a new block without leaving the current inspector.

- `documentPanelOpen = $state(false)` + `toggleDocumentPanel()`
  method on the provider. Exposed in the drawer snippet props as
  `documentPanelOpen` / `toggleDocumentPanel()`.

- New provider method `insertBlockOfType(id: WordsSlashCommandId)`:
  reuses the slash-id space (paragraph / heading-* / quote /
  code-block / *-list / image / table) but with different semantics:
    - Empty paragraph context → `setBlock` (transform in-place,
      no stray blank).
    - Non-empty → insert a fresh block AFTER current via
      `insertBlock`. Caret lands at start of the new block.
    - Image / table → existing `insertImage` / `insertTable`
      commands (URL prompt still inline).
  Exposed in `ProviderSnippetProps` so the Insert panel buttons
  call it directly without going through the slash menu state.

Eidos panels:
- `insertPanel` snippet: a 2-column grid of `[data-words-drawer-
  insert-item]` buttons, each with icon + label. Click →
  `s.insertBlockOfType(item.id)`.
- `documentPanel` snippet: outline + stats + suggestions
  (verbatim from the old default).
- Header gains a circular `FileText` toggle on the right
  (`[data-words-drawer-document-toggle]`) — `data-active` when
  doc panel is in the stack. Same hover affordance as the rest of
  the drawer header.

CSS additions:
- `[data-words-drawer-insert]` grid layout.
- `[data-words-drawer-insert-item]` button styling — neutral
  border, accent on hover.
- `[data-words-drawer-document-toggle]` header chip with active
  state.

Verified: 152/152 soma words tests pass. `npm run check` clean
(only pre-existing errors). Browser smoke-test confirmed clicking
"Heading 1" with caret on the image block inserts a new heading
after the image and switches drawer to Block scope, Insert collapsed
below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 71c24dca9b
commit 3128b5f13c

@ -19,7 +19,19 @@
ChevronUp,
Pencil,
Check,
X
X,
FileText,
Type,
Heading1,
Heading2,
Heading3,
Quote,
Code,
List,
ListOrdered,
ListChecks,
Image as ImageIcon,
Table as TableIcon
} from '$uix/eidos/components/icon';
import { Editable } from '$uix/eidos/components/editable';
import * as Words from '$soma/components/words';
@ -40,7 +52,8 @@
}: Omit<DrawerProps, 'child'> = $props();
const MODE_LABELS: Record<WordsDrawerMode, string> = {
default: 'Document',
default: 'Insert',
document: 'Document',
format: 'Format',
block: 'Block',
cell: 'Cell',
@ -52,15 +65,21 @@
image: 'Image'
};
// Per-panel expand/collapse map. Default heuristic: first mode in
// the stack (most specific) is open, the rest collapsed. User
// explicit toggles override the default and persist across context
// changes.
// Per-panel expand/collapse map. Default heuristic:
// - The top of the stack is open (most specific scope).
// - 'default' (Insert) auto-collapses when another scope is active
// above it, so the inspector wins the focus and Insert sits as a
// secondary affordance at the bottom.
// User explicit toggles override the default and persist across
// context changes.
const panelOpenOverrides = new SvelteMap<WordsDrawerMode, boolean>();
function isPanelOpen(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]): boolean {
const override = panelOpenOverrides.get(mode);
if (override !== undefined) return override;
// Default: only the top of the stack is open.
// 'default' is collapsed by default whenever another scope sits
// above it. When it's the only mode (no scope active), open.
if (mode === 'default') return modes[0] === 'default';
// Other modes follow the original heuristic: top-of-stack opens.
return modes[0] === mode;
}
function togglePanel(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]) {
@ -162,6 +181,17 @@
{/each}
</span>
{/if}
<button
type="button"
data-words-drawer-document-toggle
data-active={d.documentPanelOpen ? '' : undefined}
aria-label="Toggle document overview"
aria-pressed={d.documentPanelOpen}
title="Document overview"
onclick={() => d.toggleDocumentPanel()}
>
<FileText size="xs" decorative />
</button>
{/if}
</header>
@ -194,7 +224,9 @@
{#if panelOpen}
<div data-words-drawer-panel-body>
{#if mode === 'default'}
{@render defaultPanel(snippet)}
{@render insertPanel(snippet)}
{:else if mode === 'document'}
{@render documentPanel(snippet)}
{:else if mode === 'format'}
{@render formatPanel(snippet)}
{:else if mode === 'block'}
@ -224,7 +256,37 @@
{/snippet}
</Words.Drawer>
{#snippet defaultPanel(s: ProviderSnippetProps)}
{#snippet insertPanel(s: ProviderSnippetProps)}
{@const insertItems = [
{ id: 'paragraph' as const, label: 'Paragraph', Icon: Type },
{ id: 'heading-1' as const, label: 'Heading 1', Icon: Heading1 },
{ id: 'heading-2' as const, label: 'Heading 2', Icon: Heading2 },
{ id: 'heading-3' as const, label: 'Heading 3', Icon: Heading3 },
{ id: 'quote' as const, label: 'Quote', Icon: Quote },
{ id: 'code-block' as const, label: 'Code', Icon: Code },
{ id: 'unordered-list' as const, label: 'Bullet list', Icon: List },
{ id: 'ordered-list' as const, label: 'Numbered list', Icon: ListOrdered },
{ id: 'check-list' as const, label: 'Check list', Icon: ListChecks },
{ id: 'image' as const, label: 'Image', Icon: ImageIcon },
{ id: 'table' as const, label: 'Table', Icon: TableIcon }
]}
<div data-words-drawer-insert>
{#each insertItems as item (item.id)}
<button
type="button"
data-words-drawer-insert-item
title="Insert {item.label.toLowerCase()}"
aria-label="Insert {item.label.toLowerCase()}"
onclick={() => s.insertBlockOfType(item.id)}
>
<item.Icon size="sm" decorative />
<span data-words-drawer-insert-item-label>{item.label}</span>
</button>
{/each}
</div>
{/snippet}
{#snippet documentPanel(s: ProviderSnippetProps)}
{@const wordCount = countWordsInText(s.plainText)}
{@const charCount = s.plainText.length}
{@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))}

@ -205,6 +205,83 @@
background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
}
/* Document-overview toggle in the drawer header. Sits on the far
right (margin-inline-start: auto). Active when the document panel
is in the stack. */
[data-words-drawer-document-toggle] {
margin-inline-start: auto;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.5rem;
block-size: 1.5rem;
padding: 0;
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-status-color);
cursor: pointer;
opacity: 0.7;
transition:
background-color 120ms ease,
color 120ms ease,
opacity 120ms ease;
}
[data-words-drawer-document-toggle]:hover {
opacity: 1;
background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
color: var(--words-command-color);
}
[data-words-drawer-document-toggle][data-active] {
opacity: 1;
background: var(--_words-accent-solid);
color: var(--_words-accent-on);
}
/* Insert panel — catalog of block constructors. Two columns on the
default drawer width so the labels fit comfortably; falls back to
single column on narrow drawers. */
[data-words-drawer-insert] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
[data-words-drawer-insert-item] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border: var(--words-border-width) solid var(--words-toolbar-border);
border-radius: var(--words-command-radius);
background: var(--words-toolbar-bg);
color: var(--words-command-color);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: start;
transition:
background-color 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
[data-words-drawer-insert-item]:hover {
border-color: var(--_words-accent-border);
background: color-mix(in srgb, var(--_words-accent-solid) 8%, transparent);
color: var(--_words-accent-text);
}
[data-words-drawer-insert-item-label] {
flex: 1 1 auto;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-words-drawer-title] {
font-weight: var(--words-strong-font-weight);
letter-spacing: 0.02em;

@ -175,6 +175,13 @@ export type WordsProviderSnippetProps = {
readonly replaceCurrentText: (replacement: string) => void;
readonly replaceAllText: (replacement: string) => void;
readonly commitSlashCommand: (id?: WordsSlashCommandId) => boolean;
/**
* Drawer-Insert-panel entry point — inserts a block of the given
* slash-id kind at the caret. Empty-paragraph context replaces
* in-place; otherwise inserts a new block after the current one.
* Image still prompts for URL synchronously.
*/
readonly insertBlockOfType: (id: WordsSlashCommandId) => boolean;
readonly closeSlashMenu: () => void;
};
@ -379,7 +386,8 @@ export type WordsStatusProps = WithChild<{ id?: string }> &
* shown when no contextual scope is active.
*/
export type WordsDrawerMode =
| 'default'
| 'default' // Insert panel — constructor catalog for new blocks
| 'document' // outline + stats + suggestions (opt-in via header toggle)
| 'format'
| 'block' // text-align scope of a paragraph/heading/quote
| 'cell' // single cell scope (tone, vertical align, header)
@ -398,9 +406,15 @@ export type WordsDrawerSnippetProps = {
readonly mode: WordsDrawerMode;
readonly open: boolean;
readonly disabled: boolean;
/** Whether the 'document' overview panel is currently in the
* stack. Toggled by the user via the header. */
readonly documentPanelOpen: boolean;
readonly snippet: WordsProviderSnippetProps;
readonly toggle: (target?: HTMLElement) => boolean;
readonly setOpen: (open: boolean, target?: HTMLElement) => void;
/** Flip the 'document' panel on/off. When on, it prepends to
* modes[] so the outline/stats/suggestions render at the top. */
readonly toggleDocumentPanel: () => void;
};
export type WordsDrawerProps = WithChild<

@ -31,7 +31,11 @@ import {
applyWordsCommand,
batchHistoryCommands,
beginWordsComposition,
createCodeBlock,
createEmptyWordsDocument,
createHeading,
createParagraph,
createQuote,
createWordsCompositionState,
createWordsHistory,
createWordsState,
@ -1211,6 +1215,7 @@ export class WordsProvider {
replaceCurrentText: (replacement: string) => this.replaceCurrentText(replacement),
replaceAllText: (replacement: string) => this.replaceAllText(replacement),
commitSlashCommand: (id?: WordsSlashCommandId) => this.commitSlashCommand(id),
insertBlockOfType: (id: WordsSlashCommandId) => this.insertBlockOfType(id),
closeSlashMenu: () => this.closeSlashMenu()
}));
@ -1394,6 +1399,102 @@ export class WordsProvider {
if (this.slashMenuMounts === 0) this.slashMenuActiveIndex = 0;
}
/**
* Imperatively insert a block of the given slash-id KIND at the
* current caret position. Different from `commitSlashCommand`
* which requires the slash menu to be open and replaces the
* `/query` text — this one is the "Insert panel" entry point and
* has these semantics:
*
* - If the current block is an empty paragraph: REPLACE it via
* `setBlock` (for paragraph/heading/quote/code) so the editor
* feels in-place rather than spawning a stray blank.
* - Otherwise: insert a NEW block AFTER the current one. Image
* and table use their dedicated `insertImage` / `insertTable`
* commands (which already do the right thing). Lists use
* `toggleList`. Caret lands at the start of the new block.
*/
insertBlockOfType(id: WordsSlashCommandId, target?: HTMLElement): boolean {
const sel = this.selection;
const doc = this.history.present.document;
const currentIdx =
sel?.anchor.path[0] ?? Math.max(0, doc.children.length - 1);
const current = doc.children[currentIdx];
// Empty-paragraph heuristic — match what the slash menu treats
// as "free space".
const isEmptyParagraph =
!!current &&
current.type === 'paragraph' &&
current.children.length === 1 &&
current.children[0].type === 'text' &&
current.children[0].text === '';
// Lists, image and table delegate to their existing commands
// (these already include the necessary normalisation +
// selection management).
if (id === 'table') return this.applyCommand({ type: 'insertTable' });
if (id === 'unordered-list')
return this.applyCommand({ type: 'toggleList', kind: 'unordered' });
if (id === 'ordered-list')
return this.applyCommand({ type: 'toggleList', kind: 'ordered' });
if (id === 'check-list')
return this.applyCommand({ type: 'toggleList', kind: 'check' });
if (id === 'image') {
const node = this.opts.ref.current;
const win = node?.ownerDocument?.defaultView ?? undefined;
const src = win?.prompt?.('Image URL:')?.trim();
if (!src) return false;
const altRaw = win?.prompt?.('Alt text (optional):');
const alt = altRaw?.trim() ? altRaw.trim() : undefined;
return this.applyCommand({ type: 'insertImage', src, alt });
}
// For paragraph / heading / quote / code-block: in-place transform
// when the current line is empty (so we don't litter), otherwise
// drop a new block after.
if (isEmptyParagraph) {
let command: WordsCommand | undefined;
if (id === 'paragraph') command = { type: 'setBlock', block: 'paragraph' };
else if (id === 'heading-1') command = { type: 'setBlock', block: 'heading', level: 1 };
else if (id === 'heading-2') command = { type: 'setBlock', block: 'heading', level: 2 };
else if (id === 'heading-3') command = { type: 'setBlock', block: 'heading', level: 3 };
else if (id === 'quote') command = { type: 'setBlock', block: 'quote' };
else if (id === 'code-block') command = { type: 'setBlock', block: 'code' };
if (!command) return false;
const changed = this.applyCommand(command);
if (changed) {
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
}
return changed;
}
// Non-empty current: insert a fresh block AFTER it.
const insertIndex = currentIdx + 1;
let block: unknown;
if (id === 'paragraph') block = createParagraph();
else if (id === 'heading-1') block = createHeading(1);
else if (id === 'heading-2') block = createHeading(2);
else if (id === 'heading-3') block = createHeading(3);
else if (id === 'quote') block = createQuote();
else if (id === 'code-block') block = createCodeBlock();
else return false;
const changed = this.applyCommand({
type: 'insertBlock',
blockIndex: insertIndex,
block: block as Readonly<Record<string, unknown>>
});
if (changed) {
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
}
return changed;
}
commitSlashCommand(id?: WordsSlashCommandId, target?: HTMLElement): boolean {
const context = this.slashMenuContext;
if (!context || !this.slashMenuOpen) return false;
@ -2490,6 +2591,7 @@ export class WordsStatusProvider {
*/
export type WordsDrawerMode =
| 'default'
| 'document'
| 'format'
| 'block'
| 'cell'
@ -2533,47 +2635,62 @@ export class WordsDrawerProvider {
* The user can see + act on every layer in one surface instead
* of guessing which popover/menu owns the action they need.
*/
/** User-toggleable: when true, the 'document' overview panel is
* prepended to the stack (header chevron in eidos drives this). */
documentPanelOpen = $state(false);
readonly modes: readonly WordsDrawerMode[] = $derived.by(() => {
const sel = this.provider.selection;
const block = this.provider.currentBlock;
// Atomic blocks (no inline format, no parent scopes)
if (block === 'image') return ['image'];
if (block === 'code') return ['code'];
if (!sel) return ['default'];
const scopeStack: WordsDrawerMode[] = [];
const stack: WordsDrawerMode[] = [];
// Atomic blocks (no inline format, no parent scopes)
if (block === 'image') {
scopeStack.push('image');
} else if (block === 'code') {
scopeStack.push('code');
} else if (sel) {
// Inline-text format is the most specific scope. Only meaningful
// when there's a non-collapsed selection AND the current block
// can host inline marks (excludes 'code' which is plain text).
const isRange =
sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.path[1] !== sel.focus.path[1] ||
sel.anchor.path[2] !== sel.focus.path[2] ||
sel.anchor.offset !== sel.focus.offset;
if (
isRange &&
(block === 'paragraph' ||
block === 'heading' ||
block === 'quote' ||
block === 'list' ||
block === 'table')
) {
scopeStack.push('format');
}
// Inline-text format is the most specific scope. Only meaningful
// when there's a non-collapsed selection AND the current block
// can host inline marks (excludes 'code' which is plain text).
const isRange =
sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.path[1] !== sel.focus.path[1] ||
sel.anchor.path[2] !== sel.focus.path[2] ||
sel.anchor.offset !== sel.focus.offset;
if (
isRange &&
(block === 'paragraph' ||
block === 'heading' ||
block === 'quote' ||
block === 'list' ||
block === 'table')
) {
stack.push('format');
// Block-scope stack — from innermost container outward.
if (block === 'table') {
scopeStack.push('cell', 'row', 'table');
} else if (block === 'list') {
scopeStack.push('list-item', 'list');
} else if (block === 'paragraph' || block === 'heading' || block === 'quote') {
scopeStack.push('block');
}
}
// Block-scope stack — from innermost container outward.
if (block === 'table') {
stack.push('cell', 'row', 'table');
} else if (block === 'list') {
stack.push('list-item', 'list');
} else if (block === 'paragraph' || block === 'heading' || block === 'quote') {
stack.push('block');
}
// 'default' (Insert panel) is ALWAYS the last entry in the stack —
// the user can drop a new block anywhere even while editing the
// current one. Eidos renders it collapsed when other scopes are
// active so it doesn't dominate.
const out: WordsDrawerMode[] = [...scopeStack, 'default'];
// 'document' (outline / stats / suggestions) is opt-in via the
// header. When on, prepend so it shows at the top of the drawer.
if (this.documentPanelOpen) out.unshift('document');
return stack.length > 0 ? stack : ['default'];
return out;
});
/** Top of the stack — convenience for consumers that just want the
@ -2609,16 +2726,23 @@ export class WordsDrawerProvider {
}
}
/** Flip the 'document' overview panel on/off. */
toggleDocumentPanel(): void {
this.documentPanelOpen = !this.documentPanelOpen;
}
readonly snippetProps = $derived.by(() => ({
modes: this.modes,
mode: this.mode,
open: this.opts.open.current,
disabled: this.opts.disabled.current || this.provider.isDisabled,
documentPanelOpen: this.documentPanelOpen,
// Forward the parent snippet so eidos panels can call runCommand,
// read activeMarks, etc. without re-piping every action.
snippet: this.provider.snippetProps,
toggle: (target?: HTMLElement) => this.toggle(target),
setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target)
setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target),
toggleDocumentPanel: () => this.toggleDocumentPanel()
}));
readonly props = $derived.by(() =>

Loading…
Cancel
Save

Powered by TurnKey Linux.