Timeline keys can be copied and pasted at the playhead, duplicated with Ctrl+drag, and frames inserted or removed from a right-click menu (#102)
game-maker / gate (push) Successful in 1m34s
game-maker / suite (push) Successful in 5m38s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 12:53:56 +02:00
1 parent 195fc94e08
commit bf02c9e8b3
11 files changed
+666 -23

No files matched your search

+168 -3
View File
@@ -285,7 +285,10 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
}
}
/** Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it. */
/**
* Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it.
* With `copy` the originals stay where they were and the copies travel – Ctrl+drag (#102).
*/
export class MoveAnimationKeysCommand extends TimelineCommand {
readonly label: string;
@@ -293,13 +296,16 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
assetId: string,
private readonly keys: readonly KeyRef[],
private readonly delta: number,
private readonly copy = false,
) {
super(assetId);
this.label = keys.length === 1 ? 'Move key' : `Move ${keys.length} keys`;
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.delta)) throw new CommandError('Keys move by whole frames');
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
const byTrack = new Map<string, Set<number>>();
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
@@ -310,7 +316,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
}
for (const [trackId, frames] of byTrack) {
const track = requireTrack(asset, trackId);
const staying = track.keys.filter((k) => !frames.has(k.frame));
const staying = this.copy ? track.keys : track.keys.filter((k) => !frames.has(k.frame));
const moved = track.keys
.filter((k) => frames.has(k.frame))
.map((k) => ({ ...k, frame: k.frame + this.delta }));
@@ -319,3 +325,162 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
}
}
}
/** One row of copied keys: where it came from, its kind, and the keys counted from the first copied frame. */
export interface ClipboardRow {
trackId: string;
target: string;
property: string;
/** Unknown when the row aimed at something gone – such keys only go back onto that very row. */
kind: string | null;
keys: { offset: number; value: PropertyValue; interpolation?: KeyInterpolation }[];
}
/** What Ctrl+C on picked keys holds (#102). Frames are relative: the earliest copied key is offset 0. */
export interface KeyClipboard {
assetId: string;
rows: ClipboardRow[];
}
/** The picked keys as a clipboard, or null when none of them exists. */
export function copyAnimationKeys(
project: Project,
asset: AnimationAsset,
refs: readonly KeyRef[],
): KeyClipboard | null {
const picked = asset.tracks
.map((track) => ({
track,
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
}))
.filter((p) => p.keys.length > 0);
if (picked.length === 0) return null;
const first = Math.min(...picked.flatMap((p) => p.keys.map((k) => k.frame)));
return {
assetId: asset.id,
rows: picked.map(({ track, keys }) => ({
trackId: track.id,
target: track.target,
property: track.property,
kind: trackDef(project, asset, track)?.kind ?? null,
keys: keys.map((k) => ({
offset: k.frame - first,
value: structuredClone(k.value),
...(k.interpolation ? { interpolation: k.interpolation } : {}),
})),
})),
};
}
/**
* Pastes a clipboard with its first key on `frame`. Keys of one row go onto `intoTrackId` when given
* – the row picked in the timeline – as long as it has the same kind. Otherwise every row finds its
* own: the same row in the animation it came from, the row animating the same property elsewhere.
* Rows that find none are left out and counted in `skipped`; a pasted key replaces one already there.
*/
export class PasteAnimationKeysCommand extends TimelineCommand {
readonly label: string;
/** The keys as they landed, for the timeline to pick. */
pasted: KeyRef[] = [];
skipped = 0;
constructor(
assetId: string,
private readonly clipboard: KeyClipboard,
private readonly frame: number,
private readonly intoTrackId = '',
) {
super(assetId);
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
}
protected apply(project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('Keys paste onto a whole frame from 0');
this.pasted = [];
this.skipped = 0;
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
for (const row of this.clipboard.rows) {
const track = single ? requireTrack(asset, this.intoTrackId) : this.matchingTrack(asset, row);
if (!track) {
this.skipped += 1;
continue;
}
const def = trackDef(project, asset, track);
if (row.kind === null ? track.id !== row.trackId : def?.kind !== row.kind) {
if (single)
throw new CommandError(
def
? `${row.kind ?? 'These'} keys cannot go on a ${def.kind} row`
: 'The row aims at something that is gone',
);
this.skipped += 1;
continue;
}
const keys = row.keys.map((k) => {
const key: AnimationKey = {
frame: this.frame + k.offset,
value: def ? coerceValue(def, k.value) : structuredClone(k.value),
};
if (k.interpolation && (!def || blends(def.kind))) key.interpolation = k.interpolation;
return key;
});
track.keys = normalizeKeys([...track.keys, ...keys]);
this.pasted.push(...keys.map((k) => ({ trackId: track.id, frame: k.frame })));
}
if (this.pasted.length === 0) throw new CommandError('None of the copied rows is in this animation');
}
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
return asset.id === this.clipboard.assetId
? asset.tracks.find((t) => t.id === row.trackId)
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
}
}
/**
* Where a key on `frame` ends up when `count` frames are inserted at `at` (count > 0) or removed from
* it (count < 0): keys from `at` on move, and removing drops the keys in the removed span (null).
*/
export function shiftedFrame(frame: number, at: number, count: number): number | null {
if (frame < at) return frame;
if (count < 0 && frame < at - count) return null;
return frame + count;
}
/**
* Inserts (`count` > 0) or removes (`count` < 0) frames at `at`, over every row – the way to make an
* animation longer or shorter in the middle (#102). A step that would change nothing is refused.
*/
export class ShiftAnimationFramesCommand extends TimelineCommand {
readonly label: string;
constructor(
assetId: string,
private readonly at: number,
private readonly count: number,
) {
super(assetId);
const frames = Math.abs(count) === 1 ? 'frame' : `${Math.abs(count)} frames`;
this.label = count > 0 ? `Insert ${frames}` : `Remove ${frames}`;
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.at) || this.at < 0)
throw new CommandError('Frames are inserted at a whole frame from 0');
if (!Number.isInteger(this.count) || this.count === 0)
throw new CommandError('Give a whole number of frames');
let touched = 0;
for (const track of asset.tracks) {
const keys: AnimationKey[] = [];
for (const key of track.keys) {
const frame = shiftedFrame(key.frame, this.at, this.count);
if (frame !== key.frame) touched += 1;
if (frame !== null) keys.push({ ...key, frame });
}
track.keys = keys;
}
if (touched === 0) throw new CommandError(`No keys from frame ${this.at} on`);
}
}
+2
View File
@@ -262,6 +262,8 @@ export class Editor implements PlayHost {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
});
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node, a frame and a template object exist only in their own tab, so selecting one
+127
View File
@@ -0,0 +1,127 @@
import { shiftedFrame } from '../../core/commands/animationCommands';
import type { AnimationAsset } from '../../core/model/Asset';
import { Button, HStack, Label, NumberField, Popup, Spacer, Theme } from '../../ui';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
let current: FramesDialog | null = null;
/** The open frames dialog, for the check scripts. */
export function currentFramesDialog(): FramesDialog | null {
return current;
}
/** Opens the dialog centred, closing whichever one was open before it. */
export function openFramesDialog(dialog: FramesDialog): FramesDialog {
current?.close();
current = dialog;
dialog.openCentered();
return dialog;
}
/**
* Asks how many frames to insert at the playhead, or to remove from it (#102). The summary says what
* that does to the keys before anything happens – removing frames takes keys with it.
*/
export class FramesDialog extends Popup {
private count = 1;
private readonly summary: Label;
private readonly field: NumberField;
/** Public for the check scripts. */
readonly confirm: Button;
constructor(
private readonly asset: AnimationAsset,
private readonly at: number,
private readonly mode: 'insert' | 'remove',
onConfirm: (count: number) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 320;
this.content.add(
new Label(mode === 'insert' ? `Insert frames at frame ${at}` : `Remove frames from frame ${at}`, {
style: 'bodyBold',
}),
);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label('Frames', { style: 'muted' });
label.minWidth = LABEL_WIDTH;
row.add(label);
this.field = new NumberField(this.count, {
min: 1,
step: 1,
decimals: 0,
onInput: (value) => this.setCount(value),
onChange: (value) => this.setCount(value),
});
this.field.minWidth = FIELD_WIDTH;
row.add(this.field);
row.add(new Spacer(), 1);
this.content.add(row);
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button(mode === 'insert' ? 'Insert' : 'Remove', {
variant: mode === 'insert' ? 'primary' : 'danger',
});
this.confirm.onClick = this.closeThen(() => onConfirm(mode === 'insert' ? this.count : -this.count));
buttons.add(this.confirm);
this.content.add(buttons);
this.refreshSummary();
}
override close(): void {
if (current === this) current = null;
super.close();
}
setCount(value: number): void {
this.count = Math.max(1, Math.round(value));
this.field.setNumber(this.count);
this.refreshSummary();
}
debugInfo(): { at: number; mode: 'insert' | 'remove'; count: number; summary: string; enabled: boolean } {
return {
at: this.at,
mode: this.mode,
count: this.count,
summary: this.summary.text,
enabled: this.confirm.enabled,
};
}
private refreshSummary(): void {
const shift = this.mode === 'insert' ? this.count : -this.count;
let moved = 0;
let removed = 0;
for (const track of this.asset.tracks)
for (const key of track.keys) {
const frame = shiftedFrame(key.frame, this.at, shift);
if (frame === null) removed += 1;
else if (frame !== key.frame) moved += 1;
}
const keys = (n: number) => `${n} ${n === 1 ? 'key' : 'keys'}`;
const frames = `${this.count} ${this.count === 1 ? 'frame' : 'frames'}`;
const parts: string[] = [];
if (removed > 0) parts.push(`removes ${keys(removed)}`);
if (moved > 0)
parts.push(`moves ${keys(moved)} ${frames} ${this.mode === 'insert' ? 'later' : 'earlier'}`);
const text = parts.join(', ');
this.summary.setText(
text ? text[0]!.toUpperCase() + text.slice(1) + '.' : `No keys from frame ${this.at} on.`,
);
this.confirm.setEnabled(parts.length > 0);
this.refreshLayout();
}
}
+135 -11
View File
@@ -1,8 +1,12 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
copyAnimationKeys,
type KeyRef,
MoveAnimationKeysCommand,
PasteAnimationKeysCommand,
SetAnimationKeyCommand,
ShiftAnimationFramesCommand,
shiftedFrame,
} from '../../core/commands/animationCommands';
import {
animationPlayheadAt,
@@ -14,8 +18,9 @@ import {
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { drawBand, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { drawBand, Menu, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { type EditorState, trackSelection } from '../state';
import { FramesDialog, openFramesDialog } from './FramesDialog';
import {
clampMove,
clampPan,
@@ -44,12 +49,16 @@ const DRAG_THRESHOLD = 3;
/** Inset of a row's label from the header's left edge. */
const LABEL_INSET = 8;
/** Keys being dragged: what was picked, and how far the pointer has taken them, in whole frames. */
/**
* Keys being dragged: what was picked, and how far the pointer has taken them, in whole frames.
* `copy` follows Ctrl while dragging: the originals stay and copies travel (#102).
*/
interface KeyDrag {
keys: KeyRef[];
pointer: Vec2;
delta: number;
moved: boolean;
copy: boolean;
}
/** A band being spanned over the cells, in widget-local pixels. */
@@ -62,7 +71,8 @@ interface Band {
/**
* The Animation tab's canvas (#97): row headers on the left, a frame ruler on top, a cell per row
* and frame below it. A double click in a cell sets a key, keys are picked, banded, dragged and
* deleted, the ruler scrubs the playhead. Everything is drawn in widget pixels: keys and labels
* deleted, the ruler scrubs the playhead. Ctrl+drag duplicates keys; a right-click copies, pastes and
* inserts or removes frames at the playhead (#102). Everything is drawn in widget pixels: keys and labels
* keep their size whatever the horizontal zoom.
*/
export class TimelineCanvas extends Widget {
@@ -190,6 +200,99 @@ export class TimelineCanvas extends Widget {
return fps > 0 ? frame / fps : 0;
}
// --- clipboard and frames (#102) ------------------------------------------------------------
/** The frame the playhead stands on – where a paste lands and frames are inserted. */
get playheadFrame(): number {
return Math.max(0, Math.floor(this.playhead));
}
/** Copies the picked keys; false when none are picked, so Ctrl+C can fall through. */
copyKeys(): boolean {
const asset = this.asset();
if (!asset) return false;
const clipboard = copyAnimationKeys(this.state.project, asset, this.state.selectedKeys);
if (!clipboard) return false;
this.state.keyClipboard = clipboard;
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
this.state.setNotice(`Copied ${count} ${count === 1 ? 'key' : 'keys'}`, 'quiet');
return true;
}
/**
* Pastes at the playhead and picks what landed. Copied from one row, the keys go onto the row
* picked now – which is the row they came from until another one is clicked.
*/
pasteKeys(): boolean {
const asset = this.asset();
const clipboard = this.state.keyClipboard;
if (!asset || !clipboard) return false;
const s = this.state.selection;
const into = s?.kind === 'track' && s.assetId === asset.id ? s.trackId : '';
const command = new PasteAnimationKeysCommand(asset.id, clipboard, this.playheadFrame, into);
if (this.state.execute(command)) {
this.state.select(trackSelection(asset.id, command.pasted));
const n = command.skipped;
if (n > 0)
this.state.setNotice(
`${n} copied ${n === 1 ? 'row has' : 'rows have'} no matching row here and ${n === 1 ? 'was' : 'were'} left out.`,
);
}
return true;
}
/** Inserts (`count` > 0) or removes frames at the playhead; the picked keys follow theirs. */
shiftFrames(count: number): boolean {
const asset = this.asset();
if (!asset) return false;
const at = this.playheadFrame;
const picked = this.state.selectedKeys;
const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined;
if (!this.state.execute(new ShiftAnimationFramesCommand(asset.id, at, count))) return false;
if (picked.length > 0) {
const kept = picked.flatMap((k) => {
const frame = shiftedFrame(k.frame, at, count);
return frame === null ? [] : [{ trackId: k.trackId, frame }];
});
this.state.select(trackSelection(asset.id, kept, primary) ?? { kind: 'asset', assetId: asset.id });
}
return true;
}
openFramesDialog(mode: 'insert' | 'remove'): FramesDialog | null {
const asset = this.asset();
if (!asset) return null;
return openFramesDialog(
new FramesDialog(asset, this.playheadFrame, mode, (count) => this.shiftFrames(count)),
);
}
/** A right-click on a key picks it first, the way a node's menu does, so Copy speaks about it. */
private openMenu(asset: AnimationAsset, local: Vec2, global: Vec2): void {
const hit =
local.x >= HEADER_WIDTH && local.y >= RULER_HEIGHT
? keyAt(this.view, asset.tracks, { x: local.x - HEADER_WIDTH, y: local.y })
: null;
if (hit && !hasKey(this.state.selectedKeys, hit)) this.state.select(trackSelection(asset.id, [hit]));
const picked = this.state.selectedKeys.length;
const at = this.playheadFrame;
new Menu([
{
label: picked > 1 ? `Copy ${picked} keys` : 'Copy key',
disabled: picked === 0,
onSelect: () => this.copyKeys(),
},
{
label: `Paste at frame ${at}`,
disabled: this.state.keyClipboard === null,
onSelect: () => this.pasteKeys(),
},
{ separator: true },
{ label: `Insert frames at ${at}…`, onSelect: () => this.openFramesDialog('insert') },
{ label: `Remove frames from ${at}…`, danger: true, onSelect: () => this.openFramesDialog('remove') },
]).open(global.x, global.y);
}
// --- for the check scripts -------------------------------------------------------------------
/** Where a cell's centre sits on screen. */
@@ -259,6 +362,11 @@ export class TimelineCanvas extends Widget {
e.stopPropagation();
return;
}
if (e.button === 2) {
e.stopPropagation();
this.openMenu(asset, point, e.global);
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
e.stopPropagation();
@@ -282,7 +390,13 @@ export class TimelineCanvas extends Widget {
// A picked key is taken as it is, so a drag moves the whole pick; an unpicked one replaces it.
const keys = hasKey(picked, hit) ? picked : [hit];
if (!hasKey(picked, hit)) this.state.select(trackSelection(asset.id, keys, hit.trackId));
this.dragging = { keys: [...keys], pointer: point, delta: 0, moved: false };
this.dragging = {
keys: [...keys],
pointer: point,
delta: 0,
moved: false,
copy: e.ctrlKey || e.metaKey,
};
return;
}
const row = rowAt(cell.y, asset.tracks.length);
@@ -324,6 +438,7 @@ export class TimelineCanvas extends Widget {
const dx = local.x - this.dragging.pointer.x;
if (!this.dragging.moved && Math.abs(dx) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.dragging.copy = e.ctrlKey || e.metaKey;
this.dragging.delta = clampMove(this.dragging.keys, Math.round(dx / this.view.cellWidth));
this.redraw();
return;
@@ -351,7 +466,7 @@ export class TimelineCanvas extends Widget {
if (drag.moved && drag.delta !== 0) {
const moved = drag.keys.map((k) => ({ trackId: k.trackId, frame: k.frame + drag.delta }));
const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined;
if (this.state.execute(new MoveAnimationKeysCommand(asset.id, drag.keys, drag.delta)))
if (this.state.execute(new MoveAnimationKeysCommand(asset.id, drag.keys, drag.delta, drag.copy)))
this.state.select(trackSelection(asset.id, moved, primary));
}
this.redraw();
@@ -488,12 +603,21 @@ export class TimelineCanvas extends Widget {
for (const key of track.keys) {
const ref = { trackId: track.id, frame: key.frame };
const selected = hasKey(picked, ref);
const shifted = drag && drag.keys.some((k) => sameKey(k, ref)) ? drag.delta : 0;
const x = HEADER_WIDTH + frameCentreX(this.view, key.frame + shifted);
if (x < HEADER_WIDTH - half || x > this.w + half) continue;
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
.fill(selected ? c.accent : c.text)
.stroke({ width: 1, color: selected ? c.accentText : c.viewport });
const dragged = drag !== null && drag.keys.some((k) => sameKey(k, ref));
// A duplicate leaves the original where it is, drawn unpicked, and the copy travels picked.
const frames = !dragged
? [key.frame]
: drag.copy
? [key.frame, key.frame + drag.delta]
: [key.frame + drag.delta];
frames.forEach((frame, i) => {
const x = HEADER_WIDTH + frameCentreX(this.view, frame);
if (x < HEADER_WIDTH - half || x > this.w + half) return;
const lit = selected && !(dragged && drag.copy && i === 0);
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
.fill(lit ? c.accent : c.text)
.stroke({ width: 1, color: lit ? c.accentText : c.viewport });
});
}
});
}
+11
View File
@@ -20,6 +20,7 @@ import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { currentFramesDialog } from './animation/FramesDialog';
import { currentRowDialog } from './animation/RowDialog';
import type { CentreTab, Editor } from './Editor';
import { currentFontPicker } from './font/FontPicker';
@@ -482,6 +483,16 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */
rowDialog: () => currentRowDialog()?.debugInfo() ?? null,
rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property),
/** Copy/paste and frames (#102): what the clipboard holds, and the open frames dialog. */
clipboard: () => structuredClone(state.keyClipboard),
framesDialog: () => currentFramesDialog()?.debugInfo() ?? null,
framesDialogCount: (count: number) => currentFramesDialog()?.setCount(count),
framesDialogConfirm: () => {
const dialog = currentFramesDialog();
if (!dialog) return null;
const g = dialog.confirm.getGlobalPosition();
return { x: g.x + dialog.confirm.w / 2, y: g.y + dialog.confirm.h / 2 };
},
/** The preview above the timeline (#99): where a row's target is drawn and with which values. */
preview: {
/** The id the preview draws a row's target under (`''` = the root itself). */
+5
View File
@@ -8,6 +8,9 @@ export interface ShortcutActions {
play(): void;
stop(): void;
togglePause(): void;
/** Copy and paste of timeline keys (#102); false when nothing to do there, so the key falls through. */
copyKeys(): boolean;
pasteKeys(): boolean;
}
/**
@@ -31,6 +34,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
state.redo();
return true;
}
if (mod && key === 'c') return actions.copyKeys();
if (mod && key === 'v') return actions.pasteKeys();
if (mod && key === 'g') {
const s = state.selection;
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
+3 -1
View File
@@ -1,4 +1,4 @@
import type { KeyRef } from '../core/commands/animationCommands';
import type { KeyClipboard, KeyRef } from '../core/commands/animationCommands';
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
@@ -182,6 +182,8 @@ export class EditorState {
saveStatus: SaveStatus = 'saved';
/** Last user-facing message (errors, hints). */
notice = '';
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
keyClipboard: KeyClipboard | null = null;
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;