diff --git a/desktop/generated/menu.json b/desktop/generated/menu.json index be8997f54..f99933a0f 100644 --- a/desktop/generated/menu.json +++ b/desktop/generated/menu.json @@ -91,6 +91,10 @@ "label": "Paste", "accelerator": "CmdOrCtrl+V" }, + { + "id": "paste-to-replace", + "label": "Paste to Replace" + }, { "id": "selection.duplicate", "label": "Duplicate" diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index ed406a242..84b18ef4b 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -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 +} + 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) { 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 + ) { + 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 + ) { + 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, images: Map, - 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) } }) diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index 952812433..6cf1b2afe 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -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', diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index b87dbf236..f736eb18c 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -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", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index b71f5adce..8c7668bbf 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -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", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index 500d49f4f..2e7a0cc1f 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -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", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index f9c88b208..eb6e6db34 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -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", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index d1a2910d5..e9b8c7016 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -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", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index bc79b80aa..c3a9a9db2 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -32,6 +32,7 @@ "italic": "Курсив", "underline": "Подчёркнутый", "pasteHere": "Вставить сюда", + "pasteToReplace": "Paste to replace", "copyPasteAs": "Копировать/Вставить как", "copyAsText": "Копировать как текст", "copyAsSVG": "Копировать как SVG", diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index 0fd12c70a..99a84e66a 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -32,6 +32,7 @@ "italic": "斜体", "underline": "下划线", "pasteHere": "粘贴到此处", + "pasteToReplace": "Paste to replace", "copyPasteAs": "复制/粘贴为", "copyAsText": "复制为文本", "copyAsSVG": "复制为 SVG", diff --git a/src/app/editor/canvas/menu/actions.ts b/src/app/editor/canvas/menu/actions.ts index e65789801..b8ceb0b44 100644 --- a/src/app/editor/canvas/menu/actions.ts +++ b/src/app/editor/canvas/menu/actions.ts @@ -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 pasteClipboardToReplace(store), + clipboardWrite, + copyNodeId, + copyXPath, + copyAsPNG + } } diff --git a/src/app/editor/clipboard/paste-to-replace.ts b/src/app/editor/clipboard/paste-to-replace.ts new file mode 100644 index 000000000..d21113578 --- /dev/null +++ b/src/app/editor/clipboard/paste-to-replace.ts @@ -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') + } +} diff --git a/src/app/shell/menu/schema.ts b/src/app/shell/menu/schema.ts index babb9a9c1..c5afedfb6 100644 --- a/src/app/shell/menu/schema.ts +++ b/src/app/shell/menu/schema.ts @@ -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', diff --git a/src/app/shell/menu/use.ts b/src/app/shell/menu/use.ts index e94f31188..08fcb54f2 100644 --- a/src/app/shell/menu/use.ts +++ b/src/app/shell/menu/use.ts @@ -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) } diff --git a/src/components/CanvasMenu.vue b/src/components/CanvasMenu.vue index 14aaefaa0..558c81f3c 100644 --- a/src/components/CanvasMenu.vue +++ b/src/components/CanvasMenu.vue @@ -79,6 +79,14 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin {{ t.pasteHere }}{{ appMenuShortcutLabel('paste') }} + + {{ t.pasteToReplace }} + { + 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])) + }) +})