Files
feuerwerk/src/editor/inspector/trackSection.ts
T
adminandClaude Opus 5.5 89b95aa8ac Move "Delete row" from the inspector into the timeline menu
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>
2026-09-26 22:43:36 +02:00

244 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}