diff --git a/src/uix/eidos/components/words/words-block-gutter.svelte b/src/uix/eidos/components/words/words-block-gutter.svelte
index 1fb56566f..3d9aeb439 100644
--- a/src/uix/eidos/components/words/words-block-gutter.svelte
+++ b/src/uix/eidos/components/words/words-block-gutter.svelte
@@ -202,6 +202,13 @@
function duplicate() {
api.applyCommand({ type: 'duplicateBlock', blockIndex: index });
}
+ // Paste the clipboard (rich HTML → blocks + marks, else plain text) as new
+ // blocks right below this one — a discoverable, click-driven alternative to
+ // Ctrl+V. Async (reads the Clipboard API); the menu closes immediately.
+ function paste() {
+ void api.pasteAfterBlock(index);
+ show = false;
+ }
function remove() {
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
show = false;
@@ -340,6 +347,7 @@
{/each}
+ Paste below
Delete
diff --git a/src/uix/soma/components/words/engine/operations/commands.ts b/src/uix/soma/components/words/engine/operations/commands.ts
index 6b7d843b2..493f8c027 100644
--- a/src/uix/soma/components/words/engine/operations/commands.ts
+++ b/src/uix/soma/components/words/engine/operations/commands.ts
@@ -153,6 +153,9 @@ export type WordsCommand =
| {
readonly type: 'insertBlocks';
readonly blocks: readonly WordsBlock[];
+ // Optional top-level index to insert AT (the block-menu "Paste"
+ // inserts after a specific block). Omitted → insert at the caret.
+ readonly at?: number;
}
// Caretless insert into a column slot (the `+` overlay in each
// column). Carries the new block AND its post-insert selection in
@@ -291,7 +294,7 @@ export function applyWordsCommand(
command.block as unknown as WordsBlock
);
case 'insertBlocks':
- return insertBlocks(state, command.blocks);
+ return insertBlocks(state, command.blocks, command.at);
case 'insertBlockInColumn':
return insertBlockInColumn(
state,
diff --git a/src/uix/soma/components/words/engine/operations/insert-blocks.test.ts b/src/uix/soma/components/words/engine/operations/insert-blocks.test.ts
index d487cb8fd..b38452689 100644
--- a/src/uix/soma/components/words/engine/operations/insert-blocks.test.ts
+++ b/src/uix/soma/components/words/engine/operations/insert-blocks.test.ts
@@ -86,6 +86,17 @@ describe('insertBlocks (rich paste)', () => {
]);
});
+ it('inserts at an explicit top-level index (block-menu "Paste below")', () => {
+ const d = doc(para('A'), para('B'), para('C'));
+ // "Paste below A" → at index 1 (after block 0), ignoring any caret.
+ const r = insertBlocks(createState(d, null), [para('X'), para('Y')], 1);
+ expect(r.changed).toBe(true);
+ const texts = r.state.document.children.map((b) =>
+ b.type === 'paragraph' ? b.children.map((c) => (c.type === 'text' ? c.text : '')).join('') : ''
+ );
+ expect(texts).toEqual(['A', 'X', 'Y', 'B', 'C']);
+ });
+
it('no-ops on empty input', () => {
const d = doc(para('x'));
const r = insertBlocks(createState(d, createCollapsedSelection([0, 0], 0)), []);
diff --git a/src/uix/soma/components/words/engine/operations/insert-blocks.ts b/src/uix/soma/components/words/engine/operations/insert-blocks.ts
index c4fb53f91..d2ca9a08b 100644
--- a/src/uix/soma/components/words/engine/operations/insert-blocks.ts
+++ b/src/uix/soma/components/words/engine/operations/insert-blocks.ts
@@ -36,10 +36,20 @@ const INLINE_BLOCKS = new Set(['paragraph', 'heading', 'quote']);
export function insertBlocks(
state: WordsEditorState,
- blocks: readonly WordsBlock[]
+ blocks: readonly WordsBlock[],
+ at?: number
): WordsOperationResult {
if (blocks.length === 0) return noOp(state);
+ // 0. Explicit top-level index (the block-menu "Paste" inserts AFTER a
+ // specific block, ignoring the caret). Insert as siblings — no split.
+ if (typeof at === 'number') {
+ const doc = state.document;
+ const idx = Math.max(0, Math.min(at, doc.children.length));
+ const children = [...doc.children.slice(0, idx), ...blocks, ...doc.children.slice(idx)];
+ return commit(doc, children, idx + blocks.length - 1);
+ }
+
// 1. Replace a non-empty selection.
let cur = state;
if (cur.selection && !normalizeRange(cur.selection).collapsed) {
diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts
index 9ec66f206..4c0bead85 100644
--- a/src/uix/soma/components/words/types.ts
+++ b/src/uix/soma/components/words/types.ts
@@ -198,6 +198,13 @@ export type WordsProviderSnippetProps = {
* not for sema-tuned text edits.
*/
readonly applyCommand: (command: WordsCommand) => boolean;
+ /**
+ * Paste clipboard content (rich HTML → blocks + marks, else plain text)
+ * as siblings AFTER the top-level block at `index` — the block-menu
+ * "Paste" affordance. Reads the async Clipboard API; resolves false when
+ * the clipboard is empty / unreadable / permission-denied.
+ */
+ readonly pasteAfterBlock: (index: number) => Promise;
readonly setCodeLanguage: (language?: string) => boolean;
readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean;
diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts
index ba96cea67..0f07363dc 100644
--- a/src/uix/soma/components/words/words-provider.svelte.ts
+++ b/src/uix/soma/components/words/words-provider.svelte.ts
@@ -49,6 +49,7 @@ import {
createParagraph,
createQuote,
createState,
+ createText,
createWordsCompositionState,
createWordsHistory,
decodeWordsPath,
@@ -1218,6 +1219,55 @@ export class WordsProvider {
}
}
+ /**
+ * Paste clipboard content AFTER a given top-level block — the block-menu
+ * "Paste" affordance (a discoverable, click-driven alternative to Ctrl+V).
+ * Reads the async Clipboard API (`navigator.clipboard.read`, falling back
+ * to `readText`), parses `text/html` into rich blocks (else splits plain
+ * text into paragraphs), and inserts them as siblings after `index`.
+ * Returns false (no-op) when the clipboard is empty / unreadable / the
+ * read permission is denied.
+ */
+ async pasteAfterBlock(index: number): Promise {
+ if (this.isDisabled || this.isReadonly) return false;
+ const target = this.contentRef.current;
+ if (!target) return false;
+ const clipboard = this.soma.dom.getWindow(target)?.navigator?.clipboard;
+ if (!clipboard) return false;
+
+ let html = '';
+ let text = '';
+ try {
+ if (typeof clipboard.read === 'function') {
+ const items = await clipboard.read();
+ for (const item of items) {
+ if (!html && item.types.includes('text/html')) {
+ html = await (await item.getType('text/html')).text();
+ }
+ if (!text && item.types.includes('text/plain')) {
+ text = await (await item.getType('text/plain')).text();
+ }
+ }
+ }
+ if (!html && !text && typeof clipboard.readText === 'function') {
+ text = await clipboard.readText();
+ }
+ } catch {
+ return false;
+ }
+
+ let blocks: readonly WordsBlock[] = [];
+ if (html.trim().length > 0) blocks = this.parseClipboardHtml(html, target);
+ if (blocks.length === 0 && text.trim().length > 0) {
+ blocks = text.split(/\r\n?|\n/).map((line) => createParagraph([createText(line)]));
+ }
+ if (blocks.length === 0) return false;
+
+ const changed = this.applyCommand({ type: 'insertBlocks', blocks, at: index + 1 });
+ if (changed) this.soma.dom.focus(target, { preventScroll: true });
+ return changed;
+ }
+
readonly ondragover = (e: DragEvent & { currentTarget: HTMLElement }) => {
// Only intercept drags carrying image files; let everything else
// (text drags, internal selection drags) follow the browser
@@ -1398,6 +1448,7 @@ export class WordsProvider {
setActiveBlock: (id: string | null) => this.setActiveBlock(id),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
applyCommand: (command: WordsCommand) => this.applyCommand(command),
+ pasteAfterBlock: (index: number) => this.pasteAfterBlock(index),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language),
insertLink: (href: string, title?: string) => this.insertLink(href, title),
unlink: () => this.unlink(),