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

412 lines
14 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 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>

Powered by TurnKey Linux.