feat(words): "Paste below" in the block-handle menu

A discoverable, click-driven paste — the answer to "where do I paste?".
The block drag-handle menu gains a "Paste below" item that reads the
clipboard and inserts its content as new blocks right after that block,
no Ctrl+V needed.

- provider.pasteAfterBlock(index): reads the async Clipboard API
  (navigator.clipboard.read, falling back to readText), parses text/html
  into rich blocks via the inert-document parser (else splits plain text
  into paragraphs), and inserts them at top-level index+1. No-op when the
  clipboard is empty / unreadable / permission-denied. Exposed on the
  provider snippet api.
- insertBlocks gains an optional `at` index: insert as siblings at an
  explicit top-level position (ignoring the caret) for the menu paste.
  Command carries `at`.
- eidos block gutter: "Paste below" DropdownMenu item -> api.pasteAfterBlock.

Tests: insert-blocks 7/7 (added explicit-index case). Reuses the
browser-verified parseWordsHtml + insertBlocks core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 65883089b3
commit 75556338db

@ -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}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item onSelect={paste}>Paste below</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={remove}>Delete</DropdownMenu.Item>
</DropdownMenu.Content>

@ -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,

@ -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)), []);

@ -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) {

@ -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<boolean>;
readonly setCodeLanguage: (language?: string) => boolean;
readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean;

@ -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<boolean> {
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(),

Loading…
Cancel
Save

Powered by TurnKey Linux.