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>
active-uix
parent
ef95b1a82a
commit
23f90a775c
@ -0,0 +1,264 @@
|
||||
<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>
|
||||
Loading…
Reference in new issue