diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts
index d7d5aa1ae..cbe72bf4a 100644
--- a/src/uix/eidos/components/words/index.ts
+++ b/src/uix/eidos/components/words/index.ts
@@ -15,6 +15,7 @@ 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';
+import BlockHandle from './words-block-handle.svelte';
type WordsNamespace = typeof WordsComponent & {
Content: typeof Content;
@@ -33,6 +34,7 @@ type WordsNamespace = typeof WordsComponent & {
FindReplace: typeof FindReplace;
Drawer: typeof Drawer;
ImageFloatBar: typeof ImageFloatBar;
+ BlockHandle: typeof BlockHandle;
};
const Words = WordsComponent as WordsNamespace;
@@ -52,6 +54,7 @@ Words.Status = Status;
Words.FindReplace = FindReplace;
Words.Drawer = Drawer;
Words.ImageFloatBar = ImageFloatBar;
+Words.BlockHandle = BlockHandle;
export { Words };
export default Words;
diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte
new file mode 100644
index 000000000..bd24324c1
--- /dev/null
+++ b/src/uix/eidos/components/words/words-block-handle.svelte
@@ -0,0 +1,209 @@
+
+
+{#if position}
+
+{/if}
+
+{#if menuOpen && position}
+
+
+
+
+
+
+
+{/if}
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css
index 41858b007..1df3d8fcc 100644
--- a/src/uix/eidos/components/words/words.css
+++ b/src/uix/eidos/components/words/words.css
@@ -521,6 +521,88 @@
margin-inline: 0.15rem;
}
+/* Block handle — Notion-style drag grip rendered to the left of the
+ hovered block. Hidden by default (`opacity: 0`) and fades in on
+ hover via opacity transition. Click reveals an action menu
+ (Move up / Move down / Duplicate / Delete). The button itself is
+ positioned via inline `top` / `left` set from getBoundingClientRect. */
+[data-words-block-handle] {
+ position: fixed;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 1.5rem;
+ block-size: 1.5rem;
+ padding: 0;
+ border: 0;
+ border-radius: var(--words-command-radius);
+ background: transparent;
+ color: var(--words-status-color);
+ cursor: grab;
+ opacity: 0.45;
+ transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
+ z-index: var(--z-index-popover);
+}
+
+[data-words-block-handle]:hover,
+[data-words-block-handle][data-open] {
+ opacity: 1;
+ background: var(--words-command-hover-bg);
+ color: var(--words-command-active-color);
+}
+
+[data-words-block-handle]:active {
+ cursor: grabbing;
+}
+
+[data-words-block-handle-menu] {
+ position: fixed;
+ display: flex;
+ flex-direction: column;
+ min-inline-size: 9rem;
+ padding: 0.25rem;
+ 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);
+}
+
+[data-words-block-handle-menu] button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.5rem;
+ border: 0;
+ border-radius: var(--words-command-radius);
+ background: transparent;
+ color: var(--words-command-color);
+ text-align: start;
+ font: inherit;
+ cursor: pointer;
+}
+
+[data-words-block-handle-menu] button:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
+ color: var(--_words-accent-text);
+}
+
+[data-words-block-handle-menu] button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+[data-words-block-handle-menu] button[data-tone='risk']:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--words-threat-text) 15%, transparent);
+ color: var(--words-threat-text);
+}
+
+[data-words-block-handle-menu-divider] {
+ block-size: 1px;
+ margin-block: 0.25rem;
+ background: var(--words-toolbar-border);
+}
+
[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 f750135d7..87ac32252 100644
--- a/src/uix/soma/components/words/engine/commands.ts
+++ b/src/uix/soma/components/words/engine/commands.ts
@@ -29,10 +29,12 @@ import {
insertLineBreak,
insertParagraph,
deleteBlockAt,
+ duplicateBlockAt,
insertImage,
insertTable,
insertTableColumn,
insertTableRow,
+ moveBlockAt,
updateBlockAt,
insertText,
moveTableCell,
@@ -119,6 +121,10 @@ export type WordsCommand =
/** Remove a block by index. Used by the image float bar delete
* action and any future block-action menus. */
| { type: 'deleteBlock'; blockIndex: number }
+ /** Swap a block with its neighbor in the given direction. */
+ | { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' }
+ /** Insert a deep copy of the block immediately after it. */
+ | { type: 'duplicateBlock'; blockIndex: number }
| { type: 'insertTableRow'; position?: 'before' | 'after' }
| { type: 'insertTableColumn'; position?: 'before' | 'after' }
| { type: 'deleteTableRow' }
@@ -175,6 +181,10 @@ export function applyWordsCommand(
return updateBlockAt(state, command.blockIndex, command.patch);
case 'deleteBlock':
return deleteBlockAt(state, command.blockIndex);
+ case 'moveBlock':
+ return moveBlockAt(state, command.blockIndex, command.direction);
+ case 'duplicateBlock':
+ return duplicateBlockAt(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 5357911f1..67445dc9f 100644
--- a/src/uix/soma/components/words/engine/operations.ts
+++ b/src/uix/soma/components/words/engine/operations.ts
@@ -807,6 +807,76 @@ export function updateBlockAt(
};
}
+/**
+ * Move a block one step in `direction`. No-op when already at the
+ * boundary. After moving, selection follows the block to its new
+ * position so the user keeps the visual focus on what they moved.
+ */
+export function moveBlockAt(
+ state: WordsEditorState,
+ blockIndex: number,
+ direction: 'up' | 'down'
+): WordsOperationResult {
+ const children = state.document.children;
+ if (blockIndex < 0 || blockIndex >= children.length) return { state, changed: false };
+ const target = direction === 'up' ? blockIndex - 1 : blockIndex + 1;
+ if (target < 0 || target >= children.length) return { state, changed: false };
+ const nextChildren = children.slice();
+ const tmp = nextChildren[blockIndex];
+ nextChildren[blockIndex] = nextChildren[target];
+ nextChildren[target] = tmp;
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: nextChildren
+ }).document;
+ const point = pointFromInlineTextOffset(normalized, [target], 0);
+ const selection = createCollapsedSelection(point.path, point.offset);
+ return {
+ state: {
+ document: normalized,
+ selection,
+ activeMarks: getActiveMarksForSelection(normalized, selection)
+ },
+ changed: true
+ };
+}
+
+/**
+ * Insert a deep copy of the block at `blockIndex` immediately after
+ * it. Selection moves to the duplicate so the user can edit the new
+ * copy without losing context.
+ */
+export function duplicateBlockAt(
+ state: WordsEditorState,
+ blockIndex: number
+): WordsOperationResult {
+ const block = state.document.children[blockIndex];
+ if (!block) return { state, changed: false };
+ // Structured clone preserves the deep readonly shape — JSON
+ // round-trip is portable and avoids importing structuredClone.
+ const copy = JSON.parse(JSON.stringify(block)) as WordsBlock;
+ const nextChildren = [
+ ...state.document.children.slice(0, blockIndex + 1),
+ copy,
+ ...state.document.children.slice(blockIndex + 1)
+ ];
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: nextChildren
+ }).document;
+ const newIndex = blockIndex + 1;
+ const point = pointFromInlineTextOffset(normalized, [newIndex], 0);
+ const selection = createCollapsedSelection(point.path, point.offset);
+ return {
+ state: {
+ document: normalized,
+ selection,
+ activeMarks: 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
diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts
index 8c35bd0da..617bcca56 100644
--- a/src/uix/soma/components/words/types.ts
+++ b/src/uix/soma/components/words/types.ts
@@ -8,6 +8,7 @@ import type {
PrimitiveSpanAttributes
} from '../../types';
import type {
+ WordsCommand,
WordsDocument,
WordsExportFormat,
WordsHeadingLevel,
@@ -138,7 +139,16 @@ export type WordsProviderSnippetProps = {
readonly focus: () => void;
readonly selectAll: () => void;
readonly selectBlock: () => void;
+ readonly selectAtomicBlock: (blockIndex: number) => void;
+ readonly clearSelectedBlock: () => void;
readonly runCommand: (command: WordsCommandName) => void;
+ /**
+ * Apply a command directly without going through the sema runtime
+ * (no event emit, no semantic side-effects). Use this for discrete
+ * imperatives invoked by overlays / drag handles / float bars —
+ * not for sema-tuned text edits.
+ */
+ readonly applyCommand: (command: WordsCommand) => boolean;
readonly setCodeLanguage: (language?: string) => boolean;
readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean;
diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte
index ae050b0b0..6e4216007 100644
--- a/web/routes/uix/components/words/+page.svelte
+++ b/web/routes/uix/components/words/+page.svelte
@@ -1078,6 +1078,7 @@
{/if}
+
{#if showStatus}
{#if invalid}