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:
1 parent
193378e6a9
commit
5af0a1c83a
12 files changed
+1462
-42
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +132,8 @@ export class Inspector extends Panel {
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user