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

265 lines
7.8 KiB

feat(words): companion drawer right-rail (concept A+D) — shell + default panel (DRAWER-1) A radical disposition shift: instead of N fragmented popovers (bubble menu + slash menu + link editor + heading picker + language picker + table popover + image controls) we mount ONE contextual right-rail that changes its panel based on what the user is editing. Spike-1 surface (this commit) ships: - New eidos component <Words.Drawer> (words-drawer.svelte) — a collapsible right sidebar with mode detection derived from snippet props (currentBlock + selection). 6 modes mapped: default | format | image | table | code | list. - Default panel (visible when no contextual mode active): - OUTLINE — flat list of h1/h2/h3 headings, indented by level - STATS — word count + char count + reading time (200wpm) - SUGGESTIONS — writer-coaching warnings derived from the document model: missing alt text, long paragraphs (>600 chars), heading skip patterns (h3 without h2 above). Empty state shows "Nothing flagged" in affirm color. - Contextual panels (format/image/table/code/list) render a placeholder "coming soon" message — implemented in DRAWER-2. - Eidos CSS: - Provider host gains a grid-template-areas layout when a drawer is present: 'toolbar drawer / content drawer / status drawer'. Existing children (toolbar/content/placeholder/status) get explicit grid-area assignments. - Drawer collapses to 2.25rem (just the chevron) when closed. 16rem default open. - Outline items, stats grid, warning list each have their own discrete styling. Demo (+page.svelte): - New `showDrawer` state (default true). - Children snippet refactored to capture full snippetProps via @const destructure (kept selectedLink working). - <Words.Drawer snippet={snippetProps} /> mounted inside the children block alongside the other parts. Visual verification: - Drawer renders as a dedicated right column with the editor content on the left, no layout overlap. - DOCUMENT header + OUTLINE shows "H1 ActiveUIX Words", STATS shows 73 words / 508 chars / 1 min reading, SUGGESTIONS shows "Nothing flagged" (sample doc has no warnings). - Collapse button toggles open/closed via animated inline-size transition. Next: - DRAWER-2: format/image/table/code/list contextual panels wired to runCommand. - DRAWER-3: polish (mobile drawer-as-bottom-sheet, transitions, A/B toggle in demo to compare classic toolbar vs companion drawer). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 months ago
<script lang="ts">
/**
* Words.Drawer — companion right-rail that REPLACES the fragmented
* 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
* controls depending on what the user is editing.
*
* Contextual modes (derived from snippetProps):
* - 'default' → outline + word/char/reading time + writer warnings
* - 'format' → bold/italic/underline + link + align (text selected)
* - 'image' → alt/caption + alignment + status (in image)
* - 'table' → row/col ops + header + striped + tone (in table)
* - 'code' → language picker (in code block)
* - 'list' → kind toggle + indent (in list item)
*
* 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 type {
ProviderSnippetProps,
WordsBlockType
} 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();
const mode: DrawerMode = $derived.by(() => resolveMode(snippet));
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',
format: 'Format',
image: 'Image',
table: 'Table',
code: 'Code',
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 {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
}
function toggleOpen() {
open = !open;
}
</script>
<aside
data-words-drawer
data-mode={mode}
data-open={open ? '' : undefined}
aria-label="Editor companion drawer"
>
<header data-words-drawer-header>
<button
type="button"
data-words-drawer-collapse
aria-label={open ? 'Collapse drawer' : 'Expand drawer'}
aria-expanded={open}
onclick={toggleOpen}
>
{#if open}
<ChevronRight size="sm" decorative />
{:else}
<ChevronLeft size="sm" decorative />
{/if}
</button>
{#if open}
<span data-words-drawer-title>{modeLabel}</span>
{/if}
</header>
{#if open}
<div data-words-drawer-body>
{#if content}
{@render content({ mode, snippet })}
{:else if mode === 'default'}
<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}
<!-- Contextual panels land in DRAWER-2. -->
<section data-words-drawer-section>
<p data-words-drawer-empty>
Panel for <strong>{modeLabel}</strong> coming soon.
</p>
</section>
{/if}
</div>
{/if}
</aside>

Powered by TurnKey Linux.