diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index 918302e83..53f51180a 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -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) diff --git a/packages/core/src/editor/clipboard/images.ts b/packages/core/src/editor/clipboard/images.ts index 3df081999..f5bcf93a2 100644 --- a/packages/core/src/editor/clipboard/images.ts +++ b/packages/core/src/editor/clipboard/images.ts @@ -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, diff --git a/packages/core/src/editor/clipboard/paste-target.ts b/packages/core/src/editor/clipboard/paste-target.ts new file mode 100644 index 000000000..5a8cf656a --- /dev/null +++ b/packages/core/src/editor/clipboard/paste-target.ts @@ -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 +} diff --git a/tests/e2e/clipboard/paste-into-container.spec.ts b/tests/e2e/clipboard/paste-into-container.spec.ts new file mode 100644 index 000000000..1cc19a252 --- /dev/null +++ b/tests/e2e/clipboard/paste-into-container.spec.ts @@ -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() +}) diff --git a/tests/engine/editor/clipboard/paste-target.test.ts b/tests/engine/editor/clipboard/paste-target.test.ts new file mode 100644 index 000000000..75eede56c --- /dev/null +++ b/tests/engine/editor/clipboard/paste-target.test.ts @@ -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) => { + 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) + }) +})