Refactoring round 6: shared sheet canvas, fewer copies (#85)
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 06:57:43 +02:00
1 parent d7c0b7cfdb
commit 056a622524
51 files changed
+393 -525

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import type { Vec2 } from '../../core/model/Properties';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { imageToLocal, type SheetView, zoomAbout } from './sheetView';
/**
* The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art
* and Vector art tabs. It owns the ground, the clip, the hint and the view: a middle drag or a
* Shift drag pans, the wheel zooms, Fit fits, and each document keeps its view. What a left press
* does is the tab's; it only gets one while `pressTarget` has something to press on.
*/
export abstract class SheetCanvas<A> extends Widget {
protected readonly bg = new Graphics();
/** Everything the view moves; the checker board is its bottom layer. */
protected readonly content = new Container();
protected readonly checker = new Graphics();
protected readonly hint = new Text({ text: '', style: textStyle('muted') });
protected readonly clip = new Graphics();
protected view: SheetView = { panX: 0, panY: 0, zoom: 1 };
protected assetId = '';
/** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */
protected needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
constructor() {
super();
this.content.addChild(this.checker);
this.addChild(this.bg, this.content, this.hint, this.clip);
this.content.mask = this.clip;
this.content.eventMode = 'none';
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
}
/** What a press works on; while null every press is ignored, a pan included. */
protected abstract pressTarget(): A | null;
protected abstract press(target: A, e: FederatedPointerEvent, local: Vec2): void;
/** The pointer moved while no pan runs. */
protected abstract move(local: Vec2): void;
protected abstract release(): void;
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
get currentAssetId(): string {
return this.assetId;
}
saveView(): { view: SheetView; needsFit: boolean } {
return { view: { ...this.view }, needsFit: this.needsFit };
}
restoreView(saved: unknown): void {
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
if (needsFit) return;
this.view = { ...view };
this.needsFit = false;
this.redraw();
}
fit(): void {
this.needsFit = true;
this.redraw();
}
/** Where a picture pixel sits on screen; the check scripts aim their drags with it. */
imageToScreen(x: number, y: number): Vec2 {
const local = imageToLocal(this.view, x, y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/** The pan a drag has reached; the pixel art tab keeps it on whole screen pixels. */
protected panTo(panX: number, panY: number): void {
this.view = { ...this.view, panX, panY };
}
protected onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
e.preventDefault();
}
protected drawGround(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
}
private onPointerDown(e: FederatedPointerEvent): void {
const target = this.pressTarget();
if (!target) return;
const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
this.press(target, e, { x: local.x, y: local.y });
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const panning = this.panning;
if (!panning) {
this.move({ x: local.x, y: local.y });
return;
}
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
this.redraw();
}
private onPointerUp(): void {
this.panning = null;
this.release();
}
}
+17 -102
View File
@@ -1,11 +1,4 @@
import {
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
Sprite,
Text,
} from 'pixi.js';
import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js';
import {
AddFrameCommand,
SetFrameCollisionCommand,
@@ -28,7 +21,7 @@ import {
setFrameCollision,
setFrameSlice,
} from '../../core/model/texture';
import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
import {
gripAt,
insideOutline,
@@ -42,6 +35,7 @@ import { dashedLine } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from './checker';
import { SheetCanvas } from './SheetCanvas';
import {
borderAt,
borderCursor,
@@ -59,8 +53,6 @@ import {
imageToLocal,
localToImage,
rectBetween,
type SheetView,
zoomAbout,
} from './sheetView';
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
@@ -113,22 +105,11 @@ interface NewFrame {
* The Texture tab's canvas. Everything but the picture is drawn in widget-local pixels rather
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
*/
export class TextureCanvas extends Widget {
private readonly bg = new Graphics();
/** Everything the view can move: panned or zoomed, all of it reaches past the widget. */
private readonly content = new Container();
private readonly checker = new Graphics();
export class TextureCanvas extends SheetCanvas<TextureAsset> {
private readonly image = new Sprite();
private readonly overlay = new Graphics();
private readonly labels = new Map<string, Text>();
private readonly labelLayer = new Container();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
private assetId = '';
/** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */
private needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null;
@@ -140,18 +121,7 @@ export class TextureCanvas extends Widget {
constructor(private readonly state: EditorState) {
super();
this.content.addChild(this.checker, this.image, this.overlay, this.labelLayer);
this.addChild(this.bg, this.content, this.hint, this.clip);
this.content.mask = this.clip;
this.content.eventMode = 'none';
this.hint.eventMode = 'none';
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
this.content.addChild(this.image, this.overlay, this.labelLayer);
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
@@ -169,10 +139,6 @@ export class TextureCanvas extends Widget {
super.destroy(options);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
/** Switching resets the view, since the next picture is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
@@ -181,22 +147,6 @@ export class TextureCanvas extends Widget {
this.redraw();
}
get currentAssetId(): string {
return this.assetId;
}
saveView(): { view: SheetView; needsFit: boolean } {
return { view: { ...this.view }, needsFit: this.needsFit };
}
restoreView(saved: unknown): void {
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
if (needsFit) return;
this.view = { ...view };
this.needsFit = false;
this.redraw();
}
setCollisionMode(on: boolean): void {
if (this.collision === on) return;
this.collision = on;
@@ -207,18 +157,6 @@ export class TextureCanvas extends Widget {
return this.collision;
}
fit(): void {
this.needsFit = true;
this.redraw();
}
/** Where an image pixel sits on screen; only the check scripts need it, to aim drags. */
imageToScreen(x: number, y: number): Vec2 {
const local = imageToLocal(this.view, x, y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
private asset(): TextureAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'texture' ? asset : null;
@@ -259,19 +197,16 @@ export class TextureCanvas extends Widget {
return true;
}
private onPointerDown(e: FederatedPointerEvent): void {
/**
* Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame that
* lies off the sheet.
*/
protected pressTarget(): TextureAsset | null {
const asset = this.asset();
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
// that lies off the sheet.
if (!asset || asset.width <= 0 || asset.height <= 0) return;
const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
}
if (e.button !== 0) return;
ui().focus.focus(this);
return asset && asset.width > 0 && asset.height > 0 ? asset : null;
}
protected press(asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void {
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
if (this.collision) {
@@ -430,17 +365,7 @@ export class TextureCanvas extends Widget {
};
}
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = {
...this.view,
panX: this.panning.panX + (local.x - this.panning.pointer.x),
panY: this.panning.panY + (local.y - this.panning.pointer.y),
};
this.redraw();
return;
}
protected move(local: Vec2): void {
if (this.corner) {
const corner = this.corner;
const frame = this.frame(corner.frameId);
@@ -502,8 +427,7 @@ export class TextureCanvas extends Widget {
this.state.preview();
}
private onPointerUp(): void {
this.panning = null;
protected release(): void {
const corner = this.corner;
this.corner = null;
const frame = corner && this.frame(corner.frameId);
@@ -553,14 +477,6 @@ export class TextureCanvas extends Widget {
this.redraw();
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
e.preventDefault();
}
private updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
@@ -594,8 +510,7 @@ export class TextureCanvas extends Widget {
}
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.drawGround();
const asset = this.asset();
const texture = asset && asset.file ? this.pictureOf(asset) : null;
-6
View File
@@ -41,12 +41,6 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
return this.canvas.imageToScreen(x, y);
}
/** The drawing area 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 };
}
override selectedId(): string {
return this.state.selectedTextureId ?? '';
}