/** * Copy and paste of placed objects and graph pieces (#240). The clipboard is the editor's own: a * copy names assets of this project, which mean nothing anywhere else. */ import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands'; import { BEHAVIOR_PARAM, findAnnotation, findNode, type GraphNode, nodeDef, RUN_BEHAVIOR, } from '../core/graph'; import type { GraphAsset } from '../core/model/Asset'; import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; import { findObject } from '../core/model/hierarchy'; import { isLiveInstance } from '../core/model/instance'; import type { Vec2 } from '../core/model/Properties'; import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project'; import { GRID_STEP } from './graph/graphView'; import { paletteEntries } from './graph/paletteEntries'; import { type EditorState, graphSelection, objectSelection } from './state'; import { SCENE_GRID_STEP } from './viewport/sceneViewHost'; /** * `lineage` holds the originals and everything pasted from them since: selecting one of those and * pasting puts the copy beside it, not inside it. `pastes` holds one id per paste; those still there * stagger the next paste that lands without a pointer, so an undone paste frees its spot again. */ export interface EditorClipboard { clip: Clip; lineage: Set; pastes: string[]; } function put(state: EditorState, clip: Clip, lineage: Iterable, noun: string): true { state.clipboard = { clip, lineage: new Set(lineage), pastes: [] }; state.setNotice(`Copied ${noun}`); return true; } function counted(count: number, one: string): string { return count === 1 ? `1 ${one}` : `${count} ${one}s`; } /** Copies the objects with everything below them; false when none of them is there. */ export function copyObjectIds(state: EditorState, holderId: string, ids: readonly string[]): boolean { const holder = findObjectHolder(state.project, holderId); const clip = holder && copyObjects(state.project, holder, ids); if (!clip) return false; const roots = clip.roots.map((r) => r.id); return put(state, clip, roots, counted(roots.length, 'object')); } /** Copies the selected objects, or the selected nodes and boxes; false when nothing of that is selected. */ export function copySelection(state: EditorState): boolean { const s = state.selection; if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds); if (s?.kind === 'node' || s?.kind === 'annotation') { const graph = findGraphAsset(state.project, s.assetId)?.graph; const nodeIds = s.kind === 'node' ? s.nodeIds : []; const clip = graph && copyGraph(graph, nodeIds, s.annotationIds); if (!clip) return false; return put(state, clip, [], counted(clip.nodes.length + clip.annotations.length, 'item')); } return false; } function objectClip(state: EditorState): ObjectClip | null { const clip = state.clipboard?.clip; return clip?.kind === 'objects' ? clip : null; } function graphClip(state: EditorState): GraphClip | null { const clip = state.clipboard?.clip; return clip?.kind === 'graph' ? clip : null; } export function canPasteObjects(state: EditorState): boolean { return objectClip(state) !== null; } export function canPasteGraph(state: EditorState): boolean { return graphClip(state) !== null; } /** Whether an object may take children: stored in the holder, and not a container showing its template. */ function takesChildren(project: Project, holder: ObjectHolder, id: string): boolean { const object = findObject(holder, id); return object !== undefined && !isLiveInstance(project, holder, object); } /** The one selected object of `holderId`; empty when there are none or several. */ function pickedObject(state: EditorState, holderId: string): string { const s = state.selection; return s?.kind === 'object' && s.holderId === holderId && s.objectIds.length === 1 ? s.objectId : ''; } /** * Where each copied root goes. Into the `picked` object when there is one; beside it when that one * is an original or an earlier paste; otherwise under the parent it had, if this holder has it, * else on top. */ function pasteParents( project: Project, holder: ObjectHolder, clip: ObjectClip, lineage: ReadonlySet, picked: string, ): Record { let into: string | null = null; if (picked && lineage.has(picked)) { const parentId = findObject(holder, picked)?.parentId ?? ''; into = parentId && takesChildren(project, holder, parentId) ? parentId : ''; } else if (picked && takesChildren(project, holder, picked)) into = picked; const parents: Record = {}; for (const root of clip.roots) { const own = clip.objects.find((o) => o.id === root.id)?.parentId ?? ''; parents[root.id] = into ?? (own && takesChildren(project, holder, own) ? own : ''); } return parents; } /** * Pastes the copied objects into `holderId`, into or beside `picked` – by default the one selected * object. With `pointer` (world units) the copy is centred on it, otherwise it lands a step beside * the original, a step further for every earlier paste still there. */ export function pasteObjects( state: EditorState, holderId: string, pointer: Vec2 | null, picked = pickedObject(state, holderId), ): boolean { const board = state.clipboard; const clip = objectClip(state); const holder = findObjectHolder(state.project, holderId); // Nothing of the right kind lets the key through: Ctrl+V then imports an image, as it always did. if (!board || !clip || !holder) return false; const centred = pointer && centredOn( pointer, clip.roots.map((r) => ({ x: r.world.x, y: r.world.y, w: 0, h: 0 })), ); const shift = centred ? { x: Math.round(centred.x), y: Math.round(centred.y) } : stepped(board.pastes.filter((id) => findObject(holder, id)).length, SCENE_GRID_STEP); const parents = pasteParents(state.project, holder, clip, board.lineage, picked); const command = new PasteObjectsCommand(holderId, clip, parents, shift); if (!state.execute(command)) return false; board.pastes.push(command.rootIds[0]!); for (const id of command.rootIds) board.lineage.add(id); state.select(objectSelection(holderId, command.rootIds)); return true; } /** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean { return paletteEntries(project, asset).some( (entry) => entry.def.type === node.type && (node.type !== RUN_BEHAVIOR || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]), ); } /** The part of a graph copy `asset` can take, and the nodes it cannot. */ function fittingPart( project: Project, asset: GraphAsset, clip: GraphClip, ): { clip: GraphClip; skipped: GraphNode[] } { const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n)); const kept = new Set(nodes.map((n) => n.id)); return { clip: { ...clip, nodes, edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)), }, skipped: clip.nodes.filter((n) => !kept.has(n.id)), }; } /** * Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid * step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is * left out and named. */ export function pasteGraph( state: EditorState, assetId: string, pointer: Vec2 | null, snap: boolean, ): boolean { const board = state.clipboard; const clip = graphClip(state); const asset = findGraphAsset(state.project, assetId); if (!board || !clip || !asset) return false; const part = fittingPart(state.project, asset, clip); const skipped = [...new Set(part.skipped.map((n) => nodeDef(n.type)?.label ?? n.type))].join(', '); if (part.clip.nodes.length === 0 && part.clip.annotations.length === 0) { state.setNotice(`Nothing of the copy fits into this graph (${skipped})`); return false; } const standing = board.pastes.filter((id) => findNode(asset.graph, id) || findAnnotation(asset.graph, id)); let shift = stepped(standing.length, GRID_STEP); if (pointer) { shift = centredOn(pointer, [ ...part.clip.nodes.map((n) => ({ x: n.x, y: n.y, w: 0, h: 0 })), ...part.clip.annotations.map((a) => a.annotation), ]); if (snap) shift = { x: snapped(shift.x), y: snapped(shift.y) }; } const command = new PasteGraphCommand(assetId, part.clip, shift); if (!state.execute(command)) return false; board.pastes.push(command.nodeIds[0] ?? command.annotationIds[0]!); state.select(graphSelection(assetId, command.nodeIds, command.annotationIds)); if (skipped) state.setNotice(`Pasted without what this graph cannot take: ${skipped}`); return true; } function snapped(value: number): number { return Math.round(value / GRID_STEP) * GRID_STEP; } /** One step down and right per paste still standing, and one more for this one. */ function stepped(standing: number, step: number): Vec2 { const by = step * (standing + 1); return { x: by, y: by }; } /** The shift that puts the middle of the boxes' bounds on `at`. */ function centredOn(at: Vec2, boxes: readonly { x: number; y: number; w: number; h: number }[]): Vec2 { const minX = Math.min(...boxes.map((b) => b.x)); const minY = Math.min(...boxes.map((b) => b.y)); const maxX = Math.max(...boxes.map((b) => b.x + b.w)); const maxY = Math.max(...boxes.map((b) => b.y + b.h)); return { x: at.x - (minX + maxX) / 2, y: at.y - (minY + maxY) / 2 }; }