An animation's rows are edited on a timeline: double click sets a key, keys are banded, dragged and deleted, the ruler scrubs (#98)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 20:38:34 +02:00
1 parent 193378e6a9
commit 5af0a1c83a
12 files changed
+1463 -43

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import type { AnimationTrack } from '../../src/core/model/Asset';
import {
clampMove,
clampPan,
DEFAULT_VIEW,
frameAt,
frameCentreX,
frameToX,
keyAt,
keysWithin,
MAX_CELL_WIDTH,
MIN_CELL_WIDTH,
ROW_HEIGHT,
rowAt,
rowCentreY,
RULER_HEIGHT,
rulerStep,
type TimelineView,
xToFrame,
zoomCells,
} from '../../src/editor/animation/timelineView';
const TRACKS: AnimationTrack[] = [
{
id: 'a',
target: '',
property: 'x',
keys: [
{ frame: 0, value: 0 },
{ frame: 4, value: 40 },
],
},
{ id: 'b', target: '', property: 'y', keys: [{ frame: 2, value: 0 }] },
];
describe('timeline geometry', () => {
it('maps frames to cells and back', () => {
const view: TimelineView = { panX: 10, cellWidth: 20 };
expect(frameToX(view, 0)).toBe(10);
expect(frameToX(view, 3)).toBe(70);
expect(frameCentreX(view, 3)).toBe(80);
expect(xToFrame(view, 80)).toBe(3.5);
expect(frameAt(view, 89)).toBe(3);
expect(frameAt(view, 90)).toBe(4);
});
it('never answers a frame below 0', () => {
expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0);
});
it('finds rows below the ruler and nothing beyond the last', () => {
expect(rowAt(RULER_HEIGHT - 1, 2)).toBe(-1);
expect(rowAt(RULER_HEIGHT, 2)).toBe(0);
expect(rowAt(RULER_HEIGHT + ROW_HEIGHT, 2)).toBe(1);
expect(rowAt(RULER_HEIGHT + 2 * ROW_HEIGHT, 2)).toBe(-1);
});
it('zooms about the pointer, so the frame under it stays put', () => {
const view: TimelineView = { panX: -100, cellWidth: 16 };
const x = 200;
const before = xToFrame(view, x);
const zoomed = zoomCells(view, 1.5, x);
expect(zoomed.cellWidth).toBe(24);
expect(xToFrame(zoomed, x)).toBeCloseTo(before);
});
it('keeps the cell width within its bounds', () => {
expect(zoomCells(DEFAULT_VIEW, 100, 0).cellWidth).toBe(MAX_CELL_WIDTH);
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
});
it('does not let frame 0 drift right of the left edge', () => {
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
});
it('labels the ruler with a step that keeps the numbers apart', () => {
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
});
});
describe('picking keys', () => {
const view: TimelineView = { panX: 0, cellWidth: 16 };
it('hits the key drawn at the centre of its cell', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({
trackId: 'a',
frame: 4,
});
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({
trackId: 'b',
frame: 2,
});
});
it('misses an empty cell and the ruler', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull();
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull();
});
it('a band catches every key whose diamond centre it spans, across rows', () => {
const a = { x: frameToX(view, 1), y: RULER_HEIGHT };
const b = { x: frameToX(view, 5), y: RULER_HEIGHT + 2 * ROW_HEIGHT };
expect(keysWithin(view, TRACKS, a, b)).toEqual([
{ trackId: 'a', frame: 4 },
{ trackId: 'b', frame: 2 },
]);
// Spanned the other way round it is the same band.
expect(keysWithin(view, TRACKS, b, a)).toHaveLength(2);
});
it('a band over one row leaves the other alone', () => {
const a = { x: frameToX(view, 0), y: RULER_HEIGHT };
const b = { x: frameToX(view, 10), y: RULER_HEIGHT + ROW_HEIGHT - 1 };
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },
{ trackId: 'b', frame: 5 },
];
expect(clampMove(keys, -5)).toBe(-2);
expect(clampMove(keys, 3)).toBe(3);
expect(clampMove([], -3)).toBe(0);
});
});