Fold object groups in the timeline, with summary keys (#414)
Long animations with many objects fill the timeline with rows. A group per object can now fold away, and its header row still shows where the group has keys, so they stay reachable while folded. - A click on an object header or its triangle folds the group. The groups of the objects below it fold along. - Which groups fold along follows the real parent in the hierarchy, not the indent. The root shows at the level of its children, so folding it hides only its own rows. - Every header shows a summary diamond on each frame where a row of the group or of a group below has a key. Click selects all those keys, drag moves them, Ctrl+drag duplicates, a band catches them and Delete removes them, each through the existing commands. - The fold state is a view of the session per animation. It is no undo step and is not saved. - Selecting a row without keys inside a folded group unfolds it, so a fresh row from + Row does not stay hidden. - The fold rules live in the new timelineFold.ts. keyAt became keysAt, which returns every key behind a summary diamond. Found on the way: the triangle moved the object name to x 22 while its rows stayed at x 20, left of the name (#408 check in e2e 25). Rows now sit one step behind the name, as in the hierarchy. Tests: - Unit animation + timeline-view 114/114, 6 new - 6 counter-checks, all red in the matching test - e2e 25 (atx) ALL PASSED, 41 checks, 4 new (25k) - Gate (atx) CHECK EXIT 0, 324 files, 5008 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19902a9f02
commit
37779a2070
8 files changed
+518
-98
No files matched your search
@@ -428,11 +428,12 @@ export function placedRoot(root: Asset): SceneObject {
|
||||
};
|
||||
}
|
||||
|
||||
/** One object a row may aim at below the root, under the id the row stores. */
|
||||
/** One object a row may aim at below the root, under the id the row stores; `parent` is the one above it, '' at the top. */
|
||||
export interface AnimationTarget {
|
||||
id: string;
|
||||
object: SceneObject;
|
||||
depth: number;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,12 +448,18 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani
|
||||
id: object.id,
|
||||
object,
|
||||
depth,
|
||||
parent: depth > 0 ? object.parentId : '',
|
||||
}));
|
||||
if (root?.type !== 'container') return [];
|
||||
const prefix = instanceChildId(PLACED_ROOT_ID, '');
|
||||
return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) })
|
||||
.filter(({ object }) => object.id.startsWith(prefix))
|
||||
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
|
||||
.map(({ object, depth }) => ({
|
||||
id: object.id.slice(prefix.length),
|
||||
object,
|
||||
depth: depth - 1,
|
||||
parent: object.parentId.startsWith(prefix) ? object.parentId.slice(prefix.length) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,12 +586,13 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A
|
||||
|
||||
/**
|
||||
* A line of the timeline's header column (#408): the head of an object's group, or a row under it,
|
||||
* named by its property alone. `depth` is the object's in the hierarchy. A row carries its property
|
||||
* (none once that is gone) and `held`, what it shows before it has keys: it writes nothing then, so
|
||||
* that is what the object holds – or no offset at all for a relative row.
|
||||
* named by its property alone. `depth` is the object's in the hierarchy, `above` the objects it sits
|
||||
* under, the nearest last – the root not among them: its head stands level with its children's (#414).
|
||||
* A row carries its property (none once that is gone) and `held`, what it shows before it has keys:
|
||||
* it writes nothing then, so that is what the object holds – or no offset at all for a relative row.
|
||||
*/
|
||||
export type TimelineLine =
|
||||
| { kind: 'group'; target: string; name: string; depth: number }
|
||||
| { kind: 'group'; target: string; name: string; depth: number; above: readonly string[] }
|
||||
| {
|
||||
kind: 'row';
|
||||
track: AnimationTrack;
|
||||
@@ -600,10 +608,21 @@ export type TimelineLine =
|
||||
* gone come last, as they were added. Only the view is sorted: the asset keeps its own order.
|
||||
*/
|
||||
export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] {
|
||||
const placed = new Map<string, { at: number; depth: number }>([['', { at: 0, depth: 0 }]]);
|
||||
const placed = new Map<string, { at: number; depth: number; parent: string }>([
|
||||
['', { at: 0, depth: 0, parent: '' }],
|
||||
]);
|
||||
animationTargets(project, findAsset(project, animation.root)).forEach((t, i) =>
|
||||
placed.set(t.id, { at: i + 1, depth: t.depth }),
|
||||
placed.set(t.id, { at: i + 1, depth: t.depth, parent: t.parent }),
|
||||
);
|
||||
const aboveOf = (target: string): string[] => {
|
||||
const above: string[] = [];
|
||||
let at = placed.get(target)?.parent ?? '';
|
||||
while (at !== '' && !above.includes(at)) {
|
||||
above.unshift(at);
|
||||
at = placed.get(at)?.parent ?? '';
|
||||
}
|
||||
return above;
|
||||
};
|
||||
const targets = [...new Set(animation.tracks.map((t) => t.target))];
|
||||
const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target);
|
||||
const lines: TimelineLine[] = [];
|
||||
@@ -616,7 +635,7 @@ export function timelineLines(project: Project, animation: AnimationAsset): Time
|
||||
: aimed.object
|
||||
? objectName(aimed.object, aimed.asset)
|
||||
: aimed.asset.name;
|
||||
lines.push({ kind: 'group', target, name, depth });
|
||||
lines.push({ kind: 'group', target, name, depth, above: aboveOf(target) });
|
||||
const order = (track: AnimationTrack) => {
|
||||
const i = defs.findIndex((d) => d.key === track.property);
|
||||
return i < 0 ? defs.length : i;
|
||||
|
||||
@@ -67,8 +67,9 @@ import {
|
||||
frameCentreX,
|
||||
frameToX,
|
||||
HEADER_WIDTH,
|
||||
isSummary,
|
||||
KEY_SIZE,
|
||||
keyAt,
|
||||
keysAt,
|
||||
keysWithin,
|
||||
type Lane,
|
||||
maxScrollY,
|
||||
@@ -81,9 +82,12 @@ import {
|
||||
rulerStep,
|
||||
scrollByThumb,
|
||||
scrollThumb,
|
||||
type SummaryLane,
|
||||
summaryFrames,
|
||||
type TimelineView,
|
||||
zoomCells,
|
||||
} from './timelineView';
|
||||
import { foldLines, type ShownLine, unfoldedFor } from './timelineFold';
|
||||
|
||||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
@@ -94,6 +98,8 @@ const FIELD_WIDTH = 104;
|
||||
const FIELD_INSET = 4;
|
||||
/** How far from the right edge a press takes the scrollbar rather than the cells under it. */
|
||||
const SCROLLBAR_REACH = 10;
|
||||
/** Room in front of an object's name for the triangle that folds its group (#414). */
|
||||
const FOLD_SLOT = 14;
|
||||
|
||||
/** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */
|
||||
interface ValueField {
|
||||
@@ -145,7 +151,14 @@ export class TimelineCanvas extends Widget {
|
||||
/** By row id, and by `group:<target>` for an object's head. */
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly fields = new Map<string, ValueField>();
|
||||
private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null;
|
||||
private linesFor: {
|
||||
asset: AnimationAsset | null;
|
||||
project: object;
|
||||
all: TimelineLine[];
|
||||
lines: ShownLine[];
|
||||
} | null = null;
|
||||
/** The folded heads by animation (#414): a way of looking, kept for the session and never an undo step. */
|
||||
private readonly folds = new Map<string, ReadonlySet<string>>();
|
||||
private readonly rulerTexts = new TextPool(this.rulerLayer);
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
/** Right of the header column. */
|
||||
@@ -230,6 +243,7 @@ export class TimelineCanvas extends Widget {
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.linesFor = null;
|
||||
this.folds.clear();
|
||||
this.view = { ...DEFAULT_VIEW };
|
||||
this.redraw();
|
||||
});
|
||||
@@ -267,15 +281,39 @@ export class TimelineCanvas extends Widget {
|
||||
this.view = clampScroll({ ...this.view, scrollY: Math.round(y) }, this.lines().length, this.h);
|
||||
}
|
||||
|
||||
/** A row picked outside the view – by a click elsewhere, or a row just added – is brought into it. */
|
||||
/**
|
||||
* A row picked outside the view – by a click elsewhere, or a row just added – is brought into it. A
|
||||
* row picked alone in a folded group opens it; keys picked through a head's diamond leave it folded.
|
||||
*/
|
||||
private revealPicked(): void {
|
||||
const picked = this.state.selectedAnimationId === this.assetId ? (this.state.selectedTrackId ?? '') : '';
|
||||
if (picked === this.revealed) return;
|
||||
this.revealed = picked;
|
||||
const target = this.asset()?.tracks.find((t) => t.id === picked)?.target;
|
||||
if (target !== undefined && this.rowOf(picked) < 0 && this.state.selectedKeys.length === 0)
|
||||
this.setFolded(unfoldedFor(this.allLines(), this.foldedHere(), target));
|
||||
const row = picked ? this.rowOf(picked) : -1;
|
||||
if (row >= 0) this.view = revealRow(this.view, row, this.lines().length, this.h);
|
||||
}
|
||||
|
||||
private foldedHere(): ReadonlySet<string> {
|
||||
return this.folds.get(this.assetId) ?? new Set();
|
||||
}
|
||||
|
||||
private setFolded(folded: ReadonlySet<string>): void {
|
||||
if (folded.size > 0) this.folds.set(this.assetId, folded);
|
||||
else this.folds.delete(this.assetId);
|
||||
this.linesFor = null;
|
||||
}
|
||||
|
||||
/** Folds or opens the group of the object `target` names ('' the root's); its children's fold with it. */
|
||||
toggleFold(target: string): void {
|
||||
const folded = new Set(this.foldedHere());
|
||||
if (!folded.delete(target)) folded.add(target);
|
||||
this.setFolded(folded);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingNow;
|
||||
}
|
||||
@@ -450,9 +488,10 @@ export class TimelineCanvas extends Widget {
|
||||
private openMenu(asset: AnimationAsset, local: Vec2, global: Vec2): void {
|
||||
const hit =
|
||||
local.x >= HEADER_WIDTH && local.y >= RULER_HEIGHT
|
||||
? keyAt(this.view, this.lanes(), { x: local.x - HEADER_WIDTH, y: local.y })
|
||||
: null;
|
||||
if (hit && !hasKey(this.state.selectedKeys, hit)) this.state.select(trackSelection(asset.id, [hit]));
|
||||
? keysAt(this.view, this.lanes(), { x: local.x - HEADER_WIDTH, y: local.y })
|
||||
: [];
|
||||
if (!hit.every((k) => hasKey(this.state.selectedKeys, k)))
|
||||
this.state.select(trackSelection(asset.id, hit));
|
||||
const track = this.laneAt(local.y);
|
||||
if (track && local.x < HEADER_WIDTH && this.state.selectedTrackId !== track.id)
|
||||
this.state.select(trackSelection(asset.id, [], track.id));
|
||||
@@ -518,6 +557,26 @@ export class TimelineCanvas extends Widget {
|
||||
return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row, this.view.scrollY) };
|
||||
}
|
||||
|
||||
/** Where the fold triangle of an object's head sits on screen ('' the root's). */
|
||||
foldGlobal(target: string): Vec2 | null {
|
||||
const row = this.headOf(target);
|
||||
const line = this.lines()[row];
|
||||
if (!line) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + this.indentOf(line) + 5, y: origin.y + rowCentreY(row, this.view.scrollY) };
|
||||
}
|
||||
|
||||
/** Where a head's diamond on `frame` sits on screen. */
|
||||
summaryGlobal(target: string, frame: number): Vec2 | null {
|
||||
const row = this.headOf(target);
|
||||
if (row < 0) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return {
|
||||
x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame),
|
||||
y: origin.y + rowCentreY(row, this.view.scrollY),
|
||||
};
|
||||
}
|
||||
|
||||
/** The scrollbar's thumb on screen while the rows do not all fit, its height and the scroll's reach. */
|
||||
thumbGlobal(): (Vec2 & { height: number; max: number }) | null {
|
||||
const thumb = scrollThumb(this.view, this.lines().length, this.h);
|
||||
@@ -555,15 +614,30 @@ export class TimelineCanvas extends Widget {
|
||||
/** The rows as their full labels (`object · property`), top to bottom. */
|
||||
rowsDrawn(): string[] {
|
||||
const asset = this.asset();
|
||||
return asset ? this.lanes().flatMap((t) => (t ? [trackLabel(this.state.project, asset, t)] : [])) : [];
|
||||
return asset
|
||||
? this.lines().flatMap((l) =>
|
||||
l.kind === 'row' ? [trackLabel(this.state.project, asset, l.track)] : [],
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** The header column as drawn (#408): objects' heads and rows, with the value a row's field shows. */
|
||||
linesDrawn(): { kind: string; text: string; indent: number; value?: PropertyValue; trackId?: string }[] {
|
||||
/**
|
||||
* The header column as drawn (#408): objects' heads – folded or not, with the frames of their
|
||||
* diamonds (#414) – and rows, with the value a row's field shows.
|
||||
*/
|
||||
linesDrawn(): {
|
||||
kind: string;
|
||||
text: string;
|
||||
indent: number;
|
||||
value?: PropertyValue;
|
||||
trackId?: string;
|
||||
folded?: boolean;
|
||||
summary?: number[];
|
||||
}[] {
|
||||
return this.lines().map((line) => {
|
||||
const label = this.labels.get(this.labelKey(line));
|
||||
const base = { kind: line.kind, text: label?.text ?? '', indent: label?.x ?? 0 };
|
||||
if (line.kind === 'group') return base;
|
||||
if (line.kind === 'group') return { ...base, folded: line.folded, summary: summaryFrames(line) };
|
||||
const shown = this.fields.get(line.track.id)?.shown;
|
||||
return {
|
||||
...base,
|
||||
@@ -592,24 +666,41 @@ export class TimelineCanvas extends Widget {
|
||||
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'animation') ?? null) : null;
|
||||
}
|
||||
|
||||
/** The header's lines, built once per change of the project rather than per frame of play. */
|
||||
private lines(): TimelineLine[] {
|
||||
/** The header's lines as shown, built once per change of the project or a fold rather than per frame of play. */
|
||||
private lines(): ShownLine[] {
|
||||
const asset = this.asset();
|
||||
const project = this.state.project;
|
||||
if (this.linesFor?.asset !== asset || this.linesFor.project !== project)
|
||||
this.linesFor = { asset, project, lines: asset ? timelineLines(project, asset) : [] };
|
||||
if (this.linesFor?.asset !== asset || this.linesFor.project !== project) {
|
||||
const all = asset ? timelineLines(project, asset) : [];
|
||||
this.linesFor = { asset, project, all, lines: foldLines(all, this.foldedHere()) };
|
||||
}
|
||||
return this.linesFor.lines;
|
||||
}
|
||||
|
||||
/** Every line, folded or not. */
|
||||
private allLines(): TimelineLine[] {
|
||||
this.lines();
|
||||
return this.linesFor?.all ?? [];
|
||||
}
|
||||
|
||||
private lanes(): Lane[] {
|
||||
return this.lines().map((line) => (line.kind === 'row' ? line.track : null));
|
||||
return this.lines().map((line) => (line.kind === 'row' ? line.track : line));
|
||||
}
|
||||
|
||||
/** The row under `y`; undefined over an object's head, the ruler and below the last line. */
|
||||
private laneAt(y: number): AnimationTrack | undefined {
|
||||
const lanes = this.lanes();
|
||||
const row = rowAt(y, lanes.length, this.view.scrollY);
|
||||
return (row >= 0 ? lanes[row] : null) ?? undefined;
|
||||
const line = this.lineAt(y);
|
||||
return line?.kind === 'row' ? line.track : undefined;
|
||||
}
|
||||
|
||||
private lineAt(y: number): ShownLine | undefined {
|
||||
const lines = this.lines();
|
||||
const row = rowAt(y, lines.length, this.view.scrollY);
|
||||
return row >= 0 ? lines[row] : undefined;
|
||||
}
|
||||
|
||||
private headOf(target: string): number {
|
||||
return this.lines().findIndex((line) => line.kind === 'group' && line.target === target);
|
||||
}
|
||||
|
||||
private rowOf(trackId: string): number {
|
||||
@@ -657,8 +748,9 @@ export class TimelineCanvas extends Widget {
|
||||
e.stopPropagation();
|
||||
|
||||
if (local.x < HEADER_WIDTH) {
|
||||
const track = this.laneAt(local.y);
|
||||
if (track) this.state.select(trackSelection(asset.id, [], track.id));
|
||||
const line = this.lineAt(local.y);
|
||||
if (line?.kind === 'group') this.toggleFold(line.target);
|
||||
else if (line) this.state.select(trackSelection(asset.id, [], line.track.id));
|
||||
return;
|
||||
}
|
||||
const cell = { x: local.x - HEADER_WIDTH, y: local.y };
|
||||
@@ -669,11 +761,12 @@ export class TimelineCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const picked = this.state.selectedKeys;
|
||||
const hit = keyAt(this.view, this.lanes(), cell);
|
||||
if (hit) {
|
||||
const hit = keysAt(this.view, this.lanes(), cell);
|
||||
if (hit.length > 0) {
|
||||
// A picked key is taken as it is, so a drag moves the whole pick; an unpicked one replaces it.
|
||||
const keys = hasKey(picked, hit) ? picked : [hit];
|
||||
if (!hasKey(picked, hit)) this.state.select(trackSelection(asset.id, keys, hit.trackId));
|
||||
const taken = hit.every((k) => hasKey(picked, k));
|
||||
const keys = taken ? picked : hit;
|
||||
if (!taken) this.state.select(trackSelection(asset.id, keys));
|
||||
this.dragging = {
|
||||
keys: [...keys],
|
||||
pointer: point,
|
||||
@@ -854,15 +947,19 @@ export class TimelineCanvas extends Widget {
|
||||
const count = frameCount(asset);
|
||||
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
|
||||
const x1 = HEADER_WIDTH + frameToX(this.view, count);
|
||||
const lines = this.lines();
|
||||
lanes.forEach((track, row) => {
|
||||
const y = rowTop(row, this.view.scrollY);
|
||||
if (!this.inView(y)) return;
|
||||
// An object's head: a band across both halves, no cells – nothing is keyed on it.
|
||||
if (!track) {
|
||||
// An object's head: a band across both halves with the triangle that folds it, no cells of its own.
|
||||
const line = lines[row];
|
||||
if (line?.kind === 'group') {
|
||||
this.rowsBg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader);
|
||||
this.rowsBg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
drawFoldTriangle(this.rowsBg, this.indentOf(line) + 4, y + ROW_HEIGHT / 2, line.folded);
|
||||
return;
|
||||
}
|
||||
if (!track || isSummary(track)) return;
|
||||
if (track.id === selectedRow)
|
||||
this.rowsBg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
|
||||
this.cells.rect(HEADER_WIDTH, y, this.w - HEADER_WIDTH, ROW_HEIGHT).fill({
|
||||
@@ -912,34 +1009,54 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
private drawKeys(lanes: readonly Lane[]): void {
|
||||
const c = Theme.colors;
|
||||
const picked = this.state.selectedKeys;
|
||||
const drag = this.dragging?.moved ? this.dragging : null;
|
||||
const half = KEY_SIZE / 2;
|
||||
lanes.forEach((track, row) => {
|
||||
if (!track || !this.inView(rowTop(row, this.view.scrollY))) return;
|
||||
const seen = (frame: number) => {
|
||||
const x = HEADER_WIDTH + frameCentreX(this.view, frame);
|
||||
return x >= HEADER_WIDTH - half && x <= this.w + half ? x : null;
|
||||
};
|
||||
lanes.forEach((lane, row) => {
|
||||
if (!lane || !this.inView(rowTop(row, this.view.scrollY))) return;
|
||||
const y = rowCentreY(row, this.view.scrollY);
|
||||
for (const key of track.keys) {
|
||||
const ref = { trackId: track.id, frame: key.frame };
|
||||
const selected = hasKey(picked, ref);
|
||||
const dragged = drag !== null && drag.keys.some((k) => sameKey(k, ref));
|
||||
// A duplicate leaves the original where it is, drawn unpicked, and the copy travels picked.
|
||||
const frames = !dragged
|
||||
? [key.frame]
|
||||
: drag.copy
|
||||
? [key.frame, key.frame + drag.delta]
|
||||
: [key.frame + drag.delta];
|
||||
frames.forEach((frame, i) => {
|
||||
const x = HEADER_WIDTH + frameCentreX(this.view, frame);
|
||||
if (x < HEADER_WIDTH - half || x > this.w + half) return;
|
||||
const lit = selected && !(dragged && drag.copy && i === 0);
|
||||
drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half)
|
||||
.fill(lit ? c.accent : c.text)
|
||||
if (isSummary(lane)) {
|
||||
for (const [frame, lit] of this.summaryShown(lane)) {
|
||||
const x = seen(frame);
|
||||
if (x === null) continue;
|
||||
drawKeyShape(this.keyMarks, 'linear', x, y, half - 1)
|
||||
.fill(lit ? c.accent : c.textMuted)
|
||||
.stroke({ width: 1, color: lit ? c.accentText : c.viewport });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const key of lane.keys)
|
||||
for (const shown of this.keyShown({ trackId: lane.id, frame: key.frame })) {
|
||||
const x = seen(shown.frame);
|
||||
if (x === null) continue;
|
||||
drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half)
|
||||
.fill(shown.lit ? c.accent : c.text)
|
||||
.stroke({ width: 1, color: shown.lit ? c.accentText : c.viewport });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Where a key is drawn and whether lit: a duplicate leaves the original where it is, unpicked, and the copy travels picked. */
|
||||
private keyShown(ref: KeyRef): { frame: number; lit: boolean }[] {
|
||||
const selected = hasKey(this.state.selectedKeys, ref);
|
||||
const drag = this.dragging?.moved ? this.dragging : null;
|
||||
if (!drag?.keys.some((k) => sameKey(k, ref))) return [{ frame: ref.frame, lit: selected }];
|
||||
const moved = { frame: ref.frame + drag.delta, lit: selected };
|
||||
return drag.copy ? [{ frame: ref.frame, lit: false }, moved] : [moved];
|
||||
}
|
||||
|
||||
/** A head's diamonds as drawn (#414), by frame: lit when every key of its group there is. */
|
||||
private summaryShown(lane: SummaryLane): Map<number, boolean> {
|
||||
const shown = new Map<number, boolean>();
|
||||
for (const track of lane.tracks)
|
||||
for (const key of track.keys)
|
||||
for (const { frame, lit } of this.keyShown({ trackId: track.id, frame: key.frame }))
|
||||
shown.set(frame, (shown.get(frame) ?? true) && lit);
|
||||
return shown;
|
||||
}
|
||||
|
||||
private drawPlayhead(): void {
|
||||
const x = HEADER_WIDTH + frameCentreX(this.view, this.playhead);
|
||||
if (x < HEADER_WIDTH || x > this.w) return;
|
||||
@@ -956,6 +1073,10 @@ export class TimelineCanvas extends Widget {
|
||||
return line.kind === 'group' ? `group:${line.target}` : line.track.id;
|
||||
}
|
||||
|
||||
private indentOf(line: TimelineLine): number {
|
||||
return LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent;
|
||||
}
|
||||
|
||||
/** An object's name at its depth in the hierarchy; under it its rows, a step further in. */
|
||||
private syncLabels(lines: readonly TimelineLine[], selectedRow: string): void {
|
||||
const alive = new Set<string>();
|
||||
@@ -968,8 +1089,9 @@ export class TimelineCanvas extends Widget {
|
||||
this.labels.set(id, label);
|
||||
this.labelLayer.addChild(label);
|
||||
}
|
||||
const indent = LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent;
|
||||
const x = line.kind === 'group' ? indent : indent + Theme.treeIndent;
|
||||
const indent = this.indentOf(line);
|
||||
// A row stands a step in from its object's name, as a leaf does under its parent in the hierarchy.
|
||||
const x = indent + FOLD_SLOT + (line.kind === 'group' ? 0 : Theme.treeIndent);
|
||||
const style =
|
||||
line.kind === 'group'
|
||||
? line.name === '(missing)'
|
||||
@@ -1091,6 +1213,21 @@ function drawKeyShape(
|
||||
return g.closePath();
|
||||
}
|
||||
|
||||
/** Points down while the group is open, right once folded – like the hierarchy's chevrons. */
|
||||
function drawFoldTriangle(g: Graphics, x: number, y: number, folded: boolean): void {
|
||||
const color = Theme.colors.textMuted;
|
||||
if (folded)
|
||||
g.moveTo(x - 1, y - 4)
|
||||
.lineTo(x + 4, y)
|
||||
.lineTo(x - 1, y + 4)
|
||||
.fill(color);
|
||||
else
|
||||
g.moveTo(x - 3, y - 2)
|
||||
.lineTo(x + 5, y - 2)
|
||||
.lineTo(x + 1, y + 3)
|
||||
.fill(color);
|
||||
}
|
||||
|
||||
function isWithin(target: unknown, root: Container): boolean {
|
||||
for (let node = target as Container | null; node; node = node.parent) if (node === root) return true;
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TimelineLine } from '../../core/model/animation';
|
||||
import type { AnimationTrack } from '../../core/model/Asset';
|
||||
|
||||
/** A line as the timeline shows it (#414): an object's head knows whether it is folded and which rows it sums up. */
|
||||
export type ShownLine =
|
||||
| (TimelineLine & { kind: 'row' })
|
||||
| (TimelineLine & { kind: 'group'; folded: boolean; tracks: readonly AnimationTrack[] });
|
||||
|
||||
/**
|
||||
* The lines left with the heads `folded` names folded: a folded head hides its rows and the groups of
|
||||
* the objects below it. Every head carries the rows of its group and of the groups below it – what its
|
||||
* diamonds sum up, folded or not.
|
||||
*/
|
||||
export function foldLines(lines: readonly TimelineLine[], folded: ReadonlySet<string>): ShownLine[] {
|
||||
const above = aboveOf(lines);
|
||||
const under = (target: string, head: string) => target === head || (above.get(target) ?? []).includes(head);
|
||||
const out: ShownLine[] = [];
|
||||
for (const line of lines) {
|
||||
const target = line.kind === 'group' ? line.target : line.track.target;
|
||||
const hidden =
|
||||
(above.get(target) ?? []).some((t) => folded.has(t)) || (line.kind === 'row' && folded.has(target));
|
||||
if (hidden) continue;
|
||||
if (line.kind === 'row') {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const tracks = lines.flatMap((l) => (l.kind === 'row' && under(l.track.target, target) ? [l.track] : []));
|
||||
out.push({ ...line, folded: folded.has(target), tracks });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The heads still folded once the rows of `target` are to be seen: its own and those of the objects above it open. */
|
||||
export function unfoldedFor(
|
||||
lines: readonly TimelineLine[],
|
||||
folded: ReadonlySet<string>,
|
||||
target: string,
|
||||
): Set<string> {
|
||||
const open = new Set([target, ...(aboveOf(lines).get(target) ?? [])]);
|
||||
return new Set([...folded].filter((t) => !open.has(t)));
|
||||
}
|
||||
|
||||
function aboveOf(lines: readonly TimelineLine[]): Map<string, readonly string[]> {
|
||||
const above = new Map<string, readonly string[]>();
|
||||
for (const line of lines) if (line.kind === 'group') above.set(line.target, line.above);
|
||||
return above;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KeyRef } from '../../core/model/animation';
|
||||
import { hasKey, type KeyRef } from '../../core/model/animation';
|
||||
import type { AnimationTrack } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { clamp } from '../../ui';
|
||||
@@ -17,8 +17,30 @@ export interface TimelineView {
|
||||
scrollY: number;
|
||||
}
|
||||
|
||||
/** A line of the timeline as the geometry sees it: a row, or `null` for an object's head (#408). */
|
||||
export type Lane = AnimationTrack | null;
|
||||
/** An object's head (#414): the rows of its group, its children's included, whose keys it sums up frame by frame. */
|
||||
export interface SummaryLane {
|
||||
target: string;
|
||||
tracks: readonly AnimationTrack[];
|
||||
}
|
||||
|
||||
/** A line of the timeline as the geometry sees it: a row, an object's head (#408), or `null` for nothing to hit. */
|
||||
export type Lane = AnimationTrack | SummaryLane | null;
|
||||
|
||||
export function isSummary(lane: Lane | undefined): lane is SummaryLane {
|
||||
return lane != null && 'tracks' in lane;
|
||||
}
|
||||
|
||||
/** The frames a head's diamonds stand on: every frame any of its rows has a key on, in order. */
|
||||
export function summaryFrames(lane: SummaryLane): number[] {
|
||||
return [...new Set(lane.tracks.flatMap((t) => t.keys.map((k) => k.frame)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** The keys one of a head's diamonds stands for. */
|
||||
export function summaryKeys(lane: SummaryLane, frame: number): KeyRef[] {
|
||||
return lane.tracks.flatMap((t) =>
|
||||
t.keys.some((k) => k.frame === frame) ? [{ trackId: t.id, frame }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Width of the row header column, in pixels. */
|
||||
export const HEADER_WIDTH = 240;
|
||||
@@ -142,35 +164,37 @@ export function rulerStep(view: TimelineView): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The key under a point, tested against the diamond drawn at the cell's centre rather than the
|
||||
* whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing.
|
||||
* The keys under a point, tested against the diamond drawn at the cell's centre rather than the
|
||||
* whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing. A
|
||||
* row's diamond is one key, a head's every key of its group on that frame (#414).
|
||||
*/
|
||||
export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null {
|
||||
const row = rowAt(point.y, tracks.length, view.scrollY);
|
||||
const track = row < 0 ? null : tracks[row];
|
||||
if (!track) return null;
|
||||
export function keysAt(view: TimelineView, lanes: readonly Lane[], point: Vec2): KeyRef[] {
|
||||
const row = rowAt(point.y, lanes.length, view.scrollY);
|
||||
const lane = row < 0 ? null : lanes[row];
|
||||
if (!lane) return [];
|
||||
const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2);
|
||||
for (const key of track.keys) {
|
||||
if (Math.abs(frameCentreX(view, key.frame) - point.x) <= reach)
|
||||
return { trackId: track.id, frame: key.frame };
|
||||
}
|
||||
return null;
|
||||
const frames = isSummary(lane) ? summaryFrames(lane) : lane.keys.map((k) => k.frame);
|
||||
const frame = frames.find((f) => Math.abs(frameCentreX(view, f) - point.x) <= reach);
|
||||
if (frame === undefined) return [];
|
||||
return isSummary(lane) ? summaryKeys(lane, frame) : [{ trackId: lane.id, frame }];
|
||||
}
|
||||
|
||||
/** The keys whose diamond centre lies inside the band spanned between two points. */
|
||||
export function keysWithin(view: TimelineView, tracks: readonly Lane[], a: Vec2, b: Vec2): KeyRef[] {
|
||||
/** The keys whose diamond centre lies inside the band spanned between two points – a head's standing for its group's. */
|
||||
export function keysWithin(view: TimelineView, lanes: readonly Lane[], a: Vec2, b: Vec2): KeyRef[] {
|
||||
const left = Math.min(a.x, b.x);
|
||||
const right = Math.max(a.x, b.x);
|
||||
const top = Math.min(a.y, b.y);
|
||||
const bottom = Math.max(a.y, b.y);
|
||||
const out: KeyRef[] = [];
|
||||
tracks.forEach((track, row) => {
|
||||
lanes.forEach((lane, row) => {
|
||||
const y = rowCentreY(row, view.scrollY);
|
||||
if (!track || y < top || y > bottom) return;
|
||||
for (const key of track.keys) {
|
||||
const x = frameCentreX(view, key.frame);
|
||||
if (x >= left && x <= right) out.push({ trackId: track.id, frame: key.frame });
|
||||
}
|
||||
if (!lane || y < top || y > bottom) return;
|
||||
for (const track of isSummary(lane) ? lane.tracks : [lane])
|
||||
for (const key of track.keys) {
|
||||
const x = frameCentreX(view, key.frame);
|
||||
const ref = { trackId: track.id, frame: key.frame };
|
||||
if (x >= left && x <= right && !hasKey(out, ref)) out.push(ref);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ export function documentHooks({ state, editor }: HookContext) {
|
||||
/** The header column (#408): objects' heads and rows, and where a row's value field sits. */
|
||||
lines: () => editor.animationEditor.canvas.linesDrawn(),
|
||||
valueAt: (trackId: string) => editor.animationEditor.canvas.valueGlobal(trackId),
|
||||
/** An object's fold triangle and the diamond its head shows on a frame (#414). */
|
||||
foldAt: (target: string) => editor.animationEditor.canvas.foldGlobal(target),
|
||||
summaryAt: (target: string, frame: number) =>
|
||||
editor.animationEditor.canvas.summaryGlobal(target, frame),
|
||||
view: () => editor.animationEditor.canvas.viewInfo(),
|
||||
/** The scrollbar's thumb beside the rows (#413), or null while they all fit. */
|
||||
thumb: () => editor.animationEditor.canvas.thumbGlobal(),
|
||||
|
||||
Reference in new issue
Block a user