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
@@ -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 });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user