You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/words/words-drawer.svelte

784 lines
25 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
/**
* Words.Drawer — companion right-rail eidos wrapper.
*
* 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).
*
* Modes are derived in soma (`WordsDrawerProvider.modes`); eidos
* just paints them.
*/
import {
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronUp,
Pencil,
Check,
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';
import type {
DrawerSnippetProps,
DrawerProps,
ProviderSnippetProps,
WordsDrawerMode,
WordsCommandName,
WordsMark
} from '$soma/components/words';
import { SvelteMap } from 'svelte/reactivity';
let {
open = $bindable(undefined),
defaultOpen = true,
disabled = false,
/**
* Which side of the editor the drawer occupies. Default `'right'`.
* When `'left'`, the editor grid flips so the drawer sits at the
* start of the inline axis; the floating overlays (block handle,
* block inserter "+" button) automatically render on the
* opposite margin to stay out of the drawer's gutter.
*/
side = 'right' as 'left' | 'right',
children: customChildren,
...rest
}: Omit<DrawerProps, 'child'> & { side?: 'left' | 'right' } = $props();
const MODE_LABELS: Record<WordsDrawerMode, string> = {
default: 'Insert',
document: 'Document',
format: 'Format',
block: 'Block',
cell: 'Cell',
row: 'Row',
table: 'Table',
'list-item': 'List item',
list: 'List',
code: 'Code',
image: 'Image'
};
// 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' 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[]) {
panelOpenOverrides.set(mode, !isPanelOpen(mode, modes));
}
function countWordsInText(text: string): number {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
}
function imageBlockAtCaret(s: ProviderSnippetProps):
| {
readonly index: number;
readonly block: {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
readonly status?: 'pending' | 'error';
};
}
| undefined {
// Atomic block selection wins (image is contenteditable=false so
// the text selection rarely points at it). Falls back to the
// caret's anchor block when the user hasn't atomic-clicked.
const blockIndex =
s.selectedBlockIndex !== undefined
? s.selectedBlockIndex
: s.selection?.anchor.path[0];
if (blockIndex === undefined) return undefined;
const block = s.document.children[blockIndex];
if (!block || block.type !== 'image') return undefined;
return { index: blockIndex, block };
}
const CODE_LANGUAGES = [
{ id: '', label: 'Plain' },
{ id: 'ts', label: 'TS' },
{ id: 'js', label: 'JS' },
{ id: 'css', label: 'CSS' },
{ id: 'html', label: 'HTML' },
{ id: 'json', label: 'JSON' },
{ id: 'bash', label: 'Bash' },
{ id: 'python', label: 'Py' }
] as const;
/** Hand-picked palette — keeps the picker compact while covering
* most editorial intents. Free hex still available via the input
* next to the chips. */
const COLOR_PRESETS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // amber
'#22c55e', // green
'#06b6d4', // cyan
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#ffffff', // white
'#a1a1aa' // grey
] as const;
/** Read the parametric mark value for a given prefix from the
* active marks. Returns '' when not set. */
function currentParametricMark(
marks: readonly string[],
prefix: 'color' | 'bgcolor'
): string {
const found = marks.find((m) => m.startsWith(`${prefix}:`));
return found ? found.slice(prefix.length + 1) : '';
}
// Typed catalogs for drawer action buttons. `as const` narrows
// `id` to its literal type so it satisfies the canonical unions
// (WordsMark for inline marks; WordsCommandName for runCommand).
// Without this, an inline array literal infers `id: string` and
// runCommand / activeMarks.includes reject the call.
const FORMAT_MARKS = [
{ 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 const satisfies readonly { id: WordsMark & WordsCommandName; label: string; title: string }[];
const TEXT_ALIGN_ACTIONS = [
{ 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 const satisfies readonly { id: WordsCommandName; label: string; title: string }[];
const CELL_TONE_ACTIONS = [
{ id: 'table-cell-tone-default', label: 'Plain' },
{ id: 'table-cell-tone-muted', label: 'Muted' },
{ id: 'table-cell-tone-accent', label: 'Accent' }
] as const satisfies readonly { id: WordsCommandName; label: string }[];
const CELL_VERTICAL_ACTIONS = [
{ id: 'table-cell-vertical-top', label: '⬆ Top' },
{ id: 'table-cell-vertical-middle', label: '◆ Mid' },
{ id: 'table-cell-vertical-bottom', label: '⬇ Bot' }
] as const satisfies readonly { id: WordsCommandName; label: string }[];
const CELL_TEXT_ALIGN_ACTIONS = [
{ id: 'align-left', label: '⫷' },
{ id: 'align-center', label: '☰' },
{ id: 'align-right', label: '⫸' },
{ id: 'align-justify', label: '☷' }
] as const satisfies readonly { id: WordsCommandName; label: string }[];
</script>
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest} data-side={side}>
{#snippet children(d: DrawerSnippetProps)}
{@const { modes, snippet, toggle: toggleDrawer } = d}
<header data-words-drawer-header>
<button
type="button"
data-words-drawer-collapse
aria-label={d.open ? 'Collapse drawer' : 'Expand drawer'}
aria-expanded={d.open}
onclick={(e) => toggleDrawer(e.currentTarget as HTMLElement)}
>
{#if d.open}
<ChevronRight size="sm" decorative />
{:else}
<ChevronLeft size="sm" decorative />
{/if}
</button>
{#if d.open}
<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}
<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>
{#if d.open}
<div data-words-drawer-body>
{#if customChildren}
{@render customChildren(d)}
{: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 insertPanel(snippet)}
{:else if mode === 'document'}
{@render documentPanel(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 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))}
{@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)}
<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}
<ol data-words-drawer-outline>
{#each outline as entry (entry.blockIndex)}
<li data-words-drawer-outline-item data-level={entry.level}>
<span data-words-drawer-outline-bullet>H{entry.level}</span>
<span data-words-drawer-outline-text>{entry.text}</span>
</li>
{/each}
</ol>
{/if}
</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>
</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}
</h4>
{#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}
</div>
{/snippet}
{#snippet formatPanel(s: ProviderSnippetProps)}
<div data-words-drawer-actions>
{#each FORMAT_MARKS as mark (mark.id)}
<button
type="button"
data-words-drawer-action
data-active={s.activeMarks.includes(mark.id) ? '' : undefined}
title={mark.title}
aria-pressed={s.activeMarks.includes(mark.id)}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand(mark.id)}
>
{mark.label}
</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Foreground</div>
<div data-words-drawer-color-picker>
{#each COLOR_PRESETS as preset (preset)}
<button
type="button"
data-words-drawer-color-swatch
data-active={currentParametricMark(s.activeMarks, 'color') === preset ? '' : undefined}
style="background-color:{preset}"
title={preset}
aria-label={`Set foreground to ${preset}`}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.applyCommand({ type: 'toggleMark', mark: `color:${preset}` as never })}
></button>
{/each}
<input
type="color"
data-words-drawer-color-input
value={currentParametricMark(s.activeMarks, 'color') || '#000000'}
aria-label="Pick custom foreground color"
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
s.applyCommand({ type: 'toggleMark', mark: `color:${v}` as never });
}}
/>
{#if currentParametricMark(s.activeMarks, 'color')}
<button
type="button"
data-words-drawer-color-clear
title="Clear foreground"
aria-label="Clear foreground color"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.applyCommand({ type: 'toggleMark', mark: 'color:' as never })}
>×</button>
{/if}
</div>
<div data-words-drawer-section-subtitle>Background</div>
<div data-words-drawer-color-picker>
{#each COLOR_PRESETS as preset (preset)}
<button
type="button"
data-words-drawer-color-swatch
data-active={currentParametricMark(s.activeMarks, 'bgcolor') === preset ? '' : undefined}
style="background-color:{preset}"
title={preset}
aria-label={`Set background to ${preset}`}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.applyCommand({ type: 'toggleMark', mark: `bgcolor:${preset}` as never })}
></button>
{/each}
<input
type="color"
data-words-drawer-color-input
value={currentParametricMark(s.activeMarks, 'bgcolor') || '#ffff00'}
aria-label="Pick custom background color"
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
s.applyCommand({ type: 'toggleMark', mark: `bgcolor:${v}` as never });
}}
/>
{#if currentParametricMark(s.activeMarks, 'bgcolor')}
<button
type="button"
data-words-drawer-color-clear
title="Clear background"
aria-label="Clear background color"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.applyCommand({ type: 'toggleMark', mark: 'bgcolor:' as never })}
>×</button>
{/if}
</div>
<div data-words-drawer-actions data-flush>
<button
type="button"
data-words-drawer-action
data-tone="full"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
const url = window.prompt('Link URL:');
if (url) s.insertLink(url);
}}
>
Add link
</button>
{#if s.canUnlink}
<button
type="button"
data-words-drawer-action
onmousedown={(e) => e.preventDefault()}
onclick={() => s.unlink()}
>
Remove
</button>
{/if}
<button
type="button"
data-words-drawer-action
data-tone="ghost"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand('clear-formatting')}
>
Clear
</button>
</div>
{/snippet}
{#snippet blockPanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Text align</div>
<div data-words-drawer-actions>
{#each TEXT_ALIGN_ACTIONS 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>
{/snippet}
{#snippet cellPanel(s: ProviderSnippetProps)}
<div data-words-drawer-section-subtitle>Tone</div>
<div data-words-drawer-actions>
{#each CELL_TONE_ACTIONS as t (t.id)}
<button
type="button"
data-words-drawer-action
data-active={s.currentTableCellTone === t.id.replace('table-cell-tone-', '') ? '' : undefined}
onclick={() => s.runCommand(t.id)}
>{t.label}</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Vertical</div>
<div data-words-drawer-actions>
{#each CELL_VERTICAL_ACTIONS as v (v.id)}
<button
type="button"
data-words-drawer-action
data-active={s.currentTableCellVerticalAlign === v.id.replace('table-cell-vertical-', '') ? '' : undefined}
onclick={() => s.runCommand(v.id)}
>{v.label}</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Text align</div>
<div data-words-drawer-actions>
{#each CELL_TEXT_ALIGN_ACTIONS 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>
{/snippet}
{#snippet rowPanel(s: ProviderSnippetProps)}
<div data-words-drawer-actions>
<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>
{/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 entry = imageBlockAtCaret(s)}
{#if entry}
{@const block = entry.block}
{@const blockIndex = entry.index}
<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>
<Editable
data-words-drawer-editable
value={block.alt ?? ''}
placeholder="Describe the image…"
activationMode="none"
submitMode="none"
size="sm"
variant="ghost"
color="primary"
onValueCommit={(next) =>
s.applyCommand({
type: 'updateBlock',
blockIndex,
patch: { alt: next }
})}
>
<Editable.Area>
<Editable.Preview />
<Editable.Input>
{#snippet child({ props })}
<textarea {...props} rows={2}></textarea>
{/snippet}
</Editable.Input>
</Editable.Area>
<Editable.Control>
<Editable.EditTrigger aria-label="Edit alt text">
<Pencil size="xs" decorative />
</Editable.EditTrigger>
<Editable.SubmitTrigger aria-label="Save alt text">
<Check size="xs" decorative />
</Editable.SubmitTrigger>
<Editable.CancelTrigger aria-label="Cancel editing">
<X size="xs" decorative />
</Editable.CancelTrigger>
</Editable.Control>
</Editable>
<div data-words-drawer-section-subtitle>Caption</div>
<Editable
data-words-drawer-editable
value={block.caption ?? ''}
placeholder="Optional caption shown below the image"
activationMode="none"
submitMode="none"
size="sm"
variant="ghost"
color="primary"
onValueCommit={(next) =>
s.applyCommand({
type: 'updateBlock',
blockIndex,
patch: { caption: next || undefined }
})}
>
<Editable.Area>
<Editable.Preview />
<Editable.Input>
{#snippet child({ props })}
<textarea {...props} rows={2}></textarea>
{/snippet}
</Editable.Input>
</Editable.Area>
<Editable.Control>
<Editable.EditTrigger aria-label="Edit caption">
<Pencil size="xs" decorative />
</Editable.EditTrigger>
<Editable.SubmitTrigger aria-label="Save caption">
<Check size="xs" decorative />
</Editable.SubmitTrigger>
<Editable.CancelTrigger aria-label="Cancel editing">
<X size="xs" decorative />
</Editable.CancelTrigger>
</Editable.Control>
</Editable>
{#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}
<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>

Powered by TurnKey Linux.