From e32673cc214e882b7ddeb889d7546980af3d261d Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sun, 19 Jul 2026 16:37:37 +0200 Subject: [PATCH] docs(pixel-draw-renderer): enhance API documentation and source code comments --- .changeset/khaki-moles-count.md | 5 + packages/pixel-draw-renderer/README.md | 21 +- .../docs/PixelArtCanvas.md | 95 ++++++-- .../docs/buffer/PixelBuffer.md | 26 +- .../docs/history/HistoryStack.md | 27 ++- .../docs/network/ConflictResolver.md | 9 +- .../docs/network/PixelSyncServer.md | 2 +- .../pixel-draw-renderer/docs/network/index.md | 2 +- .../pixel-draw-renderer/docs/network/types.md | 9 +- .../pixel-draw-renderer/docs/tools/Brush.md | 2 +- .../docs/utils/keybindings.md | 9 +- .../pixel-draw-renderer/src/PixelArtCanvas.ts | 69 +++--- .../src/buffer/CanvasBuffer.ts | 34 +-- .../src/buffer/PixelBuffer.ts | 43 ++-- .../pixel-draw-renderer/src/buffer/types.ts | 5 +- .../src/history/HistoryController.ts | 33 +-- .../src/history/HistoryStack.ts | 36 +-- .../pixel-draw-renderer/src/history/utils.ts | 4 +- .../src/input/InputController.ts | 12 +- .../pixel-draw-renderer/src/input/utils.ts | 14 +- .../src/network/ConflictResolver.ts | 23 +- .../src/network/PixelCommandApplier.ts | 10 +- .../src/network/PixelSyncServer.ts | 62 ++--- .../src/network/PixelSyncSession.ts | 31 +-- .../src/network/PixelTransport.ts | 25 +- .../src/network/PixelWorld.ts | 5 +- .../pixel-draw-renderer/src/network/index.ts | 4 +- .../pixel-draw-renderer/src/network/types.ts | 27 ++- .../src/rendering/CanvasRenderer.ts | 62 +++-- .../src/rendering/SvgManager.ts | 35 ++- .../src/rendering/Viewport.ts | 53 ++-- .../src/rendering/ViewportTexture.ts | 10 +- .../pixel-draw-renderer/src/rendering/Zoom.ts | 31 +-- .../overlays/BrushHighlightOverlay.ts | 3 +- .../overlays/FloatingSelectionOverlay.ts | 54 ++--- .../rendering/overlays/LinePreviewOverlay.ts | 12 +- .../rendering/overlays/SelectionOverlay.ts | 56 ++--- .../src/sync/SyncController.ts | 25 +- .../pixel-draw-renderer/src/tools/Brush.ts | 38 ++- .../src/tools/BrushController.ts | 48 ++-- .../pixel-draw-renderer/src/tools/Fill.ts | 72 ++++-- .../src/tools/FillController.ts | 26 +- .../pixel-draw-renderer/src/tools/Line.ts | 13 +- .../src/tools/LineController.ts | 45 ++-- .../pixel-draw-renderer/src/tools/Select.ts | 229 +++++++----------- .../src/tools/SelectController.ts | 147 +++++------ .../src/tools/ShapeSelect.ts | 44 ++-- .../src/tools/ToolControllers.ts | 7 +- .../pixel-draw-renderer/src/utils/colors.ts | 4 +- .../src/utils/keybindings.ts | 42 ++-- 50 files changed, 870 insertions(+), 830 deletions(-) create mode 100644 .changeset/khaki-moles-count.md diff --git a/.changeset/khaki-moles-count.md b/.changeset/khaki-moles-count.md new file mode 100644 index 00000000..17b25986 --- /dev/null +++ b/.changeset/khaki-moles-count.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": minor +--- + +Update API documentation and codebase comments diff --git a/packages/pixel-draw-renderer/README.md b/packages/pixel-draw-renderer/README.md index 5c0b1104..7faff267 100644 --- a/packages/pixel-draw-renderer/README.md +++ b/packages/pixel-draw-renderer/README.md @@ -33,7 +33,9 @@ $ yarn add @jolly-pixel/pixel-draw.renderer ## πŸ‘€ Usage Example ```ts -import { PixelArtCanvas } from "@jolly-pixel/pixel-draw.renderer"; +import { + PixelArtCanvas +} from "@jolly-pixel/pixel-draw.renderer"; const container = document.getElementById("editor-container")!; const manager = new PixelArtCanvas(container, { @@ -50,12 +52,12 @@ const manager = new PixelArtCanvas(container, { manager.onResize(); manager.centerTexture(); -manager.brush.primary.set("#FF6600"); // CSS string or a colorjs.io `Color` instance +manager.brush.primary.set("#FF6600"); manager.brush.primary.opacity = 0.8; manager.brush.secondary.set("#3366FF"); manager.brush.size = 3; -manager.mode = "fill"; // "paint" | "move" | "fill" | "select", see Modes below +manager.mode = "fill"; ``` Loading an existing texture: @@ -69,7 +71,7 @@ manager.texture = img; ### Modes -`mode` selects how left-click/drag is interpreted. Read [PixelArtCanvas.md](./docs/PixelArtCanvas.md#mode) for the full behavior: +`mode` selects how left-click/drag is interpreted. - `"paint"`: left-click draws with `brush.primary`, right-click draws with `brush.secondary` (mutually exclusive β€” one button's stroke blocks the other from starting); hold `Shift` for a straight line (always `primary`); `Ctrl`+right-click eyedroppers a color into `brush.primary` - `"move"`: pans the camera @@ -78,6 +80,9 @@ manager.texture = img; Middle-click pans in any mode. +> [!TIP] +> Read [PixelArtCanvas.md](./docs/PixelArtCanvas.md#mode) for the full behavior. + ### Keybinds Copy/paste/undo/redo/delete are configurable; Shift (line-tool arm/disarm) is not. Defaults: @@ -94,10 +99,14 @@ Override at construction, or live via `patchKeybindings()`: ```ts const manager = new PixelArtCanvas(container, { - keybindings: { undo: "alt+u" } // unspecified actions keep their default + keybindings: { + undo: "alt+u" + } // unspecified actions keep their default }); -manager.patchKeybindings({ redo: "alt+shift+u" }); +manager.patchKeybindings({ + redo: "alt+shift+u" +}); ``` > [!TIP] diff --git a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md index 2aacebc1..2e33d0b6 100644 --- a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md +++ b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md @@ -1,6 +1,15 @@ # PixelArtCanvas -`PixelArtCanvas` is the top-level coordinator for the pixel-draw renderer, and the package's primary public API. It wires together a viewport, canvas buffer, renderer, input handling, and SVG overlay β€” all internal implementation details β€” and owns the [`Brush`](./tools/Brush.md) tool, internal line/fill/select tools, and an internal `HistoryController` that wraps a [`HistoryStack`](./history/HistoryStack.md) for undo/redo (constructed unconditionally; only records entries when `history.enabled` is passed). +`PixelArtCanvas` is the top-level coordinator for the pixel-draw renderer and the package's primary public API. + +It wires together a viewport, canvas buffer, renderer, input handling, and SVG overlay (all internal implementation details) and owns: + +- the [`Brush`](./tools/Brush.md) tool +- internal line/fill/select tools (no public class of their own) +- an internal `HistoryController` wrapping a [`HistoryStack`](./history/HistoryStack.md) for undo/redo + +> [!IMPORTANT] +> `HistoryController` is constructed unconditionally, but only records entries when `history.enabled` is passed. Leaving it unset skips that bookkeeping entirely. ## Types @@ -35,7 +44,7 @@ interface PixelArtCanvasOptions { init?: HTMLCanvasElement; }; zoom?: { - default: number; + default?: number; sensitivity?: number; min?: number; max?: number; @@ -91,7 +100,7 @@ interface PixelArtCanvasOptions { `Mode` is `"paint" | "move" | "fill" | "select"`. `ColorInput` (`string | Color`, where `Color` is [colorjs.io](https://colorjs.io)'s class) is used throughout the package wherever a color option is accepted: a CSS color string (hex, `rgb()`, `hsl()`, named color, ...) or a `Color` instance. `BrushOptions` is forwarded to the internal `Brush` instance, see [Brush.md](./tools/Brush.md). `PixelBufferHookListener` is described in [buffer/PixelBuffer.md](./buffer/PixelBuffer.md) and [network/index.md](./network/index.md). `Keybindings` is described in [utils/keybindings.md](./utils/keybindings.md). -`history.enabled` (default `false`) tells the internal `HistoryController` to back itself with a [`HistoryStack`](./history/HistoryStack.md) that records every stroke, resize, and texture replace, enabling `undo()`/`redo()`. Leaving it disabled skips that bookkeeping entirely β€” there's no per-edit cost paid for a feature that isn't used. +`history.enabled` (default `false`) tells the internal `HistoryController` to back itself with a [`HistoryStack`](./history/HistoryStack.md) that records every stroke, resize, and texture replace, enabling `undo()`/`redo()`. Leaving it disabled skips that bookkeeping entirely: there's no per-edit cost paid for a feature that isn't used. Undocumented defaults: `texture.size` is `{ x: 64, y: 32 }` (`y` falls back to `x` when only `x` is given), `texture.maxSize` is `2048`, `zoom.default` is `4`, `zoom.min`/`zoom.max` are `1`/`32`, `zoom.sensitivity` is `0.1`, `backgroundTransparency.squareSize` is `8`, `backgroundTransparency.colors` is `{ odd: "#999", even: "#666" }`. @@ -113,7 +122,7 @@ The brush instance. Use it to read or change the primary/secondary brush colors, readonly viewport: DefaultViewport // { readonly zoom: Zoom; readonly camera: Readonly; } ``` -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. +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 @@ -124,13 +133,23 @@ get mode(): Mode set mode(mode: Mode) ``` -Reads or sets the current interaction mode. `"paint"` routes left-click events to brush drawing with `brush.primary` (holding `Shift` arms a line tool, always drawn in `primary`) and right-click events to brush drawing with `brush.secondary` β€” the two buttons paint mutually exclusively, a stroke already in progress on one button blocks the other from starting until it ends; `"move"` routes left-click to panning; `"fill"` routes a left-click to a paint-bucket fill with `brush.primary` and a right-click to the same fill with `brush.secondary` (contiguous region by default, or every same-colored pixel on the canvas when `fillGlobal` is `true` β€” see below; unlike `"paint"`, a fill click is single-shot and not tracked as a drag, so the two buttons aren't mutually exclusive the way brush strokes are); `"select"` routes them to a rectangle-selection tool: drag to select or move, `Ctrl`/`Cmd`+`C`/`V` to copy/duplicate, `Delete` to erase, `R` to rotate the selection 90Β° clockwise around its center (repeatable β€” press again for further rotation; no counterclockwise binding), `H`/`V` to flip the selection's content horizontally/vertically in place. The line/fill/select tools are internal implementation details with no public class of their own. +Reads or sets the current interaction mode. -A drag that never grows past its starting pixel (a plain click) does not create a selection. +| Mode | Left-click | Right-click | +|---|---|---| +| `"paint"` | Brush stroke with `brush.primary`. Hold `Shift` to arm a straight-line tool (always drawn in `primary`). | Brush stroke with `brush.secondary`. | +| `"move"` | Pans the camera. | N/A | +| `"fill"` | Paint-bucket fill from the clicked pixel with `brush.primary`. | Same fill with `brush.secondary`. | +| `"select"` | Drag to select or move a rectangle. | N/A | -Switching to `"move"` cancels an armed line. Switching away from `"select"` clears any active selection. +- `"paint"`: the two buttons are mutually exclusive, a stroke already in progress on one button blocks the other from starting until it ends. +- `"fill"`: fills the clicked pixel's contiguous region by default, or every same-colored pixel on the canvas when `fillGlobal` is `true` (see below). A fill click is single-shot and not tracked as a drag, so, unlike `"paint"`, the two buttons aren't mutually exclusive. +- `"select"`: `Ctrl`/`Cmd`+`C`/`V` copies/duplicates, `Delete` erases, `R` rotates the selection 90Β° clockwise around its center (repeatable: press again for further rotation; no counterclockwise binding), `H`/`V` flips the selection's content horizontally/vertically in place. A drag that never grows past its starting pixel (a plain click) does not create a selection. -The SVG brush-cursor highlight is active in `"paint"` and `"fill"` modes. In `"fill"`, and in `"paint"` while `pickColorArmed` is `true`, the highlight is always a single pixel regardless of `brush`'s configured size, since neither a fill's seed nor a color pick is brush-sized. +> [!IMPORTANT] +> - The line/fill/select tools are internal implementation details with no public class of their own. +> - Switching to `"move"` cancels an armed line. Switching away from `"select"` clears any active selection. +> - The SVG brush-cursor highlight is active only in `"paint"` and `"fill"`. In `"fill"`, and in `"paint"` while `pickColorArmed` is `true`, the highlight is always a single pixel regardless of `brush`'s configured size, since neither a fill's seed nor a color pick is brush-sized. --- @@ -141,9 +160,28 @@ get fillGlobal(): boolean set fillGlobal(global: boolean) ``` -Reads or sets whether `"fill"` mode recolors every pixel matching the seed's color anywhere on the canvas (`true`) instead of only the seed's 4-directionally connected region (`false`, the default). Runtime-only β€” there is no constructor option β€” and the setting persists across mode switches, mirroring `brush`'s size/color. +Reads or sets whether `"fill"` mode recolors every pixel matching the seed's color anywhere on the canvas (`true`), instead of only the seed's 4-directionally connected region (`false`, the default). + +Runtime-only: there is no constructor option. The setting persists across mode switches, mirroring `brush`'s size/color. -A global fill is still committed and undoable as a single atomic edit, but is broadcast over `onBufferUpdated`/the network layer as a compact `"global-fill"` event (`{ fromColor, toColor }`, no position list) rather than `"stroke"`, since it can touch a large fraction of the canvas β€” see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md). Undoing/redoing a global fill falls back to a full-position `"stroke"` event. +> [!IMPORTANT] +> A global fill still commits and undoes as a single atomic edit, but broadcasts over `onBufferUpdated`/the network layer as a compact `"global-fill"` event (`{ fromColor, toColor }`, no position list) instead of `"stroke"`, since it can touch a large fraction of the canvas; see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md). Undoing/redoing a global fill replays as a full-position `"stroke"` event instead. + +--- + +### `selectShape` + +```ts +get selectShape(): boolean +set selectShape(shape: boolean) +``` + +Reads or sets whether `"select"` mode's click-to-start (on empty space, not on top of an existing selection) computes a magic-wand shape selection around the clicked pixel's connected region (`true`), instead of starting a rectangle drag (`false`, the default). + +Runtime-only: there is no constructor option. Toggling this value clears any active selection. + +> [!IMPORTANT] +> A connected region smaller than 2 pixels does not produce a selection; the click is a no-op. --- @@ -155,13 +193,20 @@ set pickColorArmed(armed: boolean) pickColorAt(x: number, y: number): RGBA | null ``` -`pickColorArmed` arms/disarms a one-shot color picker on top of `"paint"` mode β€” it is not a separate `Mode`. While armed, the next left-click in `"paint"` mode samples that pixel instead of painting, applies it to `brush.primary`, and disarms itself. It has no effect in any other mode, and switching `mode` away from `"paint"` disarms it automatically. A click outside the texture bounds is ignored (no pick, stays armed) rather than sampling transparent black. +Three independent paths sample a pixel color into `brush.primary`: -`pickColorAt(x, y)` performs the same sample-and-apply immediately at the given texture position, independent of the current mode and of `pickColorArmed` β€” it's the direct/programmatic entry point, e.g. for a "pick color" toolbar button that should always work regardless of what mode is active. Returns the sampled `RGBA`, or `null` (brush left untouched) when `(x, y)` is outside the texture. +| Path | Trigger | Scope | +|---|---|---| +| `pickColorArmed = true` | Next left-click in `"paint"` mode | Arms/disarms itself; no effect in any other mode | +| `pickColorAt(x, y)` | Called directly, any time | Programmatic entry point (e.g. a toolbar button); independent of `mode`/`pickColorArmed` | +| `Ctrl`+right-click | Mousedown, in `"paint"` mode | Always available; single-shot, independent of `pickColorArmed` | -`Ctrl`+right-click in `"paint"` mode is a third, always-available path to the same one-shot pick into `brush.primary`: it's a single-shot sample-and-apply at mousedown (not tracked as a drag, and does not start a `brush.secondary` stroke), independent of `pickColorArmed`. +- `pickColorArmed`: not a separate `Mode`, just a flag on top of `"paint"`. While armed, the next left-click samples that pixel instead of painting, applies it, and disarms itself. Switching `mode` away from `"paint"` disarms it automatically. A click outside the texture bounds is ignored (stays armed) rather than sampling transparent black. +- `pickColorAt(x, y)`: returns the sampled `RGBA`, or `null` (brush left untouched) when `(x, y)` is outside the texture. +- `Ctrl`+right-click: a single-shot sample-and-apply at mousedown, not tracked as a drag, and does not start a `brush.secondary` stroke. Plain right-click (no `Ctrl`) is not a picker; see `mode` above for what it does instead. -Every path dispatches a `"colorpicked"` CustomEvent (`detail: { hex, opacity }`, bubbling and composed) on the element returned by `canvas()`, for UI that mirrors the pick onto a color swatch. Plain right-click (no `Ctrl`) is not a picker β€” see `mode` above for what it does instead. +> [!IMPORTANT] +> Every path dispatches a `"colorpicked"` CustomEvent (`detail: { hex, opacity }`, bubbling and composed) on the element returned by `canvas()`; use it to mirror the pick onto a UI color swatch. --- @@ -172,7 +217,7 @@ get backgroundColor(): string set backgroundColor(color: ColorInput) ``` -Reads or changes the fill color for the canvas area outside the texture bounds β€” see the `backgroundColor` constructor option above for how the initial value is resolved. The setter takes effect immediately (redraws the canvas itself); no `drawFrame()` call needed. +Reads or changes the fill color for the canvas area outside the texture bounds; see the `backgroundColor` constructor option above for how the initial value is resolved. The setter takes effect immediately (redraws the canvas itself); no `drawFrame()` call needed. --- @@ -193,7 +238,9 @@ Reads or changes the current texture size. Setting it resizes the working buffer commitPixels(pixels: Vec2[], slot?: BrushColorSlot): void ``` -Commits an already-computed pixel set as a single atomic edit: one draw call, one redraw, one `"stroke"` hook emission. Used internally by the line tool to commit a whole rasterized line in one operation instead of redrawing once per point (always `"primary"`, matching the line tool itself), and by the fill tool to commit a flood-filled region in one shot (`"primary"` for a left-click, `"secondary"` for a right-click). A no-op when `pixels` is empty. `slot` defaults to `"primary"`; the color used is that slot's current color/opacity. +Commits an already-computed pixel set as a single atomic edit: one draw call, one redraw, one `"stroke"` hook emission. A no-op when `pixels` is empty. `slot` defaults to `"primary"`; the color used is that slot's current color/opacity. + +Used internally by the line tool (a whole rasterized line committed in one operation instead of redrawing once per point, always `"primary"`) and the fill tool (a flood-filled region committed in one shot, `"primary"` for a left-click / `"secondary"` for a right-click). --- @@ -206,11 +253,17 @@ canUndo(): boolean canRedo(): boolean ``` -Reverts/re-applies the most recent local edit (stroke, resize, or texture replace) via the internal `HistoryController`, which wraps a [`HistoryStack`](./history/HistoryStack.md). `undo()`/`redo()` return `false` and do nothing when `history.enabled` wasn't passed at construction, or when the corresponding stack is empty; `canUndo()`/`canRedo()` report the same condition without mutating anything. Both bound to the configurable undo/redo keybindings by default, see [utils/keybindings.md](./utils/keybindings.md). +Reverts/re-applies the most recent local edit (stroke, resize, or texture replace) via the internal `HistoryController`, which wraps a [`HistoryStack`](./history/HistoryStack.md). + +- `undo()`/`redo()` return `false` and do nothing when `history.enabled` wasn't passed at construction, or when the corresponding stack is empty. +- `canUndo()`/`canRedo()` report the same condition without mutating anything. +- Both are bound to the configurable undo/redo keybindings by default; see [utils/keybindings.md](./utils/keybindings.md). -A successful `undo()`/`redo()` redraws the canvas, calls `onDrawEnd`, fires `onHistoryChange`, and β€” for a history-enabled `PixelArtCanvas` attached to a `PixelSyncSession` β€” emits the reverted/re-applied state through `onBufferUpdated` so peers converge to the same result (see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md) for how the replayed event's `originTimestamp` keeps that fair under conflict resolution). The one exception: undoing/redoing a `"select"`-mode edit (move/delete/paste/rotate/flip) never emits `onBufferUpdated`, since those edits aren't networked in the first place (see `mode`/select-mode note above) β€” undo/redo for them is local-only. +A successful call redraws the canvas, calls `onDrawEnd`, and fires `onHistoryChange`. -A remote resize, texture-replace, or snapshot load clears the local history stack (its recorded positions/sizes no longer describe the buffer), so `canUndo()`/`canRedo()` drop to `false` after one. +> [!IMPORTANT] +> - For a history-enabled `PixelArtCanvas` attached to a `PixelSyncSession`, a successful `undo()`/`redo()` also emits the reverted/re-applied state through `onBufferUpdated` so peers converge to the same result (the replayed event's `originTimestamp` keeps that fair under conflict resolution; see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md)). **Exception:** undoing/redoing a `"select"`-mode edit (move/delete/paste/rotate/flip) never emits `onBufferUpdated`, since those edits aren't networked in the first place, so their undo/redo is local-only. +> - A remote resize, texture-replace, or snapshot load clears the local history stack (its recorded positions/sizes no longer describe the buffer), so `canUndo()`/`canRedo()` drop to `false` afterward. --- @@ -222,7 +275,7 @@ flipSelectionHorizontal(): boolean flipSelectionVertical(): boolean ``` -Programmatic equivalents of the `R`/`H`/`V` select-mode keybindings (e.g. for a toolbar button) β€” same underlying commit path, so keyboard and button can't drift apart. Each returns `false` and does nothing without an active `"select"`-mode selection. +Programmatic equivalents of the `R`/`H`/`V` select-mode keybindings (e.g. for a toolbar button): same underlying commit path, so keyboard and button can't drift apart. Each returns `false` and does nothing without an active `"select"`-mode selection. --- @@ -295,7 +348,7 @@ get keybindings(): Readonly patchKeybindings(patch: Partial): void ``` -Reads the currently effective keybindings, or merges `patch` onto them (actions not present in `patch` keep their current binding). Throws `InvalidKeybindingError` for a malformed combo string, or `KeybindingConflictError` if the result would bind two actions to the same combo β€” either way the previous keybindings remain in effect. See [utils/keybindings.md](./utils/keybindings.md). +Reads the currently effective keybindings, or merges `patch` onto them (actions not present in `patch` keep their current binding). Throws `InvalidKeybindingError` for a malformed combo string, or `KeybindingConflictError` if the result would bind two actions to the same combo; either way the previous keybindings remain in effect. See [utils/keybindings.md](./utils/keybindings.md). --- diff --git a/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md b/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md index 4977b27d..3dda9be2 100644 --- a/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md +++ b/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md @@ -82,6 +82,16 @@ Writes a rectangular block of per-pixel colors (row-major, `rect.width * rect.he --- +### `drawMaskedRegion` + +```ts +drawMaskedRegion(rect: SelectionRect, pixels: RGBA[], mask: boolean[]): void +``` + +Same as `drawRegion`, but skips any cell whose row-major `mask` entry is `false`. Used to paint a non-rectangular (e.g. shape-selected) region without touching the cells outside its mask. Out-of-bounds positions are skipped, same as `drawPixels`/`drawRegion`. + +--- + ### `copyToMaster` ```ts @@ -100,6 +110,16 @@ samplePixel(x: number, y: number): [number, number, number, number] Returns the `[r, g, b, a]` of the working buffer at `(x, y)`. Out-of-bounds reads return `0` for each component rather than throwing. +--- + +### `samplePixels` + +```ts +samplePixels(positions: Vec2[]): RGBA[] +``` + +Batch form of `samplePixel`: returns one `RGBA` per position, in order. Out-of-bounds positions sample as fully transparent black (`{ r: 0, g: 0, b: 0, a: 0 }`) rather than being skipped or throwing. + ## Hooks ```ts @@ -140,6 +160,8 @@ type PixelBufferHookAction = PixelBufferHookEvent["action"]; type PixelBufferHookListener = (event: PixelBufferHookEvent) => void; ``` -This is the shape of `PixelArtCanvas`'s `onBufferUpdated` local-mutation hook, and the vocabulary the [network layer](../network/index.md) is built on β€” every event is a valid network command payload once stamped with routing metadata. `"stroke"` covers a whole paint stroke or `commitPixels` call, not one event per brush stamp. `originTimestamp`, set only when `PixelArtCanvas.undo()`/`redo()` replay an edit, carries that edit's original timestamp so the network [conflict resolver](../network/ConflictResolver.md) re-races the replay fairly instead of it always winning by virtue of being freshly stamped; it's stripped before the command is sent over the wire. +This is the shape of `PixelArtCanvas`'s `onBufferUpdated` local-mutation hook, and the vocabulary the [network layer](../network/index.md) is built on: every event is a valid network command payload once stamped with routing metadata. `"stroke"` covers a whole paint stroke or `commitPixels` call, not one event per brush stamp. -`"global-fill"` (emitted by `PixelArtCanvas`'s fill tool when `setFillGlobal(true)`) is deliberately compact β€” no position list β€” since it can touch a large fraction of the canvas. Every applier (a remote peer via `applyRemoteCommand`, or [`PixelCommandApplier`](../network/PixelCommandApplier.md) on the server) recomputes the affected pixels itself by scanning its own buffer for `fromColor` and repainting them `toColor`, which is only correct because peers apply commands in the same order against an already-synced buffer. It also bypasses per-pixel conflict resolution (unlike `"stroke"`) β€” see [network/ConflictResolver.md](../network/ConflictResolver.md). Undoing/redoing a global fill locally still replays as an ordinary full-position `"stroke"` event, since exact undo requires knowing exactly which pixels were touched. +> [!IMPORTANT] +> - `originTimestamp` is set only when `PixelArtCanvas.undo()`/`redo()` replay an edit. It carries the edit's original timestamp so the network [conflict resolver](../network/ConflictResolver.md) re-races the replay fairly instead of it always winning by virtue of being freshly stamped; it's stripped before the command is sent over the wire. +> - `"global-fill"` (emitted by the fill tool when `setFillGlobal(true)`) is deliberately compact, with no position list, since it can touch a large fraction of the canvas. Every applier (a remote peer via `applyRemoteCommand`, or [`PixelCommandApplier`](../network/PixelCommandApplier.md) on the server) recomputes the affected pixels itself by scanning its own buffer for `fromColor` and repainting them `toColor`; this is only correct because peers apply commands in the same order against an already-synced buffer. It also bypasses per-pixel conflict resolution, unlike `"stroke"` (see [network/ConflictResolver.md](../network/ConflictResolver.md)). Undoing/redoing a global fill locally still replays as an ordinary full-position `"stroke"` event, since exact undo requires knowing exactly which pixels were touched. diff --git a/packages/pixel-draw-renderer/docs/history/HistoryStack.md b/packages/pixel-draw-renderer/docs/history/HistoryStack.md index 5b8e1723..2918279f 100644 --- a/packages/pixel-draw-renderer/docs/history/HistoryStack.md +++ b/packages/pixel-draw-renderer/docs/history/HistoryStack.md @@ -1,8 +1,8 @@ # HistoryStack -Bounded undo/redo stack over a `DefaultPixelBuffer` (`PixelBuffer` or `CanvasBuffer`) β€” no DOM or network dependency, so it runs identically headless or in the browser. `PixelArtCanvas`'s internal `HistoryController` owns one when constructed with `history.enabled: true` (see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo)); most consumers drive undo/redo through `PixelArtCanvas.undo()`/`redo()` rather than this class directly. +Bounded undo/redo stack over a `DefaultPixelBuffer` (`PixelBuffer` or `CanvasBuffer`), with no DOM or network dependency, so it runs identically headless or in the browser. `PixelArtCanvas`'s internal `HistoryController` owns one when constructed with `history.enabled: true` (see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo)); most consumers drive undo/redo through `PixelArtCanvas.undo()`/`redo()` rather than this class directly. -`HistoryStack` only owns the stack and replays before/after data against its buffer β€” capturing that before/after data on each edit is the caller's job (`PixelArtCanvas` does this internally for strokes, resizes, and texture replaces). +`HistoryStack` only owns the stack and replays before/after data against its buffer; capturing that before/after data on each edit is the caller's job (`PixelArtCanvas` does this internally for strokes, resizes, and texture replaces). ## Types @@ -44,13 +44,28 @@ type HistoryEntry = positions: Vec2[]; beforeColors: RGBA[]; afterColors: RGBA[]; + oldRect: SelectionRect; + newRect: SelectionRect; + oldMask: boolean[]; + newMask: boolean[]; }; -/** Same as HistoryEntry, minus `timestamp` β€” stamped by `push()`. */ +/** Same as HistoryEntry, minus `timestamp` (stamped by `push()`). */ type HistoryEntryInput = Omit; ``` -`limit` bounds the undo stack: pushing past it silently drops the oldest entry. A `"stroke"` entry's `beforeColors` is per-position (a stroke can cross pixels of different colors); `afterColor` is a single color since a stroke always paints one uniform color. `"resized"`/`"texture-replaced"` instead snapshot the whole buffer (`beforePixels`/`afterPixels`) since there's no cheaper diff to keep. `"select-edit"` covers every `"select"`-mode edit (move/delete/paste/rotate/flip) with a single entry shape: unlike `"stroke"`, both `beforeColors` and `afterColors` are per-position, since these operations paint heterogeneous, multi-colored regions rather than one uniform color. `positions` is the union of whatever footprint(s) the edit touched (e.g. a Move's source and destination, or a Rotate's pre/post footprint when a non-square selection's dimensions swap) β€” see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo) for the network-sync caveat specific to this entry type. +`limit` bounds the undo stack: pushing past it silently drops the oldest entry. + +| Action | Before | After | +|---|---|---| +| `"stroke"` | `beforeColors`: per-position (a stroke can cross pixels of different colors) | `afterColor`: single color (a stroke always paints one uniform color) | +| `"resized"` / `"texture-replaced"` | `beforePixels`: whole-buffer snapshot | `afterPixels`: whole-buffer snapshot | +| `"select-edit"` | `beforeColors`: per-position | `afterColors`: per-position (unlike `"stroke"`, since these operations paint heterogeneous, multi-colored regions) | + +`"resized"`/`"texture-replaced"` snapshot the whole buffer since there's no cheaper diff to keep. `"select-edit"` covers every `"select"`-mode edit (move/delete/paste/rotate/flip) with a single entry shape. `positions` is the union of whatever footprint(s) the edit touched (e.g. a Move's source and destination, or a Rotate's pre/post footprint when a non-square selection's dimensions swap). `oldRect`/`oldMask` and `newRect`/`newMask` capture the active selection's rectangle and shape mask before/after the edit, so undo/redo can restore the selection itself, not just the pixels. + +> [!IMPORTANT] +> A `"select-edit"` entry's undo/redo is never broadcast over the network; see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo) for why. ## Properties @@ -71,7 +86,7 @@ Whether there's an entry to undo/redo. push(entry: HistoryEntryInput): void ``` -Stamps the entry with the current time (`Date.now()`) and pushes it onto the undo stack, clearing the redo stack. Drops the oldest undo entry once `limit` is exceeded. The timestamp is preserved across future undo/redo replays β€” see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md) for why this matters over the network. +Stamps the entry with the current time (`Date.now()`) and pushes it onto the undo stack, clearing the redo stack. Drops the oldest undo entry once `limit` is exceeded. The timestamp is preserved across future undo/redo replays; see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md) for why this matters over the network. --- @@ -101,4 +116,4 @@ Re-applies the most recently undone entry (applying its `after*` data to the buf clear(): void ``` -Discards every recorded entry, both stacks. Call when the buffer is replaced wholesale from outside the stack's knowledge (e.g. a remote resize/texture-replace/snapshot β€” `PixelArtCanvas` does this automatically in `applyRemoteCommand`/`loadSnapshot`). +Discards every recorded entry, both stacks. Call when the buffer is replaced wholesale from outside the stack's knowledge (e.g. a remote resize/texture-replace/snapshot; `PixelArtCanvas` does this automatically in `applyRemoteCommand`/`loadSnapshot`). diff --git a/packages/pixel-draw-renderer/docs/network/ConflictResolver.md b/packages/pixel-draw-renderer/docs/network/ConflictResolver.md index 8b54e5fb..98a981c5 100644 --- a/packages/pixel-draw-renderer/docs/network/ConflictResolver.md +++ b/packages/pixel-draw-renderer/docs/network/ConflictResolver.md @@ -1,6 +1,11 @@ # ConflictResolver -Conflicts are resolved **per pixel**, not per command. A single stroke command can touch thousands of pixels, so [`PixelSyncServer`](./PixelSyncServer.md) splits a command: pixels that lose the race are dropped from the applied/broadcast copy, the rest are applied normally. `"buffer-added"`, `"buffer-removed"`, `"resized"`, `"texture-replaced"`, and `"global-fill"` are always accepted with no per-pixel arbitration; only `"stroke"` goes through a resolver. `"global-fill"` carries no position list to arbitrate against (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)) β€” it's applied by recomputing matching pixels against the server's own authoritative buffer at receive-time, which is self-consistent as long as commands are applied in the order the server processes them. +Conflicts are resolved **per pixel**, not per command. A single stroke command can touch thousands of pixels, so [`PixelSyncServer`](./PixelSyncServer.md) splits a command: pixels that lose the race are dropped from the applied/broadcast copy, the rest are applied normally. + +Only `"stroke"` goes through a resolver. `"buffer-added"`, `"buffer-removed"`, `"resized"`, `"texture-replaced"`, and `"global-fill"` are always accepted with no per-pixel arbitration. + +> [!IMPORTANT] +> `"global-fill"` carries no position list to arbitrate against (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)). It's applied by recomputing matching pixels against the server's own authoritative buffer at receive-time, which is self-consistent only because commands are applied in the order the server processes them. ## Types @@ -19,7 +24,7 @@ interface PixelConflictContext { * given the last known command header at the same pixel. * * Only the header is tracked (not the full stroke command) since a single - * stroke can touch thousands of pixels β€” keeping a full command per pixel + * stroke can touch thousands of pixels; keeping a full command per pixel * would be wasteful. */ interface PixelConflictResolver { diff --git a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md index 3fbf633f..d7e1e2dd 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md +++ b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md @@ -87,7 +87,7 @@ Processes an incoming command: - `"buffer-added"`: creates the buffer if it doesn't already exist, then broadcasts. - `"buffer-removed"`: deletes the buffer and its conflict-tracking state, then broadcasts. - `"stroke"`: resolves conflicts per-pixel (see [ConflictResolver](./ConflictResolver.md)); applies and broadcasts only the accepted pixels. Dropped entirely (no broadcast) if nothing was accepted. -- `"resized"` / `"texture-replaced"` / `"global-fill"`: always accepted, applied, and broadcast β€” `"global-fill"` carries no position list, so it can't be arbitrated per pixel; see [ConflictResolver](./ConflictResolver.md). +- `"resized"` / `"texture-replaced"` / `"global-fill"`: always accepted, applied, and broadcast. `"global-fill"` carries no position list, so it can't be arbitrated per pixel; see [ConflictResolver](./ConflictResolver.md). Commands targeting an unknown buffer (other than `"buffer-added"`) are dropped. diff --git a/packages/pixel-draw-renderer/docs/network/index.md b/packages/pixel-draw-renderer/docs/network/index.md index 513f630c..37617ed8 100644 --- a/packages/pixel-draw-renderer/docs/network/index.md +++ b/packages/pixel-draw-renderer/docs/network/index.md @@ -11,7 +11,7 @@ implementation: this package has no dependency on voxel-renderer. β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” onBufferUpdated β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” sendCommand β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ PixelArtCanvas │───────────────────▢│ PixelSyncSession │────────────────▢│ Transport β”‚ β”‚ (per buffer) β”‚ β”‚ (multi-buffer) │◀────────────────│ (WebSocket, β”‚ -β”‚ │◀──applyRemote──────│ β”‚ onCommand β”‚ WebRTC, …) β”‚ +β”‚ │◀──applyRemote──────│ β”‚ onCommand β”‚ WebRTC, ...) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ wire β–Ό diff --git a/packages/pixel-draw-renderer/docs/network/types.md b/packages/pixel-draw-renderer/docs/network/types.md index 1e18c35f..723870c6 100644 --- a/packages/pixel-draw-renderer/docs/network/types.md +++ b/packages/pixel-draw-renderer/docs/network/types.md @@ -6,8 +6,8 @@ Wire-format types for the [network sync layer](./index.md). ```ts /** - * Buffer create/destroy events. A PixelArtCanvas has no concept of a bufferId - * so these are never emitted from a PixelArtCanvas's onBufferUpdated hook β€” + * Buffer create/destroy events. A PixelArtCanvas has no concept of a bufferId, + * so these are never emitted from a PixelArtCanvas's onBufferUpdated hook; * they are constructed directly by PixelSyncSession.createBuffer/removeBuffer. */ type PixelLifecycleEvent = @@ -18,10 +18,12 @@ type PixelLifecycleEvent = /** Base64-encoded RGBA bytes for the buffer's initial content, if any. */ pixels?: string; }; + originTimestamp?: number; } | { action: "buffer-removed"; metadata: Record; + originTimestamp?: number; }; type PixelNetworkEvent = PixelBufferHookEvent | PixelLifecycleEvent; @@ -49,3 +51,6 @@ interface PixelBufferSnapshot { ``` `PixelBufferHookEvent` (the `"stroke"` / `"resized"` / `"texture-replaced"` / `"global-fill"` local-mutation events) is defined in [buffer/PixelBuffer.md](../buffer/PixelBuffer.md); a `PixelNetworkCommand` is that same event shape plus `PixelLifecycleEvent`, enriched with the header fields. Six actions total: `"buffer-added"`, `"buffer-removed"`, `"stroke"`, `"resized"`, `"texture-replaced"`, `"global-fill"`. All pixel payloads (`stroke` positions and `global-fill`'s colors excepted) are raw RGBA bytes, base64-encoded via `js-base64`: no image codec dependency, so `PixelSyncServer` stays headless. Commands are plain JSON-serializable objects. + +> [!IMPORTANT] +> `PixelLifecycleEvent`'s `originTimestamp` exists only for structural symmetry with `PixelBufferHookEvent` (so the stamping logic in [`PixelSyncSession`](./PixelSyncSession.md) can handle every event uniformly). `"buffer-added"`/`"buffer-removed"` are never replayed through undo/redo, so it's always `undefined` in practice for these two actions. diff --git a/packages/pixel-draw-renderer/docs/tools/Brush.md b/packages/pixel-draw-renderer/docs/tools/Brush.md index cab7669f..6667336c 100644 --- a/packages/pixel-draw-renderer/docs/tools/Brush.md +++ b/packages/pixel-draw-renderer/docs/tools/Brush.md @@ -54,7 +54,7 @@ readonly primary: BrushColor readonly secondary: BrushColor ``` -Each is a `BrushColor` value object β€” a color+opacity pair: +Each is a `BrushColor` value object, a color+opacity pair: ```ts set(color: ColorInput, opacity?: number): void diff --git a/packages/pixel-draw-renderer/docs/utils/keybindings.md b/packages/pixel-draw-renderer/docs/utils/keybindings.md index 70609f9d..2923fb00 100644 --- a/packages/pixel-draw-renderer/docs/utils/keybindings.md +++ b/packages/pixel-draw-renderer/docs/utils/keybindings.md @@ -25,7 +25,10 @@ type KeybindingAction = type Keybindings = Record; ``` -A `Keybinding` is a `+`-separated combo string, e.g. `"mod+z"` or `"mod+shift+z"`. `"mod"` matches either Ctrl or Cmd, so a binding behaves the same on every platform. The key segment is matched against the character produced (`KeyboardEvent.key`, case-insensitive), not physical key position, so `"z"` means "whatever key produces the Z character on the user's layout" β€” correct on AZERTY/QWERTZ without the DSL needing to know about layouts. `NamedKey` lists the non-printable keys for editor autocomplete; any other string is still accepted. +A `Keybinding` is a `+`-separated combo string, e.g. `"mod+z"` or `"mod+shift+z"`. `"mod"` matches either Ctrl or Cmd, so a binding behaves the same on every platform. + +> [!IMPORTANT] +> The key segment is matched against the character produced (`KeyboardEvent.key`, case-insensitive), not physical key position. `"z"` means "whatever key produces the Z character on the user's layout," correct on AZERTY/QWERTZ without the DSL needing to know about layouts. `NamedKey` lists the non-printable keys for editor autocomplete; any other string is still accepted. Only `copy`, `paste`, `undo`, `redo`, `delete`, `rotate`, `flipHorizontal`, and `flipVertical` are configurable. Shift (used to arm/disarm the line tool in `"paint"` mode) is not. @@ -44,7 +47,7 @@ const DEFAULT_KEYBINDINGS: Keybindings = { }; ``` -The keybindings `PixelArtCanvas` uses when `keybindings` isn't passed to its options, and the base a partial override is merged onto. `redo` has two default triggers; any action may be given an array of alternate bindings. `rotate`/`flipHorizontal`/`flipVertical` only have an effect in `"select"` mode with an active selection (rotate is clockwise-only β€” press it multiple times for other angles). +The keybindings `PixelArtCanvas` uses when `keybindings` isn't passed to its options, and the base a partial override is merged onto. `redo` has two default triggers; any action may be given an array of alternate bindings. `rotate`/`flipHorizontal`/`flipVertical` only have an effect in `"select"` mode with an active selection (rotate is clockwise-only; press it multiple times for other angles). Matching is exact on modifiers: `"mod+c"` does **not** also match Ctrl+Shift+C, and the default `"Delete"` binding (no modifier) does not also match Ctrl+Delete. @@ -55,4 +58,4 @@ class InvalidKeybindingError extends Error {} class KeybindingConflictError extends Error {} ``` -Both are thrown synchronously from the constructor's `keybindings` option and from `patchKeybindings()` β€” never asynchronously, and never left as a silently-dropped binding. `InvalidKeybindingError` is thrown for a malformed combo string (unknown modifier token, empty/missing key segment). `KeybindingConflictError` is thrown when two different actions would resolve to the same combo. +Both are thrown synchronously from the constructor's `keybindings` option and from `patchKeybindings()`: never asynchronously, and never left as a silently-dropped binding. `InvalidKeybindingError` is thrown for a malformed combo string (unknown modifier token, empty/missing key segment). `KeybindingConflictError` is thrown when two different actions would resolve to the same combo. diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index d6b87e5d..165c69ab 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -84,18 +84,14 @@ export interface PixelArtCanvasOptions { squareSize: number; }; /** - * Fill color for the canvas area outside the texture bounds (the "void" - * around the drawing surface). Defaults to the parent element's own CSS - * `background-color` if it's set and non-transparent, else `#424242`. - * Can also be changed after construction via the `backgroundColor` setter. + * Canvas color outside texture bounds. */ backgroundColor?: ColorInput; brush?: BrushOptions; select?: { /** - * Fill color for deleted pixels and the source of moved selections. - * Accepts a CSS color or `Color` instance. - * @default fully transparent + * Fill color for deleted pixels and moved selections. + * @default transparent */ eraseColor?: ColorInput; }; @@ -107,17 +103,19 @@ export interface PixelArtCanvasOptions { * Called for local strokes, resizes, and texture replacements. */ onBufferUpdated?: PixelBufferHookListener; - /** Local undo/redo stack. Disabled by default. */ + /** + * History settings. If omitted, history is disabled. + */ history?: { enabled?: boolean; - /** @default 10 */ + /** + * @default 10 + */ limit?: number; }; - /** Called whenever the undo/redo stack changes. */ onHistoryChange?: (state: HistoryState) => void; /** - * Keybinding overrides. Unspecified actions keep their defaults; Shift is fixed. - * Can be updated with `patchKeybindings()`. + * Keybinding overrides. */ keybindings?: Partial; } @@ -215,7 +213,6 @@ export class PixelArtCanvas { const brushAdapter: BrushHighlight = { get size() { - // Fill and an armed color-pick both target one seed pixel. return self.#mode === "fill" || self.#tools.brush.pickArmed ? 1 : brushRef.size; }, get colorInline() { @@ -304,10 +301,7 @@ export class PixelArtCanvas { } /** - * Whether the next paint-mode click picks a color from the canvas instead - * of painting, auto-clearing itself after a successful pick. Has no - * effect outside paint mode, and is cleared automatically when `mode` is - * set to anything other than `"paint"`. + * Whether the next paint action picks a color. */ get pickColorArmed(): boolean { return this.#tools.brush.pickArmed; @@ -320,9 +314,7 @@ export class PixelArtCanvas { } /** - * Samples the pixel at the given texture position and applies it to the - * brush color, regardless of the current mode. Returns the sampled - * color, or `null` if the position is outside the texture. + * Samples a texture pixel into the primary brush color. */ pickColorAt( x: number, @@ -332,7 +324,7 @@ export class PixelArtCanvas { } /** - * Whether fill recolors all matching pixels instead of only the connected region. + * Whether fills recolor all matching pixels. */ get fillGlobal(): boolean { return this.#tools.fill.global; @@ -345,9 +337,7 @@ export class PixelArtCanvas { } /** - * Whether a select-mode mousedown on empty space performs a magic-wand - * shape click-select (connected region + enclosed holes) instead of - * dragging out a rectangle. Changing it clears any active selection. + * Whether empty-space clicks create shape selections. */ get selectShape(): boolean { return this.#tools.select.shape; @@ -360,9 +350,7 @@ export class PixelArtCanvas { } /** - * Fill color for the canvas area outside the texture bounds. See the - * `backgroundColor` constructor option for the initial-value fallback - * chain; this setter always applies immediately, no `drawFrame()` needed. + * Canvas color outside texture bounds. */ get backgroundColor(): string { return this.#renderer.backgroundColor; @@ -451,7 +439,7 @@ export class PixelArtCanvas { } /** - * Applies a partial keybinding update to the current bindings. + * Applies keybinding overrides. */ patchKeybindings( patch: Partial @@ -464,21 +452,21 @@ export class PixelArtCanvas { } /** - * Rotates the active selection clockwise. Returns `false` without a selection. + * Rotates the active selection clockwise. */ rotateSelection(): boolean { return this.#tools.select.handleRotate(); } /** - * Mirrors the active selection horizontally. Returns `false` without a selection. + * Mirrors the active selection horizontally. */ flipSelectionHorizontal(): boolean { return this.#tools.select.handleFlipHorizontal(); } /** - * Mirrors the active selection vertically. Returns `false` without a selection. + * Mirrors the active selection vertically. */ flipSelectionVertical(): boolean { return this.#tools.select.handleFlipVertical(); @@ -509,9 +497,6 @@ export class PixelArtCanvas { return this.#canvasBuffer.canvas(); } - /** - * Returns the interactive canvas used for input and overlays. - */ canvas(): HTMLCanvasElement { return this.#renderer.canvas(); } @@ -562,7 +547,7 @@ export class PixelArtCanvas { } /** - * Commits pixels as one atomic stroke edit. + * Commits pixels as a stroke edit. */ commitPixels( pixels: Vec2[], @@ -593,8 +578,8 @@ export class PixelArtCanvas { } /** - * Reverts the latest local edit. Returns `false` when history is unavailable. - **/ + * Reverts the latest local edit. + */ undo(): boolean { const entry = this.#history.undo(); if (!entry) { @@ -614,8 +599,8 @@ export class PixelArtCanvas { } /** - * Re-applies the latest undone edit. Returns `false` when history is unavailable. - **/ + * Reapplies the latest reverted edit. + */ redo(): boolean { const entry = this.#history.redo(); if (!entry) { @@ -643,7 +628,7 @@ export class PixelArtCanvas { } /** - * Replaces the local buffer-mutation listener. + * Replaces the local buffer-mutation listener. */ set onBufferUpdated( fn: PixelBufferHookListener | undefined @@ -652,7 +637,7 @@ export class PixelArtCanvas { } /** - * Applies a remote mutation without emitting it again. + * Applies a remote mutation without emitting it. */ applyRemoteCommand( event: PixelBufferHookEvent @@ -661,7 +646,7 @@ export class PixelArtCanvas { } /** - * Replaces the buffer from a remote snapshot without emitting a mutation. + * Replaces the buffer from a remote snapshot. */ loadSnapshot( size: Vec2, diff --git a/packages/pixel-draw-renderer/src/buffer/CanvasBuffer.ts b/packages/pixel-draw-renderer/src/buffer/CanvasBuffer.ts index 9b2f2ef3..95fe21c3 100644 --- a/packages/pixel-draw-renderer/src/buffer/CanvasBuffer.ts +++ b/packages/pixel-draw-renderer/src/buffer/CanvasBuffer.ts @@ -8,15 +8,14 @@ import type { SelectionRect, Vec2 } from "../types.ts"; -import type { DefaultPixelBuffer } from "./types.ts"; +import type { + DefaultPixelBuffer +} from "./types.ts"; export type CanvasBufferOptions = PixelBufferOptions; /** - * CanvasBuffer is a Canvas2D adapter over a headless PixelBuffer: PixelBuffer - * is the canonical pixel data (usable server-side with no DOM), and this class - * keeps a working HTMLCanvasElement in sync with it so CanvasRenderer can blit - * it directly every frame instead of paying a putImageData cost per draw. + * Synchronizes a PixelBuffer with a canvas. */ export class CanvasBuffer implements DefaultPixelBuffer { #buffer: PixelBuffer; @@ -76,7 +75,6 @@ export class CanvasBuffer implements DefaultPixelBuffer { source: HTMLCanvasElement | HTMLImageElement ): void { let canvas: HTMLCanvasElement; - // HTMLCanvasElement has getContext(); HTMLImageElement does not. if ("getContext" in source) { canvas = source; } @@ -110,8 +108,7 @@ export class CanvasBuffer implements DefaultPixelBuffer { } /** - * Replaces the pixel data in place, keeping #workingCanvas's identity β€” - * used to apply raw bytes (e.g. a network snapshot) with no source canvas/image. + * Replaces pixels and resizes the existing canvas. */ replacePixels( pixels: Uint8ClampedArray, @@ -125,7 +122,7 @@ export class CanvasBuffer implements DefaultPixelBuffer { } /** - * Returns a copy β€” unlike PixelBuffer.pixels, mutating it won't affect the canvas. + * Returns a copy of the pixel data. */ pixels(): Uint8ClampedArray { return Uint8ClampedArray.from( @@ -134,8 +131,7 @@ export class CanvasBuffer implements DefaultPixelBuffer { } /** - * Syncs the canvas with one putImageData over the affected bounding box, - * instead of one per pixel (a real cost for large edits like a flood fill). + * Writes one color to each in-bounds position and syncs the canvas. */ drawPixels( pixels: Iterable, @@ -190,8 +186,7 @@ export class CanvasBuffer implements DefaultPixelBuffer { } /** - * Syncs the canvas with one putImageData over the in-bounds intersection - * of `rect` β€” the multi-color counterpart to drawPixels. + * Writes row-major colors to in-bounds rectangle cells and syncs the canvas. */ drawRegion( rect: SelectionRect, @@ -202,9 +197,7 @@ export class CanvasBuffer implements DefaultPixelBuffer { } /** - * Mask-aware counterpart to drawRegion, for non-rectangular (shape) - * selections: cells where `mask[i]` is false are left untouched instead - * of being overwritten with `pixels[i]`. + * Writes only rectangle cells with a true mask value and syncs the canvas. */ drawMaskedRegion( rect: SelectionRect, @@ -215,12 +208,6 @@ export class CanvasBuffer implements DefaultPixelBuffer { this.#resyncCanvasRegion(rect); } - /** - * Re-reads `rect`'s in-bounds intersection back from the buffer (now the - * source of truth) into the working canvas with one putImageData call β€” - * shared by drawRegion/drawMaskedRegion, since the buffer write already - * happened and only the untouched-vs-touched cells differ between them. - */ #resyncCanvasRegion( rect: SelectionRect ): void { @@ -266,6 +253,9 @@ export class CanvasBuffer implements DefaultPixelBuffer { return this.#buffer.samplePixel(x, y); } + /** + * Returns transparent pixels for out-of-bounds positions. + */ samplePixels( positions: Vec2[] ): RGBA[] { diff --git a/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts b/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts index 2cdbc0d6..cc0c7f35 100644 --- a/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts +++ b/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts @@ -1,25 +1,26 @@ // Import Internal Dependencies -import { toRGBA } from "../utils/colors.ts"; +import { + toRGBA +} from "../utils/colors.ts"; import type { ColorInput, RGBA, SelectionRect, Vec2 } from "../types.ts"; -import type { DefaultPixelBuffer } from "./types.ts"; +import type { + DefaultPixelBuffer +} from "./types.ts"; export interface PixelBufferOptions { size: Vec2; /** - * Default fill color for newly created pixels. Accepts an RGBA object, a - * CSS color string (hex, rgb(), hsl(), named color, ...) or a colorjs.io - * `Color` instance. + * Default fill color. * @default { r: 255, g: 255, b: 255, a: 255 } */ defaultColor?: RGBA | ColorInput; /** - * Size of the backing master buffer. The working buffer can be resized up - * to this limit without losing data previously committed via copyToMaster. + * Maximum master-buffer dimension. * @default 2048 */ maxSize?: number; @@ -34,9 +35,7 @@ const kDefaultColor: RGBA = { }; /** - * PixelBuffer holds raw RGBA pixel data with no DOM dependency, so it can run - * in a headless environment (server, tests) as well as behind a Canvas2D - * adapter (CanvasBuffer) in the browser. + * Stores raw RGBA pixel data without DOM APIs. */ export class PixelBuffer implements DefaultPixelBuffer { #width: number; @@ -68,8 +67,7 @@ export class PixelBuffer implements DefaultPixelBuffer { const { r, g, b, a } = color; for (let i = 0; i < this.#master.length; i += 4) { - // Pixel (0,0) is always initialized fully transparent, regardless of - // defaultColor β€” preserved from the original CanvasBuffer behavior. + // Preserve a transparent origin pixel. const alpha = i === 0 ? 0 : a; this.#master[i] = r; this.#master[i + 1] = g; @@ -115,14 +113,14 @@ export class PixelBuffer implements DefaultPixelBuffer { } /** - * Returns the live working buffer (not a copy). + * Returns the mutable working buffer. */ pixels(): Uint8ClampedArray { return this.#working; } /** - * Replaces the pixel data wholesale, resizing the buffer to match. + * Replaces pixels and resizes the buffer. */ replacePixels( pixels: Uint8ClampedArray, @@ -134,8 +132,7 @@ export class PixelBuffer implements DefaultPixelBuffer { } /** - * Stamps a single color across a list of positions; out-of-bounds - * positions are skipped. + * Writes one color to each in-bounds position. */ drawPixels( positions: Iterable, @@ -144,7 +141,6 @@ export class PixelBuffer implements DefaultPixelBuffer { const { r, g, b, a } = color; for (const { x, y } of positions) { - // Mirrors Canvas2D's implicit clipping of out-of-bounds putImageData calls. if ( x < 0 || x >= this.#width || y < 0 || y >= this.#height @@ -161,9 +157,7 @@ export class PixelBuffer implements DefaultPixelBuffer { } /** - * Writes a rectangular block of per-pixel colors (row-major), unlike - * drawPixels which stamps one color across a list of positions. - * Out-of-bounds positions are skipped, same as drawPixels. + * Writes row-major colors to in-bounds rectangle cells. */ drawRegion( rect: SelectionRect, @@ -191,10 +185,7 @@ export class PixelBuffer implements DefaultPixelBuffer { } /** - * Like drawRegion, but skips any cell where `mask[i]` is false β€” used for - * non-rectangular (shape) selections, where the RGBA array is still a - * dense rect-sized grid but only some of its cells are actually part of - * the selection. Untouched cells keep whatever they held before. + * Writes only rectangle cells with a true mask value. */ drawMaskedRegion( rect: SelectionRect, @@ -256,9 +247,7 @@ export class PixelBuffer implements DefaultPixelBuffer { } /** - * Batched samplePixel: out-of-bounds positions resolve to fully - * transparent rather than reading a neighboring row (samplePixel only - * guards negative indices, not x/y past width/height). + * Returns transparent pixels for out-of-bounds positions. */ samplePixels( positions: Vec2[] diff --git a/packages/pixel-draw-renderer/src/buffer/types.ts b/packages/pixel-draw-renderer/src/buffer/types.ts index d40f9bfc..0db409b6 100644 --- a/packages/pixel-draw-renderer/src/buffer/types.ts +++ b/packages/pixel-draw-renderer/src/buffer/types.ts @@ -1,5 +1,8 @@ // Import Internal Dependencies -import type { RGBA, Vec2 } from "../types.ts"; +import type { + RGBA, + Vec2 +} from "../types.ts"; export interface DefaultPixelBuffer { size(): Vec2; diff --git a/packages/pixel-draw-renderer/src/history/HistoryController.ts b/packages/pixel-draw-renderer/src/history/HistoryController.ts index 1f3b6e75..1bf2f7a8 100644 --- a/packages/pixel-draw-renderer/src/history/HistoryController.ts +++ b/packages/pixel-draw-renderer/src/history/HistoryController.ts @@ -7,9 +7,15 @@ import { 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"; +import { + groupPositionsByColor +} from "./utils.ts"; +import type { + DefaultPixelBuffer +} from "../buffer/types.ts"; +import type { + PixelBufferHookEvent +} from "../buffer/hooks.ts"; export interface HistoryState { canUndo: boolean; @@ -19,28 +25,24 @@ export interface HistoryState { export interface HistoryControllerOptions { /** * @default false - **/ + */ enabled?: boolean; /** * @default 10 */ limit?: number; - /** Called whenever the undo/redo stack changes (push, undo, redo, clear). */ + /** + * Called when the history state changes. + */ onChange?: (state: HistoryState) => void; } /** - * Owns the optional HistoryStack and its change notifications. Callers stay - * responsible for the side effects that go with undo/redo (buffer refresh, - * hook replay, tool resync) β€” this class only tracks entries and reports - * canUndo/canRedo state. + * Manages an optional history stack and change notifications. */ 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. + * Builds hook events that restore an entry's prior state. */ static buildUndoReplayEvents( entry: HistoryEntry @@ -86,7 +88,9 @@ export class HistoryController { } } - /** A stroke's after-state is always one uniform color, so no grouping is needed here (unlike undo). */ + /** + * Builds hook events that restore an entry's later state. + */ static buildRedoReplayEvents( entry: HistoryEntry ): PixelBufferHookEvent[] { @@ -187,7 +191,6 @@ export class HistoryController { return entry; } - /** Discards every recorded entry, e.g. after a remote structural change. */ clear(): void { if (!this.#stack) { return; diff --git a/packages/pixel-draw-renderer/src/history/HistoryStack.ts b/packages/pixel-draw-renderer/src/history/HistoryStack.ts index 95e4e3b9..bcda4c20 100644 --- a/packages/pixel-draw-renderer/src/history/HistoryStack.ts +++ b/packages/pixel-draw-renderer/src/history/HistoryStack.ts @@ -4,8 +4,12 @@ import type { SelectionRect, Vec2 } from "../types.ts"; -import type { DefaultPixelBuffer } from "../buffer/types.ts"; -import { groupPositionsByColor } from "./utils.ts"; +import type { + DefaultPixelBuffer +} from "../buffer/types.ts"; +import { + groupPositionsByColor +} from "./utils.ts"; export interface HistoryStrokeEntry { action: "stroke"; @@ -34,17 +38,7 @@ export interface HistoryTextureReplacedEntry { } /** - * A Select-tool edit (move/delete/paste/rotate/flip) β€” positions cover the - * union of whatever footprint(s) were actually selected (mask-true), with a - * per-position before/after color since these operations paint - * heterogeneous, multi-colored regions (unlike a stroke's single - * afterColor). oldRect/newRect are the selection's footprint before/after - * the edit (identical for delete/paste/flip, which don't move or resize the - * box) and oldMask/newMask are its rect-relative selection mask before/ - * after, so the caller can resync the selection tool's own rect/snapshot/ - * mask state β€” including a shape selection's true outline, not just its - * bounding box β€” on undo/redo. This stack only ever replays raw buffer - * pixels; it has no mask awareness of its own. + * Stores pixels and selection state before and after a selection edit. */ export interface HistorySelectEditEntry { action: "select-edit"; @@ -71,7 +65,9 @@ export type HistoryEntryInput = | Omit; export interface HistoryStackOptions { - /** @default 10 */ + /** + * @default 10 + */ limit?: number; } @@ -79,10 +75,7 @@ export interface HistoryStackOptions { const kDefaultLimit = 10; /** - * Bounded undo/redo stack over DefaultPixelBuffer β€” no DOM or network - * dependency, so it runs identically over a headless PixelBuffer or a - * DOM-backed CanvasBuffer. Capturing before/after data is the caller's job; - * this class only owns the stack and the replay against its buffer. + * Replays a bounded undo/redo stack against a pixel buffer. */ export class HistoryStack { #buffer: DefaultPixelBuffer; @@ -107,9 +100,7 @@ export class HistoryStack { } /** - * Stamps the entry with the current time β€” preserved across future - * undo/redo replays for fair network conflict re-racing β€” and clears the - * redo stack. + * Records an entry with its creation timestamp. */ push( entry: HistoryEntryInput @@ -125,7 +116,6 @@ export class HistoryStack { this.#redoStack = []; } - /** Reverts the most recent entry, moving it to the redo stack. Null when there's nothing to undo. */ undo(): HistoryEntry | null { const entry = this.#undoStack.pop(); if (!entry) { @@ -138,7 +128,6 @@ export class HistoryStack { return entry; } - /** Re-applies the most recently undone entry, moving it back to the undo stack. Null when there's nothing to redo. */ redo(): HistoryEntry | null { const entry = this.#redoStack.pop(); if (!entry) { @@ -151,7 +140,6 @@ export class HistoryStack { return entry; } - /** Discards every recorded entry, e.g. when the buffer is replaced wholesale from outside the stack's knowledge. */ clear(): void { this.#undoStack = []; this.#redoStack = []; diff --git a/packages/pixel-draw-renderer/src/history/utils.ts b/packages/pixel-draw-renderer/src/history/utils.ts index edf0c6b7..04069152 100644 --- a/packages/pixel-draw-renderer/src/history/utils.ts +++ b/packages/pixel-draw-renderer/src/history/utils.ts @@ -10,9 +10,7 @@ export interface ColorGroup { } /** - * Buckets positions by identical color, so a heterogeneous per-pixel - * restore can be applied as a few uniform-color drawPixels calls instead of - * one call per pixel. + * Groups positions by RGBA color. */ export function groupPositionsByColor( positions: Vec2[], diff --git a/packages/pixel-draw-renderer/src/input/InputController.ts b/packages/pixel-draw-renderer/src/input/InputController.ts index e9630b78..d95119cb 100644 --- a/packages/pixel-draw-renderer/src/input/InputController.ts +++ b/packages/pixel-draw-renderer/src/input/InputController.ts @@ -6,9 +6,15 @@ import { type KeybindingAction, type Keybindings } from "../utils/keybindings.ts"; -import type { Vec2 } from "../types.ts"; -import type { Viewport } from "../rendering/Viewport.ts"; -import { isEditableTarget } from "./utils.ts"; +import type { + Vec2 +} from "../types.ts"; +import type { + Viewport +} from "../rendering/Viewport.ts"; +import { + isEditableTarget +} from "./utils.ts"; export interface InputActions { /** diff --git a/packages/pixel-draw-renderer/src/input/utils.ts b/packages/pixel-draw-renderer/src/input/utils.ts index acc09507..4dc36420 100644 --- a/packages/pixel-draw-renderer/src/input/utils.ts +++ b/packages/pixel-draw-renderer/src/input/utils.ts @@ -1,7 +1,17 @@ // CONSTANTS const kEditableInputTypes = new Set([ - "text", "search", "email", "url", "tel", "password", "number", - "date", "datetime-local", "month", "time", "week" + "text", + "search", + "email", + "url", + "tel", + "password", + "number", + "date", + "datetime-local", + "month", + "time", + "week" ]); /** diff --git a/packages/pixel-draw-renderer/src/network/ConflictResolver.ts b/packages/pixel-draw-renderer/src/network/ConflictResolver.ts index 9c574f08..adefb10e 100644 --- a/packages/pixel-draw-renderer/src/network/ConflictResolver.ts +++ b/packages/pixel-draw-renderer/src/network/ConflictResolver.ts @@ -1,31 +1,24 @@ // Import Internal Dependencies -import type { PixelNetworkCommandHeader } from "./types.ts"; +import type { + PixelNetworkCommandHeader +} from "./types.ts"; export interface PixelConflictContext { incoming: PixelNetworkCommandHeader; /** - * Header of the last accepted command at the same pixel, if any. - * `undefined` means no prior command exists at that pixel β†’ always accept. + * Last accepted command at the pixel. */ existing: PixelNetworkCommandHeader | undefined; } -/** - * Determines whether an incoming command should be accepted or rejected - * given the last known command header at the same pixel. - * - * Only the header is tracked (not the full stroke command) since a single - * stroke can touch thousands of pixels β€” keeping a full command per pixel - * would be wasteful. - */ export interface PixelConflictResolver { - resolve(ctx: PixelConflictContext): "accept" | "reject"; + resolve( + ctx: PixelConflictContext + ): "accept" | "reject"; } /** - * Last-Write-Wins resolver: the command with the higher `timestamp` wins. - * On a timestamp tie, the lexicographically greater `clientId` wins, - * giving a deterministic total order without coordination. + * Resolves conflicts by timestamp, then client ID. */ export class LastWriteWinsResolver implements PixelConflictResolver { resolve( diff --git a/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts b/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts index b1106cb7..3877f1d0 100644 --- a/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts +++ b/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts @@ -4,14 +4,10 @@ import { toUint8Array } from "js-base64"; // Import Internal Dependencies import { Fill } from "../tools/Fill.ts"; import { PixelWorld } from "./PixelWorld.ts"; -import type { PixelNetworkCommand } from "./types.ts"; +import type { + PixelNetworkCommand +} from "./types.ts"; -/** - * Applies a single network command to a headless PixelWorld instance. - * - * Used by PixelSyncServer (Node.js, no DOM) and can be used standalone - * for testing replay logic without a renderer. - */ export function applyCommandToWorld( world: PixelWorld, cmd: PixelNetworkCommand diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts index 33374d00..cdb89280 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts @@ -17,42 +17,26 @@ import type { export type PixelStrokeCommand = Extract; /** - * A connected client handle. The consumer creates these objects and passes - * them to PixelSyncServer.connect(). The server calls send() to transmit - * data back to the real network peer. + * Represents a connected network client. */ export interface ClientHandle { readonly id: string; - /** - * Transmit data to this client over the underlying transport. - * The consumer is responsible for framing (JSON-stringify, etc.). - */ send(data: unknown): void; } export interface PixelSyncServerOptions { /** - * Existing PixelWorld to use as the authoritative state. - * A new (empty) world is created when omitted. + * Authoritative buffer store. */ world?: PixelWorld; /** - * Custom conflict resolver. - * Defaults to LastWriteWinsResolver. + * Resolves conflicting pixel writes. */ conflictResolver?: PixelConflictResolver; } /** - * Headless, server-authoritative pixel sync manager. - * - * Has no DOM/Canvas2D dependency and runs in Node.js / Deno / Bun. - * - * Workflow: - * 1. connect(client) β€” register a peer; notifies existing peers. Sends no buffer data. - * 2. subscribe(clientId, bufferId) β€” sends that buffer's current snapshot, if it exists. - * 3. receive(cmd) β€” validate, apply to the world, and broadcast to subscribers of that buffer. - * 4. disconnect(clientId) β€” remove the client and notify peers. + * Manages authoritative pixel state and client synchronization. */ export class PixelSyncServer { readonly world: PixelWorld; @@ -69,15 +53,20 @@ export class PixelSyncServer { this.#resolver = options.conflictResolver ?? new LastWriteWinsResolver(); } - /** Sends no buffer data β€” clients receive a buffer's data via subscribe(). */ connect( client: ClientHandle ): void { - this.#clients.set(client.id, client); + this.#clients.set( + client.id, + client + ); for (const [id, peer] of this.#clients) { if (id !== client.id) { - peer.send({ type: "peer-joined", peerId: client.id }); + peer.send({ + type: "peer-joined", + peerId: client.id + }); } } } @@ -91,13 +80,15 @@ export class PixelSyncServer { } for (const peer of this.#clients.values()) { - peer.send({ type: "peer-left", peerId: clientId }); + peer.send({ + type: "peer-left", + peerId: clientId + }); } } /** - * Subscribes a client to a buffer's future updates and sends its current - * snapshot immediately, if the buffer already exists. + * Subscribes a client and sends the current buffer snapshot. */ subscribe( clientId: string, @@ -133,14 +124,7 @@ export class PixelSyncServer { } /** - * Processes an incoming command: - * - "buffer-added": creates the buffer if it doesn't already exist, then broadcasts. - * - "buffer-removed": deletes the buffer and its conflict-tracking state, then broadcasts. - * - "stroke": resolves conflicts per-pixel; applies and broadcasts only the accepted - * pixels. Dropped entirely (no broadcast) if nothing was accepted. - * - "resized" / "texture-replaced": always accepted, applied, and broadcast. - * - * Commands targeting an unknown buffer (other than "buffer-added") are dropped. + * Applies and broadcasts an incoming command. */ receive( cmd: PixelNetworkCommand @@ -206,7 +190,10 @@ export class PixelSyncServer { const acceptedCmd: PixelStrokeCommand = { ...cmd, - metadata: { ...cmd.metadata, positions: accepted } + metadata: { + ...cmd.metadata, + positions: accepted + } }; applyCommandToWorld(this.world, acceptedCmd); @@ -233,7 +220,10 @@ export class PixelSyncServer { } for (const clientId of subs) { - this.#clients.get(clientId)?.send({ type: "command", data: cmd }); + this.#clients.get(clientId)?.send({ + type: "command", + data: cmd + }); } } diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts index c6e6f2bb..ad376696 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts @@ -22,15 +22,7 @@ export interface OnBufferAddedEventMetadata { } /** - * Client-side network orchestrator. - * - * A single PixelSyncSession multiplexes many buffers (textures/tilesets) - * over one transport connection. Each attached PixelArtCanvas still owns - * exactly one texture β€” the session just assigns it a bufferId for routing, - * so: - * - Local mutations from an attached PixelArtCanvas are stamped and forwarded. - * - Remote commands are routed to the matching PixelArtCanvas by bufferId. - * - Buffer lifecycle (add/remove) is announced/received at the session level. + * Synchronizes multiple canvases over one transport. */ export class PixelSyncSession { #transport: PixelTransport; @@ -55,8 +47,7 @@ export class PixelSyncSession { } /** - * Attaches an existing PixelArtCanvas to sync as `bufferId`. Assumes the - * buffer already exists on the server; subscribes and awaits its snapshot. + * Attaches a canvas to an existing buffer. */ attach( bufferId: string, @@ -72,8 +63,7 @@ export class PixelSyncSession { } /** - * Attaches a PixelArtCanvas AND announces a brand new buffer to peers, - * carrying the manager's current pixel data as the initial shared state. + * Attaches a canvas and creates its shared buffer. */ createBuffer( bufferId: string, @@ -87,7 +77,9 @@ export class PixelSyncSession { })); } - /** Detaches a PixelArtCanvas without announcing its removal to peers. */ + /** + * Detaches a canvas without removing its shared buffer. + */ detach( bufferId: string ): void { @@ -101,7 +93,9 @@ export class PixelSyncSession { this.#transport.unsubscribe(bufferId); } - /** Detaches a PixelArtCanvas and announces its removal to peers. */ + /** + * Detaches a canvas and removes its shared buffer. + */ removeBuffer( bufferId: string ): void { @@ -121,7 +115,9 @@ export class PixelSyncSession { ); } - /** `originTimestamp` (an undo/redo replay) is kept as the command's timestamp instead of "now"; never sent as-is over the wire. */ + /** + * Uses an event's origin timestamp when available. + */ #stamp( bufferId: string, event: PixelNetworkEvent @@ -173,8 +169,7 @@ export class PixelSyncSession { } /** - * Detaches every buffer and clears transport callbacks. Call when the - * session ends. + * Detaches all canvases and clears transport callbacks. */ destroy(): void { for (const bufferId of [...this.#managers.keys()]) { diff --git a/packages/pixel-draw-renderer/src/network/PixelTransport.ts b/packages/pixel-draw-renderer/src/network/PixelTransport.ts index ee80114a..020d583c 100644 --- a/packages/pixel-draw-renderer/src/network/PixelTransport.ts +++ b/packages/pixel-draw-renderer/src/network/PixelTransport.ts @@ -1,18 +1,21 @@ // Import Internal Dependencies -import type { PixelBufferSnapshot, PixelNetworkCommand } from "./types.ts"; +import type { + PixelBufferSnapshot, + PixelNetworkCommand +} from "./types.ts"; /** - * Transport-agnostic interface for sending and receiving pixel network commands. - * - * Consumers implement this interface with a concrete transport layer - * (WebSocket, WebRTC, Partykit, BroadcastChannel, etc.) and pass an instance - * to PixelSyncSession. + * Sends and receives pixel network commands. */ export interface PixelTransport { - /** The client ID assigned to the local peer by the transport layer. */ + /** + * Identifies the local peer. + */ readonly localClientId: string; - /** Sends a local mutation or lifecycle command to the server / peers. */ + /** + * Sends a local command. + */ sendCommand( command: PixelNetworkCommand ): void; @@ -20,14 +23,12 @@ export interface PixelTransport { unsubscribe(bufferId: string): void; /** - * Called by the transport when a command arrives from a remote peer. - * Set this before connecting. + * Receives a command from a remote peer. */ onCommand: ((command: PixelNetworkCommand) => void) | null; /** - * Called by the transport when the server sends a buffer snapshot - * (in response to subscribe). Set this before connecting. + * Receives a buffer snapshot. */ onSnapshot: ((bufferId: string, snapshot: PixelBufferSnapshot) => void) | null; diff --git a/packages/pixel-draw-renderer/src/network/PixelWorld.ts b/packages/pixel-draw-renderer/src/network/PixelWorld.ts index afa10c54..c4c5c33d 100644 --- a/packages/pixel-draw-renderer/src/network/PixelWorld.ts +++ b/packages/pixel-draw-renderer/src/network/PixelWorld.ts @@ -5,10 +5,7 @@ import { } from "../buffer/PixelBuffer.ts"; /** - * Headless, multi-buffer registry. Used by PixelSyncServer as the - * authoritative store for every buffer (texture) shared in a session. - * - * Has no DOM/Canvas2D dependency and runs in Node.js / Deno / Bun. + * Stores headless pixel buffers by ID. */ export class PixelWorld { #buffers = new Map(); diff --git a/packages/pixel-draw-renderer/src/network/index.ts b/packages/pixel-draw-renderer/src/network/index.ts index a0342a45..e7a87c64 100644 --- a/packages/pixel-draw-renderer/src/network/index.ts +++ b/packages/pixel-draw-renderer/src/network/index.ts @@ -13,7 +13,9 @@ export type { export { LastWriteWinsResolver } from "./ConflictResolver.ts"; export { applyCommandToWorld } from "./PixelCommandApplier.ts"; export { PixelWorld } from "./PixelWorld.ts"; -export type { PixelSyncSessionOptions } from "./PixelSyncSession.ts"; +export type { + PixelSyncSessionOptions +} from "./PixelSyncSession.ts"; export { PixelSyncSession } from "./PixelSyncSession.ts"; export type { ClientHandle, diff --git a/packages/pixel-draw-renderer/src/network/types.ts b/packages/pixel-draw-renderer/src/network/types.ts index 3155a64d..d54e45b2 100644 --- a/packages/pixel-draw-renderer/src/network/types.ts +++ b/packages/pixel-draw-renderer/src/network/types.ts @@ -1,18 +1,20 @@ // Import Internal Dependencies -import type { PixelBufferHookEvent } from "../buffer/hooks.ts"; +import type { + PixelBufferHookEvent +} from "../buffer/hooks.ts"; import type { Vec2 } from "../types.ts"; /** - * Buffer create/destroy events. A PixelArtCanvas has no concept of a bufferId - * so these are never emitted from a PixelArtCanvas's onBufferUpdated hook β€” - * they are constructed directly by PixelSyncSession.createBuffer/removeBuffer. + * Describes buffer lifecycle events. */ export type PixelLifecycleEvent = | { action: "buffer-added"; metadata: { size: Vec2; - /** Base64-encoded RGBA bytes for the buffer's initial content, if any. */ + /** + * Base64-encoded initial RGBA data. + */ pixels?: string; }; originTimestamp?: number; @@ -28,20 +30,25 @@ export type PixelNetworkEvent = PixelBufferHookEvent | PixelLifecycleEvent; export interface PixelNetworkCommandHeader { bufferId: string; clientId: string; - /** Monotonically increasing sequence number per client. */ + /** + * Client-local command sequence number. + */ seq: number; - /** Unix timestamp in milliseconds when the command was created. */ + /** + * Command creation time in milliseconds. + */ timestamp: number; } /** - * A network command is a buffer event enriched with routing metadata. - * It can be sent over any transport (WebSocket, WebRTC, Partykit, etc.). + * Combines a network event with routing metadata. */ export type PixelNetworkCommand = PixelNetworkEvent & PixelNetworkCommandHeader; export interface PixelBufferSnapshot { size: Vec2; - /** Base64-encoded RGBA bytes. */ + /** + * Base64-encoded RGBA data. + */ pixels: string; } diff --git a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts index 1f234976..7c77ccdd 100644 --- a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts +++ b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts @@ -5,19 +5,20 @@ import Color from "colorjs.io"; import type { ColorInput } from "../types.ts"; import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; import type { DefaultViewport } from "./Viewport.ts"; -import { FloatingSelectionOverlay } from "./overlays/FloatingSelectionOverlay.ts"; +import { + FloatingSelectionOverlay +} from "./overlays/FloatingSelectionOverlay.ts"; export interface CanvasRendererOptions { viewport: DefaultViewport; canvasBuffer: CanvasBuffer; /** - * Size of the squares in the background checkerboard pattern. Should be a positive integer. + * Checkerboard square size. * @default 8 */ bgSquareSize?: number; /** - * Colors used for the background checkerboard pattern. Accepts CSS color - * strings or colorjs.io `Color` instances. + * Checkerboard colors. * @default { odd: "#999", even: "#666" } */ bgColors?: { @@ -25,17 +26,14 @@ export interface CanvasRendererOptions { even: ColorInput; }; /** - * Background color used when texture has transparent pixels. Accepts a - * CSS color string or a colorjs.io `Color` instance. + * Transparent-pixel background color. * @default "#555555" */ backgroundColor?: ColorInput; } /** - * CanvasRenderer is responsible for rendering the pixel art canvas, including the texture and the background checkerboard pattern. - * It manages an off-screen canvas for the background pattern and the main canvas for rendering the texture. - * The renderer listens to changes in the viewport and texture buffer to update the display accordingly. + * Renders the texture and its background. */ export class CanvasRenderer { #canvas: HTMLCanvasElement; @@ -57,7 +55,10 @@ export class CanvasRenderer { viewport, canvasBuffer, bgSquareSize = 8, - bgColors = { odd: "#999", even: "#666" }, + bgColors = { + odd: "#999", + even: "#666" + }, backgroundColor = "#555555" } = options; @@ -68,7 +69,9 @@ export class CanvasRenderer { odd: new Color(bgColors.odd).toString(), even: new Color(bgColors.even).toString() }; - this.#backgroundColor = new Color(backgroundColor).toString(); + this.#backgroundColor = new Color( + backgroundColor + ).toString(); this.#canvas = document.createElement("canvas"); this.#ctx = this.#canvas.getContext("2d")!; @@ -89,12 +92,17 @@ export class CanvasRenderer { set backgroundColor( color: ColorInput ) { - this.#backgroundColor = new Color(color).toString(); + this.#backgroundColor = new Color( + color + ).toString(); this.drawFrame(); } drawFrame(): void { - if (this.#canvas.width === 0 || this.#canvas.height === 0) { + if ( + this.#canvas.width === 0 || + this.#canvas.height === 0 + ) { return; } @@ -105,17 +113,34 @@ export class CanvasRenderer { this.#ctx.setTransform(1, 0, 0, 1, 0, 0); this.#ctx.fillStyle = this.#backgroundColor; - this.#ctx.fillRect(0, 0, this.#canvas.width, this.#canvas.height); + this.#ctx.fillRect( + 0, + 0, + this.#canvas.width, + this.#canvas.height + ); this.#ctx.save(); this.#ctx.beginPath(); - this.#ctx.rect(camera.x, camera.y, texPixelW, texPixelH); + this.#ctx.rect( + camera.x, + camera.y, + texPixelW, + texPixelH + ); this.#ctx.clip(); this.#ctx.setTransform(1, 0, 0, 1, 0, 0); this.#ctx.drawImage(this.#bgCanvas, 0, 0); - this.#ctx.setTransform(zoom.value, 0, 0, zoom.value, 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); @@ -175,6 +200,9 @@ export class CanvasRenderer { parent.appendChild(this.#canvas); const bounds = parent.getBoundingClientRect(); - this.resize(bounds.width, bounds.height); + this.resize( + bounds.width, + bounds.height + ); } } diff --git a/packages/pixel-draw-renderer/src/rendering/SvgManager.ts b/packages/pixel-draw-renderer/src/rendering/SvgManager.ts index d987c5c5..3b52bb31 100644 --- a/packages/pixel-draw-renderer/src/rendering/SvgManager.ts +++ b/packages/pixel-draw-renderer/src/rendering/SvgManager.ts @@ -3,8 +3,12 @@ import { SVG_NS } from "./constants.ts"; import { BrushHighlightOverlay } from "./overlays/BrushHighlightOverlay.ts"; import { LinePreviewOverlay } from "./overlays/LinePreviewOverlay.ts"; import { SelectionOverlay } from "./overlays/SelectionOverlay.ts"; -import type { DefaultViewport } from "./Viewport.ts"; -import type { BrushHighlight } from "../types.ts"; +import type { + DefaultViewport +} from "./Viewport.ts"; +import type { + BrushHighlight +} from "../types.ts"; export interface SvgManagerOptions { parent: HTMLDivElement; @@ -13,12 +17,7 @@ export interface SvgManagerOptions { } /** - * SvgManager owns the SVG overlay element stacked on top of the canvas and - * its lifecycle (sizing, reparenting, teardown). Each visual aid (brush - * highlight, line preview, selection rect, ...) is its own overlay class in - * `./overlays/`, exposed directly here rather than mirrored through - * pass-through methods β€” callers interact with e.g. `svgManager.selection` - * instead of `svgManager.setSelectionRect(...)`. + * Manages SVG rendering overlays. */ export class SvgManager { #parentHtmlElement: HTMLDivElement; @@ -65,8 +64,14 @@ export class SvgManager { }); const boundingRect = this.#parentHtmlElement.getBoundingClientRect(); - svg.setAttribute("width", String(boundingRect.width)); - svg.setAttribute("height", String(boundingRect.height)); + svg.setAttribute( + "width", + String(boundingRect.width) + ); + svg.setAttribute( + "height", + String(boundingRect.height) + ); this.#parentHtmlElement.appendChild(svg); @@ -77,8 +82,14 @@ export class SvgManager { width: number, height: number ): void { - this.#svg.setAttribute("width", String(width)); - this.#svg.setAttribute("height", String(height)); + this.#svg.setAttribute( + "width", + String(width) + ); + this.#svg.setAttribute( + "height", + String(height) + ); } destroy(): void { diff --git a/packages/pixel-draw-renderer/src/rendering/Viewport.ts b/packages/pixel-draw-renderer/src/rendering/Viewport.ts index 1cabea23..fe966326 100644 --- a/packages/pixel-draw-renderer/src/rendering/Viewport.ts +++ b/packages/pixel-draw-renderer/src/rendering/Viewport.ts @@ -2,7 +2,9 @@ import { clamp } from "../utils/math.ts"; import { ViewportTexture } from "./ViewportTexture.ts"; import { Zoom } from "./Zoom.ts"; -import type { Vec2 } from "../types.ts"; +import type { + Vec2 +} from "../types.ts"; export interface DefaultViewport { readonly zoom: Zoom; @@ -11,37 +13,33 @@ export interface DefaultViewport { export interface ViewportOptions { /** - * Size of the texture to display in the viewport. - * This is used to calculate the camera bounds and the zoom level. + * Displayed texture size. */ textureSize: Vec2; /** * Default zoom level. - * Can be overridden by passing a texture with a different size than the default one. * @default 4 */ zoom?: number; /** - * Minimum zoom level. Must be under the max zoom level. + * Minimum zoom level. * @default 1 */ zoomMin?: number; /** - * Maximum zoom level. Must be above the min zoom level. + * Maximum zoom level. * @default 32 */ zoomMax?: 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. + * Mouse-wheel zoom sensitivity. * @default 0.1 */ zoomSensitivity?: number; } /** - * Viewport manages the camera position and zoom level for a pixel drawing application. - * It provides methods to convert between screen coordinates and texture coordinates, as well as to apply zooming and panning transformations. + * Manages viewport camera and zoom state. */ export class Viewport implements DefaultViewport { #camera: Vec2 = { @@ -94,7 +92,9 @@ export class Viewport implements DefaultViewport { } centerTexture(): void { - const texPx = this.#texture.pixelSize(this.zoom.value); + 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; @@ -102,7 +102,9 @@ export class Viewport implements DefaultViewport { } clampCamera(): void { - const texPx = this.#texture.pixelSize(this.zoom.value); + const texPx = this.#texture.pixelSize( + this.zoom.value + ); const margin = this.zoom.value; const minX = -texPx.x + margin; @@ -115,16 +117,18 @@ export class Viewport implements DefaultViewport { } /** - * Updates the canvas size while preserving the current camera position. - * Shifts the camera by half the size delta so the same world point stays - * at the center of the screen. Use this for interactive resizes. + * Resizes the canvas while preserving its center point. */ resizeCanvas( width: number, height: number ): void { - const dx = (width - this.#canvasWidth) / 2; - const dy = (height - this.#canvasHeight) / 2; + const dx = ( + width - this.#canvasWidth + ) / 2; + const dy = ( + height - this.#canvasHeight + ) / 2; this.#canvasWidth = width; this.#canvasHeight = height; @@ -176,10 +180,17 @@ export class Viewport implements DefaultViewport { my: number, parameters: { bounds: DOMRect; limit?: boolean; } ): Vec2 | null { - const { bounds, limit } = parameters; - - 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); + const { + bounds, + limit + } = parameters; + + 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/ViewportTexture.ts b/packages/pixel-draw-renderer/src/rendering/ViewportTexture.ts index 52d5cd73..730bff1b 100644 --- a/packages/pixel-draw-renderer/src/rendering/ViewportTexture.ts +++ b/packages/pixel-draw-renderer/src/rendering/ViewportTexture.ts @@ -1,18 +1,18 @@ // Import Internal Dependencies -import type { Vec2 } from "../types.ts"; +import type { + Vec2 +} from "../types.ts"; export interface ViewportTextureOptions { size: Vec2; /** - * Called after the texture is resized, so the owning Viewport can react - * (e.g. re-clamp the camera, since its bounds depend on the texture size). + * Called after resizing. */ onResize?: () => void; } /** - * Holds the dimensions of the texture displayed in a Viewport, and the - * size/bounds computations that only depend on those dimensions. + * Stores viewport texture dimensions. */ export class ViewportTexture { #size: Vec2; diff --git a/packages/pixel-draw-renderer/src/rendering/Zoom.ts b/packages/pixel-draw-renderer/src/rendering/Zoom.ts index 82c1a60d..b0c6f218 100644 --- a/packages/pixel-draw-renderer/src/rendering/Zoom.ts +++ b/packages/pixel-draw-renderer/src/rendering/Zoom.ts @@ -1,5 +1,7 @@ // Import Internal Dependencies -import { clamp } from "../utils/math.ts"; +import { + clamp +} from "../utils/math.ts"; export interface ZoomOptions { /** @@ -8,26 +10,24 @@ export interface ZoomOptions { */ default?: number; /** - * Minimum zoom level. Must be under the max zoom level. + * Minimum zoom level. * @default 1 */ min?: number; /** - * Maximum zoom level. Must be above the min zoom level. + * Maximum 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. + * Mouse-wheel zoom sensitivity. * @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. + * Stores zoom state and bounds. */ export class Zoom { #value: number; @@ -54,7 +54,11 @@ export class Zoom { ); } - this.#value = clamp(value, this.#min, this.#max); + this.#value = clamp( + value, + this.#min, + this.#max + ); this.#sensitivity = sensitivity; } @@ -80,11 +84,6 @@ export class Zoom { 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 { @@ -94,7 +93,11 @@ export class Zoom { ? this.#sensitivity / 10 : this.#sensitivity; - this.#value = clamp(this.#value - signDelta * smoothSensitivity, this.#min, this.#max); + 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 79af5c01..767edabf 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/BrushHighlightOverlay.ts @@ -8,8 +8,7 @@ import type { } from "../../types.ts"; /** - * Renders the paint-mode brush cursor as two overlaid rects (inline/outline) - * so the highlight reads against both light and dark texture pixels. + * Renders the brush cursor overlay. */ export class BrushHighlightOverlay { #viewport: DefaultViewport; diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/FloatingSelectionOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/FloatingSelectionOverlay.ts index 24c2368a..1974620c 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/FloatingSelectionOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/FloatingSelectionOverlay.ts @@ -1,45 +1,36 @@ // Import Internal Dependencies -import type { RGBA, SelectionRect } from "../../types.ts"; +import type { + RGBA, + SelectionRect +} from "../../types.ts"; export interface FloatingOverlayOptions { - /** The selection's original position, rendered as eraseColor while floating. */ + /** + * Original selection position. + */ sourceRect: SelectionRect; - /** Row-major pixel data (sourceRect.width * sourceRect.height long). */ + /** + * Row-major selection pixels. + */ pixels: RGBA[]; /** - * Rect-relative, row-major selection mask (same length as `pixels`). - * Omitted (or every cell true) means the whole rect is selected β€” the - * common rectangle-drag case, which skips the per-pixel erase canvas - * below in favor of a cheap 1x1-stretched fill. When some cells are - * false (a shape selection), both the content and erase canvases only - * show/blank the masked cells, leaving the rest of the bounding box's - * pixels visible/untouched. + * Row-major selection mask. */ mask?: boolean[]; eraseColor: RGBA; /** - * Whether to preview sourceRect as vacated (filled with eraseColor) while - * dragging. Set false for a just-pasted selection's first move, whose - * source still holds real content that dropping will NOT erase β€” blanking - * it during the drag would misleadingly hide that content. + * Whether to erase the source while dragging. * @default true */ blankSource?: boolean; } /** - * Renders a selection's captured pixels as a display-only overlay on top of - * the base texture while it's being dragged (move/paste), blanking its - * original position with `eraseColor` so it doesn't look duplicated. Owns no - * canvas of its own to paint onto β€” CanvasRenderer calls draw(ctx) at the - * point in its frame where the overlay should composite. The real - * CanvasBuffer is never touched here β€” PixelArtCanvas commits the actual - * move/paste separately, once, on drop. + * Renders a floating selection overlay. */ export class FloatingSelectionOverlay { #canvas: HTMLCanvasElement | null = null; #eraseCanvas: HTMLCanvasElement | null = null; - /** Whether #eraseCanvas is a 1x1 swatch meant to be stretched (rect case) vs a full-size per-pixel mask (shape case). */ #eraseIsUniform = true; #sourceRect: SelectionRect | null = null; #liveRect: SelectionRect | null = null; @@ -78,7 +69,6 @@ export class FloatingSelectionOverlay { this.#blankSource = blankSource; } - /** A 1x1 canvas holding eraseColor, stretched over the whole rect at draw time. */ static #buildUniformEraseCanvas( eraseColor: RGBA ): HTMLCanvasElement { @@ -97,11 +87,6 @@ export class FloatingSelectionOverlay { return eraseCanvas; } - /** - * A full sourceRect-sized canvas: opaque eraseColor over masked cells, - * fully transparent (untouched when blitted) elsewhere β€” so blanking a - * shape selection's source only visually erases its actual cells. - */ static #buildMaskedEraseCanvas( sourceRect: SelectionRect, mask: boolean[], @@ -130,8 +115,7 @@ export class FloatingSelectionOverlay { } /** - * Updates the overlay's live (drag) position without rebuilding its pixel - * content. No-op when no overlay is active. + * Updates the floating selection position. */ updatePosition( liveRect: SelectionRect @@ -153,9 +137,7 @@ export class FloatingSelectionOverlay { } /** - * Composites the overlay onto `ctx` at its current live position. No-op - * when no overlay is active. Called by CanvasRenderer.drawFrame() after - * the base texture has been blitted, under the same zoom/camera transform. + * Draws the floating selection. */ draw( ctx: CanvasRenderingContext2D @@ -167,15 +149,9 @@ export class FloatingSelectionOverlay { if (this.#blankSource && this.#eraseCanvas) { const source = this.#sourceRect; if (this.#eraseIsUniform) { - // Blitted (drawImage), not fillRect: fillRect anti-aliases its own - // edges under a scaled transform while drawImage (with smoothing - // off) doesn't, and mixing the two left a thin seam at the boundary - // whenever zoom/camera didn't land on whole device pixels. ctx.drawImage(this.#eraseCanvas, 0, 0, 1, 1, source.x, source.y, source.width, source.height); } else { - // Already sourceRect-sized 1:1 (masked cells opaque, rest - // transparent) β€” a direct blit, no stretch needed. ctx.drawImage(this.#eraseCanvas, source.x, source.y); } } diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts index f32af5fd..90458387 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/LinePreviewOverlay.ts @@ -1,19 +1,15 @@ // Import Internal Dependencies import { SVG_NS } from "../constants.ts"; -import type { DefaultViewport } from "../Viewport.ts"; +import type { + DefaultViewport +} from "../Viewport.ts"; import type { BrushHighlight, Vec2 } from "../../types.ts"; /** - * Renders the Shift-to-line preview as a single straight segment through the - * centers of the start/end texture pixels β€” a lightweight indicator of the - * line's path, not a preview of every pixel it will stamp. - * - * A wider "casing" line (colorOutline) behind a narrower one (colorInline) on - * the same path β€” unlike the brush highlight's adjacent-border trick, a - * single segment needs differing stroke widths to read as a halo. + * Renders a line preview overlay. */ export class LinePreviewOverlay { #viewport: DefaultViewport; diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts index 5e817ed6..35e42d1f 100644 --- a/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts +++ b/packages/pixel-draw-renderer/src/rendering/overlays/SelectionOverlay.ts @@ -8,19 +8,7 @@ import type { } from "../../types.ts"; /** - * Renders the current select-mode selection (texture-space `rect`, - * converted to screen space via zoom/camera like every other overlay here) - * as a two-color dashed border ("marching ants"): both shapes share the - * same dash length, offset by half a cycle from each other, so the gaps in - * one are filled by the other's dashes instead of the background showing - * through. - * - * A plain rectangle selection renders as two `` elements (drawRect) β€” - * cheap, and exactly what earlier versions of this class always drew. A - * non-rectangular (shape) selection renders as two `` elements - * tracing the selection mask's true outline (drawMask), falling back to - * drawRect's cheaper rendering when the mask happens to be a full rectangle. - * Call the appropriate one again on every pan/zoom/drag update to reposition. + * Renders rectangular and masked selection outlines. */ export class SelectionOverlay { #viewport: DefaultViewport; @@ -92,7 +80,9 @@ export class SelectionOverlay { el.setAttribute("visibility", "hidden"); } - /** Plain-rectangle rendering: always the whole `rect`, no mask involved. */ + /** + * Renders a rectangular selection outline. + */ drawRect( rect: SelectionRect ): void { @@ -116,9 +106,7 @@ export class SelectionOverlay { } /** - * Mask-aware rendering: traces `mask`'s true outline (in `rect`'s - * bounding box) as an SVG path. Degenerates to drawRect's cheaper - * rendering when every cell in `mask` is selected. + * Renders a masked selection outline. */ drawMask( rect: SelectionRect, @@ -137,7 +125,11 @@ export class SelectionOverlay { zoom: this.#viewport.zoom.value, camera: this.#viewport.camera }; - const loops = SelectionOverlay.traceContour(rect.width, rect.height, mask); + const loops = SelectionOverlay.traceContour( + rect.width, + rect.height, + mask + ); const d = loops .map((loop) => SelectionOverlay.#loopToPath(loop, rect, screen)) .join(" "); @@ -171,26 +163,17 @@ export class SelectionOverlay { } /** - * Traces the boundary of a row-major `width`x`height` selection mask into - * one or more closed polygon loops, in mask-local grid coordinates (cell - * (0,0)'s corners are (0,0)-(1,1), etc.) β€” pure geometry, no DOM/viewport - * involved. - * - * Every selected cell contributes a directed unit edge for each side that - * borders a non-selected (or out-of-grid) neighbor, oriented so the - * selected area stays on the edge's right (a clockwise walk in this - * y-down grid). Because every vertex has at most one outgoing boundary - * edge for the simply-connected masks this package ever produces (see - * ShapeSelect β€” flood-filled, then hole-filled), these edges chain - * head-to-tail into closed loops with no extra bookkeeping. Consecutive - * collinear points are merged to keep the resulting path compact. + * Traces a selection mask into closed contour loops. */ static traceContour( width: number, height: number, mask: boolean[] ): Vec2[][] { - function isSelected(x: number, y: number): boolean { + function isSelected( + x: number, + y: number + ): boolean { return x >= 0 && x < width && y >= 0 && y < height && mask[(y * width) + x]; } @@ -224,7 +207,10 @@ export class SelectionOverlay { while (edges.size > 0) { const [startKey] = edges.keys(); const [startX, startY] = startKey.split(",").map(Number); - let current: Vec2 = { x: startX, y: startY }; + let current: Vec2 = { + x: startX, + y: startY + }; const points: Vec2[] = [current]; for (;;) { @@ -243,7 +229,9 @@ export class SelectionOverlay { return loops; } - /** Drops points that sit strictly between two collinear neighbors. */ + /** + * Removes collinear contour points. + */ static #simplifyLoop( points: Vec2[] ): Vec2[] { diff --git a/packages/pixel-draw-renderer/src/sync/SyncController.ts b/packages/pixel-draw-renderer/src/sync/SyncController.ts index 996a0062..77fc8a08 100644 --- a/packages/pixel-draw-renderer/src/sync/SyncController.ts +++ b/packages/pixel-draw-renderer/src/sync/SyncController.ts @@ -11,8 +11,8 @@ import type { HistoryController } from "../history/HistoryController.ts"; import type { HistoryEntryInput } from "../history/HistoryStack.ts"; import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; import type { Viewport } from "../rendering/Viewport.ts"; -import { Fill } from "../tools/Fill.ts"; import type { RGBA, Vec2 } from "../types.ts"; +import { Fill } from "../tools/Fill.ts"; export interface SyncControllerOptions { canvasBuffer: CanvasBuffer; @@ -20,15 +20,14 @@ export interface SyncControllerOptions { renderer: CanvasRenderer; history: HistoryController; onBufferUpdated?: PixelBufferHookListener; - /** Called after every local or remote mutation that touches pixels. */ + /** + * Called after a pixel mutation. + */ onDrawEnd?: () => void; } /** - * Applies pixel mutations to the shared CanvasBuffer and keeps history/hook - * emission echo-safe: while a remote command or snapshot is being applied, - * `recordHistory`/`emitHook` become no-ops so the mutation isn't re-recorded - * or re-broadcast as if it were a local edit. + * Synchronizes canvas, rendering, history, and mutation hooks. */ export class SyncController { #canvasBuffer: CanvasBuffer; @@ -51,7 +50,7 @@ export class SyncController { } /** - * Replaces the local buffer-mutation listener. + * Replaces the local buffer-mutation listener. */ set onBufferUpdated( fn: PixelBufferHookListener | undefined @@ -59,7 +58,9 @@ export class SyncController { this.#onBufferUpdated = fn; } - /** Records a local edit when history is enabled, skipped while applying a remote command. */ + /** + * Records a local history entry. + */ recordHistory( entry: HistoryEntryInput ): void { @@ -70,7 +71,9 @@ export class SyncController { this.#history.push(entry); } - /** Forwards a local mutation to the buffer-updated listener, skipped while applying a remote command. */ + /** + * Emits a local buffer mutation. + */ emitHook( event: PixelBufferHookEvent ): void { @@ -108,7 +111,7 @@ export class SyncController { } /** - * Applies a remote mutation without emitting it again. + * Applies a remote mutation without emitting it. */ applyRemoteCommand( event: PixelBufferHookEvent @@ -153,7 +156,7 @@ export class SyncController { } /** - * Replaces the buffer from a remote snapshot without emitting a mutation. + * Replaces the buffer from a remote snapshot. */ loadSnapshot( size: Vec2, diff --git a/packages/pixel-draw-renderer/src/tools/Brush.ts b/packages/pixel-draw-renderer/src/tools/Brush.ts index 9d3027b9..5ad4a10d 100644 --- a/packages/pixel-draw-renderer/src/tools/Brush.ts +++ b/packages/pixel-draw-renderer/src/tools/Brush.ts @@ -10,7 +10,7 @@ import type { } from "../types.ts"; /** - * A single color+opacity slot (e.g. a brush's primary or secondary color). + * Stores a color and opacity. */ export class BrushColor { #color: Color; @@ -24,21 +24,21 @@ export class BrushColor { } /** - * Sets the color from a CSS color string or a colorjs.io `Color` instance. - * If `opacity` is omitted, the current opacity is preserved. + * Sets the color and optional opacity. */ set( color: ColorInput, opacity?: number ): void { - const alpha = opacity === undefined ? this.#color.alpha : clamp(opacity, 0, 1); + const alpha = opacity === undefined ? + this.#color.alpha : + clamp(opacity, 0, 1); this.#color = new Color(color); this.#color.alpha = alpha; } /** - * Returns the color as a string. Defaults to `rgba(r, g, b, a)`; pass - * `"hex"` for a 6-digit hex string (opacity is not represented in hex output). + * Returns the color in the requested format. */ asString( format: "rgba" | "hex" = "rgba" @@ -71,32 +71,27 @@ export type BrushColorSlot = "primary" | "secondary"; export interface BrushOptions { /** - * Base primary color of the brush. Can be any valid CSS color string or a - * colorjs.io `Color` instance. Opacity can be controlled separately with - * `primary.opacity`. + * Primary brush color. * @default "#000000" */ color?: ColorInput; /** - * Base secondary color of the brush, applied by a right-click stroke. + * Secondary brush color. * @default "#FFFFFF" */ secondaryColor?: ColorInput; /** - * Size of the brush in pixels. Must be a positive integer. - * The actual affected area will be a square of `size x size` pixels centered around the target pixel. + * Brush size in pixels. * @default 32 */ size?: number; /** - * Maximum allowed size for the brush. This is used to constrain the `size` property. - * Must be a positive integer. If `size` is set higher than `maxSize`, it will be clamped to `maxSize`. + * Maximum brush size. * @default 32 */ maxSize?: number; /** - * Highlight colors for the brush preview. - * These colors are used to render the brush outline and fill when hovering over the canvas. + * Brush highlight colors. * @default { colorInline: "#FFF", colorOutline: "#000" } */ highlight?: { @@ -106,9 +101,7 @@ export interface BrushOptions { } /** - * Manages brush properties such as primary/secondary color, size, and - * highlight for a pixel drawing application, and computes the affected - * pixels for a stroke centered at a given position. + * Stores brush colors, size, and highlight settings. */ export class Brush { readonly primary: BrushColor; @@ -142,13 +135,16 @@ export class Brush { } /** - * Exchanges the primary and secondary colors (including their opacity). + * Exchanges primary and secondary colors. */ swapColors(): void { const primaryHex = this.primary.asString("hex"); const primaryOpacity = this.primary.opacity; - this.primary.set(this.secondary.asString("hex"), this.secondary.opacity); + this.primary.set( + this.secondary.asString("hex"), + this.secondary.opacity + ); this.secondary.set(primaryHex, primaryOpacity); } diff --git a/packages/pixel-draw-renderer/src/tools/BrushController.ts b/packages/pixel-draw-renderer/src/tools/BrushController.ts index aada1e05..a0a80751 100644 --- a/packages/pixel-draw-renderer/src/tools/BrushController.ts +++ b/packages/pixel-draw-renderer/src/tools/BrushController.ts @@ -10,25 +10,27 @@ export interface BrushControllerOptions { canvasBuffer: CanvasBuffer; renderer: CanvasRenderer; /** - * Called once per completed stroke with the deduplicated pixels touched, - * their stamped color, and each pixel's color immediately before this - * stroke touched it (parallel to `pixels`, for undo history). Not called - * for a stroke that never touched any in-bounds pixel. + * Commits a completed stroke. */ - onCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; + onCommit: ( + pixels: Vec2[], + color: RGBA, + beforeColors: RGBA[] + ) => void; } /** - * Glues the Brush model (pixel-stamping geometry) to the pixel buffer and - * renderer, mirroring LineController/SelectController: owns the in-progress - * stroke state (dirty pixels + color) and commits it as a single atomic edit - * on endStroke. + * Applies brush strokes to the canvas. */ export class BrushController { #brush: Brush; #canvasBuffer: CanvasBuffer; #renderer: CanvasRenderer; - #onCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; + #onCommit: ( + pixels: Vec2[], + color: RGBA, + beforeColors: RGBA[] + ) => void; #strokeDirty = new Map(); #strokeBefore = new Map(); @@ -45,14 +47,15 @@ export class BrushController { this.#onCommit = options.onCommit; } - /** Which color slot is being dragged, or `false` when no stroke is active. */ + /** + * Active brush color slot. + */ get isActive(): BrushColorSlot | false { return this.#activeSlot ?? false; } /** - * Whether the next primary-down in paint mode should pick a color instead - * of starting a stroke. Cleared automatically by a successful `pick()`. + * Whether the next primary action picks a color. */ get pickArmed(): boolean { return this.#pickArmed; @@ -65,9 +68,7 @@ export class BrushController { } /** - * Samples the pixel at the given texture position, applies it to the - * brush's primary color, and disarms picking. Returns the sampled color, - * or `null` (leaving state untouched) if the position is outside the texture. + * Samples a pixel into the primary brush color. */ pick( tx: number, @@ -95,9 +96,7 @@ export class BrushController { } /** - * Starts a stroke stamping with the given color slot. Mutually exclusive - * with the other slot: callers should check `isActive` before starting a - * stroke on the other button. + * Starts a brush stroke. */ startStroke( tx: number, @@ -116,8 +115,7 @@ export class BrushController { } /** - * Ends the current stroke: copies the working buffer to master and emits - * the accumulated dirty pixels as a single "stroke" commit. + * Commits the current brush stroke. */ endStroke(): void { this.#canvasBuffer.copyToMaster(); @@ -129,12 +127,10 @@ export class BrushController { tx: number, ty: number ): void { - const rgba = toRGBA(this.#brush[this.#activeSlot ?? "primary"].asString()); + const rgba = toRGBA( + this.#brush[this.#activeSlot ?? "primary"].asString() + ); - // Materialized once (unlike the rest of this class, which prefers - // re-calling the fresh generator over allocating) because the before- - // color of each newly touched pixel must be sampled before drawPixels - // overwrites it, then the same list is reused for the draw call. const affected = [...this.#brush.affectedPixels(tx, ty)]; for (const pixel of affected) { const key = `${pixel.x},${pixel.y}`; diff --git a/packages/pixel-draw-renderer/src/tools/Fill.ts b/packages/pixel-draw-renderer/src/tools/Fill.ts index 9078d03c..73fa8ba9 100644 --- a/packages/pixel-draw-renderer/src/tools/Fill.ts +++ b/packages/pixel-draw-renderer/src/tools/Fill.ts @@ -6,17 +6,11 @@ import type { import type { DefaultPixelBuffer } from "../buffer/types.ts"; /** - * Computes the connected region of same-colored pixels reachable from a seed - * point (paint-bucket flood fill). Pure algorithm with no DOM coupling β€” - * callers own reading the seed color, committing the result to a buffer, and - * any network/hook emission (see PixelArtCanvas). + * Computes pixel-fill regions. */ export class Fill { /** - * 4-directional (orthogonal) flood fill starting at `seed`, matching every - * pixel whose RGBA exactly equals the seed pixel's current color. Returns - * an empty array when the seed is out of bounds or already matches - * `fillColor` (nothing would visibly change). + * Returns the connected region requiring a color change. */ static floodFill( buffer: DefaultPixelBuffer, @@ -24,12 +18,23 @@ export class Fill { fillColor: RGBA ): Vec2[] { const size = buffer.size(); - if (seed.x < 0 || seed.x >= size.x || seed.y < 0 || seed.y >= size.y) { + if ( + seed.x < 0 || seed.x >= size.x || + seed.y < 0 || seed.y >= size.y + ) { return []; } - const [tr, tg, tb, ta] = buffer.samplePixel(seed.x, seed.y); - if (tr === fillColor.r && tg === fillColor.g && tb === fillColor.b && ta === fillColor.a) { + const [tr, tg, tb, ta] = buffer.samplePixel( + seed.x, + seed.y + ); + if ( + tr === fillColor.r && + tg === fillColor.g && + tb === fillColor.b && + ta === fillColor.a + ) { return []; } @@ -37,22 +42,24 @@ export class Fill { } /** - * The pure connectivity primitive behind floodFill: every position - * reachable from `seed` through 4-directional neighbors whose RGBA - * exactly matches the seed pixel's own color (no target-color bail-out). - * Reused by Select's magic-wand shape selection, which needs the region - * itself rather than a repaint. + * Returns the four-connected region at a seed. */ static connectedRegion( buffer: DefaultPixelBuffer, seed: Vec2 ): Vec2[] { const size = buffer.size(); - if (seed.x < 0 || seed.x >= size.x || seed.y < 0 || seed.y >= size.y) { + if ( + seed.x < 0 || seed.x >= size.x || + seed.y < 0 || seed.y >= size.y + ) { return []; } - const [tr, tg, tb, ta] = buffer.samplePixel(seed.x, seed.y); + const [tr, tg, tb, ta] = buffer.samplePixel( + seed.x, + seed.y + ); const visited = new Set(); const positions: Vec2[] = []; @@ -64,12 +71,23 @@ export class Fill { if (visited.has(key)) { continue; } - if (point.x < 0 || point.x >= size.x || point.y < 0 || point.y >= size.y) { + if ( + point.x < 0 || point.x >= size.x || + point.y < 0 || point.y >= size.y + ) { continue; } - const [r, g, b, a] = buffer.samplePixel(point.x, point.y); - if (r !== tr || g !== tg || b !== tb || a !== ta) { + const [r, g, b, a] = buffer.samplePixel( + point.x, + point.y + ); + if ( + r !== tr || + g !== tg || + b !== tb || + a !== ta + ) { continue; } @@ -86,10 +104,7 @@ export class Fill { } /** - * Scans the whole buffer for every pixel whose RGBA exactly equals `color`, - * regardless of connectivity β€” the "global fill" counterpart to - * `floodFill`'s contiguous region. Returns an empty array when nothing - * matches. + * Returns all pixels matching a color. */ static matchAll( buffer: DefaultPixelBuffer, @@ -101,7 +116,12 @@ export class Fill { for (let y = 0; y < size.y; y++) { for (let x = 0; x < size.x; x++) { const [r, g, b, a] = buffer.samplePixel(x, y); - if (r === color.r && g === color.g && b === color.b && a === color.a) { + if ( + r === color.r && + g === color.g && + b === color.b && + a === color.a + ) { positions.push({ x, y }); } } diff --git a/packages/pixel-draw-renderer/src/tools/FillController.ts b/packages/pixel-draw-renderer/src/tools/FillController.ts index 8c428bcd..58069332 100644 --- a/packages/pixel-draw-renderer/src/tools/FillController.ts +++ b/packages/pixel-draw-renderer/src/tools/FillController.ts @@ -1,9 +1,17 @@ // Import Internal Dependencies -import type { Brush, BrushColorSlot } from "./Brush.ts"; import { Fill } from "./Fill.ts"; -import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; import { toRGBA } from "../utils/colors.ts"; -import type { RGBA, Vec2 } from "../types.ts"; +import type { + Brush, + BrushColorSlot +} from "./Brush.ts"; +import type { + CanvasBuffer +} from "../buffer/CanvasBuffer.ts"; +import type { + RGBA, + Vec2 +} from "../types.ts"; export interface FillGlobalCommit { positions: Vec2[]; @@ -16,23 +24,17 @@ export interface FillControllerOptions { brush: Brush; canvasBuffer: CanvasBuffer; /** - * Commits a contiguous flood-fill region as an ordinary "stroke", painted - * with the given color slot. + * Commits a contiguous fill. */ onCommit: (pixels: Vec2[], slot: BrushColorSlot) => void; /** - * Commits a global fill (every pixel matching `fromColor` anywhere on the - * canvas, recolored to `toColor`). `positions`/`beforeColors` are provided - * for the caller's own local history bookkeeping β€” see PixelArtCanvas's - * "global-fill" hook, which is deliberately compact and carries neither. + * Commits a global fill. */ onGlobalCommit: (commit: FillGlobalCommit) => void; } /** - * Glues the Fill algorithms (pure, DOM-free) to the brush color and the - * host's commit path, and owns the runtime-only contiguous/global toggle β€” - * mirroring BrushController/LineController/SelectController. + * Runs contiguous and global fills. */ export class FillController { #brush: Brush; diff --git a/packages/pixel-draw-renderer/src/tools/Line.ts b/packages/pixel-draw-renderer/src/tools/Line.ts index 784847ec..7427138b 100644 --- a/packages/pixel-draw-renderer/src/tools/Line.ts +++ b/packages/pixel-draw-renderer/src/tools/Line.ts @@ -6,10 +6,7 @@ import type { export type LineCommitTrigger = "mousedown" | "mouseup"; /** - * Owns the Shift-to-line armed-state machine and the Bresenham rasterization - * of the segment between its start and current end position. - * Has no brush knowledge β€” callers expand the raw points into brush-stamped - * pixels themselves (see PixelArtCanvas). + * Manages line state and rasterization. */ export class Line { #armed = false; @@ -52,7 +49,7 @@ export class Line { } /** - * Rasterizes the current start->end segment without changing armed state. + * Returns rasterized points without disarming. */ get previewPoints(): Vec2[] | null { if ( @@ -67,8 +64,7 @@ export class Line { } /** - * Rasterizes the current segment and disarms the tool. - * Returns null when the tool wasn't armed. + * Returns rasterized points and disarms. */ commit(): Vec2[] | null { const points = this.previewPoints; @@ -78,8 +74,7 @@ export class Line { } /** - * Bresenham's line algorithm. A zero-length segment (start === end) - * naturally rasterizes to a single point. + * Rasterizes a line with Bresenham's algorithm. */ static rasterize( start: Vec2, diff --git a/packages/pixel-draw-renderer/src/tools/LineController.ts b/packages/pixel-draw-renderer/src/tools/LineController.ts index 9b0d0af0..3bdc6fda 100644 --- a/packages/pixel-draw-renderer/src/tools/LineController.ts +++ b/packages/pixel-draw-renderer/src/tools/LineController.ts @@ -10,20 +10,21 @@ import type { Vec2 } from "../types.ts"; export interface LineControllerOptions { brush: Brush; linePreview: LinePreviewOverlay; - /** Commits the stamped pixels of a completed segment as a single atomic edit. */ - onCommit: (pixels: Vec2[]) => void; + onCommit: ( + pixels: Vec2[] + ) => void; } /** - * Glues the Line state machine to the brush (pixel stamping), the SVG line - * preview overlay, and the host's commit path. Also owns the Shift-held flag - * and last known cursor position, both meaningless outside line-arming. + * Coordinates line state, preview, and commits. */ export class LineController { #line = new Line(); #brush: Brush; #linePreview: LinePreviewOverlay; - #onCommit: (pixels: Vec2[]) => void; + #onCommit: ( + pixels: Vec2[] + ) => void; #lastCursorPos: Vec2 | null = null; #isShiftHeld = false; @@ -51,8 +52,7 @@ export class LineController { } /** - * Tracks the latest cursor position for arm() to start from, and keeps an - * already-armed preview following the cursor. + * Updates the cursor position and line preview. */ updateCursor( pos: Vec2 | null @@ -71,15 +71,15 @@ export class LineController { return; } - this.#line.arm(this.#lastCursorPos, commitTrigger); + this.#line.arm( + this.#lastCursorPos, + commitTrigger + ); this.refreshPreview(); } /** - * Commits the armed segment. If Shift is still held afterwards, immediately - * re-arms from the just-committed endpoint (commitTrigger "mousedown") so - * the next click continues a connected polyline instead of requiring the - * user to release and re-press Shift to resume line drawing. + * Commits the armed line segment. */ commit(): void { const points = this.#line.commit(); @@ -88,10 +88,15 @@ export class LineController { return; } - this.#onCommit(this.#stampLinePixels(points)); + this.#onCommit( + this.#stampLinePixels(points) + ); if (this.#isShiftHeld) { - this.#line.arm(points.at(-1) ?? points[0], "mousedown"); + this.#line.arm( + points.at(-1) ?? points[0], + "mousedown" + ); this.refreshPreview(); } } @@ -120,15 +125,19 @@ export class LineController { } /** - * Expands raw rasterized line points into brush-stamped, deduplicated - * texture pixels (Line has no brush awareness). + * Expands line points into unique brush pixels. */ #stampLinePixels( points: Vec2[] ): Vec2[] { const stamped = new Map(); for (const point of points) { - for (const pixel of this.#brush.affectedPixels(point.x, point.y)) { + const affectedPixels = this.#brush.affectedPixels( + point.x, + point.y + ); + + for (const pixel of affectedPixels) { stamped.set(`${pixel.x},${pixel.y}`, pixel); } } diff --git a/packages/pixel-draw-renderer/src/tools/Select.ts b/packages/pixel-draw-renderer/src/tools/Select.ts index 62f85ed4..ea0222c3 100644 --- a/packages/pixel-draw-renderer/src/tools/Select.ts +++ b/packages/pixel-draw-renderer/src/tools/Select.ts @@ -27,16 +27,7 @@ export interface PasteResult { } /** - * Rectangle- and shape-selection state machine - * (idle -> creating -> selected -> moving -> selected) + clipboard. - * - * Every selection carries a rect-relative `mask` (row-major, same length as - * `snapshot`) alongside its bounding rect: true where a cell is actually - * part of the selection. A plain rectangle drag selects every cell (an - * all-true mask); a magic-wand shape selection (see ShapeSelect) can leave - * some cells outside the mask, and every operation below (hitTest, move, - * delete, copy/paste, rotate, flip) respects it instead of assuming the - * whole bounding box is selected. + * Manages rectangular and masked selection state. */ export class Select { #state: SelectState = "idle"; @@ -48,39 +39,20 @@ export class Select { #moveBaseRect: SelectionRect | null = null; #liveRect: SelectionRect | null = null; #clipboard: ClipboardSnapshot | null = null; - /** - * True right after paste(): the selection still sits on the original it - * was copied from, so the next finishMove() must not erase it. Reset to - * false once that move happens β€” later moves of the same piece erase normally. - */ #skipNextErase = false; get state(): SelectState { return this.#state; } - /** - * The rect to render: the live drag preview while creating/moving, the - * static rect otherwise. Null when idle. - */ get rect(): SelectionRect | null { return this.#state === "moving" ? this.#liveRect : this.#rect; } - /** - * Pixel data inside the selection's bounding box (row-major). Stays valid - * across a move β€” position changes, content doesn't. Cells outside `mask` - * hold whatever was last captured there but are never painted/erased. - */ get snapshot(): RGBA[] | null { return this.#snapshot; } - /** - * Rect-relative, row-major selection mask (same length/indexing as - * `snapshot`): true where a cell is actually selected. Always all-true - * for a rectangle-drag selection; only null while idle/creating. - */ get mask(): boolean[] | null { return this.#mask; } @@ -89,19 +61,10 @@ export class Select { return this.#clipboard !== null; } - /** - * Whether the next finishMove() will skip erasing its source (see - * #skipNextErase) β€” lets a caller decide whether to preview the source as - * vacated while dragging. - */ get willSkipErase(): boolean { return this.#skipNextErase; } - /** - * Begins dragging a new rectangle selection from `pos`, discarding prior - * state. Callers should already have ruled out a move via hitTest. - */ startCreate( position: Vec2 ): SelectionRect { @@ -131,11 +94,6 @@ export class Select { return this.#rect; } - /** - * Finalizes a rectangle-drag creation with a snapshot the caller captured - * (via captureSnapshot). The mask is implicitly all-true β€” a drag always - * selects its whole bounding box. No-op unless "creating". - */ finishCreate( snapshot: RGBA[] ): void { @@ -144,34 +102,26 @@ export class Select { } this.#snapshot = snapshot; - this.#mask = new Array(snapshot.length).fill(true); + this.#mask = new Array( + snapshot.length + ).fill(true); this.#state = "selected"; this.#createStart = null; } - /** - * Establishes a brand-new selection directly (no drag), e.g. a - * magic-wand shape click: enters "selected" with the given rect/snapshot/ - * mask from any prior state, discarding whatever was there before. - */ selectRegion( rect: SelectionRect, snapshot: RGBA[], mask: boolean[] ): void { - this.#enterSelected(rect, snapshot, mask); + this.#enterSelected( + rect, + snapshot, + mask + ); this.#skipNextErase = false; } - /** - * Whether `pos` falls inside the current selection's bounding rect (only - * meaningful while "selected") β€” used to decide if a mousedown starts a - * move or a new selection. Deliberately bounding-rect-only, not - * mask-aware: a shape selection should be grabbable from anywhere in its - * bounding box, exactly like a rectangle selection β€” requiring a click on - * a masked-true cell specifically made moving an oddly-shaped selection - * feel unreliable in practice. - */ hitTest( pos: Vec2 ): boolean { @@ -228,12 +178,6 @@ export class Select { return this.#liveRect; } - /** - * Ends the move (selection becomes "selected" again). Returns source/dest - * rects, or null if not moving or nothing was displaced. `skipErase` true - * means the caller must paint dest but not erase source (see #skipNextErase). - * The mask itself is unaffected by a move (same shape, new position). - */ finishMove(): MoveResult | null { if ( this.#state !== "moving" || @@ -251,7 +195,10 @@ export class Select { this.#moveBaseRect = null; this.#liveRect = null; - if (source.x === dest.x && source.y === dest.y) { + if ( + source.x === dest.x && + source.y === dest.y + ) { return null; } @@ -265,22 +212,18 @@ export class Select { }; } - /** - * Forcibly resyncs rect/snapshot/mask to `rect`/`snapshot`/`mask`, - * becoming "selected" regardless of prior state. Used to re-align the - * selection box and cached content with the buffer after a history - * undo/redo replay, which mutates the buffer directly without going - * through this class's own move/rotate/flip methods. - */ restoreRect( rect: SelectionRect, snapshot: RGBA[], mask: boolean[] ): void { - this.#enterSelected(rect, snapshot, mask); + this.#enterSelected( + rect, + snapshot, + mask + ); } - /** Discards the current selection entirely. Does not clear the clipboard. */ clear(): void { this.#state = "idle"; this.#rect = null; @@ -293,11 +236,6 @@ export class Select { this.#skipNextErase = false; } - /** - * Marks the selection's masked cells as erased (uniform eraseColor) in - * this tool's own bookkeeping β€” caller still has to write eraseColor to - * the actual pixel buffer. Cells outside the mask are left untouched. - */ markErased( eraseColor: RGBA ): void { @@ -307,10 +245,11 @@ export class Select { const mask = this.#mask; const snapshot = this.#snapshot; - this.#snapshot = mask.map((selected, i) => (selected ? eraseColor : snapshot[i])); + this.#snapshot = mask.map( + (selected, i) => (selected ? eraseColor : snapshot[i]) + ); } - /** Snapshots the current selection into the clipboard. No-op with nothing selected. */ copy(): void { if (!this.#rect || !this.#snapshot || !this.#mask) { return; @@ -329,10 +268,6 @@ export class Select { }; } - /** - * Activates the clipboard as the new selection at its original position. - * Returns the rect/pixels/mask to paint, or null if the clipboard is empty. - */ paste(): PasteResult | null { if (!this.#clipboard) { return null; @@ -357,13 +292,6 @@ export class Select { }; } - /** - * Rotates the active selection 90 degrees clockwise, pivoting on its - * center (width/height swap, center point held fixed). Rotates the mask - * along with the pixel content. No-op (null) unless "selected". Returns - * the pre/post rects so the caller can repaint the old footprint away and - * the new one in. - */ rotate(): { oldRect: SelectionRect; newRect: SelectionRect; } | null { if ( this.#state !== "selected" || @@ -376,17 +304,21 @@ export class Select { const oldRect = this.#rect; const newRect = Select.rotateRectCW(oldRect); - this.#snapshot = Select.rotateSnapshotCW(this.#snapshot, oldRect.width, oldRect.height); - this.#mask = Select.rotateMaskCW(this.#mask, oldRect.width, oldRect.height); + this.#snapshot = Select.rotateSnapshotCW( + this.#snapshot, + oldRect.width, + oldRect.height + ); + this.#mask = Select.rotateMaskCW( + this.#mask, + oldRect.width, + oldRect.height + ); this.#rect = newRect; return { oldRect, newRect }; } - /** - * Mirrors the active selection's content (and mask) left-right in place - * (rect is unchanged). No-op (null) unless "selected". - */ flipHorizontal(): SelectionRect | null { if ( this.#state !== "selected" || @@ -397,16 +329,20 @@ export class Select { return null; } - this.#snapshot = Select.flipSnapshotHorizontal(this.#snapshot, this.#rect.width, this.#rect.height); - this.#mask = Select.flipMaskHorizontal(this.#mask, this.#rect.width, this.#rect.height); + this.#snapshot = Select.flipSnapshotHorizontal( + this.#snapshot, + this.#rect.width, + this.#rect.height + ); + this.#mask = Select.flipMaskHorizontal( + this.#mask, + this.#rect.width, + this.#rect.height + ); return this.#rect; } - /** - * Mirrors the active selection's content (and mask) top-bottom in place - * (rect is unchanged). No-op (null) unless "selected". - */ flipVertical(): SelectionRect | null { if ( this.#state !== "selected" || @@ -417,18 +353,20 @@ export class Select { return null; } - this.#snapshot = Select.flipSnapshotVertical(this.#snapshot, this.#rect.width, this.#rect.height); - this.#mask = Select.flipMaskVertical(this.#mask, this.#rect.width, this.#rect.height); + this.#snapshot = Select.flipSnapshotVertical( + this.#snapshot, + this.#rect.width, + this.#rect.height + ); + this.#mask = Select.flipMaskVertical( + this.#mask, + this.#rect.width, + this.#rect.height + ); return this.#rect; } - /** - * Shared tail for restoreRect/selectRegion: both enter "selected" from - * any prior state with a fully-specified rect/snapshot/mask, clearing any - * in-progress create/move bookkeeping. Only #skipNextErase differs - * between the two callers, so it's left to them. - */ #enterSelected( rect: SelectionRect, snapshot: RGBA[], @@ -444,10 +382,6 @@ export class Select { this.#liveRect = null; } - /** - * Normalizes two drag corners into a positive-size rect, inclusive of both - * corner pixels (so a==b yields a 1x1 rect). - */ static normalizeRect( a: Vec2, b: Vec2 @@ -460,11 +394,6 @@ export class Select { }; } - /** - * Reads a rect's pixels from `buffer` in row-major order. Out-of-bounds - * positions sample as fully transparent, mirroring clipped writes - * elsewhere in this package. - */ static captureSnapshot( buffer: DefaultPixelBuffer, rect: SelectionRect @@ -493,11 +422,6 @@ export class Select { return pixels; } - /** - * Rotates `rect` 90 degrees clockwise around its center: width/height - * swap, center point held fixed (rounded, since positions are integer - * pixels β€” unavoidable drift when width/height parities differ). - */ static rotateRectCW( rect: SelectionRect ): SelectionRect { @@ -514,67 +438,76 @@ export class Select { }; } - /** - * Rotates a row-major `width`x`height` pixel array 90 degrees clockwise, - * returning a new `height`x`width` array. - */ static rotateSnapshotCW( snapshot: RGBA[], width: number, height: number ): RGBA[] { - return Select.#rotateGridCW(snapshot, width, height); + return Select.#rotateGridCW( + snapshot, + width, + height + ); } - /** Same rotation as rotateSnapshotCW, applied to a selection mask. */ static rotateMaskCW( mask: boolean[], width: number, height: number ): boolean[] { - return Select.#rotateGridCW(mask, width, height); + return Select.#rotateGridCW( + mask, + width, + height + ); } - /** - * Mirrors a row-major `width`x`height` pixel array left-right, same - * dimensions. - */ static flipSnapshotHorizontal( snapshot: RGBA[], width: number, height: number ): RGBA[] { - return Select.#flipGridHorizontal(snapshot, width, height); + return Select.#flipGridHorizontal( + snapshot, + width, + height + ); } - /** Same mirroring as flipSnapshotHorizontal, applied to a selection mask. */ static flipMaskHorizontal( mask: boolean[], width: number, height: number ): boolean[] { - return Select.#flipGridHorizontal(mask, width, height); + return Select.#flipGridHorizontal( + mask, + width, + height + ); } - /** - * Mirrors a row-major `width`x`height` pixel array top-bottom, same - * dimensions. - */ static flipSnapshotVertical( snapshot: RGBA[], width: number, height: number ): RGBA[] { - return Select.#flipGridVertical(snapshot, width, height); + return Select.#flipGridVertical( + snapshot, + width, + height + ); } - /** Same mirroring as flipSnapshotVertical, applied to a selection mask. */ static flipMaskVertical( mask: boolean[], width: number, height: number ): boolean[] { - return Select.#flipGridVertical(mask, width, height); + return Select.#flipGridVertical( + mask, + width, + height + ); } static #rotateGridCW( diff --git a/packages/pixel-draw-renderer/src/tools/SelectController.ts b/packages/pixel-draw-renderer/src/tools/SelectController.ts index 9f8c6679..57b1ae11 100644 --- a/packages/pixel-draw-renderer/src/tools/SelectController.ts +++ b/packages/pixel-draw-renderer/src/tools/SelectController.ts @@ -2,8 +2,12 @@ import { Select } from "./Select.ts"; import { ShapeSelect } from "./ShapeSelect.ts"; import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; -import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; -import type { SelectionOverlay } from "../rendering/overlays/SelectionOverlay.ts"; +import type { + CanvasRenderer +} from "../rendering/CanvasRenderer.ts"; +import type { + SelectionOverlay +} from "../rendering/overlays/SelectionOverlay.ts"; import type { RGBA, SelectionRect, @@ -26,16 +30,13 @@ export interface SelectControllerOptions { selectionOverlay: SelectionOverlay; eraseColor: RGBA; /** - * Called after a selection edit (delete/move/paste/rotate/flip) is - * committed to the buffer, reporting exactly what changed so the caller - * can record it for undo/redo. + * Commits a selection edit. */ onCommit: (entry: SelectEditEntry) => void; } /** - * Glues the Select state machine to the pixel buffer, renderer (floating - * drag overlay + frame redraws) and the SVG selection-rect overlay. + * Coordinates selection state, rendering, and commits. */ export class SelectController { #select = new Select(); @@ -61,12 +62,7 @@ export class SelectController { } /** - * Whether a mousedown on empty space starts a magic-wand shape selection - * (flood-filled connected region + enclosed holes, see ShapeSelect) - * instead of dragging out a rectangle. Mirrors FillController's own - * runtime-only toggle. Changing it clears any active selection, since a - * selection's meaning (rect-only vs. masked shape) shouldn't be - * reinterpreted mid-flight. + * Whether empty-space clicks create shape selections. */ get shape(): boolean { return this.#shapeMode; @@ -84,15 +80,15 @@ export class SelectController { } /** - * Left mousedown in "select" mode: grabs the existing selection to move it - * when `pos` falls inside its bounding rect, otherwise discards any prior - * selection and either starts dragging out a new rectangle or, in shape - * mode, performs a magic-wand click-select. + * Starts creating or moving a selection. */ handleStart( pos: Vec2 ): void { - if (this.#select.state === "selected" && this.#select.hitTest(pos)) { + if ( + this.#select.state === "selected" && + this.#select.hitTest(pos) + ) { this.#startMoveAt(pos); return; @@ -137,8 +133,15 @@ export class SelectController { return; } - const snapshot = Select.captureSnapshot(this.#canvasBuffer, shape.rect); - this.#select.selectRegion(shape.rect, snapshot, shape.mask); + const snapshot = Select.captureSnapshot( + this.#canvasBuffer, + shape.rect + ); + this.#select.selectRegion( + shape.rect, + snapshot, + shape.mask + ); this.#selectionOverlay.drawMask(shape.rect, shape.mask); } @@ -174,7 +177,10 @@ export class SelectController { this.#selectionOverlay.clear(); } else { - const snapshot = Select.captureSnapshot(this.#canvasBuffer, rect); + const snapshot = Select.captureSnapshot( + this.#canvasBuffer, + rect + ); this.#select.finishCreate(snapshot); } } @@ -202,15 +208,16 @@ export class SelectController { const rect = this.#select.rect; const currentMask = this.#select.mask; if (rect && currentMask) { - this.#selectionOverlay.drawMask(rect, currentMask); + this.#selectionOverlay.drawMask( + rect, + currentMask + ); } } } /** - * Ctrl/Cmd+C: snapshots the active selection into Select's clipboard. - * No-op (returns false, letting the browser's default copy proceed) - * unless a selection is currently active. + * Copies the active selection. */ handleCopy(): boolean { if (this.#select.state !== "selected") { @@ -223,9 +230,7 @@ export class SelectController { } /** - * Ctrl/Cmd+V: stamps the clipboard snapshot back onto the buffer at the - * exact position it was copied from, and makes it the new active - * selection so the next drag relocates the duplicate. + * Pastes the clipboard as the active selection. */ handlePaste(): boolean { const result = this.#select.paste(); @@ -241,15 +246,16 @@ export class SelectController { newContent: result.pixels, skipErase: true }); - this.#selectionOverlay.drawMask(result.rect, result.mask); + this.#selectionOverlay.drawMask( + result.rect, + result.mask + ); return true; } /** - * Delete key: fills the active selection's masked cells with the - * configured erase color. The selection stays active, now over blanked - * pixels. + * Erases the active selection. */ handleDelete(): boolean { if (this.#select.state !== "selected") { @@ -262,7 +268,9 @@ export class SelectController { return false; } - const eraseColors: RGBA[] = new Array(rect.width * rect.height).fill(this.#eraseColor); + const eraseColors: RGBA[] = new Array( + rect.width * rect.height + ).fill(this.#eraseColor); this.#commitFootprintChange({ oldRect: rect, oldMask: mask, @@ -277,9 +285,7 @@ export class SelectController { } /** - * "R": rotates the active selection (content and mask) 90 degrees - * clockwise around its center. No-op (returns false) unless a selection - * is currently active. + * Rotates the active selection clockwise. */ handleRotate(): boolean { if (this.#select.state !== "selected") { @@ -302,25 +308,22 @@ export class SelectController { newContent: snapshot, skipErase: false }); - this.#selectionOverlay.drawMask(result.newRect, newMask); + this.#selectionOverlay.drawMask( + result.newRect, + newMask + ); return true; } - /** "H": mirrors the active selection's content (and mask) left-right in place. */ handleFlipHorizontal(): boolean { return this.#handleFlip((select) => select.flipHorizontal()); } - /** "V": mirrors the active selection's content (and mask) top-bottom in place. */ handleFlipVertical(): boolean { return this.#handleFlip((select) => select.flipVertical()); } - /** - * Shared guard/commit tail for handleFlipHorizontal/handleFlipVertical β€” - * flipping never moves or resizes the rect, only its content and mask. - */ #handleFlip( flip: (select: Select) => SelectionRect | null ): boolean { @@ -358,21 +361,12 @@ export class SelectController { refreshOverlay(): void { const rect = this.#select.rect; const mask = this.#select.mask; + if (rect && mask) { this.#selectionOverlay.drawMask(rect, mask); } } - /** - * Shared commit step for move/delete/paste/rotate/flip: vacates - * `oldRect`'s masked cells (unless `skipErase`), paints `newContent`'s - * masked cells into `newRect`, and reports the union of both footprints' - * masked before/after colors so the caller can record a single undo/redo - * entry. Cells outside a mask are left completely untouched β€” not even - * blanked β€” which is what makes a non-rectangular shape selection behave - * like one. Out-of-bounds positions are silently clipped by CanvasBuffer, - * same as every other paint path here. - */ #commitFootprintChange( change: { oldRect: SelectionRect; @@ -383,14 +377,32 @@ export class SelectController { skipErase: boolean; } ): void { - const { oldRect, oldMask, newRect, newMask, newContent, skipErase } = change; - const positions = unionMaskedPositions({ rect: oldRect, mask: oldMask }, { rect: newRect, mask: newMask }); + const { + oldRect, + oldMask, + newRect, + newMask, + newContent, + skipErase + } = change; + + const positions = unionMaskedPositions( + { rect: oldRect, mask: oldMask }, + { rect: newRect, mask: newMask } + ); const beforeColors = this.#canvasBuffer.samplePixels(positions); if (!skipErase) { - this.#canvasBuffer.drawPixels(maskedPositions({ rect: oldRect, mask: oldMask }), this.#eraseColor); + this.#canvasBuffer.drawPixels( + maskedPositions({ rect: oldRect, mask: oldMask }), + this.#eraseColor + ); } - this.#canvasBuffer.drawMaskedRegion(newRect, newContent, newMask); + this.#canvasBuffer.drawMaskedRegion( + newRect, + newContent, + newMask + ); this.#canvasBuffer.copyToMaster(); const afterColors = this.#canvasBuffer.samplePixels(positions); @@ -407,17 +419,16 @@ export class SelectController { } /** - * Resyncs the selection box, cached content and mask after a history - * undo/redo replay: `rect`/`mask` are the footprint/shape the selection - * should now cover (old on undo, new on redo), and the content is - * re-sampled from the buffer β€” now the source of truth β€” rather than - * trusting whatever Select had cached before the replay. + * Restores selection state after a history replay. */ syncSelectionAfterHistory( rect: SelectionRect, mask: boolean[] ): void { - const snapshot = Select.captureSnapshot(this.#canvasBuffer, rect); + const snapshot = Select.captureSnapshot( + this.#canvasBuffer, + rect + ); this.#select.restoreRect(rect, snapshot, mask); this.#selectionOverlay.drawMask(rect, mask); } @@ -428,10 +439,6 @@ interface MaskedFootprint { mask: boolean[]; } -/** - * Enumerates every texture-space position covered by a footprint's masked - * (selected) cells, row-major. - */ function* maskedPositions( footprint: MaskedFootprint ): IterableIterator { @@ -446,12 +453,6 @@ function* maskedPositions( } } -/** - * The deduplicated union of two masked footprints' positions β€” a Move's - * source/dest can overlap, and a Rotate's old/new footprint can differ in - * shape entirely (non-square rect), so this can't be expressed as a single - * bounding rect without over-capturing untouched cells. - */ function unionMaskedPositions( a: MaskedFootprint, b: MaskedFootprint diff --git a/packages/pixel-draw-renderer/src/tools/ShapeSelect.ts b/packages/pixel-draw-renderer/src/tools/ShapeSelect.ts index a9b07721..b907e24f 100644 --- a/packages/pixel-draw-renderer/src/tools/ShapeSelect.ts +++ b/packages/pixel-draw-renderer/src/tools/ShapeSelect.ts @@ -8,23 +8,18 @@ import type { DefaultPixelBuffer } from "../buffer/types.ts"; export interface ShapeSelection { rect: SelectionRect; - /** Row-major, rect-relative: true where the pixel is part of the shape. */ + /** + * Row-major shape mask. + */ mask: boolean[]; } /** - * Computes a magic-wand-style shape selection: the 4-connected region of - * pixels exactly matching the clicked pixel's color (see - * Fill.connectedRegion), plus every pixel fully enclosed by that region β€” - * unreachable from the region's own bounding-box border without crossing it - * β€” so clicking a hollow outline selects the whole shape, border and - * interior alike. Pure algorithm, no DOM coupling. + * Computes a connected shape selection and enclosed holes. */ export class ShapeSelect { /** - * Returns null when the seed has no matching neighbors and the resulting - * shape (after hole-filling) covers 1 or fewer pixels β€” mirrors the - * "not 1x1" discard of a zero-drag rectangle selection. + * Returns null for selections smaller than two pixels. */ static compute( buffer: DefaultPixelBuffer, @@ -36,9 +31,15 @@ export class ShapeSelect { } const rect = ShapeSelect.#boundingRect(region); - const mask = ShapeSelect.#fillEnclosedHoles(region, rect); + const mask = ShapeSelect.#fillEnclosedHoles( + region, + rect + ); - const selectedCount = mask.reduce((count, selected) => count + (selected ? 1 : 0), 0); + const selectedCount = mask.reduce( + (count, selected) => count + (selected ? 1 : 0), + 0 + ); if (selectedCount <= 1) { return null; } @@ -69,24 +70,21 @@ export class ShapeSelect { }; } - /** - * Builds the rect-relative mask: true for every region pixel, plus every - * non-region pixel inside `rect` that has no path to the rect's own - * border without crossing a region pixel (an enclosed hole). Flood-fills - * from the rect's border cells outward-in, treating region pixels as - * walls β€” whatever the border flood-fill never reaches is enclosed. - */ static #fillEnclosedHoles( region: Vec2[], rect: SelectionRect ): boolean[] { const { width, height } = rect; - const isRegion = new Array(width * height).fill(false); + const isRegion = new Array( + width * height + ).fill(false); for (const { x, y } of region) { isRegion[((y - rect.y) * width) + (x - rect.x)] = true; } - const exteriorReachable = new Array(width * height).fill(false); + const exteriorReachable = new Array( + width * height + ).fill(false); const stack: Vec2[] = []; function seed(x: number, y: number): void { const idx = (y * width) + x; @@ -122,6 +120,8 @@ export class ShapeSelect { } } - return isRegion.map((selected, i) => selected || !exteriorReachable[i]); + return isRegion.map( + (selected, i) => selected || !exteriorReachable[i] + ); } } diff --git a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts index b3ab670d..84955dca 100644 --- a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts +++ b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts @@ -27,20 +27,15 @@ export interface ToolControllersOptions { linePreview: LinePreviewOverlay; selectionOverlay: SelectionOverlay; eraseColor: RGBA; - /** Forwarded to BrushController: a completed freehand stroke. */ onStrokeCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; - /** Forwarded to LineController: always commits in the primary color. */ onCommitPixels: (pixels: Vec2[]) => void; - /** Forwarded to FillController: a contiguous fill, painted with the clicked button's color slot. */ onFillCommitPixels: (pixels: Vec2[], slot: BrushColorSlot) => 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. + * Groups drawing tool controllers. */ export class ToolControllers { readonly brush: BrushController; diff --git a/packages/pixel-draw-renderer/src/utils/colors.ts b/packages/pixel-draw-renderer/src/utils/colors.ts index 0da40761..545bdb2f 100644 --- a/packages/pixel-draw-renderer/src/utils/colors.ts +++ b/packages/pixel-draw-renderer/src/utils/colors.ts @@ -2,7 +2,9 @@ import Color from "colorjs.io"; // Import Internal Dependencies -import { clamp } from "./math.ts"; +import { + clamp +} from "./math.ts"; import type { ColorInput, RGBA diff --git a/packages/pixel-draw-renderer/src/utils/keybindings.ts b/packages/pixel-draw-renderer/src/utils/keybindings.ts index 41a8bc24..43518b5f 100644 --- a/packages/pixel-draw-renderer/src/utils/keybindings.ts +++ b/packages/pixel-draw-renderer/src/utils/keybindings.ts @@ -1,22 +1,19 @@ export type ModifierToken = "mod" | "shift" | "alt"; export type NamedKey = - | "Delete" | "Backspace" | "Enter" | "Escape" | "Tab" | "Space" + | "Delete" + | "Backspace" + | "Enter" + | "Escape" + | "Tab" + | "Space" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12"; export type KeyToken = NamedKey | (string & {}); /** - * A single key combo, e.g. "mod+z" or "mod+shift+z". "mod" matches either - * Ctrl or Cmd, so a default binding works the same on every platform. The - * key segment is matched against the character produced (`KeyboardEvent.key`, - * case-insensitive) rather than physical key position, so "z" means - * "whatever key produces the Z character on the user's layout" β€” the same - * convention every other app uses for Ctrl+Z/Ctrl+C-style shortcuts, and - * correct on AZERTY/QWERTZ without the DSL needing to know about layouts. - * Named, non-printable keys (Delete, ArrowUp, F1, ...) are listed for - * autocomplete, but any string is accepted. + * Describes a keyboard shortcut. */ export type Keybinding = | KeyToken @@ -25,8 +22,14 @@ export type Keybinding = | `${ModifierToken}+${ModifierToken}+${ModifierToken}+${KeyToken}`; export type KeybindingAction = - | "copy" | "paste" | "undo" | "redo" | "delete" - | "rotate" | "flipHorizontal" | "flipVertical"; + | "copy" + | "paste" + | "undo" + | "redo" + | "delete" + | "rotate" + | "flipHorizontal" + | "flipVertical"; export type Keybindings = Record; @@ -39,8 +42,14 @@ export interface ParsedKeybinding { // CONSTANTS const kKeybindingActions: KeybindingAction[] = [ - "copy", "paste", "undo", "redo", "delete", - "rotate", "flipHorizontal", "flipVertical" + "copy", + "paste", + "undo", + "redo", + "delete", + "rotate", + "flipHorizontal", + "flipVertical" ]; export const DEFAULT_KEYBINDINGS: Keybindings = { @@ -133,10 +142,7 @@ function eventMatchesKeybinding( } /** - * Merges a partial override onto a base keybinding set (constructor options - * and `patchKeybindings()` both go through this), validating every binding - * (throws InvalidKeybindingError) and rejecting the result if two different - * actions now resolve to the same combo (throws KeybindingConflictError). + * Merges and validates keybinding overrides. */ export function mergeKeybindings( base: Keybindings,