Group timeline rows by object and edit values in the row (#408)

The rows of the animation timeline stood in the order they were keyed.
With several objects and properties the list was hard to read, and a
value could only be changed by picking a key and using the inspector.
The Play button also kept saying Pause after scrubbing had paused.

- Each object gets a header row with its name, indented by depth. The
  groups follow the scene hierarchy, the root first. Rows whose object
  is missing go to the end.
- Under an object the rows follow the order in which it lists its
  properties: X, Y, Z, then the order of the inspector. Only the view
  is sorted, the animation itself is unchanged and there is no undo
  step. After a move in the hierarchy the order is right by itself.
- The row head shows the same field as the inspector with the value at
  the playhead. Without keys it shows the value of the object, for a
  relative row the offset.
- A change in that field sets or changes the key at the playhead,
  selects it and is one undo step. A running playback pauses first.
- The Play/Pause button follows every change of the playing state, so
  scrubbing that pauses shows Play again.

Tests:
- Unit animation + timeline-view: 100 passed, 3 new.
- e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups,
  order and values at frame 4, a typed 55 becomes a selected key on
  frame 4, Play then a click on the ruler shows Play and pauses).
- Gate on melektaus-atx: exit 0, 297 files, 4561 tests.
- tsc, eslint, Prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 17:42:10 +02:00
1 parent dd60a795e1
commit 8679dfbe65
9 files changed
+449 -69

No files matched your search

+13
View File
@@ -14,6 +14,7 @@ import {
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
type TimelineView,
@@ -125,6 +126,18 @@ describe('picking keys', () => {
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
});
it("skips an object's head between the rows (#408)", () => {
const lanes = [null, TRACKS[0]!, null, TRACKS[1]!];
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toBeNull();
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual({
trackId: 'a',
frame: 4,
});
expect(keyAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })?.trackId).toBe('b');
const all = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(4) });
expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },