|
|
<script lang="ts">
|
|
|
/**
|
|
|
* 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.
|
|
|
*
|
|
|
* 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.
|
|
|
*/
|
|
|
import { ChevronRight, ChevronLeft } from '$uix/eidos/components/icon';
|
|
|
import * as Words from '$soma/components/words';
|
|
|
import type {
|
|
|
DrawerSnippetProps,
|
|
|
DrawerProps,
|
|
|
ProviderSnippetProps
|
|
|
} from '$soma/components/words';
|
|
|
|
|
|
let {
|
|
|
open = $bindable(undefined),
|
|
|
defaultOpen = true,
|
|
|
disabled = false,
|
|
|
children: customChildren,
|
|
|
...rest
|
|
|
}: Omit<DrawerProps, 'child'> = $props();
|
|
|
|
|
|
const MODE_LABELS: Record<DrawerSnippetProps['mode'], string> = {
|
|
|
default: 'Document',
|
|
|
format: 'Format',
|
|
|
image: 'Image',
|
|
|
table: 'Table',
|
|
|
code: 'Code',
|
|
|
list: 'List'
|
|
|
};
|
|
|
|
|
|
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 {
|
|
|
const blockIndex = 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;
|
|
|
</script>
|
|
|
|
|
|
<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))}
|
|
|
|
|
|
<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) => toggle(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[mode]}</span>
|
|
|
{/if}
|
|
|
</header>
|
|
|
|
|
|
{#if d.open}
|
|
|
<div data-words-drawer-body>
|
|
|
{#if customChildren}
|
|
|
{@render customChildren(d)}
|
|
|
{: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>
|
|
|
<h3 data-words-drawer-section-title>Outline</h3>
|
|
|
{#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}
|
|
|
</section>
|
|
|
|
|
|
<section data-words-drawer-section>
|
|
|
<h3 data-words-drawer-section-title>Stats</h3>
|
|
|
<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>
|
|
|
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}
|
|
|
</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>
|
|
|
<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>
|
|
|
<button
|
|
|
type="button"
|
|
|
data-words-drawer-action
|
|
|
data-tone="full"
|
|
|
onmousedown={(e) => e.preventDefault()}
|
|
|
onclick={() => {
|
|
|
const url = window.prompt('Link URL:');
|
|
|
if (url) snippet.insertLink(url);
|
|
|
}}
|
|
|
>
|
|
|
Add link
|
|
|
</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 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>
|
|
|
<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}
|
|
|
{/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>
|