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(),