feat(editor): add paste to replace

This commit is contained in:
Danila Poyarkov 2026-05-17 16:05:28 +03:00
parent daef4bdde7
commit bdbb37b092
16 changed files with 259 additions and 18 deletions

View file

@ -91,6 +91,10 @@
"label": "Paste",
"accelerator": "CmdOrCtrl+V"
},
{
"id": "paste-to-replace",
"label": "Paste to Replace"
},
{
"id": "selection.duplicate",
"label": "Duplicate"

View file

@ -3,6 +3,7 @@ import {
parseFigmaClipboard,
parseOpenPencilClipboard
} from '#core/clipboard'
import { computeAbsoluteBounds } from '#core/geometry'
import { computeAllLayouts } from '#core/layout'
import type { SceneNode } from '#core/scene-graph'
import type { Vector } from '#core/types'
@ -16,6 +17,17 @@ import { createClipboardPlacementActions } from './clipboard/placement'
import { collectSubtrees, restoreSubtree, snapshotSubtree } from './clipboard/subtree-history'
import type { EditorContext } from './types'
type PasteOptions = {
replaceSelection?: boolean
}
type DeletedEntry = {
id: string
parentId: string
index: number
subtree: Map<string, SceneNode>
}
export function createClipboardActions(ctx: EditorContext) {
function duplicateSelected(selectedNodes: SceneNode[]) {
const prevSelection = new Set(ctx.state.selectedIds)
@ -59,42 +71,131 @@ export function createClipboardActions(ctx: EditorContext) {
}
}
function recreateSnapshots(snapshots: SceneNode[], pageId: string) {
for (const snapshot of snapshots) {
ctx.graph.createNode(snapshot.type, snapshot.parentId ?? pageId, {
...snapshot,
childIds: []
})
}
}
function deleteIds(ids: string[]) {
for (const id of [...ids].reverse()) ctx.graph.deleteNode(id)
}
function restoreDeletedEntries(entries: DeletedEntry[]) {
for (const { id, parentId, index, subtree } of [...entries].reverse()) {
const rootSnap = subtree.get(id)
if (rootSnap) restoreSubtree(ctx.graph, rootSnap, parentId, subtree)
if (index >= 0) ctx.graph.reorderChild(id, parentId, index)
}
}
function pushPasteUndo(created: string[], prevSelection: Set<string>) {
const allNodes = collectSubtrees(ctx.graph, created)
const pageId = ctx.state.currentPageId
ctx.undo.push({
label: 'Paste',
forward: () => {
for (const snapshot of allNodes) {
ctx.graph.createNode(snapshot.type, snapshot.parentId ?? pageId, {
...snapshot,
childIds: []
})
}
recreateSnapshots(allNodes, pageId)
computeAllLayouts(ctx.graph, pageId)
ctx.setSelectedIds(new Set(created))
},
inverse: () => {
for (const id of [...created].reverse()) ctx.graph.deleteNode(id)
deleteIds(created)
computeAllLayouts(ctx.graph, pageId)
ctx.setSelectedIds(prevSelection)
}
})
}
async function pasteFromHTML(html: string, cursorPos?: Vector) {
function selectedReplacementTargets() {
const selected = [...ctx.state.selectedIds]
.map((id) => ctx.graph.getNode(id))
.filter((node): node is SceneNode => node != null && !node.locked)
const selectedSet = new Set(selected.map((node) => node.id))
return selected.filter((node) => !node.parentId || !selectedSet.has(node.parentId))
}
function pushPasteReplaceUndo(
created: string[],
deleted: DeletedEntry[],
prevSelection: Set<string>
) {
const createdSnapshots = collectSubtrees(ctx.graph, created)
const pageId = ctx.state.currentPageId
ctx.undo.push({
label: 'Paste to replace',
forward: () => {
for (const { id } of deleted) ctx.graph.deleteNode(id)
recreateSnapshots(createdSnapshots, pageId)
computeAllLayouts(ctx.graph, pageId)
ctx.setSelectedIds(new Set(created))
},
inverse: () => {
deleteIds(created)
restoreDeletedEntries(deleted)
computeAllLayouts(ctx.graph, pageId)
ctx.setSelectedIds(prevSelection)
}
})
}
function replaceTargetsWithCreated(
created: string[],
targets: SceneNode[],
prevSelection: Set<string>
) {
if (created.length === 0 || targets.length === 0) return false
const deleted = targets.map((node) => {
const parentId = node.parentId ?? ctx.state.currentPageId
const parent = ctx.graph.getNode(parentId)
return {
id: node.id,
parentId,
index: parent?.childIds.indexOf(node.id) ?? -1,
subtree: snapshotSubtree(ctx.graph, node.id)
}
})
const targetBounds = computeAbsoluteBounds(targets, (id) => ctx.graph.getAbsolutePosition(id))
placementActions.centerNodesAt(
created,
targetBounds.x + targetBounds.width / 2,
targetBounds.y + targetBounds.height / 2
)
const insertParentId = deleted[0]?.parentId ?? ctx.state.currentPageId
const insertIndex = deleted[0]?.index ?? 0
for (let i = 0; i < created.length; i++) ctx.graph.reorderChild(created[i], insertParentId, insertIndex + i)
for (const { id } of deleted) ctx.graph.deleteNode(id)
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
pushPasteReplaceUndo(created, deleted, prevSelection)
return true
}
async function pasteFromHTML(html: string, cursorPos?: Vector, options: PasteOptions = {}) {
const openPencil = parseOpenPencilClipboard(html)
if (openPencil) {
pasteOpenPencilNodes(openPencil.nodes, openPencil.images, cursorPos)
pasteOpenPencilNodes(openPencil.nodes, openPencil.images, cursorPos, options)
return
}
const figma = await parseFigmaClipboard(html)
if (figma) {
const prevSelection = new Set(ctx.state.selectedIds)
const pasteTarget = resolvePasteTarget(ctx)
const replacementTargets = options.replaceSelection ? selectedReplacementTargets() : []
const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx)
const created = importClipboardNodes(figma.nodes, ctx.graph, pasteTarget, 0, 0, figma.blobs)
if (created.length > 0) {
if (replacementTargets.length > 0) {
replaceTargetsWithCreated(created, replacementTargets, prevSelection)
void fontActions.loadFontsForNodes(created)
warnMissingImages(created)
ctx.requestRender()
return
}
const { width: viewW, height: viewH } = ctx.getViewportSize()
const cx = cursorPos?.x ?? (-ctx.state.panX + viewW / 2) / ctx.state.zoom
const cy = cursorPos?.y ?? (-ctx.state.panY + viewH / 2) / ctx.state.zoom
@ -113,9 +214,11 @@ export function createClipboardActions(ctx: EditorContext) {
function pasteOpenPencilNodes(
nodes: Array<SceneNode & { children?: SceneNode[] }>,
images: Map<string, Uint8Array>,
cursorPos?: Vector
cursorPos?: Vector,
options: PasteOptions = {}
) {
const prevSelection = new Set(ctx.state.selectedIds)
const replacementTargets = options.replaceSelection ? selectedReplacementTargets() : []
for (const [hash, bytes] of images) ctx.graph.images.set(hash, bytes)
const created: string[] = []
@ -131,10 +234,15 @@ export function createClipboardActions(ctx: EditorContext) {
return node.id
}
const pasteTarget = resolvePasteTarget(ctx)
const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx)
for (const node of nodes) created.push(createNodeTree(node, pasteTarget))
if (created.length === 0) return
if (replacementTargets.length > 0) {
replaceTargetsWithCreated(created, replacementTargets, prevSelection)
return
}
if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y)
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
@ -176,11 +284,7 @@ export function createClipboardActions(ctx: EditorContext) {
ctx.setSelectedIds(new Set())
},
inverse: () => {
for (const { id, parentId, index, subtree } of [...entries].reverse()) {
const rootSnap = subtree.get(id)
if (rootSnap) restoreSubtree(ctx.graph, rootSnap, parentId, subtree)
if (index >= 0) ctx.graph.reorderChild(id, parentId, index)
}
restoreDeletedEntries(entries)
ctx.setSelectedIds(prevSelection)
}
})

View file

@ -63,6 +63,7 @@ export const menuMessages = i18n('menu', {
underline: 'Underline',
pasteHere: 'Paste here',
pasteToReplace: 'Paste to replace',
copyPasteAs: 'Copy/Paste as',
copyAsText: 'Copy as text',
copyAsSVG: 'Copy as SVG',

View file

@ -32,6 +32,7 @@
"italic": "Kursiv",
"underline": "Unterstrichen",
"pasteHere": "Hier einfügen",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Kopieren/Einfügen als",
"copyAsText": "Als Text kopieren",
"copyAsSVG": "Als SVG kopieren",

View file

@ -32,6 +32,7 @@
"italic": "Cursiva",
"underline": "Subrayado",
"pasteHere": "Pegar aquí",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Copiar/Pegar como",
"copyAsText": "Copiar como texto",
"copyAsSVG": "Copiar como SVG",

View file

@ -32,6 +32,7 @@
"italic": "Italique",
"underline": "Souligné",
"pasteHere": "Coller ici",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Copier/Coller en tant que",
"copyAsText": "Copier en texte",
"copyAsSVG": "Copier en SVG",

View file

@ -32,6 +32,7 @@
"italic": "Corsivo",
"underline": "Sottolineato",
"pasteHere": "Incolla qui",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Copia/Incolla come",
"copyAsText": "Copia come testo",
"copyAsSVG": "Copia come SVG",

View file

@ -32,6 +32,7 @@
"italic": "Kursywa",
"underline": "Podkreślenie",
"pasteHere": "Wklej tutaj",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Kopiuj/Wklej jako",
"copyAsText": "Kopiuj jako tekst",
"copyAsSVG": "Kopiuj jako SVG",

View file

@ -32,6 +32,7 @@
"italic": "Курсив",
"underline": "Подчёркнутый",
"pasteHere": "Вставить сюда",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Копировать/Вставить как",
"copyAsText": "Копировать как текст",
"copyAsSVG": "Копировать как SVG",

View file

@ -32,6 +32,7 @@
"italic": "斜体",
"underline": "下划线",
"pasteHere": "粘贴到此处",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "复制/粘贴为",
"copyAsText": "复制为文本",
"copyAsSVG": "复制为 SVG",

View file

@ -4,6 +4,7 @@ import type { Ref } from 'vue'
import { nodeToXPath } from '@open-pencil/core/xpath'
import type { EditorStore } from '@/app/editor/active-store'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import { toast } from '@/app/shell/ui'
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
@ -65,5 +66,13 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
toast.info('Copied as PNG')
}
return { ids, execCommand, clipboardWrite, copyNodeId, copyXPath, copyAsPNG }
return {
ids,
execCommand,
pasteToReplace: () => pasteClipboardToReplace(store),
clipboardWrite,
copyNodeId,
copyXPath,
copyAsPNG
}
}

View file

@ -0,0 +1,26 @@
import type { EditorStore } from '@/app/editor/active-store'
import { toast } from '@/app/shell/ui'
async function readClipboardHtml() {
if (typeof navigator.clipboard.read !== 'function') return null
const items = await navigator.clipboard.read()
for (const item of items) {
if (!item.types.includes('text/html')) continue
return await (await item.getType('text/html')).text()
}
return null
}
export async function pasteClipboardToReplace(store: EditorStore) {
try {
const html = await readClipboardHtml()
if (!html) {
toast.error('Clipboard does not contain design data')
return
}
await store.pasteFromHTML(html, undefined, { replaceSelection: true })
} catch (error) {
console.warn('Paste to replace failed', error)
toast.error('Clipboard access is blocked in this browser context')
}
}

View file

@ -69,6 +69,7 @@ export const APP_MENU_SCHEMA = [
{ id: 'copy', label: 'Copy', shortcut: 'MOD+C' },
{ id: 'cut', label: 'Cut', shortcut: 'MOD+X' },
{ id: 'paste', label: 'Paste', shortcut: 'MOD+V' },
{ id: 'paste-to-replace', label: 'Paste to Replace' },
{
id: 'selection.duplicate',
label: 'Duplicate',

View file

@ -4,6 +4,7 @@ import { useEditorCommands, useI18n } from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { importFileDialog, openFileDialog } from '@/app/shell/menu/files'
import { useAppTheme } from '@/app/shell/theme'
@ -72,6 +73,7 @@ export function useMenu() {
copy: () => execBrowserCommand('copy'),
cut: () => execBrowserCommand('cut'),
paste: () => execBrowserCommand('paste'),
'paste-to-replace': () => void pasteClipboardToReplace(store),
'check-updates': () => void checkForAppUpdate({ messages: dialogs }),
...createSharedEditorMenuActions(setTheme)
}

View file

@ -79,6 +79,14 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
<span>{{ t.pasteHere }}</span
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('paste') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-paste-to-replace"
:class="cls.item"
:disabled="!hasSelection"
@select="canvasMenuActions.pasteToReplace"
>
<span>{{ t.pasteToReplace }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
:class="cls.item"

View file

@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'
import { buildOpenPencilClipboardHTML } from '@open-pencil/core/clipboard'
import { createEditor } from '@open-pencil/core/editor'
describe('paste to replace', () => {
test('replaces selected nodes with pasted OpenPencil nodes', async () => {
const source = createEditor()
const sourcePageId = source.state.currentPageId
const pasted = source.graph.createNode('RECTANGLE', sourcePageId, {
name: 'Pasted',
x: 0,
y: 0,
width: 20,
height: 20
})
const html = buildOpenPencilClipboardHTML([pasted], source.graph)
const editor = createEditor()
const pageId = editor.state.currentPageId
const target = editor.graph.createNode('ELLIPSE', pageId, {
name: 'Target',
x: 100,
y: 100,
width: 40,
height: 40
})
editor.select([target.id])
await editor.pasteFromHTML(html, undefined, { replaceSelection: true })
expect(editor.graph.getNode(target.id)).toBeUndefined()
const [createdId] = [...editor.state.selectedIds]
const created = editor.graph.getNode(createdId)
expect(created?.name).toBe('Pasted')
expect(created?.parentId).toBe(pageId)
expect(created?.x).toBe(110)
expect(created?.y).toBe(110)
})
test('undo and redo preserve replaced and pasted subtrees', async () => {
const source = createEditor()
const sourcePageId = source.state.currentPageId
const pasted = source.graph.createNode('RECTANGLE', sourcePageId, {
name: 'Pasted',
x: 0,
y: 0,
width: 20,
height: 20
})
const html = buildOpenPencilClipboardHTML([pasted], source.graph)
const editor = createEditor()
const pageId = editor.state.currentPageId
const target = editor.graph.createNode('ELLIPSE', pageId, {
name: 'Target',
x: 100,
y: 100,
width: 40,
height: 40
})
editor.select([target.id])
await editor.pasteFromHTML(html, undefined, { replaceSelection: true })
const [createdId] = [...editor.state.selectedIds]
editor.undo.undo()
expect(editor.graph.getNode(createdId)).toBeUndefined()
expect(editor.graph.getNode(target.id)?.parentId).toBe(pageId)
expect(editor.state.selectedIds).toEqual(new Set([target.id]))
editor.undo.redo()
expect(editor.graph.getNode(target.id)).toBeUndefined()
expect(editor.graph.getNode(createdId)?.parentId).toBe(pageId)
expect(editor.state.selectedIds).toEqual(new Set([createdId]))
})
})