From 028d0cb3b6da1a101e9750e32817179c0010247d Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sat, 18 Jul 2026 22:11:17 +0200 Subject: [PATCH 1/3] refactor(pixel-draw-renderer): implement a new Zoom VO --- .../.changeset/wild-otters-zoom.md | 5 + packages/pixel-draw-renderer/README.md | 2 +- .../docs/PixelArtCanvas.md | 4 +- .../pixel-draw-renderer/src/PixelArtCanvas.ts | 16 ++- packages/pixel-draw-renderer/src/index.ts | 4 + .../src/rendering/CanvasRenderer.ts | 6 +- .../src/rendering/Viewport.ts | 76 +++++-------- .../pixel-draw-renderer/src/rendering/Zoom.ts | 101 ++++++++++++++++++ .../overlays/BrushHighlightOverlay.ts | 2 +- .../rendering/overlays/LinePreviewOverlay.ts | 2 +- .../rendering/overlays/SelectionOverlay.ts | 2 +- .../test/rendering/SvgManager.spec.ts | 3 +- .../test/rendering/Viewport.spec.ts | 36 +++---- .../test/rendering/Zoom.spec.ts | 73 +++++++++++++ .../overlays/BrushHighlightOverlay.spec.ts | 3 +- .../overlays/LinePreviewOverlay.spec.ts | 3 +- .../overlays/SelectionOverlay.spec.ts | 3 +- 17 files changed, 250 insertions(+), 91 deletions(-) create mode 100644 packages/pixel-draw-renderer/.changeset/wild-otters-zoom.md create mode 100644 packages/pixel-draw-renderer/src/rendering/Zoom.ts create mode 100644 packages/pixel-draw-renderer/test/rendering/Zoom.spec.ts diff --git a/packages/pixel-draw-renderer/.changeset/wild-otters-zoom.md b/packages/pixel-draw-renderer/.changeset/wild-otters-zoom.md new file mode 100644 index 0000000..5e3f7c0 --- /dev/null +++ b/packages/pixel-draw-renderer/.changeset/wild-otters-zoom.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": major +--- + +Extracted zoom state (value, bounds, wheel sensitivity, and delta-stepping math) out of `Viewport` into a new `Zoom` value object, exported from the package. `DefaultViewport.zoom` (and `Viewport.zoom`) is now a `Zoom` instance instead of a plain `number` — use `.zoom.value` for the numeric level and `.zoom.sensitivity` (get/set) instead of the removed `Viewport.zoomSensitivity` accessor pair. `PixelArtCanvas.zoom`/`zoomSensitivity` are unaffected and still return/accept plain numbers. `PixelArtCanvasOptions.zoom` now reuses the exported `ZoomOptions` type instead of an inline duplicate shape; as a result `zoom.default` is no longer required when passing a `zoom` option. diff --git a/packages/pixel-draw-renderer/README.md b/packages/pixel-draw-renderer/README.md index 984dfe4..015497a 100644 --- a/packages/pixel-draw-renderer/README.md +++ b/packages/pixel-draw-renderer/README.md @@ -138,7 +138,7 @@ Open `http://localhost:5173` to see the interactive demo. - [`PixelArtCanvas`](./docs/PixelArtCanvas.md): top-level coordinator, the primary public API - [`Brush`](./docs/tools/Brush.md): brush size, color, opacity, and affected-pixel computation — read/write via `PixelArtCanvas.brush` -- [`PixelBuffer`](./docs/buffer/PixelBuffer.md): headless RGBA pixel storage, usable server-side with no DOM (also documents the `onBufferUpdated`/`applyRemoteCommand` hook events) +- [`PixelBuffer`](./docs/buffer/PixelBuffer.md): headless RGBA pixel storage, usable server-side with no DOM - [`HistoryStack`](./docs/history/HistoryStack.md): bounded undo/redo stack backing `PixelArtCanvas.undo()`/`redo()` - [`Keybindings`](./docs/utils/keybindings.md): `Keybindings`/`Keybinding` types, `DEFAULT_KEYBINDINGS`, and the errors thrown by `patchKeybindings()` - [`Network`](./docs/network/index.md): transport-agnostic, server-authoritative multiplayer for `PixelArtCanvas` diff --git a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md index fb280e7..9b8bf6b 100644 --- a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md +++ b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md @@ -104,10 +104,10 @@ The brush instance. Use it to read or change the current brush color, opacity, a ### `viewport` ```ts -readonly viewport: DefaultViewport // { readonly zoom: number; readonly camera: Readonly; } +readonly viewport: DefaultViewport // { readonly zoom: Zoom; readonly camera: Readonly; } ``` -Read-only camera/zoom state. Use `camera`/`zoom` for copies, or the methods below for coordinate conversions and mutation. +Read-only camera/zoom state. `viewport.zoom` is a `Zoom` value object (`.value`, `.min`, `.max`, `.sensitivity`), not a plain number — use the top-level `zoom`/`zoomSensitivity` accessors below for the numeric level, or the methods below for coordinate conversions and mutation. ## Methods diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index 72bb9e3..82b17e7 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -47,6 +47,9 @@ import { Viewport, type DefaultViewport } from "./rendering/Viewport.ts"; +import type { + ZoomOptions +} from "./rendering/Zoom.ts"; import { SyncController } from "./sync/SyncController.ts"; @@ -86,12 +89,7 @@ export interface PixelArtCanvasOptions { maxSize?: number; init?: HTMLCanvasElement; }; - zoom?: { - default: number; - sensitivity?: number; - min?: number; - max?: number; - }; + zoom?: ZoomOptions; backgroundTransparency?: { colors: { odd: string; even: string; }; squareSize: number; @@ -400,17 +398,17 @@ export class PixelArtCanvas { } get zoom(): number { - return this.#viewport.zoom; + return this.#viewport.zoom.value; } get zoomSensitivity(): number { - return this.#viewport.zoomSensitivity; + return this.#viewport.zoom.sensitivity; } set zoomSensitivity( sensitivity: number ) { - this.#viewport.zoomSensitivity = sensitivity; + this.#viewport.zoom.sensitivity = sensitivity; } /** diff --git a/packages/pixel-draw-renderer/src/index.ts b/packages/pixel-draw-renderer/src/index.ts index 5138eaa..4ed1dbe 100644 --- a/packages/pixel-draw-renderer/src/index.ts +++ b/packages/pixel-draw-renderer/src/index.ts @@ -30,6 +30,10 @@ export { export type { DefaultViewport } from "./rendering/Viewport.ts"; +export { + Zoom, + type ZoomOptions +} from "./rendering/Zoom.ts"; export type { RGBA, SelectionRect, diff --git a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts index a8134a4..decbf1e 100644 --- a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts +++ b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts @@ -89,8 +89,8 @@ export class CanvasRenderer { const { zoom, camera } = this.#viewport; const texPx = this.#canvasBuffer.size(); - const texPixelW = texPx.x * zoom; - const texPixelH = texPx.y * zoom; + const texPixelW = texPx.x * zoom.value; + const texPixelH = texPx.y * zoom.value; this.#ctx.setTransform(1, 0, 0, 1, 0, 0); this.#ctx.fillStyle = this.#backgroundColor; @@ -104,7 +104,7 @@ export class CanvasRenderer { this.#ctx.setTransform(1, 0, 0, 1, 0, 0); this.#ctx.drawImage(this.#bgCanvas, 0, 0); - this.#ctx.setTransform(zoom, 0, 0, zoom, camera.x, camera.y); + this.#ctx.setTransform(zoom.value, 0, 0, zoom.value, camera.x, camera.y); this.#ctx.drawImage(this.#canvasBuffer.canvas(), 0, 0); this.floatingSelection.draw(this.#ctx); diff --git a/packages/pixel-draw-renderer/src/rendering/Viewport.ts b/packages/pixel-draw-renderer/src/rendering/Viewport.ts index 7f49f70..1cabea2 100644 --- a/packages/pixel-draw-renderer/src/rendering/Viewport.ts +++ b/packages/pixel-draw-renderer/src/rendering/Viewport.ts @@ -1,10 +1,11 @@ // Import Internal Dependencies import { clamp } from "../utils/math.ts"; import { ViewportTexture } from "./ViewportTexture.ts"; +import { Zoom } from "./Zoom.ts"; import type { Vec2 } from "../types.ts"; export interface DefaultViewport { - readonly zoom: number; + readonly zoom: Zoom; readonly camera: Readonly; } @@ -47,56 +48,35 @@ export class Viewport implements DefaultViewport { x: 0, y: 0 }; - #zoom: number; - #zoomMin: number; - #zoomMax: number; - #zoomSensitivity: number; #texture: ViewportTexture; #canvasWidth: number = 0; #canvasHeight: number = 0; + readonly zoom: Zoom; + constructor( options: ViewportOptions ) { const { - zoom = 4, - zoomMin = 1, - zoomMax = 32, - zoomSensitivity = 0.1, + zoom, + zoomMin, + zoomMax, + zoomSensitivity, textureSize } = options; - this.#zoomMin = zoomMin; - this.#zoomMax = zoomMax; - - if (this.#zoomMax < this.#zoomMin) { - throw new Error( - `Max zoom (${this.#zoomMax}) can't be under min zoom (${this.#zoomMin})` - ); - } - - this.#zoom = clamp(zoom, this.#zoomMin, this.#zoomMax); - this.#zoomSensitivity = zoomSensitivity; + this.zoom = new Zoom({ + default: zoom, + min: zoomMin, + max: zoomMax, + sensitivity: zoomSensitivity + }); this.#texture = new ViewportTexture({ size: textureSize, onResize: () => this.clampCamera() }); } - get zoom(): number { - return this.#zoom; - } - - get zoomSensitivity(): number { - return this.#zoomSensitivity; - } - - set zoomSensitivity( - sensitivity: number - ) { - this.#zoomSensitivity = Math.max(0.01, sensitivity); - } - get camera(): Readonly { return this.#camera; } @@ -114,7 +94,7 @@ export class Viewport implements DefaultViewport { } centerTexture(): void { - const texPx = this.#texture.pixelSize(this.#zoom); + const texPx = this.#texture.pixelSize(this.zoom.value); this.#camera.x = this.#canvasWidth / 2 - texPx.x / 2; this.#camera.y = this.#canvasHeight / 2 - texPx.y / 2; @@ -122,8 +102,8 @@ export class Viewport implements DefaultViewport { } clampCamera(): void { - const texPx = this.#texture.pixelSize(this.#zoom); - const margin = this.#zoom; + const texPx = this.#texture.pixelSize(this.zoom.value); + const margin = this.zoom.value; const minX = -texPx.x + margin; const maxX = this.#canvasWidth - margin; @@ -159,20 +139,14 @@ export class Viewport implements DefaultViewport { mx: number, my: number ): void { - const worldX = (mx - this.#camera.x) / this.#zoom; - const worldY = (my - this.#camera.y) / this.#zoom; - - const signDelta = Math.sign(delta); - const smoothSensitivity = - this.#zoom - signDelta * this.#zoomSensitivity < 1 || this.#zoom < 1 - ? this.#zoomSensitivity / 10 - : this.#zoomSensitivity; + const oldZoom = this.zoom.value; + const worldX = (mx - this.#camera.x) / oldZoom; + const worldY = (my - this.#camera.y) / oldZoom; - const newZoom = clamp(this.#zoom - signDelta * smoothSensitivity, this.#zoomMin, this.#zoomMax); + const newZoom = this.zoom.applyDelta(delta); - this.#camera.x -= worldX * newZoom - worldX * this.#zoom; - this.#camera.y -= worldY * newZoom - worldY * this.#zoom; - this.#zoom = newZoom; + this.#camera.x -= worldX * newZoom - worldX * oldZoom; + this.#camera.y -= worldY * newZoom - worldY * oldZoom; this.clampCamera(); } @@ -204,8 +178,8 @@ export class Viewport implements DefaultViewport { ): Vec2 | null { const { bounds, limit } = parameters; - const x = Math.floor((mx - bounds.left - this.#camera.x) / this.#zoom); - const y = Math.floor((my - bounds.top - this.#camera.y) / this.#zoom); + const x = Math.floor((mx - bounds.left - this.#camera.x) / this.zoom.value); + const y = Math.floor((my - bounds.top - this.#camera.y) / this.zoom.value); if (limit && !this.#texture.contains({ x, y })) { return null; diff --git a/packages/pixel-draw-renderer/src/rendering/Zoom.ts b/packages/pixel-draw-renderer/src/rendering/Zoom.ts new file mode 100644 index 0000000..82c1a60 --- /dev/null +++ b/packages/pixel-draw-renderer/src/rendering/Zoom.ts @@ -0,0 +1,101 @@ +// Import Internal Dependencies +import { clamp } from "../utils/math.ts"; + +export interface ZoomOptions { + /** + * Default zoom level. + * @default 4 + */ + default?: number; + /** + * Minimum zoom level. Must be under the max zoom level. + * @default 1 + */ + min?: number; + /** + * Maximum zoom level. Must be above the min zoom level. + * @default 32 + */ + max?: number; + /** + * Sensitivity of zooming when using the mouse wheel. The higher, the faster the zoom changes. + * If the zoom level is under 1, the sensitivity is divided by 10 to allow finer control. + * @default 0.1 + */ + sensitivity?: number; +} + +/** + * Zoom holds the current zoom level along with its bounds and wheel sensitivity, + * and knows how to step itself in response to a wheel delta. + */ +export class Zoom { + #value: number; + #min: number; + #max: number; + #sensitivity: number; + + constructor( + options: ZoomOptions = {} + ) { + const { + default: value = 4, + min = 1, + max = 32, + sensitivity = 0.1 + } = options; + + this.#min = min; + this.#max = max; + + if (this.#max < this.#min) { + throw new Error( + `Max zoom (${this.#max}) can't be under min zoom (${this.#min})` + ); + } + + this.#value = clamp(value, this.#min, this.#max); + this.#sensitivity = sensitivity; + } + + get value(): number { + return this.#value; + } + + get min(): number { + return this.#min; + } + + get max(): number { + return this.#max; + } + + get sensitivity(): number { + return this.#sensitivity; + } + + set sensitivity( + value: number + ) { + this.#sensitivity = Math.max(0.01, value); + } + + /** + * Steps the zoom level for a wheel `delta` and clamps the result to [min, max]. + * Sensitivity is reduced near/under 1x so small pixel-art textures stay controllable. + * Returns the resulting value. + */ + applyDelta( + delta: number + ): number { + const signDelta = Math.sign(delta); + const smoothSensitivity = + this.#value - signDelta * this.#sensitivity < 1 || this.#value < 1 + ? this.#sensitivity / 10 + : this.#sensitivity; + + this.#value = clamp(this.#value - signDelta * smoothSensitivity, this.#min, this.#max); + + return this.#value; + } +} diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts index e58f32a..79af5c0 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts @@ -72,7 +72,7 @@ export class BrushHighlightOverlay { return; } - const zoom = this.#viewport.zoom; + const zoom = this.#viewport.zoom.value; const camera = this.#viewport.camera; const brushSize = this.#brush.size; const highlightBrushSize = brushSize * zoom; diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts index fb16366..f32af5f 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts @@ -61,7 +61,7 @@ export class LinePreviewOverlay { start: Vec2, end: Vec2 ): void { - const zoom = this.#viewport.zoom; + const zoom = this.#viewport.zoom.value; const camera = this.#viewport.camera; const x1 = (start.x + 0.5) * zoom + camera.x; const y1 = (start.y + 0.5) * zoom + camera.y; diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts index cf4185a..709883e 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts @@ -63,7 +63,7 @@ export class SelectionOverlay { drawRect( rect: SelectionRect ): void { - const zoom = this.#viewport.zoom; + const zoom = this.#viewport.zoom.value; const camera = this.#viewport.camera; const x = rect.x * zoom + camera.x; const y = rect.y * zoom + camera.y; diff --git a/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts b/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts index cedd10e..b3e42ac 100644 --- a/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts @@ -10,6 +10,7 @@ import { SvgManager } from "../../src/rendering/SvgManager.ts"; import { BrushHighlightOverlay } from "../../src/rendering/overlays/BrushHighlightOverlay.ts"; import { LinePreviewOverlay } from "../../src/rendering/overlays/LinePreviewOverlay.ts"; import { SelectionOverlay } from "../../src/rendering/overlays/SelectionOverlay.ts"; +import { Zoom } from "../../src/rendering/Zoom.ts"; import type { DefaultViewport } from "../../src/rendering/Viewport.ts"; // CONSTANTS @@ -35,7 +36,7 @@ function makeParent(): HTMLDivElement { function makeViewport(): DefaultViewport { return { - zoom: 4, + zoom: new Zoom({ default: 4 }), camera: { x: 0, y: 0 } }; } diff --git a/packages/pixel-draw-renderer/test/rendering/Viewport.spec.ts b/packages/pixel-draw-renderer/test/rendering/Viewport.spec.ts index 4a55b9a..99baa55 100644 --- a/packages/pixel-draw-renderer/test/rendering/Viewport.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/Viewport.spec.ts @@ -16,19 +16,19 @@ describe("Viewport", () => { test("clamps initial zoom to [min, max]", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom: 100, zoomMin: 1, zoomMax: 32 }); - assert.strictEqual(vp.zoom, 32); + assert.strictEqual(vp.zoom.value, 32); }); test("defaults zoom to 4", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 } }); - assert.strictEqual(vp.zoom, 4); + assert.strictEqual(vp.zoom.value, 4); }); }); describe("texture", () => { test("pixelSize returns textureSize * zoom", () => { const vp = new Viewport({ textureSize: { x: 10, y: 20 }, zoom: 3 }); - assert.deepStrictEqual(vp.texture.pixelSize(vp.zoom), { x: 30, y: 60 }); + assert.deepStrictEqual(vp.texture.pixelSize(vp.zoom.value), { x: 30, y: 60 }); }); test("resize clamps the camera when it goes out of bounds (wired via onResize)", () => { @@ -37,8 +37,8 @@ describe("Viewport", () => { vp.centerTexture(); // Shrinking the texture moves minX/maxX inward; camera must be re-clamped. vp.texture.resize({ x: 1, y: 1 }); - const texPx = vp.texture.pixelSize(vp.zoom); - const margin = vp.zoom; + const texPx = vp.texture.pixelSize(vp.zoom.value); + const margin = vp.zoom.value; const minX = -texPx.x + margin; const maxX = 100 - margin; assert.ok(vp.camera.x >= minX && vp.camera.x <= maxX); @@ -77,31 +77,31 @@ describe("Viewport", () => { test("zooms in (negative delta increases zoom)", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom: 4 }); vp.updateCanvasSize(200, 200); - const before = vp.zoom; + const before = vp.zoom.value; vp.applyZoom(-1, 100, 100); - assert.ok(vp.zoom > before, `zoom ${vp.zoom} should be greater than ${before}`); + assert.ok(vp.zoom.value > before, `zoom ${vp.zoom.value} should be greater than ${before}`); }); test("zooms out (positive delta decreases zoom)", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom: 4 }); vp.updateCanvasSize(200, 200); - const before = vp.zoom; + const before = vp.zoom.value; vp.applyZoom(1, 100, 100); - assert.ok(vp.zoom < before, `zoom ${vp.zoom} should be less than ${before}`); + assert.ok(vp.zoom.value < before, `zoom ${vp.zoom.value} should be less than ${before}`); }); test("clamps zoom to zoomMin", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom: 1, zoomMin: 1 }); vp.updateCanvasSize(200, 200); vp.applyZoom(100, 100, 100); - assert.strictEqual(vp.zoom, 1); + assert.strictEqual(vp.zoom.value, 1); }); test("clamps zoom to zoomMax", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 }, zoom: 32, zoomMax: 32 }); vp.updateCanvasSize(200, 200); vp.applyZoom(-100, 100, 100); - assert.strictEqual(vp.zoom, 32); + assert.strictEqual(vp.zoom.value, 32); }); }); @@ -159,22 +159,22 @@ describe("Viewport", () => { }); }); - describe("zoomSensitivity setter", () => { - test("updates zoomSensitivity", () => { + describe("zoom.sensitivity setter", () => { + test("updates sensitivity", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 } }); - vp.zoomSensitivity = 0.5; - assert.strictEqual(vp.zoomSensitivity, 0.5); + vp.zoom.sensitivity = 0.5; + assert.strictEqual(vp.zoom.sensitivity, 0.5); }); test("clamps to a minimum of 0.01", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 } }); - vp.zoomSensitivity = -5; - assert.strictEqual(vp.zoomSensitivity, 0.01); + vp.zoom.sensitivity = -5; + assert.strictEqual(vp.zoom.sensitivity, 0.01); }); test("defaults to 0.1", () => { const vp = new Viewport({ textureSize: { x: 16, y: 16 } }); - assert.strictEqual(vp.zoomSensitivity, 0.1); + assert.strictEqual(vp.zoom.sensitivity, 0.1); }); }); }); diff --git a/packages/pixel-draw-renderer/test/rendering/Zoom.spec.ts b/packages/pixel-draw-renderer/test/rendering/Zoom.spec.ts new file mode 100644 index 0000000..34a7cf6 --- /dev/null +++ b/packages/pixel-draw-renderer/test/rendering/Zoom.spec.ts @@ -0,0 +1,73 @@ +// Import Node.js Dependencies +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { Zoom } from "../../src/rendering/Zoom.ts"; + +describe("Zoom", () => { + describe("constructor", () => { + test("throws when max < min", () => { + assert.throws( + () => new Zoom({ min: 4, max: 2 }), + /Max zoom.*can't be under min zoom/ + ); + }); + + test("clamps the initial value to [min, max]", () => { + const zoom = new Zoom({ default: 100, min: 1, max: 32 }); + assert.strictEqual(zoom.value, 32); + }); + + test("defaults value to 4, min to 1, max to 32, sensitivity to 0.1", () => { + const zoom = new Zoom(); + assert.strictEqual(zoom.value, 4); + assert.strictEqual(zoom.min, 1); + assert.strictEqual(zoom.max, 32); + assert.strictEqual(zoom.sensitivity, 0.1); + }); + }); + + describe("sensitivity setter", () => { + test("updates sensitivity", () => { + const zoom = new Zoom(); + zoom.sensitivity = 0.5; + assert.strictEqual(zoom.sensitivity, 0.5); + }); + + test("clamps to a minimum of 0.01", () => { + const zoom = new Zoom(); + zoom.sensitivity = -5; + assert.strictEqual(zoom.sensitivity, 0.01); + }); + }); + + describe("applyDelta", () => { + test("negative delta increases the value", () => { + const zoom = new Zoom({ default: 4 }); + const before = zoom.value; + const after = zoom.applyDelta(-1); + assert.ok(after > before, `zoom ${after} should be greater than ${before}`); + assert.strictEqual(zoom.value, after); + }); + + test("positive delta decreases the value", () => { + const zoom = new Zoom({ default: 4 }); + const before = zoom.value; + const after = zoom.applyDelta(1); + assert.ok(after < before, `zoom ${after} should be less than ${before}`); + }); + + test("clamps to min", () => { + const zoom = new Zoom({ default: 1, min: 1 }); + zoom.applyDelta(100); + assert.strictEqual(zoom.value, 1); + }); + + test("clamps to max", () => { + const zoom = new Zoom({ default: 32, max: 32 }); + zoom.applyDelta(-100); + assert.strictEqual(zoom.value, 32); + }); + }); +}); diff --git a/packages/pixel-draw-renderer/test/rendering/overlays/BrushHighlightOverlay.spec.ts b/packages/pixel-draw-renderer/test/rendering/overlays/BrushHighlightOverlay.spec.ts index a36f205..9ed0376 100644 --- a/packages/pixel-draw-renderer/test/rendering/overlays/BrushHighlightOverlay.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/overlays/BrushHighlightOverlay.spec.ts @@ -8,6 +8,7 @@ import { Window } from "happy-dom"; // Import Internal Dependencies import { BrushHighlightOverlay } from "../../../src/rendering/overlays/BrushHighlightOverlay.ts"; import { SVG_NS } from "../../../src/rendering/constants.ts"; +import { Zoom } from "../../../src/rendering/Zoom.ts"; import type { DefaultViewport } from "../../../src/rendering/Viewport.ts"; // CONSTANTS @@ -23,7 +24,7 @@ function makeSvg(): SVGElement { function makeViewport(): DefaultViewport { return { - zoom: 4, + zoom: new Zoom({ default: 4 }), camera: { x: 0, y: 0 } }; } diff --git a/packages/pixel-draw-renderer/test/rendering/overlays/LinePreviewOverlay.spec.ts b/packages/pixel-draw-renderer/test/rendering/overlays/LinePreviewOverlay.spec.ts index fe294ad..1e68762 100644 --- a/packages/pixel-draw-renderer/test/rendering/overlays/LinePreviewOverlay.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/overlays/LinePreviewOverlay.spec.ts @@ -8,6 +8,7 @@ import { Window } from "happy-dom"; // Import Internal Dependencies import { LinePreviewOverlay } from "../../../src/rendering/overlays/LinePreviewOverlay.ts"; import { SVG_NS } from "../../../src/rendering/constants.ts"; +import { Zoom } from "../../../src/rendering/Zoom.ts"; import type { DefaultViewport } from "../../../src/rendering/Viewport.ts"; // CONSTANTS @@ -23,7 +24,7 @@ function makeSvg(): SVGElement { function makeViewport(): DefaultViewport { return { - zoom: 4, + zoom: new Zoom({ default: 4 }), camera: { x: 0, y: 0 } }; } diff --git a/packages/pixel-draw-renderer/test/rendering/overlays/SelectionOverlay.spec.ts b/packages/pixel-draw-renderer/test/rendering/overlays/SelectionOverlay.spec.ts index a366955..2efae8a 100644 --- a/packages/pixel-draw-renderer/test/rendering/overlays/SelectionOverlay.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/overlays/SelectionOverlay.spec.ts @@ -8,6 +8,7 @@ import { Window } from "happy-dom"; // Import Internal Dependencies import { SelectionOverlay } from "../../../src/rendering/overlays/SelectionOverlay.ts"; import { SVG_NS } from "../../../src/rendering/constants.ts"; +import { Zoom } from "../../../src/rendering/Zoom.ts"; import type { DefaultViewport } from "../../../src/rendering/Viewport.ts"; // CONSTANTS @@ -23,7 +24,7 @@ function makeSvg(): SVGElement { function makeViewport(): DefaultViewport { return { - zoom: 4, + zoom: new Zoom({ default: 4 }), camera: { x: 0, y: 0 } }; } From fe859f189e95cb49d9a7df35ff6c6615d2975808 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sat, 18 Jul 2026 22:17:58 +0200 Subject: [PATCH 2/3] refactor(pixel-draw-renderer): move ./history/replayEvents.ts functions to HistoryController --- .../pixel-draw-renderer/src/PixelArtCanvas.ts | 8 +- .../src/history/HistoryController.ts | 100 +++++++++++++++++ .../src/history/replayEvents.ts | 102 ------------------ 3 files changed, 102 insertions(+), 108 deletions(-) delete mode 100644 packages/pixel-draw-renderer/src/history/replayEvents.ts diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index 82b17e7..fe737f0 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -23,10 +23,6 @@ import { HistoryController, type HistoryState } from "./history/HistoryController.ts"; -import { - buildRedoReplayEvents, - buildUndoReplayEvents -} from "./history/replayEvents.ts"; import { createInputActions } from "./input/createInputActions.ts"; @@ -553,7 +549,7 @@ export class PixelArtCanvas { if (entry.action === "select-edit") { this.#selectController.syncSelectionAfterHistory(entry.oldRect); } - for (const event of buildUndoReplayEvents(entry)) { + for (const event of HistoryController.buildUndoReplayEvents(entry)) { this.#sync.emitHook(event); } this.#onDrawEnd?.(); @@ -572,7 +568,7 @@ export class PixelArtCanvas { if (entry.action === "select-edit") { this.#selectController.syncSelectionAfterHistory(entry.newRect); } - for (const event of buildRedoReplayEvents(entry)) { + for (const event of HistoryController.buildRedoReplayEvents(entry)) { this.#sync.emitHook(event); } this.#onDrawEnd?.(); diff --git a/packages/pixel-draw-renderer/src/history/HistoryController.ts b/packages/pixel-draw-renderer/src/history/HistoryController.ts index df25430..1f3b6e7 100644 --- a/packages/pixel-draw-renderer/src/history/HistoryController.ts +++ b/packages/pixel-draw-renderer/src/history/HistoryController.ts @@ -1,10 +1,15 @@ +// Import Third-party Dependencies +import { fromUint8Array } from "js-base64"; + // Import Internal Dependencies import { HistoryStack, type HistoryEntry, type HistoryEntryInput } from "./HistoryStack.ts"; +import { groupPositionsByColor } from "./utils.ts"; import type { DefaultPixelBuffer } from "../buffer/types.ts"; +import type { PixelBufferHookEvent } from "../buffer/hooks.ts"; export interface HistoryState { canUndo: boolean; @@ -31,6 +36,101 @@ export interface HistoryControllerOptions { * canUndo/canRedo state. */ export class HistoryController { + /** + * Stamped with the entry's original timestamp (not "now") so the server's + * per-pixel conflict resolver re-races the replay fairly against a peer's + * edit made since. A stroke's before-state is usually heterogeneous, so + * it's split into uniform-color groups — "stroke" only carries one color. + */ + static buildUndoReplayEvents( + entry: HistoryEntry + ): PixelBufferHookEvent[] { + const { timestamp } = entry; + + switch (entry.action) { + case "stroke": + return groupPositionsByColor(entry.positions, entry.beforeColors).map((group) => { + return { + action: "stroke", + metadata: { + color: group.color, + positions: group.positions + }, + originTimestamp: timestamp + }; + }); + + case "resized": + return [ + { + action: "resized", + metadata: { size: entry.beforeSize }, + originTimestamp: timestamp + } + ]; + + case "texture-replaced": + return [ + { + action: "texture-replaced", + metadata: { + size: entry.beforeSize, + pixels: fromUint8Array(new Uint8Array(entry.beforePixels)) + }, + originTimestamp: timestamp + } + ]; + + default: + return []; + } + } + + /** A stroke's after-state is always one uniform color, so no grouping is needed here (unlike undo). */ + static buildRedoReplayEvents( + entry: HistoryEntry + ): PixelBufferHookEvent[] { + const { timestamp } = entry; + + switch (entry.action) { + case "stroke": + return [ + { + action: "stroke", + metadata: { + color: entry.afterColor, + positions: entry.positions + }, + originTimestamp: timestamp + } + ]; + + case "resized": + return [ + { + action: "resized", + metadata: { size: entry.afterSize }, + originTimestamp: timestamp + } + ]; + + case "texture-replaced": + return [ + { + action: "texture-replaced", + metadata: { + size: entry.afterSize, + pixels: fromUint8Array(new Uint8Array(entry.afterPixels)) + }, + originTimestamp: timestamp + } + ]; + + default: + return []; + } + } + #stack?: HistoryStack; #onChange?: (state: HistoryState) => void; diff --git a/packages/pixel-draw-renderer/src/history/replayEvents.ts b/packages/pixel-draw-renderer/src/history/replayEvents.ts deleted file mode 100644 index 673369a..0000000 --- a/packages/pixel-draw-renderer/src/history/replayEvents.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Import Third-party Dependencies -import { fromUint8Array } from "js-base64"; - -// Import Internal Dependencies -import type { HistoryEntry } from "./HistoryStack.ts"; -import type { PixelBufferHookEvent } from "../buffer/hooks.ts"; -import { groupPositionsByColor } from "./utils.ts"; - -/** - * Stamped with the entry's original timestamp (not "now") so the server's - * per-pixel conflict resolver re-races the replay fairly against a peer's - * edit made since. A stroke's before-state is usually heterogeneous, so - * it's split into uniform-color groups — "stroke" only carries one color. - */ -export function buildUndoReplayEvents( - entry: HistoryEntry -): PixelBufferHookEvent[] { - const { timestamp } = entry; - - switch (entry.action) { - case "stroke": - return groupPositionsByColor(entry.positions, entry.beforeColors).map((group) => { - return { - action: "stroke", - metadata: { - color: group.color, - positions: group.positions - }, - originTimestamp: timestamp - }; - }); - - case "resized": - return [ - { - action: "resized", - metadata: { size: entry.beforeSize }, - originTimestamp: timestamp - } - ]; - - case "texture-replaced": - return [ - { - action: "texture-replaced", - metadata: { - size: entry.beforeSize, - pixels: fromUint8Array(new Uint8Array(entry.beforePixels)) - }, - originTimestamp: timestamp - } - ]; - - default: - return []; - } -} - -/** A stroke's after-state is always one uniform color, so no grouping is needed here (unlike undo). */ -export function buildRedoReplayEvents( - entry: HistoryEntry -): PixelBufferHookEvent[] { - const { timestamp } = entry; - - switch (entry.action) { - case "stroke": - return [ - { - action: "stroke", - metadata: { - color: entry.afterColor, - positions: entry.positions - }, - originTimestamp: timestamp - } - ]; - - case "resized": - return [ - { - action: "resized", - metadata: { size: entry.afterSize }, - originTimestamp: timestamp - } - ]; - - case "texture-replaced": - return [ - { - action: "texture-replaced", - metadata: { - size: entry.afterSize, - pixels: fromUint8Array(new Uint8Array(entry.afterPixels)) - }, - originTimestamp: timestamp - } - ]; - - default: - return []; - } -} From 6400db1a800b3788f031e797f08a18874c473d72 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sat, 18 Jul 2026 22:29:47 +0200 Subject: [PATCH 3/3] refactor(pixel-draw-renderer): implement ToolControllers.ts --- .../pixel-draw-renderer/src/PixelArtCanvas.ts | 124 ++++++++---------- .../src/input/createInputActions.ts | 89 ++++++------- .../src/tools/ToolControllers.ts | 80 +++++++++++ 3 files changed, 172 insertions(+), 121 deletions(-) create mode 100644 packages/pixel-draw-renderer/src/tools/ToolControllers.ts diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index fe737f0..f12630e 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -8,17 +8,14 @@ import { type BrushOptions } from "./tools/Brush.ts"; import { - BrushController -} from "./tools/BrushController.ts"; + ToolControllers +} from "./tools/ToolControllers.ts"; import { CanvasBuffer } from "./buffer/CanvasBuffer.ts"; import { CanvasRenderer } from "./rendering/CanvasRenderer.ts"; -import { - FillController -} from "./tools/FillController.ts"; import { HistoryController, type HistoryState @@ -30,12 +27,6 @@ import { InputController, type WindowLike } from "./input/InputController.ts"; -import { - LineController -} from "./tools/LineController.ts"; -import { - SelectController -} from "./tools/SelectController.ts"; import { SvgManager } from "./rendering/SvgManager.ts"; @@ -134,10 +125,7 @@ export class PixelArtCanvas { #onDrawEnd?: () => void; #history: HistoryController; #mode: Mode; - #brushController: BrushController; - #fillController: FillController; - #lineController: LineController; - #selectController: SelectController; + #tools: ToolControllers; readonly brush: Brush; readonly viewport: DefaultViewport; @@ -233,41 +221,26 @@ export class PixelArtCanvas { brush: brushAdapter }); - this.#brushController = new BrushController({ + this.#tools = new ToolControllers({ brush: this.brush, canvasBuffer: this.#canvasBuffer, renderer: this.#renderer, - onCommit: (pixels, color, beforeColors) => { + linePreview: this.#svgManager.linePreview, + selectionOverlay: this.#svgManager.selection, + eraseColor, + onStrokeCommit: (pixels, color, beforeColors) => { this.#sync.recordHistory({ action: "stroke", positions: pixels, beforeColors, afterColor: color }); this.#sync.emitHook({ action: "stroke", metadata: { color, positions: pixels } }); this.#onDrawEnd?.(); - } - }); - - this.#fillController = new FillController({ - brush: this.brush, - canvasBuffer: this.#canvasBuffer, - onCommit: (pixels) => this.commitPixels(pixels), - onGlobalCommit: ({ positions, beforeColors, fromColor, toColor }) => { + }, + onCommitPixels: (pixels) => this.commitPixels(pixels), + onGlobalFillCommit: ({ positions, beforeColors, fromColor, toColor }) => { this.#sync.applyStroke(toColor, positions); this.#sync.recordHistory({ action: "stroke", positions, beforeColors, afterColor: toColor }); this.#sync.emitHook({ action: "global-fill", metadata: { fromColor, toColor } }); this.#onDrawEnd?.(); - } - }); - - this.#lineController = new LineController({ - brush: this.brush, - linePreview: this.#svgManager.linePreview, - onCommit: (pixels) => this.commitPixels(pixels) - }); - - this.#selectController = new SelectController({ - canvasBuffer: this.#canvasBuffer, - renderer: this.#renderer, - selectionOverlay: this.#svgManager.selection, - eraseColor, - onCommit: (entry) => { + }, + onSelectCommit: (entry) => { this.#sync.recordHistory({ action: "select-edit", ...entry @@ -280,21 +253,20 @@ export class PixelArtCanvas { canvas: this.#renderer.canvas(), viewport: this.#viewport, window: options.window, - actions: createInputActions({ - getMode: () => this.#mode, - brush: this.brush, - canvasBuffer: this.#canvasBuffer, - renderer: this.#renderer, - svgManager: this.#svgManager, - viewport: this.#viewport, - brushController: this.#brushController, - fillController: this.#fillController, - lineController: this.#lineController, - selectController: this.#selectController, - undo: () => this.undo(), - redo: () => this.redo(), - stopDrawing: () => this.#input.stopDrawing() - }), + actions: { + ...createInputActions({ + getMode: () => this.#mode, + brush: this.brush, + canvasBuffer: this.#canvasBuffer, + renderer: this.#renderer, + svgManager: this.#svgManager, + viewport: this.#viewport, + tools: this.#tools, + stopDrawing: () => this.#input.stopDrawing() + }), + onUndo: () => this.undo(), + onRedo: () => this.redo() + }, keybindings: options.keybindings }); @@ -311,10 +283,10 @@ export class PixelArtCanvas { this.#mode = mode; if (mode === "move") { this.#svgManager.brushHighlight.hide(); - this.#lineController.cancelIfArmed(); + this.#tools.line.cancelIfArmed(); } if (mode !== "select") { - this.#selectController.clear(); + this.#tools.select.clear(); } } @@ -322,13 +294,13 @@ export class PixelArtCanvas { * Whether fill recolors all matching pixels instead of only the connected region. */ get fillGlobal(): boolean { - return this.#fillController.global; + return this.#tools.fill.global; } set fillGlobal( global: boolean ) { - this.#fillController.global = global; + this.#tools.fill.global = global; } get parentHtmlElement(): HTMLDivElement { @@ -424,21 +396,21 @@ export class PixelArtCanvas { * Rotates the active selection clockwise. Returns `false` without a selection. */ rotateSelection(): boolean { - return this.#selectController.handleRotate(); + return this.#tools.select.handleRotate(); } /** * Mirrors the active selection horizontally. Returns `false` without a selection. */ flipSelectionHorizontal(): boolean { - return this.#selectController.handleFlipHorizontal(); + return this.#tools.select.handleFlipHorizontal(); } /** * Mirrors the active selection vertically. Returns `false` without a selection. */ flipSelectionVertical(): boolean { - return this.#selectController.handleFlipVertical(); + return this.#tools.select.handleFlipVertical(); } centerTexture(): void { @@ -529,16 +501,28 @@ export class PixelArtCanvas { } const color = toRGBA(this.brush.colorAsString()); - const beforeColors = this.#history.enabled ? this.#canvasBuffer.samplePixels(pixels) : []; + const beforeColors = this.#history.enabled ? + this.#canvasBuffer.samplePixels(pixels) : + []; this.#sync.applyStroke(color, pixels); - this.#sync.recordHistory({ action: "stroke", positions: pixels, beforeColors, afterColor: color }); - this.#sync.emitHook({ action: "stroke", metadata: { color, positions: pixels } }); + this.#sync.recordHistory({ + action: "stroke", + positions: pixels, + beforeColors, + afterColor: color + }); + this.#sync.emitHook({ + action: "stroke", + metadata: { color, positions: pixels } + }); this.#onDrawEnd?.(); } - /** Reverts the latest local edit. Returns `false` when history is unavailable. */ + /** + * Reverts the latest local edit. Returns `false` when history is unavailable. + **/ undo(): boolean { const entry = this.#history.undo(); if (!entry) { @@ -547,7 +531,7 @@ export class PixelArtCanvas { this.#refreshAfterHistoryApply(); if (entry.action === "select-edit") { - this.#selectController.syncSelectionAfterHistory(entry.oldRect); + this.#tools.select.syncSelectionAfterHistory(entry.oldRect); } for (const event of HistoryController.buildUndoReplayEvents(entry)) { this.#sync.emitHook(event); @@ -557,7 +541,9 @@ export class PixelArtCanvas { return true; } - /** Re-applies the latest undone edit. Returns `false` when history is unavailable. */ + /** + * Re-applies the latest undone edit. Returns `false` when history is unavailable. + **/ redo(): boolean { const entry = this.#history.redo(); if (!entry) { @@ -566,7 +552,7 @@ export class PixelArtCanvas { this.#refreshAfterHistoryApply(); if (entry.action === "select-edit") { - this.#selectController.syncSelectionAfterHistory(entry.newRect); + this.#tools.select.syncSelectionAfterHistory(entry.newRect); } for (const event of HistoryController.buildRedoReplayEvents(entry)) { this.#sync.emitHook(event); diff --git a/packages/pixel-draw-renderer/src/input/createInputActions.ts b/packages/pixel-draw-renderer/src/input/createInputActions.ts index 9a1ce49..296d589 100644 --- a/packages/pixel-draw-renderer/src/input/createInputActions.ts +++ b/packages/pixel-draw-renderer/src/input/createInputActions.ts @@ -1,11 +1,8 @@ // Import Internal Dependencies import type { Brush } from "../tools/Brush.ts"; -import type { BrushController } from "../tools/BrushController.ts"; +import type { ToolControllers } from "../tools/ToolControllers.ts"; import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; -import type { FillController } from "../tools/FillController.ts"; -import type { LineController } from "../tools/LineController.ts"; -import type { SelectController } from "../tools/SelectController.ts"; import type { SvgManager } from "../rendering/SvgManager.ts"; import type { Viewport } from "../rendering/Viewport.ts"; import { rgbToHex } from "../utils/colors.ts"; @@ -19,12 +16,7 @@ export interface CreateInputActionsOptions { renderer: CanvasRenderer; svgManager: SvgManager; viewport: Viewport; - brushController: BrushController; - fillController: FillController; - lineController: LineController; - selectController: SelectController; - undo: () => boolean; - redo: () => boolean; + tools: ToolControllers; /** Cancels the active primary drag without calling `onPrimaryUp`. */ stopDrawing: () => void; } @@ -35,7 +27,7 @@ export interface CreateInputActionsOptions { */ export function createInputActions( options: CreateInputActionsOptions -): InputActions { +): Omit { const { getMode, brush, @@ -43,12 +35,7 @@ export function createInputActions( renderer, svgManager, viewport, - brushController, - fillController, - lineController, - selectController, - undo, - redo, + tools, stopDrawing } = options; @@ -57,25 +44,25 @@ export function createInputActions( switch (getMode()) { case "paint": if ( - lineController.isArmed && - lineController.commitTrigger === "mousedown" + tools.line.isArmed && + tools.line.commitTrigger === "mousedown" ) { - lineController.commit(); + tools.line.commit(); return false; } - brushController.startStroke(tx, ty); + tools.brush.startStroke(tx, ty); return true; case "fill": - fillController.run(tx, ty); + tools.fill.run(tx, ty); return false; case "select": - selectController.handleStart({ x: tx, y: ty }); + tools.select.handleStart({ x: tx, y: ty }); return true; @@ -86,11 +73,11 @@ export function createInputActions( onPrimaryMove: (tx, ty) => { switch (getMode()) { case "paint": - brushController.continueStroke(tx, ty); + tools.brush.continueStroke(tx, ty); break; case "select": - selectController.handleMove({ x: tx, y: ty }); + tools.select.handleMove({ x: tx, y: ty }); break; default: @@ -99,11 +86,11 @@ export function createInputActions( onPrimaryUp: () => { switch (getMode()) { case "paint": - brushController.endStroke(); + tools.brush.endStroke(); break; case "select": - selectController.handleEnd(); + tools.select.handleEnd(); break; default: @@ -115,8 +102,8 @@ export function createInputActions( onPanMove: (dx, dy) => { viewport.applyPan(dx, dy); renderer.drawFrame(); - lineController.refreshPreview(); - selectController.refreshOverlay(); + tools.line.refreshPreview(); + tools.select.refreshOverlay(); }, onPanEnd: () => { // No-op. The viewport handles panning internally. @@ -124,8 +111,8 @@ export function createInputActions( onZoom: (delta, cx, cy) => { viewport.applyZoom(delta, cx, cy); renderer.drawFrame(); - lineController.refreshPreview(); - selectController.refreshOverlay(); + tools.line.refreshPreview(); + tools.select.refreshOverlay(); }, onColorPick: (tx, ty) => { const mode = getMode(); @@ -158,48 +145,46 @@ export function createInputActions( } }, onCursorMove: (pos) => { - lineController.updateCursor(pos); + tools.line.updateCursor(pos); }, onMouseUp: () => { if ( - lineController.isArmed && - lineController.commitTrigger === "mouseup" + tools.line.isArmed && + tools.line.commitTrigger === "mouseup" ) { - lineController.commit(); + tools.line.commit(); } }, onShiftDown: () => { - lineController.shiftHeld = true; + tools.line.shiftHeld = true; if (getMode() !== "paint") { return; } - if (brushController.isActive) { + if (tools.brush.isActive) { // A held pointer requires committing the line on mouseup. stopDrawing(); - brushController.endStroke(); - lineController.arm("mouseup"); + tools.brush.endStroke(); + tools.line.arm("mouseup"); return; } - lineController.arm("mousedown"); + tools.line.arm("mousedown"); }, onShiftUp: () => { - lineController.shiftHeld = false; - lineController.cancelIfArmed(); + tools.line.shiftHeld = false; + tools.line.cancelIfArmed(); }, onBlur: () => { - lineController.shiftHeld = false; - lineController.cancelIfArmed(); + tools.line.shiftHeld = false; + tools.line.cancelIfArmed(); }, - onCopy: () => selectController.handleCopy(), - onPaste: () => selectController.handlePaste(), - onDelete: () => selectController.handleDelete(), - onUndo: () => undo(), - onRedo: () => redo(), - onRotate: () => selectController.handleRotate(), - onFlipHorizontal: () => selectController.handleFlipHorizontal(), - onFlipVertical: () => selectController.handleFlipVertical() + onCopy: () => tools.select.handleCopy(), + onPaste: () => tools.select.handlePaste(), + onDelete: () => tools.select.handleDelete(), + onRotate: () => tools.select.handleRotate(), + onFlipHorizontal: () => tools.select.handleFlipHorizontal(), + onFlipVertical: () => tools.select.handleFlipVertical() }; } diff --git a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts new file mode 100644 index 0000000..58c572b --- /dev/null +++ b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts @@ -0,0 +1,80 @@ +// Import Internal Dependencies +import type { Brush } from "./Brush.ts"; +import { + BrushController +} from "./BrushController.ts"; +import { + FillController, + type FillGlobalCommit +} from "./FillController.ts"; +import { + LineController +} from "./LineController.ts"; +import { + SelectController, + type SelectEditEntry +} from "./SelectController.ts"; +import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; +import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; +import type { LinePreviewOverlay } from "../rendering/overlays/LinePreviewOverlay.ts"; +import type { SelectionOverlay } from "../rendering/overlays/SelectionOverlay.ts"; +import type { RGBA, Vec2 } from "../types.ts"; + +export interface ToolControllersOptions { + brush: Brush; + canvasBuffer: CanvasBuffer; + renderer: CanvasRenderer; + linePreview: LinePreviewOverlay; + selectionOverlay: SelectionOverlay; + eraseColor: RGBA; + /** Forwarded to BrushController: a completed freehand stroke. */ + onStrokeCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; + /** Shared by FillController (contiguous fill) and LineController. */ + onCommitPixels: (pixels: Vec2[]) => void; + onGlobalFillCommit: (commit: FillGlobalCommit) => void; + onSelectCommit: (entry: SelectEditEntry) => void; +} + +/** + * Groups the four interaction-mode controllers (paint/fill/line/select) + * behind one object, exposed as `brush`/`fill`/`line`/`select` so callers + * don't repeat the "Controller" suffix. + */ +export class ToolControllers { + readonly brush: BrushController; + readonly fill: FillController; + readonly line: LineController; + readonly select: SelectController; + + constructor( + options: ToolControllersOptions + ) { + this.brush = new BrushController({ + brush: options.brush, + canvasBuffer: options.canvasBuffer, + renderer: options.renderer, + onCommit: options.onStrokeCommit + }); + + this.fill = new FillController({ + brush: options.brush, + canvasBuffer: options.canvasBuffer, + onCommit: options.onCommitPixels, + onGlobalCommit: options.onGlobalFillCommit + }); + + this.line = new LineController({ + brush: options.brush, + linePreview: options.linePreview, + onCommit: options.onCommitPixels + }); + + this.select = new SelectController({ + canvasBuffer: options.canvasBuffer, + renderer: options.renderer, + selectionOverlay: options.selectionOverlay, + eraseColor: options.eraseColor, + onCommit: options.onSelectCommit + }); + } +}