@ -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< DrawerProps , ' child ' > = $props();
const MODE_LABELS: Record< DrawerSnippetProps[' mode '] , string > = {
const MODE_LABELS: Record< WordsDrawerMode , string > = {
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< 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.
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 @@
< Words.Drawer bind:open { defaultOpen } { disabled } {... rest } >
{ # 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 }
< header data-words-drawer-header >
< button
@ -85,7 +100,7 @@
data-words-drawer-collapse
aria-label={ d . open ? 'Collapse drawer' : 'Expand drawer' }
aria-expanded={ d . open }
onclick={( e ) => toggle ( e . currentTarget as HTMLElement )}
onclick={( e ) => toggle Drawer ( e . currentTarget as HTMLElement )}
>
{ #if d . open }
< ChevronRight size = "sm" decorative / >
@ -94,7 +109,15 @@
{ /if }
< / button >
{ #if d . open }
< span data-words-drawer-title > { MODE_LABELS [ mode ]} </ span >
< span data-words-drawer-title > { MODE_LABELS [ modes [ 0 ]]} </ span >
{ #if modes . length > 1 }
< span data-words-drawer-trail aria-hidden = "true" >
{ #each modes . slice ( 1 ) as m , i ( m )}
< span data-words-drawer-trail-sep > ·< / span >
< span data-words-drawer-trail-item > { MODE_LABELS [ m ]} </ span >
{ /each }
< / span >
{ /if }
{ /if }
< / header >
@ -102,8 +125,66 @@
< div data-words-drawer-body >
{ #if customChildren }
{ @render customChildren ( d )}
{ :else if mode === 'default' }
{ @const outline = snippet . document . children
{ : else }
{ #each modes as mode , i ( mode )}
{ @const panelOpen = isPanelOpen ( mode , modes )}
< section
data-words-drawer-panel
data-mode={ mode }
data-open={ panelOpen ? '' : undefined }
data-depth={ i }
>
< button
type="button"
data-words-drawer-panel-header
aria-expanded={ panelOpen }
onclick={() => togglePanel ( mode , modes )}
>
< span data-words-drawer-panel-title > { MODE_LABELS [ mode ]} </ span >
{ #if panelOpen }
< ChevronUp size = "xs" decorative / >
{ : else }
< ChevronDown size = "xs" decorative / >
{ /if }
< / button >
{ #if panelOpen }
< div data-words-drawer-panel-body >
{ #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 }
< / div >
{ /if }
< / section >
{ /each }
{ /if }
< / div >
{ /if }
{ /snippet }
< / Words.Drawer >
{ # 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 } ) => ({
@ -111,16 +192,14 @@
level: block.type === 'heading' ? block.level : 1,
text:
block.type === 'heading'
? block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('')
? block.children.map((i) => (i.type === 'text' ? i.text : '')).join('')
: ''
}))
.filter((e) => e.text.trim())}
{ @const warnings = collectWarnings ( s nippet )}
{ @const warnings = collectWarnings ( s )}
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Outline< / h3 >
< div data-words-drawer-section data-flush >
< h4 data-words-drawer-section-title > Outline< / h4 >
{ #if outline . length === 0 }
< p data-words-drawer-empty > No headings yet< / p >
{ : else }
@ -133,24 +212,22 @@
{ /each }
< / ol >
{ /if }
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Stats< / h3 >
< / div >
< div data-words-drawer-section data-flush >
< h4 data-words-drawer-section-title > Stats< / h4 >
< dl data-words-drawer-stats >
< div >< dt > Words</ dt >< dd > { wordCount } </ dd ></ div >
< div >< dt > Chars</ dt >< dd > { charCount } </ dd ></ div >
< div >< dt > Reading</ dt >< dd > { readingTimeMin } min</ dd ></ div >
< / dl >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title >
< / div >
< div data-words-drawer-section data-flush >
< h4 data-words-drawer-section-title >
Suggestions
{ #if warnings . length > 0 }
< span data-words-drawer-section-count > { warnings . length } </ span >
{ /if }
< / h3 >
< / h4 >
{ #if warnings . length === 0 }
< p data-words-drawer-empty data-tone = "affirm" > Nothing flagged< / p >
{ : else }
@ -160,10 +237,10 @@
{ /each }
< / ul >
{ /if }
< / section >
{ :else if mode === 'format' }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Marks< / h3 >
< / div >
{ /snippet }
{ # snippet formatPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-actions >
{ #each [
{ id : 'bold' , label : 'B' , title : 'Bold' } ,
@ -175,44 +252,17 @@
< button
type="button"
data-words-drawer-action
data-active={ s nippet . activeMarks . includes ( mark . id ) ? '' : undefined }
data-active={ s . activeMarks . includes ( mark . id ) ? '' : undefined }
title={ mark . title }
aria-pressed={ s nippet . activeMarks . includes ( mark . id )}
aria-pressed={ s . activeMarks . includes ( mark . id )}
onmousedown={( e ) => e . preventDefault ()}
onclick={() => s nippet . runCommand ( mark . id )}
onclick={() => s . runCommand ( mark . id )}
>
{ mark . label }
< / button >
{ /each }
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Align< / h3 >
< div data-words-drawer-actions >
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentTextAlign === a . id . replace ( 'align-' , '' ) ? '' : undefined }
title={ a . title }
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . runCommand ( a . id )}
>
{ a . label }
< / button >
{ /each }
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Link< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions data-flush >
< button
type="button"
data-words-drawer-action
@ -220,98 +270,58 @@
onmousedown={( e ) => e . preventDefault ()}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet .insertLink(url);
if (url) s.insertLink(url);
}}
>
Add link
< / button >
{ #if s nippet . canUnlink }
{ #if s . canUnlink }
< button
type="button"
data-words-drawer-action
onmousedown={( e ) => e . preventDefault ()}
onclick={() => s nippet . unlink ()}
onclick={() => s . unlink ()}
>
Remove
< / button >
{ /if }
< / div >
< / section >
< section data-words-drawer-section >
< button
type="button"
data-words-drawer-action
data-tone="ghost"
onmousedown={( e ) => e . preventDefault ()}
onclick={() => s nippet . runCommand ( 'clear-formatting' )}
onclick={() => s . runCommand ( 'clear-formatting' )}
>
Clear formatting
Clear
< / button >
< / section >
{ :else if mode === 'image' }
{ @const block = imageBlockAtCaret ( snippet )}
{ #if block }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Source< / h3 >
< p data-words-drawer-readonly-text > { block . src } </ p >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Alt text< / h3 >
< p data-words-drawer-readonly-text data-empty = { ! block . alt } >
{ block . alt || '(missing)' }
< / p >
< / section >
{ #if block . caption }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Caption< / h3 >
< p data-words-drawer-readonly-text > { block . caption } </ p >
< / section >
{ /if }
{ #if block . status }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Status< / h3 >
< p data-words-drawer-readonly-text data-tone = { block . status } >
{ block . status === 'pending' ? 'Uploading…' : 'Upload failed' }
< / p >
< / section >
{ /if }
{ : else }
< section data-words-drawer-section >
< p data-words-drawer-empty > Place caret on an image to edit it.< / p >
< / section >
{ /if }
{ :else if mode === 'table' }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Rows< / h3 >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'insert-table-row' )} > + Row </ button >
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => snippet . runCommand ( 'delete-table-row' )} > − Row </ button >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Columns< / h3 >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'insert-table-column' )} > + Col </ button >
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => snippet . runCommand ( 'delete-table-column' )} > − Col </ button >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Headers< / h3 >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableHeaderRowActive ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-header-row' )} > Row header</ button >
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableHeaderColumnActive ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-header-column' )} > Col header</ button >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Appearance< / h3 >
{ /snippet }
{ # snippet blockPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-section-subtitle > Text align< / div >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableStriped ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-striped' )} > Striped</ button >
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableCompact ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-compact' )} > Compact</ button >
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ s . currentTextAlign === a . id . replace ( 'align-' , '' ) ? '' : undefined }
title={ a . title }
onmousedown={( e ) => e . preventDefault ()}
onclick={() => s . runCommand ( a . id )}
>
{ a . label }
< / button >
{ /each }
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Cell tone< / h3 >
{ /snippet }
{ # snippet cellPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-section-subtitle > Tone< / div >
< div data-words-drawer-actions >
{ #each [
{ id : 'table-cell-tone-default' , label : 'Plain' } ,
@ -321,47 +331,138 @@
< button
type="button"
data-words-drawer-action
data-active={ s nippet . currentTableCellTone === t . id . replace ( 'table-cell-tone-' , '' ) ? '' : undefined }
onclick={() => s nippet . runCommand ( t . id )}
data-active={ s . currentTableCellTone === t . id . replace ( 'table-cell-tone-' , '' ) ? '' : undefined }
onclick={() => s . runCommand ( t . id )}
>{ t . label } </ button >
{ /each }
< / div >
< / section >
{ :else if mode === 'code' }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Language< / h3 >
< div data-words-drawer-section-subtitle > Vertical< / div >
< div data-words-drawer-actions >
{ #each CODE_LANGUAGES as lang ( lang . id )}
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentCodeLanguage === lang . id ? '' : undefined }
onclick={() => snippet . setCodeLanguage ( lang . id || undefine d)}
>{ lang . label } </ button >
data-active={ s . currentTableCellVerticalAlign === v . id . replace ( 'table-cell-vertical-' , '' ) ? '' : undefined }
onclick={() => s . runCommand ( v . i d)}
>{ v . label } </ button >
{ /each }
< / div >
< / section >
{ :else if mode === 'list' }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > List kind< / h3 >
< div data-words-drawer-section-subtitle > Text align< / div >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'unordered-list' )} > Bullet</button >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'ordered-list' )} > Numbered</button >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'check-list' )} > Check</button >
{ #each [
{ id : 'align-left' , label : '⫷' } ,
{ id : 'align-center' , label : '☰' } ,
{ id : 'align-right' , label : '⫸' } ,
{ id : 'align-justify' , label : '☷' }
] as a (a.id)}
< button
type="button"
data-words-drawer-action
data-active={ s . currentTextAlign === a . id . replace ( 'align-' , '' ) ? '' : undefined }
onclick={() => s . runCommand ( a . id )}
>{ a . label } </ button >
{ /each }
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Indent< / h3 >
{ /snippet }
{ # snippet rowPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'decrease-indent' )} > ⇤ Outdent </ button >
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'increase-indent' )} > ⇥ Indent </ button >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'insert-table-row' )} > + Row </ button >
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => s . runCommand ( 'delete-table-row' )} > − Row </ button >
< button
type="button"
data-words-drawer-action
data-active={ s . currentTableHeaderRowActive ? '' : undefined }
onclick={() => s . runCommand ( 'toggle-table-header-row' )}
>Header< / button >
< / div >
< / section >
{ /if }
{ /snippet }
{ # snippet tablePanel ( s : ProviderSnippetProps )}
< div data-words-drawer-section-subtitle > Columns< / div >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'insert-table-column' )} > + Col </ button >
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => s . runCommand ( 'delete-table-column' )} > − Col </ button >
< button
type="button"
data-words-drawer-action
data-active={ s . currentTableHeaderColumnActive ? '' : undefined }
onclick={() => s . runCommand ( 'toggle-table-header-column' )}
>Header< / button >
< / div >
< div data-words-drawer-section-subtitle > Appearance< / div >
< div data-words-drawer-actions >
< button
type="button"
data-words-drawer-action
data-active={ s . currentTableStriped ? '' : undefined }
onclick={() => s . runCommand ( 'toggle-table-striped' )}
>Striped< / button >
< button
type="button"
data-words-drawer-action
data-active={ s . currentTableCompact ? '' : undefined }
onclick={() => s . runCommand ( 'toggle-table-compact' )}
>Compact< / button >
< / div >
{ /snippet }
{ # snippet listItemPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'decrease-indent' )} > ⇤ Outdent </ button >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'increase-indent' )} > ⇥ Indent </ button >
< / div >
{ /snippet }
{ # snippet listPanel ( s : ProviderSnippetProps )}
< div data-words-drawer-section-subtitle > Kind< / div >
< div data-words-drawer-actions >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'unordered-list' )} > Bullet</button >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'ordered-list' )} > Numbered</button >
< button type = "button" data-words-drawer-action onclick = {() => s . runCommand ( 'check-list' )} > Check</button >
< / div >
{ /snippet }
{ # snippet codePanel ( s : ProviderSnippetProps )}
< div data-words-drawer-actions >
{ #each CODE_LANGUAGES as lang ( lang . id )}
< button
type="button"
data-words-drawer-action
data-active={ s . currentCodeLanguage === lang . id ? '' : undefined }
onclick={() => s . setCodeLanguage ( lang . id || undefined )}
>{ lang . label } </ button >
{ /each }
< / div >
{ /snippet }
{ # snippet imagePanel ( s : ProviderSnippetProps )}
{ @const block = imageBlockAtCaret ( s )}
{ #if block }
< div data-words-drawer-section-subtitle > Source< / div >
< p data-words-drawer-readonly-text > { block . src } </ p >
< div data-words-drawer-section-subtitle > Alt< / div >
< p data-words-drawer-readonly-text data-empty = { ! block . alt } >
{ block . alt || '(missing)' }
< / p >
{ #if block . caption }
< div data-words-drawer-section-subtitle > Caption< / div >
< p data-words-drawer-readonly-text > { block . caption } </ p >
{ /if }
{ #if block . status }
< div data-words-drawer-section-subtitle > Status< / div >
< p data-words-drawer-readonly-text data-tone = { block . status } >
{ block . status === 'pending' ? 'Uploading…' : 'Upload failed' }
< / p >
{ /if }
{ : else }
< p data-words-drawer-empty > Place caret on an image to edit it.< / p >
{ /if }
{ /snippet }
< / Words.Drawer >
< script context = "module" lang = "ts" >
import type { ProviderSnippetProps as PSP } from '$soma/components/words';