fix(editor): paste into selected container
Paste now targets the selected frame/group/component/section instead of always placing nodes on the page root, matching Figma's behavior: - Single container selected: paste as child - Non-container child selected: paste as sibling in its parent - Entered container (double-click): paste inside it - Nothing or multiple nodes selected: paste on the page Also applies to image paste via placeImageFiles. Fixes #79
This commit is contained in:
parent
98ce9a70ac
commit
72614fac4c
|
|
@ -11,6 +11,7 @@ import { createClipboardCopyActions } from './clipboard/copy'
|
|||
import { createClipboardExportActions } from './clipboard/export'
|
||||
import { createClipboardFontActions } from './clipboard/fonts'
|
||||
import { createClipboardImageActions } from './clipboard/images'
|
||||
import { resolvePasteTarget } from './clipboard/paste-target'
|
||||
import { createClipboardPlacementActions } from './clipboard/placement'
|
||||
import { collectSubtrees, restoreSubtree, snapshotSubtree } from './clipboard/subtree-history'
|
||||
import type { EditorContext } from './types'
|
||||
|
|
@ -68,14 +69,8 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
const figma = await parseFigmaClipboard(html)
|
||||
if (figma) {
|
||||
const prevSelection = new Set(ctx.state.selectedIds)
|
||||
const created = importClipboardNodes(
|
||||
figma.nodes,
|
||||
ctx.graph,
|
||||
ctx.state.currentPageId,
|
||||
0,
|
||||
0,
|
||||
figma.blobs
|
||||
)
|
||||
const pasteTarget = resolvePasteTarget(ctx)
|
||||
const created = importClipboardNodes(figma.nodes, ctx.graph, pasteTarget, 0, 0, figma.blobs)
|
||||
if (created.length > 0) {
|
||||
const { width: viewW, height: viewH } = ctx.getViewportSize()
|
||||
const cx = cursorPos?.x ?? (-ctx.state.panX + viewW / 2) / ctx.state.zoom
|
||||
|
|
@ -132,7 +127,8 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
return node.id
|
||||
}
|
||||
|
||||
for (const node of nodes) created.push(createNodeTree(node, ctx.state.currentPageId))
|
||||
const pasteTarget = resolvePasteTarget(ctx)
|
||||
for (const node of nodes) created.push(createNodeTree(node, pasteTarget))
|
||||
if (created.length === 0) return
|
||||
|
||||
if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { resolvePasteTarget } from '#core/editor/clipboard/paste-target'
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
import { computeImageHash } from '#core/figma-api'
|
||||
import type { Fill } from '#core/scene-graph'
|
||||
|
|
@ -38,7 +39,7 @@ export function createClipboardImageActions(ctx: EditorContext) {
|
|||
): string | null {
|
||||
const hash = storeImage(bytes)
|
||||
const displayName = name.replace(/\.[^.]+$/, '')
|
||||
const pid = ctx.state.currentPageId
|
||||
const pid = resolvePasteTarget(ctx)
|
||||
const fill: Fill = {
|
||||
type: 'IMAGE',
|
||||
imageHash: hash,
|
||||
|
|
|
|||
12
packages/core/src/editor/clipboard/paste-target.ts
Normal file
12
packages/core/src/editor/clipboard/paste-target.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { EditorContext } from '#core/editor/types'
|
||||
import { CONTAINER_TYPES } from '#core/scene-graph/node-defaults'
|
||||
|
||||
export function resolvePasteTarget(ctx: EditorContext): string {
|
||||
if (ctx.state.enteredContainerId) return ctx.state.enteredContainerId
|
||||
const ids = [...ctx.state.selectedIds]
|
||||
if (ids.length !== 1) return ctx.state.currentPageId
|
||||
const node = ctx.graph.getNode(ids[0])
|
||||
if (!node) return ctx.state.currentPageId
|
||||
if (CONTAINER_TYPES.has(node.type) && node.type !== 'CANVAS') return node.id
|
||||
return node.parentId ?? ctx.state.currentPageId
|
||||
}
|
||||
181
tests/e2e/clipboard/paste-into-container.spec.ts
Normal file
181
tests/e2e/clipboard/paste-into-container.spec.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
function getNodeChildren(nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
return store.graph.getChildren(id).map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
type: n.type,
|
||||
parentId: n.parentId
|
||||
}))
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
function getSelectedParent() {
|
||||
return page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = [...store.state.selectedIds][0]
|
||||
if (!id) return null
|
||||
const node = store.graph.getNode(id)
|
||||
return node?.parentId ?? null
|
||||
})
|
||||
}
|
||||
|
||||
function createFrameWithChild() {
|
||||
return page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const pageId = store.state.currentPageId
|
||||
const frameId = store.createShape('FRAME', 50, 50, 300, 200, pageId)
|
||||
store.graph.updateNode(frameId, { name: 'Container' })
|
||||
const childId = store.createShape('RECTANGLE', 10, 10, 80, 60, frameId)
|
||||
store.graph.updateNode(childId, { name: 'Inner Rect' })
|
||||
store.requestRender()
|
||||
return { frameId, childId, pageId }
|
||||
})
|
||||
}
|
||||
|
||||
function selectNode(nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
store.select([id])
|
||||
store.requestRender()
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
function copyAndPaste() {
|
||||
return page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const data = new DataTransfer()
|
||||
await store.writeCopyData(data)
|
||||
const html = data.getData('text/html')
|
||||
if (html) await store.pasteFromHTML(html)
|
||||
})
|
||||
}
|
||||
|
||||
test('paste into selected frame places node as child', async () => {
|
||||
await canvas.clearCanvas()
|
||||
const { frameId } = await createFrameWithChild()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await canvas.drawRect(400, 50, 60, 60)
|
||||
await canvas.waitForRender()
|
||||
|
||||
await copyAndPaste()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await selectNode(frameId)
|
||||
await canvas.waitForRender()
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const rect = [...store.graph.nodes.values()].find(
|
||||
(n) => n.name === 'Rectangle' && n.type === 'RECTANGLE'
|
||||
)
|
||||
if (!rect) throw new Error('Rectangle not found')
|
||||
store.select([rect.id])
|
||||
|
||||
const data = new DataTransfer()
|
||||
await store.writeCopyData(data)
|
||||
const html = data.getData('text/html')
|
||||
|
||||
const frame = [...store.graph.nodes.values()].find((n) => n.name === 'Container')
|
||||
if (!frame) throw new Error('Container not found')
|
||||
store.select([frame.id])
|
||||
|
||||
if (html) await store.pasteFromHTML(html)
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
|
||||
const children = await getNodeChildren(frameId)
|
||||
const pastedChild = children.find((c) => c.name === 'Rectangle')
|
||||
expect(pastedChild).toBeDefined()
|
||||
expect(pastedChild?.parentId).toBe(frameId)
|
||||
})
|
||||
|
||||
test('paste with child selected places node as sibling in parent frame', async () => {
|
||||
await canvas.clearCanvas()
|
||||
const { frameId, childId } = await createFrameWithChild()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await canvas.drawRect(400, 50, 60, 60)
|
||||
await canvas.waitForRender()
|
||||
|
||||
await page.evaluate(
|
||||
async ({ childId: cid }) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const rect = [...store.graph.nodes.values()].find(
|
||||
(n) => n.name === 'Rectangle' && n.type === 'RECTANGLE'
|
||||
)
|
||||
if (!rect) throw new Error('Rectangle not found')
|
||||
store.select([rect.id])
|
||||
|
||||
const data = new DataTransfer()
|
||||
await store.writeCopyData(data)
|
||||
const html = data.getData('text/html')
|
||||
|
||||
store.select([cid])
|
||||
if (html) await store.pasteFromHTML(html)
|
||||
},
|
||||
{ childId }
|
||||
)
|
||||
await canvas.waitForRender()
|
||||
|
||||
const parent = await getSelectedParent()
|
||||
expect(parent).toBe(frameId)
|
||||
|
||||
const children = await getNodeChildren(frameId)
|
||||
expect(children.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('paste with no selection places on page', async () => {
|
||||
await canvas.clearCanvas()
|
||||
await canvas.drawRect(100, 100, 60, 60)
|
||||
await canvas.waitForRender()
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const data = new DataTransfer()
|
||||
await store.writeCopyData(data)
|
||||
const html = data.getData('text/html')
|
||||
store.clearSelection()
|
||||
if (html) await store.pasteFromHTML(html)
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
|
||||
const parent = await getSelectedParent()
|
||||
const pageId = await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
return store.state.currentPageId
|
||||
})
|
||||
expect(parent).toBe(pageId)
|
||||
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
115
tests/engine/editor/clipboard/paste-target.test.ts
Normal file
115
tests/engine/editor/clipboard/paste-target.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditor, type EditorState } from '@open-pencil/core/editor'
|
||||
import { SceneGraph } from '@open-pencil/core/scene-graph'
|
||||
|
||||
import { resolvePasteTarget } from '#core/editor/clipboard/paste-target'
|
||||
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const editor = createEditor({ graph, skipInitialGraphSetup: true })
|
||||
return { graph, pageId, editor, state: editor.state }
|
||||
}
|
||||
|
||||
function ctx(state: EditorState, graph: SceneGraph) {
|
||||
return {
|
||||
graph,
|
||||
state,
|
||||
undo: undefined,
|
||||
loadFont: async () => null,
|
||||
getViewportSize: () => ({ width: 800, height: 600 }),
|
||||
getCk: () => null,
|
||||
getRenderer: () => null,
|
||||
getTextEditor: () => null,
|
||||
requestRender: () => undefined,
|
||||
requestRepaint: () => undefined,
|
||||
emitEditorEvent: () => undefined,
|
||||
setSelectedIds: (ids: Set<string>) => {
|
||||
state.selectedIds = ids
|
||||
},
|
||||
setActiveTool: () => undefined,
|
||||
runLayoutForNode: () => undefined,
|
||||
subscribeToGraph: () => undefined
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('resolvePasteTarget', () => {
|
||||
test('returns current page when nothing is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
state.selectedIds = new Set()
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(pageId)
|
||||
})
|
||||
|
||||
test('returns current page when multiple nodes are selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const a = graph.createNode('RECTANGLE', pageId, { name: 'A', width: 50, height: 50 })
|
||||
const b = graph.createNode('RECTANGLE', pageId, { name: 'B', width: 50, height: 50 })
|
||||
state.selectedIds = new Set([a.id, b.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(pageId)
|
||||
})
|
||||
|
||||
test('returns selected frame when a single frame is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const frame = graph.createNode('FRAME', pageId, { name: 'Container', width: 200, height: 200 })
|
||||
state.selectedIds = new Set([frame.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(frame.id)
|
||||
})
|
||||
|
||||
test('returns selected group when a single group is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const group = graph.createNode('GROUP', pageId, { name: 'Group', width: 200, height: 200 })
|
||||
state.selectedIds = new Set([group.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(group.id)
|
||||
})
|
||||
|
||||
test('returns selected component when a single component is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const comp = graph.createNode('COMPONENT', pageId, { name: 'Comp', width: 100, height: 100 })
|
||||
state.selectedIds = new Set([comp.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(comp.id)
|
||||
})
|
||||
|
||||
test('returns selected section when a single section is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const section = graph.createNode('SECTION', pageId, {
|
||||
name: 'Section',
|
||||
width: 400,
|
||||
height: 300
|
||||
})
|
||||
state.selectedIds = new Set([section.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(section.id)
|
||||
})
|
||||
|
||||
test('returns parent frame when a non-container child is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const frame = graph.createNode('FRAME', pageId, { name: 'Parent', width: 200, height: 200 })
|
||||
const rect = graph.createNode('RECTANGLE', frame.id, { name: 'Child', width: 50, height: 50 })
|
||||
state.selectedIds = new Set([rect.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(frame.id)
|
||||
})
|
||||
|
||||
test('returns page when a top-level rectangle is selected', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const rect = graph.createNode('RECTANGLE', pageId, { name: 'TopRect', width: 50, height: 50 })
|
||||
state.selectedIds = new Set([rect.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(pageId)
|
||||
})
|
||||
|
||||
test('returns entered container when set', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const frame = graph.createNode('FRAME', pageId, { name: 'Deep', width: 200, height: 200 })
|
||||
state.enteredContainerId = frame.id
|
||||
state.selectedIds = new Set()
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(frame.id)
|
||||
})
|
||||
|
||||
test('entered container takes priority over selection', () => {
|
||||
const { graph, pageId, state } = setup()
|
||||
const frameA = graph.createNode('FRAME', pageId, { name: 'A', width: 200, height: 200 })
|
||||
const frameB = graph.createNode('FRAME', pageId, { name: 'B', width: 200, height: 200 })
|
||||
state.enteredContainerId = frameA.id
|
||||
state.selectedIds = new Set([frameB.id])
|
||||
expect(resolvePasteTarget(ctx(state, graph))).toBe(frameA.id)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue