diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts
index cbe72bf4a..6abbe5872 100644
--- a/src/uix/eidos/components/words/index.ts
+++ b/src/uix/eidos/components/words/index.ts
@@ -16,6 +16,7 @@ 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';
+import BlockInserter from './words-block-inserter.svelte';
type WordsNamespace = typeof WordsComponent & {
Content: typeof Content;
@@ -35,6 +36,7 @@ type WordsNamespace = typeof WordsComponent & {
Drawer: typeof Drawer;
ImageFloatBar: typeof ImageFloatBar;
BlockHandle: typeof BlockHandle;
+ BlockInserter: typeof BlockInserter;
};
const Words = WordsComponent as WordsNamespace;
@@ -55,6 +57,7 @@ Words.FindReplace = FindReplace;
Words.Drawer = Drawer;
Words.ImageFloatBar = ImageFloatBar;
Words.BlockHandle = BlockHandle;
+Words.BlockInserter = BlockInserter;
export { Words };
export default Words;
diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte
new file mode 100644
index 000000000..6a8e76e3c
--- /dev/null
+++ b/src/uix/eidos/components/words/words-block-inserter.svelte
@@ -0,0 +1,150 @@
+
+
+{#if seam}
+
+
+
+
+{/if}
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css
index dba16f7b9..4ee2539bf 100644
--- a/src/uix/eidos/components/words/words.css
+++ b/src/uix/eidos/components/words/words.css
@@ -733,6 +733,63 @@
background: var(--words-toolbar-border);
}
+/* Block inserter — hover-zone between blocks. Solves the wedge case
+ where two atomic blocks (image, table, code) are back-to-back and
+ no caret position exists between them. The wrapper is a thin
+ horizontal strip anchored at the seam between two blocks; it
+ carries a faint line and a left-margin "+" button. Click → insert
+ a paragraph at that position. */
+[data-words-block-inserter] {
+ position: fixed;
+ pointer-events: none;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ z-index: var(--z-index-popover);
+}
+
+[data-words-block-inserter-button] {
+ pointer-events: auto;
+ position: absolute;
+ left: -28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 1.5rem;
+ block-size: 1.5rem;
+ padding: 0;
+ border: var(--words-border-width) solid var(--words-toolbar-border);
+ border-radius: 999px;
+ background: var(--words-toolbar-bg);
+ color: var(--words-status-color);
+ cursor: pointer;
+ box-shadow: var(--words-shadow);
+ transition:
+ background 120ms ease,
+ color 120ms ease,
+ border-color 120ms ease;
+}
+
+[data-words-block-inserter-button]:hover {
+ background: var(--_words-accent-solid);
+ border-color: var(--_words-accent-solid);
+ color: var(--_words-accent-on);
+}
+
+[data-words-block-inserter-line] {
+ pointer-events: auto;
+ flex: 1 1 auto;
+ block-size: 2px;
+ border-radius: 1px;
+ background: color-mix(in srgb, var(--_words-accent-solid) 35%, transparent);
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+[data-words-block-inserter]:hover [data-words-block-inserter-line] {
+ opacity: 1;
+}
+
[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 87ac32252..308021911 100644
--- a/src/uix/soma/components/words/engine/commands.ts
+++ b/src/uix/soma/components/words/engine/commands.ts
@@ -30,6 +30,7 @@ import {
insertParagraph,
deleteBlockAt,
duplicateBlockAt,
+ insertBlockAt,
insertImage,
insertTable,
insertTableColumn,
@@ -125,6 +126,17 @@ export type WordsCommand =
| { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' }
/** Insert a deep copy of the block immediately after it. */
| { type: 'duplicateBlock'; blockIndex: number }
+ /**
+ * Insert an arbitrary block at a position. Caret lands at the start
+ * of the new block. Used by the block-inserter overlay to drop a
+ * paragraph between atomic blocks (image / table / code) where no
+ * caret position exists.
+ */
+ | {
+ type: 'insertBlock';
+ blockIndex: number;
+ block: Readonly>;
+ }
| { type: 'insertTableRow'; position?: 'before' | 'after' }
| { type: 'insertTableColumn'; position?: 'before' | 'after' }
| { type: 'deleteTableRow' }
@@ -185,6 +197,16 @@ export function applyWordsCommand(
return moveBlockAt(state, command.blockIndex, command.direction);
case 'duplicateBlock':
return duplicateBlockAt(state, command.blockIndex);
+ case 'insertBlock':
+ // Reducer accepts a typed WordsBlock; the command surface keeps
+ // it loose so consumers (overlays / extensions) can pass JSON
+ // without importing the full block union. Cast on the boundary
+ // — normalizeDocument enforces shape downstream.
+ return insertBlockAt(
+ state,
+ command.blockIndex,
+ command.block as unknown as Parameters[2]
+ );
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 67445dc9f..38d100f20 100644
--- a/src/uix/soma/components/words/engine/operations.ts
+++ b/src/uix/soma/components/words/engine/operations.ts
@@ -907,6 +907,46 @@ export function deleteBlockAt(
};
}
+/**
+ * Insert an arbitrary block at `blockIndex`. The new block takes that
+ * index; everything from `blockIndex` onwards shifts down one slot.
+ * `blockIndex === children.length` appends to the end. Caret lands at
+ * the start of the newly-inserted block, so the user can start typing
+ * straight away.
+ *
+ * Used by the block-inserter overlay to drop a paragraph between two
+ * atomic blocks (image, table, code) where no caret position exists.
+ */
+export function insertBlockAt(
+ state: WordsEditorState,
+ blockIndex: number,
+ block: WordsBlock
+): WordsOperationResult {
+ const children = state.document.children;
+ if (blockIndex < 0 || blockIndex > children.length) {
+ return { state, changed: false };
+ }
+ const nextChildren = [
+ ...children.slice(0, blockIndex),
+ block,
+ ...children.slice(blockIndex)
+ ];
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: nextChildren
+ }).document;
+ const point = pointFromInlineTextOffset(normalized, [blockIndex], 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/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte
index 6e4216007..820e8c6fc 100644
--- a/web/routes/uix/components/words/+page.svelte
+++ b/web/routes/uix/components/words/+page.svelte
@@ -1079,6 +1079,7 @@
{/if}
+
{#if showStatus}
{#if invalid}