From 5c10ab74b39031434d418345601ee7c99542dd05 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 21:59:32 +0200 Subject: [PATCH] feat(words): floating image control bar + generic updateBlock/deleteBlock commands (POLISH-1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: mounted next to . 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) --- src/uix/eidos/components/words/index.ts | 3 + .../words/words-image-float-bar.svelte | 173 ++++++++++++++++++ src/uix/eidos/components/words/words.css | 58 ++++++ .../soma/components/words/engine/commands.ts | 21 +++ .../components/words/engine/operations.ts | 64 +++++++ .../components/words/words-provider.svelte.ts | 1 + web/routes/uix/components/words/+page.svelte | 2 + 7 files changed, 322 insertions(+) create mode 100644 src/uix/eidos/components/words/words-image-float-bar.svelte diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index 97db25cd6..d7d5aa1ae 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -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; diff --git a/src/uix/eidos/components/words/words-image-float-bar.svelte b/src/uix/eidos/components/words/words-image-float-bar.svelte new file mode 100644 index 000000000..2fe203339 --- /dev/null +++ b/src/uix/eidos/components/words/words-image-float-bar.svelte @@ -0,0 +1,173 @@ + + +{#if position} +
+ + + + + + +
+{/if} diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 85a913b21..41858b007 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -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; diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index f290b4e15..f750135d7 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -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>; + } + /** 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': diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index b762f93fe..5357911f1 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -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> +): 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; + 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: { diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 615c95b3d..b1c5a96a1 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -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(), diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index d0c14e145..ae050b0b0 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -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} {/if} + {#if showStatus} {#if invalid}