feat(editor): add paste to replace
This commit is contained in:
parent
daef4bdde7
commit
bdbb37b092
|
|
@ -91,6 +91,10 @@
|
|||
"label": "Paste",
|
||||
"accelerator": "CmdOrCtrl+V"
|
||||
},
|
||||
{
|
||||
"id": "paste-to-replace",
|
||||
"label": "Paste to Replace"
|
||||
},
|
||||
{
|
||||
"id": "selection.duplicate",
|
||||
"label": "Duplicate"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"italic": "Курсив",
|
||||
"underline": "Подчёркнутый",
|
||||
"pasteHere": "Вставить сюда",
|
||||
"pasteToReplace": "Paste to replace",
|
||||
"copyPasteAs": "Копировать/Вставить как",
|
||||
"copyAsText": "Копировать как текст",
|
||||
"copyAsSVG": "Копировать как SVG",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"italic": "斜体",
|
||||
"underline": "下划线",
|
||||
"pasteHere": "粘贴到此处",
|
||||
"pasteToReplace": "Paste to replace",
|
||||
"copyPasteAs": "复制/粘贴为",
|
||||
"copyAsText": "复制为文本",
|
||||
"copyAsSVG": "复制为 SVG",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/app/editor/clipboard/paste-to-replace.ts
Normal file
26
src/app/editor/clipboard/paste-to-replace.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
tests/engine/editor/clipboard/paste-to-replace.test.ts
Normal file
79
tests/engine/editor/clipboard/paste-to-replace.test.ts
Normal 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]))
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue