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

+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;