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}