diff --git a/CHANGELOG.md b/CHANGELOG.md index b0bfcc0d1..2c734ea67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ ### Changed +- Copy selections as Figma-compatible clipboard data while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste. - Choose whether interface animations follow the system motion preference or stay off, with live updates and a persistent override. - Put unbound fill and stroke style pickers in section headers, preserve applied and missing style rows, and remove the redundant Dimensions heading for text layers. - Open variable pickers below their trigger when space permits, flipping above near the viewport edge. diff --git a/packages/core/src/clipboard/openpencil.ts b/packages/core/src/clipboard/openpencil.ts index d3d5531a8..7153af7dc 100644 --- a/packages/core/src/clipboard/openpencil.ts +++ b/packages/core/src/clipboard/openpencil.ts @@ -6,6 +6,7 @@ import { serializeInstanceOverrideState, setInstanceOverride, type InstanceOverrideState, + type GeometryPath, type SceneGraph, type SceneNode, type SerializedInstanceOverrideState @@ -13,6 +14,7 @@ import { import type { JSONObject } from '@open-pencil/scene-graph/primitives' import { decodeBase64, encodeBase64 } from '#core/bytes' +import type { ClipboardSnapshot } from '#core/editor/clipboard/copy' interface SerializedClipboardNode extends JSONObject { overrides?: Record @@ -23,10 +25,7 @@ interface SerializedClipboardNode extends JSONObject { type ClipboardNode = SceneNode & { children?: ClipboardNode[] } -export interface OpenPencilClipboardData { - nodes: Array - images: Map -} +export type OpenPencilClipboardData = Pick export function parseOpenPencilClipboard(html: string): OpenPencilClipboardData | null { const match = html.match(//s) @@ -75,6 +74,19 @@ function legacyInstanceOverrides( return state } +function restoreGeometry(paths: unknown): GeometryPath[] { + if (!Array.isArray(paths)) return [] + return paths.map( + (path: GeometryPath & { commandsBlob: Uint8Array | Record }) => ({ + ...path, + commandsBlob: + path.commandsBlob instanceof Uint8Array + ? path.commandsBlob + : Uint8Array.from(Object.values(path.commandsBlob)) + }) + ) +} + function restoreNodeData(nodes: SerializedClipboardNode[]): ClipboardNode[] { return nodes.map((node) => { const { children, instanceOverrides, overrides, textPicture, ...rest } = node @@ -84,6 +96,8 @@ function restoreNodeData(nodes: SerializedClipboardNode[]): ClipboardNode[] { : legacyInstanceOverrides(nodeId, overrides) return { ...rest, + fillGeometry: restoreGeometry(rest.fillGeometry), + strokeGeometry: restoreGeometry(rest.strokeGeometry), instanceOverrides: overrideState, textPicture: typeof textPicture === 'string' ? decodeBase64(textPicture) : textPicture, ...(children ? { children: restoreNodeData(children) } : {}) diff --git a/packages/core/src/editor/bridges/clipboard.ts b/packages/core/src/editor/bridges/clipboard.ts index 8ef5eef94..2eab7734f 100644 --- a/packages/core/src/editor/bridges/clipboard.ts +++ b/packages/core/src/editor/bridges/clipboard.ts @@ -7,8 +7,8 @@ type SelectionActions = ReturnType export function createClipboardBridge(clipboard: ClipboardActions, selection: SelectionActions) { return { duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()), - writeCopyData: (data: DataTransfer) => - clipboard.writeCopyData(data, selection.getSelectedNodes()), + prepareCopy: () => clipboard.prepareCopy(selection.getSelectedNodes()), + pasteSnapshot: clipboard.pasteSnapshot, pasteFromHTML: clipboard.pasteFromHTML, deleteSelected: clipboard.deleteSelected, storeImage: clipboard.storeImage, diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index dd010ae23..ea22fa7fe 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -9,7 +9,9 @@ import { import { computeAllLayouts } from '#core/layout' import { createClipboardAssetActions } from './clipboard/assets' +import type { ClipboardSnapshot } from './clipboard/copy' import { createClipboardCopyActions } from './clipboard/copy' +import { importClipboardDependencies } from './clipboard/dependencies' import { createClipboardExportActions } from './clipboard/export' import { createClipboardFontActions } from './clipboard/fonts' import { deleteIds, recreateSnapshots, restoreDeletedEntries } from './clipboard/history' @@ -84,10 +86,57 @@ export function createClipboardActions(ctx: EditorContext) { }) } + async function pasteSnapshot( + snapshot: ClipboardSnapshot, + cursorPos?: Vector, + options: PasteOptions = {} + ) { + let created: string[] = [] + ctx.undo.runBatch('Paste', () => { + const dependencies = importClipboardDependencies(ctx, snapshot) + if (dependencies.styleSnapshots.length > 0) { + ctx.undo.push({ + label: 'Import clipboard styles', + forward: () => { + for (const style of dependencies.styleSnapshots) { + ctx.graph.preserveSourceMetadataDuring(() => + ctx.graph.createNode(style.type, ctx.state.currentPageId, style) + ) + } + }, + inverse: () => { + for (const style of dependencies.styleSnapshots) ctx.graph.deleteNode(style.id) + } + }) + } + if (dependencies.applyVariables && dependencies.revertVariables) { + ctx.undo.push({ + label: 'Import clipboard variables', + forward: dependencies.applyVariables, + inverse: dependencies.revertVariables + }) + } + created = pasteOpenPencilNodes( + dependencies.nodes, + snapshot.images, + snapshot.componentDependencies, + cursorPos, + options + ) + }) + await fontActions.loadFontsForNodes(created) + } + async function pasteFromHTML(html: string, cursorPos?: Vector, options: PasteOptions = {}) { const openPencil = parseOpenPencilClipboard(html) if (openPencil) { - const created = pasteOpenPencilNodes(openPencil.nodes, openPencil.images, cursorPos, options) + const created = pasteOpenPencilNodes( + openPencil.nodes, + openPencil.images, + [], + cursorPos, + options + ) await fontActions.loadFontsForNodes(created) return } @@ -129,6 +178,7 @@ export function createClipboardActions(ctx: EditorContext) { function pasteOpenPencilNodes( nodes: Array, images: Map, + dependencies: Array = [], cursorPos?: Vector, options: PasteOptions = {} ) { @@ -137,6 +187,7 @@ export function createClipboardActions(ctx: EditorContext) { for (const [hash, bytes] of images) ctx.graph.images.set(hash, bytes) const created: string[] = [] + const copiedIds = new Map() const createNodeTree = (source: SceneNode & { children?: SceneNode[] }, parentId: string) => { const { id: _id, childIds: _childIds, children = [], parentId: _parentId, ...rest } = source const node = ctx.graph.createNode(source.type, parentId, { @@ -145,12 +196,39 @@ export function createClipboardActions(ctx: EditorContext) { y: source.y + 20, childIds: [] }) + copiedIds.set(source.id, node.id) for (const child of children) createNodeTree(child, node.id) return node.id } const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx) + const dependencyRootIds: string[] = [] + for (const dependency of dependencies) + dependencyRootIds.push(createNodeTree(dependency, ctx.state.currentPageId)) for (const node of nodes) created.push(createNodeTree(node, pasteTarget)) + for (const id of copiedIds.values()) { + const node = ctx.graph.getNode(id) + if (!node) continue + const componentId = node.componentId ? copiedIds.get(node.componentId) : undefined + const instanceOverrides = { + self: node.instanceOverrides.self, + descendants: new Map( + [...node.instanceOverrides.descendants].map(([target, fields]) => [ + copiedIds.get(target) ?? target, + fields + ]) + ) + } + ctx.graph.updateNode(id, { componentId: componentId ?? node.componentId, instanceOverrides }) + } + if (dependencyRootIds.length > 0) { + const snapshots = collectSubtrees(ctx.graph, dependencyRootIds) + ctx.undo.push({ + label: 'Import component dependencies', + forward: () => recreateSnapshots(ctx, snapshots, ctx.state.currentPageId), + inverse: () => deleteIds(ctx, dependencyRootIds) + }) + } if (created.length === 0) return created if (replacementTargets.length > 0) { @@ -270,6 +348,7 @@ export function createClipboardActions(ctx: EditorContext) { ...fontActions, duplicateSelected, ...copyActions, + pasteSnapshot, pasteFromHTML, warnMissingImages, deleteSelected, diff --git a/packages/core/src/editor/clipboard/copy.ts b/packages/core/src/editor/clipboard/copy.ts index fa8e77dcf..3933c247d 100644 --- a/packages/core/src/editor/clipboard/copy.ts +++ b/packages/core/src/editor/clipboard/copy.ts @@ -1,19 +1,38 @@ -import type { SceneNode } from '@open-pencil/scene-graph' +import { SceneGraph, type SceneNode } from '@open-pencil/scene-graph' -import { buildFigmaClipboardHTML, buildOpenPencilClipboardHTML } from '#core/clipboard' +import { buildFigmaClipboardHTML } from '#core/clipboard' import type { EditorContext } from '#core/editor/types' -export function createClipboardCopyActions(ctx: EditorContext) { - async function writeCopyData(clipboardData: DataTransfer, selectedNodes: SceneNode[]) { - if (selectedNodes.length === 0) return +import { captureClipboardSnapshot, type ClipboardSnapshot } from './snapshot' - const names = selectedNodes.map((n) => n.name).join('\n') - clipboardData.setData('text/html', buildOpenPencilClipboardHTML(selectedNodes, ctx.graph)) - clipboardData.setData('text/plain', names) +export type { ClipboardSnapshot } from './snapshot' - const html = await buildFigmaClipboardHTML(selectedNodes, ctx.graph) - if (html) clipboardData.setData('text/html', html) - } - - return { writeCopyData } +export interface ClipboardPayload { + snapshot?: ClipboardSnapshot + html: string + plainText: string +} + +function graphForSnapshot(ctx: EditorContext, snapshot: ClipboardSnapshot): SceneGraph { + const graph = new SceneGraph() + graph.documentColorSpace = ctx.graph.documentColorSpace + graph.images = snapshot.images + function index(node: SceneNode & { children?: SceneNode[] }) { + graph.nodes.set(node.id, node) + for (const child of node.children ?? []) index(child) + } + for (const node of snapshot.nodes) index(node) + return graph +} + +export function createClipboardCopyActions(ctx: EditorContext) { + async function prepareCopy(selectedNodes: SceneNode[]): Promise { + if (selectedNodes.length === 0) return { html: '', plainText: '' } + const snapshot = captureClipboardSnapshot(ctx.graph, selectedNodes) + const plainText = snapshot.nodes.map((node) => node.name).join('\n') + const html = await buildFigmaClipboardHTML(snapshot.nodes, graphForSnapshot(ctx, snapshot)) + if (!html) throw new Error('Could not encode selection for the clipboard') + return { html, plainText, snapshot } + } + return { prepareCopy } } diff --git a/packages/core/src/editor/clipboard/dependencies.ts b/packages/core/src/editor/clipboard/dependencies.ts new file mode 100644 index 000000000..d1b4ca9b3 --- /dev/null +++ b/packages/core/src/editor/clipboard/dependencies.ts @@ -0,0 +1,60 @@ +import type { SceneNode } from '@open-pencil/scene-graph' + +import type { EditorContext } from '#core/editor/types' + +import type { ClipboardSnapshot } from './snapshot' +import { importClipboardVariables, remapClipboardVariableBindings } from './variables' + +export interface ClipboardDependencyImport { + nodes: ClipboardSnapshot['nodes'] + styleSnapshots: SceneNode[] + applyVariables?: () => void + revertVariables?: () => void +} + +export function importClipboardDependencies( + ctx: EditorContext, + snapshot: ClipboardSnapshot +): ClipboardDependencyImport { + const nodes = structuredClone(snapshot.nodes) + if (snapshot.sourceRootId === ctx.graph.rootId) return { nodes, styleSnapshots: [] } + const styleIds = new Map() + const styleSnapshots: SceneNode[] = [] + for (const definition of snapshot.styleDefinitions) { + const sourceStyleId = definition.source.id + if (!sourceStyleId) continue + const pastedStyleId = crypto.randomUUID() + let pasted: SceneNode | undefined + ctx.graph.preserveSourceMetadataDuring(() => { + pasted = ctx.graph.createNode(definition.type, ctx.state.currentPageId, { + ...structuredClone(definition), + id: undefined, + parentId: ctx.state.currentPageId, + childIds: [], + source: { ...definition.source, id: pastedStyleId } + }) + }) + if (!pasted) continue + styleIds.set(sourceStyleId, pastedStyleId) + styleSnapshots.push(structuredClone(pasted)) + } + const variables = importClipboardVariables(ctx.graph, snapshot.variableDependencies) + function remap(node: ClipboardSnapshot['nodes'][number]) { + remapClipboardVariableBindings( + node, + variables.variableIds, + variables.collectionIds, + variables.modeIds, + styleIds + ) + for (const child of node.children ?? []) remap(child) + } + for (const node of nodes) remap(node) + variables.apply() + return { + nodes, + styleSnapshots, + applyVariables: variables.apply, + revertVariables: variables.revert + } +} diff --git a/packages/core/src/editor/clipboard/snapshot.ts b/packages/core/src/editor/clipboard/snapshot.ts new file mode 100644 index 000000000..e0342b1a9 --- /dev/null +++ b/packages/core/src/editor/clipboard/snapshot.ts @@ -0,0 +1,77 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' + +import { captureClipboardVariables, type ClipboardVariables } from './variables' + +export type ClipboardNodeTree = SceneNode & { children?: ClipboardNodeTree[] } + +export interface ClipboardSnapshot { + sourceRootId: string + componentDependencies: ClipboardNodeTree[] + styleDefinitions: SceneNode[] + variableDependencies: ClipboardVariables + nodes: ClipboardNodeTree[] + images: Map +} + +function referencedStyleIds(nodes: SceneNode[]): Set { + const ids = new Set() + for (const node of nodes) { + for (const id of [ + node.fillStyleId, + node.strokeStyleId, + node.textStyleId, + node.effectStyleId, + node.gridStyleId + ]) { + if (id) ids.add(id) + } + } + return ids +} + +export function captureClipboardSnapshot( + graph: SceneGraph, + selectedNodes: SceneNode[] +): ClipboardSnapshot { + const selectedIds = new Set(selectedNodes.map((node) => node.id)) + const roots = selectedNodes.filter((node) => !node.parentId || !selectedIds.has(node.parentId)) + const images = new Map() + const capturedNodes: SceneNode[] = [] + function capture(node: SceneNode): ClipboardNodeTree { + capturedNodes.push(node) + for (const paint of node.fills) { + if (!paint.imageHash) continue + const bytes = graph.images.get(paint.imageHash) + if (bytes) images.set(paint.imageHash, bytes.slice()) + } + return { ...structuredClone(node), children: graph.getChildren(node.id).map(capture) } + } + const nodes = roots.map(capture) + const componentDependencies = new Map() + function captureComponent(id: string) { + if (componentDependencies.has(id) || selectedIds.has(id)) return + const component = graph.getNode(id) + if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) return + const tree = capture(component) + componentDependencies.set(id, tree) + visitReferences(tree) + } + function visitReferences(node: ClipboardNodeTree) { + if (node.componentId) captureComponent(node.componentId) + for (const child of node.children ?? []) visitReferences(child) + } + for (const node of nodes) visitReferences(node) + const styleIds = referencedStyleIds(capturedNodes) + const styleDefinitions: SceneNode[] = [] + for (const node of graph.getAllNodes()) { + if (node.source.id && styleIds.has(node.source.id)) styleDefinitions.push(structuredClone(node)) + } + return { + sourceRootId: graph.rootId, + componentDependencies: [...componentDependencies.values()], + styleDefinitions, + variableDependencies: captureClipboardVariables(graph, capturedNodes), + nodes, + images + } +} diff --git a/packages/core/src/editor/clipboard/variables.ts b/packages/core/src/editor/clipboard/variables.ts new file mode 100644 index 000000000..60e06c174 --- /dev/null +++ b/packages/core/src/editor/clipboard/variables.ts @@ -0,0 +1,115 @@ +import type { SceneGraph, SceneNode, Variable, VariableCollection } from '@open-pencil/scene-graph' + +export interface ClipboardVariables { + activeModes: Array<[string, string]> + variables: Variable[] + collections: VariableCollection[] +} + +export function captureClipboardVariables( + graph: SceneGraph, + nodes: SceneNode[] +): ClipboardVariables { + const variables = new Map() + const collections = new Map() + function visit(id: string) { + if (variables.has(id)) return + const variable = graph.variables.get(id) + if (!variable) return + variables.set(id, structuredClone(variable)) + const collection = graph.variableCollections.get(variable.collectionId) + if (collection) collections.set(collection.id, structuredClone(collection)) + for (const value of Object.values(variable.valuesByMode)) { + if (typeof value === 'object' && 'aliasId' in value) visit(value.aliasId) + } + } + for (const node of nodes) for (const id of Object.values(node.boundVariables)) visit(id) + const activeModes: Array<[string, string]> = [] + for (const collectionId of collections.keys()) { + const modeId = graph.activeMode.get(collectionId) + if (modeId) activeModes.push([collectionId, modeId]) + } + return { activeModes, variables: [...variables.values()], collections: [...collections.values()] } +} + +export function importClipboardVariables(graph: SceneGraph, dependencies: ClipboardVariables) { + const variableIds = new Map( + dependencies.variables.map((variable) => [variable.id, crypto.randomUUID()]) + ) + const collectionIds = new Map( + dependencies.collections.map((collection) => [collection.id, crypto.randomUUID()]) + ) + const modeIds = new Map( + dependencies.collections.flatMap((collection) => + collection.modes.map((mode) => [mode.modeId, crypto.randomUUID()] as const) + ) + ) + const collections = dependencies.collections.map((collection) => ({ + ...structuredClone(collection), + id: collectionIds.get(collection.id) ?? collection.id, + modes: collection.modes.map((mode) => ({ + ...mode, + modeId: modeIds.get(mode.modeId) ?? mode.modeId + })), + defaultModeId: modeIds.get(collection.defaultModeId) ?? collection.defaultModeId, + variableIds: collection.variableIds.flatMap((id) => { + const mapped = variableIds.get(id) + return mapped ? [mapped] : [] + }) + })) + const variables = dependencies.variables.map((variable) => ({ + ...structuredClone(variable), + id: variableIds.get(variable.id) ?? variable.id, + collectionId: collectionIds.get(variable.collectionId) ?? variable.collectionId, + valuesByMode: Object.fromEntries( + Object.entries(variable.valuesByMode).map(([mode, value]) => [ + modeIds.get(mode) ?? mode, + typeof value === 'object' && 'aliasId' in value + ? { aliasId: variableIds.get(value.aliasId) ?? value.aliasId } + : structuredClone(value) + ]) + ) + })) + function apply() { + for (const collection of collections) graph.addCollection(structuredClone(collection)) + for (const variable of variables) graph.addVariable(structuredClone(variable)) + for (const [collectionId, modeId] of dependencies.activeModes) { + const nextCollectionId = collectionIds.get(collectionId) + const nextModeId = modeIds.get(modeId) + if (nextCollectionId && nextModeId) graph.activeMode.set(nextCollectionId, nextModeId) + } + } + function revert() { + for (const collection of collections) graph.removeCollection(collection.id) + } + return { variableIds, collectionIds, modeIds, apply, revert } +} + +/** Remap only declared references; never rewrite arbitrary node strings. */ +export function remapClipboardVariableBindings( + node: SceneNode, + variables: ReadonlyMap, + collections: ReadonlyMap, + modeIds: ReadonlyMap = new Map(), + styleIds: ReadonlyMap = new Map() +): void { + node.boundVariables = Object.fromEntries( + Object.entries(node.boundVariables).map(([field, id]) => [field, variables.get(id) ?? id]) + ) + node.variableModes = Object.fromEntries( + Object.entries(node.variableModes).map(([id, mode]) => [ + collections.get(id) ?? id, + modeIds.get(mode) ?? mode + ]) + ) + for (const key of [ + 'fillStyleId', + 'strokeStyleId', + 'textStyleId', + 'effectStyleId', + 'gridStyleId' + ] as const) { + const id = node[key] + if (id && styleIds.has(id)) node[key] = styleIds.get(id) ?? id + } +} diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 32c006322..13d241399 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -13,6 +13,7 @@ export { pickEditorViewState } from './state/view' export { createDefaultEditorState, createEditor } from './create' +export type { ClipboardPayload, ClipboardSnapshot } from './clipboard/copy' export type { Editor } from './create' export { reapplyInstanceComponentProperties } from './components/properties' export { createGuideActions } from './guides' diff --git a/src/app/editor/clipboard/memory.ts b/src/app/editor/clipboard/memory.ts index 72d7cb87e..8a1622694 100644 --- a/src/app/editor/clipboard/memory.ts +++ b/src/app/editor/clipboard/memory.ts @@ -2,6 +2,14 @@ import type { ClipboardPayload } from '@/app/editor/clipboard/system/types' let memoryClipboard: ClipboardPayload = { html: '', plainText: '' } +export function setInMemoryClipboardPayload(payload: ClipboardPayload): void { + memoryClipboard = payload +} + +export function matchingClipboardSnapshot(html: string) { + return html && html === memoryClipboard.html ? memoryClipboard.snapshot : undefined +} + export function setInMemoryClipboardHTML(html: string, plainText = ''): void { memoryClipboard = { html, plainText } } diff --git a/src/app/editor/clipboard/paste-to-replace.ts b/src/app/editor/clipboard/paste-to-replace.ts index 574c88c7d..7e2730080 100644 --- a/src/app/editor/clipboard/paste-to-replace.ts +++ b/src/app/editor/clipboard/paste-to-replace.ts @@ -1,6 +1,7 @@ import type { EditorStore } from '@/app/editor/active-store' import { isDesignClipboardHTML } from '@/app/editor/clipboard/html' import { getInMemoryClipboardHTML } from '@/app/editor/clipboard/memory' +import { pasteClipboardHTML } from '@/app/editor/clipboard/paste' import { notificationMessages } from '@/app/i18n/notifications' import { toast } from '@/app/shell/ui' import { readTauriClipboardText } from '@/app/tauri/clipboard' @@ -47,7 +48,7 @@ export async function pasteClipboardToReplace(store: EditorStore) { toast.error(notificationMessages.get().clipboardMissingDesignData) return } - await store.pasteFromHTML(html, undefined, { replaceSelection: true }) + await pasteClipboardHTML(store, html, undefined, { replaceSelection: true }) } catch (error) { console.warn('Paste to replace failed', error) toast.error(notificationMessages.get().clipboardAccessBlocked) diff --git a/src/app/editor/clipboard/paste.ts b/src/app/editor/clipboard/paste.ts new file mode 100644 index 000000000..c10c96837 --- /dev/null +++ b/src/app/editor/clipboard/paste.ts @@ -0,0 +1,15 @@ +import type { Editor } from '@open-pencil/core/editor' +import type { Vector } from '@open-pencil/scene-graph/primitives' + +import { matchingClipboardSnapshot } from './memory' + +export async function pasteClipboardHTML( + editor: Editor, + html: string, + cursorPos?: Vector, + options: Parameters[2] = {} +): Promise { + const snapshot = matchingClipboardSnapshot(html) + if (snapshot) await editor.pasteSnapshot(snapshot, cursorPos, options) + else await editor.pasteFromHTML(html, cursorPos, options) +} diff --git a/src/app/editor/clipboard/system/browser.ts b/src/app/editor/clipboard/system/browser.ts index fe40fdffa..7d5837d54 100644 --- a/src/app/editor/clipboard/system/browser.ts +++ b/src/app/editor/clipboard/system/browser.ts @@ -1,4 +1,4 @@ -import copy, { type Options as ClipboardCopyOptions } from 'copy-to-clipboard' +import copy from 'copy-to-clipboard' import type { Vector } from '@open-pencil/scene-graph/primitives' @@ -7,9 +7,9 @@ import { isDesignClipboardHTML } from '@/app/editor/clipboard/html' import { clearInMemoryClipboardHTML, getInMemoryClipboardHTML, - setInMemoryClipboardHTML + setInMemoryClipboardPayload } from '@/app/editor/clipboard/memory' -import { createClipboardTransfer } from '@/app/editor/clipboard/system/transfer' +import { pasteClipboardHTML } from '@/app/editor/clipboard/paste' import type { BrowserClipboardIO, BrowserClipboardReadResult, @@ -17,38 +17,32 @@ import type { SystemClipboard } from '@/app/editor/clipboard/system/types' -function clipboardItem(payload: ClipboardPayload): ClipboardItem | undefined { - if (typeof Blob === 'undefined' || typeof ClipboardItem === 'undefined') return undefined - const itemData: Record = {} - if (payload.html) itemData['text/html'] = new Blob([payload.html], { type: 'text/html' }) - if (payload.plainText) { - itemData['text/plain'] = new Blob([payload.plainText], { type: 'text/plain' }) - } - return new ClipboardItem(itemData) -} - function populateLegacyClipboard(data: DataTransfer, payload: ClipboardPayload): void { if (payload.html) data.setData('text/html', payload.html) if (payload.plainText) data.setData('text/plain', payload.plainText) } -function customizeClipboardPayload( - payload: ClipboardPayload -): NonNullable { - return (data) => { - if (typeof DataTransfer !== 'undefined' && data instanceof DataTransfer) { - populateLegacyClipboard(data, payload) - return undefined +async function writeBrowserClipboard(payload: Promise): Promise { + let ready: ClipboardPayload | undefined + const prepared = payload.then((value) => { + ready = value + return value + }) + // The library starts the async Clipboard API write within the user gesture. + // Its synchronous fallback may only use a payload that has actually completed. + return copy('', { + format: 'text/html', + onCopy: (data) => { + if (typeof DataTransfer !== 'undefined' && data instanceof DataTransfer) { + if (!ready) throw new Error('Clipboard payload is not ready for synchronous copying') + populateLegacyClipboard(data, ready) + return undefined + } + return new ClipboardItem({ + 'text/html': prepared.then((value) => new Blob([value.html], { type: 'text/html' })), + 'text/plain': prepared.then((value) => new Blob([value.plainText], { type: 'text/plain' })) + }) } - return clipboardItem(payload) - } -} - -async function writeBrowserClipboard(payload: ClipboardPayload): Promise { - const text = payload.html || payload.plainText - return copy(text, { - format: payload.html ? 'text/html' : 'text/plain', - onCopy: customizeClipboardPayload(payload) }) } @@ -79,17 +73,15 @@ const browserClipboardIO: BrowserClipboardIO = { async function copySelection(store: EditorStore, io: BrowserClipboardIO): Promise { try { - const transfer = createClipboardTransfer() - await store.writeCopyData(transfer) - const payload: ClipboardPayload = { - html: transfer.getData('text/html'), - plainText: transfer.getData('text/plain') - } + if (store.state.selectedIds.size === 0) return false + const prepared = store.prepareCopy() + const writing = io.write(prepared) + const [payload, written] = await Promise.all([prepared, writing]) if (!payload.html && !payload.plainText) return false - if (payload.html) setInMemoryClipboardHTML(payload.html, payload.plainText) - else clearInMemoryClipboardHTML() + if (written && payload.html) setInMemoryClipboardPayload(payload) + else if (!written) clearInMemoryClipboardHTML() - return await io.write(payload) + return written } catch (error) { console.warn('Browser clipboard copy failed', error) return false @@ -104,7 +96,7 @@ async function pasteSelection( const result = await io.readHTML() if (result.available) { if (result.html && isDesignClipboardHTML(result.html)) { - await store.pasteFromHTML(result.html, cursorPos) + await pasteClipboardHTML(store, result.html, cursorPos) return true } return false @@ -112,7 +104,7 @@ async function pasteSelection( const memoryHTML = getInMemoryClipboardHTML() if (memoryHTML && isDesignClipboardHTML(memoryHTML)) { - await store.pasteFromHTML(memoryHTML, cursorPos) + await pasteClipboardHTML(store, memoryHTML, cursorPos) return true } diff --git a/src/app/editor/clipboard/system/tauri.ts b/src/app/editor/clipboard/system/tauri.ts index b98a57e80..dd9c2cad8 100644 --- a/src/app/editor/clipboard/system/tauri.ts +++ b/src/app/editor/clipboard/system/tauri.ts @@ -5,9 +5,9 @@ import { isDesignClipboardHTML } from '@/app/editor/clipboard/html' import { clearInMemoryClipboardHTML, getInMemoryClipboardHTML, - setInMemoryClipboardHTML + setInMemoryClipboardPayload } from '@/app/editor/clipboard/memory' -import { createClipboardTransfer } from '@/app/editor/clipboard/system/transfer' +import { pasteClipboardHTML } from '@/app/editor/clipboard/paste' import type { SystemClipboard } from '@/app/editor/clipboard/system/types' import { readTauriClipboardText, @@ -19,14 +19,12 @@ import { isTauri } from '@/app/tauri/env' async function copySelection(store: EditorStore): Promise { if (!isTauri()) return false try { - const transfer = createClipboardTransfer() - await store.writeCopyData(transfer) - const html = transfer.getData('text/html') - const plainText = transfer.getData('text/plain') + const payload = await store.prepareCopy() + const { html, plainText } = payload if (!html && !plainText) return false if (html) { await writeTauriClipboardHTML(html, plainText) - setInMemoryClipboardHTML(html, plainText) + setInMemoryClipboardPayload(payload) } else { await writeTauriClipboardText(plainText) clearInMemoryClipboardHTML() @@ -43,12 +41,12 @@ async function pasteSelection(store: EditorStore, cursorPos?: Vector): Promise undefined - -class MemoryDataTransfer implements DataTransfer { - private data = new Map() - - dropEffect: 'none' | 'copy' | 'link' | 'move' = 'none' - effectAllowed: - | 'none' - | 'copy' - | 'copyLink' - | 'copyMove' - | 'link' - | 'linkMove' - | 'move' - | 'all' - | 'uninitialized' = 'none' - files = { - length: 0, - item: () => null, - [Symbol.iterator]: function* () { - yield* [] - } - } as FileList - items = { - length: 0, - add: () => null, - clear: noop, - remove: noop, - [Symbol.iterator]: function* () { - yield* [] - } - } as DataTransferItemList - - setData(format: string, data: string): void { - this.data.set(format, data) - } - - getData(format: string): string { - return this.data.get(format) ?? '' - } - - clearData(format?: string): void { - if (format) this.data.delete(format) - else this.data.clear() - } - - setDragImage(image: Element, x: number, y: number): void { - void image - void x - void y - } - - get types(): readonly string[] { - return [...this.data.keys()] - } -} - -export function createClipboardTransfer(): DataTransfer { - if (typeof DataTransfer !== 'undefined') { - try { - return new DataTransfer() - } catch (error) { - console.warn('DataTransfer instantiation failed', error) - } - } - return new MemoryDataTransfer() -} diff --git a/src/app/editor/clipboard/system/types.ts b/src/app/editor/clipboard/system/types.ts index 6e654f24f..ad41dd8f8 100644 --- a/src/app/editor/clipboard/system/types.ts +++ b/src/app/editor/clipboard/system/types.ts @@ -1,11 +1,8 @@ +import type { ClipboardPayload } from '@open-pencil/core/editor' import type { Vector } from '@open-pencil/scene-graph/primitives' import type { EditorStore } from '@/app/editor/active-store' - -export interface ClipboardPayload { - html: string - plainText: string -} +export type { ClipboardPayload } from '@open-pencil/core/editor' export interface SystemClipboard { copy(store: EditorStore): Promise @@ -17,6 +14,6 @@ export type BrowserClipboardReadResult = | { available: true; html: string | null } export interface BrowserClipboardIO { - write(payload: ClipboardPayload): Promise + write(payload: Promise): Promise readHTML(): Promise } diff --git a/src/app/editor/mobile-clipboard/index.ts b/src/app/editor/mobile-clipboard/index.ts index 603a4da83..e4f9f177c 100644 --- a/src/app/editor/mobile-clipboard/index.ts +++ b/src/app/editor/mobile-clipboard/index.ts @@ -1,23 +1,34 @@ -import type { Editor, EditorState } from '@open-pencil/core/editor' +import type { Editor } from '@open-pencil/core/editor' -type MobileClipboardState = EditorState & { clipboardHTML: string } +import { + getInMemoryClipboardHTML, + setInMemoryClipboardPayload +} from '@/app/editor/clipboard/memory' +import { pasteClipboardHTML } from '@/app/editor/clipboard/paste' -export function createMobileClipboardActions(editor: Editor, state: MobileClipboardState) { - async function mobileCopy() { - const transfer = new DataTransfer() - await editor.writeCopyData(transfer) - state.clipboardHTML = transfer.getData('text/html') +/** Explicit in-app mobile clipboard; shares the same typed capture and paste dispatch. */ +export function createMobileClipboardActions(editor: Editor) { + async function mobileCopy(): Promise { + const payload = await editor.prepareCopy() + if (!payload.html) return false + setInMemoryClipboardPayload(payload) + return true } async function mobileCut() { - await mobileCopy() + const selectedIds = new Set(editor.state.selectedIds) + if (!(await mobileCopy())) return + if ( + selectedIds.size !== editor.state.selectedIds.size || + [...selectedIds].some((id) => !editor.state.selectedIds.has(id)) + ) + return editor.deleteSelected() } - function mobilePaste() { - if (state.clipboardHTML) { - void editor.pasteFromHTML(state.clipboardHTML) - } + async function mobilePaste() { + const html = getInMemoryClipboardHTML() + if (html) await pasteClipboardHTML(editor, html) } return { mobileCopy, mobileCut, mobilePaste } diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index e6fb3f7d7..e3712778b 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -65,7 +65,7 @@ export function createEditorStoreModules( const vectorEdit = createVectorEditActions(editor, state) const documentIO = createDocumentIOActions(editor, state, viewportSize, preparationController) const documentExport = createDocumentExportActions(editor, state, io, documentIO.downloadBlob) - const mobileClipboard = createMobileClipboardActions(editor, state) + const mobileClipboard = createMobileClipboardActions(editor) const profiler = createProfilerActions(editor) return { diff --git a/src/app/editor/session/types.ts b/src/app/editor/session/types.ts index 7a5560916..b7ade4ef7 100644 --- a/src/app/editor/session/types.ts +++ b/src/app/editor/session/types.ts @@ -15,7 +15,6 @@ export function createInitialAppEditorState(pageId: string): AppEditorState { panelMode: 'design', actionToast: null, mobileDrawerSnap: 'closed', - clipboardHTML: '', autosaveEnabled: false, cursorCanvasX: null, cursorCanvasY: null, @@ -35,7 +34,6 @@ export type AppEditorState = EditorState & { panelMode: 'layers' | 'design' actionToast: string | null mobileDrawerSnap: 'closed' | 'half' | 'full' - clipboardHTML: string autosaveEnabled: boolean cursorCanvasX: number | null cursorCanvasY: number | null diff --git a/src/app/shell/keyboard/clipboard.ts b/src/app/shell/keyboard/clipboard.ts index e78b40c50..0431377ab 100644 --- a/src/app/shell/keyboard/clipboard.ts +++ b/src/app/shell/keyboard/clipboard.ts @@ -4,7 +4,10 @@ import { extractImageFilesFromClipboard } from '@open-pencil/vue' import type { EditorStore } from '@/app/editor/active-store' import { getInMemoryClipboardHTML } from '@/app/editor/clipboard/memory' +import { pasteClipboardHTML } from '@/app/editor/clipboard/paste' +import { browserSystemClipboard } from '@/app/editor/clipboard/system/browser' import { tauriSystemClipboard } from '@/app/editor/clipboard/system/tauri' +import type { SystemClipboard } from '@/app/editor/clipboard/system/types' import { hasDocumentTextSelection, isEditing } from '@/app/shell/keyboard/focus' import { isTauri } from '@/app/tauri/env' @@ -15,10 +18,11 @@ function cursorPosition(store: EditorStore) { export async function copyAndDeleteSelection( store: EditorStore, - clipboardData: DataTransfer + clipboard: SystemClipboard = browserSystemClipboard ): Promise { try { - await store.writeCopyData(clipboardData) + const selectedIds = new Set(store.state.selectedIds) + if (!(await clipboard.copy(store)) || !selectionMatches(store, selectedIds)) return false store.deleteSelected() return true } catch (error) { @@ -42,7 +46,7 @@ export function bindEditorClipboard(store: EditorStore) { void tauriSystemClipboard.copy(store) return } - if (e.clipboardData) void store.writeCopyData(e.clipboardData) + void browserSystemClipboard.copy(store) }) useEventListener(window, 'cut', (e: ClipboardEvent) => { @@ -56,7 +60,7 @@ export function bindEditorClipboard(store: EditorStore) { }) return } - if (e.clipboardData) void copyAndDeleteSelection(store, e.clipboardData) + void copyAndDeleteSelection(store) }) useEventListener(window, 'paste', (e: ClipboardEvent) => { @@ -75,7 +79,7 @@ export function bindEditorClipboard(store: EditorStore) { const html = e.clipboardData?.getData('text/html') ?? '' if (html) { - void store.pasteFromHTML(html, cursorPos) + void pasteClipboardHTML(store, html, cursorPos) return } @@ -86,7 +90,7 @@ export function bindEditorClipboard(store: EditorStore) { const memoryHTML = getInMemoryClipboardHTML() if (memoryHTML) { - void store.pasteFromHTML(memoryHTML, cursorPos) + void pasteClipboardHTML(store, memoryHTML, cursorPos) } }) } diff --git a/src/components/Toolbar/actions.ts b/src/components/Toolbar/actions.ts index 4317f5cd6..30d7f87bf 100644 --- a/src/components/Toolbar/actions.ts +++ b/src/components/Toolbar/actions.ts @@ -25,7 +25,7 @@ type ToolbarActionOptions = { export function useToolbarActions({ store, getCommand, menu }: ToolbarActionOptions) { const editActions = computed(() => [ { icon: IconCopy, label: menu.value.copy, action: () => void store.mobileCopy() }, - { icon: IconClipboard, label: menu.value.paste, action: () => store.mobilePaste() }, + { icon: IconClipboard, label: menu.value.paste, action: () => void store.mobilePaste() }, { icon: IconScissors, label: menu.value.cut, action: () => void store.mobileCut() }, { icon: IconCopyPlus, diff --git a/tests/e2e/clipboard/copy-paste.spec.ts b/tests/e2e/clipboard/copy-paste.spec.ts index 104c50222..a4624a0ff 100644 --- a/tests/e2e/clipboard/copy-paste.spec.ts +++ b/tests/e2e/clipboard/copy-paste.spec.ts @@ -28,9 +28,7 @@ test('copy + paste via store duplicates a shape', async () => { await editor.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') + const { html } = await store.prepareCopy() if (html) await store.pasteFromHTML(html) }) await editor.canvas.waitForRender() @@ -99,11 +97,10 @@ test('cut removes original', async () => { expect(await getPageChildCount()).toBe(1) // Cut via store - await editor.page.evaluate(() => { + await editor.page.evaluate(async () => { const store = window.openPencil?.getStore?.() if (!store) throw new Error('OpenPencil store not initialized') - const data = new DataTransfer() - store.writeCopyData(data) + await store.prepareCopy() store.deleteSelected() }) await editor.canvas.waitForRender() diff --git a/tests/e2e/clipboard/paste-into-container.spec.ts b/tests/e2e/clipboard/paste-into-container.spec.ts index 0b7d0cf74..fcf5ba8b7 100644 --- a/tests/e2e/clipboard/paste-into-container.spec.ts +++ b/tests/e2e/clipboard/paste-into-container.spec.ts @@ -53,9 +53,7 @@ function copyAndPaste() { return editor.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') + const { html } = await store.prepareCopy() if (html) await store.pasteFromHTML(html) }) } @@ -83,9 +81,7 @@ test('paste into selected frame places node as child', async () => { 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 { html } = await store.prepareCopy() const frame = [...store.graph.nodes.values()].find((n) => n.name === 'Container') if (!frame) throw new Error('Container not found') @@ -119,9 +115,7 @@ test('paste with child selected places node as sibling in parent frame', async ( 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 { html } = await store.prepareCopy() store.select([cid]) if (html) await store.pasteFromHTML(html) @@ -145,9 +139,7 @@ test('paste with no selection places on page', async () => { await editor.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') + const { html } = await store.prepareCopy() store.clearSelection() if (html) await store.pasteFromHTML(html) }) diff --git a/tests/engine/app/clipboard/component-dependencies.test.ts b/tests/engine/app/clipboard/component-dependencies.test.ts new file mode 100644 index 000000000..568f264a4 --- /dev/null +++ b/tests/engine/app/clipboard/component-dependencies.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test' + +import { createEditorStore } from '@/app/editor/session/create' + +test('cross-document instance-only paste imports its component dependency', async () => { + const source = createEditorStore() + const component = source.graph.createNode('COMPONENT', source.state.currentPageId, { + name: 'Dependency' + }) + const componentChild = source.graph.createNode('RECTANGLE', component.id, { + name: 'Dependency child' + }) + const instance = source.graph.createNode('INSTANCE', source.state.currentPageId, { + name: 'Instance only', + componentId: component.id + }) + source.graph.createNode('RECTANGLE', instance.id, { + name: 'Instance child', + componentId: componentChild.id + }) + source.select([instance.id]) + const payload = await source.prepareCopy() + if (!payload.snapshot) throw new Error('Missing snapshot') + const target = createEditorStore() + await target.pasteSnapshot(payload.snapshot) + const pastedId = [...target.state.selectedIds][0] + const pasted = target.graph.getNode(pastedId) + const dependency = pasted?.componentId ? target.graph.getNode(pasted.componentId) : undefined + expect(dependency?.type).toBe('COMPONENT') + expect(dependency?.name).toBe('Dependency') + const child = pasted?.childIds[0] ? target.graph.getNode(pasted.childIds[0]) : undefined + expect(child?.componentId).toBe(dependency?.childIds[0]) + target.undo.undo() + expect(target.graph.getNode(pastedId)).toBeUndefined() + expect(target.graph.getNode(dependency?.id ?? '')).toBeUndefined() + target.undo.redo() + expect(target.graph.getNode(pastedId)?.componentId).toBe(dependency?.id) +}) diff --git a/tests/engine/app/clipboard/keyboard.test.ts b/tests/engine/app/clipboard/keyboard.test.ts index 0563a6c3a..64530321d 100644 --- a/tests/engine/app/clipboard/keyboard.test.ts +++ b/tests/engine/app/clipboard/keyboard.test.ts @@ -5,8 +5,15 @@ import { copyAndDeleteSelection } from '@/app/shell/keyboard/clipboard' function storeWithCopyResult(result: Promise) { let deleted = false return { + clipboard: { + copy: async () => { + await result + return true + }, + paste: async () => false + }, store: { - writeCopyData: () => result, + state: { selectedIds: new Set() }, deleteSelected: () => { deleted = true } @@ -17,14 +24,16 @@ function storeWithCopyResult(result: Promise) { describe('browser keyboard clipboard cut', () => { test('deletes only after clipboard data is written', async () => { - const { store, wasDeleted } = storeWithCopyResult(Promise.resolve()) - expect(await copyAndDeleteSelection(store as never, {} as DataTransfer)).toBe(true) + const { store, clipboard, wasDeleted } = storeWithCopyResult(Promise.resolve()) + expect(await copyAndDeleteSelection(store as never, clipboard)).toBe(true) expect(wasDeleted()).toBe(true) }) test('preserves selection when clipboard serialization fails', async () => { - const { store, wasDeleted } = storeWithCopyResult(Promise.reject(new Error('copy failed'))) - expect(await copyAndDeleteSelection(store as never, {} as DataTransfer)).toBe(false) + const { store, clipboard, wasDeleted } = storeWithCopyResult( + Promise.reject(new Error('copy failed')) + ) + expect(await copyAndDeleteSelection(store as never, clipboard)).toBe(false) expect(wasDeleted()).toBe(false) }) }) diff --git a/tests/engine/app/clipboard/memory.test.ts b/tests/engine/app/clipboard/memory.test.ts index 6abdb9127..06603aa66 100644 --- a/tests/engine/app/clipboard/memory.test.ts +++ b/tests/engine/app/clipboard/memory.test.ts @@ -63,7 +63,7 @@ describe('in-memory clipboard', () => { }) store.select([rect.id]) - const write = mock(async (_payload: ClipboardPayload) => true) + const write = mock(async (_payload: Promise) => true) const clipboard = createBrowserSystemClipboard({ write, readHTML: async () => ({ available: false }) @@ -72,12 +72,131 @@ describe('in-memory clipboard', () => { expect(success).toBe(true) expect(write).toHaveBeenCalledTimes(1) - const payload = write.mock.calls[0]?.[0] + const payload = await write.mock.calls[0]?.[0] expect(payload?.html).toBeDefined() expect(payload?.plainText).toBeDefined() expect(hasInMemoryClipboardHTML()).toBe(true) }) + test('starts the writer before serialization completes', async () => { + const store = createEditorStore() + store.select([store.graph.createNode('RECTANGLE', store.state.currentPageId).id]) + const prepared = Promise.withResolvers() + store.prepareCopy = () => prepared.promise + let writerStarted = false + const clipboard = createBrowserSystemClipboard({ + write: async (payload) => { + writerStarted = true + await payload + return true + }, + readHTML: async () => ({ available: false }) + }) + const copying = clipboard.copy(store) + expect(writerStarted).toBe(true) + prepared.resolve({ html: '

Prepared

', plainText: 'Prepared' }) + expect(await copying).toBe(true) + }) + + test('reports serialization failure without deleting or claiming success', async () => { + const store = createEditorStore() + const node = store.graph.createNode('RECTANGLE', store.state.currentPageId) + store.select([node.id]) + store.prepareCopy = async () => { + throw new Error('Encoding failed') + } + const clipboard = createBrowserSystemClipboard({ + write: async (payload) => { + await payload + return true + }, + readHTML: async () => ({ available: false }) + }) + expect(await executeClipboardCommand(store, 'cut', undefined, clipboard)).toBe(false) + expect(store.graph.getNode(node.id)).toBeDefined() + }) + + test('matching internal snapshots preserve geometry buffers and text sizing', async () => { + const store = createEditorStore() + const node = store.graph.createNode('TEXT', store.state.currentPageId, { + name: 'Snapshot source', + text: 'Hello', + textAutoResize: 'WIDTH_AND_HEIGHT', + fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: new Uint8Array([0, 1, 2, 3]) }] + }) + store.select([node.id]) + expect(await memoryClipboard.copy(store)).toBe(true) + expect(await memoryClipboard.paste(store)).toBe(true) + const pasted = store.graph.getNode([...store.state.selectedIds][0]) + expect(pasted?.id).not.toBe(node.id) + expect(pasted?.textAutoResize).toBe('WIDTH_AND_HEIGHT') + expect(pasted?.fillGeometry[0].commandsBlob).toBeInstanceOf(Uint8Array) + expect(pasted?.fillGeometry[0].commandsBlob).toEqual(node.fillGeometry[0].commandsBlob) + expect(pasted?.fillGeometry[0].commandsBlob).not.toBe(node.fillGeometry[0].commandsBlob) + }) + + test('mobile clipboard shares the typed snapshot across editor sessions', async () => { + const source = createEditorStore() + const node = source.graph.createNode('TEXT', source.state.currentPageId, { + name: 'Mobile source', + text: 'Hello', + textAutoResize: 'WIDTH_AND_HEIGHT' + }) + source.select([node.id]) + expect(await source.mobileCopy()).toBe(true) + const target = createEditorStore() + await target.mobilePaste() + const pasted = target.graph.getNode([...target.state.selectedIds][0]) + expect(pasted?.name).toBe('Mobile source') + expect(pasted?.textAutoResize).toBe('WIDTH_AND_HEIGHT') + }) + + test('does not use a stale snapshot for different valid clipboard HTML', async () => { + const source = createEditorStore() + const original = source.graph.createNode('TEXT', source.state.currentPageId, { + name: 'Snapshot only', + textAutoResize: 'WIDTH_AND_HEIGHT' + }) + source.select([original.id]) + expect(await memoryClipboard.copy(source)).toBe(true) + const staleHTML = getInMemoryClipboardHTML() + const external = createEditorStore() + const rectangle = external.graph.createNode('RECTANGLE', external.state.currentPageId, { + name: 'External' + }) + external.select([rectangle.id]) + const differentHTML = (await external.prepareCopy()).html + expect(differentHTML).not.toBe(staleHTML) + const target = createEditorStore() + await target.pasteFromHTML(differentHTML) + expect([...target.state.selectedIds].map((id) => target.graph.getNode(id)?.name)).toEqual([ + 'External' + ]) + }) + + test('failed writes clear the previous in-memory snapshot', async () => { + const first = createEditorStore() + const firstNode = first.graph.createNode('RECTANGLE', first.state.currentPageId, { + name: 'Successful' + }) + first.select([firstNode.id]) + expect(await memoryClipboard.copy(first)).toBe(true) + expect(getInMemoryClipboardHTML()).not.toBe('') + const second = createEditorStore() + second.select([ + second.graph.createNode('RECTANGLE', second.state.currentPageId, { name: 'Failed' }).id + ]) + const failing = createBrowserSystemClipboard({ + write: async (payload) => { + await payload + return false + }, + readHTML: async () => ({ available: false }) + }) + expect(await failing.copy(second)).toBe(false) + expect(getInMemoryClipboardHTML()).toBe('') + }) + test('browser clipboard returns false when its writer fails', async () => { const store = createEditorStore() const pageId = store.state.currentPageId @@ -216,7 +335,7 @@ describe('in-memory clipboard', () => { expect(store.graph.getNode(rect.id)).toBeUndefined() }) - test('executeClipboardCommand paste forwards cursorPos to store.pasteFromHTML', async () => { + test('executeClipboardCommand paste forwards cursorPos to the matching snapshot', async () => { const store = createEditorStore() const pageId = store.state.currentPageId const rect = store.graph.createNode('RECTANGLE', pageId, { @@ -230,10 +349,10 @@ describe('in-memory clipboard', () => { await executeClipboardCommand(store, 'copy', undefined, memoryClipboard) let receivedCursorPos: Vector | undefined - const originalPaste = store.pasteFromHTML.bind(store) - store.pasteFromHTML = async (html, cursorPos, options) => { + const originalPaste = store.pasteSnapshot.bind(store) + store.pasteSnapshot = async (snapshot, cursorPos, options) => { receivedCursorPos = cursorPos - return originalPaste(html, cursorPos, options) + return originalPaste(snapshot, cursorPos, options) } const cursorPos: Vector = { x: 150, y: 250 } diff --git a/tests/engine/app/clipboard/references.test.ts b/tests/engine/app/clipboard/references.test.ts new file mode 100644 index 000000000..257fa6322 --- /dev/null +++ b/tests/engine/app/clipboard/references.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'bun:test' + +import { createEditorStore } from '@/app/editor/session/create' + +test('pasting a component and instance together remaps their component references', async () => { + const editor = createEditorStore() + const page = editor.state.currentPageId + const component = editor.graph.createNode('COMPONENT', page, { name: 'Master' }) + const child = editor.graph.createNode('RECTANGLE', component.id, { name: 'Master child' }) + const instance = editor.graph.createNode('INSTANCE', page, { + name: 'Instance', + componentId: component.id + }) + const instanceChild = editor.graph.createNode('RECTANGLE', instance.id, { + name: 'Instance child', + componentId: child.id + }) + instance.instanceOverrides.descendants.set(child.id, new Map([['name', 'Overridden']])) + editor.select([component.id, instance.id]) + const payload = await editor.prepareCopy() + if (!payload.snapshot) throw new Error('Missing snapshot') + await editor.pasteSnapshot(payload.snapshot) + const pasted = [...editor.state.selectedIds].map((id) => editor.graph.getNode(id)) + const master = pasted.find((node) => node?.type === 'COMPONENT') + const copy = pasted.find((node) => node?.type === 'INSTANCE') + if (!master || !copy) throw new Error('Missing pasted roots') + const copiedChild = editor.graph.getNode(copy.childIds[0]) + if (!copiedChild) throw new Error('Missing pasted child') + const masterChildId = master.childIds[0] + expect(copy.componentId).toBe(master.id) + expect(copiedChild.id).not.toBe(instanceChild.id) + expect(copiedChild.componentId).toBe(masterChildId) + expect(copy.instanceOverrides.descendants.has(masterChildId)).toBe(true) + editor.undo.undo() + expect(editor.graph.getNode(copy.id)).toBeUndefined() + editor.undo.redo() + expect(editor.graph.getNode(copy.id)?.componentId).toBe(master.id) + expect(editor.graph.getNode(copiedChild.id)?.componentId).toBe(masterChildId) +}) diff --git a/tests/engine/app/clipboard/styles.test.ts b/tests/engine/app/clipboard/styles.test.ts new file mode 100644 index 000000000..b3dd997e0 --- /dev/null +++ b/tests/engine/app/clipboard/styles.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'bun:test' + +import { createEditorStore } from '@/app/editor/session/create' + +test('cross-document paste imports and remaps referenced shared styles', async () => { + const source = createEditorStore() + const definition = source.graph.createNode('RECTANGLE', source.state.currentPageId, { + name: 'Brand/Primary', + sharedStyleType: 'FILL', + internalOnly: true + }) + source.graph.preserveSourceMetadataDuring(() => + source.graph.updateNode(definition.id, { + source: { ...definition.source, format: 'fig', id: 'source:style' } + }) + ) + const node = source.graph.createNode('RECTANGLE', source.state.currentPageId, { + fillStyleId: 'source:style' + }) + source.select([node.id]) + const payload = await source.prepareCopy() + if (!payload.snapshot) throw new Error('Missing snapshot') + const target = createEditorStore() + await target.pasteSnapshot(payload.snapshot) + const pastedId = [...target.state.selectedIds][0] + const styleId = target.graph.getNode(pastedId)?.fillStyleId + expect(styleId).toBeString() + expect(styleId).not.toBe('source:style') + const imported = [...target.graph.getAllNodes()].find( + (candidate) => candidate.source.id === styleId + ) + expect(imported?.name).toBe('Brand/Primary') + target.undo.undo() + expect(target.graph.getNode(pastedId)).toBeUndefined() + expect([...target.graph.getAllNodes()].some((candidate) => candidate.source.id === styleId)).toBe( + false + ) + target.undo.redo() + expect(target.graph.getNode(pastedId)?.fillStyleId).toBe(styleId) + expect([...target.graph.getAllNodes()].some((candidate) => candidate.source.id === styleId)).toBe( + true + ) +}) diff --git a/tests/engine/app/clipboard/variables.test.ts b/tests/engine/app/clipboard/variables.test.ts new file mode 100644 index 000000000..4f96c20dd --- /dev/null +++ b/tests/engine/app/clipboard/variables.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'bun:test' + +import { createEditorStore } from '@/app/editor/session/create' + +test('cross-document paste imports variable aliases and undoes definitions with the pasted nodes', async () => { + const source = createEditorStore() + const collection = source.graph.createCollection('Spacing') + const alternateModeId = crypto.randomUUID() + source.graph.addMode(collection.id, alternateModeId, 'Comfortable') + source.graph.activeMode.set(collection.id, alternateModeId) + const base = source.graph.createVariable('Base', 'FLOAT', collection.id, 12) + const alias = source.graph.createVariable('Alias', 'FLOAT', collection.id, { aliasId: base.id }) + const node = source.graph.createNode('RECTANGLE', source.state.currentPageId, { + boundVariables: { cornerRadius: alias.id } + }) + source.graph.updateNode(node.id, { variableModes: { [collection.id]: alternateModeId } }) + source.select([node.id]) + const payload = await source.prepareCopy() + if (!payload.snapshot) throw new Error('Missing snapshot') + const target = createEditorStore() + await target.pasteSnapshot(payload.snapshot) + const pastedId = [...target.state.selectedIds][0] + const pasted = target.graph.getNode(pastedId) + const importedId = pasted?.boundVariables.cornerRadius + if (!importedId) throw new Error('Missing imported binding') + expect(importedId).not.toBe(alias.id) + const imported = target.graph.variables.get(importedId) + if (!imported) throw new Error('Missing imported variable') + expect(Object.keys(imported.valuesByMode)).not.toContain(alternateModeId) + const importedCollection = target.graph.variableCollections.get(imported.collectionId) + if (!importedCollection) throw new Error('Missing imported collection') + expect(importedCollection.modes.map((mode) => mode.modeId)).not.toContain(alternateModeId) + expect(target.graph.getActiveModeId(importedCollection.id)).toBe( + target.graph.getNode(pastedId)?.variableModes[importedCollection.id] + ) + const value = Object.values(imported.valuesByMode)[0] + if (typeof value !== 'object' || !('aliasId' in value)) throw new Error('Missing alias') + expect(target.graph.variables.get(value.aliasId)?.name).toBe('Base') + expect(target.graph.variableCollections.size).toBe(1) + target.undo.undo() + expect(target.graph.getNode(pastedId)).toBeUndefined() + expect(target.graph.variables.size).toBe(0) + expect(target.graph.variableCollections.size).toBe(0) + target.undo.redo() + expect(target.graph.getNode(pastedId)?.boundVariables.cornerRadius).toBe(importedId) + expect(target.graph.variables.size).toBe(2) +}) diff --git a/tests/engine/clipboard/openpencil/green-50.test.ts b/tests/engine/clipboard/openpencil/green-50.test.ts new file mode 100644 index 000000000..745924b35 --- /dev/null +++ b/tests/engine/clipboard/openpencil/green-50.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'bun:test' + +import { parseOpenPencilClipboard } from '@open-pencil/core' +import { geometryBlobBounds } from '@open-pencil/scene-graph/geometry' + +const fixture = await Bun.file('tests/fixtures/clipboard/green-50.openpencil.html').text() + +test('preserved Green/50 clipboard geometry restores typed command buffers', () => { + const parsed = parseOpenPencilClipboard(fixture) + const node = parsed?.nodes[0] + expect(node?.name).toBe('Green/50') + const path = node?.fillGeometry[0] + expect(path?.commandsBlob).toBeInstanceOf(Uint8Array) + expect(() => geometryBlobBounds(node?.fillGeometry ?? [])).not.toThrow() +}) diff --git a/tests/fixtures/clipboard/green-50.openpencil.html b/tests/fixtures/clipboard/green-50.openpencil.html new file mode 100644 index 000000000..8b0d3c3b1 --- /dev/null +++ b/tests/fixtures/clipboard/green-50.openpencil.html @@ -0,0 +1 @@ +