diff --git a/src/uix/eidos/components/words/words-drawer.svelte b/src/uix/eidos/components/words/words-drawer.svelte index d2c95796f..d3464dabd 100644 --- a/src/uix/eidos/components/words/words-drawer.svelte +++ b/src/uix/eidos/components/words/words-drawer.svelte @@ -71,7 +71,13 @@ readonly status?: 'pending' | 'error'; } | undefined { - const blockIndex = s.selection?.anchor.path[0]; + // Atomic block selection wins (image is contenteditable=false so + // the text selection rarely points at it). Falls back to the + // caret's anchor block when the user hasn't atomic-clicked. + const blockIndex = + s.selectedBlockIndex !== undefined + ? s.selectedBlockIndex + : s.selection?.anchor.path[0]; if (blockIndex === undefined) return undefined; const block = s.document.children[blockIndex]; if (!block || block.type !== 'image') return undefined; diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 356f7dcc8..72417324b 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -1586,6 +1586,23 @@ outline-offset: 2px; } +/* Atomic block selection — when the user clicks an image the provider + flags it as the visually-selected atomic block (selectedBlockIndex). + The render decorates the figure with data-words-block-selected so we + can paint a strong accent border + soft glow. The drawer enters + image mode in parallel. */ +[data-words-content] figure[data-words-block='image'][data-words-block-selected] { + outline: 3px solid var(--_words-accent-solid); + outline-offset: 3px; + box-shadow: + 0 0 0 6px color-mix(in srgb, var(--_words-accent-solid) 18%, transparent), + 0 0 24px color-mix(in srgb, var(--_words-accent-solid) 25%, transparent); +} + +[data-words-content] figure[data-words-block='image'] { + cursor: pointer; +} + [data-words-content] [data-words-list-kind='check'] { list-style: none; } diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index ec331c061..c7265c4be 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -20,6 +20,13 @@ import { renderTable, renderTablePlainText } from '../extensions/table/render'; export interface WordsRenderFindOptions { readonly matches?: readonly WordsTextMatch[]; readonly activeMatchIndex?: number; + /** + * Index of the block currently selected as an atomic unit (image + * or any other contenteditable=false block). When set, render + * decorates the matching block with `data-words-block-selected` so + * eidos can paint an accent outline + the drawer enters its mode. + */ + readonly selectedBlockIndex?: number; } interface IndexedWordsTextMatch { @@ -157,6 +164,10 @@ function renderWordsBlock( children: [{ kind: 'text', text: block.caption }] }); } + const isSelected = + findOpts?.selectedBlockIndex !== undefined && + path.length === 1 && + path[0] === findOpts.selectedBlockIndex; return { kind: 'element', tag: 'figure', @@ -168,6 +179,7 @@ function renderWordsBlock( ? { 'data-words-image-align': block.align } : {}), ...(block.status ? { 'data-words-image-status': block.status } : {}), + ...(isSelected ? { 'data-words-block-selected': '' } : {}), // contenteditable=false so the caret can't enter the // image atom; selection lands on the figure as a whole. contenteditable: 'false' diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 31389c732..b51f3b0f1 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -172,6 +172,15 @@ export class WordsProvider { slashMenuMounts = $state(0); slashMenuActiveIndex = $state(0); slashDismissedAnchorKey = $state(null); + /** + * Atomic block selection — block index pointing to an image (or + * any other contenteditable=false block we add later). Distinct + * from the text selection because the caret can never live inside + * an atomic block. Set by the eidos click handler when the user + * clicks an image; cleared by any selectionchange / focus loss / + * text-editing event. + */ + selectedBlockIndex = $state(undefined); private pendingCommitReason: WordsCommitReason = 'programmatic'; private typingBatchOpen = false; @@ -279,7 +288,8 @@ export class WordsProvider { readonly html = $derived.by(() => renderWordsDomHtml(this.document, { matches: this.findMatches, - activeMatchIndex: this.currentMatchIndex + activeMatchIndex: this.currentMatchIndex, + selectedBlockIndex: this.selectedBlockIndex }) ); @@ -308,10 +318,41 @@ export class WordsProvider { }); readonly currentBlock = $derived.by(() => { + // Atomic block selection (image / etc) takes precedence over + // text-selection-derived currentBlock — the caret can't live + // inside an atomic block, so without this short-circuit the + // drawer would never enter the atomic block's mode. + if (this.selectedBlockIndex !== undefined) { + const atomic = this.document.children[this.selectedBlockIndex]; + if (atomic) return atomic.type; + } const index = this.selection?.anchor.path[0] ?? 0; return this.document.children[index]?.type ?? 'paragraph'; }); + /** + * Mark an atomic block (image / etc) as visually selected. The + * eidos render reads this via `selectedBlockIndex` to paint the + * accent outline; the drawer picks it up via `currentBlock` to + * enter the block's mode. Distinct from `selectBlock()` (which + * extends the text selection to cover the current block's + * contents). + */ + selectAtomicBlock(blockIndex: number): void { + if (blockIndex < 0 || blockIndex >= this.document.children.length) return; + this.selectedBlockIndex = blockIndex; + // Focus the editor root so keyboard handlers (esc, delete) work + // on the atomic block. + const root = this.opts.ref.current; + if (root && typeof (root as HTMLElement).focus === 'function') { + (root as HTMLElement).focus({ preventScroll: true } as FocusOptions); + } + } + + clearSelectedBlock(): void { + if (this.selectedBlockIndex !== undefined) this.selectedBlockIndex = undefined; + } + readonly currentHeadingLevel = $derived.by(() => { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; @@ -474,6 +515,15 @@ export class WordsProvider { if (this.shouldIgnoreTransientSelectionCollapse(next, root, options.source ?? 'direct')) { return false; } + // Clear the atomic-block highlight only when the text selection + // genuinely moved (the user clicked somewhere else / pressed an + // arrow / typed). Synthetic selectionchange echoes that arrive + // right after `selectAtomicBlock` (which doesn't actually move + // the DOM caret) would otherwise wipe the highlight a tick + // later, defeating the click. + if (!sameWordsSelection(this.selection, next)) { + this.clearSelectedBlock(); + } this.updateSelection(next); return true; } @@ -864,11 +914,28 @@ export class WordsProvider { this.selectBlock(); return; } - if (!this.isEditable) return; const target = e.target; const targetElement = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; if (!targetElement) return; + // Atomic-block click — figure[data-words-block='image'] (and any + // future atomic block types). Don't preventDefault: the browser + // will still resolve a caret-friendly text position; we just + // flag the block as the visually-selected atomic so the drawer + // can enter image mode. + const atomicFigure = targetElement.closest( + 'figure[data-words-block="image"][data-words-path]' + ); + if (atomicFigure && e.currentTarget.contains(atomicFigure)) { + const path = decodeWordsPath(atomicFigure.getAttribute('data-words-path')); + const blockIndex = path?.[0]; + if (typeof blockIndex === 'number') { + e.preventDefault(); + this.selectAtomicBlock(blockIndex); + return; + } + } + if (!this.isEditable) return; const checkToggle = targetElement.closest('[data-words-check-toggle][data-words-path]'); if (checkToggle && e.currentTarget.contains(checkToggle)) { e.preventDefault(); @@ -1083,6 +1150,7 @@ export class WordsProvider { readonly snippetProps = $derived.by(() => ({ document: this.document, selection: this.selection, + selectedBlockIndex: this.selectedBlockIndex, plainText: this.plainText, isEmpty: this.isEmpty, isFocused: this.focused, @@ -1122,6 +1190,8 @@ export class WordsProvider { focus: () => this.focus(), selectAll: () => this.selectAll(), selectBlock: () => this.selectBlock(), + selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex), + clearSelectedBlock: () => this.clearSelectedBlock(), runCommand: (command: WordsCommandName) => this.runCommandName(command), setCodeLanguage: (language?: string) => this.setCodeLanguage(language), insertLink: (href: string, title?: string) => this.insertLink(href, title),