import { AddAnimationTrackCommand, RetargetAnimationTrackCommand, } from '../../core/commands/animationCommands'; import { animationTargets, targetDefs, trackDef } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { dialogSlot, FormDialog } from '../FormDialog'; import { type EditorState, trackSelection } from '../state'; const PICKER_WIDTH = 240; const slot = dialogSlot(); /** The open row dialog, for the check scripts. */ export const currentRowDialog = slot.current; /** Opens the dialog centred, closing whichever one was open before it. */ export const openRowDialog = slot.open; /** 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 FormDialog { 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(360, track ? 'Change what the row animates' : `Add a row to "${asset.name}"`); this.target = track?.target ?? ''; this.property = track?.property ?? ''; const root = findAsset(state.project, asset.root); 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.formRow(this.fieldLabel('Object'), this.targetButton)); this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() }); this.content.add(this.formRow(this.fieldLabel('Property'), this.propertyButton)); this.summary = new Label('', { style: 'muted' }); this.content.add(this.summary); this.confirm = this.addButtons(track ? 'Change' : 'Add row', 'primary', () => this.apply()); // 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(); } /** 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 targets(): TargetChoice[] { const root = findAsset(this.state.project, this.asset.root); if (!root) return []; const choices: TargetChoice[] = [{ id: '', label: `${root.name} (itself)` }]; for (const { id, object, depth } of animationTargets(this.state.project, root)) choices.push({ 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 defs = targetDefs(this.state.project, this.asset, this.target); if (!defs) return []; const was = this.trackKind(); return defs.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 or property is gone, so it may go anywhere. */ private trackKind(): string | null { return this.track ? (trackDef(this.state.project, this.asset, this.track)?.kind ?? null) : null; } private openTargets(): void { const items: MenuItem[] = this.targets().map((choice) => ({ label: choice.label, current: choice.id === this.target, 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: items.length > 9 }).openBelow(this.targetButton); } private openProperties(): void { const items: MenuItem[] = this.properties().map((def) => ({ label: def.label, current: def.key === this.property, 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(trackSelection(this.asset.id, [], command.trackId)); } }