From bf02c9e8b3b2aa1c461ba90e52bb75c23d8b21b3 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 17 Sep 2026 12:53:56 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/animationCommands.ts | 171 ++++++++++++++++++++++++- src/editor/Editor.ts | 2 + src/editor/animation/FramesDialog.ts | 127 ++++++++++++++++++ src/editor/animation/TimelineCanvas.ts | 146 +++++++++++++++++++-- src/editor/debugHook.ts | 11 ++ src/editor/shortcuts.ts | 5 + src/editor/state.ts | 4 +- tests/core/animation.test.ts | 134 +++++++++++++++++++ tools/e2e/25-animation.mjs | 80 +++++++++++- tools/lib/harness.mjs | 7 +- 11 files changed, 666 insertions(+), 23 deletions(-) create mode 100644 src/editor/animation/FramesDialog.ts diff --git a/README.md b/README.md index b1ebaaa2..c8ba27b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 6740dcee..1b192ade 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -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>(); 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`); + } +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c420c7d1..894e0912 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 diff --git a/src/editor/animation/FramesDialog.ts b/src/editor/animation/FramesDialog.ts new file mode 100644 index 00000000..cdeade51 --- /dev/null +++ b/src/editor/animation/FramesDialog.ts @@ -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(); + } +} diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 51105642..c4f2f26a 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -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 }); + }); } }); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index c91948c7..c97d430a 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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). */ diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index cb2aef14..1c1c9905 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -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); diff --git a/src/editor/state.ts b/src/editor/state.ts index fe5bde5e..01c2b8d8 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index cebf5f28..5e87f57e 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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(); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 487750b4..815c5f26 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -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(); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 9b7995e0..f8bbbb55 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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) {