Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
4.9 KiB
TypeScript
147 lines
4.9 KiB
TypeScript
import type { Asset } from '../core/model/Asset';
|
||
import { findAsset } from '../core/model/Project';
|
||
import { Button, Label, Spacer, VStack, type Widget } from '../ui';
|
||
import type { EditorState } from './state';
|
||
import { TabHeader } from './TabHeader';
|
||
|
||
/**
|
||
* The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much.
|
||
* A canvas without `fit` belongs to a tab whose Fit frames something else (see `AssetTabEditor.fit`).
|
||
* `saveView`/`restoreView` carry zoom and scroll across a switch to another document and back.
|
||
*/
|
||
export interface TabCanvas {
|
||
readonly currentAssetId: string;
|
||
show(assetId: string): void;
|
||
fit?(): void;
|
||
saveView?(): unknown;
|
||
restoreView?(view: unknown): void;
|
||
}
|
||
|
||
export interface AssetTabOptions {
|
||
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
|
||
infoWidth: number;
|
||
}
|
||
|
||
/**
|
||
* The Behavior, State machine, Texture, Container … tabs. One editor per kind serves every open
|
||
* document of that kind (#171): the tab row tells it which one to show, and it keeps each one's
|
||
* view for when it comes back. A subclass must call `mount()`: header and canvas depend on each
|
||
* other, so the base cannot build them.
|
||
*/
|
||
export abstract class AssetTabEditor<
|
||
A extends Asset,
|
||
C extends TabCanvas & Widget = TabCanvas & Widget,
|
||
> extends VStack {
|
||
readonly fitButton: Button;
|
||
protected readonly info: Label;
|
||
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
|
||
canvas!: C;
|
||
private readonly views = new Map<string, unknown>();
|
||
|
||
constructor(
|
||
protected readonly state: EditorState,
|
||
options: AssetTabOptions,
|
||
) {
|
||
super();
|
||
this.fitButton = new Button('Fit', { onClick: () => this.fit() });
|
||
this.info = new Label('', { style: 'small', align: 'right' });
|
||
this.info.minWidth = options.infoWidth;
|
||
}
|
||
|
||
/** Builds the header (with `strip` below it) around `canvas` and wires the state events. */
|
||
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
|
||
this.canvas = canvas;
|
||
const header = new TabHeader();
|
||
for (const widget of extras) header.add(widget);
|
||
header.add(this.fitButton);
|
||
header.add(new Spacer(), 1);
|
||
header.add(this.info);
|
||
|
||
this.add(header);
|
||
if (strip) this.add(strip);
|
||
this.add(this.body(canvas), 1);
|
||
|
||
this.state.on('project', () => this.refresh());
|
||
this.state.on('load', () => {
|
||
this.views.clear();
|
||
this.canvas.show('');
|
||
this.refresh();
|
||
});
|
||
this.refresh();
|
||
}
|
||
|
||
/** The asset currently shown, `''` for none – the check scripts read this. */
|
||
get currentAssetId(): string {
|
||
return this.canvas.currentAssetId;
|
||
}
|
||
|
||
/** The right-hand readout as shown – the check scripts read this. */
|
||
get shownInfo(): string {
|
||
return this.info.text;
|
||
}
|
||
|
||
/** Shows a document, with the view it had when it was last on screen. */
|
||
showAsset(assetId: string): void {
|
||
const from = this.canvas.currentAssetId;
|
||
if (from !== assetId) {
|
||
if (from) this.views.set(from, this.saveView());
|
||
this.canvas.show(assetId);
|
||
const view = this.views.get(assetId);
|
||
if (view !== undefined) this.restoreView(view);
|
||
}
|
||
this.refresh();
|
||
}
|
||
|
||
/** A closed tab: opened again, the document starts from a fitted view. */
|
||
forgetView(assetId: string): void {
|
||
this.views.delete(assetId);
|
||
}
|
||
|
||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||
abstract selectedId(): string;
|
||
|
||
abstract isMine(asset: Asset): asset is A;
|
||
|
||
/** The right-hand readout; `null` is the empty state. */
|
||
protected abstract infoText(asset: A | null): string;
|
||
|
||
/** What fills the tab below the header. Override to put something beside the canvas. */
|
||
protected body(canvas: C): Widget {
|
||
return canvas;
|
||
}
|
||
|
||
/** What the header's Fit button frames. Override when the tab has more than one view. */
|
||
protected fit(): void {
|
||
this.canvas.fit?.();
|
||
}
|
||
|
||
/** The view to come back to; override when the tab has more than one. */
|
||
protected saveView(): unknown {
|
||
return this.canvas.saveView?.();
|
||
}
|
||
|
||
protected restoreView(view: unknown): void {
|
||
this.canvas.restoreView?.(view);
|
||
}
|
||
|
||
/** The canvas's area on screen, without the header – so a check can ask what lies outside it. */
|
||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||
const p = this.canvas.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||
}
|
||
|
||
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||
protected syncExtras(_asset: A | null): void {}
|
||
|
||
protected asset(): A | null {
|
||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||
return asset && this.isMine(asset) ? asset : null;
|
||
}
|
||
|
||
protected refresh(): void {
|
||
const asset = this.asset();
|
||
this.info.setText(asset ? this.infoText(asset) : '');
|
||
this.syncExtras(asset);
|
||
}
|
||
}
|