fix(clipboard): preserve typed selection data across copy and paste

This commit is contained in:
Danila Poyarkov 2026-09-11 02:59:31 +03:00
parent b436ee4d2b
commit 89b60b9870
31 changed files with 817 additions and 194 deletions

View file

@ -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.

View file

@ -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<string, unknown>
@ -23,10 +25,7 @@ interface SerializedClipboardNode extends JSONObject {
type ClipboardNode = SceneNode & { children?: ClipboardNode[] }
export interface OpenPencilClipboardData {
nodes: Array<SceneNode & { children?: SceneNode[] }>
images: Map<string, Uint8Array>
}
export type OpenPencilClipboardData = Pick<ClipboardSnapshot, 'nodes' | 'images'>
export function parseOpenPencilClipboard(html: string): OpenPencilClipboardData | null {
const match = html.match(/<!--\(openpencil\)(.*?)\(\/openpencil\)-->/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<string, number> }) => ({
...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) } : {})

View file

@ -7,8 +7,8 @@ type SelectionActions = ReturnType<typeof createSelectionActions>
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,

View file

@ -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<SceneNode & { children?: SceneNode[] }>,
images: Map<string, Uint8Array>,
dependencies: Array<SceneNode & { children?: SceneNode[] }> = [],
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<string, string>()
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,

View file

@ -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<ClipboardPayload> {
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 }
}

View file

@ -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<string, string>()
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
}
}

View file

@ -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<string, Uint8Array>
}
function referencedStyleIds(nodes: SceneNode[]): Set<string> {
const ids = new Set<string>()
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<string, Uint8Array>()
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<string, ClipboardNodeTree>()
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
}
}

View file

@ -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<string, Variable>()
const collections = new Map<string, VariableCollection>()
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<string, string>,
collections: ReadonlyMap<string, string>,
modeIds: ReadonlyMap<string, string> = new Map(),
styleIds: ReadonlyMap<string, string> = 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
}
}

View file

@ -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'

View file

@ -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 }
}

View file

@ -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)

View file

@ -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<Editor['pasteFromHTML']>[2] = {}
): Promise<void> {
const snapshot = matchingClipboardSnapshot(html)
if (snapshot) await editor.pasteSnapshot(snapshot, cursorPos, options)
else await editor.pasteFromHTML(html, cursorPos, options)
}

View file

@ -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<string, Blob> = {}
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<ClipboardCopyOptions['onCopy']> {
return (data) => {
if (typeof DataTransfer !== 'undefined' && data instanceof DataTransfer) {
populateLegacyClipboard(data, payload)
return undefined
async function writeBrowserClipboard(payload: Promise<ClipboardPayload>): Promise<boolean> {
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<boolean> {
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<boolean> {
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
}

View file

@ -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<boolean> {
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<b
try {
const text = await readTauriClipboardText()
if (text && isDesignClipboardHTML(text)) {
await store.pasteFromHTML(text, cursorPos)
await pasteClipboardHTML(store, text, cursorPos)
return true
}
const matchingMemoryHTML = getInMemoryClipboardHTML(text ?? '')
if (matchingMemoryHTML && isDesignClipboardHTML(matchingMemoryHTML)) {
await store.pasteFromHTML(matchingMemoryHTML, cursorPos)
await pasteClipboardHTML(store, matchingMemoryHTML, cursorPos)
return true
}
return false

View file

@ -1,67 +0,0 @@
const noop = () => undefined
class MemoryDataTransfer implements DataTransfer {
private data = new Map<string, string>()
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()
}

View file

@ -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<boolean>
@ -17,6 +14,6 @@ export type BrowserClipboardReadResult =
| { available: true; html: string | null }
export interface BrowserClipboardIO {
write(payload: ClipboardPayload): Promise<boolean>
write(payload: Promise<ClipboardPayload>): Promise<boolean>
readHTML(): Promise<BrowserClipboardReadResult>
}

View file

@ -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<boolean> {
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 }

View file

@ -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 {

View file

@ -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

View file

@ -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<boolean> {
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)
}
})
}

View file

@ -25,7 +25,7 @@ type ToolbarActionOptions = {
export function useToolbarActions({ store, getCommand, menu }: ToolbarActionOptions) {
const editActions = computed<ToolbarActionItem[]>(() => [
{ 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,

View file

@ -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()

View file

@ -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)
})

View file

@ -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)
})

View file

@ -5,8 +5,15 @@ import { copyAndDeleteSelection } from '@/app/shell/keyboard/clipboard'
function storeWithCopyResult(result: Promise<void>) {
let deleted = false
return {
clipboard: {
copy: async () => {
await result
return true
},
paste: async () => false
},
store: {
writeCopyData: () => result,
state: { selectedIds: new Set<string>() },
deleteSelected: () => {
deleted = true
}
@ -17,14 +24,16 @@ function storeWithCopyResult(result: Promise<void>) {
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)
})
})

View file

@ -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<ClipboardPayload>) => 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<ClipboardPayload>()
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: '<p>Prepared</p>', 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 }

View file

@ -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)
})

View file

@ -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
)
})

View file

@ -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)
})

View file

@ -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()
})

View file

@ -0,0 +1 @@
<!--(openpencil)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(/openpencil)-->