Scroll the rows of the animation timeline up and down (#413)
An animation with more rows than the panel is tall cut the bottom rows off. There was no way to reach them, their fields or their keys. - The view has a new `scrollY`. Rows scroll up under the ruler, which stays where it is together with the tab head. - The wheel over the header column scrolls the rows. Over the cells it still zooms, and `Shift`+wheel still scrolls across. - A scrollbar at the right edge shows up only when the rows do not fit. Its thumb can be dragged, a click beside it jumps there. - The middle button pans both ways. `Shift`+drag stays across only. - A row picked or added out of view is scrolled just far enough to be seen whole. Value fields out of view are not drawn. - The tab keeps its scroll like its zoom when switching. Found on the way: `zoomCells` built a new view from `panX` and `cellWidth` only, so every zoom would have dropped the scroll. The type check caught it, and a unit test now holds it. Tests: - tsc, eslint, Prettier clean - Unit timeline-view: 20 green, 6 new. Bending `rowAt` to ignore the scroll or `revealRow` to do nothing turns exactly those tests red. - e2e 25 on melektaus-atx: ALL PASSED, 37 checks (25j, 25j-ii new). 32 rows, the wheel brings the last field into view and typing keys 77 there. Dragging the thumb up goes back to 0. - Gate on melektaus-atx: green, 305 files, 4730 tests. - Not checked in the browser: the middle button, revealing a picked row, the wheel right over a value field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0648853356
commit
480db9c4e4
6 files changed
+341
-56
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2057 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2059 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -60,6 +60,7 @@ import { FramesDialog, openFramesDialog } from './FramesDialog';
|
||||
import {
|
||||
clampMove,
|
||||
clampPan,
|
||||
clampScroll,
|
||||
DEFAULT_VIEW,
|
||||
frameAt,
|
||||
frameCentreX,
|
||||
@@ -69,12 +70,16 @@ import {
|
||||
keyAt,
|
||||
keysWithin,
|
||||
type Lane,
|
||||
maxScrollY,
|
||||
revealRow,
|
||||
ROW_HEIGHT,
|
||||
rowAt,
|
||||
rowCentreY,
|
||||
rowTop,
|
||||
RULER_HEIGHT,
|
||||
rulerStep,
|
||||
scrollByThumb,
|
||||
scrollThumb,
|
||||
type TimelineView,
|
||||
zoomCells,
|
||||
} from './timelineView';
|
||||
@@ -86,6 +91,8 @@ const LABEL_INSET = 8;
|
||||
/** Width of the value field at the right of a row's header (#408). */
|
||||
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;
|
||||
|
||||
/** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */
|
||||
interface ValueField {
|
||||
@@ -122,8 +129,15 @@ interface Band {
|
||||
*/
|
||||
export class TimelineCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
/** Objects' heads and the picked row's header – what scrolls with the rows across both halves. */
|
||||
private readonly rowsBg = new Graphics();
|
||||
private readonly cells = new Graphics();
|
||||
private readonly keyMarks = new Graphics();
|
||||
/** The ruler's band and borders, drawn over rows scrolled up under it. */
|
||||
private readonly chrome = new Graphics();
|
||||
private readonly ruler = new Graphics();
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly scrollbar = new Graphics();
|
||||
private readonly labelLayer = new Container();
|
||||
private readonly fieldLayer = new Container();
|
||||
private readonly rulerLayer = new Container();
|
||||
@@ -133,7 +147,12 @@ export class TimelineCanvas extends Widget {
|
||||
private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null;
|
||||
private readonly rulerTexts: Text[] = [];
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
/** Right of the header column. */
|
||||
private readonly clip = new Graphics();
|
||||
/** Below the ruler. */
|
||||
private readonly rowsClip = new Graphics();
|
||||
/** Below the ruler and right of the header column. */
|
||||
private readonly cellClip = new Graphics();
|
||||
private view: TimelineView = { ...DEFAULT_VIEW };
|
||||
/** Only across: a frame's cell is `cellWidth` wide and frame 0 starts at `panX`. */
|
||||
private readonly tween = new ViewTween({
|
||||
@@ -151,7 +170,11 @@ export class TimelineCanvas extends Widget {
|
||||
/** Seconds since play began; the playhead is a function of it (see `animationPlayheadAt`). */
|
||||
private time = 0;
|
||||
private playingNow = false;
|
||||
private panning: { pointer: Vec2; panX: number } | null = null;
|
||||
/** The middle button pans both ways, Shift and the left button only across. */
|
||||
private panning: { pointer: Vec2; panX: number; scrollY: number; vertical: boolean } | null = null;
|
||||
private thumbDrag: { pointerY: number; scrollY: number } | null = null;
|
||||
/** The picked row last brought into view: a pick that stays on its row leaves the scroll alone. */
|
||||
private revealed = '';
|
||||
private dragging: KeyDrag | null = null;
|
||||
private band: Band | null = null;
|
||||
private scrubbing = false;
|
||||
@@ -164,15 +187,27 @@ export class TimelineCanvas extends Widget {
|
||||
super();
|
||||
this.addChild(
|
||||
this.bg,
|
||||
this.rowsBg,
|
||||
this.cells,
|
||||
this.overlay,
|
||||
this.keyMarks,
|
||||
this.labelLayer,
|
||||
this.fieldLayer,
|
||||
this.chrome,
|
||||
this.ruler,
|
||||
this.rulerLayer,
|
||||
this.overlay,
|
||||
this.scrollbar,
|
||||
this.hint,
|
||||
this.clip,
|
||||
this.rowsClip,
|
||||
this.cellClip,
|
||||
);
|
||||
this.cells.mask = this.clip;
|
||||
this.rowsBg.mask = this.rowsClip;
|
||||
this.labelLayer.mask = this.rowsClip;
|
||||
this.fieldLayer.mask = this.rowsClip;
|
||||
this.cells.mask = this.cellClip;
|
||||
this.keyMarks.mask = this.cellClip;
|
||||
this.ruler.mask = this.clip;
|
||||
this.overlay.mask = this.clip;
|
||||
this.rulerLayer.mask = this.clip;
|
||||
this.hint.eventMode = 'none';
|
||||
@@ -188,7 +223,10 @@ export class TimelineCanvas extends Widget {
|
||||
this.linesFor = null;
|
||||
this.redraw();
|
||||
});
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('selection', () => {
|
||||
this.revealPicked();
|
||||
this.redraw();
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.linesFor = null;
|
||||
this.view = { ...DEFAULT_VIEW };
|
||||
@@ -219,10 +257,24 @@ export class TimelineCanvas extends Widget {
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as TimelineView) };
|
||||
this.view = { ...DEFAULT_VIEW, ...(view as Partial<TimelineView>) };
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Scrolls the rows to `y` pixels down, within what there is to scroll. */
|
||||
private scrollRowsTo(y: number): void {
|
||||
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. */
|
||||
private revealPicked(): void {
|
||||
const picked = this.state.selectedAnimationId === this.assetId ? (this.state.selectedTrackId ?? '') : '';
|
||||
if (picked === this.revealed) return;
|
||||
this.revealed = picked;
|
||||
const row = picked ? this.rowOf(picked) : -1;
|
||||
if (row >= 0) this.view = revealRow(this.view, row, this.lines().length, this.h);
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingNow;
|
||||
}
|
||||
@@ -451,7 +503,10 @@ export class TimelineCanvas extends Widget {
|
||||
const row = this.rowOf(trackId);
|
||||
if (row < 0) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + rowCentreY(row) };
|
||||
return {
|
||||
x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame),
|
||||
y: origin.y + rowCentreY(row, this.view.scrollY),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a row's header sits on screen. */
|
||||
@@ -459,7 +514,20 @@ export class TimelineCanvas extends Widget {
|
||||
const row = this.rowOf(trackId);
|
||||
if (row < 0) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row) };
|
||||
return { x: origin.x + HEADER_WIDTH / 2, 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);
|
||||
if (!thumb) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return {
|
||||
x: origin.x + this.w - SCROLLBAR_REACH / 2,
|
||||
y: origin.y + thumb.top + thumb.height / 2,
|
||||
height: thumb.height,
|
||||
max: maxScrollY(this.lines().length, this.h),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a frame sits on the ruler. */
|
||||
@@ -539,7 +607,7 @@ export class TimelineCanvas extends Widget {
|
||||
/** 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);
|
||||
const row = rowAt(y, lanes.length, this.view.scrollY);
|
||||
return (row >= 0 ? lanes[row] : null) ?? undefined;
|
||||
}
|
||||
|
||||
@@ -553,9 +621,27 @@ export class TimelineCanvas extends Widget {
|
||||
if (!asset || isWithin(e.target, this.fieldLayer)) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = { x: local.x, y: local.y };
|
||||
const thumb = scrollThumb(this.view, this.lines().length, this.h);
|
||||
if (e.button === 0 && thumb && local.x >= this.w - SCROLLBAR_REACH && local.y >= RULER_HEIGHT) {
|
||||
// A press beside the thumb brings the thumb's middle under the pointer, then drags from there.
|
||||
const off = local.y < thumb.top || local.y > thumb.top + thumb.height;
|
||||
if (off) {
|
||||
const dy = local.y - (thumb.top + thumb.height / 2);
|
||||
this.scrollRowsTo(scrollByThumb(this.view.scrollY, dy, this.lines().length, this.h));
|
||||
this.redraw();
|
||||
}
|
||||
this.thumbDrag = { pointerY: local.y, scrollY: this.view.scrollY };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
|
||||
this.tween.target(() => {
|
||||
this.panning = { pointer: point, panX: this.view.panX };
|
||||
this.panning = {
|
||||
pointer: point,
|
||||
panX: this.view.panX,
|
||||
scrollY: this.view.scrollY,
|
||||
vertical: e.button === 1,
|
||||
};
|
||||
});
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -616,11 +702,19 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
if (this.thumbDrag) {
|
||||
const { thumbDrag } = this;
|
||||
const dy = local.y - thumbDrag.pointerY;
|
||||
this.scrollRowsTo(scrollByThumb(thumbDrag.scrollY, dy, this.lines().length, this.h));
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
const { panning } = this;
|
||||
this.tween.carry(() => {
|
||||
this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) });
|
||||
});
|
||||
if (panning.vertical) this.scrollRowsTo(panning.scrollY - (local.y - panning.pointer.y));
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
@@ -648,6 +742,7 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
this.thumbDrag = null;
|
||||
this.scrubbing = false;
|
||||
const drag = this.dragging;
|
||||
const band = this.band;
|
||||
@@ -680,12 +775,16 @@ export class TimelineCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The wheel zooms over the cells and scrolls the rows over their headers; with Shift it pans across. */
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.shiftKey) {
|
||||
this.tween.land();
|
||||
this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY });
|
||||
this.redraw();
|
||||
} else if (local.x < HEADER_WIDTH) {
|
||||
this.scrollRowsTo(this.view.scrollY + e.deltaY);
|
||||
this.redraw();
|
||||
} else {
|
||||
this.tween.change(() => {
|
||||
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
|
||||
@@ -699,23 +798,28 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
|
||||
this.clip
|
||||
.clear()
|
||||
.rect(HEADER_WIDTH, 0, Math.max(0, this.w - HEADER_WIDTH), this.h)
|
||||
.fill(0xffffff);
|
||||
this.cells.clear();
|
||||
this.overlay.clear();
|
||||
const asset = this.asset();
|
||||
const lines = this.lines();
|
||||
const lanes = this.lanes();
|
||||
const selectedRow = this.state.selectedTrackId ?? '';
|
||||
// Fewer rows, or a taller tab, leave less to scroll.
|
||||
this.view = clampScroll(this.view, lines.length, this.h);
|
||||
|
||||
const below = Math.max(0, this.h - RULER_HEIGHT);
|
||||
const right = Math.max(0, this.w - HEADER_WIDTH);
|
||||
this.clip.clear().rect(HEADER_WIDTH, 0, right, this.h).fill(0xffffff);
|
||||
this.rowsClip.clear().rect(0, RULER_HEIGHT, this.w, below).fill(0xffffff);
|
||||
this.cellClip.clear().rect(HEADER_WIDTH, RULER_HEIGHT, right, below).fill(0xffffff);
|
||||
for (const g of [this.rowsBg, this.cells, this.keyMarks, this.ruler, this.overlay, this.scrollbar])
|
||||
g.clear();
|
||||
|
||||
// Header column and ruler chrome.
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
|
||||
this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel);
|
||||
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
|
||||
this.bg.rect(HEADER_WIDTH - 1, 0, 1, this.h).fill(c.border);
|
||||
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
|
||||
this.chrome.clear().rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
|
||||
this.chrome.rect(HEADER_WIDTH - 1, 0, 1, RULER_HEIGHT).fill(c.border);
|
||||
this.chrome.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
|
||||
|
||||
if (asset) {
|
||||
this.drawRows(asset, lanes, selectedRow);
|
||||
@@ -724,26 +828,42 @@ export class TimelineCanvas extends Widget {
|
||||
this.drawPlayhead();
|
||||
}
|
||||
if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current);
|
||||
this.drawScrollbar(lines.length);
|
||||
this.syncLabels(lines, selectedRow);
|
||||
this.syncFields(lines);
|
||||
this.syncHint(asset);
|
||||
}
|
||||
|
||||
/** Whether any of a row starting at `top` shows between the ruler and the bottom edge. */
|
||||
private inView(top: number): boolean {
|
||||
return top + ROW_HEIGHT > RULER_HEIGHT && top < this.h;
|
||||
}
|
||||
|
||||
private drawScrollbar(rowCount: number): void {
|
||||
const thumb = scrollThumb(this.view, rowCount, this.h);
|
||||
if (!thumb) return;
|
||||
const width = Theme.scrollbarWidth;
|
||||
this.scrollbar
|
||||
.roundRect(this.w - width - 2, thumb.top, width, thumb.height, width / 2)
|
||||
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
|
||||
}
|
||||
|
||||
private drawRows(asset: AnimationAsset, lanes: readonly Lane[], selectedRow: string): void {
|
||||
const c = Theme.colors;
|
||||
const count = frameCount(asset);
|
||||
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
|
||||
const x1 = HEADER_WIDTH + frameToX(this.view, count);
|
||||
lanes.forEach((track, row) => {
|
||||
const y = rowTop(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) {
|
||||
this.bg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader);
|
||||
this.bg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
if (track.id === selectedRow)
|
||||
this.bg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
|
||||
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({
|
||||
color: 0xffffff,
|
||||
alpha: row % 2 === 0 ? 0.02 : 0.04,
|
||||
@@ -773,7 +893,7 @@ export class TimelineCanvas extends Widget {
|
||||
if (count > 0) {
|
||||
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
|
||||
const x1 = HEADER_WIDTH + frameToX(this.view, count);
|
||||
this.cells.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
|
||||
this.ruler.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
|
||||
}
|
||||
let used = 0;
|
||||
const first = frameAt(this.view, 0);
|
||||
@@ -781,7 +901,7 @@ export class TimelineCanvas extends Widget {
|
||||
const x = HEADER_WIDTH + frameToX(this.view, frame);
|
||||
if (x > this.w) break;
|
||||
if (x < HEADER_WIDTH) continue;
|
||||
this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
|
||||
this.ruler.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
|
||||
let text = this.rulerTexts[used];
|
||||
if (!text) {
|
||||
text = crispText({ style: textStyle('small') });
|
||||
@@ -802,8 +922,8 @@ export class TimelineCanvas extends Widget {
|
||||
const drag = this.dragging?.moved ? this.dragging : null;
|
||||
const half = KEY_SIZE / 2;
|
||||
lanes.forEach((track, row) => {
|
||||
if (!track) return;
|
||||
const y = rowCentreY(row);
|
||||
if (!track || !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);
|
||||
@@ -818,7 +938,7 @@ export class TimelineCanvas extends Widget {
|
||||
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.overlay, interpolationOf(key), x, y, half)
|
||||
drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half)
|
||||
.fill(lit ? c.accent : c.text)
|
||||
.stroke({ width: 1, color: lit ? c.accentText : c.viewport });
|
||||
});
|
||||
@@ -871,8 +991,9 @@ export class TimelineCanvas extends Widget {
|
||||
(line.kind === 'row' && line.def ? HEADER_WIDTH - FIELD_WIDTH - FIELD_INSET * 2 : HEADER_WIDTH) - x;
|
||||
const text = truncate(line.kind === 'group' ? line.name : line.label, label.style, room - FIELD_INSET);
|
||||
if (label.text !== text) label.text = text;
|
||||
label.position.set(x, rowTop(row) + (ROW_HEIGHT - label.height) / 2);
|
||||
label.visible = rowTop(row) + ROW_HEIGHT <= this.h;
|
||||
const top = rowTop(row, this.view.scrollY);
|
||||
label.position.set(x, top + (ROW_HEIGHT - label.height) / 2);
|
||||
label.visible = this.inView(top);
|
||||
});
|
||||
for (const [id, label] of this.labels) {
|
||||
if (alive.has(id)) continue;
|
||||
@@ -916,9 +1037,10 @@ export class TimelineCanvas extends Widget {
|
||||
const widget = entry.field.widget;
|
||||
const w = entry.field.grow ? FIELD_WIDTH : Math.min(FIELD_WIDTH, widget.measure().w);
|
||||
const h = Theme.controlHeight;
|
||||
widget.position.set(HEADER_WIDTH - FIELD_INSET - w, rowTop(row) + (ROW_HEIGHT - h) / 2);
|
||||
const top = rowTop(row, this.view.scrollY);
|
||||
widget.position.set(HEADER_WIDTH - FIELD_INSET - w, top + (ROW_HEIGHT - h) / 2);
|
||||
if (widget.w !== w || widget.h !== h) widget.resize(w, h);
|
||||
widget.visible = rowTop(row) + ROW_HEIGHT <= this.h;
|
||||
widget.visible = this.inView(top);
|
||||
});
|
||||
for (const [id, entry] of this.fields) {
|
||||
if (alive.has(id)) continue;
|
||||
|
||||
@@ -6,13 +6,15 @@ import { clamp } from '../../ui';
|
||||
/**
|
||||
* The timeline's geometry, apart from Pixi so a test can drive it: a column of row headers on the
|
||||
* left, a frame ruler on top, and a grid of cells below it – one cell per row and frame. The view
|
||||
* pans and zooms horizontally only; rows have a fixed height and are never scrolled away.
|
||||
* pans and zooms across; rows have a fixed height and scroll up under the ruler (#413).
|
||||
*/
|
||||
export interface TimelineView {
|
||||
/** Screen pixels the cell of frame 0 starts at, measured from the left edge of the cell area. */
|
||||
panX: number;
|
||||
/** Width of one frame's cell in screen pixels. */
|
||||
cellWidth: number;
|
||||
/** Pixels the rows are scrolled up by; the ruler stays where it is. */
|
||||
scrollY: number;
|
||||
}
|
||||
|
||||
/** A line of the timeline as the geometry sees it: a row, or `null` for an object's head (#408). */
|
||||
@@ -32,7 +34,7 @@ export const KEY_SIZE = 10;
|
||||
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
|
||||
const RULER_LABEL_MIN_PX = 40;
|
||||
|
||||
export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH };
|
||||
export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH, scrollY: 0 };
|
||||
|
||||
/** The x of the left edge of `frame`'s cell, in cell-area pixels. */
|
||||
export function frameToX(view: TimelineView, frame: number): number {
|
||||
@@ -54,26 +56,73 @@ export function frameAt(view: TimelineView, x: number): number {
|
||||
return Math.max(0, Math.floor(xToFrame(view, x)));
|
||||
}
|
||||
|
||||
/** The row under `y` (measured from the top of the ruler), or -1 above the rows and past the last one. */
|
||||
export function rowAt(y: number, rowCount: number): number {
|
||||
/**
|
||||
* The row under `y` (measured from the top of the ruler), or -1 over the ruler – a row scrolled up
|
||||
* under it included – and past the last one.
|
||||
*/
|
||||
export function rowAt(y: number, rowCount: number, scrollY = 0): number {
|
||||
if (y < RULER_HEIGHT) return -1;
|
||||
const row = Math.floor((y - RULER_HEIGHT) / ROW_HEIGHT);
|
||||
const row = Math.floor((y - RULER_HEIGHT + scrollY) / ROW_HEIGHT);
|
||||
return row < rowCount ? row : -1;
|
||||
}
|
||||
|
||||
export function rowTop(row: number): number {
|
||||
return RULER_HEIGHT + row * ROW_HEIGHT;
|
||||
export function rowTop(row: number, scrollY = 0): number {
|
||||
return RULER_HEIGHT + row * ROW_HEIGHT - scrollY;
|
||||
}
|
||||
|
||||
export function rowCentreY(row: number): number {
|
||||
return rowTop(row) + ROW_HEIGHT / 2;
|
||||
export function rowCentreY(row: number, scrollY = 0): number {
|
||||
return rowTop(row, scrollY) + ROW_HEIGHT / 2;
|
||||
}
|
||||
|
||||
/** How far the rows scroll in a timeline `height` pixels tall: the last row may come up to the bottom edge. */
|
||||
export function maxScrollY(rowCount: number, height: number): number {
|
||||
return Math.max(0, rowCount * ROW_HEIGHT - Math.max(0, height - RULER_HEIGHT));
|
||||
}
|
||||
|
||||
export function clampScroll(view: TimelineView, rowCount: number, height: number): TimelineView {
|
||||
const scrollY = clamp(view.scrollY, 0, maxScrollY(rowCount, height));
|
||||
return scrollY === view.scrollY ? view : { ...view, scrollY };
|
||||
}
|
||||
|
||||
/** Scrolls just far enough that the whole of `row` is below the ruler and above the bottom edge. */
|
||||
export function revealRow(view: TimelineView, row: number, rowCount: number, height: number): TimelineView {
|
||||
const top = row * ROW_HEIGHT;
|
||||
const room = Math.max(ROW_HEIGHT, height - RULER_HEIGHT);
|
||||
const scrollY =
|
||||
top < view.scrollY
|
||||
? top
|
||||
: top + ROW_HEIGHT > view.scrollY + room
|
||||
? top + ROW_HEIGHT - room
|
||||
: view.scrollY;
|
||||
return clampScroll({ ...view, scrollY }, rowCount, height);
|
||||
}
|
||||
|
||||
/** The scrollbar's thumb beside the rows, or null while every row fits. */
|
||||
export function scrollThumb(
|
||||
view: TimelineView,
|
||||
rowCount: number,
|
||||
height: number,
|
||||
): { top: number; height: number } | null {
|
||||
const max = maxScrollY(rowCount, height);
|
||||
if (max <= 0) return null;
|
||||
const track = height - RULER_HEIGHT;
|
||||
const thumb = Math.min(track, Math.max(20, (track * track) / (rowCount * ROW_HEIGHT)));
|
||||
return { top: RULER_HEIGHT + (clamp(view.scrollY, 0, max) / max) * (track - thumb), height: thumb };
|
||||
}
|
||||
|
||||
/** The scroll a thumb dragged by `dy` pixels from where it was at `from` asks for. */
|
||||
export function scrollByThumb(from: number, dy: number, rowCount: number, height: number): number {
|
||||
const max = maxScrollY(rowCount, height);
|
||||
const thumb = scrollThumb({ ...DEFAULT_VIEW, scrollY: from }, rowCount, height);
|
||||
const travel = thumb ? height - RULER_HEIGHT - thumb.height : 0;
|
||||
return travel > 0 ? clamp(from + (dy / travel) * max, 0, max) : 0;
|
||||
}
|
||||
|
||||
/** Zooms the cells about `x` (cell-area pixels), so the frame under the pointer stays under it. */
|
||||
export function zoomCells(view: TimelineView, factor: number, x: number): TimelineView {
|
||||
const cellWidth = clamp(view.cellWidth * factor, MIN_CELL_WIDTH, MAX_CELL_WIDTH);
|
||||
const frame = xToFrame(view, x);
|
||||
return { panX: x - frame * cellWidth, cellWidth };
|
||||
return { ...view, panX: x - frame * cellWidth, cellWidth };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +146,7 @@ export function rulerStep(view: TimelineView): number {
|
||||
* whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing.
|
||||
*/
|
||||
export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null {
|
||||
const row = rowAt(point.y, tracks.length);
|
||||
const row = rowAt(point.y, tracks.length, view.scrollY);
|
||||
const track = row < 0 ? null : tracks[row];
|
||||
if (!track) return null;
|
||||
const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2);
|
||||
@@ -116,7 +165,7 @@ export function keysWithin(view: TimelineView, tracks: readonly Lane[], a: Vec2,
|
||||
const bottom = Math.max(a.y, b.y);
|
||||
const out: KeyRef[] = [];
|
||||
tracks.forEach((track, row) => {
|
||||
const y = rowCentreY(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);
|
||||
|
||||
@@ -47,6 +47,8 @@ export function documentHooks({ state, editor }: HookContext) {
|
||||
lines: () => editor.animationEditor.canvas.linesDrawn(),
|
||||
valueAt: (trackId: string) => editor.animationEditor.canvas.valueGlobal(trackId),
|
||||
view: () => editor.animationEditor.canvas.viewInfo(),
|
||||
/** The scrollbar's thumb beside the rows (#413), or null while they all fit. */
|
||||
thumb: () => editor.animationEditor.canvas.thumbGlobal(),
|
||||
scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame),
|
||||
/** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */
|
||||
rowDialog: () => currentRowDialog()?.debugInfo() ?? null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AnimationTrack } from '../../src/core/model/Asset';
|
||||
import {
|
||||
clampMove,
|
||||
clampPan,
|
||||
clampScroll,
|
||||
DEFAULT_VIEW,
|
||||
frameAt,
|
||||
frameCentreX,
|
||||
@@ -10,13 +11,17 @@ import {
|
||||
keyAt,
|
||||
keysWithin,
|
||||
MAX_CELL_WIDTH,
|
||||
maxScrollY,
|
||||
MIN_CELL_WIDTH,
|
||||
revealRow,
|
||||
ROW_HEIGHT,
|
||||
rowAt,
|
||||
rowCentreY,
|
||||
rowTop,
|
||||
RULER_HEIGHT,
|
||||
rulerStep,
|
||||
scrollByThumb,
|
||||
scrollThumb,
|
||||
type TimelineView,
|
||||
xToFrame,
|
||||
zoomCells,
|
||||
@@ -37,7 +42,7 @@ const TRACKS: AnimationTrack[] = [
|
||||
|
||||
describe('timeline geometry', () => {
|
||||
it('maps frames to cells and back', () => {
|
||||
const view: TimelineView = { panX: 10, cellWidth: 20 };
|
||||
const view: TimelineView = { panX: 10, cellWidth: 20, scrollY: 0 };
|
||||
expect(frameToX(view, 0)).toBe(10);
|
||||
expect(frameToX(view, 3)).toBe(70);
|
||||
expect(frameCentreX(view, 3)).toBe(80);
|
||||
@@ -47,7 +52,7 @@ describe('timeline geometry', () => {
|
||||
});
|
||||
|
||||
it('never answers a frame below 0', () => {
|
||||
expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0);
|
||||
expect(frameAt({ panX: 0, cellWidth: 16, scrollY: 0 }, -40)).toBe(0);
|
||||
});
|
||||
|
||||
it('finds rows below the ruler and nothing beyond the last', () => {
|
||||
@@ -58,7 +63,7 @@ describe('timeline geometry', () => {
|
||||
});
|
||||
|
||||
it('zooms about the pointer, so the frame under it stays put', () => {
|
||||
const view: TimelineView = { panX: -100, cellWidth: 16 };
|
||||
const view: TimelineView = { panX: -100, cellWidth: 16, scrollY: 0 };
|
||||
const x = 200;
|
||||
const before = xToFrame(view, x);
|
||||
const zoomed = zoomCells(view, 1.5, x);
|
||||
@@ -77,21 +82,21 @@ describe('timeline geometry', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
expect(clampPan({ panX: 30, cellWidth: 16, scrollY: 0 }).panX).toBe(0);
|
||||
expect(clampPan({ panX: -30, cellWidth: 16, scrollY: 0 }).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);
|
||||
expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500);
|
||||
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000);
|
||||
expect(rulerStep({ panX: 0, cellWidth: 48, scrollY: 0 })).toBe(1);
|
||||
expect(rulerStep({ panX: 0, cellWidth: 16, scrollY: 0 })).toBe(5);
|
||||
expect(rulerStep({ panX: 0, cellWidth: 4, scrollY: 0 })).toBe(10);
|
||||
expect(rulerStep({ panX: 0, cellWidth: 0.1, scrollY: 0 })).toBe(500);
|
||||
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH, scrollY: 0 })).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picking keys', () => {
|
||||
const view: TimelineView = { panX: 0, cellWidth: 16 };
|
||||
const view: TimelineView = { panX: 0, cellWidth: 16, scrollY: 0 };
|
||||
|
||||
it('hits the key drawn at the centre of its cell', () => {
|
||||
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({
|
||||
@@ -138,6 +143,19 @@ describe('picking keys', () => {
|
||||
expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('finds the key of a row scrolled up, and nothing under the ruler (#413)', () => {
|
||||
const scrolled: TimelineView = { ...view, scrollY: ROW_HEIGHT };
|
||||
expect(keyAt(scrolled, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })?.trackId).toBe('b');
|
||||
expect(rowAt(RULER_HEIGHT - 1, 2, ROW_HEIGHT)).toBe(-1);
|
||||
const band = keysWithin(
|
||||
scrolled,
|
||||
TRACKS,
|
||||
{ x: 0, y: RULER_HEIGHT },
|
||||
{ x: frameToX(view, 10), y: rowTop(1) },
|
||||
);
|
||||
expect(band).toEqual([{ trackId: 'b', frame: 2 }]);
|
||||
});
|
||||
|
||||
it('a move never takes a key before frame 0', () => {
|
||||
const keys = [
|
||||
{ trackId: 'a', frame: 2 },
|
||||
@@ -148,3 +166,48 @@ describe('picking keys', () => {
|
||||
expect(clampMove([], -3)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling the rows (#413)', () => {
|
||||
// Room for 4 rows below the ruler, and 10 of them: 6 rows' worth to scroll.
|
||||
const height = RULER_HEIGHT + 4 * ROW_HEIGHT;
|
||||
const at = (scrollY: number): TimelineView => ({ ...DEFAULT_VIEW, scrollY });
|
||||
|
||||
it('scrolls no further than the last row coming up to the bottom edge, and not above the first', () => {
|
||||
expect(maxScrollY(10, height)).toBe(6 * ROW_HEIGHT);
|
||||
expect(clampScroll(at(10_000), 10, height).scrollY).toBe(6 * ROW_HEIGHT);
|
||||
expect(clampScroll(at(-40), 10, height).scrollY).toBe(0);
|
||||
expect(maxScrollY(3, height)).toBe(0);
|
||||
expect(clampScroll(at(50), 3, height).scrollY).toBe(0);
|
||||
});
|
||||
|
||||
it('brings a picked row into view, just far enough, and leaves one in view alone', () => {
|
||||
expect(revealRow(at(0), 9, 10, height).scrollY).toBe(6 * ROW_HEIGHT);
|
||||
expect(revealRow(at(0), 5, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
|
||||
expect(revealRow(at(6 * ROW_HEIGHT), 1, 10, height).scrollY).toBe(ROW_HEIGHT);
|
||||
expect(revealRow(at(2 * ROW_HEIGHT), 3, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
|
||||
// Half hidden under the ruler is out of view.
|
||||
expect(revealRow(at(ROW_HEIGHT / 2), 0, 10, height).scrollY).toBe(0);
|
||||
});
|
||||
|
||||
it('shows a thumb only while the rows do not fit, from top to bottom of its track', () => {
|
||||
expect(scrollThumb(at(0), 4, height)).toBeNull();
|
||||
const top = scrollThumb(at(0), 10, height)!;
|
||||
const bottom = scrollThumb(at(6 * ROW_HEIGHT), 10, height)!;
|
||||
expect(top.top).toBe(RULER_HEIGHT);
|
||||
expect(top.height).toBeCloseTo((4 * ROW_HEIGHT * 4) / 10);
|
||||
expect(bottom.top + bottom.height).toBeCloseTo(height);
|
||||
});
|
||||
|
||||
it('a thumb dragged its whole travel scrolls the whole way, and no further', () => {
|
||||
const thumb = scrollThumb(at(0), 10, height)!;
|
||||
const travel = height - RULER_HEIGHT - thumb.height;
|
||||
expect(scrollByThumb(0, travel, 10, height)).toBeCloseTo(6 * ROW_HEIGHT);
|
||||
expect(scrollByThumb(0, travel / 2, 10, height)).toBeCloseTo(3 * ROW_HEIGHT);
|
||||
expect(scrollByThumb(0, 10 * travel, 10, height)).toBe(6 * ROW_HEIGHT);
|
||||
expect(scrollByThumb(ROW_HEIGHT, -10 * travel, 10, height)).toBe(0);
|
||||
});
|
||||
|
||||
it('zooming keeps the rows where they were scrolled to', () => {
|
||||
expect(zoomCells(at(40), 2, 0).scrollY).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const SIZE = 40;
|
||||
* removes everything it made.
|
||||
*/
|
||||
export async function animation(t) {
|
||||
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText } = t;
|
||||
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText, wheel } = t;
|
||||
const { undo, settle, sleep, waitUntil, find } = t;
|
||||
|
||||
await press('Escape');
|
||||
@@ -496,6 +496,55 @@ export async function animation(t) {
|
||||
JSON.stringify({ shownX, keyedX }),
|
||||
);
|
||||
|
||||
// 25j. More rows than room (#413): 16 objects with a row each. The wheel over the header column
|
||||
// scrolls the rows, the last one comes into view and its field keys a typed value; the
|
||||
// scrollbar's thumb dragged up takes them back to the top.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const box = p.assets.find((a) => a.id === 'an-box');
|
||||
const arm = box.objects[0];
|
||||
for (let i = 2; i <= 16; i++)
|
||||
box.objects.push({ ...structuredClone(arm), id: 'an-arm' + i, name: 'Arm ' + i });
|
||||
p.assets.find((a) => a.id === 'an-walk').tracks = box.objects.map((o, i) => (
|
||||
{ id: i === 15 ? 'an-last' : 'an-s' + i, target: o.id, property: 'x', keys: [] }
|
||||
));
|
||||
`);
|
||||
await evaluate('__gm.animation.scrub(2)');
|
||||
await sleep(150);
|
||||
const before = { view: await json('__gm.animation.view()'), thumb: await json('__gm.animation.thumb()') };
|
||||
const lastHidden = await json("__gm.animation.valueAt('an-last')");
|
||||
const header = await json("__gm.animation.headerAt('an-s0')");
|
||||
await wheel(header.x, header.y, 5000);
|
||||
const scrolled = await json('__gm.animation.view()');
|
||||
const lastField = await json("__gm.animation.valueAt('an-last')");
|
||||
if (lastField) {
|
||||
await click(lastField.x, lastField.y);
|
||||
await typeText('77');
|
||||
await press('Enter');
|
||||
await sleep(150);
|
||||
}
|
||||
const lastKeys = (await walk()).tracks.find((r) => r.id === 'an-last').keys;
|
||||
check(
|
||||
'the wheel over the headers scrolls to the last row, and its field keys a typed value',
|
||||
before.view.scrollY === 0 &&
|
||||
before.thumb !== null &&
|
||||
lastHidden === null &&
|
||||
scrolled.scrollY === before.thumb.max &&
|
||||
scrolled.cellWidth === before.view.cellWidth &&
|
||||
lastField !== null &&
|
||||
JSON.stringify(lastKeys) === JSON.stringify([{ frame: 2, value: 77 }]),
|
||||
JSON.stringify({ before, lastHidden, scrolled, lastField, lastKeys }),
|
||||
);
|
||||
await shot('21-scrolled-rows');
|
||||
const thumb = await json('__gm.animation.thumb()');
|
||||
await drag(thumb.x, thumb.y, thumb.x, 5);
|
||||
const toTop = await json('__gm.animation.view()');
|
||||
check(
|
||||
'the scrollbar thumb dragged up takes the rows back to the top',
|
||||
toTop.scrollY === 0 && (await json("__gm.animation.valueAt('an-last')")) === null,
|
||||
JSON.stringify({ thumb, toTop }),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));
|
||||
|
||||
Reference in new issue
Block a user