Building a curve by mouse alone was fiddly: there was no way to put a point on an exact value, no grid, and the running preview could not be stopped at a chosen moment. The hand cursor over points also looked like a link instead of a drawing tool. - Number fields t and v in the header show the selected point. Typing or dragging on them goes through the same rules as a mouse drag, so points never overtake and handles stay clamped. One undo step each. t is greyed out on the two end points. - The magnet button from Vector art snaps points and handles to a 0.05 grid in t and v, when placing on free space and when dragging. - Points and handles keep the default arrow in the curve tab and in Vector art. The pixel art palette dropped pointer as well, which is beyond the issue. Collider corners never had it. - The preview now starts off. A vertical time line with a knob under the t axis sets t when dragged sideways. Dragging the dot on the rail jumps to the first t where the curve reaches that value, or to the closest one if it never does. - The rules are pure functions in curveView.ts (withCurvePointSet, snappedToGrid, shareAtValue, previewSecondsAt), the canvas only calls them. Tests: - ./check.sh: 180 files, 2616 tests, CHECK EXIT 0 - GM_STEPS=1,47,53: ALL PASSED, 87 checks - 4 new unit tests in curve-view.test.ts, 4 new e2e checks in 53 (53h-53k) and 1 in 47, all with real mouse and keyboard - Counter-probes bite: without "first t" 1 unit test fails, with pointer, no snap, no field commit, no scrub or preview on 5 browser checks fail, pointer in Vector art fails the new 47 check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||
import { type Size, Theme, Widget } from '../../ui';
|
||
|
||
const CELL = 20;
|
||
const GAP = 2;
|
||
|
||
export interface PaletteLayout {
|
||
columns: number;
|
||
/** The side of a swatch: at least `CELL`, grown so the row ends flush with the width. */
|
||
cell: number;
|
||
rows: number;
|
||
}
|
||
|
||
/** As many swatches to a row as the width holds (#201); a width not known yet lays out eight. */
|
||
export function paletteLayout(width: number, swatches: number): PaletteLayout {
|
||
const columns = width > 0 ? Math.max(1, Math.floor((width + GAP) / (CELL + GAP))) : 8;
|
||
const cell = width > 0 ? Math.max(CELL, Math.floor((width - (columns - 1) * GAP) / columns)) : CELL;
|
||
return { columns, cell, rows: Math.max(1, Math.ceil(swatches / columns)) };
|
||
}
|
||
|
||
/**
|
||
* The colours of a palette as swatches across the whole width (#130, #201). The first swatch is
|
||
* index 0 – transparent, drawn as a checkerboard – and the others follow the palette in order, so a
|
||
* swatch's place is its index. A click picks it; the picked one is outlined.
|
||
*/
|
||
export class PaletteGrid extends Widget {
|
||
private readonly swatches = new Graphics();
|
||
private colors: readonly number[] = [];
|
||
private picked = 1;
|
||
onPick: (index: number) => void = () => {};
|
||
/** Told when the swatches need another number of rows, so the column around lays out again. */
|
||
onReflow: () => void = () => {};
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.swatches);
|
||
this.cursor = Theme.hoverCursor;
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
const local = this.toLocal(e.global);
|
||
const { columns, cell } = this.grid();
|
||
const column = Math.floor(local.x / (cell + GAP));
|
||
const row = Math.floor(local.y / (cell + GAP));
|
||
const index = row * columns + column;
|
||
if (column < columns && index <= this.colors.length) this.onPick(index);
|
||
e.stopPropagation();
|
||
});
|
||
}
|
||
|
||
private grid(): PaletteLayout {
|
||
return paletteLayout(this.w, this.colors.length + 1);
|
||
}
|
||
|
||
setColors(colors: readonly number[], picked: number): void {
|
||
const before = this.grid().rows;
|
||
this.colors = colors;
|
||
this.picked = picked;
|
||
if (this.grid().rows !== before) this.onReflow();
|
||
this.invalidate();
|
||
}
|
||
|
||
override measure(): Size {
|
||
const { columns, cell, rows } = this.grid();
|
||
return { w: columns * (cell + GAP) - GAP, h: rows * (cell + GAP) };
|
||
}
|
||
|
||
/** A new width can mean another number of rows, and so another height than the one given. */
|
||
override layout(): void {
|
||
if (this.measure().h !== this.h) queueMicrotask(() => this.onReflow());
|
||
}
|
||
|
||
/** Where a swatch's centre sits on screen – the check scripts click it. */
|
||
swatchGlobal(index: number): { x: number; y: number } {
|
||
const p = this.getGlobalPosition();
|
||
const { cell } = this.grid();
|
||
const at = this.cellAt(index);
|
||
return { x: p.x + at.x + cell / 2, y: p.y + at.y + cell / 2 };
|
||
}
|
||
|
||
private cellAt(index: number): { x: number; y: number } {
|
||
const { columns, cell } = this.grid();
|
||
return {
|
||
x: (index % columns) * (cell + GAP),
|
||
y: Math.floor(index / columns) * (cell + GAP),
|
||
};
|
||
}
|
||
|
||
override redraw(): void {
|
||
const g = this.swatches.clear();
|
||
const { cell } = this.grid();
|
||
for (let index = 0; index <= this.colors.length; index++) {
|
||
const { x, y } = this.cellAt(index);
|
||
if (index === 0) {
|
||
g.rect(x, y, cell, cell).fill(0x9aa0aa);
|
||
g.rect(x, y, cell / 2, cell / 2)
|
||
.rect(x + cell / 2, y + cell / 2, cell / 2, cell / 2)
|
||
.fill(0x5a5f69);
|
||
} else g.rect(x, y, cell, cell).fill(this.colors[index - 1] ?? 0);
|
||
g.rect(x + 0.5, y + 0.5, cell - 1, cell - 1).stroke({ width: 1, color: Theme.colors.border });
|
||
}
|
||
if (this.picked >= 0 && this.picked <= this.colors.length) {
|
||
const { x, y } = this.cellAt(this.picked);
|
||
g.rect(x - 1, y - 1, cell + 2, cell + 2).stroke({ width: 2, color: Theme.colors.accent });
|
||
}
|
||
}
|
||
}
|