diff --git a/src/uix/eidos/components/words/words-drawer.svelte b/src/uix/eidos/components/words/words-drawer.svelte index 329155ab0..d2c95796f 100644 --- a/src/uix/eidos/components/words/words-drawer.svelte +++ b/src/uix/eidos/components/words/words-drawer.svelte @@ -2,26 +2,25 @@ /** * Words.Drawer — companion right-rail eidos wrapper. * - * After DRAWER-CANON-2 the drawer is canonical: it consumes the - * soma `Words.Drawer` provider (which owns the open/collapsed state - * and the derived `mode`) and renders the per-mode body here in - * eidos. + * After HIER-1/2/3 the drawer renders a STACK of panels from + * most-specific to most-general. E.g. inside a table cell with + * text selected the user sees: Format → Cell → Row → Table — + * each panel scoped to its level so every relevant action is + * reachable from one surface (instead of guessing which popover/ + * menu owns it). * - * Concept (still A+D from the spike): - * - Single contextual surface replacing bubble menu + slash menu + - * link editor + heading picker + language picker + table popover - * + image controls. - * - Default mode shows outline + stats + writer-coaching suggestions. - * - Format / image / table / code / list modes show controls scoped - * to what the user is editing. + * Modes are derived in soma (`WordsDrawerProvider.modes`); eidos + * just paints them. */ - import { ChevronRight, ChevronLeft } from '$uix/eidos/components/icon'; + import { ChevronRight, ChevronLeft, ChevronDown, ChevronUp } from '$uix/eidos/components/icon'; import * as Words from '$soma/components/words'; import type { DrawerSnippetProps, DrawerProps, - ProviderSnippetProps + ProviderSnippetProps, + WordsDrawerMode } from '$soma/components/words'; + import { SvelteMap } from 'svelte/reactivity'; let { open = $bindable(undefined), @@ -31,15 +30,34 @@ ...rest }: Omit = $props(); - const MODE_LABELS: Record = { + const MODE_LABELS: Record = { default: 'Document', format: 'Format', - image: 'Image', + block: 'Block', + cell: 'Cell', + row: 'Row', table: 'Table', + 'list-item': 'List item', + list: 'List', code: 'Code', - list: 'List' + 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. + const panelOpenOverrides = new SvelteMap(); + 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. + return modes[0] === mode; + } + function togglePanel(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]) { + panelOpenOverrides.set(mode, !isPanelOpen(mode, modes)); + } + function countWordsInText(text: string): number { const trimmed = text.trim(); return trimmed ? trimmed.split(/\s+/).length : 0; @@ -74,10 +92,7 @@ {#snippet children(d: DrawerSnippetProps)} - {@const { mode, snippet, toggle } = d} - {@const wordCount = countWordsInText(snippet.plainText)} - {@const charCount = snippet.plainText.length} - {@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))} + {@const { modes, snippet, toggle: toggleDrawer } = d}
{#if d.open} - {MODE_LABELS[mode]} + {MODE_LABELS[modes[0]]} + {#if modes.length > 1} + + {/if} {/if}
@@ -102,267 +125,345 @@
{#if customChildren} {@render customChildren(d)} - {:else if mode === 'default'} - {@const outline = snippet.document.children - .map((block, blockIndex) => ({ block, blockIndex })) - .filter(({ block }) => block.type === 'heading') - .map(({ block, blockIndex }) => ({ - blockIndex, - level: block.type === 'heading' ? block.level : 1, - text: - block.type === 'heading' - ? block.children - .map((i) => (i.type === 'text' ? i.text : '')) - .join('') - : '' - })) - .filter((e) => e.text.trim())} - {@const warnings = collectWarnings(snippet)} - -
-

Outline

- {#if outline.length === 0} -

No headings yet

- {:else} -
    - {#each outline as entry (entry.blockIndex)} -
  1. - H{entry.level} - {entry.text} -
  2. - {/each} -
- {/if} -
- -
-

Stats

-
-
Words
{wordCount}
-
Chars
{charCount}
-
Reading
{readingTimeMin} min
-
-
- -
-

- Suggestions - {#if warnings.length > 0} - {warnings.length} - {/if} -

- {#if warnings.length === 0} -

Nothing flagged

- {:else} -
    - {#each warnings as w (w.id)} -
  • {w.message}
  • - {/each} -
- {/if} -
- {:else if mode === 'format'} -
-

Marks

-
- {#each [ - { id: 'bold', label: 'B', title: 'Bold' }, - { id: 'italic', label: 'I', title: 'Italic' }, - { id: 'underline', label: 'U', title: 'Underline' }, - { id: 'strike', label: 'S', title: 'Strikethrough' }, - { id: 'code', label: '<>', title: 'Inline code' } - ] as mark (mark.id)} - - {/each} -
-
- -
-

Align

-
- {#each [ - { id: 'align-left', label: '⫷', title: 'Left' }, - { id: 'align-center', label: '☰', title: 'Center' }, - { id: 'align-right', label: '⫸', title: 'Right' }, - { id: 'align-justify', label: '☷', title: 'Justify' } - ] as a (a.id)} - - {/each} -
-
- -
-

Link

-
+ {:else} + {#each modes as mode, i (mode)} + {@const panelOpen = isPanelOpen(mode, modes)} +
- {#if snippet.canUnlink} - + {#if panelOpen} +
+ {#if mode === 'default'} + {@render defaultPanel(snippet)} + {:else if mode === 'format'} + {@render formatPanel(snippet)} + {:else if mode === 'block'} + {@render blockPanel(snippet)} + {:else if mode === 'cell'} + {@render cellPanel(snippet)} + {:else if mode === 'row'} + {@render rowPanel(snippet)} + {:else if mode === 'table'} + {@render tablePanel(snippet)} + {:else if mode === 'list-item'} + {@render listItemPanel(snippet)} + {:else if mode === 'list'} + {@render listPanel(snippet)} + {:else if mode === 'code'} + {@render codePanel(snippet)} + {:else if mode === 'image'} + {@render imagePanel(snippet)} + {/if} +
{/if} -
-
- -
- -
- {:else if mode === 'image'} - {@const block = imageBlockAtCaret(snippet)} - {#if block} -
-

Source

-

{block.src}

-
-

Alt text

-

- {block.alt || '(missing)'} -

-
- {#if block.caption} -
-

Caption

-

{block.caption}

-
- {/if} - {#if block.status} -
-

Status

-

- {block.status === 'pending' ? 'Uploading…' : 'Upload failed'} -

-
- {/if} - {:else} -
-

Place caret on an image to edit it.

-
- {/if} - {:else if mode === 'table'} -
-

Rows

-
- - -
-
-
-

Columns

-
- - -
-
-
-

Headers

-
- - -
-
-
-

Appearance

-
- - -
-
-
-

Cell tone

-
- {#each [ - { id: 'table-cell-tone-default', label: 'Plain' }, - { id: 'table-cell-tone-muted', label: 'Muted' }, - { id: 'table-cell-tone-accent', label: 'Accent' } - ] as t (t.id)} - - {/each} -
-
- {:else if mode === 'code'} -
-

Language

-
- {#each CODE_LANGUAGES as lang (lang.id)} - - {/each} -
-
- {:else if mode === 'list'} -
-

List kind

-
- - - -
-
-
-

Indent

-
- - -
-
+ {/each} {/if}
{/if} {/snippet}
+{#snippet defaultPanel(s: ProviderSnippetProps)} + {@const wordCount = countWordsInText(s.plainText)} + {@const charCount = s.plainText.length} + {@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))} + {@const outline = s.document.children + .map((block, blockIndex) => ({ block, blockIndex })) + .filter(({ block }) => block.type === 'heading') + .map(({ block, blockIndex }) => ({ + blockIndex, + level: block.type === 'heading' ? block.level : 1, + text: + block.type === 'heading' + ? block.children.map((i) => (i.type === 'text' ? i.text : '')).join('') + : '' + })) + .filter((e) => e.text.trim())} + {@const warnings = collectWarnings(s)} + +
+

Outline

+ {#if outline.length === 0} +

No headings yet

+ {:else} +
    + {#each outline as entry (entry.blockIndex)} +
  1. + H{entry.level} + {entry.text} +
  2. + {/each} +
+ {/if} +
+
+

Stats

+
+
Words
{wordCount}
+
Chars
{charCount}
+
Reading
{readingTimeMin} min
+
+
+
+

+ Suggestions + {#if warnings.length > 0} + {warnings.length} + {/if} +

+ {#if warnings.length === 0} +

Nothing flagged

+ {:else} +
    + {#each warnings as w (w.id)} +
  • {w.message}
  • + {/each} +
+ {/if} +
+{/snippet} + +{#snippet formatPanel(s: ProviderSnippetProps)} +
+ {#each [ + { id: 'bold', label: 'B', title: 'Bold' }, + { id: 'italic', label: 'I', title: 'Italic' }, + { id: 'underline', label: 'U', title: 'Underline' }, + { id: 'strike', label: 'S', title: 'Strikethrough' }, + { id: 'code', label: '<>', title: 'Inline code' } + ] as mark (mark.id)} + + {/each} +
+
+ + {#if s.canUnlink} + + {/if} + +
+{/snippet} + +{#snippet blockPanel(s: ProviderSnippetProps)} +
Text align
+
+ {#each [ + { id: 'align-left', label: '⫷', title: 'Left' }, + { id: 'align-center', label: '☰', title: 'Center' }, + { id: 'align-right', label: '⫸', title: 'Right' }, + { id: 'align-justify', label: '☷', title: 'Justify' } + ] as a (a.id)} + + {/each} +
+{/snippet} + +{#snippet cellPanel(s: ProviderSnippetProps)} +
Tone
+
+ {#each [ + { id: 'table-cell-tone-default', label: 'Plain' }, + { id: 'table-cell-tone-muted', label: 'Muted' }, + { id: 'table-cell-tone-accent', label: 'Accent' } + ] as t (t.id)} + + {/each} +
+
Vertical
+
+ {#each [ + { id: 'table-cell-vertical-top', label: '⬆ Top' }, + { id: 'table-cell-vertical-middle', label: '◆ Mid' }, + { id: 'table-cell-vertical-bottom', label: '⬇ Bot' } + ] as v (v.id)} + + {/each} +
+
Text align
+
+ {#each [ + { id: 'align-left', label: '⫷' }, + { id: 'align-center', label: '☰' }, + { id: 'align-right', label: '⫸' }, + { id: 'align-justify', label: '☷' } + ] as a (a.id)} + + {/each} +
+{/snippet} + +{#snippet rowPanel(s: ProviderSnippetProps)} +
+ + + +
+{/snippet} + +{#snippet tablePanel(s: ProviderSnippetProps)} +
Columns
+
+ + + +
+
Appearance
+
+ + +
+{/snippet} + +{#snippet listItemPanel(s: ProviderSnippetProps)} +
+ + +
+{/snippet} + +{#snippet listPanel(s: ProviderSnippetProps)} +
Kind
+
+ + + +
+{/snippet} + +{#snippet codePanel(s: ProviderSnippetProps)} +
+ {#each CODE_LANGUAGES as lang (lang.id)} + + {/each} +
+{/snippet} + +{#snippet imagePanel(s: ProviderSnippetProps)} + {@const block = imageBlockAtCaret(s)} + {#if block} +
Source
+

{block.src}

+
Alt
+

+ {block.alt || '(missing)'} +

+ {#if block.caption} +
Caption
+

{block.caption}

+ {/if} + {#if block.status} +
Status
+

+ {block.status === 'pending' ? 'Uploading…' : 'Upload failed'} +

+ {/if} + {:else} +

Place caret on an image to edit it.

+ {/if} +{/snippet} +