@ -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();
let {
open = $bindable(undefined),
defaultOpen = true,
disabled = false,
children: customChildren,
...rest
}: Omit< DrawerProps , ' child ' > = $props();
const mode: DrawerMode = $derived.by(() => resolveMode(snippet));
const MODE_LABELS: Record< DrawerSnippetProps [' mode '], string > = {
const modeLabel = $derived(MODE_LABELS[mode]);
function resolveMode(s: ProviderSnippetProps): DrawerMode {
// Text-selection format takes precedence over block-type panels.
// "Range selection inside an inline-text block" → format mode.
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< DrawerMode , 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;
* Return the image block currently containing the caret, or
readonly status?: 'pending' | 'error';
* undefined if the caret is not on an image. The image panel uses
}
* this to display block attributes (src/alt/caption/status).
| undefined {
*/
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,336 +72,340 @@
] 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 >
< button
< header data-words-drawer-header >
type="button"
< button
data-words-drawer-collapse
type="button"
aria-label={ open ? 'Collapse drawer' : 'Expand drawer' }
data-words-drawer-collapse
aria-expanded={ open }
aria-label={ d . open ? 'Collapse drawer' : 'Expand drawer' }
onclick={ toggleOpen }
aria-expanded={ d . open }
>
onclick={( e ) => toggle ( e . currentTarget as HTMLElement )}
{ #if open }
>
< ChevronRight size = "sm" decorative / >
{ #if d . open }
{ : else }
< ChevronRight size = "sm" decorative / >
< ChevronLeft size = "sm" decorative / >
{ : else }
< ChevronLeft size = "sm" decorative / >
{ /if }
< / button >
{ #if d . open }
< span data-words-drawer-title > { MODE_LABELS [ mode ]} </ span >
{ /if }
{ /if }
< / button >
< / header >
{ #if open }
< span data-words-drawer-title > { modeLabel } </ span >
{ /if }
< / 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' }
< section data-words-drawer-section >
{ @const outline = snippet . document . children
< h3 data-words-drawer-section-title > Outline< / h3 >
.map((block, blockIndex) => ({ block , blockIndex } ))
{ #if outline . length === 0 }
.filter(({ block } ) => block.type === 'heading')
< p data-words-drawer-empty > No headings yet< / p >
.map(({ block , blockIndex } ) => ({
{ : else }
blockIndex,
< ol data-words-drawer-outline >
level: block.type === 'heading' ? block.level : 1,
{ #each outline as entry ( entry . blockIndex )}
text:
< li data-words-drawer-outline-item data-level = { entry . level } >
block.type === 'heading'
< span data-words-drawer-outline-bullet > H{ entry . level } </ span >
? block.children
< span data-words-drawer-outline-text > { entry . text } </ span >
.map((i) => (i.type === 'text' ? i.text : ''))
< / li >
.join('')
{ /each }
: ''
< / ol >
}))
{ /if }
.filter((e) => e.text.trim())}
< / section >
{ @const warnings = collectWarnings ( snippet )}
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Stats< / h3 >
< h3 data-words-drawer-section-title > Outline< / h3 >
< dl data-words-drawer-stats >
{ #if outline . length === 0 }
< div >< dt > Words</ dt >< dd > { wordCount } </ dd ></ div >
< p data-words-drawer-empty > No headings yet< / p >
< div >< dt > Chars</ dt >< dd > { charCount } </ dd ></ div >
{ : else }
< div >
< ol data-words-drawer-outline >
< dt > Reading< / dt >
{ #each outline as entry ( entry . blockIndex )}
< dd > { readingTimeMin } min</ dd >
< li data-words-drawer-outline-item data-level = { entry . level } >
< / div >
< span data-words-drawer-outline-bullet > H{ entry . level } </ span >
< / dl >
< span data-words-drawer-outline-text > { entry . text } </ span >
< / section >
< / li >
{ /each }
< / ol >
{ /if }
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title >
< h3 data-words-drawer-section-title > Stats< / h3 >
Suggestions
< dl data-words-drawer-stats >
{ #if warnings . length > 0 }
< div >< dt > Words</ dt >< dd > { wordCount } </ dd ></ div >
< span data-words-drawer-section-count > { warnings . length } </ span >
< 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 >
Suggestions
{ #if warnings . length > 0 }
< span data-words-drawer-section-count > { warnings . length } </ span >
{ /if }
< / h3 >
{ #if warnings . length === 0 }
< p data-words-drawer-empty data-tone = "affirm" > Nothing flagged< / p >
{ : else }
< ul data-words-drawer-warnings >
{ #each warnings as w ( w . id )}
< li data-severity = { w . severity } > { w . message } </li >
{ /each }
< / ul >
{ /if }
{ /if }
< / h3 >
< / section >
{ #if warnings . length === 0 }
{ :else if mode === 'format' }
< p data-words-drawer-empty data-tone = "affirm" > Nothing flagged< / p >
< section data-words-drawer-section >
{ : else }
< h3 data-words-drawer-section-title > Marks< / h3 >
< ul data-words-drawer-warnings >
< div data-words-drawer-actions >
{ #each warnings as w ( w . id )}
{ #each [
< li data-severity = { w . severity } > { w . message } </li >
{ 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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . activeMarks . includes ( mark . id ) ? '' : undefined }
title={ mark . title }
aria-pressed={ snippet . activeMarks . includes ( mark . id )}
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . runCommand ( mark . id )}
>
{ mark . label }
< / button >
{ /each }
{ /each }
< / ul >
< / div >
{ /if }
< / section >
< / section >
{ :else if mode === 'format' }
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Marks< / h3 >
< div data-words-drawer-actions >
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . activeMarks . includes ( mark . id ) ? '' : undefined }
title={ mark . title }
aria-pressed={ snippet . activeMarks . includes ( mark . id )}
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . runCommand ( mark . id )}
>
{ mark . label }
< / button >
{ /each }
< / div >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Align< / h3 >
< h3 data-words-drawer-section-title > Align< / h3 >
< div data-words-drawer-actions >
< div data-words-drawer-actions >
{ #each [
{ #each [
{ id : 'align-left' , label : '⫷' , title : 'Left' } ,
{ id : 'align-left' , label : '⫷' , title : 'Left' } ,
{ id : 'align-center' , label : '☰' , title : 'Center' } ,
{ id : 'align-center' , label : '☰' , title : 'Center' } ,
{ id : 'align-right' , label : '⫸' , title : 'Right' } ,
{ id : 'align-right' , label : '⫸' , title : 'Right' } ,
{ id : 'align-justify' , label : '☷' , title : 'Justify' }
{ id : 'align-justify' , label : '☷' , title : 'Justify' }
] as a (a.id)}
] 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 >
< button
< button
type="button"
type="button"
data-words-drawer-action
data-words-drawer-action
data-active={ snippet . currentTextAlign === a . id . replace ( 'align-' , '' ) ? '' : undefined }
data-tone="full"
title={ a . title }
onmousedown={( e ) => e . preventDefault ()}
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . runCommand ( a . id )}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
>
>
{ a . label }
Add link
< / button >
< / button >
{ /each }
{ #if snippet . canUnlink }
< / div >
< button
< / section >
type="button"
data-words-drawer-action
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . unlink ()}
>
Remove
< / button >
{ /if }
< / div >
< / section >
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Link< / h3 >
< div data-words-drawer-actions >
< button
< button
type="button"
type="button"
data-words-drawer-action
data-words-drawer-action
data-tone="full"
data-tone="ghost "
onmousedown={( e ) => e . preventDefault ()}
onmousedown={( e ) => e . preventDefault ()}
onclick={() => {
onclick={() => snippet . runCommand ( 'clear-formatting' )}
const url = window.prompt('Link URL:');
if (url) snippet.insertLink(url);
}}
>
>
Add link
Clear formatting
< / button >
< / button >
{ #if snippet . canUnlink }
< button
type="button"
data-words-drawer-action
onmousedown={( e ) => e . preventDefault ()}
onclick={() => snippet . 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={() => snippet . runCommand ( 'clear-formatting' )}
>
Clear formatting
< / 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 >
< section data-words-drawer-section >
{ :else if mode === 'image' }
< h3 data-words-drawer-section-title > Alt text< / h3 >
{ @const block = imageBlockAtCaret ( snippet )}
< p data-words-drawer-readonly-text data-empty = { ! block . alt } >
{ #if block }
{ block . alt || '(missing)' }
< / p >
< / section >
{ #if block . caption }
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Caption < / h3 >
< h3 data-words-drawer-section-title > Source< / h3 >
< p data-words-drawer-readonly-text > { block . caption } </ p >
< p data-words-drawer-readonly-text > { block . src } </ p >
< / section >
< / section >
{ /if }
{ #if block . status }
< section data-words-drawer-section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Status < / h3 >
< h3 data-words-drawer-section-title > Alt text< / h3 >
< p data-words-drawer-readonly-text data- tone= { block . status } >
< p data-words-drawer-readonly-text data-empty = { ! block . alt } >
{ block . status === 'pending' ? 'Uploading…' : 'Upload failed '}
{ block . alt || '(missing)' }
< / p >
< / p >
< / section >
< / 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 }
{ /if }
{ : else }
{ :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 >
< section data-words-drawer-section >
< p data-words-drawer-empty > Place caret on an image to edit it.< / p >
< 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 >
< 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 >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Cell tone< / h3 >
< div data-words-drawer-actions >
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentTableCellTone === t . id . replace ( 'table-cell-tone-' , '' ) ? '' : undefined }
onclick={() => snippet . 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-actions >
{ #each CODE_LANGUAGES as lang ( lang . id )}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentCodeLanguage === lang . id ? '' : undefined }
onclick={() => snippet . setCodeLanguage ( lang . id || undefined )}
>{ lang . 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-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 >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Indent< / h3 >
< 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 >
< / div >
< / section >
< / section >
{ /if }
{ /if }
{ :else if mode === 'table' }
< / div >
< section data-words-drawer-section >
{ /if }
< h3 data-words-drawer-section-title > Rows< / h3 >
{ /snippet }
< div data-words-drawer-actions >
< / Words.Drawer >
< button
type="button"
< script context = "module" lang = "ts" >
data-words-drawer-action
import type { ProviderSnippetProps as PSP } from '$soma/components/words';
onclick={() => snippet . runCommand ( 'insert-table-row' )}
type Warning = {
>+ Row< / button >
readonly id: string;
< button
readonly severity: 'info' | 'warn';
type="button"
readonly message: string;
data-words-drawer-action
};
data-tone="risk"
export function collectWarnings(s: PSP): readonly Warning[] {
onclick={() => snippet . runCommand ( 'delete-table-row' )}
const out: Warning[] = [];
>− Row< / button >
s.document.children.forEach((block, index) => {
< / div >
if (block.type === 'image' && !block.alt?.trim()) {
< / section >
out.push({
< section data-words-drawer-section >
id: `alt-${ index } `,
< h3 data-words-drawer-section-title > Columns< / h3 >
severity: 'warn',
< div data-words-drawer-actions >
message: `Image at block ${ index + 1 } has no alt text`
< button
});
type="button"
}
data-words-drawer-action
if (block.type === 'paragraph') {
onclick={() => snippet . runCommand ( 'insert-table-column' )}
const text = block.children
>+ Col< / button >
.map((i) => (i.type === 'text' ? i.text : ''))
< button
.join('');
type="button"
if (text.length > 600) {
data-words-drawer-action
out.push({
data-tone="risk"
id: `long-${ index } `,
onclick={() => snippet . runCommand ( 'delete-table-column' )}
severity: 'info',
>− Col< / button >
message: `Paragraph ${ index + 1 } is long (${ text . length } chars) — consider splitting`
< / div >
});
< / section >
}
< section data-words-drawer-section >
}
< h3 data-words-drawer-section-title > Headers< / h3 >
});
< div data-words-drawer-actions >
const headingLevels = s.document.children
< button
.filter((b) => b.type === 'heading')
type="button"
.map((b) => (b.type === 'heading' ? b.level : 0));
data-words-drawer-action
for (let i = 1; i < headingLevels.length ; i ++) {
data-active={ snippet . currentTableHeaderRowActive ? '' : undefined }
if (headingLevels[i] - headingLevels[i - 1] > 1) {
onclick={() => snippet . runCommand ( 'toggle-table-header-row' )}
out.push({
>Row header< / button >
id: `skip-${ i } `,
< button
severity: 'warn',
type="button"
message: `Heading h${ headingLevels [ i ]} appears without an h${ headingLevels [ i - 1 ] + 1 } above — structure skip`
data-words-drawer-action
});
data-active={ snippet . currentTableHeaderColumnActive ? '' : undefined }
break;
onclick={() => snippet . runCommand ( 'toggle-table-header-column' )}
}
>Col header< / button >
}
< / div >
return out;
< / section >
}
< section data-words-drawer-section >
< / script >
< h3 data-words-drawer-section-title > Appearance< / h3 >
< 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 >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Cell tone< / h3 >
< div data-words-drawer-actions >
{ #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)}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentTableCellTone === t . id . replace ( 'table-cell-tone-' , '' ) ? '' : undefined }
onclick={() => snippet . 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-actions >
{ #each CODE_LANGUAGES as lang ( lang . id )}
< button
type="button"
data-words-drawer-action
data-active={ snippet . currentCodeLanguage === lang . id ? '' : undefined }
onclick={() => snippet . setCodeLanguage ( lang . id || undefined )}
>{ lang . 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-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 >
< / div >
< / section >
< section data-words-drawer-section >
< h3 data-words-drawer-section-title > Indent< / h3 >
< 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 >
< / div >
< / section >
{ /if }
< / div >
{ /if }
< / aside >