The context menu of the timeline is where the other row and key actions live. A separate button in the inspector was easy to miss and sat apart from them. - The timeline menu gets "Delete row" as a danger entry after a separator. It deletes the row under the pointer, whether the click hits the row header or a cell. Below the last row or on the ruler it is disabled. - A right-click on a row header now also selects that row, like a right-click on a key selects the key. A right-click in an empty cell keeps the selection so chosen keys stay available for Copy. - The "Delete row" button in the "Timeline row" inspector is gone. "Delete keys" for a multi-key selection stays. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 25: ALL PASSED, 30 checks. New check in 25g deletes a row via a real right-click and restores it with all three keys via Undo. - No counter-probe. No check covers the right-click into a cell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
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;
|
||
}
|