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

+1 -1
View File
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 943 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 948 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+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;
+134
View File
@@ -6,13 +6,17 @@
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
copyAnimationKeys,
MoveAnimationKeysCommand,
PasteAnimationKeysCommand,
RemoveAnimationKeysCommand,
RetargetAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
SetKeyInterpolationCommand,
SetTrackRelativeCommand,
ShiftAnimationFramesCommand,
shiftedFrame,
} from '../../src/core/commands/animationCommands';
import {
CreateAssetCommand,
@@ -386,6 +390,136 @@ describe('editing the timeline', () => {
});
});
describe('copy, paste, duplicate and frames (#102)', () => {
it('pastes relative to the frame, into the same rows, replacing a key there, and undoes in one step', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [
[2, 20],
[4, 40],
[9, 90],
]);
const rot = addRow(f, walk, 'rotation', [[3, 1]]);
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'ease-in'));
const clip = copyAnimationKeys(f.project, walk, [
{ trackId: x, frame: 2 },
{ trackId: x, frame: 4 },
{ trackId: rot, frame: 3 },
])!;
expect(clip.rows.map((r) => r.keys.map((k) => k.offset))).toEqual([[0, 2], [1]]);
const paste = new PasteAnimationKeysCommand(walk.id, clip, 7);
f.stack.execute(paste);
expect(rowOf(walk, x).keys).toEqual([
{ frame: 2, value: 20, interpolation: 'ease-in' },
{ frame: 4, value: 40 },
{ frame: 7, value: 20, interpolation: 'ease-in' },
{ frame: 9, value: 40 },
]);
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [8, 1]));
expect(paste.pasted).toEqual([
{ trackId: x, frame: 7 },
{ trackId: x, frame: 9 },
{ trackId: rot, frame: 8 },
]);
f.stack.undo();
expect(rowOf(walk, x).keys).toHaveLength(3);
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1]));
});
it('pastes one row into the row picked, only of the same kind', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [[0, 5]]);
const rot = addRow(f, walk, 'rotation', []);
const color = addRow(f, walk, 'color', []);
const clip = copyAnimationKeys(f.project, walk, [{ trackId: x, frame: 0 }])!;
f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, rot));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 5]));
expect(rowOf(walk, x).keys).toEqual(keys([0, 5]));
expect(() => f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, color))).toThrow(
CommandError,
);
expect(rowOf(walk, color).keys).toEqual([]);
});
it('finds the same property in another animation, and leaves out rows it has not', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [[1, 10]]);
const rot = addRow(f, walk, 'rotation', [[1, 2]]);
const run = addAnimation(f, f.spriteId, {}, 'Run');
const runX = addRow(f, run, 'x', []);
const clip = copyAnimationKeys(f.project, walk, [
{ trackId: x, frame: 1 },
{ trackId: rot, frame: 1 },
])!;
const paste = new PasteAnimationKeysCommand(run.id, clip, 0);
f.stack.execute(paste);
expect(rowOf(current(f, run), runX).keys).toEqual(keys([0, 10]));
expect(paste.skipped).toBe(1);
const empty = addAnimation(f, f.spriteId, {}, 'Idle');
expect(() => f.stack.execute(new PasteAnimationKeysCommand(empty.id, clip, 0))).toThrow(CommandError);
});
it('duplicates keys: the originals stay, the copies replace what they land on', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [
[0, 0],
[2, 20],
[5, 50],
]);
const picked = [
{ trackId: id, frame: 0 },
{ trackId: id, frame: 2 },
];
f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 3, true));
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [3, 0], [5, 20]));
expect(() => f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 0, true))).toThrow(
CommandError,
);
f.stack.undo();
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [5, 50]));
});
it('inserts frames over every row from the frame on, and removes them taking their keys along', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const x = addRow(f, walk, 'x', [
[0, 0],
[4, 40],
[9, 90],
]);
const rot = addRow(f, walk, 'rotation', [
[3, 1],
[6, 2],
]);
f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 4, 3));
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 40], [12, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [9, 2]));
expect(frameCount(walk)).toBe(13);
f.stack.undo();
f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 3, -2));
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([4, 2]));
f.stack.undo();
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [4, 40], [9, 90]));
expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [6, 2]));
// Past the last key nothing would change – no empty undo step.
expect(() => f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 10, 5))).toThrow(CommandError);
expect(shiftedFrame(4, 3, -2)).toBeNull();
expect(shiftedFrame(5, 3, -2)).toBe(3);
expect(shiftedFrame(2, 3, -2)).toBe(2);
});
});
describe('saving', () => {
it('keeps root, rows and keys through a round trip, and cleans what a hand-edited file brings', () => {
const f = makeSpriteScene();
+75 -5
View File
@@ -5,9 +5,9 @@ const RED = '#ff0000';
const SIZE = 40;
/**
* Animations (#97, #98, #99, #110): a row is added through the dialog, keys are set, moved, banded
* and deleted on the timeline with real gestures, and the preview above it is read at the playhead
* – as a model value, as a screen rectangle and as pixels. The subject is a container whose child
* Animations (#97, #98, #99, #110, #102): a row is added through the dialog, keys are set, moved,
* banded, deleted, copied, pasted and duplicated on the timeline with real gestures, frames are
* inserted, and the preview above it is read at the playhead – as a model value, as a screen rectangle and as pixels. The subject is a container whose child
* is animated, since a row aiming inside the root is the case a single sprite cannot show. It
* removes everything it made.
*/
@@ -231,10 +231,80 @@ export async function animation(t) {
JSON.stringify({ held, reset }),
);
// 25g. Undo all the way: the key at 5, the value edit, the keys at 10 and 0, then the row. Exactly
// 25g. Copy and paste (#102): two keys banded, Ctrl+C, the playhead to 12, Ctrl+V – they land
// relative to the playhead, keeping their distance, and come back picked. Each step undone.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`);
const three = JSON.stringify(withMiddle);
const pickedFrames = async () =>
(await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame);
const c3 = await cell(3);
await drag((await cell(14)).x, c0.y + 40, c3.x, c0.y - 11);
const copied = await pickedFrames();
await press('c', { ctrl: true });
const clip = await json('__gm.animation.clipboard()');
await scrubTo(12);
await press('v', { ctrl: true });
const pasted = await keysOf();
const pastedPick = await pickedFrames();
check(
'Ctrl+C and Ctrl+V paste the banded keys relative to the playhead and pick them',
copied.join() === '5,10' &&
JSON.stringify(clip?.rows?.[0]?.keys?.map((k) => k.offset)) === '[0,5]' &&
JSON.stringify(pasted) ===
JSON.stringify([...withMiddle, { frame: 12, value: 50 }, { frame: 17, value: 100 }]) &&
pastedPick.join() === '12,17',
JSON.stringify({ copied, clip, pasted, pastedPick }),
);
await undo();
check(
'one undo takes the paste back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// Ctrl+drag duplicates: the key at 5 stays, a copy lands on 7.
const k5 = await cell(5);
const k7 = await cell(7);
await click(k5.x, k5.y);
await drag(k5.x, k5.y, k7.x, k7.y, { modifiers: { ctrl: true } });
const duplicated = await keysOf();
check(
'Ctrl+drag leaves the key where it was and drops a copy where the pointer lets go',
JSON.stringify(duplicated) ===
JSON.stringify([withMiddle[0], withMiddle[1], { frame: 7, value: 50 }, withMiddle[2]]),
JSON.stringify(duplicated),
);
await undo();
// Right-click → Insert frames… at the playhead: every key from there on moves by the count.
await scrubTo(5);
const inCells = await cell(2);
await click(inCells.x, inCells.y + 40, 'right');
const insertItem = await find('Insert frames at 5…', true);
await click(insertItem.x, insertItem.y);
await evaluate('__gm.animation.framesDialogCount(3)');
await settle();
const dialog = await json('__gm.animation.framesDialog()');
const confirm = await json('__gm.animation.framesDialogConfirm()');
await click(confirm.x, confirm.y);
const inserted = await keysOf();
check(
'the menu inserts 3 frames at the playhead: the keys on 5 and 10 move to 8 and 13, the one on 0 stays',
dialog?.summary === 'Moves 2 keys 3 frames later.' &&
JSON.stringify(inserted.map((k) => k.frame)) === '[0,8,13]' &&
(await json('__gm.animation.framesDialog()')) === null,
JSON.stringify({ dialog, inserted }),
);
await undo();
check(
'undo takes the inserted frames back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// 25h. Undo all the way: the key at 5, the value edit, the keys at 10 and 0, then the row. Exactly
// that many – one more would take back a command of an earlier step, so the count is checked
// on the way: after four the row is still there, empty.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`);
await focusTimeline();
for (let i = 0; i < 4; i++) await undo();
const emptied = await walk();
+5 -2
View File
@@ -44,15 +44,18 @@ const KEYS = {
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 },
c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 },
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },
z: { key: 'z', code: 'KeyZ', windowsVirtualKeyCode: 90 },
};
/** CDP modifier bitmask: 2 = Ctrl, 8 = Shift. */
const modifierBits = ({ ctrl = false, shift = false } = {}) => (ctrl ? 2 : 0) | (shift ? 8 : 0);
/** CDP modifier bitmask: 1 = Alt, 2 = Ctrl, 8 = Shift. */
const modifierBits = ({ alt = false, ctrl = false, shift = false } = {}) =>
(alt ? 1 : 0) | (ctrl ? 2 : 0) | (shift ? 8 : 0);
class CDP {
constructor(ws) {