|
|
<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 } from '$uix/eidos/components/icon';
|
|
|
import * as Words from '$soma/components/words';
|
|
|
import type {
|
|
|
DrawerSnippetProps,
|
|
|
DrawerProps,
|
|
|
ProviderSnippetProps,
|
|
|
WordsDrawerMode
|
|
|
} from '$soma/components/words';
|
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
|
|
|
|
let {
|
|
|
open = $bindable(undefined),
|
|
|
defaultOpen = true,
|
|
|
disabled = false,
|
|
|
children: customChildren,
|
|
|
...rest
|
|
|
}: Omit<DrawerProps, 'child'> = $props();
|
|
|
|
|
|
const MODE_LABELS: Record<WordsDrawerMode, string> = {
|
|
|
default: '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: 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;
|
|
|
}
|
|
|
|
|
|
function imageBlockAtCaret(s: ProviderSnippetProps):
|
|
|
| {
|
|
|
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 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) : '';
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest}>
|
|
|
{#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}
|
|
|
{/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 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 }) => ({
|
|
|
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 [
|
|
|
{ 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={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 [
|
|
|
{ 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>
|
|
|
{/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' },
|
|
|
{ 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={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 [
|
|
|
{ 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={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 [
|
|
|
{ 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>
|
|
|
{/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 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}
|
|
|
|
|
|
<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>
|