The composition tab only took single clicks and Ctrl-clicks, and animation clips could not be copied at all. A band selection and Copy/Paste for clips make longer compositions quicker to arrange. - Dragging from an empty spot draws a band. It selects every item it touches, across tracks. Ctrl adds to the selection, because Shift plus drag already pans the view. - With Alt held the band selects clips instead and may start on an item, since items fill most of a track. - Ctrl+C copies the selected clips as their own clipboard kind. Ctrl+V places them like items: under the pointer over the tracks, otherwise at the playhead on the selected track. - Clips of a track never overlap. A paste onto taken frames moves the whole copy on frame by frame until every clip fits (`pastedClips`). - The paste is one undo step and selects the new clips. Tests: - Gate on melektaus-atx: tsc, eslint, Prettier clean, 305 files, 4724 tests (2 new). - e2e 69: ALL PASSED, 17 checks (69m new). Band over three items on two tracks, Alt band from an item picks both clips, first paste at frames 50 and 60, second paste moves on to 56 and 66. - Counter-check: without moving on, the paste unit test is red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
/**
|
||
* What Ctrl+C takes (#240): a snapshot of placed objects or of a piece of graph, detached from the
|
||
* model so later edits to the originals do not change what is pasted. Ids are the originals'; the
|
||
* paste commands hand out fresh ones.
|
||
*/
|
||
import {
|
||
type AnnotationKind,
|
||
findAnnotation,
|
||
type Graph,
|
||
type GraphAnnotation,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
} from '../graph/Graph';
|
||
import { editorTransform, parentTransform, positionOf } from '../properties/transform';
|
||
import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
|
||
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
||
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
|
||
import { isCompositionSprite } from './composition';
|
||
import type { Mat } from './matrix';
|
||
import type { Position } from './Properties';
|
||
import type { ObjectHolder, Project } from './Project';
|
||
import type { SceneObject } from './SceneObject';
|
||
|
||
/**
|
||
* A copied subtree's top: where the editor showed it, editor offsets included (#263), and the
|
||
* frame its own rows were meant in.
|
||
*/
|
||
export interface ClipRoot {
|
||
id: string;
|
||
shown: Position;
|
||
parentMatrix: Mat;
|
||
}
|
||
|
||
export interface ObjectClip {
|
||
kind: 'objects';
|
||
/** Parents before children; a root's `parentId` is the one it had where it was copied. */
|
||
objects: SceneObject[];
|
||
roots: ClipRoot[];
|
||
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
|
||
items?: CompositionItem[];
|
||
/** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */
|
||
sprites?: SpriteAsset[];
|
||
}
|
||
|
||
export interface GraphClip {
|
||
kind: 'graph';
|
||
nodes: GraphNode[];
|
||
/** Only the edges between copied nodes – a link to a node left behind has nowhere to go. */
|
||
edges: GraphEdge[];
|
||
annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[];
|
||
}
|
||
|
||
/** Animation clips of a composition (#417), for a paste into the same one or another. */
|
||
export interface CompositionClipsClip {
|
||
kind: 'compositionClips';
|
||
clips: CompositionClip[];
|
||
}
|
||
|
||
export type Clip = ObjectClip | GraphClip | CompositionClipsClip;
|
||
|
||
/**
|
||
* The selected objects with everything below them. A placed container keeps showing its template,
|
||
* so its resolved content stays behind; content picked out of a placed container is copied as the
|
||
* ordinary objects it is shown as.
|
||
*/
|
||
export function copyObjects(
|
||
project: Project,
|
||
holder: ObjectHolder,
|
||
ids: readonly string[],
|
||
): ObjectClip | null {
|
||
const view = resolvedHolder(project, holder);
|
||
const roots = subtreeRoots(
|
||
view,
|
||
ids.filter((id) => findObject(view, id)),
|
||
);
|
||
if (roots.length === 0) return null;
|
||
const members = subtreeIds(view, roots);
|
||
const instances = new Set(
|
||
view.objects
|
||
.filter(
|
||
(o) => members.has(o.id) && !isTemplateContent(holder, o.id) && isLiveInstance(project, holder, o),
|
||
)
|
||
.map((o) => o.id),
|
||
);
|
||
const shownByInstance = (id: string) =>
|
||
isTemplateContent(holder, id) && instances.has(splitInstanceId(id)?.instanceId ?? '');
|
||
const objects = treeOrder(view)
|
||
.map((entry) => entry.object)
|
||
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
|
||
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
|
||
const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : [];
|
||
const worn = new Set(objects.map((o) => o.assetId));
|
||
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
|
||
return {
|
||
kind: 'objects',
|
||
objects,
|
||
...(items.length > 0 && { items: structuredClone(items) }),
|
||
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
|
||
roots: roots.map((id) => {
|
||
const object = findObject(view, id)!;
|
||
return {
|
||
id,
|
||
shown: positionOf(editorTransform(project, view, object)),
|
||
parentMatrix: parentTransform(project, view, object.parentId).m,
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function copyGraph(
|
||
graph: Graph,
|
||
nodeIds: readonly string[],
|
||
annotationIds: readonly string[],
|
||
): GraphClip | null {
|
||
const ids = new Set(nodeIds);
|
||
const nodes = graph.nodes.filter((n) => ids.has(n.id));
|
||
const annotations = annotationIds.flatMap((id) => {
|
||
const found = findAnnotation(graph, id);
|
||
return found ? [found] : [];
|
||
});
|
||
if (nodes.length === 0 && annotations.length === 0) return null;
|
||
return structuredClone({
|
||
kind: 'graph',
|
||
nodes,
|
||
edges: graph.edges.filter((e) => ids.has(e.from.node) && ids.has(e.to.node)),
|
||
annotations,
|
||
});
|
||
}
|