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 { Button, Dropdown, formatNumber, Menu, type MenuItem, VStack, type Widget } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; import { PreviewCanvas } from './PreviewCanvas'; 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; /** * The Animation tab (#97): the header picks what the animation animates and adds rows, below it * the preview shows the root at the playhead (#99) and the timeline under that edits the rows. * `fps` and `playback` are properties of the asset, edited in the inspector like any other. */ export class AnimationEditor extends AssetTabEditor { /** Public for the check scripts. */ readonly rootPicker: Dropdown; readonly addRowButton: Button; readonly playButton: Button; readonly preview: PreviewCanvas; constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); 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() }); const timeline = new TimelineCanvas(state); this.preview = new PreviewCanvas(state, () => ({ frame: timeline.playhead, reverse: timeline.playheadReverse, })); timeline.onPlayhead = () => this.preview.redraw(); this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton]); } /** Fit frames the preview; the timeline keeps its own zoom and scroll. */ protected override fit(): void { this.preview.fit(); } protected override saveView(): unknown { return { timeline: this.canvas.saveView(), preview: this.preview.saveView() }; } protected override restoreView(view: unknown): void { const saved = view as { timeline: unknown; preview: unknown }; this.canvas.restoreView(saved.timeline); // Pointed at the document first: `syncExtras` would do it later and fit it over the restored view. this.preview.show(this.currentAssetId); this.preview.restoreView(saved.preview); } /** The preview above, the timeline below, each taking half. */ protected override body(canvas: TimelineCanvas): Widget { const stack = new VStack(); stack.add(this.preview, 1); stack.add(canvas, 1); return stack; } /** Only called while the tab is visible. */ tick(dt: number): void { const drawn = this.preview.redrawCount; if (this.canvas.isPlaying) { this.canvas.tick(dt); // A finished `once` pass pauses the canvas; the button has to say so. this.syncPlayButton(); } // A video moves by itself, and a driven one (#404) has to be told every frame that the // playhead stands; the playhead redraws only when it moves. this.preview.animate(drawn); } /** Whether the preview is running – a moving picture needs a frame without an event. */ get isPlaying(): boolean { return this.canvas.isPlaying; } override selectedId(): string { return this.state.selectedAnimationId ?? ''; } override isMine(asset: Asset): asset is AnimationAsset { return asset.type === 'animation'; } protected override infoText(asset: AnimationAsset): string { const rows = asset.tracks.length; const frames = Math.max(0, frameCount(asset)); const seconds = formatNumber(animationDuration(asset), 1); 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.preview.show(asset?.id ?? ''); this.fitButton.setEnabled(Boolean(root)); 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.name} (${candidate.type})`, current: candidate.id === asset.root, 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)); } }