An animation's rows are edited on a timeline: double click sets a key, keys are banded, dragged and deleted, the ruler scrubs (#98)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 20:38:34 +02:00
1 parent 193378e6a9
commit 5af0a1c83a
12 files changed
+1463 -43

No files matched your search

+15
View File
@@ -171,6 +171,21 @@ export function targetAssetOf(
return object ? findAsset(project, object.assetId) : undefined;
}
/** How the timeline names what a row aims at: the object (or the root itself) and the property. */
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
const root = findAsset(project, animation.root);
const object = track.target === '' ? undefined : rootObjects(root).find((o) => o.id === track.target);
const aimed = targetAssetOf(project, animation, track.target);
const who =
track.target === ''
? (root?.name ?? '?')
: object
? object.name || findAsset(project, object.assetId)?.name || '?'
: '(missing)';
const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property);
return `${who} · ${def?.label ?? track.property}`;
}
/** Whether a value names something in the project – what deleting that something has to count. */
export function referencesAny(
track: AnimationTrack,
+67 -41
View File
@@ -1,56 +1,47 @@
import { animationDuration, fpsOf, frameCount } from '../../core/model/animation';
import { SetAnimationRootCommand } from '../../core/commands/animationCommands';
import { ANIMATION_ROOT_TYPES, animationDuration, fpsOf, frameCount } from '../../core/model/animation';
import type { AnimationAsset, Asset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { formatNumber, Label, Theme, VStack } from '../../ui';
import { AssetTabEditor, type TabCanvas } from '../AssetTabEditor';
import { Button, Dropdown, formatNumber, Menu, type MenuItem } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { openRowDialog, RowDialog } from './RowDialog';
import { TimelineCanvas } from './TimelineCanvas';
/** Room kept for the "3 rows · 24 frames · 10 fps · 2.4 s" readout. */
const INFO_WIDTH = 220;
const ROOT_WIDTH = 160;
/**
* Stands where the timeline goes (#97): says what the animation animates and holds the tab's
* subject, so picking, deleting and the readout already work on the new model.
* The Animation tab (#97): the header picks what the animation animates and adds rows, the
* canvas below is the timeline. `fps` and `playback` are properties of the asset, edited in the
* inspector like any other.
*/
export class TimelinePlaceholder extends VStack implements TabCanvas {
private assetId = '';
private readonly note = new Label('', { style: 'muted' });
export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanvas> {
/** Public for the check scripts. */
readonly rootPicker: Dropdown;
readonly addRowButton: Button;
readonly playButton: Button;
constructor(private readonly state: EditorState) {
super({ padding: Theme.padding });
this.add(this.note);
}
get currentAssetId(): string {
return this.assetId;
}
show(assetId: string): void {
this.assetId = assetId;
const asset = findAsset(this.state.project, assetId);
const root = asset?.type === 'animation' ? findAsset(this.state.project, asset.root) : undefined;
this.note.setText(
asset?.type !== 'animation'
? ''
: `Animates ${root ? root.name : 'nothing yet'} – the timeline editor is being built.`,
);
}
fit(): void {}
}
export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelinePlaceholder> {
constructor(state: EditorState) {
super(state, { noun: 'Animation', infoWidth: INFO_WIDTH });
this.mount(new TimelinePlaceholder(state));
this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() });
this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() });
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
this.mount(new TimelineCanvas(state), [this.rootPicker, this.addRowButton, this.playButton]);
}
/** Only called while the tab is visible; the timeline brings its own preview clock. */
tick(_dt: number): void {}
/** Only called while the tab is visible. */
tick(dt: number): void {
if (!this.canvas.isPlaying) return;
this.canvas.tick(dt);
// A finished `once` pass pauses the canvas; the button has to say so.
this.syncPlayButton();
}
/** Whether the preview is running – a moving picture needs a frame without an event. */
get isPlaying(): boolean {
return false;
return this.canvas.isPlaying;
}
protected override selectedId(): string {
@@ -61,11 +52,6 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelinePlac
return asset.type === 'animation';
}
protected override refresh(): void {
super.refresh();
this.canvas.show(this.canvas.currentAssetId);
}
protected override infoText(asset: AnimationAsset): string {
const rows = asset.tracks.length;
const frames = Math.max(0, frameCount(asset));
@@ -73,4 +59,44 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelinePlac
const fps = Number(fpsOf(asset).toFixed(2));
return `${rows} ${rows === 1 ? 'row' : 'rows'} · ${frames} ${frames === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
}
protected override syncExtras(asset: AnimationAsset | null): void {
const root = asset ? findAsset(this.state.project, asset.root) : undefined;
this.rootPicker.setText(root ? root.name : asset ? 'Animates…' : '—');
this.rootPicker.setEnabled(asset !== null);
// A row needs something to aim at.
this.addRowButton.setEnabled(Boolean(root));
this.playButton.setEnabled(Boolean(asset && frameCount(asset) > 0));
this.syncPlayButton();
}
private syncPlayButton(): void {
this.playButton.setText(this.canvas.isPlaying ? 'Pause' : 'Play');
}
private togglePlay(): void {
if (this.canvas.isPlaying) this.canvas.pause();
else this.canvas.play();
this.syncPlayButton();
}
/** What may be animated: a sprite, text, container or scene – never another animation. */
private openRootPicker(): void {
const asset = this.asset();
if (!asset) return;
const items: MenuItem[] = this.state.project.assets
.filter((a) => ANIMATION_ROOT_TYPES.includes(a.type))
.sort((a, b) => a.name.localeCompare(b.name))
.map((candidate) => ({
label: `${candidate.id === asset.root ? '● ' : ''}${candidate.name} (${candidate.type})`,
onSelect: () => this.state.execute(new SetAnimationRootCommand(asset.id, candidate.id)),
}));
if (items.length === 0) items.push({ label: 'Nothing to animate yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.rootPicker);
}
private openRowDialog(): void {
const asset = this.asset();
if (asset) openRowDialog(new RowDialog(this.state, asset));
}
}
+214
View File
@@ -0,0 +1,214 @@
import {
AddAnimationTrackCommand,
RetargetAnimationTrackCommand,
} from '../../core/commands/animationCommands';
import { animatableDefs, animatedKind, rootObjects, targetAssetOf } from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { treeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Popup, Spacer, Theme } from '../../ui';
import type { EditorState } from '../state';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
const PICKER_WIDTH = 240;
let current: RowDialog | null = null;
/** The open row dialog, for the check scripts. */
export function currentRowDialog(): RowDialog | null {
return current;
}
/** Opens the dialog centred, closing whichever one was open before it. */
export function openRowDialog(dialog: RowDialog): RowDialog {
current?.close();
current = dialog;
dialog.openCentered();
return dialog;
}
/** One choice of the object picker: the root itself (`''`) or an object inside it. */
interface TargetChoice {
id: string;
label: string;
}
/**
* Adds a row to the timeline, or aims an existing one elsewhere: first the object – the animated
* asset itself, or one placed inside it when that is a container or scene – then a property of
* it. Retargeting offers only properties of the row's kind, since its keys stay (#97).
*/
export class RowDialog extends Popup {
private target: string;
private property: string;
private readonly targetButton: Dropdown;
private readonly propertyButton: Dropdown;
private readonly summary: Label;
private readonly confirm: Button;
constructor(
private readonly state: EditorState,
private readonly asset: AnimationAsset,
private readonly track: AnimationTrack | null = null,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 360;
this.target = track?.target ?? '';
this.property = track?.property ?? '';
const root = findAsset(state.project, asset.root);
this.content.add(
new Label(track ? 'Change what the row animates' : `Add a row to "${asset.name}"`, {
style: 'bodyBold',
}),
);
this.content.add(new Label(`Animates ${root ? `"${root.name}"` : 'nothing yet'}`, { style: 'muted' }));
this.targetButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openTargets() });
this.content.add(this.pair('Object', this.targetButton));
this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() });
this.content.add(this.pair('Property', this.propertyButton));
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(track ? 'Change' : 'Add row', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => this.apply());
buttons.add(this.confirm);
this.content.add(buttons);
// Without a property yet, the first one the object offers is as good a start as any.
if (!this.property) this.property = this.properties()[0]?.key ?? '';
this.refresh();
}
override close(): void {
super.close();
if (current === this) current = null;
}
/** What the dialog offers and holds, for the check scripts. */
debugInfo(): { target: string; property: string; targets: string[]; properties: string[]; ok: boolean } {
return {
target: this.target,
property: this.property,
targets: this.targets().map((t) => t.label),
properties: this.properties().map((d) => d.label),
ok: this.confirm.enabled,
};
}
/** Picks by id / key rather than by click, for the check scripts. */
pick(target: string, property: string): void {
this.target = target;
this.property = property;
this.refresh();
}
private pair(text: string, control: Dropdown): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
row.add(label);
row.add(control);
row.add(new Spacer(), 1);
return row;
}
private targets(): TargetChoice[] {
const root = findAsset(this.state.project, this.asset.root);
if (!root) return [];
const choices: TargetChoice[] = [{ id: '', label: `${root.name} (itself)` }];
if (root.type === 'container' || root.type === 'scene')
for (const { object, depth } of treeOrder(root))
choices.push({
id: object.id,
label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`,
});
return choices;
}
/** The properties the picked object offers, less those another row already animates. */
private properties(): PropertyDef[] {
const aimed = targetAssetOf(this.state.project, this.asset, this.target);
if (!aimed) return [];
const was = this.trackKind();
return animatableDefs(aimed.type).filter((def) => {
if (was !== null && def.kind !== was) return false;
return !this.asset.tracks.some(
(t) => t.id !== this.track?.id && t.target === this.target && t.property === def.key,
);
});
}
/** The kind the row being changed has; null when its target is gone, so it may go anywhere. */
private trackKind(): string | null {
if (!this.track) return null;
const aimed = targetAssetOf(this.state.project, this.asset, this.track.target);
return aimed ? (animatedKind(aimed.type, this.track.property) ?? null) : null;
}
private openTargets(): void {
const items: MenuItem[] = this.targets().map((choice) => ({
label: choice.id === this.target ? `● ${choice.label}` : choice.label,
onSelect: () => {
this.target = choice.id;
// Another object may not have the property; fall back to its first.
if (!this.properties().some((d) => d.key === this.property))
this.property = this.properties()[0]?.key ?? '';
this.refresh();
},
}));
new Menu(items, {
searchable: rootObjects(findAsset(this.state.project, this.asset.root)).length > 8,
}).openBelow(this.targetButton);
}
private openProperties(): void {
const items: MenuItem[] = this.properties().map((def) => ({
label: def.key === this.property ? `● ${def.label}` : def.label,
onSelect: () => {
this.property = def.key;
this.refresh();
},
}));
if (items.length === 0) items.push({ label: 'Nothing left to animate here', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.propertyButton);
}
private refresh(): void {
const target = this.targets().find((t) => t.id === this.target);
this.targetButton.setText(target ? target.label.trim() : this.target === '' ? '—' : '(missing)');
const def = this.properties().find((d) => d.key === this.property);
this.propertyButton.setText(def ? def.label : '—');
const ok = Boolean(target && def);
this.summary.setText(
!target
? 'Pick the animated asset first.'
: !def
? 'Pick a property.'
: `Keys on this row set ${def.label} (${def.kind}) of ${target.label.trim()}.`,
);
this.confirm.setEnabled(ok);
this.refreshLayout();
}
private apply(): void {
if (this.track) {
this.state.execute(
new RetargetAnimationTrackCommand(this.asset.id, this.track.id, this.target, this.property),
);
return;
}
const command = new AddAnimationTrackCommand(this.asset.id, this.target, this.property);
if (this.state.execute(command))
this.state.select({ kind: 'track', assetId: this.asset.id, trackId: command.trackId, keys: [] });
}
}
+553
View File
@@ -0,0 +1,553 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
type KeyRef,
MoveAnimationKeysCommand,
SetAnimationKeyCommand,
} from '../../core/commands/animationCommands';
import {
animatedKind,
animationPlayheadAt,
frameCount,
targetAssetOf,
trackLabel,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } 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 { type EditorState, trackSelection } from '../state';
import {
clampMove,
clampPan,
DEFAULT_VIEW,
frameAt,
frameCentreX,
frameToX,
hasKey,
HEADER_WIDTH,
KEY_SIZE,
keyAt,
keysWithin,
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
sameKey,
type TimelineView,
zoomCells,
} from './timelineView';
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
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. */
interface KeyDrag {
keys: KeyRef[];
pointer: Vec2;
delta: number;
moved: boolean;
}
/** A band being spanned over the cells, in widget-local pixels. */
interface Band {
start: Vec2;
current: Vec2;
active: boolean;
}
/**
* 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
* keep their size whatever the horizontal zoom.
*/
export class TimelineCanvas extends Widget {
private readonly bg = new Graphics();
private readonly cells = new Graphics();
private readonly overlay = new Graphics();
private readonly labelLayer = new Container();
private readonly rulerLayer = new Container();
private readonly labels = new Map<string, Text>();
private readonly rulerTexts: Text[] = [];
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private view: TimelineView = { ...DEFAULT_VIEW };
private assetId = '';
/** Where the playhead stands, in frames – fractional while playing. */
playhead = 0;
/** Seconds since play began; the playhead is a function of it (see `animationPlayheadAt`). */
private time = 0;
private playingNow = false;
private panning: { pointer: Vec2; panX: number } | null = null;
private dragging: KeyDrag | null = null;
private band: Band | null = null;
private scrubbing = false;
constructor(private readonly state: EditorState) {
super();
this.addChild(this.bg, this.cells, this.overlay, this.labelLayer, this.rulerLayer, this.hint, this.clip);
this.cells.mask = this.clip;
this.overlay.mask = this.clip;
this.rulerLayer.mask = this.clip;
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('load', () => {
this.view = { ...DEFAULT_VIEW };
this.redraw();
});
}
override measure(): Size {
return { w: Math.max(this.minWidth, 300), h: Math.max(this.minHeight, 160) };
}
/** Switching resets view and playhead: the next animation is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.view = { ...DEFAULT_VIEW };
this.stop();
this.redraw();
}
get currentAssetId(): string {
return this.assetId;
}
/** Back to frame 0 at the default zoom. */
fit(): void {
this.view = { ...DEFAULT_VIEW };
this.redraw();
}
get isPlaying(): boolean {
return this.playingNow;
}
/** Runs from where the playhead stands; at the end of a finished pass it starts over. */
play(): void {
const asset = this.asset();
if (!asset) return;
const count = frameCount(asset);
if (count > 0 && this.playhead >= count) this.playhead = 0;
this.time = this.timeOf(asset, this.playhead);
this.playingNow = true;
this.redraw();
}
pause(): void {
this.playingNow = false;
this.redraw();
}
/** Pauses and puts the playhead back to frame 0. */
stop(): void {
this.playingNow = false;
this.playhead = 0;
this.time = 0;
}
/** Moves the playhead `dt` seconds on; a finished `once` pass pauses at the end. */
tick(dt: number): void {
const asset = this.asset();
if (!this.playingNow || !asset) return;
this.time += dt;
const at = animationPlayheadAt(asset, this.time);
this.playhead = at.frame;
if (at.finished) this.playingNow = false;
this.redraw();
}
/** Puts the playhead on a frame – what the ruler does, and what a check does without a pointer. */
scrub(frame: number): void {
this.playhead = Math.max(0, frame);
const asset = this.asset();
if (asset) this.time = this.timeOf(asset, this.playhead);
this.redraw();
}
/** The seconds at which a loop's playhead first reaches `frame` – so play resumes where it was scrubbed to. */
private timeOf(asset: AnimationAsset, frame: number): number {
const fps = Number(asset.props.fps);
return fps > 0 ? frame / fps : 0;
}
// --- for the check scripts -------------------------------------------------------------------
/** Where a cell's centre sits on screen. */
cellGlobal(trackId: string, frame: number): Vec2 | null {
const row = this.rowOf(trackId);
if (row < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + rowCentreY(row) };
}
/** Where a row's header sits on screen. */
headerGlobal(trackId: string): Vec2 | null {
const row = this.rowOf(trackId);
if (row < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row) };
}
/** Where a frame sits on the ruler. */
rulerGlobal(frame: number): Vec2 {
const origin = this.getGlobalPosition();
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + RULER_HEIGHT / 2 };
}
/** The keys as drawn, with whether each is picked. */
keysDrawn(): { trackId: string; frame: number; selected: boolean }[] {
const picked = this.state.selectedKeys;
const out: { trackId: string; frame: number; selected: boolean }[] = [];
for (const track of this.asset()?.tracks ?? [])
for (const key of track.keys)
out.push({
trackId: track.id,
frame: key.frame,
selected: hasKey(picked, { trackId: track.id, frame: key.frame }),
});
return out;
}
/** The row labels as drawn, top to bottom. */
rowsDrawn(): string[] {
const asset = this.asset();
return asset ? asset.tracks.map((t) => trackLabel(this.state.project, asset, t)) : [];
}
viewInfo(): TimelineView & { playhead: number; playing: boolean } {
return { ...this.view, playhead: this.playhead, playing: this.playingNow };
}
// --- pointer ---------------------------------------------------------------------------------
private asset(): AnimationAsset | null {
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'animation') ?? null) : null;
}
private rowOf(trackId: string): number {
return this.asset()?.tracks.findIndex((t) => t.id === trackId) ?? -1;
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
this.panning = { pointer: point, panX: this.view.panX };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
e.stopPropagation();
if (local.x < HEADER_WIDTH) {
const row = rowAt(local.y, asset.tracks.length);
const track = row >= 0 ? asset.tracks[row] : undefined;
if (track) this.state.select({ kind: 'track', assetId: asset.id, trackId: track.id, keys: [] });
return;
}
const cell = { x: local.x - HEADER_WIDTH, y: local.y };
if (local.y < RULER_HEIGHT) {
this.scrubbing = true;
this.pause();
this.scrub(frameAt(this.view, cell.x));
return;
}
const picked = this.state.selectedKeys;
const hit = keyAt(this.view, asset.tracks, cell);
if (hit) {
// 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 };
return;
}
const row = rowAt(cell.y, asset.tracks.length);
const track = row >= 0 ? asset.tracks[row] : undefined;
if (track && pressCount(e, this) === 2) {
this.setKey(asset, track, frameAt(this.view, cell.x));
return;
}
this.band = { start: point, current: point, active: false };
}
/** A double click: a key with the value the row has there, picked so the inspector shows it. */
private setKey(asset: AnimationAsset, track: AnimationTrack, frame: number): void {
const aimed = targetAssetOf(this.state.project, asset, track.target);
if (!aimed || !animatedKind(aimed.type, track.property)) {
this.state.setNotice('This row aims at something that is gone – change its target first.');
return;
}
if (this.state.execute(new SetAnimationKeyCommand(asset.id, track.id, frame)))
this.state.select({
kind: 'track',
assetId: asset.id,
trackId: track.id,
keys: [{ trackId: track.id, frame }],
});
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) });
this.redraw();
return;
}
if (this.scrubbing) {
this.scrub(frameAt(this.view, local.x - HEADER_WIDTH));
return;
}
if (this.dragging) {
const dx = local.x - this.dragging.pointer.x;
if (!this.dragging.moved && Math.abs(dx) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.dragging.delta = clampMove(this.dragging.keys, Math.round(dx / this.view.cellWidth));
this.redraw();
return;
}
if (this.band) {
this.band.current = { x: local.x, y: local.y };
const span = Math.max(Math.abs(local.x - this.band.start.x), Math.abs(local.y - this.band.start.y));
if (!this.band.active && span < DRAG_THRESHOLD) return;
this.band.active = true;
this.redraw();
}
}
private onPointerUp(): void {
this.panning = null;
this.scrubbing = false;
const drag = this.dragging;
const band = this.band;
this.dragging = null;
this.band = null;
const asset = this.asset();
if (!asset) return;
if (drag) {
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)))
this.state.select(trackSelection(asset.id, moved, primary));
}
this.redraw();
return;
}
if (!band) return;
if (!band.active) {
// A click on empty cells clears the pick but keeps the animation selected, like everywhere else.
this.state.select({ kind: 'asset', assetId: asset.id });
this.redraw();
return;
}
const a = { x: band.start.x - HEADER_WIDTH, y: band.start.y };
const b = { x: band.current.x - HEADER_WIDTH, y: band.current.y };
const caught = keysWithin(this.view, asset.tracks, a, b);
this.state.select(trackSelection(asset.id, caught) ?? { kind: 'asset', assetId: asset.id });
this.redraw();
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
if (e.shiftKey) {
this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY });
} else {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
}
this.redraw();
e.preventDefault();
}
// --- drawing ---------------------------------------------------------------------------------
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
this.clip
.clear()
.rect(HEADER_WIDTH, 0, Math.max(0, this.w - HEADER_WIDTH), this.h)
.fill(0xffffff);
this.cells.clear();
this.overlay.clear();
const asset = this.asset();
const tracks = asset?.tracks ?? [];
const selectedRow = this.state.selection?.kind === 'track' ? this.state.selection.trackId : '';
// Header column and ruler chrome.
this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel);
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(HEADER_WIDTH - 1, 0, 1, this.h).fill(c.border);
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
if (asset) {
this.drawRows(asset, tracks, selectedRow);
this.drawRuler(asset);
this.drawKeys(tracks);
this.drawPlayhead();
}
if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current);
this.syncLabels(asset, tracks, selectedRow);
this.syncHint(asset);
}
private drawRows(asset: AnimationAsset, tracks: readonly AnimationTrack[], selectedRow: string): void {
const c = Theme.colors;
const count = frameCount(asset);
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
const x1 = HEADER_WIDTH + frameToX(this.view, count);
tracks.forEach((track, row) => {
const y = rowTop(row);
const selected = track.id === selectedRow;
if (selected) this.bg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
this.cells.rect(HEADER_WIDTH, y, this.w - HEADER_WIDTH, ROW_HEIGHT).fill({
color: 0xffffff,
alpha: row % 2 === 0 ? 0.02 : 0.04,
});
// The frames the animation covers read lighter than the void after the last key.
if (count > 0)
this.cells
.rect(Math.max(HEADER_WIDTH, x0), y, Math.max(0, x1 - Math.max(HEADER_WIDTH, x0)), ROW_HEIGHT)
.fill({ color: 0xffffff, alpha: 0.03 });
this.cells
.rect(HEADER_WIDTH, y + ROW_HEIGHT - 1, this.w - HEADER_WIDTH, 1)
.fill({ color: c.border, alpha: 0.6 });
});
// Cell borders only when they have room to be seen.
if (this.view.cellWidth >= 8 && tracks.length > 0) {
const bottom = rowTop(tracks.length);
const first = frameAt(this.view, 0);
for (let frame = first; ; frame++) {
const x = HEADER_WIDTH + frameToX(this.view, frame);
if (x > this.w) break;
this.cells.rect(x, RULER_HEIGHT, 1, bottom - RULER_HEIGHT).fill({ color: c.border, alpha: 0.35 });
}
}
}
private drawRuler(asset: AnimationAsset): void {
const c = Theme.colors;
const step = rulerStep(this.view);
const count = frameCount(asset);
if (count > 0) {
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
const x1 = HEADER_WIDTH + frameToX(this.view, count);
this.cells.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
}
let used = 0;
const first = frameAt(this.view, 0);
for (let frame = Math.floor(first / step) * step; ; frame += step) {
const x = HEADER_WIDTH + frameToX(this.view, frame);
if (x > this.w) break;
if (x < HEADER_WIDTH) continue;
this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
let text = this.rulerTexts[used];
if (!text) {
text = new Text({ style: textStyle('small') });
this.rulerTexts.push(text);
this.rulerLayer.addChild(text);
}
text.visible = true;
if (text.text !== String(frame)) text.text = String(frame);
text.position.set(x + 3, 3);
used += 1;
}
for (let i = used; i < this.rulerTexts.length; i++) this.rulerTexts[i]!.visible = false;
}
private drawKeys(tracks: readonly AnimationTrack[]): void {
const c = Theme.colors;
const picked = this.state.selectedKeys;
const drag = this.dragging?.moved ? this.dragging : null;
const half = KEY_SIZE / 2;
tracks.forEach((track, row) => {
const y = rowCentreY(row);
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;
this.overlay
.moveTo(x, y - half)
.lineTo(x + half, y)
.lineTo(x, y + half)
.lineTo(x - half, y)
.closePath()
.fill(selected ? c.accent : c.text)
.stroke({ width: 1, color: selected ? c.accentText : c.viewport });
}
});
}
private drawPlayhead(): void {
const x = HEADER_WIDTH + frameCentreX(this.view, this.playhead);
if (x < HEADER_WIDTH || x > this.w) return;
this.overlay.rect(x - 0.5, 0, 1, this.h).fill(Theme.colors.hot);
this.overlay
.moveTo(x - 5, 0)
.lineTo(x + 5, 0)
.lineTo(x, 7)
.closePath()
.fill(Theme.colors.hot);
}
private syncLabels(
asset: AnimationAsset | null,
tracks: readonly AnimationTrack[],
selectedRow: string,
): void {
const alive = new Set<string>();
tracks.forEach((track, row) => {
alive.add(track.id);
let label = this.labels.get(track.id);
if (!label) {
label = new Text({ style: textStyle('body') });
this.labels.set(track.id, label);
this.labelLayer.addChild(label);
}
const missing = !asset || !targetAssetOf(this.state.project, asset, track.target);
const text = asset ? trackLabel(this.state.project, asset, track) : '';
if (label.text !== text) label.text = text;
label.style = textStyle(missing ? 'warning' : track.id === selectedRow ? 'accent' : 'body');
label.position.set(LABEL_INSET, rowTop(row) + (ROW_HEIGHT - label.height) / 2);
label.visible = rowTop(row) + ROW_HEIGHT <= this.h;
});
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
}
private syncHint(asset: AnimationAsset | null): void {
const text = !asset
? 'No animation selected – pick one in the Assets panel or create one.'
: !asset.root
? 'Pick what this animation animates in the header.'
: asset.tracks.length === 0
? 'Add a row with "+ Row", then double-click a cell to set a key.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(HEADER_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { KeyRef } from '../../core/commands/animationCommands';
import type { AnimationTrack } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui';
/**
* The timeline's geometry, apart from Pixi so a test can drive it: a column of row headers on the
* left, a frame ruler on top, and a grid of cells below it – one cell per row and frame. The view
* pans and zooms horizontally only; rows have a fixed height and are never scrolled away.
*/
export interface TimelineView {
/** Screen pixels the cell of frame 0 starts at, measured from the left edge of the cell area. */
panX: number;
/** Width of one frame's cell in screen pixels. */
cellWidth: number;
}
/** Width of the row header column, in pixels. */
export const HEADER_WIDTH = 220;
/** Height of the frame ruler above the rows. */
export const RULER_HEIGHT = 22;
export const ROW_HEIGHT = 26;
export const MIN_CELL_WIDTH = 4;
export const MAX_CELL_WIDTH = 48;
export const DEFAULT_CELL_WIDTH = 16;
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
export const KEY_SIZE = 10;
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
const RULER_LABEL_MIN_PX = 40;
export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH };
/** The x of the left edge of `frame`'s cell, in cell-area pixels. */
export function frameToX(view: TimelineView, frame: number): number {
return view.panX + frame * view.cellWidth;
}
/** The centre of `frame`'s cell – where its key is drawn. */
export function frameCentreX(view: TimelineView, frame: number): number {
return frameToX(view, frame) + view.cellWidth / 2;
}
/** The frame whose cell holds `x`, fractional; -1 and below for a point left of frame 0. */
export function xToFrame(view: TimelineView, x: number): number {
return (x - view.panX) / view.cellWidth;
}
/** The whole frame under `x`, never below 0. */
export function frameAt(view: TimelineView, x: number): number {
return Math.max(0, Math.floor(xToFrame(view, x)));
}
/** The row under `y` (measured from the top of the ruler), or -1 above the rows and past the last one. */
export function rowAt(y: number, rowCount: number): number {
if (y < RULER_HEIGHT) return -1;
const row = Math.floor((y - RULER_HEIGHT) / ROW_HEIGHT);
return row < rowCount ? row : -1;
}
export function rowTop(row: number): number {
return RULER_HEIGHT + row * ROW_HEIGHT;
}
export function rowCentreY(row: number): number {
return rowTop(row) + ROW_HEIGHT / 2;
}
/** Zooms the cells about `x` (cell-area pixels), so the frame under the pointer stays under it. */
export function zoomCells(view: TimelineView, factor: number, x: number): TimelineView {
const cellWidth = clamp(view.cellWidth * factor, MIN_CELL_WIDTH, MAX_CELL_WIDTH);
const frame = xToFrame(view, x);
return { panX: x - frame * cellWidth, cellWidth };
}
/**
* Keeps frame 0 from drifting off to the right: the timeline starts at 0, and empty space left
* of it is nothing to look at. The left may still be panned past when the animation is long.
*/
export function clampPan(view: TimelineView): TimelineView {
return view.panX > 0 ? { ...view, panX: 0 } : view;
}
/** How many frames the cell area shows at its width. */
export function visibleFrames(view: TimelineView, width: number): number {
return Math.ceil(width / view.cellWidth);
}
/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */
export function rulerStep(view: TimelineView): number {
const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
return steps.find((step) => step * view.cellWidth >= RULER_LABEL_MIN_PX) ?? 1000;
}
/**
* The key under a point, tested against the diamond drawn at the cell's centre rather than the
* whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing.
*/
export function keyAt(view: TimelineView, tracks: readonly AnimationTrack[], point: Vec2): KeyRef | null {
const row = rowAt(point.y, tracks.length);
if (row < 0) return null;
const track = tracks[row]!;
const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2);
for (const key of track.keys) {
if (Math.abs(frameCentreX(view, key.frame) - point.x) <= reach)
return { trackId: track.id, frame: key.frame };
}
return null;
}
/** The keys whose diamond centre lies inside the band spanned between two points. */
export function keysWithin(
view: TimelineView,
tracks: readonly AnimationTrack[],
a: Vec2,
b: Vec2,
): KeyRef[] {
const left = Math.min(a.x, b.x);
const right = Math.max(a.x, b.x);
const top = Math.min(a.y, b.y);
const bottom = Math.max(a.y, b.y);
const out: KeyRef[] = [];
tracks.forEach((track, row) => {
const y = rowCentreY(row);
if (y < top || y > bottom) return;
for (const key of track.keys) {
const x = frameCentreX(view, key.frame);
if (x >= left && x <= right) out.push({ trackId: track.id, frame: key.frame });
}
});
return out;
}
export function sameKey(a: KeyRef, b: KeyRef): boolean {
return a.trackId === b.trackId && a.frame === b.frame;
}
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
return keys.some((k) => sameKey(k, key));
}
/**
* The frames a drag may move the picked keys by: never before frame 0. `delta` is what the pointer
* asked for, in whole frames.
*/
export function clampMove(keys: readonly KeyRef[], delta: number): number {
const first = Math.min(...keys.map((k) => k.frame));
return Number.isFinite(first) ? Math.max(delta, -first) : 0;
}
+18
View File
@@ -19,6 +19,7 @@ import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { currentRowDialog } from './animation/RowDialog';
import type { CentreTab, Editor } from './Editor';
import { currentFontPicker } from './font/FontPicker';
import { currentNodePalette } from './graph/NodePalette';
@@ -463,6 +464,23 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
animation: {
current: () => editor.animationEditor.currentAssetId,
info: () => editor.animationEditor.shownInfo,
rootPickerAt: () => centreOf(editor.animationEditor.rootPicker),
addRowAt: () => centreOf(editor.animationEditor.addRowButton),
playAt: () => ({
...centreOf(editor.animationEditor.playButton),
text: editor.animationEditor.playButton.text,
}),
/** Where a cell, a row's header and a frame on the ruler sit – no text names a cell. */
cellAt: (trackId: string, frame: number) => editor.animationEditor.canvas.cellGlobal(trackId, frame),
headerAt: (trackId: string) => editor.animationEditor.canvas.headerGlobal(trackId),
rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame),
keys: () => editor.animationEditor.canvas.keysDrawn(),
rows: () => editor.animationEditor.canvas.rowsDrawn(),
view: () => editor.animationEditor.canvas.viewInfo(),
scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame),
/** 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),
},
container: {
current: () => containerEditor.currentAssetId,
+6 -1
View File
@@ -12,6 +12,7 @@ import {
objectSignature,
refreshObjectSection,
} from './objectSection';
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
/**
* Shows the properties of the current selection. The rows are generated from the PropertySchema
@@ -90,6 +91,7 @@ export class Inspector extends Panel {
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -102,6 +104,7 @@ export class Inspector extends Panel {
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
@@ -129,7 +132,9 @@ export class Inspector extends Panel {
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
: s.kind === 'track'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+152
View File
@@ -0,0 +1,152 @@
import {
RemoveAnimationKeysCommand,
RemoveAnimationTracksCommand,
SetAnimationKeyCommand,
SetTrackRelativeCommand,
} from '../../core/commands/animationCommands';
import { animatableDefs, targetAssetOf, trackLabel } from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
import { Button } from '../../ui';
import { openRowDialog, RowDialog } from '../animation/RowDialog';
import { createFieldRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
/**
* Structure-defining facts of a track selection: which row, how many keys and of what kind – a
* key's value field is built for its kind, and a colour key needs another widget than a number.
*/
export function trackSignature(
project: Project,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
): string {
const asset = findAssetOf(project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
if (!asset || !track) return 'missing';
const kind = kindOf(project, asset, track) ?? 'missing';
const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length);
return `${track.id}|${track.target}|${track.property}|${kind}|${track.relative ? 'rel' : 'abs'}|${key}`;
}
/**
* A row of the timeline: what it aims at, whether it is relative, and – with one key picked – the
* key's value. Several keys show their count and the way to delete them at once.
*/
export function buildTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
): void {
const asset = findAssetOf(host.state.project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
if (!asset || !track) return;
const kind = kindOf(host.state.project, asset, track);
addTitle(host, 'TIMELINE ROW');
const label = createFieldRow(
host,
{ key: 'row', label: 'Row', kind: 'text', default: '' },
trackLabel(host.state.project, asset, track),
{ onInput: () => {}, onChange: () => {} },
);
host.body.add(label.row);
if (!kind)
addNote(host, 'The object or property this row aims at is gone; it is skipped when played.', 'warning');
else addNote(host, `A ${kind} row of "${asset.name}".`);
host.body.add(
new Button('Change target…', {
onClick: () => {
const current = findAssetOf(host.state.project, assetId, 'animation');
const row = current?.tracks.find((t) => t.id === trackId);
if (current && row) openRowDialog(new RowDialog(host.state, current, row));
},
}),
);
if (kind === 'number') {
const relative = createFieldRow(
host,
{ key: 'relative', label: 'Relative', kind: 'boolean', default: false },
Boolean(track.relative),
{
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetTrackRelativeCommand(assetId, trackId, Boolean(value))),
},
);
host.fields.set('relative', relative);
host.body.add(relative.row);
addNote(host, 'Relative: the keys add to where the target stood when the animation started.', 'small');
}
if (keys.length === 1) buildKey(host, asset, keys[0]!);
else if (keys.length > 1) {
addGap(host);
addTitle(host, `${keys.length} KEYS`);
host.body.add(
new Button('Delete keys', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveAnimationKeysCommand(assetId, [...keys])),
}),
);
}
addGap(host);
host.body.add(
new Button('Delete row', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveAnimationTracksCommand(assetId, [trackId])),
}),
);
}
/** The value of the one picked key, editable in the widget its kind calls for. */
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: string; frame: number }): void {
const track = asset.tracks.find((t) => t.id === ref.trackId);
const key = track?.keys.find((k) => k.frame === ref.frame);
const def = track && defOf(host.state.project, asset, track);
if (!track || !key || !def) return;
addGap(host);
addTitle(host, `KEY AT FRAME ${ref.frame}`);
const row = createFieldRow(host, { ...def, key: 'value', label: 'Value' }, key.value, {
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetAnimationKeyCommand(asset.id, ref.trackId, ref.frame, value)),
});
host.fields.set('value', row);
host.body.add(row.row);
if (track.relative) addNote(host, 'An offset, since the row is relative.', 'small');
}
/** Pushes the current values into the rows. False when the row is gone (rebuild). */
export function refreshTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
): boolean {
const asset = findAssetOf(host.state.project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
if (!asset || !track) return false;
host.fields.get('relative')?.setValue(Boolean(track.relative));
const ref = keys.length === 1 ? keys[0] : undefined;
if (ref) {
const key = asset.tracks.find((t) => t.id === ref.trackId)?.keys.find((k) => k.frame === ref.frame);
if (!key) return false;
host.fields.get('value')?.setValue(key.value);
}
return true;
}
function defOf(project: Project, asset: AnimationAsset, track: AnimationTrack) {
const aimed = targetAssetOf(project, asset, track.target);
return aimed ? animatableDefs(aimed.type).find((d) => d.key === track.property) : undefined;
}
function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined {
return defOf(project, asset, track)?.kind;
}
+8
View File
@@ -1,3 +1,4 @@
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions';
@@ -41,6 +42,13 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
else if (s?.kind === 'node') removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds);
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'annotation') removeGraphSelection(state, s.assetId, [], s.annotationIds);
// The keys when some are picked, else the row itself – like a frame of a texture.
else if (s?.kind === 'track')
state.execute(
s.keys.length > 0
? new RemoveAnimationKeysCommand(s.assetId, s.keys)
: new RemoveAnimationTracksCommand(s.assetId, [s.trackId]),
);
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
+57 -1
View File
@@ -1,3 +1,4 @@
import type { 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';
@@ -37,6 +38,12 @@ export type Selection =
| { kind: 'frame'; assetId: string; frameId: string }
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] }
/**
* A row of an animation's timeline (the Animation tab), and the keys picked on it or on other
* rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector
* describes – of several keys, the one picked last.
*/
| { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] }
| null;
/**
@@ -113,6 +120,19 @@ export function graphSelection(
);
}
/**
* Builds a track selection. Without keys it is the row itself; with keys the row is the one of the
* key picked last, unless `trackId` says otherwise. Duplicate keys collapse to one.
*/
export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection {
const unique: KeyRef[] = [];
for (const key of keys)
if (!unique.some((k) => k.trackId === key.trackId && k.frame === key.frame)) unique.push(key);
const last = unique[unique.length - 1];
const row = trackId ?? last?.trackId;
return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null;
}
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
const ids = [...new Set(input)];
if (ids.length === 0) return null;
@@ -377,6 +397,10 @@ export class EditorState {
selection.annotationIds ?? [selection.annotationId],
selection.annotationId,
);
// The short form `{ trackId }` names a row without keys.
if (selection?.kind === 'track')
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -464,6 +488,24 @@ export class EditorState {
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'track') {
const asset = findAssetOf(this.project, s.assetId, 'animation');
if (!asset) this.select(null);
else {
// A key that moved or died leaves the selection; a row that died takes its keys with it.
const alive = s.keys.filter((key) =>
asset.tracks.some((t) => t.id === key.trackId && t.keys.some((k) => k.frame === key.frame)),
);
const rowAlive = asset.tracks.some((t) => t.id === s.trackId);
if (alive.length !== s.keys.length || !rowAlive)
this.select(
trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? {
kind: 'asset',
assetId: s.assetId,
},
);
}
}
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
@@ -474,9 +516,16 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
}
/** The animation the selection refers to: the asset, or a row or key of its timeline. */
get selectedAnimationId(): string | null {
const s = this.selection;
return s?.kind === 'asset' ? (findAssetOf(this.project, s.assetId, 'animation')?.id ?? null) : null;
if (s?.kind !== 'asset' && s?.kind !== 'track') return null;
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
}
/** The keys picked in the Animation tab, none when the selection is about something else. */
get selectedKeys(): KeyRef[] {
return this.selection?.kind === 'track' ? this.selection.keys : [];
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
@@ -571,6 +620,13 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'track' && b.kind === 'track')
return (
a.assetId === b.assetId &&
a.trackId === b.trackId &&
a.keys.length === b.keys.length &&
a.keys.every((key, i) => key.trackId === b.keys[i]!.trackId && key.frame === b.keys[i]!.frame)
);
if (a.kind === 'annotation' && b.kind === 'annotation')
return (
a.assetId === b.assetId &&
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import type { AnimationTrack } from '../../src/core/model/Asset';
import {
clampMove,
clampPan,
DEFAULT_VIEW,
frameAt,
frameCentreX,
frameToX,
keyAt,
keysWithin,
MAX_CELL_WIDTH,
MIN_CELL_WIDTH,
ROW_HEIGHT,
rowAt,
rowCentreY,
RULER_HEIGHT,
rulerStep,
type TimelineView,
xToFrame,
zoomCells,
} from '../../src/editor/animation/timelineView';
const TRACKS: AnimationTrack[] = [
{
id: 'a',
target: '',
property: 'x',
keys: [
{ frame: 0, value: 0 },
{ frame: 4, value: 40 },
],
},
{ id: 'b', target: '', property: 'y', keys: [{ frame: 2, value: 0 }] },
];
describe('timeline geometry', () => {
it('maps frames to cells and back', () => {
const view: TimelineView = { panX: 10, cellWidth: 20 };
expect(frameToX(view, 0)).toBe(10);
expect(frameToX(view, 3)).toBe(70);
expect(frameCentreX(view, 3)).toBe(80);
expect(xToFrame(view, 80)).toBe(3.5);
expect(frameAt(view, 89)).toBe(3);
expect(frameAt(view, 90)).toBe(4);
});
it('never answers a frame below 0', () => {
expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0);
});
it('finds rows below the ruler and nothing beyond the last', () => {
expect(rowAt(RULER_HEIGHT - 1, 2)).toBe(-1);
expect(rowAt(RULER_HEIGHT, 2)).toBe(0);
expect(rowAt(RULER_HEIGHT + ROW_HEIGHT, 2)).toBe(1);
expect(rowAt(RULER_HEIGHT + 2 * ROW_HEIGHT, 2)).toBe(-1);
});
it('zooms about the pointer, so the frame under it stays put', () => {
const view: TimelineView = { panX: -100, cellWidth: 16 };
const x = 200;
const before = xToFrame(view, x);
const zoomed = zoomCells(view, 1.5, x);
expect(zoomed.cellWidth).toBe(24);
expect(xToFrame(zoomed, x)).toBeCloseTo(before);
});
it('keeps the cell width within its bounds', () => {
expect(zoomCells(DEFAULT_VIEW, 100, 0).cellWidth).toBe(MAX_CELL_WIDTH);
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
});
it('does not let frame 0 drift right of the left edge', () => {
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
});
it('labels the ruler with a step that keeps the numbers apart', () => {
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
});
});
describe('picking keys', () => {
const view: TimelineView = { panX: 0, cellWidth: 16 };
it('hits the key drawn at the centre of its cell', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({
trackId: 'a',
frame: 4,
});
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({
trackId: 'b',
frame: 2,
});
});
it('misses an empty cell and the ruler', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull();
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull();
});
it('a band catches every key whose diamond centre it spans, across rows', () => {
const a = { x: frameToX(view, 1), y: RULER_HEIGHT };
const b = { x: frameToX(view, 5), y: RULER_HEIGHT + 2 * ROW_HEIGHT };
expect(keysWithin(view, TRACKS, a, b)).toEqual([
{ trackId: 'a', frame: 4 },
{ trackId: 'b', frame: 2 },
]);
// Spanned the other way round it is the same band.
expect(keysWithin(view, TRACKS, b, a)).toHaveLength(2);
});
it('a band over one row leaves the other alone', () => {
const a = { x: frameToX(view, 0), y: RULER_HEIGHT };
const b = { x: frameToX(view, 10), y: RULER_HEIGHT + ROW_HEIGHT - 1 };
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },
{ trackId: 'b', frame: 5 },
];
expect(clampMove(keys, -5)).toBe(-2);
expect(clampMove(keys, 3)).toBe(3);
expect(clampMove([], -3)).toBe(0);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
MoveAnimationKeysCommand,
RemoveAnimationKeysCommand,
RemoveAnimationTracksCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
} from '../../src/core/commands/animationCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { AnimationAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { EditorState, trackSelection } from '../../src/editor/state';
/** A sprite, an animation over it, one row on `x` with keys on 0 and 4, and a second row on `y`. */
function setup(): { state: EditorState; animation: AnimationAsset; x: string; y: string } {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const sprite = new CreateAssetCommand('sprite', 'Dancer');
expect(state.execute(sprite)).toBe(true);
const create = new CreateAssetCommand('animation', 'Walk');
expect(state.execute(create)).toBe(true);
const animation = create.asset as AnimationAsset;
expect(state.execute(new SetAnimationRootCommand(animation.id, sprite.asset.id))).toBe(true);
const x = new AddAnimationTrackCommand(animation.id, '', 'x');
const y = new AddAnimationTrackCommand(animation.id, '', 'y');
expect(state.execute(x)).toBe(true);
expect(state.execute(y)).toBe(true);
expect(state.execute(new SetAnimationKeyCommand(animation.id, x.trackId, 0, 0))).toBe(true);
expect(state.execute(new SetAnimationKeyCommand(animation.id, x.trackId, 4, 40))).toBe(true);
return { state, animation, x: x.trackId, y: y.trackId };
}
describe('selecting rows and keys of a timeline', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => {
f = setup();
});
it('a row alone is a track selection without keys, and keeps the animation on the tab', () => {
f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] });
expect(f.state.selectedKeys).toEqual([]);
expect(f.state.selectedAnimationId).toBe(f.animation.id);
});
it('the row of the key picked last is the one the inspector describes', () => {
const keys = [
{ trackId: f.x, frame: 0 },
{ trackId: f.x, frame: 4 },
];
f.state.select(trackSelection(f.animation.id, keys));
expect(f.state.selection).toMatchObject({ kind: 'track', trackId: f.x, keys });
// The same key twice is one key.
f.state.select(trackSelection(f.animation.id, [...keys, keys[0]!]));
expect(f.state.selectedKeys).toHaveLength(2);
});
it('accepts the short form a test hook passes, without keys', () => {
f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.y } as never);
expect(f.state.selection).toEqual({ kind: 'track', assetId: f.animation.id, trackId: f.y, keys: [] });
});
it('drops a key that was deleted and keeps the rest', () => {
f.state.select(
trackSelection(f.animation.id, [
{ trackId: f.x, frame: 0 },
{ trackId: f.x, frame: 4 },
]),
);
f.state.execute(new RemoveAnimationKeysCommand(f.animation.id, [{ trackId: f.x, frame: 4 }]));
expect(f.state.selectedKeys).toEqual([{ trackId: f.x, frame: 0 }]);
});
it('a moved key leaves the selection – the frame it was picked at holds no key any more', () => {
f.state.select(trackSelection(f.animation.id, [{ trackId: f.x, frame: 4 }]));
f.state.execute(new MoveAnimationKeysCommand(f.animation.id, [{ trackId: f.x, frame: 4 }], 2));
// The canvas re-selects at the new frame after a drag; the state alone falls back to the row.
expect(f.state.selection).toEqual({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] });
});
it('falls back to the animation when the row itself is deleted, so the tab stays open', () => {
f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] });
f.state.execute(new RemoveAnimationTracksCommand(f.animation.id, [f.x]));
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.animation.id });
});
it('comes back with undo: the row returns, the selection was already elsewhere', () => {
f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] });
f.state.execute(new RemoveAnimationTracksCommand(f.animation.id, [f.x]));
f.state.undo();
const rows = (f.state.project.assets.find((a) => a.id === f.animation.id) as AnimationAsset).tracks;
expect(rows.map((t) => t.id)).toEqual([f.x, f.y]);
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.animation.id });
});
});