@ -1,80 +1,37 @@
< script lang = "ts" >
< script lang = "ts" >
/**
/**
* Words.Drawer — companion right-rail that REPLACES the fragmented
* Words.Drawer — companion right-rail eidos wrapper.
* popovers of a traditional rich-text editor (bubble menu + slash
* menu + link editor + heading picker + language picker + table
* popover + image controls) with ONE contextual surface.
*
*
* Inspired by Figma's right panel: the same UI slot shows different
* After DRAWER-CANON-2 the drawer is canonical: it consumes the
* controls depending on what the user is editing.
* soma `Words.Drawer` provider (which owns the open/collapsed state
* and the derived `mode`) and renders the per-mode body here in
* eidos.
*
*
* Contextual modes (derived from snippetProps):
* Concept (still A+D from the spike):
* - 'default' → outline + word/char/reading time + writer warnings
* - Single contextual surface replacing bubble menu + slash menu +
* - 'format' → bold/italic/underline + link + align (text selected)
* link editor + heading picker + language picker + table popover
* - 'image' → alt/caption + alignment + status (in image)
* + image controls.
* - 'table' → row/col ops + header + striped + tone (in table)
* - Default mode shows outline + stats + writer-coaching suggestions.
* - 'code' → language picker (in code block)
* - Format / image / table / code / list modes show controls scoped
* - 'list' → kind toggle + indent (in list item)
* to what the user is editing.
*
* This is the spike-1 surface (commit DRAWER-1): shell, context
* detection, default panel only. Contextual panels arrive in
* DRAWER-2.
*/
*/
import { ChevronRight , ChevronLeft } from '$uix/eidos/components/icon';
import { ChevronRight , ChevronLeft } from '$uix/eidos/components/icon';
import * as Words from '$soma/components/words';
import type {
import type {
ProviderSnippetProps,
DrawerSnippetProps,
WordsBlockType
DrawerProps,
ProviderSnippetProps
} from '$soma/components/words';
} from '$soma/components/words';
import type { Snippet } from 'svelte';
type DrawerMode = 'default' | 'format' | 'image' | 'table' | 'code' | 'list';
interface WordsDrawerProps {
/** All snippet props from `< Words > `. Forward them straight. */
readonly snippet: ProviderSnippetProps;
/** Open by default. The drawer is collapsible. */
open?: boolean;
/** Optional override for the panel renderer — consumers can
* inject their own content per mode. Receives `{ mode , snippet } `. */
readonly content?: Snippet<
[{ readonly mode : DrawerMode ; readonly snippet : ProviderSnippetProps } ]
>;
}
let { snippet , open = $bindable ( true ), content } : WordsDrawerProps = $props();
const mode: DrawerMode = $derived.by(() => resolveMode(snippet));
let {
open = $bindable(undefined),
const modeLabel = $derived(MODE_LABELS[mode]);
defaultOpen = true,
disabled = false,
function resolveMode(s: ProviderSnippetProps): DrawerMode {
children: customChildren,
// Text-selection format takes precedence over block-type panels.
...rest
// "Range selection inside an inline-text block" → format mode.
}: Omit< DrawerProps , ' child ' > = $props();
const sel = s.selection;
if (
sel & &
(sel.anchor.path[0] !== sel.focus.path[0] ||
sel.anchor.offset !== sel.focus.offset)
) {
const block = s.currentBlock as WordsBlockType;
if (
block === 'paragraph' ||
block === 'heading' ||
block === 'quote' ||
block === 'list'
) {
return 'format';
}
}
const block = s.currentBlock as WordsBlockType;
if (block === 'image') return 'image';
if (block === 'table') return 'table';
if (block === 'code') return 'code';
if (block === 'list') return 'list';
return 'default';
}
const MODE_LABELS: Record< Drawer Mode , string > = {
const MODE_LABELS: Record< DrawerSnippetProps [' mode '], string > = {
default: 'Document',
default: 'Document',
format: 'Format',
format: 'Format',
image: 'Image',
image: 'Image',
@ -83,106 +40,19 @@
list: 'List'
list: 'List'
};
};
// ── Default-panel derivations (D — writer coaching) ───────────────
const wordCount = $derived(countWordsInText(snippet.plainText));
const charCount = $derived(snippet.plainText.length);
/** Average reading speed 200wpm. Floor to 1 min so the value is
* always meaningful even for tiny drafts. */
const readingTimeMin = $derived(Math.max(1, Math.round(wordCount / 200)));
type OutlineEntry = {
readonly text: string;
readonly level: 1 | 2 | 3;
readonly blockIndex: number;
};
const outline = $derived.by< readonly OutlineEntry [] > (() => {
const out: OutlineEntry[] = [];
snippet.document.children.forEach((block, index) => {
if (block.type !== 'heading') return;
const text = block.children
.map((inline) =>
inline.type === 'text'
? inline.text
: inline.children.map((c) => (c.type === 'text' ? c.text : '')).join('')
)
.join('');
if (!text.trim()) return;
out.push({ text , level : block.level , blockIndex : index } );
});
return out;
});
type Warning = {
readonly id: string;
readonly severity: 'info' | 'warn';
readonly message: string;
};
/**
* Writer-coaching warnings (D). Lightweight, no I/O, derived from
* the document model. Each rule is independent so adding new rules
* is cheap.
*/
const warnings = $derived.by< readonly Warning [] > (() => {
const out: Warning[] = [];
snippet.document.children.forEach((block, index) => {
if (block.type === 'image' && !block.alt?.trim()) {
out.push({
id: `alt-${ index } `,
severity: 'warn',
message: `Image at block ${ index + 1 } has no alt text`
});
}
if (block.type === 'paragraph') {
const text = block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('');
if (text.length > 600) {
out.push({
id: `long-${ index } `,
severity: 'info',
message: `Paragraph ${ index + 1 } is long (${ text . length } chars) — consider splitting`
});
}
}
});
// Heading-structure check — H1 → H3 without H2 between is a
// common a11y gap.
const headingLevels = snippet.document.children
.filter((b) => b.type === 'heading')
.map((b) => (b.type === 'heading' ? b.level : 0));
for (let i = 1; i < headingLevels.length ; i ++) {
if (headingLevels[i] - headingLevels[i - 1] > 1) {
out.push({
id: `skip-${ i } `,
severity: 'warn',
message: `Heading h${ headingLevels [ i ]} appears without an h${ headingLevels [ i - 1 ] + 1 } above — structure skip`
});
break;
}
}
return out;
});
function countWordsInText(text: string): number {
function countWordsInText(text: string): number {
const trimmed = text.trim();
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
return trimmed ? trimmed.split(/\s+/).length : 0;
}
}
function toggleOpen() {
function imageBlockAtCaret(s: ProviderSnippetProps):
open = !open;
| {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
readonly status?: 'pending' | 'error';
}
}
| undefined {
/**
* Return the image block currently containing the caret, or
* undefined if the caret is not on an image. The image panel uses
* this to display block attributes (src/alt/caption/status).
*/
function imageBlockAtCaret(
s: ProviderSnippetProps
): { readonly src : string ; readonly alt? : string ; readonly caption? : string ; readonly status ?: 'pending' | 'error' } | undefined {
const blockIndex = s.selection?.anchor.path[0];
const blockIndex = s.selection?.anchor.path[0];
if (blockIndex === undefined) return undefined;
if (blockIndex === undefined) return undefined;
const block = s.document.children[blockIndex];
const block = s.document.children[blockIndex];
@ -202,36 +72,53 @@
] as const;
] as const;
< / script >
< / script >
< aside
< Words.Drawer bind:open { defaultOpen } { disabled } {... rest } >
data-words-drawer
{ # snippet children ( d : DrawerSnippetProps )}
data-mode={ mode }
{ @const { mode , snippet , toggle } = d }
data-open={ open ? '' : undefined }
{ @const wordCount = countWordsInText ( snippet . plainText )}
aria-label="Editor companion drawer"
{ @const charCount = snippet . plainText . length }
>
{ @const readingTimeMin = Math . max ( 1 , Math . round ( wordCount / 200 ))}
< header data-words-drawer-header >
< header data-words-drawer-header >
< button
< button
type="button"
type="button"
data-words-drawer-collapse
data-words-drawer-collapse
aria-label={ open ? 'Collapse drawer' : 'Expand drawer' }
aria-label={ d . open ? 'Collapse drawer' : 'Expand drawer' }
aria-expanded={ open }
aria-expanded={ d . open }
onclick={ toggleOpen }
onclick={( e ) => toggle ( e . currentTarget as HTMLElement ) }
>
>
{ #if open }
{ #if d . open }
< ChevronRight size = "sm" decorative / >
< ChevronRight size = "sm" decorative / >
{ : else }
{ : else }
< ChevronLeft size = "sm" decorative / >
< ChevronLeft size = "sm" decorative / >
{ /if }
{ /if }
< / button >
< / button >
{ #if open }
{ #if d . open }
< span data-words-drawer-title > { modeLabel }</ span >
< span data-words-drawer-title > { MODE_LABELS[ mode] }</ span >
{ /if }
{ /if }
< / header >
< / header >
{ #if open }
{ #if d . open }
< div data-words-drawer-body >
< div data-words-drawer-body >
{ #if content }
{ #if customChildren }
{ @render content ({ mode , snippet } )}
{ @render customChildren ( d )}
{ :else if mode === 'default' }
{ :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 )}
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Outline< / h3 >
< h3 data-words-drawer-section-title > Outline< / h3 >
{ #if outline . length === 0 }
{ #if outline . length === 0 }
@ -253,10 +140,7 @@
< dl data-words-drawer-stats >
< dl data-words-drawer-stats >
< div >< dt > Words</ dt >< dd > { wordCount } </ dd ></ div >
< div >< dt > Words</ dt >< dd > { wordCount } </ dd ></ div >
< div >< dt > Chars</ dt >< dd > { charCount } </ dd ></ div >
< div >< dt > Chars</ dt >< dd > { charCount } </ dd ></ div >
< div >
< div >< dt > Reading</ dt >< dd > { readingTimeMin } min</ dd ></ div >
< dt > Reading< / dt >
< dd > { readingTimeMin } min</ dd >
< / div >
< / dl >
< / dl >
< / section >
< / section >
@ -401,67 +285,29 @@
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Rows< / h3 >
< h3 data-words-drawer-section-title > Rows< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'insert-table-row' )} > + Row </ button >
type="button"
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => snippet . runCommand ( 'delete-table-row' )} > − Row </ 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 >
< / div >
< / section >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Columns< / h3 >
< h3 data-words-drawer-section-title > Columns< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'insert-table-column' )} > + Col </ button >
type="button"
< button type = "button" data-words-drawer-action data-tone = "risk" onclick = {() => snippet . runCommand ( 'delete-table-column' )} > − Col </ 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 >
< / div >
< / section >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Headers< / h3 >
< h3 data-words-drawer-section-title > Headers< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableHeaderRowActive ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-header-row' )} > Row header</ button >
type="button"
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableHeaderColumnActive ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-header-column' )} > Col header</ 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 >
< / div >
< / section >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Appearance< / h3 >
< h3 data-words-drawer-section-title > Appearance< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableStriped ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-striped' )} > Striped</ button >
type="button"
< button type = "button" data-words-drawer-action data-active = { snippet . currentTableCompact ? '' : undefined } onclick= {() => snippet . runCommand ( 'toggle-table-compact' )} > Compact</ 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 >
< / div >
< / div >
< / section >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
@ -499,39 +345,67 @@
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > List kind< / h3 >
< h3 data-words-drawer-section-title > List kind< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'unordered-list' )} > Bullet</button >
type="button"
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'ordered-list' )} > Numbered</button >
data-words-drawer-action
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'check-list' )} > Check</button >
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 >
< / div >
< / div >
< / section >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Indent< / h3 >
< h3 data-words-drawer-section-title > Indent< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
< button
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'decrease-indent' )} > ⇤ Outdent </ button >
type="button"
< button type = "button" data-words-drawer-action onclick = {() => snippet . runCommand ( 'increase-indent' )} > ⇥ Indent </ 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 >
< / div >
< / div >
< / section >
< / section >
{ /if }
{ /if }
< / div >
< / div >
{ /if }
{ /if }
< / aside >
{ /snippet }
< / Words.Drawer >
< script context = "module" lang = "ts" >
import type { ProviderSnippetProps as PSP } from '$soma/components/words';
type Warning = {
readonly id: string;
readonly severity: 'info' | 'warn';
readonly message: string;
};
export function collectWarnings(s: PSP): readonly Warning[] {
const out: Warning[] = [];
s.document.children.forEach((block, index) => {
if (block.type === 'image' && !block.alt?.trim()) {
out.push({
id: `alt-${ index } `,
severity: 'warn',
message: `Image at block ${ index + 1 } has no alt text`
});
}
if (block.type === 'paragraph') {
const text = block.children
.map((i) => (i.type === 'text' ? i.text : ''))
.join('');
if (text.length > 600) {
out.push({
id: `long-${ index } `,
severity: 'info',
message: `Paragraph ${ index + 1 } is long (${ text . length } chars) — consider splitting`
});
}
}
});
const headingLevels = s.document.children
.filter((b) => b.type === 'heading')
.map((b) => (b.type === 'heading' ? b.level : 0));
for (let i = 1; i < headingLevels.length ; i ++) {
if (headingLevels[i] - headingLevels[i - 1] > 1) {
out.push({
id: `skip-${ i } `,
severity: 'warn',
message: `Heading h${ headingLevels [ i ]} appears without an h${ headingLevels [ i - 1 ] + 1 } above — structure skip`
});
break;
}
}
return out;
}
< / script >