feat(words): floating image control bar + generic updateBlock/deleteBlock commands (POLISH-1a)

Quick-action toolbar that hovers above the selected image, mirroring
the pattern in Notion / Linear / Figma. Lets the user align L/C/R,
delete, or dismiss the image without having to expand the Image panel
of the drawer.

New eidos component (spike — no morfo/soma yet, refactor canon if it
sticks): src/uix/eidos/components/words/words-image-float-bar.svelte
  - Position: fixed, calculated from the selected figure's
    getBoundingClientRect on every selection / scroll / resize /
    figure size change (ResizeObserver + window listeners).
  - Visible only when snippet.selectedBlockIndex points to an image
    block; null position otherwise so the component reflows away.
  - 5 actions: align left / align center / align right / delete /
    dismiss. Divider between layout and destructive actions.
  - Calls snippet.applyCommand for align / delete instead of running
    through runCommand string-name path (commands target an
    arbitrary block index, no string lookup needed).

Engine — new generic commands so feature panels stop reaching for
hacky JSON re-imports to mutate single blocks:
  - WordsCommand union gains 'updateBlock' (blockIndex + patch)
    and 'deleteBlock' (blockIndex).
  - updateBlockAt(state, idx, patch) — shallow-merge patch into the
    block, normalize doc, clamp selection. No-op when patch matches
    current values.
  - deleteBlockAt(state, idx) — remove block, fall back to empty
    paragraph when doc would be empty, place caret on the next block.
  - applyCommand exposed on provider snippetProps so any consumer
    (eidos panels, future block toolbars, tests) can drive arbitrary
    WordsCommand without needing a string-name route.

CSS additions: float bar styling (position fixed, accent border,
shadow), action chips with hover + active + risk + ghost variants,
divider.

Demo: <Words.ImageFloatBar snippet={snippetProps} /> mounted next to
<Words.Drawer />.

Verified visually: clicking the demo image now (a) outlines the image
(COLOR-1 highlight), (b) opens the drawer in IMAGE mode, (c) shows
the float bar hovering above the figure with "align center" active
(default), trash + dismiss available.

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

@ -14,6 +14,7 @@ import HiddenInput from './words-hidden-input.svelte';
import Status from './words-status.svelte';
import FindReplace from './words-find-replace.svelte';
import Drawer from './words-drawer.svelte';
import ImageFloatBar from './words-image-float-bar.svelte';
type WordsNamespace = typeof WordsComponent & {
Content: typeof Content;
@ -31,6 +32,7 @@ type WordsNamespace = typeof WordsComponent & {
Status: typeof Status;
FindReplace: typeof FindReplace;
Drawer: typeof Drawer;
ImageFloatBar: typeof ImageFloatBar;
};
const Words = WordsComponent as WordsNamespace;
@ -49,6 +51,7 @@ Words.HiddenInput = HiddenInput;
Words.Status = Status;
Words.FindReplace = FindReplace;
Words.Drawer = Drawer;
Words.ImageFloatBar = ImageFloatBar;
export { Words };
export default Words;

@ -0,0 +1,173 @@
<script lang="ts">
/**
* Words.ImageFloatBar — quick-action toolbar that hovers above
* the currently-selected image block.
*
* Visible only when `snippet.selectedBlockIndex` points to an
* image. Positioned via `getBoundingClientRect` on the figure;
* keeps in sync with scroll/resize via $effect listeners.
*
* SPIKE: eidos-only (no morfo/soma part). If the concept holds
* we refactor canon (same path the drawer took: morfo Drawer →
* WordsDrawerProvider → eidos consumer).
*
* Quick actions live here so the user doesn't have to expand the
* Image panel of the drawer for the 5 most common ops:
* - align left / center / right
* - clear selection (× — clears selectedBlockIndex, drops focus)
* - delete image
*
* Caption / alt / replace src remain in the drawer (POLISH-2).
*/
import {
TextAlignStart as AlignLeft,
TextAlignCenter as AlignCenter,
TextAlignEnd as AlignRight,
Trash,
X
} from '$uix/eidos/components/icon';
import type { ProviderSnippetProps } from '$soma/components/words';
let { snippet }: { snippet: ProviderSnippetProps } = $props();
let position = $state<{ top: number; left: number; width: number } | null>(null);
const targetFigure = $derived.by(() => {
if (snippet.selectedBlockIndex === undefined) return null;
if (typeof document === 'undefined') return null;
return document.querySelector<HTMLElement>(
`figure[data-words-path="${snippet.selectedBlockIndex}"]`
);
});
function measure() {
if (!targetFigure) {
position = null;
return;
}
const r = targetFigure.getBoundingClientRect();
// position: fixed → coords are viewport-relative, no scroll
// offsets to add. Bar floats 8px above the figure's top edge.
position = {
top: r.top - 44,
left: r.left + r.width / 2,
width: r.width
};
}
$effect(() => {
// Re-run on every targetFigure change.
void targetFigure;
measure();
if (!targetFigure) return;
const win = targetFigure.ownerDocument.defaultView;
if (!win) return;
const opts: AddEventListenerOptions = { passive: true };
win.addEventListener('scroll', measure, opts);
win.addEventListener('resize', measure, opts);
const ro = new ResizeObserver(measure);
ro.observe(targetFigure);
return () => {
win.removeEventListener('scroll', measure, opts);
win.removeEventListener('resize', measure, opts);
ro.disconnect();
};
});
const imageBlock = $derived.by(() => {
const idx = snippet.selectedBlockIndex;
if (idx === undefined) return null;
const block = snippet.document.children[idx];
if (!block || block.type !== 'image') return null;
return block;
});
const currentAlign = $derived(imageBlock?.align ?? 'center');
function setAlign(align: 'left' | 'center' | 'right') {
const idx = snippet.selectedBlockIndex;
if (idx === undefined) return;
snippet.applyCommand({
type: 'updateBlock',
blockIndex: idx,
patch: align === 'center' ? { align: undefined } : { align }
});
}
function deleteImage() {
const idx = snippet.selectedBlockIndex;
if (idx === undefined) return;
snippet.applyCommand({ type: 'deleteBlock', blockIndex: idx });
snippet.clearSelectedBlock();
}
function dismiss() {
snippet.clearSelectedBlock();
}
</script>
{#if position}
<div
data-words-image-float-bar
style="top: {position.top}px; left: {position.left}px;"
role="toolbar"
aria-label="Image quick actions"
>
<button
type="button"
data-words-image-float-action
data-active={currentAlign === 'left' ? '' : undefined}
title="Align left"
aria-label="Align left"
onmousedown={(e) => e.preventDefault()}
onclick={() => setAlign('left')}
>
<AlignLeft size="sm" decorative />
</button>
<button
type="button"
data-words-image-float-action
data-active={currentAlign === 'center' ? '' : undefined}
title="Align center"
aria-label="Align center"
onmousedown={(e) => e.preventDefault()}
onclick={() => setAlign('center')}
>
<AlignCenter size="sm" decorative />
</button>
<button
type="button"
data-words-image-float-action
data-active={currentAlign === 'right' ? '' : undefined}
title="Align right"
aria-label="Align right"
onmousedown={(e) => e.preventDefault()}
onclick={() => setAlign('right')}
>
<AlignRight size="sm" decorative />
</button>
<span data-words-image-float-divider aria-hidden="true"></span>
<button
type="button"
data-words-image-float-action
data-tone="risk"
title="Delete image"
aria-label="Delete image"
onmousedown={(e) => e.preventDefault()}
onclick={deleteImage}
>
<Trash size="sm" decorative />
</button>
<button
type="button"
data-words-image-float-action
data-tone="ghost"
title="Dismiss"
aria-label="Dismiss"
onmousedown={(e) => e.preventDefault()}
onclick={dismiss}
>
<X size="sm" decorative />
</button>
</div>
{/if}

@ -463,6 +463,64 @@
color: var(--words-threat-text);
}
/* Image float bar — floats above the selected image with quick
actions (align L/C/R, delete, dismiss). Positioned via inline
`top` / `left` set by the component from getBoundingClientRect. */
[data-words-image-float-bar] {
position: fixed;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem;
border-radius: var(--words-command-radius);
background: var(--words-toolbar-bg);
border: var(--words-border-width) solid var(--_words-accent-border);
box-shadow: var(--words-shadow);
z-index: var(--z-index-popover);
white-space: nowrap;
}
[data-words-image-float-action] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.75rem;
block-size: 1.75rem;
padding: 0;
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-command-color);
cursor: pointer;
}
[data-words-image-float-action]:hover {
background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
color: var(--_words-accent-text);
}
[data-words-image-float-action][data-active] {
background: var(--_words-accent-solid);
color: var(--_words-accent-on);
}
[data-words-image-float-action][data-tone='risk']:hover {
background: color-mix(in srgb, var(--words-threat-text) 15%, transparent);
color: var(--words-threat-text);
}
[data-words-image-float-action][data-tone='ghost'] {
color: var(--words-status-color);
}
[data-words-image-float-divider] {
inline-size: 1px;
block-size: 1.25rem;
background: var(--words-toolbar-border);
margin-inline: 0.15rem;
}
[data-words-drawer-section-title] {
display: flex;
align-items: baseline;

@ -28,10 +28,12 @@ import {
insertLink,
insertLineBreak,
insertParagraph,
deleteBlockAt,
insertImage,
insertTable,
insertTableColumn,
insertTableRow,
updateBlockAt,
insertText,
moveTableCell,
outdentCodeLine,
@ -102,6 +104,21 @@ export type WordsCommand =
align?: WordsImageAlign;
status?: WordsImageStatus;
}
/**
* Patch a block in place. Generic enough to update any block-level
* field (image alt/caption/align/width, table striped/compact,
* heading level, etc.) without adding a dedicated command per
* attribute. The reducer shallow-merges patch into the block and
* runs normalizeDocument.
*/
| {
type: 'updateBlock';
blockIndex: number;
patch: Readonly<Record<string, unknown>>;
}
/** Remove a block by index. Used by the image float bar delete
* action and any future block-action menus. */
| { type: 'deleteBlock'; blockIndex: number }
| { type: 'insertTableRow'; position?: 'before' | 'after' }
| { type: 'insertTableColumn'; position?: 'before' | 'after' }
| { type: 'deleteTableRow' }
@ -154,6 +171,10 @@ export function applyWordsCommand(
align: command.align,
status: command.status
});
case 'updateBlock':
return updateBlockAt(state, command.blockIndex, command.patch);
case 'deleteBlock':
return deleteBlockAt(state, command.blockIndex);
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
case 'insertTableColumn':

@ -773,6 +773,70 @@ export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsO
};
}
/**
* Patch a block in place. Shallow-merge `patch` into the block at
* `blockIndex` and run normalization. Returns the unchanged state if
* the block doesn't exist or the patch is a no-op.
*/
export function updateBlockAt(
state: WordsEditorState,
blockIndex: number,
patch: Readonly<Record<string, unknown>>
): WordsOperationResult {
const block = state.document.children[blockIndex];
if (!block) return { state, changed: false };
const nextBlock = { ...block, ...patch } as WordsBlock;
const blockAsRecord = block as unknown as Record<string, unknown>;
if (Object.entries(patch).every(([key, value]) => blockAsRecord[key] === value)) {
return { state, changed: false };
}
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextBlock)
}).document;
const selection = state.selection ? clampSelection(normalized, state.selection) : null;
return {
state: {
document: normalized,
selection,
activeMarks: selection
? getActiveMarksForSelection(normalized, selection)
: []
},
changed: true
};
}
/**
* Remove the block at `blockIndex`. If the doc would be empty,
* leaves an empty paragraph behind so the editor still has something
* to write into.
*/
export function deleteBlockAt(
state: WordsEditorState,
blockIndex: number
): WordsOperationResult {
if (blockIndex < 0 || blockIndex >= state.document.children.length) {
return { state, changed: false };
}
const nextChildren = state.document.children.filter((_, i) => i !== blockIndex);
const normalized = normalizeDocument({
...state.document,
children: nextChildren.length ? nextChildren : [createParagraph()]
}).document;
const target = Math.min(blockIndex, normalized.children.length - 1);
const point = pointFromInlineTextOffset(normalized, [target], 0);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
export function insertImage(
state: WordsEditorState,
options: {

@ -1193,6 +1193,7 @@ export class WordsProvider {
selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex),
clearSelectedBlock: () => this.clearSelectedBlock(),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
applyCommand: (command: WordsCommand) => this.applyCommand(command),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language),
insertLink: (href: string, title?: string) => this.insertLink(href, title),
unlink: () => this.unlink(),

@ -730,6 +730,7 @@
return renderWordsPlainText(document);
}
// readPlainText handles image (no .children) — POLISH-1a
function readPlainText(document: WordsDocument): string {
return document.children
.map((block) => {
@ -1076,6 +1077,7 @@
{#if showDrawer}
<Words.Drawer />
{/if}
<Words.ImageFloatBar snippet={snippetProps} />
{#if showStatus}
<Words.Status>
{#if invalid}

Loading…
Cancel
Save

Powered by TurnKey Linux.