diff --git a/src/uix/eidos/components/words/words-drawer.svelte b/src/uix/eidos/components/words/words-drawer.svelte index 209dee25d..0b943fb1a 100644 --- a/src/uix/eidos/components/words/words-drawer.svelte +++ b/src/uix/eidos/components/words/words-drawer.svelte @@ -19,7 +19,19 @@ ChevronUp, Pencil, Check, - X + X, + FileText, + Type, + Heading1, + Heading2, + Heading3, + Quote, + Code, + List, + ListOrdered, + ListChecks, + Image as ImageIcon, + Table as TableIcon } from '$uix/eidos/components/icon'; import { Editable } from '$uix/eidos/components/editable'; import * as Words from '$soma/components/words'; @@ -40,7 +52,8 @@ }: Omit = $props(); const MODE_LABELS: Record = { - default: 'Document', + default: 'Insert', + document: 'Document', format: 'Format', block: 'Block', cell: 'Cell', @@ -52,15 +65,21 @@ 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. + // Per-panel expand/collapse map. Default heuristic: + // - The top of the stack is open (most specific scope). + // - 'default' (Insert) auto-collapses when another scope is active + // above it, so the inspector wins the focus and Insert sits as a + // secondary affordance at the bottom. + // User explicit toggles override the default and persist across + // context changes. const panelOpenOverrides = new SvelteMap(); 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. + // 'default' is collapsed by default whenever another scope sits + // above it. When it's the only mode (no scope active), open. + if (mode === 'default') return modes[0] === 'default'; + // Other modes follow the original heuristic: top-of-stack opens. return modes[0] === mode; } function togglePanel(mode: WordsDrawerMode, modes: readonly WordsDrawerMode[]) { @@ -162,6 +181,17 @@ {/each} {/if} + {/if} @@ -194,7 +224,9 @@ {#if panelOpen}
{#if mode === 'default'} - {@render defaultPanel(snippet)} + {@render insertPanel(snippet)} + {:else if mode === 'document'} + {@render documentPanel(snippet)} {:else if mode === 'format'} {@render formatPanel(snippet)} {:else if mode === 'block'} @@ -224,7 +256,37 @@ {/snippet} -{#snippet defaultPanel(s: ProviderSnippetProps)} +{#snippet insertPanel(s: ProviderSnippetProps)} + {@const insertItems = [ + { id: 'paragraph' as const, label: 'Paragraph', Icon: Type }, + { id: 'heading-1' as const, label: 'Heading 1', Icon: Heading1 }, + { id: 'heading-2' as const, label: 'Heading 2', Icon: Heading2 }, + { id: 'heading-3' as const, label: 'Heading 3', Icon: Heading3 }, + { id: 'quote' as const, label: 'Quote', Icon: Quote }, + { id: 'code-block' as const, label: 'Code', Icon: Code }, + { id: 'unordered-list' as const, label: 'Bullet list', Icon: List }, + { id: 'ordered-list' as const, label: 'Numbered list', Icon: ListOrdered }, + { id: 'check-list' as const, label: 'Check list', Icon: ListChecks }, + { id: 'image' as const, label: 'Image', Icon: ImageIcon }, + { id: 'table' as const, label: 'Table', Icon: TableIcon } + ]} +
+ {#each insertItems as item (item.id)} + + {/each} +
+{/snippet} + +{#snippet documentPanel(s: ProviderSnippetProps)} {@const wordCount = countWordsInText(s.plainText)} {@const charCount = s.plainText.length} {@const readingTimeMin = Math.max(1, Math.round(wordCount / 200))} diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index e0e801f16..67c437179 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -205,6 +205,83 @@ background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent); } +/* Document-overview toggle in the drawer header. Sits on the far + right (margin-inline-start: auto). Active when the document panel + is in the stack. */ +[data-words-drawer-document-toggle] { + margin-inline-start: auto; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.5rem; + block-size: 1.5rem; + padding: 0; + border: 0; + border-radius: var(--words-command-radius); + background: transparent; + color: var(--words-status-color); + cursor: pointer; + opacity: 0.7; + transition: + background-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +[data-words-drawer-document-toggle]:hover { + opacity: 1; + background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent); + color: var(--words-command-color); +} + +[data-words-drawer-document-toggle][data-active] { + opacity: 1; + background: var(--_words-accent-solid); + color: var(--_words-accent-on); +} + +/* Insert panel — catalog of block constructors. Two columns on the + default drawer width so the labels fit comfortably; falls back to + single column on narrow drawers. */ +[data-words-drawer-insert] { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem; +} + +[data-words-drawer-insert-item] { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + border: var(--words-border-width) solid var(--words-toolbar-border); + border-radius: var(--words-command-radius); + background: var(--words-toolbar-bg); + color: var(--words-command-color); + font: inherit; + font-size: 0.8rem; + cursor: pointer; + text-align: start; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +[data-words-drawer-insert-item]:hover { + border-color: var(--_words-accent-border); + background: color-mix(in srgb, var(--_words-accent-solid) 8%, transparent); + color: var(--_words-accent-text); +} + +[data-words-drawer-insert-item-label] { + flex: 1 1 auto; + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + [data-words-drawer-title] { font-weight: var(--words-strong-font-weight); letter-spacing: 0.02em; diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index dcf8d8861..6c31b667d 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -175,6 +175,13 @@ export type WordsProviderSnippetProps = { readonly replaceCurrentText: (replacement: string) => void; readonly replaceAllText: (replacement: string) => void; readonly commitSlashCommand: (id?: WordsSlashCommandId) => boolean; + /** + * Drawer-Insert-panel entry point — inserts a block of the given + * slash-id kind at the caret. Empty-paragraph context replaces + * in-place; otherwise inserts a new block after the current one. + * Image still prompts for URL synchronously. + */ + readonly insertBlockOfType: (id: WordsSlashCommandId) => boolean; readonly closeSlashMenu: () => void; }; @@ -379,7 +386,8 @@ export type WordsStatusProps = WithChild<{ id?: string }> & * shown when no contextual scope is active. */ export type WordsDrawerMode = - | 'default' + | 'default' // Insert panel — constructor catalog for new blocks + | 'document' // outline + stats + suggestions (opt-in via header toggle) | 'format' | 'block' // text-align scope of a paragraph/heading/quote | 'cell' // single cell scope (tone, vertical align, header) @@ -398,9 +406,15 @@ export type WordsDrawerSnippetProps = { readonly mode: WordsDrawerMode; readonly open: boolean; readonly disabled: boolean; + /** Whether the 'document' overview panel is currently in the + * stack. Toggled by the user via the header. */ + readonly documentPanelOpen: boolean; readonly snippet: WordsProviderSnippetProps; readonly toggle: (target?: HTMLElement) => boolean; readonly setOpen: (open: boolean, target?: HTMLElement) => void; + /** Flip the 'document' panel on/off. When on, it prepends to + * modes[] so the outline/stats/suggestions render at the top. */ + readonly toggleDocumentPanel: () => void; }; export type WordsDrawerProps = WithChild< diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index ef72d17dd..e80898032 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -31,7 +31,11 @@ import { applyWordsCommand, batchHistoryCommands, beginWordsComposition, + createCodeBlock, createEmptyWordsDocument, + createHeading, + createParagraph, + createQuote, createWordsCompositionState, createWordsHistory, createWordsState, @@ -1211,6 +1215,7 @@ export class WordsProvider { replaceCurrentText: (replacement: string) => this.replaceCurrentText(replacement), replaceAllText: (replacement: string) => this.replaceAllText(replacement), commitSlashCommand: (id?: WordsSlashCommandId) => this.commitSlashCommand(id), + insertBlockOfType: (id: WordsSlashCommandId) => this.insertBlockOfType(id), closeSlashMenu: () => this.closeSlashMenu() })); @@ -1394,6 +1399,102 @@ export class WordsProvider { if (this.slashMenuMounts === 0) this.slashMenuActiveIndex = 0; } + /** + * Imperatively insert a block of the given slash-id KIND at the + * current caret position. Different from `commitSlashCommand` + * which requires the slash menu to be open and replaces the + * `/query` text — this one is the "Insert panel" entry point and + * has these semantics: + * + * - If the current block is an empty paragraph: REPLACE it via + * `setBlock` (for paragraph/heading/quote/code) so the editor + * feels in-place rather than spawning a stray blank. + * - Otherwise: insert a NEW block AFTER the current one. Image + * and table use their dedicated `insertImage` / `insertTable` + * commands (which already do the right thing). Lists use + * `toggleList`. Caret lands at the start of the new block. + */ + insertBlockOfType(id: WordsSlashCommandId, target?: HTMLElement): boolean { + const sel = this.selection; + const doc = this.history.present.document; + const currentIdx = + sel?.anchor.path[0] ?? Math.max(0, doc.children.length - 1); + const current = doc.children[currentIdx]; + + // Empty-paragraph heuristic — match what the slash menu treats + // as "free space". + const isEmptyParagraph = + !!current && + current.type === 'paragraph' && + current.children.length === 1 && + current.children[0].type === 'text' && + current.children[0].text === ''; + + // Lists, image and table delegate to their existing commands + // (these already include the necessary normalisation + + // selection management). + if (id === 'table') return this.applyCommand({ type: 'insertTable' }); + if (id === 'unordered-list') + return this.applyCommand({ type: 'toggleList', kind: 'unordered' }); + if (id === 'ordered-list') + return this.applyCommand({ type: 'toggleList', kind: 'ordered' }); + if (id === 'check-list') + return this.applyCommand({ type: 'toggleList', kind: 'check' }); + if (id === 'image') { + const node = this.opts.ref.current; + const win = node?.ownerDocument?.defaultView ?? undefined; + const src = win?.prompt?.('Image URL:')?.trim(); + if (!src) return false; + const altRaw = win?.prompt?.('Alt text (optional):'); + const alt = altRaw?.trim() ? altRaw.trim() : undefined; + return this.applyCommand({ type: 'insertImage', src, alt }); + } + + // For paragraph / heading / quote / code-block: in-place transform + // when the current line is empty (so we don't litter), otherwise + // drop a new block after. + if (isEmptyParagraph) { + let command: WordsCommand | undefined; + if (id === 'paragraph') command = { type: 'setBlock', block: 'paragraph' }; + else if (id === 'heading-1') command = { type: 'setBlock', block: 'heading', level: 1 }; + else if (id === 'heading-2') command = { type: 'setBlock', block: 'heading', level: 2 }; + else if (id === 'heading-3') command = { type: 'setBlock', block: 'heading', level: 3 }; + else if (id === 'quote') command = { type: 'setBlock', block: 'quote' }; + else if (id === 'code-block') command = { type: 'setBlock', block: 'code' }; + if (!command) return false; + const changed = this.applyCommand(command); + if (changed) { + void this.runtime.trigger('commit-set-format', { + fallbackTarget: target ?? this.opts.ref.current ?? undefined + }); + } + return changed; + } + + // Non-empty current: insert a fresh block AFTER it. + const insertIndex = currentIdx + 1; + let block: unknown; + if (id === 'paragraph') block = createParagraph(); + else if (id === 'heading-1') block = createHeading(1); + else if (id === 'heading-2') block = createHeading(2); + else if (id === 'heading-3') block = createHeading(3); + else if (id === 'quote') block = createQuote(); + else if (id === 'code-block') block = createCodeBlock(); + else return false; + + const changed = this.applyCommand({ + type: 'insertBlock', + blockIndex: insertIndex, + block: block as Readonly> + }); + if (changed) { + void this.runtime.trigger('commit-set-format', { + fallbackTarget: target ?? this.opts.ref.current ?? undefined + }); + } + return changed; + } + commitSlashCommand(id?: WordsSlashCommandId, target?: HTMLElement): boolean { const context = this.slashMenuContext; if (!context || !this.slashMenuOpen) return false; @@ -2490,6 +2591,7 @@ export class WordsStatusProvider { */ export type WordsDrawerMode = | 'default' + | 'document' | 'format' | 'block' | 'cell' @@ -2533,47 +2635,62 @@ export class WordsDrawerProvider { * The user can see + act on every layer in one surface instead * of guessing which popover/menu owns the action they need. */ + /** User-toggleable: when true, the 'document' overview panel is + * prepended to the stack (header chevron in eidos drives this). */ + documentPanelOpen = $state(false); + readonly modes: readonly WordsDrawerMode[] = $derived.by(() => { const sel = this.provider.selection; const block = this.provider.currentBlock; - // Atomic blocks (no inline format, no parent scopes) - if (block === 'image') return ['image']; - if (block === 'code') return ['code']; - - if (!sel) return ['default']; + const scopeStack: WordsDrawerMode[] = []; - const stack: WordsDrawerMode[] = []; + // Atomic blocks (no inline format, no parent scopes) + if (block === 'image') { + scopeStack.push('image'); + } else if (block === 'code') { + scopeStack.push('code'); + } else if (sel) { + // Inline-text format is the most specific scope. Only meaningful + // when there's a non-collapsed selection AND the current block + // can host inline marks (excludes 'code' which is plain text). + const isRange = + sel.anchor.path[0] !== sel.focus.path[0] || + sel.anchor.path[1] !== sel.focus.path[1] || + sel.anchor.path[2] !== sel.focus.path[2] || + sel.anchor.offset !== sel.focus.offset; + if ( + isRange && + (block === 'paragraph' || + block === 'heading' || + block === 'quote' || + block === 'list' || + block === 'table') + ) { + scopeStack.push('format'); + } - // Inline-text format is the most specific scope. Only meaningful - // when there's a non-collapsed selection AND the current block - // can host inline marks (excludes 'code' which is plain text). - const isRange = - sel.anchor.path[0] !== sel.focus.path[0] || - sel.anchor.path[1] !== sel.focus.path[1] || - sel.anchor.path[2] !== sel.focus.path[2] || - sel.anchor.offset !== sel.focus.offset; - if ( - isRange && - (block === 'paragraph' || - block === 'heading' || - block === 'quote' || - block === 'list' || - block === 'table') - ) { - stack.push('format'); + // Block-scope stack — from innermost container outward. + if (block === 'table') { + scopeStack.push('cell', 'row', 'table'); + } else if (block === 'list') { + scopeStack.push('list-item', 'list'); + } else if (block === 'paragraph' || block === 'heading' || block === 'quote') { + scopeStack.push('block'); + } } - // Block-scope stack — from innermost container outward. - if (block === 'table') { - stack.push('cell', 'row', 'table'); - } else if (block === 'list') { - stack.push('list-item', 'list'); - } else if (block === 'paragraph' || block === 'heading' || block === 'quote') { - stack.push('block'); - } + // 'default' (Insert panel) is ALWAYS the last entry in the stack — + // the user can drop a new block anywhere even while editing the + // current one. Eidos renders it collapsed when other scopes are + // active so it doesn't dominate. + const out: WordsDrawerMode[] = [...scopeStack, 'default']; + + // 'document' (outline / stats / suggestions) is opt-in via the + // header. When on, prepend so it shows at the top of the drawer. + if (this.documentPanelOpen) out.unshift('document'); - return stack.length > 0 ? stack : ['default']; + return out; }); /** Top of the stack — convenience for consumers that just want the @@ -2609,16 +2726,23 @@ export class WordsDrawerProvider { } } + /** Flip the 'document' overview panel on/off. */ + toggleDocumentPanel(): void { + this.documentPanelOpen = !this.documentPanelOpen; + } + readonly snippetProps = $derived.by(() => ({ modes: this.modes, mode: this.mode, open: this.opts.open.current, disabled: this.opts.disabled.current || this.provider.isDisabled, + documentPanelOpen: this.documentPanelOpen, // Forward the parent snippet so eidos panels can call runCommand, // read activeMarks, etc. without re-piping every action. snippet: this.provider.snippetProps, toggle: (target?: HTMLElement) => this.toggle(target), - setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target) + setOpen: (open: boolean, target?: HTMLElement) => this.setOpen(open, target), + toggleDocumentPanel: () => this.toggleDocumentPanel() })); readonly props = $derived.by(() =>