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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
195fc94e08
commit
bf02c9e8b3
11 files changed
+666
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user