import { RemoveAnimationKeysCommand, SetAnimationKeyCommand, SetKeyInterpolationCommand, SetTrackRelativeCommand, } from '../../core/commands/animationCommands'; import { blends, EASING_DIRECTIONS, EASING_SHAPES, easingOf, type EasingShape, easingValue, findKey, findTrack, interpolationOf, type KeyRef, trackDef, trackLabel, } from '../../core/model/animation'; import type { AnimationAsset, AnimationKey, AnimationTrack, EasingDirection, KeyInterpolation, } 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 KeyRef[], ): string { const { asset, track } = rowOf(project, assetId, 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 KeyRef[], ): void { const { asset, track } = rowOf(host.state.project, assetId, 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 now = rowOf(host.state.project, assetId, trackId); if (now.asset && now.track) openRowDialog(new RowDialog(host.state, now.asset, now.track)); }, }), ); 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`); buildEasing(host, asset, keys); host.body.add( new Button('Delete keys', { variant: 'danger', onClick: () => host.state.execute(new RemoveAnimationKeysCommand(assetId, [...keys])), }), ); } } /** The value of the one picked key, editable in the widget its kind calls for. */ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void { const track = findTrack(asset, ref.trackId); const key = findKey(asset, ref); const def = track && trackDef(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'); buildEasing(host, asset, [ref]); } /** * How the picked keys blend into their next key – for all of them at once: a curve and, for one that * is not linear or step, its direction. It shows the easing of the key picked last among those that * can blend; a row that cannot blend offers none, it always jumps. */ function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void { const shown = easingKey(host.state.project, asset, keys); if (!shown) return; const easing = easingOf(interpolationOf(shown)); // Read at the click, not now: the other field may have changed the key since this row was built. const current = () => { const key = easingKey(host.state.project, asset, keys); return easingOf(key ? interpolationOf(key) : 'linear'); }; const set = (value: KeyInterpolation) => host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value)); const shape = createFieldRow( host, { key: 'easing', label: 'Easing', kind: 'enum', default: 'linear', options: EASING_SHAPES.map((i) => ({ ...i })), }, easing.shape, { onInput: () => {}, onChange: (value) => set(easingValue(value as EasingShape, current().direction)), }, ); host.fields.set('easing', shape); host.body.add(shape.row); if (easing.direction) { const direction = createFieldRow( host, { key: 'direction', label: 'Direction', kind: 'enum', default: 'in-out', options: EASING_DIRECTIONS.map((i) => ({ ...i })), }, easing.direction, { onInput: () => {}, onChange: (value) => set(easingValue(current().shape, value as EasingDirection)), }, ); host.fields.set('direction', direction); host.body.add(direction.row); } addNote(host, 'Easing: the way from this key to the next one.', 'small'); } /** The last picked key on a row that blends; undefined when none of them can ease. */ function easingKey( project: Project, asset: AnimationAsset, keys: readonly KeyRef[], ): AnimationKey | undefined { for (const ref of [...keys].reverse()) { const track = findTrack(asset, ref.trackId); const kind = track && kindOf(project, asset, track); if (!kind || !blends(kind)) continue; const key = findKey(asset, ref); if (key) return key; } return undefined; } /** 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 KeyRef[], ): boolean { const { asset, track } = rowOf(host.state.project, assetId, 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 = findKey(asset, ref); if (!key) return false; host.fields.get('value')?.setValue(key.value); } const eased = easingKey(host.state.project, asset, keys); if (Boolean(eased) !== host.fields.has('easing')) return false; if (!eased) return true; const easing = easingOf(interpolationOf(eased)); if (Boolean(easing.direction) !== host.fields.has('direction')) return false; host.fields.get('easing')?.setValue(easing.shape); if (easing.direction) host.fields.get('direction')?.setValue(easing.direction); return true; } function rowOf( project: Project, assetId: string, trackId: string, ): { asset?: AnimationAsset; track?: AnimationTrack } { const asset = findAssetOf(project, assetId, 'animation'); return { asset, track: asset && findTrack(asset, trackId) }; } function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined { return trackDef(project, asset, track)?.kind; }