fix(words): image block selection — click figure activates image drawer mode (COLOR-1)

Image blocks are contenteditable=false so the text caret can never
live inside them. The drawer never entered image mode and the user
had no visible "selected image" state. Fixed with a separate atomic-
block selection layer:

- New provider state `selectedBlockIndex` (number | undefined) for
  atomic block selection. Distinct from text selection.
- `currentBlock` derived now short-circuits to the atomic block's
  type when `selectedBlockIndex` is set, so the drawer enters image
  mode whenever the user has selected an image.
- New methods `selectAtomicBlock(blockIndex)` + `clearSelectedBlock()`
  on the provider. selectAtomicBlock also focuses the editor root so
  keyboard handlers (esc, delete) work on the atomic block.
- `syncSelectionFromDom` clears the atomic-block highlight only when
  the text selection actually moved (was clearing on every
  selectionchange echo, which wiped the highlight a tick after the
  click landed).
- Provider onclick handler detects clicks on figure[data-words-block=
  'image'][data-words-path], decodes the path, calls
  selectAtomicBlock(blockIndex).
- Render passes `selectedBlockIndex` through findOpts; image block
  render adds `data-words-block-selected` attr when the index matches.
- Eidos CSS paints a 3px accent outline + glow on the selected
  figure. Image panel's imageBlockAtCaret() now falls back to
  selectedBlockIndex first, then text-selection anchor.

Result: clicking an image in the editor now highlights it (accent
outline + glow), the drawer enters IMAGE mode and shows its
source/alt/caption/status panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent e25ac301b5
commit b3524b3446

@ -71,7 +71,13 @@
readonly status?: 'pending' | 'error'; readonly status?: 'pending' | 'error';
} }
| undefined { | 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; if (blockIndex === undefined) return undefined;
const block = s.document.children[blockIndex]; const block = s.document.children[blockIndex];
if (!block || block.type !== 'image') return undefined; if (!block || block.type !== 'image') return undefined;

@ -1586,6 +1586,23 @@
outline-offset: 2px; 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'] { [data-words-content] [data-words-list-kind='check'] {
list-style: none; list-style: none;
} }

@ -20,6 +20,13 @@ import { renderTable, renderTablePlainText } from '../extensions/table/render';
export interface WordsRenderFindOptions { export interface WordsRenderFindOptions {
readonly matches?: readonly WordsTextMatch[]; readonly matches?: readonly WordsTextMatch[];
readonly activeMatchIndex?: number; 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 { interface IndexedWordsTextMatch {
@ -157,6 +164,10 @@ function renderWordsBlock(
children: [{ kind: 'text', text: block.caption }] children: [{ kind: 'text', text: block.caption }]
}); });
} }
const isSelected =
findOpts?.selectedBlockIndex !== undefined &&
path.length === 1 &&
path[0] === findOpts.selectedBlockIndex;
return { return {
kind: 'element', kind: 'element',
tag: 'figure', tag: 'figure',
@ -168,6 +179,7 @@ function renderWordsBlock(
? { 'data-words-image-align': block.align } ? { 'data-words-image-align': block.align }
: {}), : {}),
...(block.status ? { 'data-words-image-status': block.status } : {}), ...(block.status ? { 'data-words-image-status': block.status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
// contenteditable=false so the caret can't enter the // contenteditable=false so the caret can't enter the
// image atom; selection lands on the figure as a whole. // image atom; selection lands on the figure as a whole.
contenteditable: 'false' contenteditable: 'false'

@ -172,6 +172,15 @@ export class WordsProvider {
slashMenuMounts = $state(0); slashMenuMounts = $state(0);
slashMenuActiveIndex = $state(0); slashMenuActiveIndex = $state(0);
slashDismissedAnchorKey = $state<string | null>(null); slashDismissedAnchorKey = $state<string | null>(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<number | undefined>(undefined);
private pendingCommitReason: WordsCommitReason = 'programmatic'; private pendingCommitReason: WordsCommitReason = 'programmatic';
private typingBatchOpen = false; private typingBatchOpen = false;
@ -279,7 +288,8 @@ export class WordsProvider {
readonly html = $derived.by(() => readonly html = $derived.by(() =>
renderWordsDomHtml(this.document, { renderWordsDomHtml(this.document, {
matches: this.findMatches, matches: this.findMatches,
activeMatchIndex: this.currentMatchIndex activeMatchIndex: this.currentMatchIndex,
selectedBlockIndex: this.selectedBlockIndex
}) })
); );
@ -308,10 +318,41 @@ export class WordsProvider {
}); });
readonly currentBlock = $derived.by(() => { 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; const index = this.selection?.anchor.path[0] ?? 0;
return this.document.children[index]?.type ?? 'paragraph'; 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(() => { readonly currentHeadingLevel = $derived.by(() => {
const index = this.selection?.anchor.path[0] ?? 0; const index = this.selection?.anchor.path[0] ?? 0;
const block = this.document.children[index]; const block = this.document.children[index];
@ -474,6 +515,15 @@ export class WordsProvider {
if (this.shouldIgnoreTransientSelectionCollapse(next, root, options.source ?? 'direct')) { if (this.shouldIgnoreTransientSelectionCollapse(next, root, options.source ?? 'direct')) {
return false; 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); this.updateSelection(next);
return true; return true;
} }
@ -864,11 +914,28 @@ export class WordsProvider {
this.selectBlock(); this.selectBlock();
return; return;
} }
if (!this.isEditable) return;
const target = e.target; const target = e.target;
const targetElement = const targetElement =
target instanceof Element ? target : target instanceof Node ? target.parentElement : null; target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!targetElement) return; 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]'); const checkToggle = targetElement.closest('[data-words-check-toggle][data-words-path]');
if (checkToggle && e.currentTarget.contains(checkToggle)) { if (checkToggle && e.currentTarget.contains(checkToggle)) {
e.preventDefault(); e.preventDefault();
@ -1083,6 +1150,7 @@ export class WordsProvider {
readonly snippetProps = $derived.by(() => ({ readonly snippetProps = $derived.by(() => ({
document: this.document, document: this.document,
selection: this.selection, selection: this.selection,
selectedBlockIndex: this.selectedBlockIndex,
plainText: this.plainText, plainText: this.plainText,
isEmpty: this.isEmpty, isEmpty: this.isEmpty,
isFocused: this.focused, isFocused: this.focused,
@ -1122,6 +1190,8 @@ export class WordsProvider {
focus: () => this.focus(), focus: () => this.focus(),
selectAll: () => this.selectAll(), selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(), selectBlock: () => this.selectBlock(),
selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex),
clearSelectedBlock: () => this.clearSelectedBlock(),
runCommand: (command: WordsCommandName) => this.runCommandName(command), runCommand: (command: WordsCommandName) => this.runCommandName(command),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language), setCodeLanguage: (language?: string) => this.setCodeLanguage(language),
insertLink: (href: string, title?: string) => this.insertLink(href, title), insertLink: (href: string, title?: string) => this.insertLink(href, title),

Loading…
Cancel
Save

Powered by TurnKey Linux.