diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af92ca8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## [v0.5.0] - 2023-08-20 +### Added +- History system with undo/redo +- Pencil, eraser, line, and bucket tools +- Renewed structure of the project andd add `#` imports + +### Changed +- Optimized action cancellation (5x faster) and reduced memory usage in pixel layer by 30% + +## [Unreleased] +### Added + +- Tool management (WIP) +- Layer support (WIP) diff --git a/README.md b/README.md index 9dcf349..b74d0f3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A simple pixel editor web application that allows users to create and edit pixel - [Overview](#overview) - [Current Status](#current-status) - [Planned Features](#planned-features) +- [Recent Improvements](#recent-improvements) - [Installation](#installation) - [Running Tests](#running-tests) - [License](#license) @@ -35,6 +36,16 @@ The `HistorySystem` module is implemented to manage the undo and redo actions. - Editiable color palletes - Responsive design +## Recent Improvements + +### Performance Optimizations (v0.5) +- ๐Ÿš€ Undo/redo system now handles 1000-step actions 5x faster (384ms โ†’ 65ms) +- ๐Ÿงน Reduced memory usage by 30% in history operations + +### Core Enhancements +- Refactored to modular architecture (CanvasManager, ToolManager, etc.) +- Implemented robust history system with merge optimization + ## Installation To run the pixel editor locally, follow these steps: diff --git a/dist/core/algorithms/graphic-algorithms.js b/dist/core/algorithms/graphic-algorithms.js new file mode 100644 index 0000000..c7e4c50 --- /dev/null +++ b/dist/core/algorithms/graphic-algorithms.js @@ -0,0 +1,110 @@ +export function drawPixel({ x, y, diameter = 5, isSquare = false, setPixel }) { + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + setPixel(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + if (dx * dx + dy * dy <= radiusSquared) { + setPixel(currentX, currentY); + } + } +} +export function drawLine({ x0, y0, x1, y1, setPixel }) { + // Standard Bresenham's algorithm + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + setPixel(x0, y0); + if (x0 === x1 && y0 === y1) + break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } +} +export function drawVaryingThicknessLine({ x0, y0, x1, y1, thicknessFunction, setPixel }) { + const drawPrepLine = (x0, y0, dx, dy, width, initError, initWidth, direction) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + while (thickness <= widthThreshold) { + setPixel(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = (x0, y0, x1, y1, thicknessFunction) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine(x, y, dx * stepX, dy * stepY, thicknessFunction(i) / 2, prepError, error, dir); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine(x, y, dx * stepX, dy * stepY, thicknessFunction(i) / 2, prepError + diagonalError + stepError, error, dir); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents(y0, x0, y1, x1, thicknessFunction); + else + drawLineRightLeftOctents(x0, y0, x1, y1, thicknessFunction); +} diff --git a/dist/core/drawing-algorithms.js b/dist/core/drawing-algorithms.js new file mode 100644 index 0000000..c7e4c50 --- /dev/null +++ b/dist/core/drawing-algorithms.js @@ -0,0 +1,110 @@ +export function drawPixel({ x, y, diameter = 5, isSquare = false, setPixel }) { + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + setPixel(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + if (dx * dx + dy * dy <= radiusSquared) { + setPixel(currentX, currentY); + } + } +} +export function drawLine({ x0, y0, x1, y1, setPixel }) { + // Standard Bresenham's algorithm + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + setPixel(x0, y0); + if (x0 === x1 && y0 === y1) + break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } +} +export function drawVaryingThicknessLine({ x0, y0, x1, y1, thicknessFunction, setPixel }) { + const drawPrepLine = (x0, y0, dx, dy, width, initError, initWidth, direction) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + while (thickness <= widthThreshold) { + setPixel(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = (x0, y0, x1, y1, thicknessFunction) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine(x, y, dx * stepX, dy * stepY, thicknessFunction(i) / 2, prepError, error, dir); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine(x, y, dx * stepX, dy * stepY, thicknessFunction(i) / 2, prepError + diagonalError + stepError, error, dir); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents(y0, x0, y1, x1, thicknessFunction); + else + drawLineRightLeftOctents(x0, y0, x1, y1, thicknessFunction); +} diff --git a/dist/core/drawing-context.js b/dist/core/drawing-context.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/core/drawing-context.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/core/events.js b/dist/core/events.js new file mode 100644 index 0000000..1ae833d --- /dev/null +++ b/dist/core/events.js @@ -0,0 +1,8 @@ +export {}; +// +// // Extension pattern +// declare module "./events" { +// interface EventTypes { +// "CUSTOM_TOOL_EVENT": { customData: any }; +// } +// } diff --git a/dist/core/interfaces/drawing-context.js b/dist/core/interfaces/drawing-context.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/core/interfaces/drawing-context.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/core/layers/concrete/pixel-layer.js b/dist/core/layers/concrete/pixel-layer.js new file mode 100644 index 0000000..04a8105 --- /dev/null +++ b/dist/core/layers/concrete/pixel-layer.js @@ -0,0 +1,317 @@ +import LayerHistory from "../layer-history.js"; +import PixelChanges from "../../../services/pixel-change.js"; +import { validateNumber } from "../../../utils/validation.js"; +import Color from "../../../services/color.js"; +/** + * Represents a canvas grid system + * @class + */ +export default class PixelLayer { + /** + * The width of the canvas + */ + layerWidth; + /** + * The height of the canvas + */ + layerHeight; + /** + * Current used action + */ + inAction = false; + /** + * The action history system to store main changes + */ + history = new LayerHistory(64); + /** + * The 2-D grid containing the Pixel data of the canvas + */ + pixelMatrix; + /** + * Buffer logs changes performed on pixels (Ex. color change) + */ + pixelChanges = new PixelChanges(); + /** + * Creates a blank canvas with specified width and height + * @constructor + * @param [width=1] - The width of the grid + * @param [height=1] - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + constructor(width = 1, height = 1) { + this.initializeBlankCanvas(width, height); + } + /** + * Initializes the canvas with a blank grid of transparent pixel data + * @method + * @param width - The width of the grid + * @param height - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + initializeBlankCanvas(width, height) { + validateNumber(width, "Width", { start: 1, end: 1024, integerOnly: true }); + validateNumber(height, "Height", { start: 1, end: 1024, integerOnly: true }); + this.layerWidth = width; + this.layerHeight = height; + this.pixelMatrix = new Array(width * height); + for (let x = 0; x < this.layerWidth; x++) { + for (let y = 0; y < this.layerHeight; y++) { + this.pixelMatrix[x + this.layerWidth * y] = { color: Color.TRANSPARENT }; + } + } + } + /** + * Loads an image data at (x, y) position + * @method + * @param imageData - The image to be loaded + * @param [x0=0] - X-coordinate + * @param [y0=0] - Y-coordinate + * @throws {TypeError} If x or y are not integers + */ + loadImage(imageData, x0 = 0, y0 = 0) { + validateNumber(x0, "x", { integerOnly: true }); + validateNumber(y0, "y", { integerOnly: true }); + let start_y = Math.max(y0, 0); + let start_x = Math.max(x0, 0); + for (let y = start_y; y < imageData.height + y0 && y < this.layerHeight; y++) { + for (let x = start_x; x < imageData.width + x0 && x < this.layerWidth; x++) { + let dist = (x - x0 + imageData.width * (y - y0)) * 4; + let red = imageData.data[dist + 0]; + let green = imageData.data[dist + 1]; + let blue = imageData.data[dist + 2]; + let alpha = imageData.data[dist + 3]; + this.setColor(x, y, Color.get({ rgb: [red, green, blue], alpha: alpha / 255 }), { validate: false }); + } + } + } + /** + * Gets an image data from certain area + * @method + * @param [x0=0] - start X-coordinate + * @param [y0=0] - start Y-coordinate + * @param [x1=this.width] - end X-coordinate + * @param [y1=this.height] - end Y-coordinate + * @returns An image data object for the specified area of the layer + * @throws {TypeError} If x or y are not integers + */ + getImage(x0 = 0, y0 = 0, x1 = this.width, y1 = this.height) { + validateNumber(x0, "x0", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y0, "y0", { start: 0, end: this.height - 1, integerOnly: true }); + validateNumber(x1, "x1", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y1, "y1", { start: 0, end: this.height - 1, integerOnly: true }); + if (x0 > x1) + [x0, y0] = [x1, y1]; + if (y0 > y1) + [y0, y1] = [y1, y0]; + const image = new ImageData(x1 - x0 + 1, y1 - y0 + 1); + for (let x = x0; x <= x1; x++) { + for (let y = y0; y <= y1; y++) { + const dist = (x - x0 + image.width * (y - y0)) * 4; + const color = this.getColor(x, y); + image.data[dist + 0] = color.rgb[0]; + image.data[dist + 1] = color.rgb[1]; + image.data[dist + 2] = color.rgb[2]; + image.data[dist + 3] = Math.abs(color.alpha * 255); + } + } + return image; + } + /** + * Clears the layer + * @method + */ + clear() { + for (let i = 0; i < this.layerHeight; i++) { + for (let j = 0; j < this.layerWidth; j++) { + this.setColor(j, i, Color.TRANSPARENT, { validate: false }); + } + } + } + /** + * Resets changes buffer to be empty + * @method + * @returns Change buffer before emptying + */ + resetChangeBuffer() { + const changeBuffer = this.pixelChanges; + this.pixelChanges = new PixelChanges(); + return changeBuffer; + } + /** + * Starts a new action into the history with given name + * @param actionName - The name + * @method + */ + startAction(actionName) { + if (this.inAction) + this.endAction(); + this.history.setRecord({ + name: actionName, + timestamp: Date.now(), + change: new PixelChanges(), + steps: [], + }); + this.inAction = true; + } + /** + * Commits current pixel buffer to current action in history then resets change buffer + * @method + * @throws {Error} If no active action to add steps to + */ + commitStep() { + if (!this.history.getRecordData()) + throw new Error("No active action to add step to"); + const record = this.history.getRecordData(); + if (this.pixelChanges.isEmpty) + return this.pixelChanges.clone(); + if (record.steps.length === 10 || this.pixelChanges.count >= 100) + compressActionSteps(record); + this.history.getRecordData().steps.push(this.pixelChanges); + return this.resetChangeBuffer(); + } + /** + * Ends the current action in the history + * @method + */ + endAction() { + if (!this.isInAction) + return; + this.inAction = false; + } + /** + * Cancels the current action in the history + * @method + */ + cancelAction() { + if (!this.isInAction) + return; + this.endAction(); + this.undo(); + } + /** + * Undos an action + * @method + */ + undo() { + this.cancelAction(); + if (this.history.atStart) + return; + this.history.undo(); + this.applyRecord(1 /* HistoryMove.Backward */); + } + /** + * Redos an action + * @method + */ + redo() { + this.cancelAction(); + if (this.history.atEnd) + return; + this.history.redo(); + this.applyRecord(0 /* HistoryMove.Forward */); + } + /** helper method */ + applyRecord(direction) { + const record = this.history.getRecordData(); + let state; + if (direction === 0 /* HistoryMove.Forward */) + state = "after"; + else if (direction === 1 /* HistoryMove.Backward */) + state = "before"; + if (record.steps.length !== 0) + compressActionSteps(record); + for (const change of record.change) + this.setColor(change.key.x, change.key.y, change.states[state].color, { quietly: true, validate: false }); + } + /** + * Sets color to pixel at position (x, y). + * @method + * @param x - X-coordinate. + * @param y - X-coordinate. + * @param color - The Color object to be set + * @param options - An object containing additional options. + * @param [options.quietly=false] - If set to true, the pixel data at which color changed will not be pushed to the changeBuffers array. + * @param [options.validate=true] - If set to true, the x, y, and color types are validated. + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + * @throws {Error} If not quiet when no action is active + */ + setColor(x, y, color, { quietly = false, validate = true } = {}) { + if (validate) { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + } + if (!quietly) { + if (!this.isInAction) + throw new Error("Cannot set color outside of an action"); + const newColor = color, oldColor = this.pixelMatrix[x + y * this.layerWidth].color; + if (!Color.isEqualTo(this.pixelMatrix[x + y * this.layerWidth].color, color)) { + this.pixelChanges.setChange({ x, y }, { color: newColor }, { color: oldColor }); + } + } + this.pixelMatrix[x + y * this.layerWidth].color = color; + } + /** + * Returns pixel data at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Pixel data at position (x, y) + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + */ + get(x, y) { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + return this.pixelMatrix[x + y * this.layerWidth]; + } + /** + * Returns pixel color at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Color object of pixel at position (x, y) + */ + getColor(x, y) { + return this.get(x, y).color; + } + /** + * Returns copy of change buffer + * @method + * @returns Copy of change buffer + */ + get changeBuffer() { + return this + .pixelChanges.clone(); + } + /** + * Returns the width of the canvas + * @method + * @returns The width of the canvas + */ + get width() { + return this.layerWidth; + } + /** + * Returns the height of the canvas + * @method + * @returns The height of the canvas + */ + get height() { + return this.layerHeight; + } + /** + * Returns whether an action is active + * @method + * @returns Whether an action is active + */ + get isInAction() { + return this.inAction; + } +} +function compressActionSteps(record) { + record.steps.reduce((totalChange, step) => totalChange.mergeMutable(step), record.change); + record.steps = []; +} diff --git a/dist/core/layers/layer-history.js b/dist/core/layers/layer-history.js new file mode 100644 index 0000000..50de701 --- /dev/null +++ b/dist/core/layers/layer-history.js @@ -0,0 +1,12 @@ +import HistorySystem from "../../generics/history-system.js"; +import PixelChanges from "../../services/pixel-change.js"; +export default class LayerHistory extends HistorySystem { + constructor(capacity) { + super(capacity, { + name: "START", + timestamp: Date.now(), + change: new PixelChanges(), + steps: [] + }); + } +} diff --git a/dist/core/layers/pixel-layer.js b/dist/core/layers/pixel-layer.js new file mode 100644 index 0000000..1de98c7 --- /dev/null +++ b/dist/core/layers/pixel-layer.js @@ -0,0 +1,322 @@ +import { validateNumber } from "../../utils/validation.js"; +import { LayerHistory } from "../../core/layers/types/history-types.js"; +import Color from "../../services/color-service.js"; +import { PixelChanges } from "../../core/layers/types/pixel-types.js"; +export var HistoryMove; +(function (HistoryMove) { + HistoryMove[HistoryMove["Forward"] = 0] = "Forward"; + HistoryMove[HistoryMove["Backward"] = 1] = "Backward"; +})(HistoryMove || (HistoryMove = {})); +/** + * Represents a canvas grid system + * @class + */ +export default class PixelLayer { + /** + * The width of the canvas + */ + layerWidth; + /** + * The height of the canvas + */ + layerHeight; + /** + * Current used action + */ + inAction = false; + /** + * The action history system to store main changes + */ + history = new LayerHistory(64); + /** + * The 2-D grid containing the Pixel data of the canvas + */ + pixelMatrix; + /** + * Buffer logs changes performed on pixels (Ex. color change) + */ + pixelChanges = new PixelChanges(); + /** + * Creates a blank canvas with specified width and height + * @constructor + * @param [width=1] - The width of the grid + * @param [height=1] - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + constructor(width = 1, height = 1) { + this.initializeBlankCanvas(width, height); + } + /** + * Initializes the canvas with a blank grid of transparent pixel data + * @method + * @param width - The width of the grid + * @param height - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + initializeBlankCanvas(width, height) { + validateNumber(width, "Width", { start: 1, end: 1024, integerOnly: true }); + validateNumber(height, "Height", { start: 1, end: 1024, integerOnly: true }); + this.layerWidth = width; + this.layerHeight = height; + this.pixelMatrix = new Array(width * height); + for (let x = 0; x < this.layerWidth; x++) { + for (let y = 0; y < this.layerHeight; y++) { + this.pixelMatrix[x + this.layerWidth * y] = { color: Color.TRANSPARENT }; + } + } + } + /** + * Loads an image data at (x, y) position + * @method + * @param imageData - The image to be loaded + * @param [x0=0] - X-coordinate + * @param [y0=0] - Y-coordinate + * @throws {TypeError} If x or y are not integers + */ + loadImage(imageData, x0 = 0, y0 = 0) { + validateNumber(x0, "x", { integerOnly: true }); + validateNumber(y0, "y", { integerOnly: true }); + let start_y = Math.max(y0, 0); + let start_x = Math.max(x0, 0); + for (let y = start_y; y < imageData.height + y0 && y < this.layerHeight; y++) { + for (let x = start_x; x < imageData.width + x0 && x < this.layerWidth; x++) { + let dist = (x - x0 + imageData.width * (y - y0)) * 4; + let red = imageData.data[dist + 0]; + let green = imageData.data[dist + 1]; + let blue = imageData.data[dist + 2]; + let alpha = imageData.data[dist + 3]; + this.setColor(x, y, Color.get({ rgb: [red, green, blue], alpha: alpha / 255 }), { validate: false }); + } + } + } + /** + * Gets an image data from certain area + * @method + * @param [x0=0] - start X-coordinate + * @param [y0=0] - start Y-coordinate + * @param [x1=this.width] - end X-coordinate + * @param [y1=this.height] - end Y-coordinate + * @returns An image data object for the specified area of the layer + * @throws {TypeError} If x or y are not integers + */ + getImage(x0 = 0, y0 = 0, x1 = this.width, y1 = this.height) { + validateNumber(x0, "x0", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y0, "y0", { start: 0, end: this.height - 1, integerOnly: true }); + validateNumber(x1, "x1", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y1, "y1", { start: 0, end: this.height - 1, integerOnly: true }); + if (x0 > x1) + [x0, y0] = [x1, y1]; + if (y0 > y1) + [y0, y1] = [y1, y0]; + const image = new ImageData(x1 - x0 + 1, y1 - y0 + 1); + for (let x = x0; x <= x1; x++) { + for (let y = y0; y <= y1; y++) { + const dist = (x - x0 + image.width * (y - y0)) * 4; + const color = this.getColor(x, y); + image.data[dist + 0] = color.rgb[0]; + image.data[dist + 1] = color.rgb[1]; + image.data[dist + 2] = color.rgb[2]; + image.data[dist + 3] = Math.abs(color.alpha * 255); + } + } + return image; + } + /** + * Clears the layer + * @method + */ + clear() { + for (let i = 0; i < this.layerHeight; i++) { + for (let j = 0; j < this.layerWidth; j++) { + this.setColor(j, i, Color.TRANSPARENT, { validate: false }); + } + } + } + /** + * Resets changes buffer to be empty + * @method + * @returns Change buffer before emptying + */ + resetChangeBuffer() { + const changeBuffer = this.pixelChanges; + this.pixelChanges = new PixelChanges(); + return changeBuffer; + } + /** + * Starts a new action into the history with given name + * @param actionName - The name + * @method + */ + startAction(actionName) { + if (this.inAction) + this.endAction(); + this.history.setRecord({ + name: actionName, + timestamp: Date.now(), + change: new PixelChanges(), + steps: [], + }); + this.inAction = true; + } + /** + * Commits current pixel buffer to current action in history then resets change buffer + * @method + * @throws {Error} If no active action to add steps to + */ + commitActionStep() { + if (!this.history.getRecordData()) + throw new Error("No active action to add step to"); + const record = this.history.getRecordData(); + if (this.pixelChanges.isEmpty) + return this.pixelChanges.clone(); + if (record.steps.length === 10 || this.pixelChanges.count >= 100) + compressActionSteps(record); + this.history.getRecordData().steps.push(this.pixelChanges); + return this.resetChangeBuffer(); + } + /** + * Ends the current action in the history + * @method + */ + endAction() { + if (!this.isInAction) + return; + this.inAction = false; + } + /** + * Cancels the current action in the history + * @method + */ + cancelAction() { + if (!this.isInAction) + return; + this.endAction(); + this.undo(); + } + /** + * Undos an action + * @method + */ + undo() { + this.cancelAction(); + if (this.history.atStart) + return; + this.history.undo(); + this.applyRecord(HistoryMove.Backward); + } + /** + * Redos an action + * @method + */ + redo() { + this.cancelAction(); + if (this.history.atEnd) + return; + this.history.redo(); + this.applyRecord(HistoryMove.Forward); + } + /** helper method */ + applyRecord(direction) { + const record = this.history.getRecordData(); + let state; + if (direction === HistoryMove.Forward) + state = "after"; + else if (direction === HistoryMove.Backward) + state = "before"; + if (record.steps.length !== 0) + compressActionSteps(record); + for (const change of record.change) + this.setColor(change.key.x, change.key.y, change.states[state].color, { quietly: true, validate: false }); + } + /** + * Sets color to pixel at position (x, y). + * @method + * @param x - X-coordinate. + * @param y - X-coordinate. + * @param color - The Color object to be set + * @param options - An object containing additional options. + * @param [options.quietly=false] - If set to true, the pixel data at which color changed will not be pushed to the changeBuffers array. + * @param [options.validate=true] - If set to true, the x, y, and color types are validated. + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + * @throws {Error} If not quiet when no action is active + */ + setColor(x, y, color, { quietly = false, validate = true } = {}) { + if (validate) { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + } + if (!quietly) { + if (!this.isInAction) + throw new Error("Cannot set color outside of an action"); + const newColor = color, oldColor = this.pixelMatrix[x + y * this.layerWidth].color; + if (!Color.isEqualTo(this.pixelMatrix[x + y * this.layerWidth].color, color)) { + this.pixelChanges.setChange({ x, y }, { color: newColor }, { color: oldColor }); + } + } + this.pixelMatrix[x + y * this.layerWidth].color = color; + } + /** + * Returns pixel data at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Pixel data at position (x, y) + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + */ + get(x, y) { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + return this.pixelMatrix[x + y * this.layerWidth]; + } + /** + * Returns pixel color at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Color object of pixel at position (x, y) + */ + getColor(x, y) { + return this.get(x, y).color; + } + /** + * Returns copy of change buffer + * @method + * @returns Copy of change buffer + */ + get changeBuffer() { + return this + .pixelChanges.clone(); + } + /** + * Returns the width of the canvas + * @method + * @returns The width of the canvas + */ + get width() { + return this.layerWidth; + } + /** + * Returns the height of the canvas + * @method + * @returns The height of the canvas + */ + get height() { + return this.layerHeight; + } + /** + * Returns whether an action is active + * @method + * @returns Whether an action is active + */ + get isInAction() { + return this.inAction; + } +} +function compressActionSteps(record) { + record.steps.reduce((totalChange, step) => totalChange.mergeMutable(step), record.change); + record.steps = []; +} diff --git a/dist/core/layers/types/history-types.js b/dist/core/layers/types/history-types.js new file mode 100644 index 0000000..ee089b2 --- /dev/null +++ b/dist/core/layers/types/history-types.js @@ -0,0 +1,12 @@ +import HistorySystem from "../../../systems/history-system.js"; +import { PixelChanges } from "../../../core/layers/types/pixel-types.js"; +export class LayerHistory extends HistorySystem { + constructor(capacity) { + super(capacity, { + name: "START", + timestamp: Date.now(), + change: new PixelChanges(), + steps: [] + }); + } +} diff --git a/dist/core/layers/types/pixel-types.js b/dist/core/layers/types/pixel-types.js new file mode 100644 index 0000000..f1814d0 --- /dev/null +++ b/dist/core/layers/types/pixel-types.js @@ -0,0 +1,61 @@ +import ChangeSystem from "../../../systems/change-system.js"; +import Color from "../../../services/color-service.js"; +/** + * Tracks pixel modifications with boundary detection. + * Extends ChangeSystem with pixel-specific optimizations: + * - Automatic bounds calculation for changed areas + * - Color-specific comparison logic + * + * @property {PixelRectangleBounds|null} bounds - Returns the minimal rectangle containing all changes + */ +export class PixelChanges extends ChangeSystem { + boundaries = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + constructor() { + super((a, b) => Color.isEqualTo(a.color, b.color)); + } + mergeMutable(source) { + super.mergeMutable(source); + this.boundaries = { + x0: Math.min(this.boundaries.x0, source.boundaries.x0), + y0: Math.min(this.boundaries.y0, source.boundaries.y0), + x1: Math.max(this.boundaries.x1, source.boundaries.x1), + y1: Math.max(this.boundaries.y1, source.boundaries.y1), + }; + return this; + } + clone() { + const copy = super.clone(); + copy.boundaries = { ...this.boundaries }; + return copy; + } + clear() { + super.clear(); + this.boundaries = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + } + setChange(key, after, before) { + const p = super.setChange(key, after, before); + if (p !== null) { + this.boundaries.x0 = Math.min(this.boundaries.x0, key.x); + this.boundaries.y0 = Math.min(this.boundaries.y0, key.y); + this.boundaries.x1 = Math.max(this.boundaries.x1, key.x); + this.boundaries.y1 = Math.max(this.boundaries.y1, key.y); + } + return p; + } + get bounds() { + if (this.count === 0) + return null; + else + return { ...this.boundaries }; + } +} diff --git a/dist/core/managers/layer-manager.js b/dist/core/managers/layer-manager.js new file mode 100644 index 0000000..2acc4e8 --- /dev/null +++ b/dist/core/managers/layer-manager.js @@ -0,0 +1,351 @@ +import { validateNumber } from "../../utils/validation.js"; +import PixelLayer from "../layers/concrete/pixel-layer.js"; +import Color from "../../services/color.js"; +/** + * Represents a system for managing layers of canvas grids + * @class + */ +export default class LayerManager { + /** + * A map containing layers accessed by their IDs + */ + layers = new Map(); + /** + * A Layer for previewing actions + */ + previewLayer; + /** + * The currently active layer + */ + activeLayer = null; + /** + * A set of IDs of the currently selected layers + */ + selections = new Set(); + /** + * An array for maintaining order, holds IDs of the layers + */ + layerOrder = []; + /** + * Dimensions of canvases that the layer system holds + */ + canvasWidth; + canvasHeight; + /** + * Internal counter to enumerate increamental IDs for the created layers + */ + layerIDCounter = -1; + /** + * Cache of the rendered image + */ + renderCache = new Map(); + /** + * Colors of the checkerboard background of transparent canvas + */ + darkBG = Color.get({ rgb: [160, 160, 160], alpha: 1 }); + lightBG = Color.get({ rgb: [217, 217, 217], alpha: 1 }); + /** + * Represents a system for managing layers of canvas + * @constructor + * @param [width=1] - The width of the canvas grid for the layers + * @param [height=1] height - The height of the canvas grid for the layers + * @param events - The event bus for subscribing to events + * @throws {TypeError} if width or height are not integers + * @throws {RangeError} if width or height are not between 1 and 1024 inclusive + */ + constructor(width = 1, height = 1) { + validateNumber(width, "width", { + start: 1, + end: 1024, + integerOnly: true, + }); + validateNumber(height, "height", { + start: 1, + end: 1024, + integerOnly: true, + }); + this.canvasWidth = width; + this.canvasHeight = height; + this.previewLayer = new PixelLayer(this.canvasWidth, this.canvasHeight); + } + /** + * validates IDs in the layers list + * @method + * @param ids - The IDs of the layers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + * @throws {TypeError} If the IDs is not integers + */ + validate(...ids) { + if (this.layers.size === 0) + throw new RangeError("No layers to get"); + for (let id of ids) { + validateNumber(id, "ID", { integerOnly: true, }); + if (!this.layerOrder.includes(id)) + throw new RangeError(`Layer with ${id} ID is not found`); + } + } + /** + * Adds a new layer object into the layers list, if only layer in list, is set as the active layer + * @method + * @param name - The name of the layer to be added + * @returns the ID of the newly created layer + * @throws {TypeError} If the name is not string + */ + add(name) { + let id = ++this.layerIDCounter; + let newLayer = { + id: id, + name: name, + pixelLayer: new PixelLayer(this.canvasWidth, this.canvasHeight), + }; + this.layers.set(id, newLayer); + this.layerOrder.push(id); + this.activeLayer = this.activeLayer ?? newLayer.pixelLayer; + return id; + } + /** + * Delete layers with given IDs from layers list and set active layer to null if got deleted. If no ID given, delete selected layers + * @param ids - The IDs of the layers to be removed + * @method + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the index is out of valid range + */ + remove(...ids) { + if (ids.length === 0) + ids = Array.from(this.selections); + this.validate(...ids); + // reverse order to avoid much index shifting + ids.sort((a, b) => this.layerOrder.indexOf(b) - this.layerOrder.indexOf(a)) + .forEach(id => { + if (this.layers.get(id).pixelLayer === this.activeLayer) + this.activeLayer = null; + this.selections.delete(id); + this.layers.delete(id); + this.layerOrder.splice(this.layerOrder.indexOf(id), 1); + }); + } + /** + * Sets the active layer + * @method + * @param id - The ID of the layer to be activated + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + activate(id) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + this.validate(id); + this.activeLayer = this.layers.get(id).pixelLayer; + } + /** + * Selects layers in the layers list + * @method + * @param ids - The IDs to select, if an ID is for an already selected layer, ignore it + * @throws {TypeError} If the IDs are not integers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + */ + select(...ids) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + this.validate(...ids); + // selection + for (let id of ids) { + this.selections.add(id); + } + } + /** + * Deselects layers in the layers list + * @method + * @param ids - The IDs to deselect, if an ID is for an already unselected layer, ignores it + * @throws {TypeError} If the IDs are not integers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + */ + deselect(...ids) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + // validation + for (let id of ids) { + this.validate(id); + } + // deselection + for (let id of ids) { + if (this.selections.has(id)) // if + this.selections.delete(id); + } + } + /** + * Deselects all layers + */ + clearSelection() { + this.selections.clear(); + } + /** + * Changes the position of a single layer in the layer list + * @method + * @param offset - The offset by which to move the layer + * @param id - The ID of the layer to move + * @throws {TypeError} If the offset or ID are not a valid integers + * @throws {RangeError} If the layer list is empty or the ID is not in the layer list + */ + move(offset, id) { + if (this.layers.size === 0) { + throw new RangeError("No layers to move"); + } + validateNumber(offset, "Offset", { integerOnly: true }); + this.validate(id); + const currentIndex = this.layerOrder.indexOf(id); + let newIndex = currentIndex + offset; + // clamp the new index to valid range + newIndex = Math.max(0, Math.min(newIndex, this.layerOrder.length - 1)); + if (newIndex !== currentIndex) { + this.layerOrder.splice(currentIndex, 1); + this.layerOrder.splice(newIndex, 0, id); + } + } + /** + * Retrieves the image at the specified bounded rectangle in the canvas, the whole canvas if no changes given + * @method + * @param bounds - The bounds of the changed pixels, if null, update everything + * @returns The resulting image data of the compsited layers and the starting position + */ + renderImage(bounds = { + x0: 0, + y0: 0, + x1: this.canvasWidth - 1, + y1: this.canvasHeight - 1, + }) { + let image; + const normalizeBounds = (bounds) => { + const { x0, y0, x1, y1 } = bounds; + return { + x0: Math.min(x0, this.canvasWidth - 1), + y0: Math.min(y0, this.canvasHeight - 1), + x1: Math.max(x1, 0), + y1: Math.max(y1, 0), + }; + }; + const fillImage = (x, y, x0, y0) => { + const index = ((y - y0) * this.canvasWidth + (x - x0)) * 4; + const color = this.getColor(x, y); + image.data[index + 0] = color.rgb[0]; + image.data[index + 1] = color.rgb[1]; + image.data[index + 2] = color.rgb[2]; + image.data[index + 3] = Math.round(color.alpha * 255); + }; + bounds = normalizeBounds(bounds); + image = new ImageData(bounds.x1 - bounds.x0 + 1, bounds.y1 - bounds.y0 + 1); + for (let y = bounds.y0; y <= bounds.y1; y++) + for (let x = bounds.x0; x <= bounds.x1; x++) + fillImage(x, y, bounds.x0, bounds.y0); + return { image, x0: bounds.x0, y0: bounds.y0 }; + } + /** + * Sets the two colors of the checkerboard background covor of the canvas + * @method + * @param lightBG - The first color + * @param darkBG - The second color + */ + setBackgroundColors(lightBG, darkBG) { + this.lightBG = lightBG; + this.darkBG = darkBG; + } + /** + * Sets a new name to a layer in the layer list for given ID + * @param id - The ID of the layer + * @param name - The index to change to in the layer list + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + setName(id, name) { + this.validate(id); + this.layers.get(id).name = name; + } + /** + * Retrieves the layer in the layer list for given ID + * @param id - The ID of the layer + * @returns the layer object + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + getLayer(id) { + this.validate(id); + return this.layers.get(id).pixelLayer; + } + /** + * Retrieves the resulting color of all layers in the list at a pixel position + * @method + * @param x - The X-Coordinate + * @param y - The Y-Coordinate + * @returns The resulting color object of all layers at the specified pixel position + * @throws {TypeError} If X-Coordinate or Y-Coordinate are not valid integers + * @throws {RangeError} If X-Coordinate or Y-Coordinate are not in valid range + */ + getColor(x, y) { + validateNumber(x, "x", { start: 0, end: this.canvasWidth, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.canvasHeight, integerOnly: true }); + if (this.renderCache.has({ x, y })) + return this.renderCache.get({ x, y }); + let finalColor = (x + y) % 2 ? this.lightBG : this.darkBG; + for (let i = this.layerOrder.length - 1; i >= 0; i--) { + const layer = this.layers.get(this.layerOrder[i]).pixelLayer; + const layerColor = layer.getColor(x, y); + if (layerColor.alpha <= 0) + continue; + finalColor = Color.compositeOver(layerColor, finalColor); + } + return finalColor; + } + ; + /** + * Retrieves name of a layer in the layer list for given ID + * @param id - The ID of the layer + * @returns the name of the layer + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + getName(id) { + this.validate(id); + return this.layers.get(id).name; + } + /** + * Retrieves width of canvas grid for which the layer system is applied + * @method + * @returns The width of the canvas grid for the layers + */ + get width() { + return this.canvasWidth; + } + /** + * Retrieves height of canvas grid for which the layer system is applied + * @method + * @returns The height of the canvas grid for the layers + */ + get height() { + return this.canvasHeight; + } + /** + * Retrieves number of layers in the layer list + * @method + * @returns the number of layers + */ + get size() { + return this.layers.size; + } + /** + * Retrieves list of IDs, names and Layer objects of all layers in the list (or just selected ones if specified) + * @method + * @param [selectedOnly=false] - if true, retrieves only selected layers + * @returns Array of objects containing IDs, names and Layer objects of the layers + */ + *list(selectedOnly = false) { + if (selectedOnly) + for (let id of this.layerOrder) { + if (this.selections.has(id)) + yield this.layers.get(id); + } + else + for (let id of this.layerOrder) { + yield this.layers.get(id); + } + } +} diff --git a/dist/core/managers/tool-manager.js b/dist/core/managers/tool-manager.js new file mode 100644 index 0000000..2d809c0 --- /dev/null +++ b/dist/core/managers/tool-manager.js @@ -0,0 +1,116 @@ +import Color from "../../services/color.js"; +import PenTool from "../tools/implementations/pen-tool.js"; +import { validateNumber } from "../../utils/validation.js"; +/** + * Class for managing the canvas tools and their functionalities + * @class + */ +export default class ToolManager { + drawColor; + eraseColor; + drawSize; + eraseSize; + selectedTool; + tools; + drawingColor = Color.get({ hex: "#0f0" }); + eraserColor = Color.get({ hex: "#0000" }); + /** + * Creates a ToolManager class that manages tools for the canvas, and applies their functionalities to the layerSystem and drawingManager, and renders the result to canvasManager + * @constructor + * @param events - the event bus that will be used to subscribe to events + * @param image - the image data that will be used to draw on + */ + constructor(context) { + this.tools = new Map([ + ["pen", new PenTool(context)], + ]); + this.selectedTool = this.tools.get("pen"); + } + setDrawingColor(color) { + this.drawColor = color; + } + setErasingColor(color) { + this.eraseColor = color; + } + setDrawingSize(size) { + validateNumber(size, "Size", { start: 1, integerOnly: true }); + this.drawSize = size; + } + setErasingSize(size) { + validateNumber(size, "Size", { start: 1, integerOnly: true }); + this.eraseSize = size; + } +} +// private tolerance: number; +// private intensity: number; +// private image: ImageData; +// setTolerance(tolerance: number) { +// validateNumber(tolerance, "Tolerance", { start: 1, integerOnly: true }); +// this.tolerance = tolerance; +// } +// +// setIntensity(intensity: number) { +// validateNumber(intensity, "Intensity", { start: 1, integerOnly: true }); +// this.intensity = intensity; +// } +// +// use(event: string, pixelPosition: {x: number, y: number}) { +// let metaData; +// let command; +// switch (this.toolName) { +// case "pen": +// metaData = { +// size: this.drawSize, +// color: this.drawColor, +// }; +// break; +// case "eraser": +// metaData = { +// size: this.eraseSize, +// color: this.eraseColor, +// }; +// break; +// case "line": +// metaData = { +// thicknessTimeFunction: () => this.drawSize, +// color: this.drawColor, +// }; +// break; +// case "bucket": +// metaData = { +// tolerance: this.tolerance, +// color: this.drawColor, +// }; +// break; +// } +// +// switch (event) { +// case "start-action": +// this.drawingTool.startAction(this.toolName, metaData); +// // this.#events.emit("layer:preview", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// this.render(this.drawingTool.action(pixelPosition)); +// break; +// case "move-action": +// // this.#events.emit("layer:repreview", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// this.render(this.drawingTool.action(pixelPosition)); +// break; +// case "mousehover": +// //this.render(this.#drawingManager.preview(pixelPosition)); +// break; +// case "end-action": +// // this.#events.emit("layer:perform", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// //this.render(this.#drawingManager.action(pixelPosition)); +// // ended action +// this.drawingTool.endAction(); +// break; +// case "eye-dropper": +// // !!! +// break; +// } +// } diff --git a/dist/core/pixel-editor.js b/dist/core/pixel-editor.js new file mode 100644 index 0000000..edb96d3 --- /dev/null +++ b/dist/core/pixel-editor.js @@ -0,0 +1,108 @@ +import { validateNumber } from "../utils/validation.js"; +import LayerManager from "../core/managers/layer-manager.js"; +import ToolManager from "../core/managers/tool-manager.js"; +import EventBus from "../services/event-bus.js"; +import Canvas from "../core/ui-components/canvas.js"; +/** + * Responsible for managing events and functionalities of the canvas element inside its container + * @class + */ +class PixelEditor { + layerManager; + toolManager; + width; + height; + canvas; + events; + /** + * Creates a canvas elements inside the given container and initializes it with width and height + * @constructor + * @param width - Integer represents the width of the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the height of the canvas, range is [0, 1024] inclusive + * @param {HTMLElement} containerElement - The DOM Element that will contain the canvas + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + constructor(containerElement, width, height) { + this.events = new EventBus(); + this.canvas = new Canvas(containerElement, this.events); + this.createBlankBoard(width, height); + this.setupEvents(); + } + /** + * Creates a blank board with given canvas width and height + * @method + * @param width - Integer represents the width of the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the height of the canvas, range is [0, 1024] inclusive + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + createBlankBoard(width, height) { + validateNumber(width, "Width", { integerOnly: true, start: 1, end: 1024 }); + validateNumber(height, "Height", { integerOnly: true, start: 1, end: 1024 }); + this.width = width; + this.height = height; + this.canvas.createBlankCanvas(width, height); + this.layerManager = new LayerManager(width, height); + this.layerManager.add("Background"); + this.toolManager = new ToolManager(this.layerManager.activeLayer); + } + /** + * Loads image into the current layer + * @method + * @param clientX - The x position on the scaled canvas element to put the image + * @param clientY - The y position on the scaled canvas element to put the image + * @throws {TypeError} if the imageURL is not a valid image url + */ + async loadImage(clientX, clientY, imageURL) { + let pixel = this.canvas.getPixelPosition(clientX, clientY); + const image = await new Promise((resolve, reject) => { + // validating the imageURL and setting the img + const pattern = /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i; + if (!pattern.test(imageURL)) + () => reject(TypeError("imgaeURL must be a valid image URL")); + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = imageURL; + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + resolve(ctx.getImageData(0, 0, canvas.width, canvas.height)); + }; + img.onerror = () => reject(new Error("Image failed to load")); + }); + this.layerManager.activeLayer.loadImage(image, pixel.x, pixel.y); + } + render(bounds = { x0: 0, y0: 0, x1: this.width - 1, y1: this.height - 1 }) { + const { image, x0, y0 } = this.layerManager.renderImage(bounds); + this.canvas.render(image, x0, y0); + } + setupEvents() { + this.events.on("tool:use", () => { + this.toolManager.selectedTool = this.toolManager.tools.get("pen"); + }); + this.events.on("canvas:mousemove", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseMove(coordinates); + if (bounds) + this.render(); + }); + this.events.on("canvas:mousedown", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseDown(coordinates); + console.log(coordinates, bounds); + if (bounds) + this.render(); + }); + this.events.on("canvas:mouseup", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseUp(coordinates); + if (bounds) + this.render(); + }); + // events.on("tool:apply-action", (actionName: string, change: PixelChanges, reapply: boolean, preview: boolean) => { + // this.selectedTool.applyAction(actionName, change, reapply, preview); + // }); + } +} +export default PixelEditor; diff --git a/dist/core/tools/base/tool-base.js b/dist/core/tools/base/tool-base.js new file mode 100644 index 0000000..71eb755 --- /dev/null +++ b/dist/core/tools/base/tool-base.js @@ -0,0 +1,6 @@ +// Simplified Tool Base Class +export default class Tool { + preview; +} +export class ContinousTool extends Tool { +} diff --git a/dist/core/tools/implementations/pen-tool.js b/dist/core/tools/implementations/pen-tool.js new file mode 100644 index 0000000..45abecc --- /dev/null +++ b/dist/core/tools/implementations/pen-tool.js @@ -0,0 +1,75 @@ +import { ContinousTool } from "../base/tool-base.js"; +import Color from "../../../services/color.js"; +import PixelChanges from "../../../services/pixel-change.js"; +import { drawLine, drawPixel } from "../../../core/algorithms/graphic-algorithms.js"; +export default class PenTool extends ContinousTool { + context; + startState = null; + recentState = null; + redraw = false; + toolEventState = "idle"; + selectedColor = Color.get({ hex: '#0f0' }); + preview = false; + changes = new PixelChanges(); + constructor(context) { + super(); + this.context = context; + this.setPixel = this.setPixel.bind(this); + } + setPixel(x, y) { + if (x < 0 || + y < 0 || + x >= this.context.width || + y >= this.context.height) + return; + this.context.setColor(x, y, this.selectedColor); + } + ; + mouseDown(coord) { + console.log("down!"); + if (this.toolEventState !== "idle") + return null; + this.toolEventState = "start"; + this.context.startAction("Pen Tool"); + this.startState = this.recentState = coord; + drawPixel({ + x: this.startState.x, + y: this.startState.y, + setPixel: this.setPixel + }); + this.toolEventState = "draw"; + return this.context.commitStep().bounds; + } + mouseMove(coord) { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "draw"; + else + return null; + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x, y) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + this.recentState = coord; + return this.context.commitStep().bounds; + } + mouseUp(coord) { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "idle"; + else + return null; + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x, y) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + this.recentState = this.startState = null; + const bounds = this.context.commitStep().bounds; + this.context.endAction(); + return bounds; + } +} diff --git a/dist/core/tools/pen-tool.js b/dist/core/tools/pen-tool.js new file mode 100644 index 0000000..f478c7c --- /dev/null +++ b/dist/core/tools/pen-tool.js @@ -0,0 +1,121 @@ +import { ContinousTool } from "../../core/tools/tool-base.js"; +import Color from "../../services/color-service.js"; +import { PixelChanges } from "../../core/layers/types/pixel-types.js"; +export default class PenTool extends ContinousTool { + context; + startState = null; + recentState = null; + redraw = false; + toolEventState = "idle"; + selectedColor = Color.get({ hex: '#0f0' }); + preview = false; + changes = new PixelChanges(); + constructor(context) { + super(); + this.context = context; + this.setPixel = this.setPixel.bind(this); + } + setPixel(x, y) { + if (x < 0 || + y < 0 || + x >= this.context.width || + y >= this.context.height) + return; + this.context.setColor(x, y, this.selectedColor); + } + ; + mouseDown(coord) { + console.log("down!"); + if (this.toolEventState !== "idle") + return null; + this.toolEventState = "start"; + this.context.startAction("Pen Tool"); + this.startState = this.recentState = coord; + drawPixel({ + x: this.startState.x, + y: this.startState.y, + setPixel: this.setPixel + }); + this.toolEventState = "draw"; + return this.context.commitActionStep().bounds; + } + mouseMove(coord) { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "draw"; + else + return null; + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x, y) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + this.recentState = coord; + return this.context.commitActionStep().bounds; + } + mouseUp(coord) { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "idle"; + else + return null; + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x, y) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + this.recentState = this.startState = null; + const bounds = this.context.commitActionStep().bounds; + this.context.endAction(); + return bounds; + } +} +function drawPixel({ x, y, diameter = 5, isSquare = false, setPixel }) { + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + setPixel(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + if (dx * dx + dy * dy <= radiusSquared) { + setPixel(currentX, currentY); + } + } +} +function drawLine({ x0, y0, x1, y1, setPixel }) { + // Standard Bresenham's algorithm + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + setPixel(x0, y0); + if (x0 === x1 && y0 === y1) + break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } +} diff --git a/dist/core/tools/tool-base.js b/dist/core/tools/tool-base.js new file mode 100644 index 0000000..71eb755 --- /dev/null +++ b/dist/core/tools/tool-base.js @@ -0,0 +1,6 @@ +// Simplified Tool Base Class +export default class Tool { + preview; +} +export class ContinousTool extends Tool { +} diff --git a/dist/core/tools/tools.js b/dist/core/tools/tools.js new file mode 100644 index 0000000..0c2be35 --- /dev/null +++ b/dist/core/tools/tools.js @@ -0,0 +1,110 @@ +export {}; +// import Tool from "@src/core/tools/tool"; +// import { validateNumber } from "../../utils/validation"; +// +// /** +// * Contains graphics methods to draw on layers managed by a layer manager class +// * @class +// */ +// export default class PixelTool { +// private canvasWidth: number = 1; +// private canvasHeight: number = 1; +// private actionMethod: ActionFunction; +// +// private recentPosition: PixelCoord = { x: 0, y: 0 }; +// +// private configs: Map; +// +// public selectedColor: Color = Color.TRANSPARENT; +// public state = ToolState.IDLE; +// public actionType = ActionType.CONSECUTIVE; +// +// +// private static changes: PixelChanges = new PixelChanges(); +// public static image: ImageData; +// +// /** +// * Sets a specific layer manager class for which the layers will be drawn on +// * @constructor +// */ +// constructor() { +// } +// +// startDrawing(x: number, y: number) { +// this.recentPosition = { x, y }; +// this.state = ToolState.DRAWING; +// } +// +// endDrawing() { +// this.state = ToolState.IDLE; +// } +// +// setAction(actionMethod: ActionFunction) { +// this.actionMethod = actionMethod; +// } +// +// private setPixel(x: number, y: number) { +// if ( +// x < 0 || +// y < 0 || +// x >= this.canvasWidth || +// y >= this.canvasHeight +// ) +// return; +// +// const newColorState = { color: this.selectedColor }; +// const oldColorState = PixelTool.changes.getChange({ x, y }) ? PixelTool.changes.getChange({ x, y }).before : { +// color: Color.create({ +// rgb: [ +// PixelTool.image.data[x * 4 + 0], +// PixelTool.image.data[x * 4 + 1], +// PixelTool.image.data[x * 4 + 2], +// ], alpha: PixelTool.image.data[x * 4 + 3] / 255 +// }) +// }; +// +// PixelTool.changes.setChange({ x, y }, newColorState, oldColorState); +// }; +// +// applyAction(x: number, y: number): PixelChanges { +// this.actionMethod({ +// x0: this.recentPosition.x, +// y0: this.recentPosition.y, +// x1: x, +// y1: y, +// toolConfigs: this.configs, +// setPixel: this.setPixel +// }); +// +// let changes = PixelTool.changes; +// PixelTool.changes = new PixelChanges(); +// return changes; +// } +// } +// +// +// export const penTool = new Tool(); +// +// penTool.setAction( function(params: { +// x0: number, +// y0: number, +// x1: number, +// y1: number, +// toolConfigs: Map, +// setPixel: (x: number, y: number) => void +// }) { +// drawPixel( +// params.x0, +// params.y0, +// params.toolConfigs.get("size"), +// true, +// ); +// +// drawLine( +// params.x0, +// params.y0, +// params.x1, +// params.y1, +// () => 1, +// ); +// }) diff --git a/dist/core/ui-components/canvas.js b/dist/core/ui-components/canvas.js new file mode 100644 index 0000000..5310525 --- /dev/null +++ b/dist/core/ui-components/canvas.js @@ -0,0 +1,318 @@ +import { validateNumber } from "../../utils/validation.js"; +/** + * Responsible for managing the canvas element inside its container + * @class + */ +export default class Canvas { + containerElement; + canvasElement; + canvasContext; + normalScale = 1; // the inital scale applied on the canvas to fit in the containerElement + minScale = 1; + maxScale = 1; + scale = 1; // the scale applied by the user on the canvas + recentPixelPos = { x: -1, y: -1 }; + //#isDragging = false; + // private doubleTapThreshold: number = 300; // Time in milliseconds to consider as double tap + // private tripleTapThreshold: number = 600; // Time in milliseconds to consider as triple tap + // + // private lastTouchTime: number = 0; + // private touchCount: number = 0; + // + // private startX: number = 0; + // private startY: number = 0; + // private offsetX: number = 0; + // private offsetY: number = 0; + /** + * Creates a canvas elements inside the given container and manages its functionalities + * @constructor + * @param containerElement - The DOM Element that will contain the canvas + * @param events - The event bus + */ + constructor(containerElement, events) { + // Setup canvas element + this.canvasElement = document.createElement("canvas"); + this.containerElement = containerElement; + this.canvasElement.id = "canvas-image"; + this.canvasElement.style.transformOrigin = 'center center'; + this.containerElement.appendChild(this.canvasElement); + // Setup canvas context + this.canvasContext = this.canvasElement.getContext("2d", { alpha: false }); + this.canvasContext.imageSmoothingEnabled = false; + // Setup events + this.setupEvents(events); + // Recalculate canvas size if container size changes + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target == this.containerElement) { + this.calculateInitialScale(); + } + } + }); + observer.observe(this.containerElement); + } + /** + * Reevaluates the initial scale of the canvas and the min and max scale + * @method + */ + calculateInitialScale() { + const containerRect = this.containerElement.getBoundingClientRect(); + this.normalScale = Math.min(containerRect.width / + this.canvasElement.width, containerRect.height / + this.canvasElement.height); + this.minScale = this.normalScale * 0.1; + this.maxScale = this.normalScale * Math.max(this.canvasElement.width, this.canvasElement.height); + this.zoom(1); + } + /** + * Creates a blank canvas with given width and height, and scale it to the container size + * @method + * @param width - Integer represents the number of pixel columns in the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the number of pixel rows in the canvas, range is [0, 1024] inclusive + * @returns An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + createBlankCanvas(width, height) { + validateNumber(width, "Width", { + start: 1, + end: 1024, + integerOnly: true, + }); + validateNumber(height, "Height", { + start: 1, + end: 1024, + integerOnly: true, + }); + this.canvasElement.width = width; + this.canvasElement.height = height; + this.calculateInitialScale(); + this.resetZoom(); + } + /** + * Renders an image at an offset in the canvas + * @method + * @param imageData - The image to be rendered + * @param [dx=0] - The x offset of the image + * @param [dy=0] - The y offset of the image + */ + render(imageData, dx = 0, dy = 0) { + validateNumber(dx, "x"); + validateNumber(dy, "y"); + this.canvasContext.putImageData(imageData, dx, dy); + } + // addOffset(offsetX, offsetY) { + // // not implemented + // } + /** + * Applies zoom multiplier to the canvas + * @method + * @param delta - Multiplier to be applied to the current scale + * @returns the current zoom level + */ + zoom(delta) { + this.scale = Math.min(Math.max(this.scale * delta, this.minScale), this.maxScale); + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + /** + * Reset zoom of the canvas + * @method + * @returns the current zoom level + */ + resetZoom() { + this.scale = this.normalScale; + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + /** + * Returns current zoom level + * @method + * @returns the current zoom level + */ + getScale() { + return this.scale; + } + /** + * Translates event coordinates of the canvas element to pixel position on the canvas + * @method + * @param clientX - The x position on the canvas element + * @param clientY - The y position on the canvas element + * @returns The resultant position of the pixel on the canvas grid + */ + getPixelPosition(clientX, clientY) { + return { + x: Math.floor(clientX / this.scale), + y: Math.floor(clientY / this.scale), + }; + } + /** + * @method + * @returns Container element + */ + get getContainer() { + return this.containerElement; + } + /** + * @method + * @returns Canvas element + */ + get canvas() { + return this.canvasElement; + } + /** + * @method + * @returns Width of the container element + */ + get containerWidth() { + return this.containerElement.style.width; + } + /** + * @method + * @returns Height of the container element + */ + get containerHeight() { + return this.containerElement.style.height; + } + /** + * @method + * @returns Width of the canvas grid + */ + get width() { + return this.canvasElement.width; + } + /** + * @method + * @returns Height of the canvas grid + */ + get height() { + return this.canvasElement.height; + } + setupEvents(events) { + const emitPointerEvent = (name, event) => { + // if (event.target !== this.canvas) return; + event.preventDefault(); + const canvasRect = this.canvas.getBoundingClientRect(); + const clientX = (event.changedTouches ? event.changedTouches[0].clientX : event.clientX) - canvasRect.left; + const clientY = (event.changedTouches ? event.changedTouches[0].clientY : event.clientY) - canvasRect.top; + const coordinates = this.getPixelPosition(clientX, clientY); + if (this.recentPixelPos.x === coordinates.x && this.recentPixelPos.y === coordinates.y && name == "mousemove") + return; + this.recentPixelPos = coordinates; + events.emit(`canvas:${name}`, { + clientX: clientX, + clientY: clientY, + coordinates, + pointerType: (event.touches ? "touch" : "mouse"), + }); + }; + this.containerElement.addEventListener("mousedown", (e) => { + emitPointerEvent("mousedown", e); + }); + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + document.addEventListener("mousemove", (e) => { + emitPointerEvent("mousemove", e); + }); + document.addEventListener("mouseleave", (e) => { + emitPointerEvent("mouseleave", e); + }); + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + // containerElement.addEventListener("touchstart", (event) => { + // event.preventDefault(); + // + // const currentTime = new Date().getTime(); + // + // if (currentTime - this.#lastTouchTime <= doubleTapThreshold) { + // touchCount++; + // } else if ( + // currentTime - this.#lastTouchTime <= + // tripleTapThreshold + // ) + // touchCount = 2; + // else touchCount = 1; + // + // lastTouchTime = currentTime; + // if (touchCount === 1) { + // this.#isDrawing = true; + // + // const clientX = event.clientX || event.touches[0].clientX; + // const clientY = event.clientY || event.touches[0].clientY; + // + // // this.#toolManager.use("touchdown - draw", clientX, clientY); + // // should be in every comment this.#events.emit("drawstart", clientX, clientY); + // } + // + // if (touchCount === 2) { + // // this.#toolManager.use("touchdown - undo", clientX, clientY); + // touchCount = 0; + // } + // + // if (touchCount === 3) { + // // this.#toolManager.use("touchdown - redo", clientX, clientY); + // touchCount = 0; + // } + // console.log(eventName); + // }); + // ["mouseup", "touchend", "touchcancel"].forEach((eventName) => { + // document.addEventListener(eventName, (event) => { + // //event.preventDefault(); + // this.#isDrawing = false; + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientX; + // console.log(eventName); + // + // // this.#toolManager.use("mouseup", clientX, clientY); + // }); + // }); + // document.addEventListener("touchmove", (event) => { + // //event.preventDefault(); + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientY; + // console.log(eventName); + // + // if (this.#isDrawing); + // // this.#toolManager.use("mousedraw", clientX, clientY); + // else; // this.#toolManager.use("mousehover", clientX, clientY); + // }); + // scroll effect + this.containerElement.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 1.1 : 0.9; + this.zoom(delta); + events.emit("canvas:zoom", { + delta: delta, + centerX: e.clientX, + centerY: e.clientY + }); + }); + // + // window.addEventListener("resize", () => { + // this.canvasElement.refresh(true); + // }); + document.addEventListener("keydown", (e) => { + if (!e.ctrlKey) + return; + if (e.key == "z") + events.emit("canvas:undo", { key: e.key }); + if (e.key == "y") + events.emit("canvas:redo", { key: e.key }); + // this.#canvasElement.render( + // this.#layerManager.getRenderImage( + // this.#canvasElement.getCanvasContext, + // ), + // ); + }); + } +} diff --git a/dist/generics/change-tracker.js b/dist/generics/change-tracker.js new file mode 100644 index 0000000..251be03 --- /dev/null +++ b/dist/generics/change-tracker.js @@ -0,0 +1,154 @@ +/** + * @class + * Tracks modified data with before/after for history support. + * Maintains both a Map for order and a Set for duplicate checking. + */ +export default class ChangeSystem { + /** + * A table of all changed data and its before and after states to a change record containing the position and before/after states + * @private + */ + changes = new Map(); + /** + * Function for comparing two states. If undefined, uses === comparison. + * @private + */ + stateComparator; + /** + * Creates a ChangeSystem instance. + * @constructor + * @param stateComparator - Function for comparing two states + */ + constructor(stateComparator) { + this.stateComparator = stateComparator ?? ((a, b) => a === b); + } + /** + * Merges another ChangeSystem into this one (mutates this object). + * @method + * @param source - Source ChangeSystem to merge. + * @returns This instance (for chaining) + */ + mergeMutable(source) { + if (!source || source.isEmpty) + return this; + source.changes.forEach((change) => { + this.setChange(change.key, change.states.after, change.states.before); + }); + return this; + } + /** + * Merges another ChangeSystem into a copy of this one, and returns it. + * @method + * @param source - Source change system to merge. + * @returns The result of merging + */ + merge(source) { + const result = this.clone(); + result.mergeMutable(source); + return result; + } + /** + * Creates a shallow copy (states are not deep-cloned). + * @method + * @returns {ChangeSystem} The clone + */ + clone() { + const copy = new this.constructor(); + this.changes.forEach(value => { + copy.setChange(value.key, value.states.after, value.states.before); + }); + copy.stateComparator = this.stateComparator; + return copy; + } + /** + * Clears the change system + * @method + */ + clear() { + this.changes.clear(); + } + /** + * Adds or updates data modification. Coordinates are floored to integers. + * + * @method + * @param key - key of data to set change for + * @param after - The state + * @param before - Original state (used only on first add). + * @returns States of change for the specfied data if still exists, null otherwise + */ + setChange(key, after, before) { + let existing = this.changes.get(key); + if (!existing) { + if (!this.stateComparator(before, after)) { + this.changes.set(key, { + key: key, + states: { + after, + before, + } + }); + } + return this.getChange(key); + } + else { + existing.states.after = after; + if (this.stateComparator(existing.states.before, existing.states.after)) + this.changes.delete(key); + return this.getChange(key); + } + } + /** + * returns an object containing the before and after states if data has been modified, null otherwise. + * @method + * @param key - key of the data to get change for + * @returns ChangeState if data has been modified, null otherwise + */ + getChange(key) { + const change = this.changes.get(key); + if (!change) + return null; + return { before: change.states.before, after: change.states.after }; + } + /** + * Returns whether the change system is empty. + * @method + * @returns {boolean} + */ + get isEmpty() { + return this.changes.size === 0; + } + /** + * Gets keys of the changes. + * @method + * @returns An array containing keys for all changed data + */ + get keys() { + return Array.from(this.changes.values()) + .map((cd) => cd.key); + } + /** + * Gets before and after states of the changes. + * @method + * @returns An array containing states for all changed data + */ + get states() { + return Array.from(this.changes.values()) + .map((cd) => cd.states); + } + /** + * Gets an iterator of changes before and after states. + * @method + * @returns An iterator containing changed data and its states for all changed data + */ + [Symbol.iterator]() { + return this.changes.values(); + } + /** + * Returns number of changes + * @method + * @returns The number of changes + */ + get count() { + return this.changes.size; + } +} diff --git a/dist/generics/history-system.js b/dist/generics/history-system.js new file mode 100644 index 0000000..b8bb4e2 --- /dev/null +++ b/dist/generics/history-system.js @@ -0,0 +1,247 @@ +import { validateNumber } from "../utils/validation.js"; +/** + * Represents a circular buffer-based history system for undo/redo operations. + * Tracks records containing arbitrary data. + * + * Key Features: + * - Fixed-capacity circular buffer (1-64 records) + * - Atomic recording + * - Reference copy data storage + * - Undo/redo functionality + * - Record metadata (IDs/data) + * + * @example + * const history = new History<{x:number,y:number,color:string}>(10); + * history.addRecord("Paint", {x:1, y:2, color:"#000000"}); + * history.addRecordData({x: 1, y: 2, color: "#FF0000"}); + * history.undo(); // Reverts to previous state + * + * @class + */ +export default class HistorySystem { + /** + * Internal circular buffer storing records + */ + buffer; + /** + * The index of the current selected record + */ + currentIndex = 0; + /** + * The index of the oldest saved record in the history system + */ + startIndex = 0; + /** + * The index of the last saved record in the history system + */ + endIndex = 0; + /** + * Internal counter to enumerate increamental IDs for the created records + */ + recordIDCounter = 0; + /** + * Creates a new History with specified capacity + * @constructor + * @param capacity - Maximum stored records (1-64) + * @param initialData - The data for the initial record in the history + * @throws {TypeError} If capacity is not an integer + * @throws {RangeError} If capacity is outside 1-64 range + */ + constructor(capacity, initialData) { + validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); + capacity = Math.floor(capacity); + this.buffer = new Array(capacity); + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + /** + * Adds a new record if at the end of history or replaces the current record while removing future of replaced record + * @method + * @param data - Data to store + * @param keepFuture - Determines if future records should be kept + */ + setRecord(data, keepFuture = false) { + let atEnd = this.currentIndex === this.endIndex; + if (this.count === this.capacity) + this.startIndex = this.normalizedIndex(this.startIndex + 1); + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + if (!keepFuture || atEnd) + this.endIndex = this.currentIndex; + this.buffer[this.currentIndex] = { + id: ++this.recordIDCounter, + data: data, + }; + } + /** + * Sets data to the current record its reference (no copy is made) + * @method + * @param data - Data to store + * @throws {Error} If no active record exists + * @example + * Stores a copy of the object + * history.addRecordData({x: 1, y: 2}); + */ + setRecordData(data) { + if (this.currentIndex === -1) { + throw new Error("No record to add to."); + } + this.buffer[this.currentIndex].data = data; + } + normalizedIndex(index) { + return (index + this.capacity) % this.capacity; + } + /** + * Gets the record at an offset from current position + * @private + * @param [offset=0] - Offset from current position + * @returns Record at specified offset, if offset crossed the boundaries, returns boundary records + */ + getRecord(offset = 0) { + validateNumber(offset, "Offset", { integerOnly: true }); + if (offset > 0) { // go right -> end + let boundary = this.normalizedIndex(this.endIndex - this.currentIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex + offset)]; + } + else if (offset < 0) { // go left -> start + offset *= -1; + let boundary = this.normalizedIndex(this.currentIndex - this.startIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex - offset)]; + } + else { + return this.buffer[this.currentIndex]; + } + } + /** + * Retrieves record ID at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which ID gets returned + * @returns The record ID at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordID(offset = 0) { + return this.getRecord(offset).id; + } + /** + * Retrieves record data at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which data gets returned + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordData(offset = 0) { + return this.getRecord(offset).data; + } + /** + * Retrieves the current offset of the record from start of the history + * @method + * @returns The record offset from start to end + */ + getRecordOffset() { + return this.currentIndex - this.endIndex; + } + /** + * Moves backward in history (undo), does nothing if already at start + * @method + * @returns The record data at the new ID and its offset from start + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + undo() { + if (this.currentIndex !== this.startIndex) + this.currentIndex = this.normalizedIndex(this.currentIndex - 1); + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + /** + * Moves forward in history (redo), does nothing if already at end or history is empty + * @method + * @returns The record data at the new ID and its offset from start + */ + redo() { + if (this.currentIndex !== this.startIndex) { + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + } + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + /** + * Moves to a record with a specific ID + * @method + * @returns Whether the record was found + */ + jumpToRecord(id) { + const index = this.buffer.findIndex(r => r.id === id); + if (index >= 0) + this.currentIndex = index; + return index >= 0; + } + /** + * Resets the history with data for the initial record + * @method + * @param initialData - Data of the initial record + */ + reset(initialData) { + this.buffer = new Array(this.capacity); + this.currentIndex = 0; + this.startIndex = 0; + this.endIndex = 0; + this.recordIDCounter = 0; + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + /** + * Returns an iterator to the history + * @method + * @yeilds the stored history records + * @returns Iterator to the history + */ + *[Symbol.iterator]() { + let index = this.startIndex; + while (index !== this.endIndex) { + yield this.buffer[index]; + index = this.normalizedIndex(index + 1); + } + yield this.buffer[index]; + } + /** + * Current number of stored records + * @method + * @returns Number of stored records + */ + get count() { + return this.normalizedIndex(this.endIndex - this.startIndex) + 1; + } + /** + * Maximum number of storable records + * @method + * @returns Maximum number of storable records + */ + get capacity() { + return this.buffer.length; + } + /** + * Returns true if current index is at the end + * @method + * @returns Whether index is at end + */ + get atEnd() { + return this.currentIndex === this.endIndex; + } + /** + * Returns true if current index is at the start + * @method + * @returns Whether current index is at start + */ + get atStart() { + return this.currentIndex === this.startIndex; + } +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..824bd11 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,115 @@ +import PixelEditor from "./core/pixel-editor.js"; +import Color from "./services/color.js"; +console.log(" Hello! "); +const containerElement = document.querySelector("#canvas-container"); +const paletteContainer = document.querySelector(".palette-container"); +const board = new PixelEditor(containerElement, 63, 63); +board.render(); +const colorMap = new Map(); +let selectedColors = [Color.get({ hex: "#ff0000" }), Color.get({ hex: "#00ff00" })]; +// Fill the color palette with random shit +for (let i = 0; i < 10; i++) { + let colorHex = ""; + for (let j = 0; j < 6; j++) { + const rand = Math.floor(Math.random() * 16); + if (rand <= 9) + colorHex += String(rand); + else + colorHex += String.fromCharCode('a'.charCodeAt(0) + rand - 10); + } + const color = Color.get({ hex: `#${colorHex}` }); + const elem = createColorElement(color); + colorMap.set(elem, color); +} +function createColorElement(color) { + let element = document.createElement("div"); + element.classList.add("color"); + element.classList.add("btn"); + element.style.backgroundColor = color.hex; + paletteContainer.appendChild(element); + return element; +} +// Click on any color on the palette +paletteContainer.addEventListener("click", (event) => { + const element = event.target; + if (!element.classList.contains("color") || element.classList.contains("add-color")) + return; + document.querySelector(".color-index.selected") + .style.backgroundColor = colorMap.get(element).hex; + board.toolManager.drawingColor = colorMap.get(element); +}); +// Click on index colors +document.querySelectorAll(".color-index").forEach((elm, index) => { + elm.addEventListener("click", () => { + if (elm.classList.contains("selected")) + return; + document.querySelectorAll(".color-index").forEach((e) => { + e.classList.toggle("selected"); + }); + board.toolManager.drawingColor = selectedColors[index]; + }); +}); +document + .getElementsByClassName("swap-colors")[0] + .addEventListener("click", () => { + const colorElements = Array.from(document.querySelectorAll(".color-index")); + if (!colorElements[0].classList.contains(".primary")) + colorElements.reverse(); + colorElements[0].classList.toggle("primary"); + colorElements[1].classList.toggle("primary"); + colorElements[0].classList.toggle("selected"); + colorElements[1].classList.toggle("selected"); + selectedColors = [selectedColors[1], selectedColors[0]]; + board.toolManager.drawingColor = selectedColors[0]; +}); +document + .getElementsByClassName("reset-colors")[0] + .addEventListener("click", () => { + const colorElements = Array.from(document.querySelectorAll(".color-index")); + colorElements[0].classList.add("primary"); + colorElements[0].classList.add("selected"); + colorElements[1].classList.remove("primary"); + colorElements[1].classList.remove("selected"); + board.toolManager.drawingColor = selectedColors[0]; +}); +const toolsElem = document.getElementsByClassName("tools")[0]; +// function downloadCanvasAsPNG() { +// const canvas: S = document.getElementById("canvas"); +// const link = document.createElement("a"); +// link.download = "pixel-art.png"; +// link.href = canvas.toDataURL("image/png"); +// link.click(); +// } +// +// document.getElementById("download-png").addEventListener("click", () => { +// drawToCanvas(canvas.colorsMatrix); +// downloadCanvasAsPNG(); +// }); +// +// document.getElementById("undo").addEventListener("click", () => { +// board.undo(); +// }); +// +// document.getElementById("redo").addEventListener("click", () => { +// board.redo(); +// }); +// +// for (let elm of toolsElem.children) { +// //if (elm.classList[0] === "color-picker") +// // elm.addEventListener("click", () => { +// // let eyeDropper = new EyeDropper(); +// // try { +// // let pickedColor = await eyeDropper.open(); +// // primaryColorSelector.style.background = pickedColor.sRGBHex; +// // } catch (error) { +// // console.log("error"); +// // } +// // console.log(elm.classList[0]); +// // }); +// //else +// elm.addEventListener("click", () => { +// console.log(elm.classList[0]); +// board.toolManager.toolName = elm.classList[0]; +// }); +// } +// /* "dev": "vite", "build": "vite build", */ diff --git a/dist/interfaces/drawable.js b/dist/interfaces/drawable.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/interfaces/drawable.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/interfaces/historyable.js b/dist/interfaces/historyable.js new file mode 100644 index 0000000..95da36c --- /dev/null +++ b/dist/interfaces/historyable.js @@ -0,0 +1,2 @@ +; +export {}; diff --git a/dist/services/color-service.js b/dist/services/color-service.js new file mode 100644 index 0000000..e32e484 --- /dev/null +++ b/dist/services/color-service.js @@ -0,0 +1,430 @@ +import { validateNumber } from "../utils/validation.js"; +var ColorSpace; +(function (ColorSpace) { + ColorSpace[ColorSpace["rgb"] = 0] = "rgb"; + ColorSpace[ColorSpace["hsl"] = 1] = "hsl"; +})(ColorSpace || (ColorSpace = {})); +const COLOR_KEY = Symbol('ColorKey'); +/** + * Represents an immutable color with support for multiple color spaces. + * All instances are cached and reused when possible. + * @class + * @global + */ +class Color { + /** + * Internal cache of color instances to prevent duplicates. + * Keys are long-version hex strings. (ex. '#11223344') + * @type {Map} + * @private + */ + static cachedColors = new Map(); + /** + * holds data of the color + * @type {ColorData} + * @private + */ + data = { + rgb: [0, 0, 0], + hsl: [0, 0, 0], + hex: '#000000', + alpha: 1 + }; + /** + * Private constructor (use Color.create() instead). + * @param {ColorData} colorData - holds the rgb, hsl, hex and alpha values of the color + * @param {symbol} key - Private key to prevent direct instantiation + * @private + * @throws {TypeError} if used directly (use Color.create() instead) + */ + constructor(colorData, key) { + if (key !== COLOR_KEY) { // Must not be used by the user + throw new TypeError("Use Color.create() instead of new Color()"); + } + this.data.rgb = colorData.rgb; + this.data.hsl = colorData.hsl; + this.data.hex = colorData.hex; + this.data.alpha = colorData.alpha; + Object.freeze(this); + } + // ==================== + // Public API Methods + // ==================== + // ==================== + // Getters + // ==================== + /** @returns {ColorVector} RGB values [r, g, b] (0-255) */ + get rgb() { return [...this.data.rgb]; } + /** @returns {ColorVector} HSL values [h, s, l] (h: 0-360, s/l: 0-100) */ + get hsl() { return [...this.data.hsl]; } + /** @returns {string} Hex color string */ + get hex() { return this.data.hex; } + /** @returns {number} Alpha value (0.0-1.0) */ + get alpha() { return this.data.alpha; } + /** @returns {string} Hex representation */ + toString() { return this.data.hex; } + // ==================== + // Static Methods + // ==================== + /** + * Creates a Color instance from various formats, or returns cached instance. + * @method + * @static + * @param {Object} params - Configuration object + * @param {ColorVector} [params.rgb] - RGB values (0-255) + * @param {ColorVector} [params.hsl] - HSL values (h:0-360, s/l:0-100) + * @param {string} [params.hex] - Hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) + * @param {number} [params.alpha=1] - Alpha value (0.0-1.0) + * @returns {Color} Color instance + * @throws {RangeError} If values are out of bounds + */ + static get(params) { + const alpha = params.alpha ?? 1; + let key, finalRGB = [0, 0, 0], finalHSL = [0, 0, 0], finalHEX, finalAlpha; + if ('rgb' in params) { + validateRGB(params.rgb); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.rgb.forEach((v, i) => finalRGB[i] = Math.round(v)); + key = finalHEX = toHex(finalRGB, alpha); + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalHSL = rgbToHsl(finalRGB); + finalAlpha = alpha; + } + else if ('hsl' in params) { + validateHSL(params.hsl); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.hsl.forEach((v, i) => finalHSL[i] = Math.round(v)); + finalRGB = hslToRgb(finalHSL); + key = finalHEX = toHex(finalRGB, alpha); + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalAlpha = alpha; + } + else { + const parsed = parseHex(params.hex); + finalHEX = toHex(parsed.rgb, parsed.alpha); + key = finalHEX; + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalRGB = parsed.rgb; + finalHSL = rgbToHsl(finalRGB); + finalAlpha = parsed.alpha; + } + const color = new Color({ + rgb: finalRGB, + hsl: finalHSL, + hex: finalHEX, + alpha: finalAlpha + }, COLOR_KEY); + Color.cachedColors.set(key, color); + return color; + } + /** + * Mixes two colors with optional weighting and color space + * @method + * @param color1 - The first color to mix with + * @param color2 - The second color to mix with + * @param [weight=0.5] - The mixing ratio (0-1) + * @param [mode=ColorSpace.rgb] - The blending mode + * @returns The resulting new mixed color + */ + static mix(color1, color2, weight = 0.5, mode = ColorSpace.rgb) { + weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 + const newAlpha = color1.data.alpha + (color2.data.alpha - color1.data.alpha) * weight; + switch (mode) { + case ColorSpace.rgb: + const [h1, s1, l1] = color1.data.hsl; + const [h2, s2, l2] = color2.data.hsl; + // Hue wrapping + let hueDiff = h2 - h1; + if (Math.abs(hueDiff) > 180) { + hueDiff += hueDiff > 0 ? -360 : 360; + } + return Color.get({ + hsl: [ + (h1 + hueDiff * weight + 360) % 360, + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight, + ], + alpha: newAlpha + }); + case ColorSpace.rgb: + default: + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + return Color.get({ + rgb: [ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight + ], + alpha: newAlpha + }); + } + } + /** + * Composites a color over another + * @method + * @param {Color} topColor - The color to composite over + * @param {Color} bottomColor - The color to be composited over + * @returns {Color} The resulting new composited color + */ + static compositeOver(topColor, bottomColor) { + const [rTop, gTop, bTop, aTop] = [...topColor.data.rgb, topColor.data.alpha]; + const [rBottom, gBottom, bBottom, aBottom] = [...bottomColor.data.rgb, bottomColor.data.alpha]; + const combinedAlpha = aTop + aBottom * (1 - aTop); + if (combinedAlpha === 0) + return Color.TRANSPARENT; + return Color.get({ + rgb: [ + Math.round((rTop * aTop + rBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((gTop * aTop + gBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((bTop * aTop + bBottom * aBottom * (1 - aTop)) / combinedAlpha), + ], + alpha: combinedAlpha + }); + } + /** + * Checks if colors are visually similar within tolerance + * @method + * @param color1 - The first color to compare + * @param color2 - The second color to compare + * @param [tolerance=5] - The allowed maximum perceptual distance (0-442) + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns Whether the two colors are visually similar within the given tolerance + */ + static isSimilarTo(color1, color2, tolerance = 5, includeAlpha = true) { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + // Calculate RGB Euclidean distance (0-442 scale) + const rDiff = Math.abs(r1 - r2); + const gDiff = Math.abs(g1 - g2); + const bDiff = Math.abs(b1 - b2); + const rgbDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); + // Calculate alpha difference (0-255 scale) + const alphaDifference = Math.abs(a1 - a2) * 255; + return rgbDistance <= tolerance && (!includeAlpha || alphaDifference <= tolerance); + } + /** + * Checks exact color equality (with optional alpha) + * @method + * @param color1 - the first color to compare with + * @param color2 - the second color to compare with + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns {boolean} Whether the two colors are equal + * @throws {TypeError} If color is an not instance of Color class + */ + static isEqualTo(color1, color2, includeAlpha = true) { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + const rgbEqual = (r1 === r2 && + g1 === g2 && + b1 === b2); + const alphaEqual = !includeAlpha || (Math.round(a1 * 255) === Math.round(a2 * 255)); + return rgbEqual && alphaEqual; + } + /** + * Creates a new color with modified RGB values + * @param {ColorVector} [rgb=this.data.rgb] - RGB values + * @returns {Color} New color instance + */ + withRGB(rgb = [...this.data.rgb]) { + return Color.get({ rgb: rgb, alpha: this.data.alpha }); + } + /** + * Creates a new color with modified HSL values + * @param {ColorVector} [hsl=this.data.rgb] - HSL values + * @returns {Color} New color instance + */ + withHSL(hsl = [...this.data.hsl]) { + return Color.get({ hsl: hsl, alpha: this.data.alpha }); + } + /** + * Creates a new color with modified alpha + * @param {number} alpha - Alpha value (0.0-1.0) + * @returns {Color} New color instance + * @throws {RangeError} If alpha is out of bounds + */ + withAlpha(alpha) { + return Color.get({ rgb: this.data.rgb, alpha }); + } + /** + * Predefined transparent color instance. + * @type {Color} + * @static + */ + static TRANSPARENT = this.get({ rgb: [0, 0, 0], alpha: 0 }); + /** + * Clears the color cache, forcing new instances to be created + * @static + */ + static clearCache() { + this.cachedColors.clear(); + this.TRANSPARENT = this.get({ rgb: [0, 0, 0], alpha: 0 }); + } + /** + * Gets the current size of the color cache (for testing/debugging) + * @static + * @returns {number} Number of cached colors + */ + static get cacheSize() { + return this.cachedColors.size; + } +} +/** + * Converts RGB to HSL color space. + * @param {ColorVector} rgb - Red (0-255), Green (0-255), Blue (0-255) + * @returns {ColorVector} HSL values + * @private + */ +function rgbToHsl(rgb) { + let [r, g, b] = rgb; + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { + h = s = 0; + } + else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h *= 60; + } + return [ + Math.round(h * 100) / 100, + Math.round(s * 10000) / 100, + Math.round(l * 10000) / 100 + ]; +} +/** + * Converts HSL to RGB color space. + * @param {ColorVector} hsl - Hue (0-360), Saturation (0-100), Lightness (0-100) + * @returns {ColorVector} RGB values + * @private + */ +function hslToRgb(hsl) { + let [h, s, l] = hsl; + h = h % 360 / 360; + s = Math.min(100, Math.max(0, s)) / 100; + l = Math.min(100, Math.max(0, l)) / 100; + let r, g, b; + if (s === 0) { + r = g = b = l * 255; + } + else { + const hue2rgb = (p, q, t) => { + if (t < 0) + t += 1; + if (t > 1) + t -= 1; + if (t < 1 / 6) + return p + (q - p) * 6 * t; + if (t < 1 / 2) + return q; + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + g = Math.round(hue2rgb(p, q, h) * 255); + b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); + } + return [r, g, b]; +} +/** + * Converts RGB+alpha to hex string. + * @param {RGBColor} rgb - RGB values + * @param {number} alpha - Alpha value + * @returns {string} Hex color string + * @private + */ +function toHex(rgb, alpha) { + const components = [ + Math.round(rgb[0]), + Math.round(rgb[1]), + Math.round(rgb[2]), + Math.round(alpha * 255) + ]; + return `#${components + .map(c => c.toString(16).padStart(2, '0')) + .join('') + .replace(/ff$/, '')}`; // Remove alpha if fully opaque +} +/** + * Validates RGB array. + * @param {ColorVector} rgb - RGB values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateRGB(rgb) { + if (!Array.isArray(rgb) || rgb.length !== 3) { + throw new TypeError(`RGB must be an array of 3 numbers`); + } + validateNumber(rgb[0], "Red component", { start: 0, end: 255 }); + validateNumber(rgb[1], "Green component", { start: 0, end: 255 }); + validateNumber(rgb[2], "Blue component", { start: 0, end: 255 }); +} +/** + * Validates HSL array. + * @param {ColorVector} hsl - HSL values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateHSL(hsl) { + if (!Array.isArray(hsl) || hsl.length !== 3) { + throw new TypeError(`HSL must be an array of 3 numbers`); + } + validateNumber(hsl[0], "Hue"); + validateNumber(hsl[1], "Saturation", { start: 0, end: 100 }); + validateNumber(hsl[2], "Lightness", { start: 0, end: 100 }); +} +/** + * Parses hex color string. + * @param {string} hex - Hex color string + * @returns {{rgb: ColorVector, alpha: number}} Parsed values + * @throws {TypeError} If invalid format + * @private + */ +function parseHex(hex) { + if (!/^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i.test(hex)) { + throw new TypeError(`Invalid hex color format: ${hex}`); + } + let hexDigits = hex.slice(1); + // Expand shorthand (#RGB or #RGBA) + if (hexDigits.length <= 4) { + hexDigits = hexDigits.split('').map(c => c + c).join(''); + } + // Parse RGB components + const rgb = [ + parseInt(hexDigits.substring(0, 2), 16), + parseInt(hexDigits.substring(2, 4), 16), + parseInt(hexDigits.substring(4, 6), 16) + ]; + // Parse alpha (default to 1 if not present) + const alpha = hexDigits.length >= 8 + ? parseInt(hexDigits.substring(6, 8), 16) / 255 + : 1; + return { rgb, alpha }; +} +export default Color; diff --git a/dist/services/color.js b/dist/services/color.js new file mode 100644 index 0000000..e32e484 --- /dev/null +++ b/dist/services/color.js @@ -0,0 +1,430 @@ +import { validateNumber } from "../utils/validation.js"; +var ColorSpace; +(function (ColorSpace) { + ColorSpace[ColorSpace["rgb"] = 0] = "rgb"; + ColorSpace[ColorSpace["hsl"] = 1] = "hsl"; +})(ColorSpace || (ColorSpace = {})); +const COLOR_KEY = Symbol('ColorKey'); +/** + * Represents an immutable color with support for multiple color spaces. + * All instances are cached and reused when possible. + * @class + * @global + */ +class Color { + /** + * Internal cache of color instances to prevent duplicates. + * Keys are long-version hex strings. (ex. '#11223344') + * @type {Map} + * @private + */ + static cachedColors = new Map(); + /** + * holds data of the color + * @type {ColorData} + * @private + */ + data = { + rgb: [0, 0, 0], + hsl: [0, 0, 0], + hex: '#000000', + alpha: 1 + }; + /** + * Private constructor (use Color.create() instead). + * @param {ColorData} colorData - holds the rgb, hsl, hex and alpha values of the color + * @param {symbol} key - Private key to prevent direct instantiation + * @private + * @throws {TypeError} if used directly (use Color.create() instead) + */ + constructor(colorData, key) { + if (key !== COLOR_KEY) { // Must not be used by the user + throw new TypeError("Use Color.create() instead of new Color()"); + } + this.data.rgb = colorData.rgb; + this.data.hsl = colorData.hsl; + this.data.hex = colorData.hex; + this.data.alpha = colorData.alpha; + Object.freeze(this); + } + // ==================== + // Public API Methods + // ==================== + // ==================== + // Getters + // ==================== + /** @returns {ColorVector} RGB values [r, g, b] (0-255) */ + get rgb() { return [...this.data.rgb]; } + /** @returns {ColorVector} HSL values [h, s, l] (h: 0-360, s/l: 0-100) */ + get hsl() { return [...this.data.hsl]; } + /** @returns {string} Hex color string */ + get hex() { return this.data.hex; } + /** @returns {number} Alpha value (0.0-1.0) */ + get alpha() { return this.data.alpha; } + /** @returns {string} Hex representation */ + toString() { return this.data.hex; } + // ==================== + // Static Methods + // ==================== + /** + * Creates a Color instance from various formats, or returns cached instance. + * @method + * @static + * @param {Object} params - Configuration object + * @param {ColorVector} [params.rgb] - RGB values (0-255) + * @param {ColorVector} [params.hsl] - HSL values (h:0-360, s/l:0-100) + * @param {string} [params.hex] - Hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) + * @param {number} [params.alpha=1] - Alpha value (0.0-1.0) + * @returns {Color} Color instance + * @throws {RangeError} If values are out of bounds + */ + static get(params) { + const alpha = params.alpha ?? 1; + let key, finalRGB = [0, 0, 0], finalHSL = [0, 0, 0], finalHEX, finalAlpha; + if ('rgb' in params) { + validateRGB(params.rgb); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.rgb.forEach((v, i) => finalRGB[i] = Math.round(v)); + key = finalHEX = toHex(finalRGB, alpha); + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalHSL = rgbToHsl(finalRGB); + finalAlpha = alpha; + } + else if ('hsl' in params) { + validateHSL(params.hsl); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.hsl.forEach((v, i) => finalHSL[i] = Math.round(v)); + finalRGB = hslToRgb(finalHSL); + key = finalHEX = toHex(finalRGB, alpha); + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalAlpha = alpha; + } + else { + const parsed = parseHex(params.hex); + finalHEX = toHex(parsed.rgb, parsed.alpha); + key = finalHEX; + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + finalRGB = parsed.rgb; + finalHSL = rgbToHsl(finalRGB); + finalAlpha = parsed.alpha; + } + const color = new Color({ + rgb: finalRGB, + hsl: finalHSL, + hex: finalHEX, + alpha: finalAlpha + }, COLOR_KEY); + Color.cachedColors.set(key, color); + return color; + } + /** + * Mixes two colors with optional weighting and color space + * @method + * @param color1 - The first color to mix with + * @param color2 - The second color to mix with + * @param [weight=0.5] - The mixing ratio (0-1) + * @param [mode=ColorSpace.rgb] - The blending mode + * @returns The resulting new mixed color + */ + static mix(color1, color2, weight = 0.5, mode = ColorSpace.rgb) { + weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 + const newAlpha = color1.data.alpha + (color2.data.alpha - color1.data.alpha) * weight; + switch (mode) { + case ColorSpace.rgb: + const [h1, s1, l1] = color1.data.hsl; + const [h2, s2, l2] = color2.data.hsl; + // Hue wrapping + let hueDiff = h2 - h1; + if (Math.abs(hueDiff) > 180) { + hueDiff += hueDiff > 0 ? -360 : 360; + } + return Color.get({ + hsl: [ + (h1 + hueDiff * weight + 360) % 360, + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight, + ], + alpha: newAlpha + }); + case ColorSpace.rgb: + default: + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + return Color.get({ + rgb: [ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight + ], + alpha: newAlpha + }); + } + } + /** + * Composites a color over another + * @method + * @param {Color} topColor - The color to composite over + * @param {Color} bottomColor - The color to be composited over + * @returns {Color} The resulting new composited color + */ + static compositeOver(topColor, bottomColor) { + const [rTop, gTop, bTop, aTop] = [...topColor.data.rgb, topColor.data.alpha]; + const [rBottom, gBottom, bBottom, aBottom] = [...bottomColor.data.rgb, bottomColor.data.alpha]; + const combinedAlpha = aTop + aBottom * (1 - aTop); + if (combinedAlpha === 0) + return Color.TRANSPARENT; + return Color.get({ + rgb: [ + Math.round((rTop * aTop + rBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((gTop * aTop + gBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((bTop * aTop + bBottom * aBottom * (1 - aTop)) / combinedAlpha), + ], + alpha: combinedAlpha + }); + } + /** + * Checks if colors are visually similar within tolerance + * @method + * @param color1 - The first color to compare + * @param color2 - The second color to compare + * @param [tolerance=5] - The allowed maximum perceptual distance (0-442) + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns Whether the two colors are visually similar within the given tolerance + */ + static isSimilarTo(color1, color2, tolerance = 5, includeAlpha = true) { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + // Calculate RGB Euclidean distance (0-442 scale) + const rDiff = Math.abs(r1 - r2); + const gDiff = Math.abs(g1 - g2); + const bDiff = Math.abs(b1 - b2); + const rgbDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); + // Calculate alpha difference (0-255 scale) + const alphaDifference = Math.abs(a1 - a2) * 255; + return rgbDistance <= tolerance && (!includeAlpha || alphaDifference <= tolerance); + } + /** + * Checks exact color equality (with optional alpha) + * @method + * @param color1 - the first color to compare with + * @param color2 - the second color to compare with + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns {boolean} Whether the two colors are equal + * @throws {TypeError} If color is an not instance of Color class + */ + static isEqualTo(color1, color2, includeAlpha = true) { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + const rgbEqual = (r1 === r2 && + g1 === g2 && + b1 === b2); + const alphaEqual = !includeAlpha || (Math.round(a1 * 255) === Math.round(a2 * 255)); + return rgbEqual && alphaEqual; + } + /** + * Creates a new color with modified RGB values + * @param {ColorVector} [rgb=this.data.rgb] - RGB values + * @returns {Color} New color instance + */ + withRGB(rgb = [...this.data.rgb]) { + return Color.get({ rgb: rgb, alpha: this.data.alpha }); + } + /** + * Creates a new color with modified HSL values + * @param {ColorVector} [hsl=this.data.rgb] - HSL values + * @returns {Color} New color instance + */ + withHSL(hsl = [...this.data.hsl]) { + return Color.get({ hsl: hsl, alpha: this.data.alpha }); + } + /** + * Creates a new color with modified alpha + * @param {number} alpha - Alpha value (0.0-1.0) + * @returns {Color} New color instance + * @throws {RangeError} If alpha is out of bounds + */ + withAlpha(alpha) { + return Color.get({ rgb: this.data.rgb, alpha }); + } + /** + * Predefined transparent color instance. + * @type {Color} + * @static + */ + static TRANSPARENT = this.get({ rgb: [0, 0, 0], alpha: 0 }); + /** + * Clears the color cache, forcing new instances to be created + * @static + */ + static clearCache() { + this.cachedColors.clear(); + this.TRANSPARENT = this.get({ rgb: [0, 0, 0], alpha: 0 }); + } + /** + * Gets the current size of the color cache (for testing/debugging) + * @static + * @returns {number} Number of cached colors + */ + static get cacheSize() { + return this.cachedColors.size; + } +} +/** + * Converts RGB to HSL color space. + * @param {ColorVector} rgb - Red (0-255), Green (0-255), Blue (0-255) + * @returns {ColorVector} HSL values + * @private + */ +function rgbToHsl(rgb) { + let [r, g, b] = rgb; + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { + h = s = 0; + } + else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h *= 60; + } + return [ + Math.round(h * 100) / 100, + Math.round(s * 10000) / 100, + Math.round(l * 10000) / 100 + ]; +} +/** + * Converts HSL to RGB color space. + * @param {ColorVector} hsl - Hue (0-360), Saturation (0-100), Lightness (0-100) + * @returns {ColorVector} RGB values + * @private + */ +function hslToRgb(hsl) { + let [h, s, l] = hsl; + h = h % 360 / 360; + s = Math.min(100, Math.max(0, s)) / 100; + l = Math.min(100, Math.max(0, l)) / 100; + let r, g, b; + if (s === 0) { + r = g = b = l * 255; + } + else { + const hue2rgb = (p, q, t) => { + if (t < 0) + t += 1; + if (t > 1) + t -= 1; + if (t < 1 / 6) + return p + (q - p) * 6 * t; + if (t < 1 / 2) + return q; + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + g = Math.round(hue2rgb(p, q, h) * 255); + b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); + } + return [r, g, b]; +} +/** + * Converts RGB+alpha to hex string. + * @param {RGBColor} rgb - RGB values + * @param {number} alpha - Alpha value + * @returns {string} Hex color string + * @private + */ +function toHex(rgb, alpha) { + const components = [ + Math.round(rgb[0]), + Math.round(rgb[1]), + Math.round(rgb[2]), + Math.round(alpha * 255) + ]; + return `#${components + .map(c => c.toString(16).padStart(2, '0')) + .join('') + .replace(/ff$/, '')}`; // Remove alpha if fully opaque +} +/** + * Validates RGB array. + * @param {ColorVector} rgb - RGB values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateRGB(rgb) { + if (!Array.isArray(rgb) || rgb.length !== 3) { + throw new TypeError(`RGB must be an array of 3 numbers`); + } + validateNumber(rgb[0], "Red component", { start: 0, end: 255 }); + validateNumber(rgb[1], "Green component", { start: 0, end: 255 }); + validateNumber(rgb[2], "Blue component", { start: 0, end: 255 }); +} +/** + * Validates HSL array. + * @param {ColorVector} hsl - HSL values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateHSL(hsl) { + if (!Array.isArray(hsl) || hsl.length !== 3) { + throw new TypeError(`HSL must be an array of 3 numbers`); + } + validateNumber(hsl[0], "Hue"); + validateNumber(hsl[1], "Saturation", { start: 0, end: 100 }); + validateNumber(hsl[2], "Lightness", { start: 0, end: 100 }); +} +/** + * Parses hex color string. + * @param {string} hex - Hex color string + * @returns {{rgb: ColorVector, alpha: number}} Parsed values + * @throws {TypeError} If invalid format + * @private + */ +function parseHex(hex) { + if (!/^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i.test(hex)) { + throw new TypeError(`Invalid hex color format: ${hex}`); + } + let hexDigits = hex.slice(1); + // Expand shorthand (#RGB or #RGBA) + if (hexDigits.length <= 4) { + hexDigits = hexDigits.split('').map(c => c + c).join(''); + } + // Parse RGB components + const rgb = [ + parseInt(hexDigits.substring(0, 2), 16), + parseInt(hexDigits.substring(2, 4), 16), + parseInt(hexDigits.substring(4, 6), 16) + ]; + // Parse alpha (default to 1 if not present) + const alpha = hexDigits.length >= 8 + ? parseInt(hexDigits.substring(6, 8), 16) / 255 + : 1; + return { rgb, alpha }; +} +export default Color; diff --git a/dist/services/event-bus.js b/dist/services/event-bus.js new file mode 100644 index 0000000..e4db516 --- /dev/null +++ b/dist/services/event-bus.js @@ -0,0 +1,44 @@ +export default class EventBus { + listeners = new Map(); + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + return () => this.off(event, callback); // Return unsubscribe function + } + off(event, callback) { + const callbacks = this.listeners.get(event); + if (callbacks) { + this.listeners.set(event, callbacks.filter(cb => cb !== callback)); + } + } + emit(event, args) { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach(callback => { + try { + callback(args); + } + catch (err) { + console.error(`Error in ${event} handler:`, err); + } + }); + } + } + once(event, callback) { + const onceWrapper = (args) => { + this.off(event, onceWrapper); + callback(args); + }; + this.on(event, onceWrapper); + } + clear(event) { + if (event) { + this.listeners.delete(event); + } + else { + this.listeners.clear(); + } + } +} diff --git a/dist/services/pixel-change.js b/dist/services/pixel-change.js new file mode 100644 index 0000000..fad5c9e --- /dev/null +++ b/dist/services/pixel-change.js @@ -0,0 +1,61 @@ +import ChangeSystem from "../generics/change-tracker.js"; +import Color from "../services/color.js"; +/** + * Tracks pixel modifications with boundary detection. + * Extends ChangeSystem with pixel-specific optimizations: + * - Automatic bounds calculation for changed areas + * - Color-specific comparison logic + * + * @property {PixelRectangleBounds|null} bounds - Returns the minimal rectangle containing all changes + */ +export default class PixelChanges extends ChangeSystem { + boundaries = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + constructor() { + super((a, b) => Color.isEqualTo(a.color, b.color)); + } + mergeMutable(source) { + super.mergeMutable(source); + this.boundaries = { + x0: Math.min(this.boundaries.x0, source.boundaries.x0), + y0: Math.min(this.boundaries.y0, source.boundaries.y0), + x1: Math.max(this.boundaries.x1, source.boundaries.x1), + y1: Math.max(this.boundaries.y1, source.boundaries.y1), + }; + return this; + } + clone() { + const copy = super.clone(); + copy.boundaries = { ...this.boundaries }; + return copy; + } + clear() { + super.clear(); + this.boundaries = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + } + setChange(key, after, before) { + const p = super.setChange(key, after, before); + if (p !== null) { + this.boundaries.x0 = Math.min(this.boundaries.x0, key.x); + this.boundaries.y0 = Math.min(this.boundaries.y0, key.y); + this.boundaries.x1 = Math.max(this.boundaries.x1, key.x); + this.boundaries.y1 = Math.max(this.boundaries.y1, key.y); + } + return p; + } + get bounds() { + if (this.count === 0) + return null; + else + return { ...this.boundaries }; + } +} diff --git a/dist/systems/change-system.js b/dist/systems/change-system.js new file mode 100644 index 0000000..251be03 --- /dev/null +++ b/dist/systems/change-system.js @@ -0,0 +1,154 @@ +/** + * @class + * Tracks modified data with before/after for history support. + * Maintains both a Map for order and a Set for duplicate checking. + */ +export default class ChangeSystem { + /** + * A table of all changed data and its before and after states to a change record containing the position and before/after states + * @private + */ + changes = new Map(); + /** + * Function for comparing two states. If undefined, uses === comparison. + * @private + */ + stateComparator; + /** + * Creates a ChangeSystem instance. + * @constructor + * @param stateComparator - Function for comparing two states + */ + constructor(stateComparator) { + this.stateComparator = stateComparator ?? ((a, b) => a === b); + } + /** + * Merges another ChangeSystem into this one (mutates this object). + * @method + * @param source - Source ChangeSystem to merge. + * @returns This instance (for chaining) + */ + mergeMutable(source) { + if (!source || source.isEmpty) + return this; + source.changes.forEach((change) => { + this.setChange(change.key, change.states.after, change.states.before); + }); + return this; + } + /** + * Merges another ChangeSystem into a copy of this one, and returns it. + * @method + * @param source - Source change system to merge. + * @returns The result of merging + */ + merge(source) { + const result = this.clone(); + result.mergeMutable(source); + return result; + } + /** + * Creates a shallow copy (states are not deep-cloned). + * @method + * @returns {ChangeSystem} The clone + */ + clone() { + const copy = new this.constructor(); + this.changes.forEach(value => { + copy.setChange(value.key, value.states.after, value.states.before); + }); + copy.stateComparator = this.stateComparator; + return copy; + } + /** + * Clears the change system + * @method + */ + clear() { + this.changes.clear(); + } + /** + * Adds or updates data modification. Coordinates are floored to integers. + * + * @method + * @param key - key of data to set change for + * @param after - The state + * @param before - Original state (used only on first add). + * @returns States of change for the specfied data if still exists, null otherwise + */ + setChange(key, after, before) { + let existing = this.changes.get(key); + if (!existing) { + if (!this.stateComparator(before, after)) { + this.changes.set(key, { + key: key, + states: { + after, + before, + } + }); + } + return this.getChange(key); + } + else { + existing.states.after = after; + if (this.stateComparator(existing.states.before, existing.states.after)) + this.changes.delete(key); + return this.getChange(key); + } + } + /** + * returns an object containing the before and after states if data has been modified, null otherwise. + * @method + * @param key - key of the data to get change for + * @returns ChangeState if data has been modified, null otherwise + */ + getChange(key) { + const change = this.changes.get(key); + if (!change) + return null; + return { before: change.states.before, after: change.states.after }; + } + /** + * Returns whether the change system is empty. + * @method + * @returns {boolean} + */ + get isEmpty() { + return this.changes.size === 0; + } + /** + * Gets keys of the changes. + * @method + * @returns An array containing keys for all changed data + */ + get keys() { + return Array.from(this.changes.values()) + .map((cd) => cd.key); + } + /** + * Gets before and after states of the changes. + * @method + * @returns An array containing states for all changed data + */ + get states() { + return Array.from(this.changes.values()) + .map((cd) => cd.states); + } + /** + * Gets an iterator of changes before and after states. + * @method + * @returns An iterator containing changed data and its states for all changed data + */ + [Symbol.iterator]() { + return this.changes.values(); + } + /** + * Returns number of changes + * @method + * @returns The number of changes + */ + get count() { + return this.changes.size; + } +} diff --git a/dist/systems/history-system.js b/dist/systems/history-system.js new file mode 100644 index 0000000..b8bb4e2 --- /dev/null +++ b/dist/systems/history-system.js @@ -0,0 +1,247 @@ +import { validateNumber } from "../utils/validation.js"; +/** + * Represents a circular buffer-based history system for undo/redo operations. + * Tracks records containing arbitrary data. + * + * Key Features: + * - Fixed-capacity circular buffer (1-64 records) + * - Atomic recording + * - Reference copy data storage + * - Undo/redo functionality + * - Record metadata (IDs/data) + * + * @example + * const history = new History<{x:number,y:number,color:string}>(10); + * history.addRecord("Paint", {x:1, y:2, color:"#000000"}); + * history.addRecordData({x: 1, y: 2, color: "#FF0000"}); + * history.undo(); // Reverts to previous state + * + * @class + */ +export default class HistorySystem { + /** + * Internal circular buffer storing records + */ + buffer; + /** + * The index of the current selected record + */ + currentIndex = 0; + /** + * The index of the oldest saved record in the history system + */ + startIndex = 0; + /** + * The index of the last saved record in the history system + */ + endIndex = 0; + /** + * Internal counter to enumerate increamental IDs for the created records + */ + recordIDCounter = 0; + /** + * Creates a new History with specified capacity + * @constructor + * @param capacity - Maximum stored records (1-64) + * @param initialData - The data for the initial record in the history + * @throws {TypeError} If capacity is not an integer + * @throws {RangeError} If capacity is outside 1-64 range + */ + constructor(capacity, initialData) { + validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); + capacity = Math.floor(capacity); + this.buffer = new Array(capacity); + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + /** + * Adds a new record if at the end of history or replaces the current record while removing future of replaced record + * @method + * @param data - Data to store + * @param keepFuture - Determines if future records should be kept + */ + setRecord(data, keepFuture = false) { + let atEnd = this.currentIndex === this.endIndex; + if (this.count === this.capacity) + this.startIndex = this.normalizedIndex(this.startIndex + 1); + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + if (!keepFuture || atEnd) + this.endIndex = this.currentIndex; + this.buffer[this.currentIndex] = { + id: ++this.recordIDCounter, + data: data, + }; + } + /** + * Sets data to the current record its reference (no copy is made) + * @method + * @param data - Data to store + * @throws {Error} If no active record exists + * @example + * Stores a copy of the object + * history.addRecordData({x: 1, y: 2}); + */ + setRecordData(data) { + if (this.currentIndex === -1) { + throw new Error("No record to add to."); + } + this.buffer[this.currentIndex].data = data; + } + normalizedIndex(index) { + return (index + this.capacity) % this.capacity; + } + /** + * Gets the record at an offset from current position + * @private + * @param [offset=0] - Offset from current position + * @returns Record at specified offset, if offset crossed the boundaries, returns boundary records + */ + getRecord(offset = 0) { + validateNumber(offset, "Offset", { integerOnly: true }); + if (offset > 0) { // go right -> end + let boundary = this.normalizedIndex(this.endIndex - this.currentIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex + offset)]; + } + else if (offset < 0) { // go left -> start + offset *= -1; + let boundary = this.normalizedIndex(this.currentIndex - this.startIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex - offset)]; + } + else { + return this.buffer[this.currentIndex]; + } + } + /** + * Retrieves record ID at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which ID gets returned + * @returns The record ID at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordID(offset = 0) { + return this.getRecord(offset).id; + } + /** + * Retrieves record data at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which data gets returned + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordData(offset = 0) { + return this.getRecord(offset).data; + } + /** + * Retrieves the current offset of the record from start of the history + * @method + * @returns The record offset from start to end + */ + getRecordOffset() { + return this.currentIndex - this.endIndex; + } + /** + * Moves backward in history (undo), does nothing if already at start + * @method + * @returns The record data at the new ID and its offset from start + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + undo() { + if (this.currentIndex !== this.startIndex) + this.currentIndex = this.normalizedIndex(this.currentIndex - 1); + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + /** + * Moves forward in history (redo), does nothing if already at end or history is empty + * @method + * @returns The record data at the new ID and its offset from start + */ + redo() { + if (this.currentIndex !== this.startIndex) { + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + } + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + /** + * Moves to a record with a specific ID + * @method + * @returns Whether the record was found + */ + jumpToRecord(id) { + const index = this.buffer.findIndex(r => r.id === id); + if (index >= 0) + this.currentIndex = index; + return index >= 0; + } + /** + * Resets the history with data for the initial record + * @method + * @param initialData - Data of the initial record + */ + reset(initialData) { + this.buffer = new Array(this.capacity); + this.currentIndex = 0; + this.startIndex = 0; + this.endIndex = 0; + this.recordIDCounter = 0; + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + /** + * Returns an iterator to the history + * @method + * @yeilds the stored history records + * @returns Iterator to the history + */ + *[Symbol.iterator]() { + let index = this.startIndex; + while (index !== this.endIndex) { + yield this.buffer[index]; + index = this.normalizedIndex(index + 1); + } + yield this.buffer[index]; + } + /** + * Current number of stored records + * @method + * @returns Number of stored records + */ + get count() { + return this.normalizedIndex(this.endIndex - this.startIndex) + 1; + } + /** + * Maximum number of storable records + * @method + * @returns Maximum number of storable records + */ + get capacity() { + return this.buffer.length; + } + /** + * Returns true if current index is at the end + * @method + * @returns Whether index is at end + */ + get atEnd() { + return this.currentIndex === this.endIndex; + } + /** + * Returns true if current index is at the start + * @method + * @returns Whether current index is at start + */ + get atStart() { + return this.currentIndex === this.startIndex; + } +} diff --git a/dist/ui/canvas.js b/dist/ui/canvas.js new file mode 100644 index 0000000..06513b9 --- /dev/null +++ b/dist/ui/canvas.js @@ -0,0 +1,318 @@ +import { validateNumber } from "../utils/validation.js"; +/** + * Responsible for managing the canvas element inside its container + * @class + */ +export default class Canvas { + containerElement; + canvasElement; + canvasContext; + normalScale = 1; // the inital scale applied on the canvas to fit in the containerElement + minScale = 1; + maxScale = 1; + scale = 1; // the scale applied by the user on the canvas + recentPixelPos = { x: -1, y: -1 }; + //#isDragging = false; + // private doubleTapThreshold: number = 300; // Time in milliseconds to consider as double tap + // private tripleTapThreshold: number = 600; // Time in milliseconds to consider as triple tap + // + // private lastTouchTime: number = 0; + // private touchCount: number = 0; + // + // private startX: number = 0; + // private startY: number = 0; + // private offsetX: number = 0; + // private offsetY: number = 0; + /** + * Creates a canvas elements inside the given container and manages its functionalities + * @constructor + * @param containerElement - The DOM Element that will contain the canvas + * @param events - The event bus + */ + constructor(containerElement, events) { + // Setup canvas element + this.canvasElement = document.createElement("canvas"); + this.containerElement = containerElement; + this.canvasElement.id = "canvas-image"; + this.canvasElement.style.transformOrigin = 'center center'; + this.containerElement.appendChild(this.canvasElement); + // Setup canvas context + this.canvasContext = this.canvasElement.getContext("2d", { alpha: false }); + this.canvasContext.imageSmoothingEnabled = false; + // Setup events + this.setupEvents(events); + // Recalculate canvas size if container size changes + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target == this.containerElement) { + this.calculateInitialScale(); + } + } + }); + observer.observe(this.containerElement); + } + /** + * Reevaluates the initial scale of the canvas and the min and max scale + * @method + */ + calculateInitialScale() { + const containerRect = this.containerElement.getBoundingClientRect(); + this.normalScale = Math.min(containerRect.width / + this.canvasElement.width, containerRect.height / + this.canvasElement.height); + this.minScale = this.normalScale * 0.1; + this.maxScale = this.normalScale * Math.max(this.canvasElement.width, this.canvasElement.height); + this.zoom(1); + } + /** + * Creates a blank canvas with given width and height, and scale it to the container size + * @method + * @param width - Integer represents the number of pixel columns in the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the number of pixel rows in the canvas, range is [0, 1024] inclusive + * @returns An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + createBlankCanvas(width, height) { + validateNumber(width, "Width", { + start: 1, + end: 1024, + integerOnly: true, + }); + validateNumber(height, "Height", { + start: 1, + end: 1024, + integerOnly: true, + }); + this.canvasElement.width = width; + this.canvasElement.height = height; + this.calculateInitialScale(); + this.resetZoom(); + } + /** + * Renders an image at an offset in the canvas + * @method + * @param imageData - The image to be rendered + * @param [dx=0] - The x offset of the image + * @param [dy=0] - The y offset of the image + */ + render(imageData, dx = 0, dy = 0) { + validateNumber(dx, "x"); + validateNumber(dy, "y"); + this.canvasContext.putImageData(imageData, dx, dy); + } + // addOffset(offsetX, offsetY) { + // // not implemented + // } + /** + * Applies zoom multiplier to the canvas + * @method + * @param delta - Multiplier to be applied to the current scale + * @returns the current zoom level + */ + zoom(delta) { + this.scale = Math.min(Math.max(this.scale * delta, this.minScale), this.maxScale); + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + /** + * Reset zoom of the canvas + * @method + * @returns the current zoom level + */ + resetZoom() { + this.scale = this.normalScale; + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + /** + * Returns current zoom level + * @method + * @returns the current zoom level + */ + getScale() { + return this.scale; + } + /** + * Translates event coordinates of the canvas element to pixel position on the canvas + * @method + * @param clientX - The x position on the canvas element + * @param clientY - The y position on the canvas element + * @returns The resultant position of the pixel on the canvas grid + */ + getPixelPosition(clientX, clientY) { + return { + x: Math.floor(clientX / this.scale), + y: Math.floor(clientY / this.scale), + }; + } + /** + * @method + * @returns Container element + */ + get getContainer() { + return this.containerElement; + } + /** + * @method + * @returns Canvas element + */ + get canvas() { + return this.canvasElement; + } + /** + * @method + * @returns Width of the container element + */ + get containerWidth() { + return this.containerElement.style.width; + } + /** + * @method + * @returns Height of the container element + */ + get containerHeight() { + return this.containerElement.style.height; + } + /** + * @method + * @returns Width of the canvas grid + */ + get width() { + return this.canvasElement.width; + } + /** + * @method + * @returns Height of the canvas grid + */ + get height() { + return this.canvasElement.height; + } + setupEvents(events) { + const emitPointerEvent = (name, event) => { + // if (event.target !== this.canvas) return; + event.preventDefault(); + const canvasRect = this.canvas.getBoundingClientRect(); + const clientX = (event.changedTouches ? event.changedTouches[0].clientX : event.clientX) - canvasRect.left; + const clientY = (event.changedTouches ? event.changedTouches[0].clientY : event.clientY) - canvasRect.top; + const coordinates = this.getPixelPosition(clientX, clientY); + if (this.recentPixelPos.x === coordinates.x && this.recentPixelPos.y === coordinates.y && name == "mousemove") + return; + this.recentPixelPos = coordinates; + events.emit(`canvas:${name}`, { + clientX: clientX, + clientY: clientY, + coordinates, + pointerType: (event.touches ? "touch" : "mouse"), + }); + }; + this.containerElement.addEventListener("mousedown", (e) => { + emitPointerEvent("mousedown", e); + }); + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + document.addEventListener("mousemove", (e) => { + emitPointerEvent("mousemove", e); + }); + document.addEventListener("mouseleave", (e) => { + emitPointerEvent("mouseleave", e); + }); + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + // containerElement.addEventListener("touchstart", (event) => { + // event.preventDefault(); + // + // const currentTime = new Date().getTime(); + // + // if (currentTime - this.#lastTouchTime <= doubleTapThreshold) { + // touchCount++; + // } else if ( + // currentTime - this.#lastTouchTime <= + // tripleTapThreshold + // ) + // touchCount = 2; + // else touchCount = 1; + // + // lastTouchTime = currentTime; + // if (touchCount === 1) { + // this.#isDrawing = true; + // + // const clientX = event.clientX || event.touches[0].clientX; + // const clientY = event.clientY || event.touches[0].clientY; + // + // // this.#toolManager.use("touchdown - draw", clientX, clientY); + // // should be in every comment this.#events.emit("drawstart", clientX, clientY); + // } + // + // if (touchCount === 2) { + // // this.#toolManager.use("touchdown - undo", clientX, clientY); + // touchCount = 0; + // } + // + // if (touchCount === 3) { + // // this.#toolManager.use("touchdown - redo", clientX, clientY); + // touchCount = 0; + // } + // console.log(eventName); + // }); + // ["mouseup", "touchend", "touchcancel"].forEach((eventName) => { + // document.addEventListener(eventName, (event) => { + // //event.preventDefault(); + // this.#isDrawing = false; + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientX; + // console.log(eventName); + // + // // this.#toolManager.use("mouseup", clientX, clientY); + // }); + // }); + // document.addEventListener("touchmove", (event) => { + // //event.preventDefault(); + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientY; + // console.log(eventName); + // + // if (this.#isDrawing); + // // this.#toolManager.use("mousedraw", clientX, clientY); + // else; // this.#toolManager.use("mousehover", clientX, clientY); + // }); + // scroll effect + this.containerElement.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 1.1 : 0.9; + this.zoom(delta); + events.emit("canvas:zoom", { + delta: delta, + centerX: e.clientX, + centerY: e.clientY + }); + }); + // + // window.addEventListener("resize", () => { + // this.canvasElement.refresh(true); + // }); + document.addEventListener("keydown", (e) => { + if (!e.ctrlKey) + return; + if (e.key == "z") + events.emit("canvas:undo", { key: e.key }); + if (e.key == "y") + events.emit("canvas:redo", { key: e.key }); + // this.#canvasElement.render( + // this.#layerManager.getRenderImage( + // this.#canvasElement.getCanvasContext, + // ), + // ); + }); + } +} diff --git a/dist/utils/validation.js b/dist/utils/validation.js new file mode 100644 index 0000000..793ab8e --- /dev/null +++ b/dist/utils/validation.js @@ -0,0 +1,29 @@ +/** + * Validates the number to be valid number between start and end inclusive. + * @param number - The number to validate. + * @param varName - The variable name to show in the error message which will be thrown. + * @param options - Contains some optional constraints: max/min limits, and if the number is integer only + * @throws {TypeError} Throws an error if boundaries are not finite. + * @throws {TypeError} Throws an error if start and end are set but start is higher than end. + * @throws {RangeError} Throws an error if the number is not in the specified range. + */ +export function validateNumber(number, varName, options = { + start: undefined, + end: undefined, + integerOnly: false +}) { + const { start, end, integerOnly = false } = options; + if ((start !== undefined && !Number.isFinite(start)) || + (end !== undefined && !Number.isFinite(end))) + throw new TypeError("Variable boundaries are of invalid type"); + if (!Number.isFinite(number)) + throw new TypeError(`${varName} must be defined finite number`); + if (integerOnly && !Number.isInteger(number)) + throw new TypeError(`${varName} must be integer`); + if (start !== undefined && end !== undefined && end < start) + throw new TypeError(`minimum can't be higher than maximum`); + if ((start !== undefined && number < start) || + (end !== undefined && end < number)) + throw new RangeError(`${varName} must have: +${start !== undefined ? "Minimum of: " + start + "\n" : ""}${end !== undefined ? "Maximum of: " + end + "\n" : ""}`); +} diff --git a/file.js b/file.js new file mode 100644 index 0000000..9f1ccd4 --- /dev/null +++ b/file.js @@ -0,0 +1,138 @@ +import { validateNumber } from "../utils/validation.js"; + +export default class HistorySystem { + buffer; + currentIndex = 0; + startIndex = 0; + endIndex = 0; + recordIDCounter = 0; + + constructor(capacity, initialData) { + validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); + capacity = Math.floor(capacity); + this.buffer = new Array(capacity); + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + + setRecord(data, keepFuture = false) { + let atEnd = this.currentIndex === this.endIndex; + if (this.count === this.capacity) + this.startIndex = this.normalizedIndex(this.startIndex + 1); + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + if (!keepFuture || atEnd) + this.endIndex = this.currentIndex; + this.buffer[this.currentIndex] = { + id: ++this.recordIDCounter, + data: data, + }; + } + + setRecordData(data) { + if (this.currentIndex === -1) { + throw new Error("No record to add to."); + } + this.buffer[this.currentIndex].data = data; + } + + normalizedIndex(index) { + return (index + this.capacity) % this.capacity; + } + + getRecord(offset = 0) { + validateNumber(offset, "Offset", { integerOnly: true }); + if (offset > 0) { + let boundary = this.normalizedIndex(this.endIndex - this.currentIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex + offset)]; + } + else if (offset < 0) { + offset *= -1; + let boundary = this.normalizedIndex(this.currentIndex - this.startIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex - offset)]; + } + else { + return this.buffer[this.currentIndex]; + } + } + + getRecordID(offset = 0) { + return this.getRecord(offset).id; + } + + getRecordData(offset = 0) { + return this.getRecord(offset).data; + } + + getRecordOffset() { + return this.currentIndex - this.endIndex; + } + + undo() { + if (this.currentIndex !== this.startIndex) + this.currentIndex = this.normalizedIndex(this.currentIndex - 1); + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + + redo() { + if (this.currentIndex !== this.startIndex) { + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + } + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + + jumpToRecord(id) { + const index = this.buffer.findIndex(r => r.id === id); + if (index >= 0) + this.currentIndex = index; + return index >= 0; + } + + reset(initialData) { + this.buffer = new Array(this.capacity); + this.currentIndex = 0; + this.startIndex = 0; + this.endIndex = 0; + this.recordIDCounter = 0; + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + + *[Symbol.iterator]() { + let index = this.startIndex; + while (index !== this.endIndex) { + yield this.buffer[index]; + index = this.normalizedIndex(index + 1); + } + yield this.buffer[index]; + } + + get count() { + return this.normalizedIndex(this.endIndex - this.startIndex) + 1; + } + + get capacity() { + return this.buffer.length; + } + + get atEnd() { + return this.currentIndex === this.endIndex; + } + + get atStart() { + return this.currentIndex === this.startIndex; + } +} diff --git a/index.html b/index.html index 089d5f1..c020ef7 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,12 @@ - - - - - - + + + + + + Document @@ -47,7 +47,7 @@
<->
-
+
reset
@@ -90,16 +90,7 @@
- - - - - - - - - - + diff --git a/jsdoc.json b/jsdoc.json new file mode 100644 index 0000000..775ae97 --- /dev/null +++ b/jsdoc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["plugins/markdown"], + "recurseDepth": 5, + "source": { + "includePattern": "\\.js$" + }, + "opts": { + "template": "templates/default", + "destination": "docs/" + } +} diff --git a/package-lock.json b/package-lock.json index 6df553a..4a5a3c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "hasown": "^2.0.2", "html-escaper": "^2.0.2", "human-signals": "^2.1.0", + "husky": "^9.1.7", "import-local": "^3.2.0", "imurmurhash": "^0.1.4", "inflight": "^1.0.6", @@ -122,6 +123,7 @@ "kleur": "^3.0.3", "leven": "^3.1.0", "lines-and-columns": "^1.2.4", + "lint-staged": "^15.5.0", "locate-path": "^5.0.0", "lru-cache": "^5.1.1", "make-dir": "^4.0.0", @@ -201,7 +203,10 @@ "@babel/register": "^7.25.9", "canvas": "^2.11.2", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0" + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.5.3", + "tsc-alias": "^1.8.16", + "vite": "^6.3.5" } }, "node_modules/@ampproject/remapping": { @@ -1965,6 +1970,431 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2376,28 +2806,346 @@ "node": ">=10" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" } }, "node_modules/@tootallnate/once": { @@ -2451,6 +3199,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2681,6 +3436,16 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2846,10 +3611,23 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3008,6 +3786,31 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -3039,6 +3842,87 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3112,6 +3996,12 @@ "color-support": "bin.js" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3125,6 +4015,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -3347,6 +4246,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -3413,6 +4325,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3471,6 +4395,47 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3544,6 +4509,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3591,12 +4562,39 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3867,6 +4865,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3927,6 +4937,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3948,6 +4971,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -3957,6 +4993,27 @@ "node": ">=4" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4090,6 +5147,21 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4103,6 +5175,16 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4154,6 +5236,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4169,6 +5264,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4187,6 +5292,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4894,178 +6012,603 @@ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "15.5.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.0.tgz", + "integrity": "sha512-WyCzSbfYGhK7cU+UuDDkzUiytbfbi0ZdPy2orwtM75P3WTtQBzmG40cCxIa8Ii2+XjfxzLH6Be46tUfWS85Xfg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.1.tgz", + "integrity": "sha512-tx4s1tp3IYxCyVdPunlZ7MHlQ3FkjadHkbTCcQsOCFK90nM/aFEVEKIwpnn4r1WK1pIRiVrfuEpHV7PmtfvSZw==", + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "p-locate": "^4.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=8" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "environment": "^1.0.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" + "node": ">=18" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "get-east-asian-width": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5127,6 +6670,16 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5172,6 +6725,18 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", @@ -5260,6 +6825,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", @@ -5267,6 +6846,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5538,6 +7136,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5556,6 +7164,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -5584,7 +7204,65 @@ "find-up": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/pretty-format": { @@ -5672,6 +7350,37 @@ "dev": true, "license": "MIT" }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -5693,6 +7402,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -5838,6 +7560,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve.exports": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", @@ -5847,6 +7579,66 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -5864,6 +7656,70 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6009,6 +7865,46 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6018,6 +7914,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -6056,6 +7962,15 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6195,6 +8110,51 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6242,6 +8202,38 @@ "node": ">=12" } }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6385,6 +8377,109 @@ "node": ">=10.12.0" } }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -6569,6 +8664,18 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 360667c..5879476 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "@babel/register": "^7.25.9", "canvas": "^2.11.2", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0" + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.5.3", + "tsc-alias": "^1.8.16", + "vite": "^6.3.5" }, "name": "pixeleditor", "version": "1.0.0", @@ -75,6 +78,7 @@ "hasown": "^2.0.2", "html-escaper": "^2.0.2", "human-signals": "^2.1.0", + "husky": "^9.1.7", "import-local": "^3.2.0", "imurmurhash": "^0.1.4", "inflight": "^1.0.6", @@ -124,6 +128,7 @@ "kleur": "^3.0.3", "leven": "^3.1.0", "lines-and-columns": "^1.2.4", + "lint-staged": "^15.5.0", "locate-path": "^5.0.0", "lru-cache": "^5.1.1", "make-dir": "^4.0.0", @@ -199,7 +204,11 @@ "yocto-queue": "^0.1.0" }, "scripts": { - "test": "jest --verbose" + "build": "tsc && tsc-alias", + "dev": "tsc --watch & tsc-alias --watch", + "format": "prettier --write .", + "test": "jest --verbose", + "preview": "vite preview" }, "jest": { "collectCoverage": true, @@ -216,5 +225,5 @@ "keywords": [], "author": "", "license": "ISC", - "type": "commonjs" + "type": "module" } diff --git a/scripts/canvas-grid.js b/scripts/canvas-grid.js deleted file mode 100644 index b1449de..0000000 --- a/scripts/canvas-grid.js +++ /dev/null @@ -1,203 +0,0 @@ -import { validateNumber, validateColorArray } from "./validation.js"; - -/** - * Represents a canvas grid system - * @class - */ -class CanvasGrid { - #width; - #height; - #pixelMatrix; - #lastActions; - - /** - * Creates a blank canvas with specified width and height - * @constructor - * @param {number} [width=1] - The width of the grid - * @param {number} [height=1] height - The height of the grid - * @throws {Error} if width or height are not integers between 1 and 1024 inclusive - */ - constructor(width = 1, height = 1) { - validateNumber(width, "Width", { - start: 1, - end: 1024, - integerOnly: true - }); - validateNumber(height, "Height", { - start: 1, - end: 1024, - integerOnly: true - }); - this.#width = width; - this.#height = height; - this.#lastActions = []; - this.initializeBlankCanvas(width, height); - } - - /** - * Initializes the canvas with a blank grid of transparent pixel data - * @method - * @param {number} width - The width of the grid - * @param {number} height - The height of the grid - * @throws {Error} if width or height are not integers between 1 and 1024 inclusive - */ - initializeBlankCanvas(width, height) { - validateNumber(width, "Width", {start: 1, end: 1024, integerOnly: true}); - validateNumber(height, "Height", {start: 1, end: 1024, integerOnly: true}); - - this.#width = width; - this.#height = height; - this.#pixelMatrix = []; - for (let i = 0; i < this.#height; i++) { - this.#pixelMatrix.push([]); - for (let j = 0; j < this.#width; j++) { - this.#pixelMatrix[i].push({ - x: j, - y: i, - color: [0, 0, 0, 0], - }); - } - } - } - - /** - * Loads an image data at (x, y) position - * @method - * @param {ImageData} imageData - * @param {number} [x=0] - * @param {number} [y=0] - * @throws {Error} if x or y are not integers - * @throws {Error} if imageData is not instance of class ImageData - */ - loadImage(imageData, x = 0, y = 0) { - validateNumber(x, "x", {integerOnly: true}); - validateNumber(y, "y", {integerOnly: true}); - - if (imageData === undefined || !(imageData instanceof ImageData)) - throw new TypeError( - "Image data must be defined instance of ImageData class", - ); - - let start_y = Math.max(y, 0); - let start_x = Math.max(x, 0); - for ( - let i = start_y; - i < imageData.height + y && i < this.#height; - i++ - ) { - for ( - let j = start_x; - j < imageData.width + x && j < this.#width; - j++ - ) { - let dist = (j - x + imageData.width * (i - y)) * 4; - - let red = Number(imageData.data[dist + 0]); - let green = Number(imageData.data[dist + 1]); - let blue = Number(imageData.data[dist + 2]); - let alpha = Number(imageData.data[dist + 3]); - - this.setColor(j, i, [red, green, blue, alpha]); - } - } - } - - /** - * Resets last taken actions array to be empty - * @method - * @returns last taken actions - */ - resetLastActions() { - let lastActions = this.#lastActions; - this.#lastActions = []; - return lastActions; - } - - /** - * Sets color to pixel at position (x, y). - * If the color alpha channel is 0, then set rgba to [0, 0, 0, 0] to represent transparency. - * @method - * @param {number} x - The x position. - * @param {number} y - The y position. - * @param {[number, number, number, number]} color - An array containing color data [red, green, blue, alpha]. - * @param {Object} An object containing additional options. - * @param {boolean} [options.quietly=false] - If set to true, the pixel data at which color changed will not be pushed to the lastActionss array. - * @param {boolean} [options.validate=true] - If set to true, the x, y, and color types are validated. - * @throws {Error} if validate is true and if x and y are not valid integers in valid range. - * @throws {Error} if validate is true and if color is not the valid array form [r, g, b, a] where r, g, b are between 0 and 255, and a is between 0 and 1. - */ - setColor(x, y, color, { quietly = false, validate = true } = {}) { - if (validate) { - validateNumber(x, "x", {start: 0, end: this.#width - 1, integerOnly: true}); - validateNumber(y, "y", {start: 0, end: this.#height - 1, integerOnly: true}); - validateColorArray(color); - } - - // consider all colors with alpha 0 as the same color [transparent black] - color = color[3] === 0 ? [0, 0, 0, 0] : color; - - if (!quietly) { - this.#lastActions.push({ - x: x, - y: y, - colorOld: this.#pixelMatrix[y][x].color, - colorNew: color, - }); - } - this.#pixelMatrix[y][x].color = color; - } - - /** - * Returns pixel data at position (x, y) - * @method - * @param {number} x - * @param {number} y - * @returns {Object} - */ - get(x, y) { - validateNumber(x, "x", {start: 0, end: this.#width - 1, integerOnly: true}); - validateNumber(y, "y", {start: 0, end: this.#height - 1, integerOnly: true}); - - return this.#pixelMatrix[y][x]; - } - - /** - * Returns pixel color at position (x, y) - * @method - * @param {number} x - * @param {number} y - * @param {[number, number, number, number]} An array containing color data [red, green, blue, alpha] - */ - getColor(x, y) { - return this.get(x, y).color; - } - - /** - * Returns last edited pixel positions with the new colors in an array - * @method - * @returns {Array} An array containing the x, y and color of each lastly edited pixel [{x: x1, y: y1, color: c1}, {x: x2, y: y2, color: c2} ...] - */ - get getLastActions() { - return this.#lastActions; - } - - /** - * Returns the width of the canvas - * @method - * @returns {number} The width of the canvas - */ - get getWidth() { - return this.#width; - } - - /** - * Returns the height of the canvas - * @method - * @returns {number} The height of the canvas - */ - get getHeight() { - return this.#height; - } -} - -export default CanvasGrid; diff --git a/scripts/canvas-manager.js b/scripts/canvas-manager.js deleted file mode 100644 index 709ce2e..0000000 --- a/scripts/canvas-manager.js +++ /dev/null @@ -1,194 +0,0 @@ -import { validateNumber, validateColorArray } from "./validation.js"; - -/* - * Responsible for managing the canvas element inside its container - * @class - */ -class CanvasManager { - #containerElement; - #canvasElement; - #canvasContext; - - #initScale = 1; // the inital scale applied on the canvas to fit in the containerElement - #scale = 1; // the scale applied by the user on the canvas - - //#isDragging = false; - #startX = 0; - #startY = 0; - #offsetX = 0; - #offsetY = 0; - - - /* - * Creates a canvas elements inside the given container and manages its functionalities - * @constructor - * @param {HTMLElement} containerElement - The DOM Element that will contain the canvas - * @throws {TypeError} if containerElement is not an instance of HTMLElement - */ - constructor(containerElement) { - if ((!containerElement) instanceof HTMLElement) - throw new TypeError( - "containerElement must be an instance of HTMLElement", - ); - - // Setup canvas element - this.#canvasElement = document.createElement("canvas"); - this.#containerElement = containerElement; - this.#canvasElement.id = "canvas-image"; - - this.#containerElement.appendChild(this.#canvasElement); - - // Setup canvas context - this.#canvasContext = this.#canvasElement.getContext("2d", { alpha: false }); - this.#canvasContext.imageSmoothingEnabled = false; - } - - /* - * Creates a blank canvas with given width and height, and scale it to the container size - * @method - * @param {number} width - Integer represents the number of pixel columns in the canvas, range is [0, 1024] inclusive - * @param {number} height - Integer represents the number of pixel rows in the canvas, range is [0, 1024] inclusive - * @returns {Object} An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} - * @throws {TypeError} if the width or the height are not valid integers - * @throws {RangeError} if the width or the height are not in valid ranges - */ - createBlankCanvas(width, height) { - validateNumber(width, "Width", { - start: 1, - end: 1024, - integerOnly: true, - }); - validateNumber(height, "Height", { - start: 1, - end: 1024, - integerOnly: true, - }); - - const containerRect = this.#containerElement.getBoundingClientRect(); - - this.#initScale = Math.min( - containerRect.width / width, - containerRect.height / height, - ); - - this.#canvasElement.width = width; - this.#canvasElement.height = height; - this.#canvasElement.style.width = `${this.#initScale * this.#scale * width}px`; - this.#canvasElement.style.height = `${this.#initScale * this.#scale * height}px`; - } - - /* - * Refreshes canvas initial scale according to the modification done on the container element size, or on the canvas size itself - * @method - * @param {boolean} [dimensionsChanged=false] - Boolean value stating if the initial scale needs to be updated too - */ - refresh(dimensionsChanged = false) { - if (dimensionsChanged) { - const containerRect = - this.#containerElement.getBoundingClientRect(); - - this.#initScale = Math.min( - containerRect.width / - this.#canvasElement.width, - containerRect.height / - this.#canvasElement.height, - ); - } - - this.#canvasElement.style.width = `${this.#initScale * this.#scale * this.#canvasElement.width}px`; - this.#canvasElement.style.height = `${this.#initScale * this.#scale * this.#canvasElement.height}px`; - } - - render(imageData, x = 0, y = 0) { - if (!(imageData instanceof ImageData)) throw new TypeError(); - validateNumber(x, "x"); - validateNumber(y, "y"); - - this.#canvasContext.putImageData(imageData, x, y); - } - - /* - * Sets dimensions of the canvas element - * @method - * @param {number} width - Integer represents the number of pixel columns in the canvas, range is [0, 1024] inclusive - * @param {number} height - Integer represents the number of pixel rows in the canvas, range is [0, 1024] inclusive - * @throws {TypeError} if the width or the height are not valid integers - * @throws {RangeError} if the width or the height are not in valid ranges - */ - setDimensions(width, height) { - validateNumber(width, "Width", { - start: 1, - end: 1024, - integerOnly: true, - }); - validateNumber(height, "Height", { - start: 1, - end: 1024, - integerOnly: true, - }); - this.#canvasElement.width = width; - this.#canvasElement.height = height; - } - - addOffset(offsetX, offsetY) { - - } - - /* - * Applies scale to the canvas - * @method - * @param {number} scale - Scale applied by the user - * @throws {TypeError} if the scale is not valid number - */ - setScale(scale) { - validateNumber(scale, "Scale"); - - const minScale = 0.5; - const maxScale = - Math.min( - parseInt(getComputedStyle(this.#containerElement).width), - parseInt(getComputedStyle(this.#containerElement).height), - ) / this.#initScale; - - this.#scale = Math.max(Math.min(scale, maxScale), minScale); - } - - getPixelPosition(clientX, clientY) { - const rect = this.#canvasElement.getBoundingClientRect(); - return { - x: Math.floor((clientX - rect.left) / (this.#initScale * this.#scale)), - y: Math.floor((clientY - rect.top) / (this.#initScale * this.#scale)), - } - } - - get getContainer() { - return this.#containerElement; - } - get getCanvas() { - return this.#canvasElement; - } - get getCanvasContext() { - return this.#canvasContext; - } - get getContainerWidth() { - return this.#containerElement.style.width; - } - get getContainerHeight() { - return this.#containerElement.style.height; - } - get getWidth() { - return this.#canvasElement.width; - } - get getHeight() { - return this.#canvasElement.height; - } - get getInitialScale() { - return this.#initScale; - } - - get getScale() { - return this.#scale; - } -} - -export default CanvasManager; diff --git a/scripts/event-manager.js b/scripts/event-manager.js deleted file mode 100644 index 471dbb6..0000000 --- a/scripts/event-manager.js +++ /dev/null @@ -1,94 +0,0 @@ -class EventManager { - #canvasManager; - #layerSystem; - #toolManager; - - #isMouseDown = false; - - constructor(canvasManager, toolManager, layerSystem) { - this.#canvasManager = canvasManager; - this.#layerSystem = layerSystem; - this.#toolManager = toolManager; - let canvasElement = this.#canvasManager.getCanvas; - let containerElement = this.#canvasManager.getContainer; - - ["mousedown", "touchstart"].forEach((eventName) => { - containerElement.addEventListener(eventName, (event) => { - event.preventDefault(); - this.#isMouseDown = true; - - const clientX = event.clientX || event.touches[0].clientX; - const clientY = event.clientY || event.touches[0].clientY; - console.log(eventName); - console.log(event.touches); - console.log(event.changedTouches); - - this.#toolManager.use("mousedown", clientX, clientY); - }); - }); - - ["mouseup", "touchend", "touchcancel"].forEach((eventName) => { - document.addEventListener(eventName, (event) => { - event.preventDefault(); - this.#isMouseDown = false; - - const clientX = event.clientX || event.changedTouches[0].clientX; - const clientY = event.clientY || event.changedTouches[0].clientX; - console.log(eventName); - console.log(event.touches); - console.log(event.changedTouches); - - this.#toolManager.use("mouseup", clientX, clientY); - }); - }); - - ["mousemove", "touchmove"].forEach((eventName) => { - document.addEventListener(eventName, (event) => { - event.preventDefault(); - - const clientX = event.clientX || event.changedTouches[0].clientX; - const clientY = event.clientY || event.changedTouches[0].clientY; - console.log(eventName); - console.log(event.touches); - console.log(event.changedTouches); - - if (this.#isMouseDown) - this.#toolManager.use("mousedraw", clientX, clientY); - else this.#toolManager.use("mousehover", clientX, clientY); - }); - }); - - // scroll effect - containerElement.addEventListener("wheel", (event) => { - event.preventDefault(); - - const delta = event.deltaY > 0 ? 1.1 : 0.9; - - this.#canvasManager.setScale(this.#canvasManager.getScale * delta); - this.#canvasManager.refresh(); - }); - - window.addEventListener("resize", () => { - this.#canvasManager.refresh(true); - }); - - document.addEventListener("keydown", (event) => { - if (event.ctrlKey === true) { - if (event.key === "z") { - console.log("undo"); - this.#layerSystem.undo(); - } else if (event.key === "y") { - console.log("redo"); - this.#layerSystem.redo(); - } - this.#canvasManager.render( - this.#layerSystem.getRenderImage( - this.#canvasManager.getCanvasContext, - ), - ); - } - }); - } -} - -export default EventManager; diff --git a/scripts/history-system.js b/scripts/history-system.js deleted file mode 100644 index b418a82..0000000 --- a/scripts/history-system.js +++ /dev/null @@ -1,236 +0,0 @@ -import {validateNumber} from "./validation.js"; - -/** - * Represents a history system to store, undo, redo action data. - * @class - */ -class HistorySystem { - #buffer; - #currentIndex = -1; - #startIndex = 0; - #endIndex = -1; - #actionGroupIDCounter = -1; - - /** - * Creates a history system with specified capacity - * @constructor - * @param {number} capacity - The max size of the history buffer (range from 1 to 64) - * @throws {Error} Throws an error if capacity is not an integer between 1 and 64 - */ - constructor(capacity) { - validateNumber(capacity, "Capacity", {start: 1, end: 64, integerOnly: true}); - - capacity = Math.floor(capacity); - this.#buffer = new Array(capacity); - } - - /** - * Creates a copy of a history system object - * @method - */ - //copy() { - // const copyHistory = new HistorySystem(); - // copyHistory.#currentIndex = this.#currentIndex; - // copyHistory.#startIndex = this.#startIndex; - // copyHistory.#endIndex = this.#endIndex; - // copyHistory.#actionGroupIDCounter = this.#actionGroupIDCounter; - // this.#buffer.forEach(elm => { - // if (typeof elm === "object" && elm !== null) { - // if (Array.isArray(elm)) - // copyHistory.#buffer.push([...elm]); - // else copyHistory.#buffer.push({...elm}); - // } else copyHistory.#buffer.push(elm); - // }); - // return copyHistory; - //} - - - /** - * Addes action group with given name and incremental ID to the action buffer - * @method - * @param {string} actionGroupName - Name of the action group to be added - * @throws {Error} Throws an error if given action group name is not of type string - */ - addActionGroup(actionGroupName = "") { - if (typeof actionGroupName !== "string") - throw new TypeError("Action group name must be string"); - - if (this.getBufferSize === this.getBufferCapacity) { - this.#startIndex = (this.#startIndex + 1) % this.getBufferCapacity; - } - - if (this.#currentIndex === -1) this.#currentIndex = this.#startIndex; - else - this.#currentIndex = - (this.#currentIndex + 1) % this.getBufferCapacity; - - this.#endIndex = this.#currentIndex; - - this.#buffer[this.#currentIndex] = { - groupName: actionGroupName, - groupID: ++this.#actionGroupIDCounter, - actionData: [], - }; - } - - /** - * Adds a shallow copy of action data to the current selected action group - * @method - * @param {any} actionDataObject - The action data to be stored in the current action group - * @throws {Error} Throws an error if no action group selected currently (eg. when at the absolute start) - */ - addActionData(actionDataObject) { - if (this.#currentIndex === -1) { - throw new Error("No action group to add to."); - } - - let newObject; - if (typeof actionDataObject === "object" && actionDataObject !== null) { - if (Array.isArray(actionDataObject)) - newObject = [...actionDataObject]; - else newObject = { ...actionDataObject }; - } else newObject = actionDataObject; - - this.#buffer[this.#currentIndex].actionData.push(newObject); - } - - /** - * Retrieves action group at an offset from current selected group - * @method - * @param {number} offset - The the offset from the current group for which ID gets returned - * @returns {Object{groupID, groupName, actionArray} | number} The selected action group, -1 if out of range - * @throws {Error} Throws an error if offset is not an integer number - */ - #getActionGroup(offset = 0) { - validateNumber(offset, "Offset", {integerOnly: true}); - - let check; - let index = this.#currentIndex; - - if (offset > 0) { - if (index === -1) { - index = this.#startIndex; - offset--; - } - - check = this.#endIndex - index; - check = check < 0 ? check + this.getBufferCapacity : check; - - if (check - offset < 0) return -1; - - return this.#buffer[(index + offset) % this.getBufferCapacity]; - } else if (offset < 0) { - offset *= -1; - - if (index === -1) return -1; - - check = index - this.#startIndex; - check = check < 0 ? check + this.getBufferCapacity : check; - - // if (check - offset) is -1 => result index at -1 which is start => return -1 - if (check - offset < 0) return -1; - - return this.#buffer[ - (index - offset + this.getBufferCapacity) % - this.getBufferCapacity - ]; - } else { - if (this.#currentIndex === -1) return -1; - return this.#buffer[this.#currentIndex]; - } - } - - /** - * Retrieves action group ID at an offset from current selected group - * @method - * @param {number} [offset=0] - The the offset from the current group for which ID gets returned - * @returns {number} The action group ID, or -1 if not in range. - */ - getActionGroupID(offset = 0) { - let group = this.#getActionGroup(offset); - if (group === -1) return -1; - return group.groupID; - } - - /** - * Retrieves action group name at an offset from current selected group - * @method - * @param {number} [offset=0] - The the offset from the current group for which name gets returned - * @returns {string | number} The action group name, or -1 if not in range. - */ - getActionGroupName(offset = 0) { - let group = this.#getActionGroup(offset); - if (group === -1) return -1; - return group.groupName; - } - - /** - * Retrieves action group data at an offset from current selected group - * @method - * @param {number} [offset=0] - The the offset from the current group for which data gets returned - * @returns {Array | number} An array containing the action group data, or -1 if not in range - */ - getActionData(offset = 0) { - let group = this.#getActionGroup(offset); - if (group === -1) return -1; - return group.actionData; - } - - /** - * Reverts to the previous action group - * @method - * @returns {number} The ID of the previous action group - */ - undo() { - if (this.#currentIndex === this.#startIndex) this.#currentIndex = -1; - - if (this.#currentIndex === -1) return this.#currentIndex; - - this.#currentIndex = - (this.#currentIndex - 1 + this.getBufferCapacity) % - this.getBufferCapacity; - return this.#buffer[this.#currentIndex].groupID; - } - - /** - * Advances to the next action group - * @method - * @returns {number} The ID of next action group - */ - redo() { - if (this.#currentIndex !== this.#endIndex) - if (this.#currentIndex === -1) { - this.#currentIndex = this.#startIndex; - } else { - this.#currentIndex = - (this.#currentIndex + 1) % this.getBufferCapacity; - } - - return this.#buffer[this.#currentIndex].groupID; - } - - /** - * Retrieves number of action groups stored currently in the action buffer - * @method - * @returns {number} The size - */ - get getBufferSize() { - if (this.#endIndex == -1) return 0; - return ( - ((this.#endIndex - this.#startIndex + this.getBufferCapacity) % - this.getBufferCapacity) + - 1 - ); - } - - /** - * Retrieves action buffer capacity (maximum number of action groups to add to the system) - * @method - * @returns {number} The capacity - */ - get getBufferCapacity() { - return this.#buffer.length; - } -} - -export default HistorySystem; diff --git a/scripts/layer-system.js b/scripts/layer-system.js deleted file mode 100644 index 9cf9ec1..0000000 --- a/scripts/layer-system.js +++ /dev/null @@ -1,397 +0,0 @@ -import HistorySystem from "./history-system.js"; -import CanvasGrid from "./canvas-grid.js"; -import { validateNumber, validateColorArray } from "./validation.js"; - -/** - * Represents a system for managing layers of canvas grids - * @class - */ -class LayerSystem { - #layerList = []; - #selectedIndex = -1; - #width = 0; - #height = 0; - #darkBG = [160, 160, 160, 1]; - #lightBG = [217, 217, 217, 1]; - - /** - * Represents a system for managing layers of canvas - * @constructor - * @param {number} [width=1] - The width of the canvas grid for the layers - * @param {number} [height=1] height - The height of the canvas grid for the layers - * @throws {Error} if width or height are not integers between 1 and 1024 inclusive - */ - constructor(width = 1, height = 1) { - validateNumber(width, "width", { - start: 1, - end: 1024, - integerOnly: true, - }); - validateNumber(height, "height", { - start: 1, - end: 1024, - integerOnly: true, - }); - this.#width = width; - this.#height = height; - } - - /** - * Retrieves a layer object from layers list - * Retrieves a layer object from layers list at given index, if given -1 then Retrieves the layer at the selected index. Returns null if index is set to -1 and no layer selected - * @param {number} [index=-1] - The index of the layer in the layer list - * @returns {Object} - Layer object containing the name, canvas data and history system of the layer, or null - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - #getLayer(index = -1) { - if (this.#layerList.length === 0) - throw new RangeError("No layers to get"); - - validateNumber(index, "Index", { - start: -1, - end: this.getSize - 1, - integerOnly: true, - }); - - index = index === -1 ? this.#selectedIndex : index; - if (index === -1) return null; - - return this.#layerList[index]; - } - - /** - * Removes a layer objects from layers list at the given index, if index is -1, it removes at the selected index, does nothing if nothing is selected and index is -1 - * @param {number} [index=-1] - The index of the layer in the layer list, will take the selected index if -1 given - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - removeLayer(index = -1) { - if (this.#layerList.length === 0) - throw new RangeError("No layers to remove"); - - validateNumber(index, "Index", { - start: -1, - end: this.getSize - 1, - integerOnly: true, - }); - - index = index === -1 ? this.#selectedIndex : index; - if (index === -1) return; - - this.#layerList.splice(index, 1); - if (this.#selectedIndex > index) - this.#selectedIndex--; // shifted up - else if (this.#selectedIndex == index) this.#selectedIndex = -1; // deselects - } - - /** - * Addes a new layer object to the layers list - * @param {string} name - The name of the layer to be added - * @throws {TypeError} throws an error if the name is not string - */ - addLayer(name) { - if (typeof name !== "string") - throw new TypeError("Layer name must be defined string"); - - this.#layerList.push({ - name: name, - canvasGrid: new CanvasGrid(this.#width, this.#height), - historySystem: new HistorySystem(64), - }); - } - - /** - * Selects a layer objects from layers list - * @param {number} index - The index of the layer in the layer list - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - selectLayer(index) { - if (this.#layerList.length === 0) - throw new RangeError("No layers to select"); - - validateNumber(index, "Index", { - start: 0, - end: this.getSize - 1, - integerOnly: true, - }); - - this.#selectedIndex = index; - } - - /** - * Changes the position of a layer in the layer list - * @param {number} currentIndex - The index of the layer in the layer list - * @param {number} newIndex - The index to change to in the layer list - * @throws {TypeError} throws an error if the currentIndex or newIndex is not integer - * @throws {RangeError} throws an error if the currentIndex or newIndex is not in valid range - */ - moveLayer(currentIndex, newIndex) { - if (this.#layerList.length === 0) - throw new RangeError("No layers to change index of"); - - validateNumber(currentIndex, "Current index", { - start: 0, - end: this.getSize - 1, - integerOnly: true, - }); - validateNumber(newIndex, "New index", { - start: 0, - end: this.getSize - 1, - integerOnly: true, - }); - - const layer = this.#layerList.splice(currentIndex, 1)[0]; - this.#layerList.splice(newIndex, 0, layer); - - if (this.#selectedIndex !== -1) { - if (this.#selectedIndex === currentIndex) { - this.#selectedIndex = newIndex; - } else if ( - this.#selectedIndex >= newIndex && - this.#selectedIndex < currentIndex - ) { - this.#selectedIndex++; // shifted up - } else if ( - this.#selectedIndex <= newIndex && - this.#selectedIndex > currentIndex - ) { - this.#selectedIndex--; // shifted down - } - } - } - - getRenderImage( - context, - x0 = 0, - y0 = 0, - x1 = this.getWidth, - y1 = this.getHeight, - positionsArray = null, - ) { - const calculateColor = (x, y) => { - validateNumber(x, "x"); - validateNumber(y, "y"); - - let finalColor = [...((x + y) % 2 ? this.#lightBG : this.#darkBG)]; - - for (let i = 0; i < this.getSize; i++) { - const canvas = this.getLayerCanvas(i); - const layerColor = [...canvas.getColor(x, y)]; - - // color[0] : red - // color[1] : green - // color[2] : blue - // color[3] : alpha - - const finalAlpha = - layerColor[3] + finalColor[3] * (1 - layerColor[3]); - for ( - let k = 0; - k < 3; - k++ // rgb values - ) - finalColor[k] = - (layerColor[k] * layerColor[3] + - finalColor[k] * - finalColor[3] * - (1 - layerColor[3])) / - finalAlpha; - - finalColor[3] = finalAlpha; // alpha value - } - - return finalColor; - }; - - if (this.#selectedIndex === -1) return; - - if (!Array.isArray(positionsArray) && positionsArray !== null) - throw new TypeError(); - - const renderImage = context.getImageData( - x0, - y0, - x1 - x0 + 1, - y1 - y0 + 1, - ); - - if (positionsArray === null) { - for (let y = y0; y < y1; y++) - for (let x = x0; x < x1; x++) { - const index = (y * renderImage.width + x) * 4; - const color = calculateColor(x, y); - renderImage.data[index] = color[0]; - renderImage.data[index + 1] = color[1]; - renderImage.data[index + 2] = color[2]; - renderImage.data[index + 3] = Math.floor(color[3] * 255); - } - } else - for (let pixel of positionsArray) { - let x = pixel.x - x0; - let y = pixel.y - y0; - const index = (y * renderImage.width + x) * 4; - const color = calculateColor(pixel.x, pixel.y); - renderImage.data[index] = color[0]; - renderImage.data[index + 1] = color[1]; - renderImage.data[index + 2] = color[2]; - renderImage.data[index + 3] = Math.floor(color[3] * 255); - } - - return renderImage; - } - - addToHistory(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - layer.canvasGrid.getLastActions.forEach((action) => - layer.historySystem.addActionData(action), - ); - layer.canvasGrid.resetLastActions(); - } - - undo(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - let actionDataArray = layer.historySystem.getActionData(); - for (let i = actionDataArray.length - 1; i >= 0; i--) { - let data = actionDataArray[i]; - layer.canvasGrid.setColor(data.x, data.y, data.colorOld, { - quietly: true, - }); - } - layer.historySystem.undo(); - } - - redo(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - layer.historySystem.redo(); - let actionDataArray = layer.historySystem.getActionData(); - for (let i = 0; i < actionDataArray.length - 1; i++) { - let data = actionDataArray[i]; - layer.canvasGrid.setColor(data.x, data.y, data.colorNew, { - quietly: true, - }); - } - } - - /** - * Sets a new name to a layer in the layer list at given index, if given -1 then sets a name at the selected index. Does nothing if index is set to -1 and no layer selected - * @param {string} name - The index to change to in the layer list - * @param {number} index - The index of the layer in the layer list, if undefined, will take selected index - * @throws {TypeError} throws an error if the index is not integer or the name is not string - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - setLayerName(name, index = -1) { - if (typeof name !== "string") - throw new TypeError("Layer name must be defined string"); - - validateNumber(index, "Index", { - start: -1, - end: this.getSize - 1, - integerOnly: true, - }); - - index = index === -1 ? this.#selectedIndex : index; - if (index === -1) return; - - this.#getLayer(index).name = name; - } - - setBackgroundColors(lightBG, darkBG) { - validateColorArray(lightBG); - validateColorArray(darkBG); - this.#lightBG = lightBG; - this.#lightBG = darkBG; - } - - /** - * Retrieves name of a layer in the layer list at given index, if given -1 then gets name at the selected index. Returns null if index is set to -1 and no layer selected - * @param {number} index - The index of the layer in the layer list - * @returns {string} - the name of the layer - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - getLayerName(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - return layer.name; - } - - /** - * Retrieves canvas data of a layer in the layer list at given index, if given -1 then gets canvas data at the selected index. Returns null if index is set to -1 and no layer selected - * @param {number} [index=-1] - The index of the layer in the layer list - * @returns {CanvasGrid} - the canvas data of the layer - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - getLayerCanvas(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - return layer.canvasGrid; - } - - /** - * Retrieves history system of a layer in the layer list at given index, if given -1 then gets history system at the selected index. Returns null if index is set to -1 and no layer selected - * @param {number} [index=-1] - The index of the layer in the layer list - * @returns {HistorySystem} - the history system of the layer - * @throws {TypeError} throws an error if the index is not integer - * @throws {RangeError} throws an error if the layer list is empty - * @throws {RangeError} throws an error if the index is out of valid range - */ - getLayerHistory(index = -1) { - const layer = this.#getLayer(index); - if (layer === null) return null; - return layer.historySystem; - } - - /** - * Retrieves width of canvas grid for which the layer system is applied - * @returns {number} - The width of the canvas grid for the layers - */ - get getWidth() { - return this.#width; - } - - /** - * Retrieves height of canvas grid for which the layer system is applied - * @returns {number} - The height of the canvas grid for the layers - */ - get getHeight() { - return this.#height; - } - - /** - * Retrieves number of layers in the layer list - * @returns {number} - the number of layers - */ - get getSize() { - return this.#layerList.length; - } - - /** - * Retrieves a list of layers names - * @returns {Array} - the returned array is on form [name_1, name_2, ... , name_n] - */ - get getNameList() { - return this.#layerList.map((elm) => elm.name); - } - - /** - * Retrieves the selected layer index in the layer list - * @returns {number} - the index of the selected layer, -1 if non selected - */ - get getSelectedIndex() { - return this.#selectedIndex; - } -} - -export default LayerSystem; diff --git a/scripts/pixel-board.js b/scripts/pixel-board.js deleted file mode 100644 index 17dd2a5..0000000 --- a/scripts/pixel-board.js +++ /dev/null @@ -1,92 +0,0 @@ -import { validateNumber } from "./validation.js"; -import LayerSystem from "./layer-system.js"; -import DrawingManager from "./drawing-manager.js"; -import CanvasManager from "./canvas-manager.js"; -import EventManager from "./event-manager.js"; -import ToolManager from "./tool-manager.js"; - -/* - * Responsible for managing events and functionalities of the canvas element inside its container - * @class - */ -class PixelBoard { - eventManager; - layerSystem; - drawingManager; - canvasManager; - toolManager; - - /* - * Creates a canvas elements inside the given container and manages its functionalities - * @constructor - * @param {HTMLElement} containerElement - The DOM Element that will contain the canvas - * @throws {TypeError} if containerElement is not an instance of HTMLElement - */ - constructor(containerElement) { - if ((!containerElement) instanceof HTMLElement) { - throw new TypeError( - "containerElement must be an instance of HTMLElement", - ); - } - - this.canvasManager = new CanvasManager(containerElement); - } - - /* - * Creates a blank board with given canvas width and height - * @method - * @param {number} width - Integer represents the width of the canvas, range is [0, 1024] inclusive - * @param {number} height - Integer represents the height of the canvas, range is [0, 1024] inclusive - * @returns {Object} An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} - * @throws {TypeError} if the width or the height are not valid integers - * @throws {RangeError} if the width or the height are not in valid ranges - */ - createBlankBoard(width, height) { - this.canvasManager.createBlankCanvas(width, height); - this.layerSystem = new LayerSystem(width, height); - this.layerSystem.addLayer("Layer 1"); - this.layerSystem.selectLayer(0); - this.drawingManager = new DrawingManager(this.layerSystem); - this.toolManager = new ToolManager(this.canvasManager, this.layerSystem, this.drawingManager); - this.eventManager = new EventManager(this.canvasManager, this.toolManager, this.layerSystem); - } - - /* - * Creates a blank board with given canvas width and height - * @method - * @param {number} clientX - The x position on the scaled canvas element to put the image - * @param {number} clientY - The y position on the scaled canvas element to put the image - * @returns {Object} An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} - * @throws {TypeError} if the clientX or the clientY are not valid numbers - * @throws {TypeError} if the imageURL is not a valid image url - */ - loadImage(clientX, clientY, imageURL) { - validateNumber(clientX, "clientX"); - validateNumber(clientY, "clientY"); - let pixel = this.getIntegerPosition(clientX, clientY); - let img; - - new Promise((resolve, reject) => { - // validating the imageURL and setting the img - const pattern = /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i; - if (!pattern.test(imageURL)) - () => reject(TypeError("imgaeURL must be a valid image URL")); - - img = new Image(); - img.src = imageURL; - - img.onload = () => resolve(true); - img.onerror = () => reject(new Error("Image failed to load")); - }); - - img.addEventListener("load", () => { - this.layerSystem.getLayerCanvas().loadImage(img, pixel.x, pixel.y); - }); - } - - render() { - this.canvasManager.render(this.layerSystem.getRenderImage(this.canvasManager.getCanvasContext)); - } -} - -export default PixelBoard; diff --git a/scripts/validation.js b/scripts/validation.js deleted file mode 100644 index 892dbc7..0000000 --- a/scripts/validation.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Validates the color array. - * @param {[number, number, number, number]} color - The color array [red, green, blue, alpha] to validate. - * @returns {boolean} - Returns true if the color array is valid, otherwise false. - * @throws {TypeError} Throws an error if the color is invalid. - */ -export function validateColorArray(color) { - if (!Array.isArray(color) || color.length !== 4) { - throw new TypeError( - "Color must be in array containing 4 finite numbers", - ); - } - color.forEach((value, index) => { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new TypeError( - "Color must be in array containing 4 finite numbers", - ); - } - - if (index < 3) { - // For r, g, b - if (value < 0 || value > 255) { - throw new RangeError( - "Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive", - ); - } - } else { - // For a - if (value < 0 || value > 1) { - throw new RangeError( - "Color alpha value (at index 3) must be between 0 and 1 inclusive", - ); - } - } - }); -} - -/** - * Validates the number to be valid number between start and end inclusive. - * @param {number} number - The number to validate. - * @param {Object} Contains some optional constraints: max/min limits, and if the number is integer only - * @param {number | undefined} start - The minimum of valid range, set to null to omit the constraint. - * @param {number | undefined} end - The maximum of valid range, set to null to omit the constraint. - * @param {boolean} integerOnly - Specifies if the number must be an integer. - * @param {String} varName - The variable name to show in the error message which will be thrown. - * @throws {TypeError} Throws an error if the number type, name type or options types is invalid. - * @throws {TypeError} Throws an error if start and end are set but start is higher than end. - * @throws {RangeError} Throws an error if the number is not in the specified range. - */ -export function validateNumber( - number, - varName, - { end = undefined, start = undefined, integerOnly = false } = {}, -) { - if ( - (start !== undefined && !Number.isFinite(start)) || - (end !== undefined && !Number.isFinite(end)) || - typeof integerOnly !== "boolean" || - typeof varName !== "string" - ) - throw new TypeError("Variable name or options are of invalid type"); - - if (typeof number !== "number" || !Number.isFinite(number)) - throw new TypeError(`${varName} must be defined finite number`); - - if (integerOnly && !Number.isInteger(number)) - throw new TypeError(`${varName} must be integer`); - - if (start !== undefined && end !== undefined && end < start) - throw new TypeError(`minimum can't be higher than maximum`); - - if ( - (start !== undefined && number < start) || - (end !== undefined && end < number) - ) - throw new RangeError( - `${varName} must have: -${start !== undefined ? "Minimum of: " + start + "\n" : "" - }${end !== undefined ? "Maximum of: " + end + "\n" : ""}`, - ); -} diff --git a/src/core/algorithms/graphic-algorithms.ts b/src/core/algorithms/graphic-algorithms.ts new file mode 100644 index 0000000..8323025 --- /dev/null +++ b/src/core/algorithms/graphic-algorithms.ts @@ -0,0 +1,187 @@ +export function drawPixel({ x, y, diameter = 5, isSquare = false, setPixel }: { + x: number, + y: number, + diameter?: number, + isSquare?: boolean, + setPixel: (x: number, y: number) => void +}) { + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + setPixel(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + + if (dx * dx + dy * dy <= radiusSquared) { + setPixel(currentX, currentY); + } + } +} + +export function drawLine({ x0, y0, x1, y1, setPixel }: { + x0: number; + y0: number; + x1: number; + y1: number; + setPixel: (x: number, y: number) => void; +}) { + + // Standard Bresenham's algorithm + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + + while (true) { + setPixel(x0, y0); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } +} + +export function drawVaryingThicknessLine({x0, y0, x1, y1, thicknessFunction, setPixel}: { + x0: number, + y0: number, + x1: number, + y1: number, + thicknessFunction: (...args: any[]) => number, + setPixel: (x: number, y: number) => void, +}) { + const drawPrepLine = ( + x0: number, + y0: number, + dx: number, + dy: number, + width: number, + initError: number, + initWidth: number, + direction: number, + ) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + + while (thickness <= widthThreshold) { + setPixel(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = ( + x0: number, + y0: number, + x1: number, + y1: number, + thicknessFunction: (...args: any[]) => number, + ) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError, + error, + dir, + ); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError + diagonalError + stepError, + error, + dir, + ); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents( + y0, + x0, + y1, + x1, + thicknessFunction, + ); + else + drawLineRightLeftOctents( + x0, + y0, + x1, + y1, + thicknessFunction, + ); +} + + diff --git a/src/core/events.ts b/src/core/events.ts new file mode 100644 index 0000000..0712bbf --- /dev/null +++ b/src/core/events.ts @@ -0,0 +1,19 @@ +// Core events.ts +export type CoreEvents = { + // System lifecycle + "MODULE_REGISTERED": { module: string }, + "DEPENDENCY_READY": { service: string }, + + // User actions + "TOOL_ACTION": { tool: string, action: "start" | "move" | "end", coordinates: [number, number] }, + + // State changes + "CANVAS_STATE_CHANGED": { layers: string[], activeLayer: string } +}; +// +// // Extension pattern +// declare module "./events" { +// interface EventTypes { +// "CUSTOM_TOOL_EVENT": { customData: any }; +// } +// } diff --git a/src/core/layers/concrete/pixel-layer.ts b/src/core/layers/concrete/pixel-layer.ts new file mode 100644 index 0000000..519fc1c --- /dev/null +++ b/src/core/layers/concrete/pixel-layer.ts @@ -0,0 +1,382 @@ +import LayerHistory from "../layer-history.js"; +import { PixelState } from "@src/types/pixel-types.js"; +import PixelChanges from "@src/services/pixel-change.js"; +import { validateNumber } from "@src/utils/validation.js"; +import Color from "@src/services/color.js"; +import { HistoryMove, RecordData } from "@src/types/history-types.js"; +import Drawable from "@src/interfaces/drawable.js"; +import Historyable from "@src/interfaces/historyable.js"; + +/** + * Represents a canvas grid system + * @class + */ +export default class PixelLayer implements Drawable, Historyable { + + /** + * The width of the canvas + */ + private layerWidth: number; + + /** + * The height of the canvas + */ + private layerHeight: number; + + /** + * Current used action + */ + private inAction: boolean = false; + + /** + * The action history system to store main changes + */ + private history: LayerHistory = new LayerHistory(64); + + /** + * The 2-D grid containing the Pixel data of the canvas + */ + private pixelMatrix: PixelState[]; + + /** + * Buffer logs changes performed on pixels (Ex. color change) + */ + private pixelChanges: PixelChanges = new PixelChanges(); + + /** + * Creates a blank canvas with specified width and height + * @constructor + * @param [width=1] - The width of the grid + * @param [height=1] - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + constructor(width: number = 1, height: number = 1) { + this.initializeBlankCanvas(width, height); + } + + /** + * Initializes the canvas with a blank grid of transparent pixel data + * @method + * @param width - The width of the grid + * @param height - The height of the grid + * @throws {TypeError} If width or height are not integers + * @throws {RangeError} If width or height are not between 1 and 1024 inclusive + */ + initializeBlankCanvas(width: number, height: number) { + validateNumber(width, "Width", { start: 1, end: 1024, integerOnly: true }); + validateNumber(height, "Height", { start: 1, end: 1024, integerOnly: true }); + + this.layerWidth = width; + this.layerHeight = height; + this.pixelMatrix = new Array( width * height ); + for (let x = 0; x < this.layerWidth; x++) { + for (let y = 0; y < this.layerHeight; y++) { + this.pixelMatrix[x + this.layerWidth * y] = { color: Color.TRANSPARENT }; + } + } + } + + /** + * Loads an image data at (x, y) position + * @method + * @param imageData - The image to be loaded + * @param [x0=0] - X-coordinate + * @param [y0=0] - Y-coordinate + * @throws {TypeError} If x or y are not integers + */ + loadImage(imageData: ImageData, x0: number = 0, y0: number = 0) { + validateNumber(x0, "x", { integerOnly: true }); + validateNumber(y0, "y", { integerOnly: true }); + + let start_y = Math.max(y0, 0); + let start_x = Math.max(x0, 0); + for ( + let y = start_y; + y < imageData.height + y0 && y < this.layerHeight; + y++ + ) { + for ( + let x = start_x; + x < imageData.width + x0 && x < this.layerWidth; + x++ + ) { + let dist = (x - x0 + imageData.width * (y - y0)) * 4; + + let red = imageData.data[dist + 0]; + let green = imageData.data[dist + 1]; + let blue = imageData.data[dist + 2]; + let alpha = imageData.data[dist + 3]; + + this.setColor(x, y, Color.get({ rgb: [red, green, blue], alpha: alpha / 255 }), { validate: false }); + } + } + } + + /** + * Gets an image data from certain area + * @method + * @param [x0=0] - start X-coordinate + * @param [y0=0] - start Y-coordinate + * @param [x1=this.width] - end X-coordinate + * @param [y1=this.height] - end Y-coordinate + * @returns An image data object for the specified area of the layer + * @throws {TypeError} If x or y are not integers + */ + getImage(x0: number = 0, y0: number = 0, x1: number = this.width, y1: number = this.height): ImageData { + validateNumber(x0, "x0", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y0, "y0", { start: 0, end: this.height - 1, integerOnly: true }); + validateNumber(x1, "x1", { start: 0, end: this.width - 1, integerOnly: true }); + validateNumber(y1, "y1", { start: 0, end: this.height - 1, integerOnly: true }); + + if (x0 > x1) [x0, y0] = [x1, y1]; + if (y0 > y1) [y0, y1] = [y1, y0]; + + const image = new ImageData(x1 - x0 + 1, y1 - y0 + 1); + + for (let x = x0; x <= x1; x++) { + for (let y = y0; y <= y1; y++) { + const dist = (x - x0 + image.width * (y - y0)) * 4; + const color = this.getColor(x, y); + + image.data[dist + 0] = color.rgb[0]; + image.data[dist + 1] = color.rgb[1]; + image.data[dist + 2] = color.rgb[2]; + image.data[dist + 3] = Math.abs(color.alpha * 255); + } + } + return image; + } + + + /** + * Clears the layer + * @method + */ + clear() { + for (let i = 0; i < this.layerHeight; i++) { + for (let j = 0; j < this.layerWidth; j++) { + this.setColor(j, i, Color.TRANSPARENT, { validate: false }); + } + } + } + + /** + * Resets changes buffer to be empty + * @method + * @returns Change buffer before emptying + */ + resetChangeBuffer(): PixelChanges { + const changeBuffer = this.pixelChanges; + this.pixelChanges = new PixelChanges(); + return changeBuffer; + } + + /** + * Starts a new action into the history with given name + * @param actionName - The name + * @method + */ + startAction(actionName: string) { + if (this.inAction) this.endAction(); + this.history.setRecord({ + name: actionName, + timestamp: Date.now(), + change: new PixelChanges(), + steps: [], + }); + this.inAction = true; + } + + /** + * Commits current pixel buffer to current action in history then resets change buffer + * @method + * @throws {Error} If no active action to add steps to + */ + commitStep(): PixelChanges { + if (!this.history.getRecordData()) + throw new Error("No active action to add step to"); + + const record = this.history.getRecordData(); + + if (this.pixelChanges.isEmpty) return this.pixelChanges.clone(); + + if (record.steps.length === 10 || this.pixelChanges.count >= 100) + compressActionSteps(record); + + this.history.getRecordData().steps.push(this.pixelChanges); + + return this.resetChangeBuffer(); + } + + /** + * Ends the current action in the history + * @method + */ + endAction() { + if (!this.isInAction) return; + this.inAction = false; + } + + /** + * Cancels the current action in the history + * @method + */ + cancelAction() { + if (!this.isInAction) return; + this.endAction(); + this.undo(); + } + + /** + * Undos an action + * @method + */ + undo() { + this.cancelAction(); + + if (this.history.atStart) return; + + this.history.undo(); + + this.applyRecord(HistoryMove.Backward); + } + + /** + * Redos an action + * @method + */ + redo() { + this.cancelAction(); + + if (this.history.atEnd) return; + + this.history.redo(); + + this.applyRecord(HistoryMove.Forward); + } + + /** helper method */ + private applyRecord(direction: HistoryMove) { + const record = this.history.getRecordData(); + + let state: string; + if (direction === HistoryMove.Forward) + state = "after"; + else if (direction === HistoryMove.Backward) + state = "before"; + + if (record.steps.length !== 0) + compressActionSteps(record); + + for (const change of record.change) + this.setColor(change.key.x, change.key.y, change.states[state].color, { quietly: true, validate: false }); + } + + /** + * Sets color to pixel at position (x, y). + * @method + * @param x - X-coordinate. + * @param y - X-coordinate. + * @param color - The Color object to be set + * @param options - An object containing additional options. + * @param [options.quietly=false] - If set to true, the pixel data at which color changed will not be pushed to the changeBuffers array. + * @param [options.validate=true] - If set to true, the x, y, and color types are validated. + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + * @throws {Error} If not quiet when no action is active + */ + setColor(x: number, y: number, color: Color, { quietly = false, validate = true } = {}) { + if (validate) { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + } + + if (!quietly) { + if (!this.isInAction) + throw new Error("Cannot set color outside of an action"); + + const newColor: Color = color, oldColor = this.pixelMatrix[x + y * this.layerWidth].color; + + if (!Color.isEqualTo(this.pixelMatrix[x + y * this.layerWidth].color, color)) { + this.pixelChanges.setChange( + { x, y }, + { color: newColor }, + { color: oldColor }) + } + } + this.pixelMatrix[x + y * this.layerWidth].color = color; + } + + /** + * Returns pixel data at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Pixel data at position (x, y) + * @throws {TypeError} If validate is true and x and y are not valid integers in valid range. + * @throws {RangeError} If validate is true and if x and y are not in valid range. + */ + get(x: number, y: number): PixelState { + validateNumber(x, "x", { start: 0, end: this.layerWidth - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.layerHeight - 1, integerOnly: true }); + + return this.pixelMatrix[x + y * this.layerWidth]; + } + + /** + * Returns pixel color at position (x, y) + * @method + * @param x - X-coordinate + * @param y - Y-coordinate + * @returns Color object of pixel at position (x, y) + */ + getColor(x: number, y: number): Color { + return this.get(x, y).color; + } + + /** + * Returns copy of change buffer + * @method + * @returns Copy of change buffer + */ + get changeBuffer(): PixelChanges { + return this + .pixelChanges.clone(); + } + + /** + * Returns the width of the canvas + * @method + * @returns The width of the canvas + */ + get width(): number { + return this.layerWidth; + } + + /** + * Returns the height of the canvas + * @method + * @returns The height of the canvas + */ + get height(): number { + return this.layerHeight; + } + + /** + * Returns whether an action is active + * @method + * @returns Whether an action is active + */ + get isInAction(): boolean { + return this.inAction; + } +} + +function compressActionSteps(record: RecordData) { + record.steps.reduce( + (totalChange: PixelChanges, step: PixelChanges) => + totalChange.mergeMutable(step), + record.change); + record.steps = []; +} diff --git a/src/core/layers/layer-history.ts b/src/core/layers/layer-history.ts new file mode 100644 index 0000000..08c0d51 --- /dev/null +++ b/src/core/layers/layer-history.ts @@ -0,0 +1,15 @@ +import HistorySystem from "@src/generics/history-system.js"; +import PixelChanges from "@src/services/pixel-change.js"; +import { RecordData } from "@src/types/history-types.js"; + +export default class LayerHistory extends HistorySystem { + constructor(capacity: number) { + super(capacity, { + name: "START", + timestamp: Date.now(), + change: new PixelChanges(), + steps: [] + }); + } +} + diff --git a/src/core/managers/layer-manager.ts b/src/core/managers/layer-manager.ts new file mode 100644 index 0000000..a3e1dd6 --- /dev/null +++ b/src/core/managers/layer-manager.ts @@ -0,0 +1,411 @@ +import { validateNumber } from "@src/utils/validation.js"; +import PixelLayer from "../layers/concrete/pixel-layer.js"; +import { PixelCoord, PixelRectangleBounds } from "@src/types/pixel-types.js"; +import Color from "@src/services/color.js"; + +export type LayerData = { + id: number, + name: string, + pixelLayer: PixelLayer +} + +/** + * Represents a system for managing layers of canvas grids + * @class + */ +export default class LayerManager { + + /** + * A map containing layers accessed by their IDs + */ + private layers: Map = new Map(); + + /** + * A Layer for previewing actions + */ + previewLayer: PixelLayer; + + /** + * The currently active layer + */ + activeLayer: PixelLayer | null = null; + + /** + * A set of IDs of the currently selected layers + */ + private selections: Set = new Set(); + + /** + * An array for maintaining order, holds IDs of the layers + */ + private layerOrder: number[] = []; + + /** + * Dimensions of canvases that the layer system holds + */ + private canvasWidth: number; + private canvasHeight: number; + + /** + * Internal counter to enumerate increamental IDs for the created layers + */ + private layerIDCounter: number = -1; + + /** + * Cache of the rendered image + */ + private renderCache: Map = new Map(); + + /** + * Colors of the checkerboard background of transparent canvas + */ + private darkBG: Color = Color.get({ rgb: [160, 160, 160], alpha: 1 }); + private lightBG: Color = Color.get({ rgb: [217, 217, 217], alpha: 1 }); + + /** + * Represents a system for managing layers of canvas + * @constructor + * @param [width=1] - The width of the canvas grid for the layers + * @param [height=1] height - The height of the canvas grid for the layers + * @param events - The event bus for subscribing to events + * @throws {TypeError} if width or height are not integers + * @throws {RangeError} if width or height are not between 1 and 1024 inclusive + */ + constructor(width: number = 1, height: number = 1) { + validateNumber(width, "width", { + start: 1, + end: 1024, + integerOnly: true, + }); + validateNumber(height, "height", { + start: 1, + end: 1024, + integerOnly: true, + }); + this.canvasWidth = width; + this.canvasHeight = height; + this.previewLayer = new PixelLayer(this.canvasWidth, this.canvasHeight); + } + + /** + * validates IDs in the layers list + * @method + * @param ids - The IDs of the layers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + * @throws {TypeError} If the IDs is not integers + */ + private validate(...ids: number[]) { + if (this.layers.size === 0) + throw new RangeError("No layers to get"); + + for (let id of ids) { + validateNumber(id, "ID", { integerOnly: true, }); + + if (!this.layerOrder.includes(id)) + throw new RangeError(`Layer with ${id} ID is not found`); + } + } + + /** + * Adds a new layer object into the layers list, if only layer in list, is set as the active layer + * @method + * @param name - The name of the layer to be added + * @returns the ID of the newly created layer + * @throws {TypeError} If the name is not string + */ + add(name: string): number { + let id = ++this.layerIDCounter; + + let newLayer = { + id: id, + name: name, + pixelLayer: new PixelLayer(this.canvasWidth, this.canvasHeight), + }; + + this.layers.set(id, newLayer); + this.layerOrder.push(id); + this.activeLayer = this.activeLayer ?? newLayer.pixelLayer; + return id; + } + + /** + * Delete layers with given IDs from layers list and set active layer to null if got deleted. If no ID given, delete selected layers + * @param ids - The IDs of the layers to be removed + * @method + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the index is out of valid range + */ + remove(...ids: number[]) { + if (ids.length === 0) ids = Array.from(this.selections); + this.validate(...ids); + + // reverse order to avoid much index shifting + ids.sort((a, b) => this.layerOrder.indexOf(b) - this.layerOrder.indexOf(a)) + .forEach(id => { + if (this.layers.get(id).pixelLayer === this.activeLayer) this.activeLayer = null; + this.selections.delete(id); + this.layers.delete(id); + this.layerOrder.splice(this.layerOrder.indexOf(id), 1); + }); + } + + /** + * Sets the active layer + * @method + * @param id - The ID of the layer to be activated + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + activate(id: number) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + + this.validate(id); + this.activeLayer = this.layers.get(id).pixelLayer; + } + + /** + * Selects layers in the layers list + * @method + * @param ids - The IDs to select, if an ID is for an already selected layer, ignore it + * @throws {TypeError} If the IDs are not integers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + */ + select(...ids: number[]) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + + this.validate(...ids); + + // selection + for (let id of ids) { + this.selections.add(id); + } + } + + /** + * Deselects layers in the layers list + * @method + * @param ids - The IDs to deselect, if an ID is for an already unselected layer, ignores it + * @throws {TypeError} If the IDs are not integers + * @throws {RangeError} If the layer list is empty or the IDs are not in the list + */ + deselect(...ids: number[]) { + if (this.layers.size === 0) + throw new RangeError("No layers to select"); + + // validation + for (let id of ids) { + this.validate(id); + } + + // deselection + for (let id of ids) { + if (this.selections.has(id)) // if + this.selections.delete(id); + } + } + + /** + * Deselects all layers + */ + clearSelection() { + this.selections.clear(); + } + + /** + * Changes the position of a single layer in the layer list + * @method + * @param offset - The offset by which to move the layer + * @param id - The ID of the layer to move + * @throws {TypeError} If the offset or ID are not a valid integers + * @throws {RangeError} If the layer list is empty or the ID is not in the layer list + */ + move(offset: number, id: number) { + if (this.layers.size === 0) { + throw new RangeError("No layers to move"); + } + + validateNumber(offset, "Offset", { integerOnly: true }); + this.validate(id); + + const currentIndex = this.layerOrder.indexOf(id); + let newIndex = currentIndex + offset; + + // clamp the new index to valid range + newIndex = Math.max(0, Math.min(newIndex, this.layerOrder.length - 1)); + + if (newIndex !== currentIndex) { + this.layerOrder.splice(currentIndex, 1); + this.layerOrder.splice(newIndex, 0, id); + } + } + + /** + * Retrieves the image at the specified bounded rectangle in the canvas, the whole canvas if no changes given + * @method + * @param bounds - The bounds of the changed pixels, if null, update everything + * @returns The resulting image data of the compsited layers and the starting position + */ + renderImage(bounds: PixelRectangleBounds = { + x0: 0, + y0: 0, + x1: this.canvasWidth - 1, + y1: this.canvasHeight - 1, + }): { image: ImageData, x0: number, y0: number } { + + let image: ImageData; + + const normalizeBounds = (bounds: PixelRectangleBounds): PixelRectangleBounds => { + const { x0, y0, x1, y1 } = bounds; + return { + x0: Math.min(x0, this.canvasWidth - 1), + y0: Math.min(y0, this.canvasHeight - 1), + x1: Math.max(x1, 0), + y1: Math.max(y1, 0), + } + } + + const fillImage = (x: number, y: number, x0: number, y0: number) => { + const index = ((y - y0) * this.canvasWidth + (x - x0)) * 4; + + const color = this.getColor(x, y); + + image.data[index + 0] = color.rgb[0]; + image.data[index + 1] = color.rgb[1]; + image.data[index + 2] = color.rgb[2]; + image.data[index + 3] = Math.round(color.alpha * 255); + } + + bounds = normalizeBounds(bounds); + + image = new ImageData(bounds.x1 - bounds.x0 + 1, bounds.y1 - bounds.y0 + 1); + for (let y = bounds.y0; y <= bounds.y1; y++) + for (let x = bounds.x0; x <= bounds.x1; x++) + fillImage(x, y, bounds.x0, bounds.y0); + + return { image, x0: bounds.x0, y0: bounds.y0 }; + } + + /** + * Sets the two colors of the checkerboard background covor of the canvas + * @method + * @param lightBG - The first color + * @param darkBG - The second color + */ + setBackgroundColors(lightBG: Color, darkBG: Color) { + this.lightBG = lightBG; + this.darkBG = darkBG; + } + + /** + * Sets a new name to a layer in the layer list for given ID + * @param id - The ID of the layer + * @param name - The index to change to in the layer list + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + setName(id: number, name: string) { + this.validate(id); + this.layers.get(id).name = name; + } + + /** + * Retrieves the layer in the layer list for given ID + * @param id - The ID of the layer + * @returns the layer object + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + getLayer(id: number): PixelLayer { + this.validate(id); + return this.layers.get(id).pixelLayer; + } + + /** + * Retrieves the resulting color of all layers in the list at a pixel position + * @method + * @param x - The X-Coordinate + * @param y - The Y-Coordinate + * @returns The resulting color object of all layers at the specified pixel position + * @throws {TypeError} If X-Coordinate or Y-Coordinate are not valid integers + * @throws {RangeError} If X-Coordinate or Y-Coordinate are not in valid range + */ + getColor(x: number, y: number): Color { + validateNumber(x, "x", { start: 0, end: this.canvasWidth, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.canvasHeight, integerOnly: true }); + + if (this.renderCache.has({ x, y })) return this.renderCache.get({ x, y }); + + let finalColor = (x + y) % 2 ? this.lightBG : this.darkBG; + + for (let i = this.layerOrder.length - 1; i >= 0; i--) { + const layer = this.layers.get(this.layerOrder[i]).pixelLayer; + const layerColor = layer.getColor(x, y); + + if (layerColor.alpha <= 0) continue; + + finalColor = Color.compositeOver(layerColor, finalColor); + } + + return finalColor; + }; + + /** + * Retrieves name of a layer in the layer list for given ID + * @param id - The ID of the layer + * @returns the name of the layer + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty or the ID is not in the list + */ + getName(id: number): string { + this.validate(id); + return this.layers.get(id).name; + } + + + /** + * Retrieves width of canvas grid for which the layer system is applied + * @method + * @returns The width of the canvas grid for the layers + */ + get width(): number { + return this.canvasWidth; + } + + /** + * Retrieves height of canvas grid for which the layer system is applied + * @method + * @returns The height of the canvas grid for the layers + */ + get height(): number { + return this.canvasHeight; + } + + /** + * Retrieves number of layers in the layer list + * @method + * @returns the number of layers + */ + get size(): number { + return this.layers.size; + } + + /** + * Retrieves list of IDs, names and Layer objects of all layers in the list (or just selected ones if specified) + * @method + * @param [selectedOnly=false] - if true, retrieves only selected layers + * @returns Array of objects containing IDs, names and Layer objects of the layers + */ + *list(selectedOnly: boolean = false): IterableIterator { + if (selectedOnly) + for (let id of this.layerOrder) { + if (this.selections.has(id)) + yield this.layers.get(id); + } + else + for (let id of this.layerOrder) { + yield this.layers.get(id); + } + } +} diff --git a/src/core/managers/tool-manager.bak b/src/core/managers/tool-manager.bak new file mode 100644 index 0000000..34e06fe --- /dev/null +++ b/src/core/managers/tool-manager.bak @@ -0,0 +1,211 @@ +import { validateNumber } from "@src/utils/validation"; +import { penTool } from "@src/core/tools/tools"; +import Tool from "@src/core/tools/tool"; +import Color from "@src/services/color-service"; +import EventBus from "@src/services/event-bus"; + +/** + * Class for managing the canvas tools and their functionalities + * @class + */ +class ToolManager { + // private drawColor: Color; + // private eraseColor: Color; + // private drawSize: number; + // private eraseSize: number; + // private tolerance: number; + // private intensity: number; + private image: ImageData; + // + // private startPixel = null; + // private recentPixel = null; + // private isActionStart = false; + // private toolName; + // + // private metaData; + // + // private recentRect = new ChangeRegion(); + // private currentRect = new ChangeRegion(); + + + private tools: Map; + private selectedTool: Tool; + + /* + * Creates a ToolManager class that manages tools for the canvas, and applies their functionalities to the layerSystem and drawingManager, and renders the result to canvasManager + * @constructor + * @param {CanvasManager} canvasManager - the canvasManager that will be rendered to + * @param {LayerSystem} layerSystem - the layerSystem that the tool will be applied to + * @param {DrawingManager} drawingManager - the drawingManager that tool will be rendered to + */ + constructor(events: EventBus) { + this.tools = new Map([ + ["pen", penTool], + ]); + Tool.image = this.image; + this.selectedTool = this.tools.get("pen"); + + this.setupEvents(events); + } + + // setDrawingColor(color: Color) { + // this.drawColor = color; + // } + // + // setErasingColor(color: Color) { + // this.eraseColor = color; + // } + // + // setDrawingSize(size: number) { + // validateNumber(size, "Size", { start: 1, integerOnly: true }); + // this.drawSize = size; + // } + // + // setErasingSize(size: number) { + // validateNumber(size, "Size", { start: 1, integerOnly: true }); + // this.eraseSize = size; + // } + // + // setTolerance(tolerance: number) { + // validateNumber(tolerance, "Tolerance", { start: 1, integerOnly: true }); + // this.tolerance = tolerance; + // } + // + // setIntensity(intensity: number) { + // validateNumber(intensity, "Intensity", { start: 1, integerOnly: true }); + // this.intensity = intensity; + // } + // + // use(event: string, pixelPosition: {x: number, y: number}) { + // let metaData; + // let command; + // switch (this.toolName) { + // case "pen": + // metaData = { + // size: this.drawSize, + // color: this.drawColor, + // }; + // break; + // case "eraser": + // metaData = { + // size: this.eraseSize, + // color: this.eraseColor, + // }; + // break; + // case "line": + // metaData = { + // thicknessTimeFunction: () => this.drawSize, + // color: this.drawColor, + // }; + // break; + // case "bucket": + // metaData = { + // tolerance: this.tolerance, + // color: this.drawColor, + // }; + // break; + // } + // + // switch (event) { + // case "start-action": + // this.drawingTool.startAction(this.toolName, metaData); + // // this.#events.emit("layer:preview", { + // // this.#drawingTool.action(pixelPosition) + // // }); + // this.render(this.drawingTool.action(pixelPosition)); + // break; + // case "move-action": + // // this.#events.emit("layer:repreview", { + // // this.#drawingTool.action(pixelPosition) + // // }); + // this.render(this.drawingTool.action(pixelPosition)); + // break; + // case "mousehover": + // //this.render(this.#drawingManager.preview(pixelPosition)); + // break; + // case "end-action": + // // this.#events.emit("layer:perform", { + // // this.#drawingTool.action(pixelPosition) + // // }); + // //this.render(this.#drawingManager.action(pixelPosition)); + // // ended action + // this.drawingTool.endAction(); + // break; + // case "eye-dropper": + // // !!! + // break; + // } + // } + + // render(toRender) { + // if (toRender.pixelPositions.length == 0) return; + // + // // this.#canvasManager.render( + // // this.#layerManager.getRenderImage( + // // this.#canvasManager.getCanvasContext, + // // toRender, + // // ), + // // toRender.dimensions.x0, + // // toRender.dimensions.y0, + // // ); + // } + + setupEvents(events: EventBus) { + events.subscribe("tool:use", 5, (details) => { + this.selectedTool; + }); + } +} + +export default ToolManager; + +/** + * draws pixel at position (x, y) with a specific diameter + * @method + * @param params + * @param params.x - x position + * @param params.y - y position + * @param [params.diameter=1] - Diameter for drawing the pixel, minimum = 1 + * @param [params.isSquare=true] - Draws a square pixel with side = radius, draws in a circle shape otherwise + * @throws {TypeError} - if x or y are not integers + * @throws {RangeError} - if diameter is less than 1 + */ +function drawPixel({ x, y, diameter = 1, isSquare = true, setPixel }: { + x: number, + y: number, + diameter?: number, + isSquare?: boolean, + setPixel: (x: number, y: number) => void +}) { + console.log(x, y); + validateNumber(x, "x", { integerOnly: true }); + validateNumber(y, "y", { integerOnly: true }); + validateNumber(diameter, "Diameter", { start: 1 }); + + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + setPixel(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + + if (dx * dx + dy * dy <= radiusSquared) { + setPixel(currentX, currentY); + } + } +} + diff --git a/src/core/managers/tool-manager.ts b/src/core/managers/tool-manager.ts new file mode 100644 index 0000000..f2e015d --- /dev/null +++ b/src/core/managers/tool-manager.ts @@ -0,0 +1,131 @@ +import Color from "@src/services/color.js"; +import Tool from "@src/core/tools/base/tool-base.js"; +import PenTool from "../tools/implementations/pen-tool.js"; + +import Historyable from "@src/interfaces/historyable.js"; +import Drawable from "@src/interfaces/drawable.js"; +import { validateNumber } from "@src/utils/validation.js"; + +/** + * Class for managing the canvas tools and their functionalities + * @class + */ +export default class ToolManager { + private drawColor: Color; + private eraseColor: Color; + private drawSize: number; + private eraseSize: number; + selectedTool: Tool; + tools: Map; + drawingColor: Color = Color.get({ hex: "#0f0" }); + eraserColor: Color = Color.get({ hex: "#0000" }); + + /** + * Creates a ToolManager class that manages tools for the canvas, and applies their functionalities to the layerSystem and drawingManager, and renders the result to canvasManager + * @constructor + * @param events - the event bus that will be used to subscribe to events + * @param image - the image data that will be used to draw on + */ + constructor(context: Historyable & Drawable) { + this.tools = new Map([ + ["pen", new PenTool(context)], + ]); + this.selectedTool = this.tools.get("pen"); + } + + setDrawingColor(color: Color) { + this.drawColor = color; + } + + setErasingColor(color: Color) { + this.eraseColor = color; + } + + setDrawingSize(size: number) { + validateNumber(size, "Size", { start: 1, integerOnly: true }); + this.drawSize = size; + } + + setErasingSize(size: number) { + validateNumber(size, "Size", { start: 1, integerOnly: true }); + this.eraseSize = size; + } + +} + +// private tolerance: number; +// private intensity: number; +// private image: ImageData; + + +// setTolerance(tolerance: number) { +// validateNumber(tolerance, "Tolerance", { start: 1, integerOnly: true }); +// this.tolerance = tolerance; +// } +// +// setIntensity(intensity: number) { +// validateNumber(intensity, "Intensity", { start: 1, integerOnly: true }); +// this.intensity = intensity; +// } +// +// use(event: string, pixelPosition: {x: number, y: number}) { +// let metaData; +// let command; +// switch (this.toolName) { +// case "pen": +// metaData = { +// size: this.drawSize, +// color: this.drawColor, +// }; +// break; +// case "eraser": +// metaData = { +// size: this.eraseSize, +// color: this.eraseColor, +// }; +// break; +// case "line": +// metaData = { +// thicknessTimeFunction: () => this.drawSize, +// color: this.drawColor, +// }; +// break; +// case "bucket": +// metaData = { +// tolerance: this.tolerance, +// color: this.drawColor, +// }; +// break; +// } +// +// switch (event) { +// case "start-action": +// this.drawingTool.startAction(this.toolName, metaData); +// // this.#events.emit("layer:preview", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// this.render(this.drawingTool.action(pixelPosition)); +// break; +// case "move-action": +// // this.#events.emit("layer:repreview", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// this.render(this.drawingTool.action(pixelPosition)); +// break; +// case "mousehover": +// //this.render(this.#drawingManager.preview(pixelPosition)); +// break; +// case "end-action": +// // this.#events.emit("layer:perform", { +// // this.#drawingTool.action(pixelPosition) +// // }); +// //this.render(this.#drawingManager.action(pixelPosition)); +// // ended action +// this.drawingTool.endAction(); +// break; +// case "eye-dropper": +// // !!! +// break; +// } +// } + diff --git a/src/core/pixel-editor.ts b/src/core/pixel-editor.ts new file mode 100644 index 0000000..c8a89b4 --- /dev/null +++ b/src/core/pixel-editor.ts @@ -0,0 +1,122 @@ +import { validateNumber } from "@src/utils/validation.js"; +import LayerManager from "@src/core/managers/layer-manager.js"; +import ToolManager from "@src/core/managers/tool-manager.js"; +import EventBus from "@src/services/event-bus.js"; +import Canvas from "@src/core/ui-components/canvas.js"; +import { PixelRectangleBounds } from "@src/types/pixel-types.js"; + +/** + * Responsible for managing events and functionalities of the canvas element inside its container + * @class + */ +class PixelEditor { + layerManager: LayerManager; + toolManager: ToolManager; + width: number; + height: number; + canvas: Canvas; + events: EventBus; + + /** + * Creates a canvas elements inside the given container and initializes it with width and height + * @constructor + * @param width - Integer represents the width of the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the height of the canvas, range is [0, 1024] inclusive + * @param {HTMLElement} containerElement - The DOM Element that will contain the canvas + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + constructor(containerElement: HTMLElement, width: number, height: number) { + this.events = new EventBus(); + this.canvas = new Canvas(containerElement, this.events); + this.createBlankBoard(width, height); + this.setupEvents(); + } + + /** + * Creates a blank board with given canvas width and height + * @method + * @param width - Integer represents the width of the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the height of the canvas, range is [0, 1024] inclusive + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + createBlankBoard(width: number, height: number) { + validateNumber(width, "Width", { integerOnly: true, start: 1, end: 1024 }); + validateNumber(height, "Height", { integerOnly: true, start: 1, end: 1024 }); + + this.width = width; + this.height = height; + + this.canvas.createBlankCanvas(width, height); + + this.layerManager = new LayerManager(width, height); + this.layerManager.add("Background"); + + this.toolManager = new ToolManager(this.layerManager.activeLayer); + } + + /** + * Loads image into the current layer + * @method + * @param clientX - The x position on the scaled canvas element to put the image + * @param clientY - The y position on the scaled canvas element to put the image + * @throws {TypeError} if the imageURL is not a valid image url + */ + async loadImage(clientX: number, clientY: number, imageURL: string) { + let pixel = this.canvas.getPixelPosition(clientX, clientY); + + const image: ImageData = await new Promise((resolve, reject) => { + + // validating the imageURL and setting the img + const pattern = /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i; + if (!pattern.test(imageURL)) + () => reject(TypeError("imgaeURL must be a valid image URL")); + + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = imageURL; + + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + resolve(ctx.getImageData(0, 0, canvas.width, canvas.height)); + } + img.onerror = () => reject(new Error("Image failed to load")); + }); + + this.layerManager.activeLayer.loadImage(image, pixel.x, pixel.y); + } + + render(bounds: PixelRectangleBounds = { x0: 0, y0: 0, x1: this.width - 1, y1: this.height - 1 }) { + const { image, x0, y0 } = this.layerManager.renderImage(bounds); + this.canvas.render(image, x0, y0); + } + + setupEvents() { + this.events.on("tool:use", () => { + this.toolManager.selectedTool = this.toolManager.tools.get("pen"); + }); + this.events.on("canvas:mousemove", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseMove(coordinates); + if (bounds) this.render(); + }); + this.events.on("canvas:mousedown", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseDown(coordinates); + console.log(coordinates, bounds); + if (bounds) this.render(); + }); + this.events.on("canvas:mouseup", ({ coordinates }) => { + const bounds = this.toolManager.selectedTool.mouseUp(coordinates); + if (bounds) this.render(); + }); + // events.on("tool:apply-action", (actionName: string, change: PixelChanges, reapply: boolean, preview: boolean) => { + // this.selectedTool.applyAction(actionName, change, reapply, preview); + // }); + } +} + +export default PixelEditor; diff --git a/src/core/tools/base/tool-base.ts b/src/core/tools/base/tool-base.ts new file mode 100644 index 0000000..172c45b --- /dev/null +++ b/src/core/tools/base/tool-base.ts @@ -0,0 +1,17 @@ +import { PixelCoord, PixelRectangleBounds } from "@src/types/pixel-types.js" + +// Simplified Tool Base Class +export default abstract class Tool { + protected preview: boolean; + + abstract mouseDown?(coord: PixelCoord): PixelRectangleBounds | null; + abstract mouseMove?(coord: PixelCoord): PixelRectangleBounds | null; + abstract mouseUp?(coord: PixelCoord): PixelRectangleBounds | null; +} + +export abstract class ContinousTool extends Tool { + protected abstract startState: any; + protected abstract recentState: any; + protected abstract readonly redraw: any; + protected abstract toolEventState: any; +} diff --git a/src/core/tools/implementations/line-tool.js b/src/core/tools/implementations/line-tool.js new file mode 100644 index 0000000..16c8d7f --- /dev/null +++ b/src/core/tools/implementations/line-tool.js @@ -0,0 +1,243 @@ +import LayerManager from "#core/layers/layer-manager"; +import Tool from "#core/tools/base-tool"; +import ChangeRegion from "#services/change-region"; +import Color from "#services/color"; + +/** + * Contains graphics methods to draw on layers managed by a layer manager class + * @class + */ +class LineTool extends Tool { + #layerManager; + state = "idle"; + + /** + * Sets a specific layer manager class for which the layers will be drawn on + * @constructor + */ + constructor(layerManager) { + if (!(layerManager instanceof LayerManager)) + throw new TypeError( + "Input type must be of instance of LayerSystem class", + ); + + this.#layerManager = layerManager; + } + + createCommand({ + x0 = 0, + y0 = 0, + x1 = 0, + y1 = 0, + color = new Color({ hex: "#ff0000" }), + size = 1 + }) { + + new Command({ name: "pen" }); + + Command.action = function() { + const setPixel = (x, y) => { + if ( + x < 0 || + y < 0 || + x >= this.#layerManager.width || + y >= this.#layerManager.height + ) + return; + + this.layer.setColor(x, y, color, { validate: false }); + }; + + this.drawPixel( + x0, + y0, + size, + true, + setPixel, + ); + + if (startPosition.x !== endPosition.x && startPosition.y !== endPosition.y) + this.drawLine( + x0, + y0, + x1, + y1, + () => 1, + (x, y) => { + this.drawPixel( + x, + y, + size, + true, + setPixel, + ); + }, + ); + } + + return new Command(); + + } + + action(pixelPosition) { + + let toRender = new ChangeRegion(); + + switch (this.#toolName) { + case "line": + if (!this.#isActionStart) { + this.#layerManager.undo(); + } + history.addActionGroup(this.#toolName); + this.drawLine( + this.#startPixel.x, + this.#startPixel.y, + pixelPosition.x, + pixelPosition.y, + this.#metaData.thicknessTimeFunction, + pixelOperation, + ); + this.#layerManager.addToHistory(); + + if (this.#isActionStart) { + toRender.add(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } else { + toRender.copy(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } + break; + } + + this.resetBuffer(this.#currentRect); + this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + this.#isActionStart = false; + + return toRender; + } + + drawLine(x0, y0, x1, y1, thicknessFunction, pixelOperation) { + const drawPrepLine = ( + x0, + y0, + dx, + dy, + width, + initError, + initWidth, + direction, + pixelOperation, + ) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + + while (thickness <= widthThreshold) { + pixelOperation(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = ( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError, + error, + dir, + pixelOperation, + ); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError + diagonalError + stepError, + error, + dir, + pixelOperation, + ); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents( + y0, + x0, + y1, + x1, + thicknessFunction, + (x, y) => pixelOperation(y, x), + ); + else + drawLineRightLeftOctents( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ); + } +} + +export default LineTool; diff --git a/src/core/tools/implementations/pen-tool.ts b/src/core/tools/implementations/pen-tool.ts new file mode 100644 index 0000000..f0da8c7 --- /dev/null +++ b/src/core/tools/implementations/pen-tool.ts @@ -0,0 +1,92 @@ +import Drawable from "@src/interfaces/drawable.js"; +import { ContinousTool } from "../base/tool-base.js"; +import Historyable from "@src/interfaces/historyable.js"; +import { PixelCoord, PixelRectangleBounds } from "@src/types/pixel-types.js"; +import Color from "@src/services/color.js"; +import PixelChanges from "@src/services/pixel-change.js"; +import { drawLine, drawPixel } from "@src/core/algorithms/graphic-algorithms.js"; + +export default class PenTool extends ContinousTool { + private context: Drawable & Historyable; + protected startState: PixelCoord | null = null; + protected recentState: PixelCoord | null = null; + protected readonly redraw: boolean = false; + protected toolEventState: "start" | "draw" | "idle" = "idle"; + protected selectedColor: Color = Color.get({ hex: '#0f0' }); + protected preview: boolean = false; + protected changes: PixelChanges = new PixelChanges(); + + constructor(context: Drawable & Historyable) { + super(); + this.context = context; + this.setPixel = this.setPixel.bind(this); + } + + private setPixel(x: number, y: number) { + if ( + x < 0 || + y < 0 || + x >= this.context.width || + y >= this.context.height + ) + return; + + this.context.setColor(x, y, this.selectedColor); + }; + + mouseDown(coord: PixelCoord): PixelRectangleBounds | null { + console.log("down!"); + if (this.toolEventState !== "idle") return null; + + this.toolEventState = "start"; + this.context.startAction("Pen Tool"); + + this.startState = this.recentState = coord; + drawPixel({ + x: this.startState.x, + y: this.startState.y, + setPixel: this.setPixel + }); + this.toolEventState = "draw"; + + return this.context.commitStep().bounds; + } + + mouseMove(coord: PixelCoord): PixelRectangleBounds | null { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "draw"; + else return null; + + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x: number, y: number) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + + this.recentState = coord; + + return this.context.commitStep().bounds; + } + + mouseUp(coord: PixelCoord): PixelRectangleBounds | null { + if (this.toolEventState == "draw" || this.toolEventState == "start") + this.toolEventState = "idle"; + else return null; + + drawLine({ + x0: this.recentState.x, + y0: this.recentState.y, + x1: coord.x, + y1: coord.y, + setPixel: (x, y) => drawPixel({ x, y, setPixel: this.setPixel }), + }); + + this.recentState = this.startState = null; + + const bounds = this.context.commitStep().bounds; + this.context.endAction(); + return bounds; + } +} diff --git a/src/core/ui-components/canvas.ts b/src/core/ui-components/canvas.ts new file mode 100644 index 0000000..9bf7715 --- /dev/null +++ b/src/core/ui-components/canvas.ts @@ -0,0 +1,370 @@ +import EventBus from "@src/services/event-bus.js"; +import { PixelCoord } from "@src/types/pixel-types.js"; +import { validateNumber } from "@src/utils/validation.js"; + +/** + * Responsible for managing the canvas element inside its container + * @class + */ +export default class Canvas { + private containerElement: HTMLElement; + private canvasElement: HTMLCanvasElement; + canvasContext: CanvasRenderingContext2D; + + private normalScale: number = 1; // the inital scale applied on the canvas to fit in the containerElement + private minScale: number = 1; + private maxScale: number = 1; + + private scale: number = 1; // the scale applied by the user on the canvas + + private recentPixelPos: PixelCoord = {x: -1, y: -1}; + //#isDragging = false; + // private doubleTapThreshold: number = 300; // Time in milliseconds to consider as double tap + // private tripleTapThreshold: number = 600; // Time in milliseconds to consider as triple tap + // + // private lastTouchTime: number = 0; + // private touchCount: number = 0; + // + // private startX: number = 0; + // private startY: number = 0; + // private offsetX: number = 0; + // private offsetY: number = 0; + + + /** + * Creates a canvas elements inside the given container and manages its functionalities + * @constructor + * @param containerElement - The DOM Element that will contain the canvas + * @param events - The event bus + */ + constructor(containerElement: HTMLElement, events: EventBus) { + // Setup canvas element + this.canvasElement = document.createElement("canvas"); + this.containerElement = containerElement; + this.canvasElement.id = "canvas-image"; + this.canvasElement.style.transformOrigin = 'center center'; + + this.containerElement.appendChild(this.canvasElement); + + // Setup canvas context + this.canvasContext = this.canvasElement.getContext("2d", { alpha: false }); + this.canvasContext.imageSmoothingEnabled = false; + + // Setup events + this.setupEvents(events); + + // Recalculate canvas size if container size changes + const observer: ResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { + for (const entry of entries) { + if (entry.target == this.containerElement) { + this.calculateInitialScale(); + } + } + }); + observer.observe(this.containerElement); + } + + /** + * Reevaluates the initial scale of the canvas and the min and max scale + * @method + */ + calculateInitialScale() { + const containerRect = + this.containerElement.getBoundingClientRect(); + + this.normalScale = Math.min( + containerRect.width / + this.canvasElement.width, + containerRect.height / + this.canvasElement.height, + ); + + this.minScale = this.normalScale * 0.1; + this.maxScale = this.normalScale * Math.max(this.canvasElement.width, this.canvasElement.height); + + this.zoom(1); + } + + /** + * Creates a blank canvas with given width and height, and scale it to the container size + * @method + * @param width - Integer represents the number of pixel columns in the canvas, range is [0, 1024] inclusive + * @param height - Integer represents the number of pixel rows in the canvas, range is [0, 1024] inclusive + * @returns An object containing the converted (x, y) position in the form of {x: xPos, y: yPos} + * @throws {TypeError} if the width or the height are not valid integers + * @throws {RangeError} if the width or the height are not in valid ranges + */ + createBlankCanvas(width: number, height: number) { + validateNumber(width, "Width", { + start: 1, + end: 1024, + integerOnly: true, + }); + validateNumber(height, "Height", { + start: 1, + end: 1024, + integerOnly: true, + }); + + this.canvasElement.width = width; + this.canvasElement.height = height; + + this.calculateInitialScale(); + + this.resetZoom(); + } + + /** + * Renders an image at an offset in the canvas + * @method + * @param imageData - The image to be rendered + * @param [dx=0] - The x offset of the image + * @param [dy=0] - The y offset of the image + */ + render(imageData: ImageData, dx: number = 0, dy: number = 0) { + validateNumber(dx, "x"); + validateNumber(dy, "y"); + + this.canvasContext.putImageData(imageData, dx, dy); + } + + // addOffset(offsetX, offsetY) { + // // not implemented + // } + + /** + * Applies zoom multiplier to the canvas + * @method + * @param delta - Multiplier to be applied to the current scale + * @returns the current zoom level + */ + zoom(delta: number): number { + this.scale = Math.min(Math.max(this.scale * delta, this.minScale), this.maxScale); + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + + /** + * Reset zoom of the canvas + * @method + * @returns the current zoom level + */ + resetZoom(): number { + this.scale = this.normalScale; + this.canvasElement.style.width = `${this.scale * this.canvasElement.width}px`; + this.canvasElement.style.height = `${this.scale * this.canvasElement.height}px`; + return this.getScale(); + } + + /** + * Returns current zoom level + * @method + * @returns the current zoom level + */ + getScale(): number { + return this.scale; + } + + /** + * Translates event coordinates of the canvas element to pixel position on the canvas + * @method + * @param clientX - The x position on the canvas element + * @param clientY - The y position on the canvas element + * @returns The resultant position of the pixel on the canvas grid + */ + getPixelPosition(clientX: number, clientY: number): PixelCoord { + return { + x: Math.floor(clientX / this.scale), + y: Math.floor(clientY / this.scale), + } + } + + /** + * @method + * @returns Container element + */ + get getContainer() { + return this.containerElement; + } + + /** + * @method + * @returns Canvas element + */ + get canvas() { + return this.canvasElement; + } + + /** + * @method + * @returns Width of the container element + */ + get containerWidth() { + return this.containerElement.style.width; + } + + /** + * @method + * @returns Height of the container element + */ + get containerHeight() { + return this.containerElement.style.height; + } + + /** + * @method + * @returns Width of the canvas grid + */ + get width() { + return this.canvasElement.width; + } + + /** + * @method + * @returns Height of the canvas grid + */ + get height() { + return this.canvasElement.height; + } + + setupEvents(events: EventBus) { + const emitPointerEvent = (name: string, event: MouseEvent | TouchEvent) => { + // if (event.target !== this.canvas) return; + event.preventDefault(); + const canvasRect = + this.canvas.getBoundingClientRect(); + const clientX = ((event as TouchEvent).changedTouches ? (event as TouchEvent).changedTouches[0].clientX : (event as MouseEvent).clientX) - canvasRect.left; + const clientY = ((event as TouchEvent).changedTouches ? (event as TouchEvent).changedTouches[0].clientY : (event as MouseEvent).clientY) - canvasRect.top; + + const coordinates: PixelCoord = this.getPixelPosition(clientX, clientY); + + if (this.recentPixelPos.x === coordinates.x && this.recentPixelPos.y === coordinates.y && name == "mousemove") return; + this.recentPixelPos = coordinates; + + events.emit(`canvas:${name}`, { + clientX: clientX, + clientY: clientY, + coordinates, + pointerType: ((event as TouchEvent).touches ? "touch" : "mouse"), + }); + }; + + this.containerElement.addEventListener("mousedown", (e: MouseEvent) => { + emitPointerEvent("mousedown", e); + }); + + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + + document.addEventListener("mousemove", (e) => { + emitPointerEvent("mousemove", e); + }); + + document.addEventListener("mouseleave", (e) => { + emitPointerEvent("mouseleave", e); + }); + + document.addEventListener("mouseup", (e) => { + emitPointerEvent("mouseup", e); + }); + + + // containerElement.addEventListener("touchstart", (event) => { + // event.preventDefault(); + // + // const currentTime = new Date().getTime(); + // + // if (currentTime - this.#lastTouchTime <= doubleTapThreshold) { + // touchCount++; + // } else if ( + // currentTime - this.#lastTouchTime <= + // tripleTapThreshold + // ) + // touchCount = 2; + // else touchCount = 1; + // + // lastTouchTime = currentTime; + // if (touchCount === 1) { + // this.#isDrawing = true; + // + // const clientX = event.clientX || event.touches[0].clientX; + // const clientY = event.clientY || event.touches[0].clientY; + // + // // this.#toolManager.use("touchdown - draw", clientX, clientY); + // // should be in every comment this.#events.emit("drawstart", clientX, clientY); + // } + // + // if (touchCount === 2) { + // // this.#toolManager.use("touchdown - undo", clientX, clientY); + // touchCount = 0; + // } + // + // if (touchCount === 3) { + // // this.#toolManager.use("touchdown - redo", clientX, clientY); + // touchCount = 0; + // } + // console.log(eventName); + // }); + + // ["mouseup", "touchend", "touchcancel"].forEach((eventName) => { + // document.addEventListener(eventName, (event) => { + // //event.preventDefault(); + // this.#isDrawing = false; + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientX; + // console.log(eventName); + // + // // this.#toolManager.use("mouseup", clientX, clientY); + // }); + // }); + + // document.addEventListener("touchmove", (event) => { + // //event.preventDefault(); + // + // const clientX = + // event.clientX || event.changedTouches[0].clientX; + // const clientY = + // event.clientY || event.changedTouches[0].clientY; + // console.log(eventName); + // + // if (this.#isDrawing); + // // this.#toolManager.use("mousedraw", clientX, clientY); + // else; // this.#toolManager.use("mousehover", clientX, clientY); + // }); + + // scroll effect + this.containerElement.addEventListener("wheel", (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 1.1 : 0.9; + this.zoom(delta); + + events.emit("canvas:zoom", { + delta: delta, + centerX: e.clientX, + centerY: e.clientY + }); + }); + // + // window.addEventListener("resize", () => { + // this.canvasElement.refresh(true); + // }); + + document.addEventListener("keydown", (e: KeyboardEvent) => { + if (!e.ctrlKey) return; + + if (e.key == "z") events.emit("canvas:undo", { key: e.key }); + if (e.key == "y") events.emit("canvas:redo", { key: e.key }); + + // this.#canvasElement.render( + // this.#layerManager.getRenderImage( + // this.#canvasElement.getCanvasContext, + // ), + // ); + }); + } +} diff --git a/src/generics/change-tracker.ts b/src/generics/change-tracker.ts new file mode 100644 index 0000000..f8959c5 --- /dev/null +++ b/src/generics/change-tracker.ts @@ -0,0 +1,185 @@ +type ChangeComparator = (a: T, b: T) => boolean; + +export type ChangeState = { + before: StateType; + after: StateType; +} + +export type ChangeRecord = { + key: KeyType, + states: ChangeState; +} + +/** + * @class + * Tracks modified data with before/after for history support. + * Maintains both a Map for order and a Set for duplicate checking. + */ +export default class ChangeSystem { + + /** + * A table of all changed data and its before and after states to a change record containing the position and before/after states + * @private + */ + private changes = new Map>(); + + /** + * Function for comparing two states. If undefined, uses === comparison. + * @private + */ + private stateComparator: ChangeComparator; + + /** + * Creates a ChangeSystem instance. + * @constructor + * @param stateComparator - Function for comparing two states + */ + constructor(stateComparator?: ChangeComparator) { + this.stateComparator = stateComparator ?? ((a, b) => a === b); + } + + /** + * Merges another ChangeSystem into this one (mutates this object). + * @method + * @param source - Source ChangeSystem to merge. + * @returns This instance (for chaining) + */ + mergeMutable(source: ChangeSystem): this { + if (!source || source.isEmpty) return this; + + source.changes.forEach((change) => { + this.setChange( + change.key, + change.states.after, + change.states.before, + ); + }); + return this; + } + + /** + * Merges another ChangeSystem into a copy of this one, and returns it. + * @method + * @param source - Source change system to merge. + * @returns The result of merging + */ + merge(source: ChangeSystem): this { + const result = this.clone(); + result.mergeMutable(source); + return result; + } + + /** + * Creates a shallow copy (states are not deep-cloned). + * @method + * @returns {ChangeSystem} The clone + */ + clone(): this { + const copy = new (this.constructor as any)(); + + this.changes.forEach(value => { + copy.setChange(value.key, value.states.after, value.states.before); + }); + + copy.stateComparator = this.stateComparator; + + return copy; + } + + /** + * Clears the change system + * @method + */ + clear() { + this.changes.clear(); + } + + /** + * Adds or updates data modification. Coordinates are floored to integers. + * + * @method + * @param key - key of data to set change for + * @param after - The state + * @param before - Original state (used only on first add). + * @returns States of change for the specfied data if still exists, null otherwise + */ + setChange(key: KeyType, after: StateType, before: StateType): ChangeState | null { + let existing = this.changes.get(key); + + if (!existing) { + if (!this.stateComparator(before, after)) { + this.changes.set(key, { + key: key, + states: { + after, + before, + } + }); + } + return this.getChange(key); + } else { + existing.states.after = after; + if (this.stateComparator(existing.states.before, existing.states.after)) this.changes.delete(key); + return this.getChange(key); + } + } + + /** + * returns an object containing the before and after states if data has been modified, null otherwise. + * @method + * @param key - key of the data to get change for + * @returns ChangeState if data has been modified, null otherwise + */ + getChange(key: KeyType): ChangeState | null { + const change = this.changes.get(key); + if (!change) return null; + return { before: change.states.before, after: change.states.after }; + } + + /** + * Returns whether the change system is empty. + * @method + * @returns {boolean} + */ + get isEmpty(): boolean { + return this.changes.size === 0; + } + + /** + * Gets keys of the changes. + * @method + * @returns An array containing keys for all changed data + */ + get keys(): KeyType[] { + return Array.from(this.changes.values()) + .map((cd: ChangeRecord) => cd.key); + } + + /** + * Gets before and after states of the changes. + * @method + * @returns An array containing states for all changed data + */ + get states(): ChangeState[] { + return Array.from(this.changes.values()) + .map((cd: ChangeRecord) => cd.states); + } + + /** + * Gets an iterator of changes before and after states. + * @method + * @returns An iterator containing changed data and its states for all changed data + */ + [Symbol.iterator](): IterableIterator> { + return this.changes.values(); + } + + /** + * Returns number of changes + * @method + * @returns The number of changes + */ + get count(): number { + return this.changes.size; + } +} diff --git a/src/generics/history-system.ts b/src/generics/history-system.ts new file mode 100644 index 0000000..835a8fd --- /dev/null +++ b/src/generics/history-system.ts @@ -0,0 +1,284 @@ +import { validateNumber } from "@src/utils/validation.js"; + +type HistoryRecord = { + id: number, + data: DataType, + timestamp?: number, +} + +/** + * Represents a circular buffer-based history system for undo/redo operations. + * Tracks records containing arbitrary data. + * + * Key Features: + * - Fixed-capacity circular buffer (1-64 records) + * - Atomic recording + * - Reference copy data storage + * - Undo/redo functionality + * - Record metadata (IDs/data) + * + * @example + * const history = new History<{x:number,y:number,color:string}>(10); + * history.addRecord("Paint", {x:1, y:2, color:"#000000"}); + * history.addRecordData({x: 1, y: 2, color: "#FF0000"}); + * history.undo(); // Reverts to previous state + * + * @class + */ +export default abstract class HistorySystem { + + /** + * Internal circular buffer storing records + */ + private buffer: HistoryRecord[]; + + /** + * The index of the current selected record + */ + private currentIndex: number = 0; + + /** + * The index of the oldest saved record in the history system + */ + private startIndex: number = 0; + + /** + * The index of the last saved record in the history system + */ + private endIndex: number = 0; + + /** + * Internal counter to enumerate increamental IDs for the created records + */ + private recordIDCounter: number = 0; + + /** + * Creates a new History with specified capacity + * @constructor + * @param capacity - Maximum stored records (1-64) + * @param initialData - The data for the initial record in the history + * @throws {TypeError} If capacity is not an integer + * @throws {RangeError} If capacity is outside 1-64 range + */ + constructor(capacity: number, initialData: DataType) { + validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); + + capacity = Math.floor(capacity); + this.buffer = new Array(capacity); + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + }; + } + + /** + * Adds a new record if at the end of history or replaces the current record while removing future of replaced record + * @method + * @param data - Data to store + * @param keepFuture - Determines if future records should be kept + */ + setRecord(data: DataType, keepFuture = false) { + let atEnd = this.currentIndex === this.endIndex; + + if (this.count === this.capacity) + this.startIndex = this.normalizedIndex(this.startIndex + 1); + + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + + if (!keepFuture || atEnd) + this.endIndex = this.currentIndex; + + this.buffer[this.currentIndex] = { + id: ++this.recordIDCounter, + data: data, + }; + } + + /** + * Sets data to the current record its reference (no copy is made) + * @method + * @param data - Data to store + * @throws {Error} If no active record exists + * @example + * Stores a copy of the object + * history.addRecordData({x: 1, y: 2}); + */ + setRecordData(data: DataType) { + if (this.currentIndex === -1) { + throw new Error("No record to add to."); + } + + this.buffer[this.currentIndex].data = data; + } + + private normalizedIndex(index: number): number { + return (index + this.capacity) % this.capacity; + } + + + /** + * Gets the record at an offset from current position + * @private + * @param [offset=0] - Offset from current position + * @returns Record at specified offset, if offset crossed the boundaries, returns boundary records + */ + private getRecord(offset: number = 0): HistoryRecord { + + validateNumber(offset, "Offset", { integerOnly: true }); + + if (offset > 0) { // go right -> end + let boundary = this.normalizedIndex(this.endIndex - this.currentIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex + offset)]; + } else if (offset < 0) { // go left -> start + offset *= -1; + let boundary = this.normalizedIndex(this.currentIndex - this.startIndex); + offset = Math.min(boundary, offset); + return this.buffer[this.normalizedIndex(this.currentIndex - offset)]; + } else { + return this.buffer[this.currentIndex]; + } + } + + /** + * Retrieves record ID at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which ID gets returned + * @returns The record ID at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordID(offset: number = 0): number { + return this.getRecord(offset).id; + } + + /** + * Retrieves record data at an offset from current selected record + * @method + * @param [offset=0] - The the offset from the current record for which data gets returned + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + getRecordData(offset: number = 0): DataType { + return this.getRecord(offset).data; + } + + /** + * Retrieves the current offset of the record from start of the history + * @method + * @returns The record offset from start to end + */ + getRecordOffset(): number { + return this.currentIndex - this.endIndex; + } + + /** + * Moves backward in history (undo), does nothing if already at start + * @method + * @returns The record data at the new ID and its offset from start + * @returns The record data at the offset, retreives it for boundary records if offset is out of bounds + */ + undo(): { data: DataType, index: number } { + if (this.currentIndex !== this.startIndex) + this.currentIndex = this.normalizedIndex(this.currentIndex - 1); + + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + + /** + * Moves forward in history (redo), does nothing if already at end or history is empty + * @method + * @returns The record data at the new ID and its offset from start + */ + redo(): { data: DataType, index: number } { + if (this.currentIndex !== this.startIndex) { + this.currentIndex = this.normalizedIndex(this.currentIndex + 1); + } + + return { + data: this.buffer[this.currentIndex].data, + index: this.normalizedIndex(this.currentIndex - this.startIndex) + }; + } + + /** + * Moves to a record with a specific ID + * @method + * @returns Whether the record was found + */ + jumpToRecord(id: number): boolean { + const index = this.buffer.findIndex(r => r.id === id); + if (index >= 0) this.currentIndex = index; + return index >= 0; + } + + /** + * Resets the history with data for the initial record + * @method + * @param initialData - Data of the initial record + */ + reset(initialData: DataType) { + this.buffer = new Array(this.capacity); + this.currentIndex = 0; + this.startIndex = 0; + this.endIndex = 0; + this.recordIDCounter = 0; + this.buffer[this.startIndex] = { + id: 0, + data: initialData, + timestamp: Date.now() + } + } + + /** + * Returns an iterator to the history + * @method + * @yeilds the stored history records + * @returns Iterator to the history + */ + *[Symbol.iterator](): IterableIterator> { + let index = this.startIndex; + while (index !== this.endIndex) { + yield this.buffer[index]; + index = this.normalizedIndex(index + 1); + } + yield this.buffer[index]; + } + + /** + * Current number of stored records + * @method + * @returns Number of stored records + */ + get count(): number { + return this.normalizedIndex(this.endIndex - this.startIndex) + 1; + } + + /** + * Maximum number of storable records + * @method + * @returns Maximum number of storable records + */ + get capacity(): number { + return this.buffer.length; + } + + /** + * Returns true if current index is at the end + * @method + * @returns Whether index is at end + */ + get atEnd(): boolean { + return this.currentIndex === this.endIndex; + } + + /** + * Returns true if current index is at the start + * @method + * @returns Whether current index is at start + */ + get atStart(): boolean { + return this.currentIndex === this.startIndex; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..763ea0d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,125 @@ +import PixelEditor from "@src/core/pixel-editor.js"; +import Color from "./services/color.js"; + +console.log(" Hello! "); + +const containerElement: HTMLElement = document.querySelector("#canvas-container"); +const paletteContainer: HTMLElement = document.querySelector(".palette-container"); +const board = new PixelEditor(containerElement, 63, 63); +board.render(); + +const colorMap: Map = new Map(); +let selectedColors: [Color, Color] = [Color.get({ hex: "#ff0000" }), Color.get({ hex: "#00ff00" })]; + +// Fill the color palette with random shit +for (let i = 0; i < 10; i++) { + let colorHex = ""; + for (let j = 0; j < 6; j++) { + const rand = Math.floor(Math.random() * 16); + if (rand <= 9) colorHex += String(rand); + else colorHex += String.fromCharCode('a'.charCodeAt(0) + rand - 10); + } + const color: Color = Color.get({ hex: `#${colorHex}` }); + const elem: HTMLElement = createColorElement(color); + colorMap.set(elem, color); +} + + +function createColorElement(color: Color): HTMLElement { + let element: HTMLElement = document.createElement("div"); + element.classList.add("color"); + element.classList.add("btn"); + element.style.backgroundColor = color.hex; + paletteContainer.appendChild(element); + return element; +} + +// Click on any color on the palette +paletteContainer.addEventListener("click", (event: MouseEvent) => { + const element = (event.target as HTMLElement); + if (!element.classList.contains("color") || element.classList.contains("add-color")) return; + (document.querySelector(".color-index.selected") as HTMLElement) + .style.backgroundColor = colorMap.get(element).hex; + board.toolManager.drawingColor = colorMap.get(element); + +}); + +// Click on index colors +document.querySelectorAll(".color-index").forEach((elm: HTMLElement, index: number) => { + elm.addEventListener("click", () => { + if (elm.classList.contains("selected")) return; + document.querySelectorAll(".color-index").forEach((e: HTMLElement) => { + e.classList.toggle("selected"); + }) + board.toolManager.drawingColor = selectedColors[index]; + }); +}); + +document + .getElementsByClassName("swap-colors")[0] + .addEventListener("click", () => { + const colorElements: HTMLElement[] = Array.from(document.querySelectorAll(".color-index")); + if (!colorElements[0].classList.contains(".primary")) colorElements.reverse(); + + colorElements[0].classList.toggle("primary"); + colorElements[1].classList.toggle("primary"); + colorElements[0].classList.toggle("selected"); + colorElements[1].classList.toggle("selected"); + selectedColors = [selectedColors[1], selectedColors[0]]; + board.toolManager.drawingColor = selectedColors[0]; + }); + +document + .getElementsByClassName("reset-colors")[0] + .addEventListener("click", () => { + const colorElements: HTMLElement[] = Array.from(document.querySelectorAll(".color-index")); + + colorElements[0].classList.add("primary"); + colorElements[0].classList.add("selected"); + colorElements[1].classList.remove("primary"); + colorElements[1].classList.remove("selected"); + board.toolManager.drawingColor = selectedColors[0]; + }); + +const toolsElem = document.getElementsByClassName("tools")[0]; + +// function downloadCanvasAsPNG() { +// const canvas: S = document.getElementById("canvas"); +// const link = document.createElement("a"); +// link.download = "pixel-art.png"; +// link.href = canvas.toDataURL("image/png"); +// link.click(); +// } +// +// document.getElementById("download-png").addEventListener("click", () => { +// drawToCanvas(canvas.colorsMatrix); +// downloadCanvasAsPNG(); +// }); +// +// document.getElementById("undo").addEventListener("click", () => { +// board.undo(); +// }); +// +// document.getElementById("redo").addEventListener("click", () => { +// board.redo(); +// }); +// +// for (let elm of toolsElem.children) { +// //if (elm.classList[0] === "color-picker") +// // elm.addEventListener("click", () => { +// // let eyeDropper = new EyeDropper(); +// // try { +// // let pickedColor = await eyeDropper.open(); +// // primaryColorSelector.style.background = pickedColor.sRGBHex; +// // } catch (error) { +// // console.log("error"); +// // } +// // console.log(elm.classList[0]); +// // }); +// //else +// elm.addEventListener("click", () => { +// console.log(elm.classList[0]); +// board.toolManager.toolName = elm.classList[0]; +// }); +// } +// /* "dev": "vite", "build": "vite build", */ diff --git a/src/interfaces/drawable.ts b/src/interfaces/drawable.ts new file mode 100644 index 0000000..a2d549b --- /dev/null +++ b/src/interfaces/drawable.ts @@ -0,0 +1,8 @@ +import Color from "@src/services/color.js"; + +export default interface Drawable { + getColor(x: number, y: number): Color; + setColor(x: number, y: number, color: Color): void; + get width(): number; + get height(): number; +} diff --git a/src/interfaces/historyable.ts b/src/interfaces/historyable.ts new file mode 100644 index 0000000..abc2ab7 --- /dev/null +++ b/src/interfaces/historyable.ts @@ -0,0 +1,10 @@ +import PixelChanges from "@src/services/pixel-change.js"; + +export default interface Historyable { + startAction(name: string): void; + commitStep(): PixelChanges; + cancelAction(): void; + endAction(): void; + undo(): void; + redo(): void; +}; diff --git a/src/services/color.ts b/src/services/color.ts new file mode 100644 index 0000000..c9f260f --- /dev/null +++ b/src/services/color.ts @@ -0,0 +1,528 @@ +import { validateNumber } from "@src/utils/validation.js"; + +type ColorVector = [number, number, number]; + +interface ColorData { + rgb: ColorVector, + hsl: ColorVector, + hex: string, + alpha: number, +} + +type ColorParams = ( + | { rgb: ColorVector; hsl?: never; hex?: never } + | { hsl: ColorVector; rgb?: never; hex?: never } + | { hex: string; rgb?: never; hsl?: never } +) & { + alpha?: number; // Optional (default: 1) +}; + + +enum ColorSpace { rgb, hsl } + + +const COLOR_KEY = Symbol('ColorKey'); + +/** + * Represents an immutable color with support for multiple color spaces. + * All instances are cached and reused when possible. + * @class + * @global + */ +class Color { + + /** + * Internal cache of color instances to prevent duplicates. + * Keys are long-version hex strings. (ex. '#11223344') + * @type {Map} + * @private + */ + private static cachedColors: Map = new Map(); + + /** + * holds data of the color + * @type {ColorData} + * @private + */ + private data: ColorData = { + rgb: [0, 0, 0], + hsl: [0, 0, 0], + hex: '#000000', + alpha: 1 + }; + + /** + * Private constructor (use Color.create() instead). + * @param {ColorData} colorData - holds the rgb, hsl, hex and alpha values of the color + * @param {symbol} key - Private key to prevent direct instantiation + * @private + * @throws {TypeError} if used directly (use Color.create() instead) + */ + constructor(colorData: ColorData, key: symbol) { + if (key !== COLOR_KEY) { // Must not be used by the user + throw new TypeError("Use Color.create() instead of new Color()"); + } + + this.data.rgb = colorData.rgb; + this.data.hsl = colorData.hsl; + this.data.hex = colorData.hex; + this.data.alpha = colorData.alpha; + Object.freeze(this); + } + + // ==================== + // Public API Methods + // ==================== + // ==================== + // Getters + // ==================== + + /** @returns {ColorVector} RGB values [r, g, b] (0-255) */ + get rgb(): ColorVector { return [...this.data.rgb]; } + + /** @returns {ColorVector} HSL values [h, s, l] (h: 0-360, s/l: 0-100) */ + get hsl(): ColorVector { return [...this.data.hsl]; } + + /** @returns {string} Hex color string */ + get hex(): string { return this.data.hex; } + + /** @returns {number} Alpha value (0.0-1.0) */ + get alpha(): number { return this.data.alpha; } + + /** @returns {string} Hex representation */ + toString(): string { return this.data.hex; } + + + // ==================== + // Static Methods + // ==================== + + /** + * Creates a Color instance from various formats, or returns cached instance. + * @method + * @static + * @param {Object} params - Configuration object + * @param {ColorVector} [params.rgb] - RGB values (0-255) + * @param {ColorVector} [params.hsl] - HSL values (h:0-360, s/l:0-100) + * @param {string} [params.hex] - Hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) + * @param {number} [params.alpha=1] - Alpha value (0.0-1.0) + * @returns {Color} Color instance + * @throws {RangeError} If values are out of bounds + */ + static get(params: ColorParams): Color { + + const alpha = params.alpha ?? 1; + + let key: string, + finalRGB: ColorVector = [0, 0, 0], + finalHSL: ColorVector = [0, 0, 0], + finalHEX: string, + finalAlpha: number; + + if ('rgb' in params) { + validateRGB(params.rgb); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.rgb.forEach((v, i) => finalRGB[i] = Math.round(v)); + key = finalHEX = toHex(finalRGB, alpha); + + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + + finalHSL = rgbToHsl(finalRGB); + finalAlpha = alpha; + } + else if ('hsl' in params) { + validateHSL(params.hsl); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + params.hsl.forEach((v, i) => finalHSL[i] = Math.round(v)); + finalRGB = hslToRgb(finalHSL); + + key = finalHEX = toHex(finalRGB, alpha); + + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + + finalAlpha = alpha; + } + else { + const parsed = parseHex(params.hex); + finalHEX = toHex(parsed.rgb, parsed.alpha); + + key = finalHEX; + + if (Color.cachedColors.has(key)) + return Color.cachedColors.get(key); // Return cached instance + + finalRGB = parsed.rgb; + finalHSL = rgbToHsl(finalRGB); + finalAlpha = parsed.alpha; + } + + const color = new Color({ + rgb: finalRGB, + hsl: finalHSL, + hex: finalHEX, + alpha: finalAlpha + }, COLOR_KEY); + + Color.cachedColors.set(key, color); + return color; + } + + /** + * Mixes two colors with optional weighting and color space + * @method + * @param color1 - The first color to mix with + * @param color2 - The second color to mix with + * @param [weight=0.5] - The mixing ratio (0-1) + * @param [mode=ColorSpace.rgb] - The blending mode + * @returns The resulting new mixed color + */ + static mix( + color1: Color, + color2: Color, + weight: number = 0.5, + mode: ColorSpace = ColorSpace.rgb + ): Color { + weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 + const newAlpha: number = color1.data.alpha + (color2.data.alpha - color1.data.alpha) * weight; + + switch (mode) { + case ColorSpace.rgb: + const [h1, s1, l1] = color1.data.hsl; + const [h2, s2, l2] = color2.data.hsl; + + // Hue wrapping + let hueDiff = h2 - h1; + if (Math.abs(hueDiff) > 180) { + hueDiff += hueDiff > 0 ? -360 : 360; + } + + return Color.get({ + hsl: [ + (h1 + hueDiff * weight + 360) % 360, + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight, + ], + alpha: newAlpha + }); + + case ColorSpace.rgb: + default: + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + + return Color.get({ + rgb: [ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight + ], + alpha: newAlpha + }); + } + } + + /** + * Composites a color over another + * @method + * @param {Color} topColor - The color to composite over + * @param {Color} bottomColor - The color to be composited over + * @returns {Color} The resulting new composited color + */ + static compositeOver(topColor: Color, bottomColor: Color): Color { + const [rTop, gTop, bTop, aTop] = [...topColor.data.rgb, topColor.data.alpha]; + const [rBottom, gBottom, bBottom, aBottom] = [...bottomColor.data.rgb, bottomColor.data.alpha]; + + const combinedAlpha: number = aTop + aBottom * (1 - aTop); + if (combinedAlpha === 0) return Color.TRANSPARENT; + + return Color.get({ + rgb: [ + Math.round((rTop * aTop + rBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((gTop * aTop + gBottom * aBottom * (1 - aTop)) / combinedAlpha), + Math.round((bTop * aTop + bBottom * aBottom * (1 - aTop)) / combinedAlpha), + ], + alpha: combinedAlpha + }); + } + + /** + * Checks if colors are visually similar within tolerance + * @method + * @param color1 - The first color to compare + * @param color2 - The second color to compare + * @param [tolerance=5] - The allowed maximum perceptual distance (0-442) + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns Whether the two colors are visually similar within the given tolerance + */ + static isSimilarTo( + color1: Color, + color2: Color, + tolerance: number = 5, + includeAlpha: boolean = true + ): boolean { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + + // Calculate RGB Euclidean distance (0-442 scale) + const rDiff = Math.abs(r1 - r2); + const gDiff = Math.abs(g1 - g2); + const bDiff = Math.abs(b1 - b2); + + const rgbDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); + + // Calculate alpha difference (0-255 scale) + const alphaDifference = Math.abs(a1 - a2) * 255; + + return rgbDistance <= tolerance && (!includeAlpha || alphaDifference <= tolerance); + } + + /** + * Checks exact color equality (with optional alpha) + * @method + * @param color1 - the first color to compare with + * @param color2 - the second color to compare with + * @param [includeAlpha=true] - Whether to compare the alpha channel + * @returns {boolean} Whether the two colors are equal + * @throws {TypeError} If color is an not instance of Color class + */ + static isEqualTo( + color1: Color, + color2: Color, + includeAlpha: boolean = true + ): boolean { + const [r1, g1, b1] = color1.data.rgb; + const [r2, g2, b2] = color2.data.rgb; + const [a1, a2] = [color1.data.alpha, color2.data.alpha]; + + const rgbEqual = ( + r1 === r2 && + g1 === g2 && + b1 === b2 + ); + + const alphaEqual = !includeAlpha || ( + Math.round(a1 * 255) === Math.round(a2 * 255) + ); + + return rgbEqual && alphaEqual; + } + + /** + * Creates a new color with modified RGB values + * @param {ColorVector} [rgb=this.data.rgb] - RGB values + * @returns {Color} New color instance + */ + withRGB(rgb: ColorVector = [... this.data.rgb]): Color { + return Color.get({ rgb: rgb, alpha: this.data.alpha }); + } + + /** + * Creates a new color with modified HSL values + * @param {ColorVector} [hsl=this.data.rgb] - HSL values + * @returns {Color} New color instance + */ + withHSL(hsl: ColorVector = [... this.data.hsl]): Color { + return Color.get({ hsl: hsl, alpha: this.data.alpha }); + } + + /** + * Creates a new color with modified alpha + * @param {number} alpha - Alpha value (0.0-1.0) + * @returns {Color} New color instance + * @throws {RangeError} If alpha is out of bounds + */ + withAlpha(alpha: number): Color { + return Color.get({ rgb: this.data.rgb, alpha }); + } + + + /** + * Predefined transparent color instance. + * @type {Color} + * @static + */ + static TRANSPARENT: Color = this.get({ rgb: [0, 0, 0], alpha: 0 }); + + /** + * Clears the color cache, forcing new instances to be created + * @static + */ + static clearCache() { + this.cachedColors.clear(); + + this.TRANSPARENT = this.get({ rgb: [0, 0, 0], alpha: 0 }); + } + + /** + * Gets the current size of the color cache (for testing/debugging) + * @static + * @returns {number} Number of cached colors + */ + static get cacheSize(): number { + return this.cachedColors.size; + } +} + +/** + * Converts RGB to HSL color space. + * @param {ColorVector} rgb - Red (0-255), Green (0-255), Blue (0-255) + * @returns {ColorVector} HSL values + * @private + */ +function rgbToHsl(rgb: ColorVector): ColorVector { + let [r, g, b] = rgb; + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h: number, s: number, l: number = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h *= 60; + } + + return [ + Math.round(h * 100) / 100, + Math.round(s * 10000) / 100, + Math.round(l * 10000) / 100 + ]; +} + +/** + * Converts HSL to RGB color space. + * @param {ColorVector} hsl - Hue (0-360), Saturation (0-100), Lightness (0-100) + * @returns {ColorVector} RGB values + * @private + */ +function hslToRgb(hsl: ColorVector): ColorVector { + let [h, s, l] = hsl; + h = h % 360 / 360; + s = Math.min(100, Math.max(0, s)) / 100; + l = Math.min(100, Math.max(0, l)) / 100; + + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l * 255; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + g = Math.round(hue2rgb(p, q, h) * 255); + b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); + } + + return [r, g, b]; +} + +/** + * Converts RGB+alpha to hex string. + * @param {RGBColor} rgb - RGB values + * @param {number} alpha - Alpha value + * @returns {string} Hex color string + * @private + */ +function toHex(rgb: ColorVector, alpha: number): string { + const components = [ + Math.round(rgb[0]), + Math.round(rgb[1]), + Math.round(rgb[2]), + Math.round(alpha * 255) + ]; + + return `#${components + .map(c => c.toString(16).padStart(2, '0')) + .join('') + .replace(/ff$/, '')}`; // Remove alpha if fully opaque +} + +/** + * Validates RGB array. + * @param {ColorVector} rgb - RGB values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateRGB(rgb: ColorVector) { + if (!Array.isArray(rgb) || rgb.length !== 3) { + throw new TypeError(`RGB must be an array of 3 numbers`); + } + validateNumber(rgb[0], "Red component", { start: 0, end: 255 }); + validateNumber(rgb[1], "Green component", { start: 0, end: 255 }); + validateNumber(rgb[2], "Blue component", { start: 0, end: 255 }); +} + +/** + * Validates HSL array. + * @param {ColorVector} hsl - HSL values to validate + * @throws {TypeError} If invalid format + * @throws {RangeError} If values out of bounds + * @private + */ +function validateHSL(hsl: ColorVector) { + if (!Array.isArray(hsl) || hsl.length !== 3) { + throw new TypeError(`HSL must be an array of 3 numbers`); + } + validateNumber(hsl[0], "Hue"); + validateNumber(hsl[1], "Saturation", { start: 0, end: 100 }); + validateNumber(hsl[2], "Lightness", { start: 0, end: 100 }); +} + +/** + * Parses hex color string. + * @param {string} hex - Hex color string + * @returns {{rgb: ColorVector, alpha: number}} Parsed values + * @throws {TypeError} If invalid format + * @private + */ +function parseHex(hex: string): { rgb: ColorVector, alpha: number } { + if (!/^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i.test(hex)) { + throw new TypeError(`Invalid hex color format: ${hex}`); + } + + let hexDigits = hex.slice(1); + + // Expand shorthand (#RGB or #RGBA) + if (hexDigits.length <= 4) { + hexDigits = hexDigits.split('').map(c => c + c).join(''); + } + + // Parse RGB components + const rgb: ColorVector = [ + parseInt(hexDigits.substring(0, 2), 16), + parseInt(hexDigits.substring(2, 4), 16), + parseInt(hexDigits.substring(4, 6), 16) + ]; + + // Parse alpha (default to 1 if not present) + const alpha = hexDigits.length >= 8 + ? parseInt(hexDigits.substring(6, 8), 16) / 255 + : 1; + + return { rgb, alpha }; +} + +export default Color; diff --git a/src/services/event-bus.ts b/src/services/event-bus.ts new file mode 100644 index 0000000..5d24f08 --- /dev/null +++ b/src/services/event-bus.ts @@ -0,0 +1,51 @@ +export default class EventBus { + + private listeners: Map = new Map(); + + on(event: string, callback: Function): Function { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + return () => this.off(event, callback); // Return unsubscribe function + } + + off(event: string, callback: Function) { + const callbacks = this.listeners.get(event); + if (callbacks) { + this.listeners.set( + event, + callbacks.filter(cb => cb !== callback) + ); + } + } + + emit(event: string, args: Object) { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach(callback => { + try { + callback(args); + } catch (err) { + console.error(`Error in ${event} handler:`, err); + } + }); + } + } + + once(event: string, callback: Function) { + const onceWrapper = (args: Object) => { + this.off(event, onceWrapper); + callback(args); + }; + this.on(event, onceWrapper); + } + + clear(event: string) { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } +} diff --git a/src/services/pixel-change.ts b/src/services/pixel-change.ts new file mode 100644 index 0000000..def4581 --- /dev/null +++ b/src/services/pixel-change.ts @@ -0,0 +1,72 @@ +import ChangeSystem, { ChangeState } from "@src/generics/change-tracker.js"; +import { PixelCoord, PixelRectangleBounds, PixelState } from "@src/types/pixel-types.js"; +import Color from "@src/services/color.js"; + +/** + * Tracks pixel modifications with boundary detection. + * Extends ChangeSystem with pixel-specific optimizations: + * - Automatic bounds calculation for changed areas + * - Color-specific comparison logic + * + * @property {PixelRectangleBounds|null} bounds - Returns the minimal rectangle containing all changes + */ +export default class PixelChanges extends ChangeSystem { + + private boundaries: PixelRectangleBounds = + { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + } + + constructor() { + super((a: PixelState, b: PixelState) => Color.isEqualTo(a.color, b.color)); + } + + mergeMutable(source: PixelChanges): this { + super.mergeMutable(source); + + this.boundaries = { + x0: Math.min(this.boundaries.x0, source.boundaries.x0), + y0: Math.min(this.boundaries.y0, source.boundaries.y0), + x1: Math.max(this.boundaries.x1, source.boundaries.x1), + y1: Math.max(this.boundaries.y1, source.boundaries.y1), + } + + return this; + } + + clone(): this { + const copy = super.clone(); + copy.boundaries = { ...this.boundaries }; + return copy; + } + + clear() { + super.clear(); + this.boundaries = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + } + + setChange(key: PixelCoord, after: PixelState, before: PixelState): ChangeState | null { + const p = super.setChange(key, after, before); + if (p !== null) { + this.boundaries.x0 = Math.min(this.boundaries.x0, key.x); + this.boundaries.y0 = Math.min(this.boundaries.y0, key.y); + this.boundaries.x1 = Math.max(this.boundaries.x1, key.x); + this.boundaries.y1 = Math.max(this.boundaries.y1, key.y); + } + return p; + } + + get bounds(): PixelRectangleBounds | null { + if (this.count === 0) return null; + else return { ...this.boundaries }; + } +} + diff --git a/src/types/history-types.d.ts b/src/types/history-types.d.ts new file mode 100644 index 0000000..224c2d5 --- /dev/null +++ b/src/types/history-types.d.ts @@ -0,0 +1,10 @@ +import PixelChanges from "@src/services/pixel-change.ts" + +export const enum HistoryMove { Forward, Backward } + +export type RecordData = { + name: string, + timestamp: number, + change: PixelChanges, + steps: PixelChanges[] +} diff --git a/src/types/pixel-types.d.ts b/src/types/pixel-types.d.ts new file mode 100644 index 0000000..7f48726 --- /dev/null +++ b/src/types/pixel-types.d.ts @@ -0,0 +1,18 @@ +import Color from "@src/services/color.js"; + +export type PixelCoord = { + x: number, + y: number, +} + +export type PixelState = { + color: Color +} + +export type PixelRectangleBounds = { + x0: number, + y0: number, + x1: number, + y1: number +} + diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..f48fd0e --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,50 @@ +interface ValidationOptions { + start?: number, + end?: number, + integerOnly?: boolean +} + +/** + * Validates the number to be valid number between start and end inclusive. + * @param number - The number to validate. + * @param varName - The variable name to show in the error message which will be thrown. + * @param options - Contains some optional constraints: max/min limits, and if the number is integer only + * @throws {TypeError} Throws an error if boundaries are not finite. + * @throws {TypeError} Throws an error if start and end are set but start is higher than end. + * @throws {RangeError} Throws an error if the number is not in the specified range. + */ +export function validateNumber( + number: number, + varName: string, + options: ValidationOptions = { + start: undefined, + end: undefined, + integerOnly: false + } +) { + const { start, end, integerOnly = false } = options; + + if ( + (start !== undefined && !Number.isFinite(start)) || + (end !== undefined && !Number.isFinite(end))) + throw new TypeError("Variable boundaries are of invalid type"); + + if (!Number.isFinite(number)) + throw new TypeError(`${varName} must be defined finite number`); + + if (integerOnly && !Number.isInteger(number)) + throw new TypeError(`${varName} must be integer`); + + if (start !== undefined && end !== undefined && end < start) + throw new TypeError(`minimum can't be higher than maximum`); + + if ( + (start !== undefined && number < start) || + (end !== undefined && end < number) + ) + throw new RangeError( + `${varName} must have: +${start !== undefined ? "Minimum of: " + start + "\n" : "" + }${end !== undefined ? "Maximum of: " + end + "\n" : ""}`, + ); +} diff --git a/styles/canvas.css b/styles/canvas.css index 7cabecb..b0e82bb 100644 --- a/styles/canvas.css +++ b/styles/canvas.css @@ -25,4 +25,5 @@ canvas { image-rendering: pixelated; position: absolute; + transition: 0.5s; } diff --git a/styles/selected-colors.css b/styles/selected-colors.css index 983e44a..c86afec 100644 --- a/styles/selected-colors.css +++ b/styles/selected-colors.css @@ -66,7 +66,7 @@ left: 0; } -#user-color > .color-index.secondary { +#user-color > .color-index { top: 1.5em; left: 1.5em; } diff --git a/tests/canvas-grid.test.js b/tests/canvas-grid.test.js deleted file mode 100644 index c00a7aa..0000000 --- a/tests/canvas-grid.test.js +++ /dev/null @@ -1,427 +0,0 @@ -import CanvasGrid from "../scripts/canvas-grid.js"; - -describe("CanvasGrid", () => { - let assertDimentions; - let cd; - beforeEach(() => { - assertDimentions = (expectedWidth, expectedHeight) => { - expect(cd.getWidth).toBe(expectedWidth); - expect(cd.getHeight).toBe(expectedHeight); - }; - }); - describe("Construction", () => { - test("should construct with (width, height) default values of (1, 1) if not given", () => { - expect(() => (cd = new CanvasGrid())).not.toThrow(); - assertDimentions(1, 1); - expect(() => (cd = new CanvasGrid(3))).not.toThrow(); - assertDimentions(3, 1); - expect(() => (cd = new CanvasGrid(undefined, 3))).not.toThrow(); - assertDimentions(1, 3); - }); - test("should throw an error if (width, height) values are not defined, not integers or not in range [1, 1024]", () => { - expect(() => new CanvasGrid("a")).toThrow( - TypeError("Width must be defined finite number"), - ); - expect(() => new CanvasGrid(undefined, "1")).toThrow( - TypeError("Height must be defined finite number"), - ); - expect(() => new CanvasGrid(1, "1")).toThrow( - TypeError("Height must be defined finite number"), - ); - expect(() => new CanvasGrid([], 1)).toThrow( - TypeError("Width must be defined finite number"), - ); - expect(() => new CanvasGrid(0, 1024)).toThrow( - RangeError(`Width must have: -Minimum of: 1 -Maximum of: 1024 -`), - ); - expect(() => new CanvasGrid(2, -1)).toThrow( - RangeError(`Height must have: -Minimum of: 1 -Maximum of: 1024 -`), - ); - expect(() => new CanvasGrid(4, 2024)).toThrow( - RangeError(`Height must have: -Minimum of: 1 -Maximum of: 1024 -`), - ); - }); - test("should construct a canvas with specified width and height and initialize it with transparent pixel data", () => { - expect(() => (cd = new CanvasGrid(2, 54))).not.toThrow(); - assertDimentions(2, 54); - expect(() => cd.getColor(2, 54)).toThrow( - RangeError(`x must have: -Minimum of: 0 -Maximum of: 1 -`), - ); - expect(() => cd.getColor(2, 54 - 1)).toThrow( - RangeError(`x must have: -Minimum of: 0 -Maximum of: 1 -`), - ); - expect(() => cd.getColor(2 - 1, 54)).toThrow( - RangeError(`y must have: -Minimum of: 0 -Maximum of: 53 -`), - ); - expect(cd.getColor(2 - 1, 54 - 1)).toStrictEqual([0, 0, 0, 0]); - }); - }); - - describe("Functionality", () => { - let cd; - beforeEach(() => { - cd = new CanvasGrid(16, 16); - }); - - beforeAll(() => { - global.ImageData = class { - constructor(data, width, height) { - this.data = []; // Uint8ClampedArray - this.width = width; - this.height = height; - for (let i = 0; i < this.width * this.height; i++) { - this.data.push(data[0]); - this.data.push(data[1]); - this.data.push(data[2]); - this.data.push(data[3]); - } - } - }; - }); - - describe("Initializing blank canvas", () => { - test("should throw an error if initialized blank canvas with (width, height) values that are not defined, not integers or not in range [1, 1024]", () => { - expect(() => cd.initializeBlankCanvas()).toThrow( - TypeError("Width must be defined finite number"), - ); - expect(() => cd.initializeBlankCanvas(1)).toThrow( - TypeError("Height must be defined finite number"), - ); - expect(() => cd.initializeBlankCanvas([], "1")).toThrow( - TypeError("Width must be defined finite number"), - ); - expect(() => cd.initializeBlankCanvas(4, 2024)).toThrow( - RangeError(`Height must have: -Minimum of: 1 -Maximum of: 1024 -`), - ); - expect(() => cd.initializeBlankCanvas(2, 54)).not.toThrow(); - }); - - test("should initialize blank canvas with valid width, and height", () => { - expect(() => cd.initializeBlankCanvas(2, 1024)).not.toThrow(); - expect(cd.get(1, 24)).toStrictEqual({ - x: 1, - y: 24, - color: [0, 0, 0, 0], - }); - expect(cd.get(0, 0)).toStrictEqual({ - x: 0, - y: 0, - color: [0, 0, 0, 0], - }); - expect(cd.get(1, 1023)).toStrictEqual({ - x: 1, - y: 1023, - color: [0, 0, 0, 0], - }); - expect(() => cd.get(2, 24)).toThrow( - RangeError(`x must have: -Minimum of: 0 -Maximum of: 1 -`), - ); - expect(() => cd.get(0, -24)).toThrow( - RangeError(`y must have: -Minimum of: 0 -Maximum of: 1023 -`), - ); - expect(() => cd.get(3, 1024)).toThrow( - RangeError(`x must have: -Minimum of: 0 -Maximum of: 1 -`), - ); - }); - }); - - describe("Loading image data - Last action array", () => { - test("should throw an error if loaded an image with invalid image data or if x and y are not finite integers", () => { - expect(() => cd.loadImage()).toThrow( - TypeError( - "Image data must be defined instance of ImageData class", - ), - ); - expect(() => cd.loadImage(undefined)).toThrow( - TypeError( - "Image data must be defined instance of ImageData class", - ), - ); - expect(() => cd.loadImage(5)).toThrow( - TypeError( - "Image data must be defined instance of ImageData class", - ), - ); - expect(() => - cd.loadImage( - new ImageData( - new Uint8ClampedArray([0, 0, 0, 1], 16, 16), - ), - "a", - 4, - ), - ).toThrow("x must be defined finite number"); - }); - - test("should load an image data sucessfully if all of it is in valid bounds", () => { - const imageData = new ImageData( - new Uint8ClampedArray([255, 0, 0, 1]), - 16, - 16, - ); // 16x16 red square - expect(() => cd.initializeBlankCanvas(32, 32)).not.toThrow(); - expect(() => cd.loadImage(imageData)).not.toThrow(); - expect(cd.getColor(0, 0)).toStrictEqual([255, 0, 0, 1]); - expect(cd.getColor(15, 15)).toStrictEqual([255, 0, 0, 1]); - expect(cd.getColor(16, 16)).toStrictEqual([0, 0, 0, 0]); - }); - - test("should load an image data sucessfully except the part of it that is out of bound", () => { - const imageData = new ImageData( - new Uint8ClampedArray([255, 0, 0, 1]), - 4, - 4, - ); // 16x16 red square - expect(() => cd.initializeBlankCanvas(32, 32)).not.toThrow(); - expect(() => cd.loadImage(imageData, -2, -2)).not.toThrow(); - expect(() => cd.loadImage(imageData, 32 - 2, -2)).not.toThrow(); - expect(() => - cd.loadImage(imageData, 32 - 2, 32 - 2), - ).not.toThrow(); - expect(() => cd.loadImage(imageData, -2, 32 - 2)).not.toThrow(); - expect(() => cd.loadImage(imageData, 100, 100)).not.toThrow(); // completely out of bound - expect(cd.getLastActions).toStrictEqual([ - { - x: 0, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 0, - y: 1, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 1, - color: [255, 0, 0, 1], - }, - - { - x: 32 - 2, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 32 - 2, - y: 1, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 1, - color: [255, 0, 0, 1], - }, - - { - x: 32 - 2, - y: 32 - 2, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 32 - 2, - color: [255, 0, 0, 1], - }, - { - x: 32 - 2, - y: 32 - 1, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 32 - 1, - color: [255, 0, 0, 1], - }, - - { - x: 0, - y: 32 - 2, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 32 - 2, - color: [255, 0, 0, 1], - }, - { - x: 0, - y: 32 - 1, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 32 - 1, - color: [255, 0, 0, 1], - }, - ]); - }); - test("should be able to reset last actions array on demand", () => { - const imageData = new ImageData( - new Uint8ClampedArray([255, 0, 0, 1]), - 2, - 2, - ); // 16x16 red square - expect(() => cd.initializeBlankCanvas(32, 32)).not.toThrow(); - expect(() => cd.loadImage(imageData, 0, 0)).not.toThrow(); - expect(cd.getLastActions).toStrictEqual([ - { - x: 0, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 0, - y: 1, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 1, - color: [255, 0, 0, 1], - }, - ]); - expect(() => cd.loadImage(imageData, 32 - 2, 0)).not.toThrow(); - expect(cd.getLastActions).toStrictEqual([ - { - x: 0, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 0, - y: 1, - color: [255, 0, 0, 1], - }, - { - x: 1, - y: 1, - color: [255, 0, 0, 1], - }, - - { - x: 32 - 2, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 0, - color: [255, 0, 0, 1], - }, - { - x: 32 - 2, - y: 1, - color: [255, 0, 0, 1], - }, - { - x: 32 - 1, - y: 1, - color: [255, 0, 0, 1], - }, - ]); - cd.resetLastActions(); - expect(cd.getLastActions).toStrictEqual([]); // got emptied - }); - }); - - describe("Color maniqulations", () => { - test("should set the color of a single pixel", () => { - const color = [255, 0, 0, 1]; - cd.setColor(5, 5, color, { radius: 0 }); - cd.setColor(2, 8, color, { radius: 0 }); - - expect(cd.getColor(5, 5)).toStrictEqual(color); - expect(cd.getColor(2, 8)).toStrictEqual(color); - expect(cd.getLastActions).toStrictEqual([ - { x: 5, y: 5, color: color }, - { x: 2, y: 8, color: color }, - ]); - }); - - test("should handle transparency correctly", () => { - const color = [255, 0, 0, 0]; // Fully transparent - cd.setColor(5, 5, color, { radius: 0 }); - - expect(cd.getColor(5, 5)).toStrictEqual([0, 0, 0, 0]); // Should be transparent black - }); - - test("should not change pixels if quietly is true", () => { - const color = [255, 0, 0, 1]; // Red color - cd.setColor(5, 5, color, { radius: 1, quietly: true }); - - expect(cd.getColor(5, 5)).toBe(color); // Color changed - expect(cd.getLastActions).toStrictEqual([]); // No actions should be recorded - }); - - test("should throw an error for invalid coordinates", () => { - const color = [255, 0, 0, 1]; - expect(() => cd.setColor(-1, 5, color)).toThrow( - TypeError(`x must have: -Minimum of: 0 -Maximum of: 15 -`), - ); - }); - - test("should throw an error for invalid color array", () => { - const invalidColor = [256, 0, 0, 1]; // Invalid red value - expect(() => cd.setColor(5, 5, invalidColor)).toThrow( - TypeError( - "Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive", - ), - ); - }); - }); - }); -}); diff --git a/tests/core/layers/pixel-layer.test.js b/tests/core/layers/pixel-layer.test.js new file mode 100644 index 0000000..628c296 --- /dev/null +++ b/tests/core/layers/pixel-layer.test.js @@ -0,0 +1,360 @@ +import PixelLayer from "#core/layers/pixel-layer.js"; +import Color from "#services/color.js"; + +describe("PixelLayer", () => { + let layer; + const testColor = Color.create({ rgb: [255, 255, 0] }); + + beforeAll(() => { + global.ImageData = class { + constructor(data, width, height) { + this.data = new Uint8ClampedArray(data); + this.width = width; + this.height = height; + } + }; + }); + + describe("Initialization", () => { + test("should create valid canvas with default size", () => { + layer = new PixelLayer(); + expect(layer.width).toBe(1); + expect(layer.height).toBe(1); + expect(layer.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test.each([[16, 16], [1024, 1024], [5, 10] + ])("should create %ix%i canvas", (width, height) => { + layer = new PixelLayer(width, height); + expect(layer.width).toBe(width); + expect(layer.height).toBe(height); + }); + + test.each([ + [0, 1], [1.5, 1], [1025, 1], [1, "invalid"] + ])("should reject invalid dimensions %p", (width, height) => { + expect(() => new PixelLayer(width, height)).toThrow(); + }); + }); + + describe("Pixel Operations", () => { + beforeEach(() => { + layer = new PixelLayer(16, 16); + layer.startAction("Test Action"); + }); + + test("should set and get pixel colors", () => { + layer.setColor(5, 5, testColor); + expect(layer.getColor(5, 5)).toEqual(testColor); + }); + + test("should validate coordinates", () => { + expect(() => layer.getColor(-1, 0)).toThrow("x"); + expect(() => layer.getColor(16, 0)).toThrow("x"); + expect(() => layer.getColor(0, -1)).toThrow("y"); + expect(() => layer.getColor(0, 16)).toThrow("y"); + }); + + test("should handle quiet updates", () => { + layer.setColor(5, 5, testColor, { quietly: true }); + expect(layer.changeBuffer.isEmpty).toBe(true); + }); + + test("should reuse color instances", () => { + const color1 = Color.create({ hex: "#ff0000" }); + const color2 = Color.create({ rgb: [255, 0, 0] }); + + layer.setColor(0, 0, color1); + layer.setColor(1, 1, color2); + expect(layer.getColor(0, 0)).toBe(layer.getColor(1, 1)); + }); + + test.only("should clear pixels", () => { + layer.setColor(0, 0, testColor); + expect(layer.changeBuffer.afterStates).toHaveLength(1); + expect(layer.getColor(0, 0)).toEqual(testColor); + layer.clear(); + expect(layer.changeBuffer.afterStates).toHaveLength(2); + expect(layer.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + }); + + describe("Change Tracking", () => { + beforeEach(() => { + layer = new PixelLayer(16, 16); + layer.startAction("Test Action"); + }); + + test("should track color changes", () => { + layer.setColor(0, 0, testColor); + layer.setColor(1, 1, testColor); + + const changes = layer.changeBuffer.afterStates; + expect(changes).toHaveLength(2); + expect(changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ x: 0, y: 0 }), + expect.objectContaining({ x: 1, y: 1 }) + ]) + ); + }); + + test("should reset change buffer", () => { + layer.setColor(0, 0, testColor); + const oldBuffer = layer.resetChangeBuffer(); + + expect(oldBuffer.afterStates).toHaveLength(1); + expect(layer.changeBuffer.isEmpty).toBe(true); + }); + + test("should track multiple changes to same pixel", () => { + layer.setColor(0, 0, testColor); + layer.setColor(0, 0, Color.TRANSPARENT); + + const changes = layer.changeBuffer.beforeStates; + expect(changes).toHaveLength(1); + expect(changes[0].state).toEqual(Color.TRANSPARENT); + }); + }); + + describe("History", () => { + let canvas; + const testActionName = "Paint Stroke"; + + beforeEach(() => { + canvas = new PixelLayer(16, 16); + canvas.startAction(testActionName); + }); + + test("should start named action", () => { + canvas.addActionStep(); + // Verify through undo/redo behavior + canvas.setColor(0, 0, testColor); + canvas.setColor(0, 1, testColor); + canvas.setColor(1, 0, testColor); + canvas.startAction("New Action"); + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test("should add segmented named action", () => { + canvas.addActionStep(); + // Verify through undo/redo behavior + canvas.setColor(0, 0, testColor); + canvas.startAction("New Action"); + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test("should undo/redo pixel states", () => { + canvas.setColor(0, 0, testColor); + canvas.addActionStep(); + + canvas.startAction("Modification"); + canvas.setColor(0, 0, Color.TRANSPARENT); + canvas.addActionStep(); + + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(testColor); + + canvas.redo(); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test("should handle history capacity", () => { + // Test through action persistence + for (let i = 0; i < 10; i++) { + canvas.startAction(`Action ${i}`); + canvas.setColor(i, 0, testColor); + canvas.addActionStep(); + } + + // Verify first actions are discarded + canvas.undo(); + canvas.undo(); + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(testColor); + }); + }); + + describe("Image Loading", () => { + const createTestImage = (color, size = 2) => { + const data = new Array(size * size * 4).fill(0).map((_, i) => + color[i % 4] ?? 0 + ); + return new ImageData(data, size, size); + }; + + test("should load full image", () => { + const imageData = createTestImage([255, 0, 0, 255], 4); + layer.loadImage(imageData, 0, 0); + + expect(layer.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); + expect(layer.getColor(3, 3)).toEqual(Color.create({ hex: '#ff0000' })); + }); + + test("should handle partial out-of-bounds images", () => { + const imageData = createTestImage([0, 255, 0, 128], 4); + layer.loadImage(imageData, 14, 14); + + expect(layer.getColor(14, 14)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); + expect(layer.getColor(15, 15)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); + expect(layer.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test("should handle negative positions", () => { + const imageData = createTestImage([255, 0, 0, 255], 4); + layer.loadImage(imageData, -2, -2); + + expect(layer.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); + expect(layer.getColor(1, 1)).toEqual(Color.create({ hex: '#ff0000' })); + expect(layer.getColor(2, 2)).toEqual(Color.TRANSPARENT); + }); + }); + + describe("Action Lifecycle", () => { + let layer; + + beforeEach(() => { + layer = new PixelLayer(16, 16); + + }); + + test("should track active action state", () => { + expect(layer.isInAction).toBe(false); + layer.startAction("test"); + expect(layer.isInAction).toBe(true); + layer.endAction(); + expect(layer.isInAction).toBe(false); + }); + + test("cancelAction() should revert changes", () => { + layer.startAction("test"); + layer.setColor(0, 0, testColor); + layer.cancelAction(); + expect(layer.getColor(0, 0)).toEqual(Color.TRANSPARENT); + expect(layer.isInAction).toBe(false); + }); + + test("should prevent pixel edits outside actions", () => { + expect(() => layer.setColor(0, 0, testColor)).toThrow("Cannot set color outside of an action"); + }); + }); + + describe("Action Cancellation", () => { + test("should fully revert multi-step actions", () => { + const layer = new PixelLayer(16, 16); + layer.startAction("multi_step"); + + // Add 5 steps + for (let i = 0; i < 5; i++) { + layer.setColor(i, i, testColor); + layer.addActionStep(); + } + + layer.cancelAction(); + + // Verify all pixels reverted + for (let i = 0; i < 5; i++) { + expect(layer.getColor(i, i)).toEqual(Color.TRANSPARENT); + } + }); + + test("should handle cancellation during merge", () => { + const layer = new PixelLayer(100, 100); + layer.startAction("massive"); + + // Add 15 steps to trigger auto-merge + for (let i = 0; i < 15; i++) { + layer.setColor(i, i, testColor); + layer.addActionStep(); + } + + console.time("Cancel with merge"); + layer.cancelAction(); + console.timeEnd("Cancel with merge"); // Should be <50ms + + expect(layer.isInAction).toBe(false); + }); + }); + + describe("Stress Test", () => { + test("1000-step action merging", () => { + const layer = new PixelLayer(1000, 1000); + layer.startAction("massive"); + + for (let i = 0; i < 1000; i++) { + layer.setColor(i % 100, i % 100, testColor); + layer.addActionStep(); + } + + console.time("Undo 1000 steps"); + layer.undo(); + console.timeEnd("Undo 1000 steps"); + + expect(layer.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test("simultaneous undo/redo", () => { + const layer = new PixelLayer(10, 10); + layer.startAction("test"); + layer.setColor(0, 0, testColor); + layer.addActionStep(); + + // Simulate rapid user input + layer.undo(); + layer.redo(); + layer.undo(); + layer.redo(); + + expect(layer.getColor(0, 0)).toEqual(testColor); + }); + }); + + describe("Edge Cases", () => { + test("should handle minimum canvas size", () => { + layer = new PixelLayer(1, 1); + layer.startAction("Test Action"); + layer.setColor(0, 0, testColor); + expect(layer.getColor(0, 0)).toEqual(testColor); + }); + + test("should handle maximum canvas size", () => { + layer = new PixelLayer(1024, 1024); + layer.startAction("Test Action"); + layer.setColor(1023, 1023, testColor); + expect(layer.getColor(1023, 1023)).toEqual(testColor); + }); + + test("should reject invalid color types", () => { + layer = new PixelLayer(16, 16); + layer.startAction("Test Action"); + expect(() => layer.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); + }); + + test("should handle rapid updates", () => { + layer = new PixelLayer(64, 64); + layer.startAction("Test Action"); + for (let i = 0; i < 1000; i++) { + layer.setColor(i % 64, i % 64, testColor, { quietly: true }); + } + expect(layer.getColor(63, 63)).toEqual(testColor); + }); + test("undo empty action", () => { + const layer = new PixelLayer(16, 16); + layer.startAction("empty"); + expect(() => layer.undo()).not.toThrow(); + }); + + test("redo without undo", () => { + const layer = new PixelLayer(16, 16); + expect(() => layer.redo()).not.toThrow(); + }); + + test("cancelAction with no changes", () => { + const layer = new PixelLayer(16, 16); + layer.startAction("noop"); + expect(() => layer.cancelAction()).not.toThrow(); + }); + }); +}); diff --git a/tests/core/managers/layer-manager.test.js b/tests/core/managers/layer-manager.test.js new file mode 100644 index 0000000..dce9782 --- /dev/null +++ b/tests/core/managers/layer-manager.test.js @@ -0,0 +1,327 @@ +global.ImageData = class MockImageData { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = new Uint8ClampedArray(width * height * 4); + } +}; + +import LayerManager from "#core/managers/layer-manager.js"; +import PixelLayer from "#core/layers/pixel-layer.js"; +import ChangeRegion from "#services/change-region.js"; +import Color from "#services/color.js"; + +describe("LayerManager", () => { + let layerManager; + + beforeEach(() => { + layerManager = new LayerManager(10, 10); + }); + + describe("Creation", () => { + test("should initialize with default [1, 1] values if no width or height given", () => { + const defaultLayerManager = new LayerManager(); + expect(defaultLayerManager.width).toBe(1); + expect(defaultLayerManager.height).toBe(1); + }); + + test.each` + width | height | description + ${1} | ${1} | ${"initialize with width 1 and height 1 (min)"} + ${3} | ${5} | ${"initialize with width 3 and height 5"} + ${1024} | ${1024} | ${"initialize with width 1024 and height 1024 (max)"} + `("should $description", ({ width, height }) => { + const defaultLayerManager = new LayerManager(width, height); + expect(defaultLayerManager.width).toBe(width); + expect(defaultLayerManager.height).toBe(height); + expect(defaultLayerManager.size).toBe(0); + expect(layerManager.list()).toEqual([]); + }); + + test.each` + width | height | errorType | description + ${-1} | ${0} | ${RangeError} | ${"throw RangeError when initialized with numbers less than 1"} + ${1024} | ${1324} | ${RangeError} | ${"throw RangeError when initialized with numbers higher than 1024"} + ${3} | ${5.5} | ${TypeError} | ${"throw TypeError when initialized with non-integer numbers"} + ${"ahem"} | ${5} | ${TypeError} | ${"throw TypeError when initialized with non-number values"} + `("should $description", ({ width, height, errorType }) => { + expect(() => new LayerManager(width, height)).toThrow(errorType); + }); + }); + + describe("Layer Manipulation", () => { + let layerIds = []; + + beforeEach(() => { + layerManager.add("Layer 1"); + layerManager.add("Layer 2"); + layerManager.add("Layer 3"); + layerManager.add("Layer 4"); + layerIds = Array.from(layerManager.list()).map(layer => layer.id); + }); + + describe("Adding Layers", () => { + test("should add a new layer", () => { + expect(layerManager.size).toBe(4); + }); + + test("should add layers with correct IDs", () => { + const layers = layerManager.list(); + expect(layers[0].id).toBe(0); + expect(layers[1].id).toBe(1); + expect(layers[2].id).toBe(2); + expect(layers[3].id).toBe(3); + }); + + test("should throw error when adding a layer with invalid name", () => { + expect(() => layerManager.add(123)).toThrow(TypeError); + expect(() => layerManager.add(null)).toThrow(TypeError); + }); + }); + + describe("Removing Layers", () => { + test("should remove a layer by ID", () => { + layerManager.remove(layerIds[1]); + expect(layerManager.size).toBe(3); + expect(layerManager.list().map(l => l.name)).toEqual(["Layer 1", "Layer 3", "Layer 4"]); + }); + + test("should remove selected layers when no IDs provided", () => { + layerManager.select(layerIds[0], layerIds[2]); + layerManager.remove(); + expect(layerManager.size).toBe(2); + expect(layerManager.list().map(l => l.name)).toEqual(["Layer 2", "Layer 4"]); + }); + + test("should throw error when removing a layer from empty list", () => { + const emptyManager = new LayerManager(); + expect(() => emptyManager.remove(0)).toThrow(RangeError); + }); + + test("should deselect removed layers", () => { + layerManager.select(layerIds[1]); + layerManager.remove(layerIds[1]); + expect(layerManager.list(true)).toEqual([]); + }); + }); + + describe("Selection", () => { + test("should select layers by ID", () => { + layerManager.select(layerIds[0], layerIds[2]); + const selected = layerManager.list(true); + expect(selected.length).toBe(2); + expect(selected[0].id).toBe(layerIds[0]); + expect(selected[1].id).toBe(layerIds[2]); + }); + + test("should throw error when selecting a layer from empty list", () => { + const emptyManager = new LayerManager(); + expect(() => emptyManager.select(0)).toThrow(RangeError); + }); + + test("should deselect layers by ID", () => { + layerManager.select(layerIds[0], layerIds[1], layerIds[2]); + layerManager.deselect(layerIds[1]); + const selected = layerManager.list(true); + expect(selected.length).toBe(2); + expect(selected.map(l => l.id)).toEqual([layerIds[0], layerIds[2]]); + }); + + test("should clear all selections", () => { + layerManager.select(layerIds[0], layerIds[1]); + layerManager.clearSelection(); + expect(layerManager.list(true)).toEqual([]); + }); + }); + + describe("Moving Layers", () => { + test("should move layer to new position", () => { + layerManager.move(2, layerIds[0]); // Move first layer down 2 positions + expect(layerManager.list().map(l => l.name)).toEqual([ + "Layer 2", + "Layer 3", + "Layer 1", + "Layer 4", + ]); + }); + + test("should not move beyond boundaries", () => { + layerManager.move(-10, layerIds[3]); // Try to move layer number 4 up beyond start + expect(layerManager.list().map(l => l.name)).toEqual([ + "Layer 4", + "Layer 1", + "Layer 2", + "Layer 3", + ]); + + layerManager.move(10, layerIds[0]); // Try to move layer number 1 down beyond end + expect(layerManager.list().map(l => l.name)).toEqual([ + "Layer 4", + "Layer 2", + "Layer 3", + "Layer 1", + ]); + }); + + test("should throw error when moving layers in empty list", () => { + const emptyManager = new LayerManager(); + expect(() => emptyManager.move(0, 1)).toThrow(RangeError); + }); + }); + + describe("Layer Properties", () => { + test("should set layer name", () => { + layerManager.setName(layerIds[0], "New Layer 1"); + expect(layerManager.getName(layerIds[0])).toBe("New Layer 1"); + }); + + test("should throw error when setting layer name with invalid ID", () => { + expect(() => layerManager.setName(999, "New Layer")).toThrow(RangeError); + }); + + test("should get layer name", () => { + expect(layerManager.getName(layerIds[0])).toBe("Layer 1"); + }); + + test("should get layer pixel data", () => { + expect(layerManager.getLayer(layerIds[0])).toBeInstanceOf(PixelLayer); + }); + + test("should throw a range error if tried to get anything from the layer list while it is empty", () => { + const emptyManager = new LayerManager(2, 2); + expect(() => emptyManager.getName(0)).toThrow(RangeError); + expect(() => emptyManager.getLayer(0)).toThrow(RangeError); + }); + }); + + describe("List Operations", () => { + test("should get the number of layers", () => { + expect(layerManager.size).toBe(4); + }); + + test("should get the list of all layer names", () => { + expect(layerManager.list().map(l => l.name)).toEqual([ + "Layer 1", + "Layer 2", + "Layer 3", + "Layer 4", + ]); + }); + + test("should get the list of selected layer names", () => { + layerManager.select(layerIds[1], layerIds[3]); + expect(layerManager.list(true).map(l => l.name)).toEqual([ + "Layer 2", + "Layer 4", + ]); + }); + }); + + describe("Edge Cases", () => { + test("should handle moving layers at boundaries", () => { + layerManager.add("Layer 5"); + const id = layerManager.list()[4].id; + + // Try to move single layer beyond top + layerManager.move(-100, id); + expect(layerManager.list().map(l => l.name)).toEqual([ + "Layer 5", + "Layer 1", + "Layer 2", + "Layer 3", + "Layer 4", + ]); + }); + + test("should maintain order after complex operations", () => { + const initialIds = [...layerIds]; + + // Move first layer to middle + layerManager.move(2, initialIds[0]); + expect(layerManager.list().map(l => l.id)).toEqual([ + initialIds[1], + initialIds[2], + initialIds[0], + initialIds[3], + ]); + + // Add new layer and move to top + layerManager.add("Layer 5"); + const newId = layerManager.list()[4].id; + layerManager.move(-4, newId); + expect(layerManager.list().map(l => l.id)).toEqual([ + newId, + initialIds[1], + initialIds[2], + initialIds[0], + initialIds[3], + ]); + }); + }); + + describe("Rendering", () => { + test("getRenderImage should create valid ImageData with all full view if not given any changes", () => { + layerManager.add("Layer 5"); + const imageData = layerManager.getRenderImage(new ChangeRegion()); + expect(imageData).toBeInstanceOf(ImageData); + expect(imageData.width).toBe(10); + expect(imageData.height).toBe(10); + }); + + test("getRenderImage should create valid mininum ImageData containing given changes postions", () => { + layerManager.add("Layer 5"); + const region = new ChangeRegion(); + region.setChange(0, 0, "after", "before"); + region.setChange(1, 0, "after", "before"); + region.setChange(0, 1, "after", "before"); + region.setChange(1, 1, "after", "before"); + const imageData = layerManager.getRenderImage(region); + expect(imageData).toBeInstanceOf(ImageData); + expect(imageData.width).toBe(2); + expect(imageData.height).toBe(2); + }); + + test("getColor should composite colors of stacked layers correctly", () => { + const [redLayerId, blueLayerId] = [layerIds[0], layerIds[1]]; + + layerManager.remove(layerIds[2], layerIds[3]); + + const red = Color.create({ rgb: [255, 0, 0], alpha: 0.5 }); + const blue = Color.create({ rgb: [0, 0, 255], alpha: 0.5 }); + + // |-bottom-> Dark BG is [160,160,160,1] -then-> transparent -then-> transparent + let result = layerManager.getColor(0, 0); + expect(result.rgb).toEqual([160, 160, 160]); + expect(result.alpha).toBeCloseTo(1); + + layerManager.getLayer(redLayerId).startAction("red"); + layerManager.getLayer(redLayerId).setColor(0, 0, red); + layerManager.getLayer(blueLayerId).startAction("blue"); + layerManager.getLayer(blueLayerId).setColor(0, 0, blue); + + result = layerManager.getColor(0, 0); + + // |-bottom-> Dark BG is [160,160,160,1] -then-> red [255,0,0,0.5] -then-> blue [0,0,255,0.5] + expect(result.rgb).toEqual([168, 40, 104]); + expect(result.alpha).toBe(1); + }); + }); + + describe("Background Management", () => { + test("should update background colors", () => { + layerManager.setBackgroundColors( + Color.create({ rgb: [0, 0, 0] }), + Color.create({ rgb: [255, 255, 255] }) + ); + + // Check even coordinate + const evenColor = layerManager.getColor(0, 0); + expect(evenColor.rgb).toEqual([255, 255, 255]); + + // Check odd coordinate + const oddColor = layerManager.getColor(1, 0); + expect(oddColor.rgb).toEqual([0, 0, 0]); + }); + }); + }); +}); diff --git a/scripts/tool-manager.js b/tests/core/managers/tool-manager.test.js similarity index 95% rename from scripts/tool-manager.js rename to tests/core/managers/tool-manager.test.js index e9c0f0d..6190087 100644 --- a/scripts/tool-manager.js +++ b/tests/core/managers/tool-manager.test.js @@ -122,11 +122,7 @@ class ToolManager { this.#canvasManager.render( this.#layerSystem.getRenderImage( this.#canvasManager.getCanvasContext, - toRender.dimensions.x0, - toRender.dimensions.y0, - toRender.dimensions.x1, - toRender.dimensions.y1, - toRender.pixelPositions, + toRender, ), toRender.dimensions.x0, toRender.dimensions.y0, diff --git a/tests/pixel-board.test.js b/tests/core/pixel-editor.test.js similarity index 100% rename from tests/pixel-board.test.js rename to tests/core/pixel-editor.test.js diff --git a/scripts/drawing-manager.js b/tests/core/tools/base-tool.test.js similarity index 84% rename from scripts/drawing-manager.js rename to tests/core/tools/base-tool.test.js index 6dc503e..0e86b53 100644 --- a/scripts/drawing-manager.js +++ b/tests/core/tools/base-tool.test.js @@ -1,38 +1,23 @@ import LayerSystem from "./layer-system.js"; -import { validateNumber, validateColorArray } from "./validation.js"; +import { validateNumber } from "./validation.js"; +import ChangeRegion from "./change-region.js"; /** * Contains graphics methods to draw on layers managed by a layer manager class * @class */ -class DrawingManager { +class PixelTool { #layerSystem; #startPixel = null; #recentPixel = null; #isActionStart = false; #toolName; + #metaData; - #recentBuffer = { - pixelPositions: [], - dimensions: { - // dirty rectangle - x0: Infinity, - y0: Infinity, - x1: -Infinity, - y1: -Infinity, - }, - }; - #currentBuffer = { - pixelPositions: [], - dimensions: { - // dirty rectangle - x0: Infinity, - y0: Infinity, - x1: -Infinity, - y1: -Infinity, - }, - }; + + #recentRect = new ChangeRegion(); + #currentRect = new ChangeRegion(); /** * Sets a specific layer manager class for which the layers will be drawn on @@ -76,36 +61,12 @@ class DrawingManager { .getLayerCanvas() .setColor(x, y, newColor, { validate: false }); - this.#currentBuffer.dimensions.x0 = Math.min( - this.#currentBuffer.dimensions.x0, - x, - ); - this.#currentBuffer.dimensions.y0 = Math.min( - this.#currentBuffer.dimensions.y0, - y, - ); - this.#currentBuffer.dimensions.x1 = Math.max( - this.#currentBuffer.dimensions.x1, - x, - ); - this.#currentBuffer.dimensions.y1 = Math.max( - this.#currentBuffer.dimensions.y1, - y, - ); - this.#currentBuffer.pixelPositions.push({ x: x, y: y }); + this.#currentRect.pushPixel(x, y); }; const history = this.#layerSystem.getLayerHistory(); - let toRender = { - pixelPositions: [], - dimensions: { - x0: Infinity, - y0: Infinity, - x1: -Infinity, - y1: -Infinity, - }, - }; + let toRender = new ChangeRegion(); if (this.#startPixel === null && !this.#isActionStart) { // did not start action yet @@ -146,7 +107,7 @@ class DrawingManager { this.#layerSystem.addToHistory(); } - this.copyBuffer(this.#currentBuffer, toRender); + toRender.copy(this.#currentRect); break; case "bucket": if (this.#isActionStart) { @@ -159,7 +120,7 @@ class DrawingManager { pixelOperation, ); this.#layerSystem.addToHistory(); - this.copyBuffer(this.#currentBuffer, toRender); + toRender.copy(this.#currentRect); } break; case "line": @@ -178,18 +139,18 @@ class DrawingManager { this.#layerSystem.addToHistory(); if (this.#isActionStart) { - this.addBuffer(this.#recentBuffer, toRender); - this.addBuffer(this.#currentBuffer, toRender); - this.setBuffer(this.#currentBuffer, this.#recentBuffer); + toRender.add(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); } else { - this.copyBuffer(this.#recentBuffer, toRender); - this.addBuffer(this.#currentBuffer, toRender); - this.setBuffer(this.#currentBuffer, this.#recentBuffer); + toRender.copy(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); } break; } - this.resetBuffer(this.#currentBuffer); + this.resetBuffer(this.#currentRect); this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; this.#isActionStart = false; @@ -198,23 +159,23 @@ class DrawingManager { //preview(pixelPosition) { // const pixelOperation = (x, y) => { - // this.#currentBuffer.dimensions.x0 = Math.min( - // this.#currentBuffer.dimensions.x0, + // this.#currentRect.dimensions.x0 = Math.min( + // this.#currentRect.dimensions.x0, // x, // ); - // this.#currentBuffer.dimensions.y0 = Math.min( - // this.#currentBuffer.dimensions.y0, + // this.#currentRect.dimensions.y0 = Math.min( + // this.#currentRect.dimensions.y0, // y, // ); - // this.#currentBuffer.dimensions.x1 = Math.max( - // this.#currentBuffer.dimensions.x1, + // this.#currentRect.dimensions.x1 = Math.max( + // this.#currentRect.dimensions.x1, // x, // ); - // this.#currentBuffer.dimensions.y1 = Math.max( - // this.#currentBuffer.dimensions.y1, + // this.#currentRect.dimensions.y1 = Math.max( + // this.#currentRect.dimensions.y1, // y, // ); - // this.#currentBuffer.pixelPositions.push({ x: x, y: y }); + // this.#currentRect.pixelPositions.push({ x: x, y: y }); // }; // // let toRender = { @@ -258,7 +219,7 @@ class DrawingManager { // // this.#layerSystem.addToHistory(); // } - // this.copyBuffer(this.#currentBuffer, toRender); + // this.copyBuffer(this.#currentRect, toRender); // break; // case "bucket": // this.setUsedColor(this.#drawColor); @@ -272,7 +233,7 @@ class DrawingManager { // pixelOperation, // ); // this.#layerSystem.addToHistory(); - // this.copyBuffer(this.#currentBuffer, toRender); + // this.copyBuffer(this.#currentRect, toRender); // } // break; // /* @@ -297,18 +258,18 @@ class DrawingManager { // this.#layerSystem.addToHistory(); // // if (this.#isActionStart) { - // this.addBuffer(this.#recentBuffer, toRender); - // this.addBuffer(this.#currentBuffer, toRender); - // this.setBuffer(this.#currentBuffer, this.#recentBuffer); + // this.addBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); // } else { - // this.copyBuffer(this.#recentBuffer, toRender); - // this.addBuffer(this.#currentBuffer, toRender); - // this.setBuffer(this.#currentBuffer, this.#recentBuffer); + // this.copyBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); // } // break; // } // - // this.resetBuffer(this.#currentBuffer); + // this.resetBuffer(this.#currentRect); // this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; // this.#isActionStart = false; // @@ -373,8 +334,8 @@ class DrawingManager { this.#isActionStart = false; if (this.#layerSystem.getLayerHistory().getActionData().length === 0) this.#layerSystem.undo(); // action does nothing, remove it - this.resetBuffer(this.#currentBuffer); - this.resetBuffer(this.#recentBuffer); + this.resetBuffer(this.#currentRect); + this.resetBuffer(this.#recentRect); } /** @@ -610,4 +571,4 @@ function isColorSimilar(color1, color2, tolerance) { return distance <= tolerance && alphaDifference <= tolerance; } -export default DrawingManager; +export default PixelTool; diff --git a/tests/core/tools/brush-tool.test.js b/tests/core/tools/brush-tool.test.js new file mode 100644 index 0000000..0e86b53 --- /dev/null +++ b/tests/core/tools/brush-tool.test.js @@ -0,0 +1,574 @@ +import LayerSystem from "./layer-system.js"; +import { validateNumber } from "./validation.js"; +import ChangeRegion from "./change-region.js"; + +/** + * Contains graphics methods to draw on layers managed by a layer manager class + * @class + */ +class PixelTool { + #layerSystem; + + #startPixel = null; + #recentPixel = null; + #isActionStart = false; + #toolName; + + #metaData; + + #recentRect = new ChangeRegion(); + #currentRect = new ChangeRegion(); + + /** + * Sets a specific layer manager class for which the layers will be drawn on + * @constructor + */ + constructor(layerSystem) { + if (!(layerSystem instanceof LayerSystem)) + throw new TypeError( + "Input type must be of instance of LayerSystem class", + ); + + this.#layerSystem = layerSystem; + } + + action(pixelPosition) { + const pixelOperation = (x, y) => { + if ( + x < 0 || + y < 0 || + x >= this.#layerSystem.getWidth || + y >= this.#layerSystem.getHeight + ) + return; + + let newColor = this.#metaData.color; + let oldColor = this.#layerSystem.getLayerCanvas().getColor(x, y); + + if ( + oldColor[0] === newColor[0] && + oldColor[1] === newColor[1] && + oldColor[2] === newColor[2] + ) + return; + this.#layerSystem.getLayerHistory().addActionData({ + x: x, + y: y, + colorOld: oldColor, + colorNew: newColor, + }); + this.#layerSystem + .getLayerCanvas() + .setColor(x, y, newColor, { validate: false }); + + this.#currentRect.pushPixel(x, y); + }; + + const history = this.#layerSystem.getLayerHistory(); + + let toRender = new ChangeRegion(); + + if (this.#startPixel === null && !this.#isActionStart) { + // did not start action yet + return toRender; + } else if (this.#startPixel === null) + // just started an action + this.#startPixel = pixelPosition; + + switch (this.#toolName) { + case "pen": + case "eraser": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.drawPixel( + pixelPosition.x, + pixelPosition.y, + this.#metaData.size, + true, + pixelOperation, + ); + } else { + this.drawLine( + this.#recentPixel.x, + this.#recentPixel.y, + pixelPosition.x, + pixelPosition.y, + () => 1, + (x, y) => { + this.drawPixel( + x, + y, + this.#metaData.size, + true, + pixelOperation, + ); + }, + ); + + this.#layerSystem.addToHistory(); + } + toRender.copy(this.#currentRect); + break; + case "bucket": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.fill( + pixelPosition.x, + pixelPosition.y, + this.#metaData.color, + this.#metaData.tolerance, + pixelOperation, + ); + this.#layerSystem.addToHistory(); + toRender.copy(this.#currentRect); + } + break; + case "line": + if (!this.#isActionStart) { + this.#layerSystem.undo(); + } + history.addActionGroup(this.#toolName); + this.drawLine( + this.#startPixel.x, + this.#startPixel.y, + pixelPosition.x, + pixelPosition.y, + this.#metaData.thicknessTimeFunction, + pixelOperation, + ); + this.#layerSystem.addToHistory(); + + if (this.#isActionStart) { + toRender.add(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } else { + toRender.copy(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } + break; + } + + this.resetBuffer(this.#currentRect); + this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + this.#isActionStart = false; + + return toRender; + } + + //preview(pixelPosition) { + // const pixelOperation = (x, y) => { + // this.#currentRect.dimensions.x0 = Math.min( + // this.#currentRect.dimensions.x0, + // x, + // ); + // this.#currentRect.dimensions.y0 = Math.min( + // this.#currentRect.dimensions.y0, + // y, + // ); + // this.#currentRect.dimensions.x1 = Math.max( + // this.#currentRect.dimensions.x1, + // x, + // ); + // this.#currentRect.dimensions.y1 = Math.max( + // this.#currentRect.dimensions.y1, + // y, + // ); + // this.#currentRect.pixelPositions.push({ x: x, y: y }); + // }; + // + // let toRender = { + // pixelPositions: [], + // dimensions: { + // x0: Infinity, + // y0: Infinity, + // x1: -Infinity, + // y1: -Infinity, + // }, + // }; + // + // switch (this.toolName) { + // case "pen": + // case "eraser": + // const size = + // this.toolName === "pen" ? this.#drawSize : this.#eraseSize; + // const color = + // this.toolName === "pen" ? this.#drawColor : [0, 0, 0, 0]; + // this.setUsedColor(color); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.drawPixel( + // pixelPosition.x, + // pixelPosition.y, + // size, + // true, + // pixelOperation, + // ); + // } else { + // this.drawLine( + // this.#recentPixel.x, + // this.#recentPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => 1, + // (x, y) => { + // this.drawPixel(x, y, size, true, pixelOperation); + // }, + // ); + // + // this.#layerSystem.addToHistory(); + // } + // this.copyBuffer(this.#currentRect, toRender); + // break; + // case "bucket": + // this.setUsedColor(this.#drawColor); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.fill( + // pixelPosition.x, + // pixelPosition.y, + // this.#color, + // this.#tolerance, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // this.copyBuffer(this.#currentRect, toRender); + // } + // break; + // /* + // case "eye-dropper": + // // !!! + // break; + // */ + // case "line": + // this.setUsedColor(this.#drawColor); + // if (!this.#isActionStart) { + // this.#layerSystem.undo(); + // } + // history.addActionGroup(this.toolName); + // this.drawLine( + // this.#startPixel.x, + // this.#startPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => this.#drawSize, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // + // if (this.#isActionStart) { + // this.addBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } else { + // this.copyBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } + // break; + // } + // + // this.resetBuffer(this.#currentRect); + // this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + // this.#isActionStart = false; + // + // return toRender; + //} + + addBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [ + ...targetBuffer.pixelPositions, + ...sourceBuffer.pixelPositions, + ]; + targetBuffer.dimensions.x0 = Math.min( + targetBuffer.dimensions.x0, + sourceBuffer.dimensions.x0, + ); + targetBuffer.dimensions.y0 = Math.min( + targetBuffer.dimensions.y0, + sourceBuffer.dimensions.y0, + ); + targetBuffer.dimensions.x1 = Math.max( + targetBuffer.dimensions.x1, + sourceBuffer.dimensions.x1, + ); + targetBuffer.dimensions.y1 = Math.max( + targetBuffer.dimensions.y1, + sourceBuffer.dimensions.y1, + ); + } + + setBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = sourceBuffer.pixelPositions; + targetBuffer.dimensions = sourceBuffer.dimensions; + } + + copyBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [...sourceBuffer.pixelPositions]; + targetBuffer.dimensions.x0 = sourceBuffer.dimensions.x0; + targetBuffer.dimensions.y0 = sourceBuffer.dimensions.y0; + targetBuffer.dimensions.x1 = sourceBuffer.dimensions.x1; + targetBuffer.dimensions.y1 = sourceBuffer.dimensions.y1; + } + + resetBuffer(buffer) { + buffer.pixelPositions = []; + buffer.dimensions = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity, + }; + } + + startAction(toolName, metaData) { + this.#toolName = toolName; + this.#metaData = metaData; + this.#isActionStart = true; + } + + endAction() { + this.#startPixel = null; + this.#recentPixel = null; + this.#isActionStart = false; + if (this.#layerSystem.getLayerHistory().getActionData().length === 0) + this.#layerSystem.undo(); // action does nothing, remove it + this.resetBuffer(this.#currentRect); + this.resetBuffer(this.#recentRect); + } + + /** + * draws pixel at position (x, y) with a specific diameter + * @method + * @param {number} x - x position + * @param {number} y - y position + * @param {number} [diameter=1] - Diameter for drawing the pixel, minimum = 1 + * @param {boolean} [isSquare=true] - Draws a square pixel with side = radius, draws in a circle shape otherwise + * @throws {TypeError} - if type of parameters is invalid + * @throws {RangeError} - if range of parameters is invalid + */ + drawPixel(x, y, diameter = 1, isSquare = true, pixelOperation) { + validateNumber(x, "x", { isInteger: true }); + validateNumber(y, "y", { isInteger: true }); + validateNumber(diameter, "Diameter", { start: 1 }); + if (typeof isSquare !== "boolean") + throw new TypeError("isSquare must be boolean"); + + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + pixelOperation(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + + if (dx * dx + dy * dy <= radiusSquared) { + pixelOperation(currentX, currentY); + } + } + } + + drawLine(x0, y0, x1, y1, thicknessFunction, pixelOperation) { + const drawPrepLine = ( + x0, + y0, + dx, + dy, + width, + initError, + initWidth, + direction, + pixelOperation, + ) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + + while (thickness <= widthThreshold) { + pixelOperation(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = ( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError, + error, + dir, + pixelOperation, + ); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError + diagonalError + stepError, + error, + dir, + pixelOperation, + ); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents( + y0, + x0, + y1, + x1, + thicknessFunction, + (x, y) => pixelOperation(y, x), + ); + else + drawLineRightLeftOctents( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ); + } + + /** + * Fills an area of semi-uniform color (with some tolerance difference) which contains the position (x, y) with the given color + * @method + * @param {number} x + * @param {number} y + * @param {[number, number, number, number]} An array containing color data [red, green, blue, alpha] + * @param {number} [tolerance=0] !!! + */ + fill(x, y, color, tolerance = 0, pixelOperation) { + if ( + x < 0 || + y < 0 || + x >= this.#layerSystem.getWidth || + y >= this.#layerSystem.getHeight + ) + return; + + const canvas = this.#layerSystem.getLayerCanvas(); + const originalPixel = canvas.get(x, y); + const originalColor = originalPixel.color; + + // early exit if the color is the same + if (isColorSimilar(originalColor, color, tolerance)) return; + + const toVisit = [{ x: x, y: y }]; + const toFill = []; + const visited = new Set(); + + while (toVisit.length) { + const currentPixel = toVisit.pop(); + const { x: currX, y: currY } = currentPixel; + + // check if already visited + const pixelKey = `${currX},${currY}`; + if (visited.has(pixelKey)) continue; + visited.add(pixelKey); + + const currentColor = canvas.getColor(currX, currY); + + if (isColorSimilar(currentColor, originalColor, tolerance)) { + toFill.push(currentPixel); + + // add adjacent pixels to visit + if (currX > 0) toVisit.push({ x: currX - 1, y: currY }); // left + if (currX < this.#layerSystem.getWidth - 1) + toVisit.push({ x: currX + 1, y: currY }); // right + if (currY > 0) toVisit.push({ x: currX, y: currY - 1 }); // up + if (currY < this.#layerSystem.getHeight - 1) + toVisit.push({ x: currX, y: currY + 1 }); // down + } + } + // set the color for all pixels to fill + toFill.forEach((pixel) => { + pixelOperation(pixel.x, pixel.y); + }); + } +} + +function isColorSimilar(color1, color2, tolerance) { + const distance = Math.sqrt( + Math.pow(color1[0] - color2[0], 2) + + Math.pow(color1[1] - color2[1], 2) + + Math.pow(color1[2] - color2[2], 2), + ); + const alphaDifference = 255 * Math.abs(color1[3] - color2[3]); + return distance <= tolerance && alphaDifference <= tolerance; +} + +export default PixelTool; diff --git a/tests/core/tools/drawing-tool.test.js b/tests/core/tools/drawing-tool.test.js new file mode 100644 index 0000000..70daa9a --- /dev/null +++ b/tests/core/tools/drawing-tool.test.js @@ -0,0 +1,574 @@ +import LayerManager from "./layer-manager.js"; +import { validateNumber } from "./validation.js"; +import ChangeRegion from "./change-region.js"; + +/** + * Contains graphics methods to draw on layers managed by a layer manager class + * @class + */ +class DrawingTool { + #layerManager; + + #startPixel = null; + #recentPixel = null; + #isActionStart = false; + #toolName; + + #metaData; + + #recentRect = new ChangeRegion(); + #currentRect = new ChangeRegion(); + + /** + * Sets a specific layer manager class for which the layers will be drawn on + * @constructor + */ + constructor(layerSystem) { + if (!(layerSystem instanceof LayerManager)) + throw new TypeError( + "Input type must be of instance of LayerSystem class", + ); + + this.#layerManager = layerSystem; + } + + action(pixelPosition) { + const pixelOperation = (x, y) => { + if ( + x < 0 || + y < 0 || + x >= this.#layerManager.width || + y >= this.#layerManager.height + ) + return; + + let newColor = this.#metaData.color; + let oldColor = this.#layerManager.getLayerCanvas().getColor(x, y); + + if ( + oldColor[0] === newColor[0] && + oldColor[1] === newColor[1] && + oldColor[2] === newColor[2] + ) + return; + this.#layerManager.getLayerHistory().addActionData({ + x: x, + y: y, + colorOld: oldColor, + colorNew: newColor, + }); + this.#layerManager + .getLayerCanvas() + .setColor(x, y, newColor, { validate: false }); + + this.#currentRect.pushPixel(x, y); + }; + + const history = this.#layerManager.getLayerHistory(); + + let toRender = new ChangeRegion(); + + if (this.#startPixel === null && !this.#isActionStart) { + // did not start action yet + return toRender; + } else if (this.#startPixel === null) + // just started an action + this.#startPixel = pixelPosition; + + switch (this.#toolName) { + case "pen": + case "eraser": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.drawPixel( + pixelPosition.x, + pixelPosition.y, + this.#metaData.size, + true, + pixelOperation, + ); + } else { + this.drawLine( + this.#recentPixel.x, + this.#recentPixel.y, + pixelPosition.x, + pixelPosition.y, + () => 1, + (x, y) => { + this.drawPixel( + x, + y, + this.#metaData.size, + true, + pixelOperation, + ); + }, + ); + + this.#layerManager.addToHistory(); + } + toRender.copy(this.#currentRect); + break; + case "bucket": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.fill( + pixelPosition.x, + pixelPosition.y, + this.#metaData.color, + this.#metaData.tolerance, + pixelOperation, + ); + this.#layerManager.addToHistory(); + toRender.copy(this.#currentRect); + } + break; + case "line": + if (!this.#isActionStart) { + this.#layerManager.undo(); + } + history.addActionGroup(this.#toolName); + this.drawLine( + this.#startPixel.x, + this.#startPixel.y, + pixelPosition.x, + pixelPosition.y, + this.#metaData.thicknessTimeFunction, + pixelOperation, + ); + this.#layerManager.addToHistory(); + + if (this.#isActionStart) { + toRender.add(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } else { + toRender.copy(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } + break; + } + + this.resetBuffer(this.#currentRect); + this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + this.#isActionStart = false; + + return toRender; + } + + //preview(pixelPosition) { + // const pixelOperation = (x, y) => { + // this.#currentRect.dimensions.x0 = Math.min( + // this.#currentRect.dimensions.x0, + // x, + // ); + // this.#currentRect.dimensions.y0 = Math.min( + // this.#currentRect.dimensions.y0, + // y, + // ); + // this.#currentRect.dimensions.x1 = Math.max( + // this.#currentRect.dimensions.x1, + // x, + // ); + // this.#currentRect.dimensions.y1 = Math.max( + // this.#currentRect.dimensions.y1, + // y, + // ); + // this.#currentRect.pixelPositions.push({ x: x, y: y }); + // }; + // + // let toRender = { + // pixelPositions: [], + // dimensions: { + // x0: Infinity, + // y0: Infinity, + // x1: -Infinity, + // y1: -Infinity, + // }, + // }; + // + // switch (this.toolName) { + // case "pen": + // case "eraser": + // const size = + // this.toolName === "pen" ? this.#drawSize : this.#eraseSize; + // const color = + // this.toolName === "pen" ? this.#drawColor : [0, 0, 0, 0]; + // this.setUsedColor(color); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.drawPixel( + // pixelPosition.x, + // pixelPosition.y, + // size, + // true, + // pixelOperation, + // ); + // } else { + // this.drawLine( + // this.#recentPixel.x, + // this.#recentPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => 1, + // (x, y) => { + // this.drawPixel(x, y, size, true, pixelOperation); + // }, + // ); + // + // this.#layerSystem.addToHistory(); + // } + // this.copyBuffer(this.#currentRect, toRender); + // break; + // case "bucket": + // this.setUsedColor(this.#drawColor); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.fill( + // pixelPosition.x, + // pixelPosition.y, + // this.#color, + // this.#tolerance, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // this.copyBuffer(this.#currentRect, toRender); + // } + // break; + // /* + // case "eye-dropper": + // // !!! + // break; + // */ + // case "line": + // this.setUsedColor(this.#drawColor); + // if (!this.#isActionStart) { + // this.#layerSystem.undo(); + // } + // history.addActionGroup(this.toolName); + // this.drawLine( + // this.#startPixel.x, + // this.#startPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => this.#drawSize, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // + // if (this.#isActionStart) { + // this.addBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } else { + // this.copyBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } + // break; + // } + // + // this.resetBuffer(this.#currentRect); + // this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + // this.#isActionStart = false; + // + // return toRender; + //} + + addBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [ + ...targetBuffer.pixelPositions, + ...sourceBuffer.pixelPositions, + ]; + targetBuffer.dimensions.x0 = Math.min( + targetBuffer.dimensions.x0, + sourceBuffer.dimensions.x0, + ); + targetBuffer.dimensions.y0 = Math.min( + targetBuffer.dimensions.y0, + sourceBuffer.dimensions.y0, + ); + targetBuffer.dimensions.x1 = Math.max( + targetBuffer.dimensions.x1, + sourceBuffer.dimensions.x1, + ); + targetBuffer.dimensions.y1 = Math.max( + targetBuffer.dimensions.y1, + sourceBuffer.dimensions.y1, + ); + } + + setBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = sourceBuffer.pixelPositions; + targetBuffer.dimensions = sourceBuffer.dimensions; + } + + copyBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [...sourceBuffer.pixelPositions]; + targetBuffer.dimensions.x0 = sourceBuffer.dimensions.x0; + targetBuffer.dimensions.y0 = sourceBuffer.dimensions.y0; + targetBuffer.dimensions.x1 = sourceBuffer.dimensions.x1; + targetBuffer.dimensions.y1 = sourceBuffer.dimensions.y1; + } + + resetBuffer(buffer) { + buffer.pixelPositions = []; + buffer.dimensions = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity, + }; + } + + startAction(toolName, metaData) { + this.#toolName = toolName; + this.#metaData = metaData; + this.#isActionStart = true; + } + + endAction() { + this.#startPixel = null; + this.#recentPixel = null; + this.#isActionStart = false; + if (this.#layerManager.getLayerHistory().getActionData().length === 0) + this.#layerManager.undo(); // action does nothing, remove it + this.resetBuffer(this.#currentRect); + this.resetBuffer(this.#recentRect); + } + + /** + * draws pixel at position (x, y) with a specific diameter + * @method + * @param {number} x - x position + * @param {number} y - y position + * @param {number} [diameter=1] - Diameter for drawing the pixel, minimum = 1 + * @param {boolean} [isSquare=true] - Draws a square pixel with side = radius, draws in a circle shape otherwise + * @throws {TypeError} - if type of parameters is invalid + * @throws {RangeError} - if range of parameters is invalid + */ + drawPixel(x, y, diameter = 1, isSquare = true, pixelOperation) { + validateNumber(x, "x", { isInteger: true }); + validateNumber(y, "y", { isInteger: true }); + validateNumber(diameter, "Diameter", { start: 1 }); + if (typeof isSquare !== "boolean") + throw new TypeError("isSquare must be boolean"); + + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + pixelOperation(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + + if (dx * dx + dy * dy <= radiusSquared) { + pixelOperation(currentX, currentY); + } + } + } + + drawLine(x0, y0, x1, y1, thicknessFunction, pixelOperation) { + const drawPrepLine = ( + x0, + y0, + dx, + dy, + width, + initError, + initWidth, + direction, + pixelOperation, + ) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + + while (thickness <= widthThreshold) { + pixelOperation(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = ( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError, + error, + dir, + pixelOperation, + ); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError + diagonalError + stepError, + error, + dir, + pixelOperation, + ); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents( + y0, + x0, + y1, + x1, + thicknessFunction, + (x, y) => pixelOperation(y, x), + ); + else + drawLineRightLeftOctents( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ); + } + + /** + * Fills an area of semi-uniform color (with some tolerance difference) which contains the position (x, y) with the given color + * @method + * @param {number} x + * @param {number} y + * @param {[number, number, number, number]} An array containing color data [red, green, blue, alpha] + * @param {number} [tolerance=0] !!! + */ + fill(x, y, color, tolerance = 0, pixelOperation) { + if ( + x < 0 || + y < 0 || + x >= this.#layerManager.getWidth || + y >= this.#layerManager.getHeight + ) + return; + + const canvas = this.#layerManager.getLayerCanvas(); + const originalPixel = canvas.get(x, y); + const originalColor = originalPixel.color; + + // early exit if the color is the same + if (isColorSimilar(originalColor, color, tolerance)) return; + + const toVisit = [{ x: x, y: y }]; + const toFill = []; + const visited = new Set(); + + while (toVisit.length) { + const currentPixel = toVisit.pop(); + const { x: currX, y: currY } = currentPixel; + + // check if already visited + const pixelKey = `${currX},${currY}`; + if (visited.has(pixelKey)) continue; + visited.add(pixelKey); + + const currentColor = canvas.getColor(currX, currY); + + if (isColorSimilar(currentColor, originalColor, tolerance)) { + toFill.push(currentPixel); + + // add adjacent pixels to visit + if (currX > 0) toVisit.push({ x: currX - 1, y: currY }); // left + if (currX < this.#layerManager.getWidth - 1) + toVisit.push({ x: currX + 1, y: currY }); // right + if (currY > 0) toVisit.push({ x: currX, y: currY - 1 }); // up + if (currY < this.#layerManager.getHeight - 1) + toVisit.push({ x: currX, y: currY + 1 }); // down + } + } + // set the color for all pixels to fill + toFill.forEach((pixel) => { + pixelOperation(pixel.x, pixel.y); + }); + } +} + +function isColorSimilar(color1, color2, tolerance) { + const distance = Math.sqrt( + Math.pow(color1[0] - color2[0], 2) + + Math.pow(color1[1] - color2[1], 2) + + Math.pow(color1[2] - color2[2], 2), + ); + const alphaDifference = 255 * Math.abs(color1[3] - color2[3]); + return distance <= tolerance && alphaDifference <= tolerance; +} + +export default DrawingTool; diff --git a/tests/core/tools/pixel-tool.test.js b/tests/core/tools/pixel-tool.test.js new file mode 100644 index 0000000..0e86b53 --- /dev/null +++ b/tests/core/tools/pixel-tool.test.js @@ -0,0 +1,574 @@ +import LayerSystem from "./layer-system.js"; +import { validateNumber } from "./validation.js"; +import ChangeRegion from "./change-region.js"; + +/** + * Contains graphics methods to draw on layers managed by a layer manager class + * @class + */ +class PixelTool { + #layerSystem; + + #startPixel = null; + #recentPixel = null; + #isActionStart = false; + #toolName; + + #metaData; + + #recentRect = new ChangeRegion(); + #currentRect = new ChangeRegion(); + + /** + * Sets a specific layer manager class for which the layers will be drawn on + * @constructor + */ + constructor(layerSystem) { + if (!(layerSystem instanceof LayerSystem)) + throw new TypeError( + "Input type must be of instance of LayerSystem class", + ); + + this.#layerSystem = layerSystem; + } + + action(pixelPosition) { + const pixelOperation = (x, y) => { + if ( + x < 0 || + y < 0 || + x >= this.#layerSystem.getWidth || + y >= this.#layerSystem.getHeight + ) + return; + + let newColor = this.#metaData.color; + let oldColor = this.#layerSystem.getLayerCanvas().getColor(x, y); + + if ( + oldColor[0] === newColor[0] && + oldColor[1] === newColor[1] && + oldColor[2] === newColor[2] + ) + return; + this.#layerSystem.getLayerHistory().addActionData({ + x: x, + y: y, + colorOld: oldColor, + colorNew: newColor, + }); + this.#layerSystem + .getLayerCanvas() + .setColor(x, y, newColor, { validate: false }); + + this.#currentRect.pushPixel(x, y); + }; + + const history = this.#layerSystem.getLayerHistory(); + + let toRender = new ChangeRegion(); + + if (this.#startPixel === null && !this.#isActionStart) { + // did not start action yet + return toRender; + } else if (this.#startPixel === null) + // just started an action + this.#startPixel = pixelPosition; + + switch (this.#toolName) { + case "pen": + case "eraser": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.drawPixel( + pixelPosition.x, + pixelPosition.y, + this.#metaData.size, + true, + pixelOperation, + ); + } else { + this.drawLine( + this.#recentPixel.x, + this.#recentPixel.y, + pixelPosition.x, + pixelPosition.y, + () => 1, + (x, y) => { + this.drawPixel( + x, + y, + this.#metaData.size, + true, + pixelOperation, + ); + }, + ); + + this.#layerSystem.addToHistory(); + } + toRender.copy(this.#currentRect); + break; + case "bucket": + if (this.#isActionStart) { + history.addActionGroup(this.#toolName); + this.fill( + pixelPosition.x, + pixelPosition.y, + this.#metaData.color, + this.#metaData.tolerance, + pixelOperation, + ); + this.#layerSystem.addToHistory(); + toRender.copy(this.#currentRect); + } + break; + case "line": + if (!this.#isActionStart) { + this.#layerSystem.undo(); + } + history.addActionGroup(this.#toolName); + this.drawLine( + this.#startPixel.x, + this.#startPixel.y, + pixelPosition.x, + pixelPosition.y, + this.#metaData.thicknessTimeFunction, + pixelOperation, + ); + this.#layerSystem.addToHistory(); + + if (this.#isActionStart) { + toRender.add(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } else { + toRender.copy(this.#recentRect); + toRender.add(this.#currentRect); + this.#recentRect.set(this.#currentRect); + } + break; + } + + this.resetBuffer(this.#currentRect); + this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + this.#isActionStart = false; + + return toRender; + } + + //preview(pixelPosition) { + // const pixelOperation = (x, y) => { + // this.#currentRect.dimensions.x0 = Math.min( + // this.#currentRect.dimensions.x0, + // x, + // ); + // this.#currentRect.dimensions.y0 = Math.min( + // this.#currentRect.dimensions.y0, + // y, + // ); + // this.#currentRect.dimensions.x1 = Math.max( + // this.#currentRect.dimensions.x1, + // x, + // ); + // this.#currentRect.dimensions.y1 = Math.max( + // this.#currentRect.dimensions.y1, + // y, + // ); + // this.#currentRect.pixelPositions.push({ x: x, y: y }); + // }; + // + // let toRender = { + // pixelPositions: [], + // dimensions: { + // x0: Infinity, + // y0: Infinity, + // x1: -Infinity, + // y1: -Infinity, + // }, + // }; + // + // switch (this.toolName) { + // case "pen": + // case "eraser": + // const size = + // this.toolName === "pen" ? this.#drawSize : this.#eraseSize; + // const color = + // this.toolName === "pen" ? this.#drawColor : [0, 0, 0, 0]; + // this.setUsedColor(color); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.drawPixel( + // pixelPosition.x, + // pixelPosition.y, + // size, + // true, + // pixelOperation, + // ); + // } else { + // this.drawLine( + // this.#recentPixel.x, + // this.#recentPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => 1, + // (x, y) => { + // this.drawPixel(x, y, size, true, pixelOperation); + // }, + // ); + // + // this.#layerSystem.addToHistory(); + // } + // this.copyBuffer(this.#currentRect, toRender); + // break; + // case "bucket": + // this.setUsedColor(this.#drawColor); + // if (this.#isActionStart) { + // history.addActionGroup(this.toolName); + // this.fill( + // pixelPosition.x, + // pixelPosition.y, + // this.#color, + // this.#tolerance, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // this.copyBuffer(this.#currentRect, toRender); + // } + // break; + // /* + // case "eye-dropper": + // // !!! + // break; + // */ + // case "line": + // this.setUsedColor(this.#drawColor); + // if (!this.#isActionStart) { + // this.#layerSystem.undo(); + // } + // history.addActionGroup(this.toolName); + // this.drawLine( + // this.#startPixel.x, + // this.#startPixel.y, + // pixelPosition.x, + // pixelPosition.y, + // () => this.#drawSize, + // pixelOperation, + // ); + // this.#layerSystem.addToHistory(); + // + // if (this.#isActionStart) { + // this.addBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } else { + // this.copyBuffer(this.#recentRect, toRender); + // this.addBuffer(this.#currentRect, toRender); + // this.setBuffer(this.#currentRect, this.#recentRect); + // } + // break; + // } + // + // this.resetBuffer(this.#currentRect); + // this.#recentPixel = { x: pixelPosition.x, y: pixelPosition.y }; + // this.#isActionStart = false; + // + // return toRender; + //} + + addBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [ + ...targetBuffer.pixelPositions, + ...sourceBuffer.pixelPositions, + ]; + targetBuffer.dimensions.x0 = Math.min( + targetBuffer.dimensions.x0, + sourceBuffer.dimensions.x0, + ); + targetBuffer.dimensions.y0 = Math.min( + targetBuffer.dimensions.y0, + sourceBuffer.dimensions.y0, + ); + targetBuffer.dimensions.x1 = Math.max( + targetBuffer.dimensions.x1, + sourceBuffer.dimensions.x1, + ); + targetBuffer.dimensions.y1 = Math.max( + targetBuffer.dimensions.y1, + sourceBuffer.dimensions.y1, + ); + } + + setBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = sourceBuffer.pixelPositions; + targetBuffer.dimensions = sourceBuffer.dimensions; + } + + copyBuffer(sourceBuffer, targetBuffer) { + targetBuffer.pixelPositions = [...sourceBuffer.pixelPositions]; + targetBuffer.dimensions.x0 = sourceBuffer.dimensions.x0; + targetBuffer.dimensions.y0 = sourceBuffer.dimensions.y0; + targetBuffer.dimensions.x1 = sourceBuffer.dimensions.x1; + targetBuffer.dimensions.y1 = sourceBuffer.dimensions.y1; + } + + resetBuffer(buffer) { + buffer.pixelPositions = []; + buffer.dimensions = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity, + }; + } + + startAction(toolName, metaData) { + this.#toolName = toolName; + this.#metaData = metaData; + this.#isActionStart = true; + } + + endAction() { + this.#startPixel = null; + this.#recentPixel = null; + this.#isActionStart = false; + if (this.#layerSystem.getLayerHistory().getActionData().length === 0) + this.#layerSystem.undo(); // action does nothing, remove it + this.resetBuffer(this.#currentRect); + this.resetBuffer(this.#recentRect); + } + + /** + * draws pixel at position (x, y) with a specific diameter + * @method + * @param {number} x - x position + * @param {number} y - y position + * @param {number} [diameter=1] - Diameter for drawing the pixel, minimum = 1 + * @param {boolean} [isSquare=true] - Draws a square pixel with side = radius, draws in a circle shape otherwise + * @throws {TypeError} - if type of parameters is invalid + * @throws {RangeError} - if range of parameters is invalid + */ + drawPixel(x, y, diameter = 1, isSquare = true, pixelOperation) { + validateNumber(x, "x", { isInteger: true }); + validateNumber(y, "y", { isInteger: true }); + validateNumber(diameter, "Diameter", { start: 1 }); + if (typeof isSquare !== "boolean") + throw new TypeError("isSquare must be boolean"); + + diameter = Math.floor(diameter); + const radius = Math.floor(0.5 * diameter); // Pre-calculate radius + const radiusSquared = radius * radius; // Pre-calculate radius squared for performance + const startX = x - radius; + const startY = y - radius; + const endX = Math.max(x + 1, x + radius); + const endY = Math.max(y + 1, y + radius); + + if (isSquare) + // For squared area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + pixelOperation(currentX, currentY); + } + else + // For circular area + for (let currentY = startY; currentY < endY; currentY++) + for (let currentX = startX; currentX < endX; currentX++) { + const dx = x - currentX - 0.5; + const dy = y - currentY - 0.5; + + if (dx * dx + dy * dy <= radiusSquared) { + pixelOperation(currentX, currentY); + } + } + } + + drawLine(x0, y0, x1, y1, thicknessFunction, pixelOperation) { + const drawPrepLine = ( + x0, + y0, + dx, + dy, + width, + initError, + initWidth, + direction, + pixelOperation, + ) => { + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + dx *= stepX; + dy *= stepY; + + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + const widthThreshold = 2 * width * Math.sqrt(dx * dx + dy * dy); + + let error = direction * initError; + let y = y0; + let x = x0; + let thickness = dx + dy - direction * initWidth; + + while (thickness <= widthThreshold) { + pixelOperation(x, y); + if (error > threshold) { + x -= stepX * direction; + error += diagonalError; + thickness += stepError; + } + error += stepError; + thickness -= diagonalError; + y += stepY * direction; + } + }; + const drawLineRightLeftOctents = ( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ) => { + const stepX = x1 - x0 > 0 ? 1 : -1; + const stepY = y1 - y0 > 0 ? 1 : -1; + const dx = (x1 - x0) * stepX; + const dy = (y1 - y0) * stepY; + const threshold = dx - 2 * dy; + const diagonalError = -2 * dx; + const stepError = 2 * dy; + + let error = 0; + let prepError = 0; + let y = y0; + let x = x0; + + for (let i = 0; i < dx; i++) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError, + error, + dir, + pixelOperation, + ); + }); + if (error > threshold) { + y += stepY; + error += diagonalError; + if (prepError > threshold) { + [1, -1].forEach((dir) => { + drawPrepLine( + x, + y, + dx * stepX, + dy * stepY, + thicknessFunction(i) / 2, + prepError + diagonalError + stepError, + error, + dir, + pixelOperation, + ); + }); + prepError += diagonalError; + } + prepError += stepError; + } + error += stepError; + x += stepX; + } + }; + + if (Math.abs(x1 - x0) < Math.abs(y1 - y0)) + // if line is steep, flip along x = y axis, then do the function then flip the pixels again then draw + drawLineRightLeftOctents( + y0, + x0, + y1, + x1, + thicknessFunction, + (x, y) => pixelOperation(y, x), + ); + else + drawLineRightLeftOctents( + x0, + y0, + x1, + y1, + thicknessFunction, + pixelOperation, + ); + } + + /** + * Fills an area of semi-uniform color (with some tolerance difference) which contains the position (x, y) with the given color + * @method + * @param {number} x + * @param {number} y + * @param {[number, number, number, number]} An array containing color data [red, green, blue, alpha] + * @param {number} [tolerance=0] !!! + */ + fill(x, y, color, tolerance = 0, pixelOperation) { + if ( + x < 0 || + y < 0 || + x >= this.#layerSystem.getWidth || + y >= this.#layerSystem.getHeight + ) + return; + + const canvas = this.#layerSystem.getLayerCanvas(); + const originalPixel = canvas.get(x, y); + const originalColor = originalPixel.color; + + // early exit if the color is the same + if (isColorSimilar(originalColor, color, tolerance)) return; + + const toVisit = [{ x: x, y: y }]; + const toFill = []; + const visited = new Set(); + + while (toVisit.length) { + const currentPixel = toVisit.pop(); + const { x: currX, y: currY } = currentPixel; + + // check if already visited + const pixelKey = `${currX},${currY}`; + if (visited.has(pixelKey)) continue; + visited.add(pixelKey); + + const currentColor = canvas.getColor(currX, currY); + + if (isColorSimilar(currentColor, originalColor, tolerance)) { + toFill.push(currentPixel); + + // add adjacent pixels to visit + if (currX > 0) toVisit.push({ x: currX - 1, y: currY }); // left + if (currX < this.#layerSystem.getWidth - 1) + toVisit.push({ x: currX + 1, y: currY }); // right + if (currY > 0) toVisit.push({ x: currX, y: currY - 1 }); // up + if (currY < this.#layerSystem.getHeight - 1) + toVisit.push({ x: currX, y: currY + 1 }); // down + } + } + // set the color for all pixels to fill + toFill.forEach((pixel) => { + pixelOperation(pixel.x, pixel.y); + }); + } +} + +function isColorSimilar(color1, color2, tolerance) { + const distance = Math.sqrt( + Math.pow(color1[0] - color2[0], 2) + + Math.pow(color1[1] - color2[1], 2) + + Math.pow(color1[2] - color2[2], 2), + ); + const alphaDifference = 255 * Math.abs(color1[3] - color2[3]); + return distance <= tolerance && alphaDifference <= tolerance; +} + +export default PixelTool; diff --git a/tests/history-system.test.js b/tests/history-system.test.js deleted file mode 100644 index 3c89df4..0000000 --- a/tests/history-system.test.js +++ /dev/null @@ -1,361 +0,0 @@ -import HistorySystem from "../scripts/history-system.js"; - -describe("HistorySystem", () => { - let hs; - describe("Construction", () => { - test("should throw an error when capacity is not defined finite integer", () => { - expect(() => new HistorySystem()).toThrow( - "Capacity must be defined", - ); - - expect(() => new HistorySystem([])).toThrow( - "Capacity must be defined finite number" - ); - expect(() => new HistorySystem(false)).toThrow( - "Capacity must be defined finite number" - ); - - expect(() => new HistorySystem(Infinity)).toThrow( - "Capacity must be defined finite number" - ); - expect(() => new HistorySystem(NaN)).toThrow( - "Capacity must be defined finite number" - ); - - expect(() => new HistorySystem(0.8)).toThrow( - "Capacity must be integer" - ); - expect(() => new HistorySystem(13.001)).toThrow( - "Capacity must be integer" - ); - }); - - test("should throw an error when capacity is not between 1 and 64", () => { - expect(() => new HistorySystem(-20)).toThrow( -`Capacity must have: -Minimum of: 1 -Maximum of: 64 -` - ); - expect(() => new HistorySystem(0)).toThrow( -`Capacity must have: -Minimum of: 1 -Maximum of: 64 -` - ); - expect(() => new HistorySystem(100)).toThrow( -`Capacity must have: -Minimum of: 1 -Maximum of: 64 -` - ); - }); - - test("should return capacity of buffer when calling getBufferCapacity", () => { - hs = new HistorySystem(1); - expect(hs.getBufferCapacity).toBe(1); - - hs = new HistorySystem(20); - expect(hs.getBufferCapacity).toBe(20); - - hs = new HistorySystem(64); - expect(hs.getBufferCapacity).toBe(64); - }); - }); - - describe("Functionality", () => { - let hs; - let assertGroup; - beforeEach(() => { - assertGroup = function( - offset, - expectedID = null, - expectedName = null, - expectedData = null, - ) { - if (expectedID !== null) - expect(hs.getActionGroupID(offset)).toBe(expectedID); - if (expectedName !== null) - expect(hs.getActionGroupName(offset)).toBe(expectedName); - if (expectedData !== null) { - expect(hs.getActionData(offset)).toStrictEqual(expectedData); - expect(hs.getActionData(offset)).not.toBe(expectedData); - } - }; - hs = new HistorySystem(5); - }); - - describe("Adding action group - Undoing/Redoing", () => { - test("should throw an error if given action group name is not a string", () => { - expect(() => hs.addActionGroup(0)).toThrow( - "Action group name must be string", - ); - }); - - test("should add action group with given name or no name given if none given", () => { - expect(() => hs.addActionGroup("")).not.toThrow(); - expect(() => hs.addActionGroup("ahmed")).not.toThrow(); - expect(() => hs.addActionGroup()).not.toThrow(); - assertGroup(0, 2, ""); - assertGroup(-1, 1, "ahmed"); - assertGroup(-2, 0, ""); - }); - - test("should undo and redo and return action group ID, -1 if at start", () => { - assertGroup(0, -1, -1); - - hs.addActionGroup("AG1"); - - assertGroup(0, 0, "AG1"); - - hs.addActionGroup("AG2"); - - assertGroup(0, 1, "AG2"); - - hs.addActionGroup("AG3"); - - assertGroup(0, 2, "AG3"); - - expect(hs.undo()).toBe(1); - - assertGroup(0, 1, "AG2"); - - expect(hs.undo()).toBe(0); - - assertGroup(0, 0, "AG1"); - - expect(hs.undo()).toBe(-1); - - assertGroup(0, -1, -1); - - expect(hs.undo()).toBe(-1); - - assertGroup(0, -1, -1); - - expect(hs.redo()).toBe(0); - - assertGroup(0, 0, "AG1"); - - expect(hs.redo()).toBe(1); - - assertGroup(0, 1, "AG2"); - - expect(hs.redo()).toBe(2); - - assertGroup(0, 2, "AG3"); - - expect(hs.redo()).toBe(2); - - assertGroup(0, 2, "AG3"); - }); - - test("should truncate the undid part if added new action group before it", () => { - expect(() => hs.addActionGroup("wash")).not.toThrow(); - expect(() => hs.addActionGroup("do whatever")).not.toThrow(); - expect(() => hs.addActionGroup("sleep")).not.toThrow(); - expect(() => hs.addActionGroup("play")).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - assertGroup(1, 2, "sleep"); - assertGroup(2, 3, "play"); - expect(() => hs.addActionGroup("study")).not.toThrow(); - assertGroup(1, -1, -1); - assertGroup(2, -1, -1); - }); - - test("should override old groups if added new groups while buffer is full", () => { - expect(hs.getBufferSize).toBe(0); - expect(() => hs.addActionGroup("wash")).not.toThrow(); - expect(() => hs.addActionGroup("do whatever")).not.toThrow(); - expect(() => hs.addActionGroup("sleep")).not.toThrow(); - expect(() => hs.addActionGroup("play")).not.toThrow(); - expect(() => hs.addActionGroup("study")).not.toThrow(); - expect(() => hs.addActionGroup("run")).not.toThrow(); - expect(() => hs.addActionGroup("eat")).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(0, 6, "eat"); - assertGroup(-1, 5, "run"); - assertGroup(-2, 4, "study"); - assertGroup(-3, 3, "play"); - assertGroup(-4, 2, "sleep"); - assertGroup(-5, -1, -1); - assertGroup(1, -1, -1); - }); - - test("should work on correctly edge-cases and query currectly", () => { - expect(hs.getBufferSize).toBe(0); - expect(() => hs.addActionGroup("wash")).not.toThrow(); - expect(hs.getBufferSize).toBe(1); - expect(() => hs.addActionGroup("do whatever")).not.toThrow(); - expect(() => hs.addActionGroup("sleep")).not.toThrow(); - expect(() => hs.addActionGroup("play")).not.toThrow(); - expect(hs.getBufferSize).toBe(4); - - assertGroup(-1, 2, "sleep"); - assertGroup(-2, 1, "do whatever"); - assertGroup(-3, 0, "wash"); - assertGroup(-4, -1, -1); - - expect(() => hs.addActionGroup("study")).not.toThrow(); - expect(() => hs.addActionGroup("run")).not.toThrow(); - expect(() => hs.addActionGroup("eat")).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(0, 6, "eat"); - assertGroup(-1, 5, "run"); - assertGroup(-2, 4, "study"); - assertGroup(-3, 3, "play"); - assertGroup(-4, 2, "sleep"); - assertGroup(-5, -1, -1); - assertGroup(1, -1, -1); - - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(0, 3, "play"); - - expect(() => hs.addActionGroup("action1")).not.toThrow(); - expect(() => hs.addActionGroup("action2")).not.toThrow(); - expect(() => hs.addActionGroup("action3")).not.toThrow(); - expect(() => hs.addActionGroup("action4")).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(-6, -1, -1); - assertGroup(-5, -1, -1); - assertGroup(-4, 3, "play"); - assertGroup(-3, 7, "action1"); - assertGroup(-2, 8, "action2"); - assertGroup(-1, 9, "action3"); - assertGroup(0, 10, "action4"); - assertGroup(1, -1, -1); - assertGroup(2, -1, -1); - - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(-1, -1, -1); - assertGroup(0, -1, -1); - assertGroup(1, 3, "play"); - assertGroup(2, 7, "action1"); - assertGroup(3, 8, "action2"); - assertGroup(4, 9, "action3"); - assertGroup(5, 10, "action4"); - assertGroup(6, -1, -1); - assertGroup(7, -1, -1); - - expect(() => hs.redo()).not.toThrow(); - expect(hs.getBufferSize).toBe(5); - - assertGroup(-2, -1, -1); - assertGroup(-1, -1, -1); - assertGroup(0, 3, "play"); - assertGroup(1, 7, "action1"); - assertGroup(2, 8, "action2"); - assertGroup(3, 9, "action3"); - assertGroup(4, 10, "action4"); - assertGroup(5, -1, -1); - assertGroup(6, -1, -1); - - expect(hs.getBufferSize).toBe(5); - }); - test("should undo and redo currectly and return id of action group that was redid/undid to, -1 if undid to the start", () => { }); - }); - describe("Adding action data to action groups", () => { - test("should throw an error if added action data without specifying action group, else adds to the specified group", () => { - expect(() => hs.addActionData("4")).toThrow( - "No action group to add to", - ); - expect(() => hs.addActionGroup()).not.toThrow(); - expect(() => hs.addActionGroup()).not.toThrow(); - expect(() => hs.addActionData("4")).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.addActionData("4")).not.toThrow(); - expect(() => hs.undo()).not.toThrow(); - expect(() => hs.addActionData("4")).toThrow( - "No action group to add to", - ); - }); - test("should do shallow copy if data is object or array or primitive data", () => { - let arrExample = [4, [3, 5, 2], 5]; - expect(() => hs.addActionGroup()).not.toThrow(); - expect(() => hs.addActionData("4")).not.toThrow(); - expect(() => hs.addActionData(4)).not.toThrow(); - expect(() => hs.addActionData([4, 3, 5])).not.toThrow(); - expect(() => hs.addActionData(arrExample)).not.toThrow(); - expect(() => - hs.addActionData({ a: 4, b: [3, 5, 2], c: 5 }), - ).not.toThrow(); - assertGroup(0, 0, "", [ - "4", - 4, - [4, 3, 5], - [4, [3, 5, 2], 5], - { a: 4, b: [3, 5, 2], c: 5 }, - ]); - arrExample[1][1] = [1, 2]; - assertGroup(0, 0, "", [ - "4", - 4, - [4, 3, 5], - [4, [3, [1, 2], 2], 5], - { a: 4, b: [3, 5, 2], c: 5 }, - ]); - }); - test("should throw an error if given action group name is not a string", () => { - expect(() => hs.addActionGroup(0)).toThrow( - "Action group name must be string", - ); - }); - test("should add action group with given name or no name given if none given", () => { - expect(() => hs.addActionGroup("")).not.toThrow(); - expect(() => hs.addActionGroup("ahmed")).not.toThrow(); - expect(() => hs.addActionGroup()).not.toThrow(); - assertGroup(0, 2, ""); - assertGroup(-1, 1, "ahmed"); - assertGroup(-2, 0, ""); - }); - }); - - describe("Getting size and capacity", () => { - test("should throw an exception if offset is not a finite integer", () => { - let toTest = (offset, expectedThrow) => { - if (expectedThrow === undefined) { - expect(() => hs.getActionGroupID(offset)).not.toThrow(); - expect(() => - hs.getActionGroupName(offset), - ).not.toThrow(); - expect(() => hs.getActionData(offset)).not.toThrow(); - } else { - expect(() => hs.getActionGroupID(offset)).toThrow( - expectedThrow, - ); - expect(() => hs.getActionGroupName(offset)).toThrow( - expectedThrow, - ); - expect(() => hs.getActionData(offset)).toThrow( - expectedThrow, - ); - } - }; - toTest(0); - toTest(-10); - toTest(100); - toTest("a", "Offset must be defined finite number"); - toTest("aah", "Offset must be defined finite number"); - toTest(["aah"], "Offset must be defined finite number"); - toTest([3], "Offset must be defined finite number"); - toTest( - { a: 3, b: 4 }, - "Offset must be defined finite number", - ); - }); - }); - }); -}); diff --git a/tests/layer-system.test.js b/tests/layer-system.test.js deleted file mode 100644 index 4416987..0000000 --- a/tests/layer-system.test.js +++ /dev/null @@ -1,224 +0,0 @@ -import LayerSystem from "../scripts/layer-system.js"; -import CanvasData from "../scripts/canvas-data.js"; -import HistorySystem from "../scripts/history-system.js"; - -describe("LayerSystem", () => { - let layerSystem; - - beforeEach(() => { - layerSystem = new LayerSystem(10, 10); - }); - - test("should initialize with default width and height", () => { - const defaultLayerSystem = new LayerSystem(); - expect(defaultLayerSystem.getSize).toBe(0); - }); - - test("should initialize with given width and height", () => { - expect(layerSystem.getSize).toBe(0); - }); - - test("should throw error for invalid width or height", () => { - expect(() => new LayerSystem(0, 10)).toThrow(RangeError); - expect(() => new LayerSystem(10, 0)).toThrow(RangeError); - expect(() => new LayerSystem(1025, 10)).toThrow(RangeError); - expect(() => new LayerSystem(10, 1025)).toThrow(RangeError); - expect(() => new LayerSystem(10.5, 10)).toThrow(TypeError); - expect(() => new LayerSystem(10, 10.5)).toThrow(TypeError); - }); - - describe("Layer Manipulation", () => { - test("should add a new layer", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getSize).toBe(1); - expect(layerSystem.getNameList).toEqual(["Layer 1"]); - }); - - test("should throw error when adding a layer with invalid name", () => { - expect(() => layerSystem.addLayer(123)).toThrow(TypeError); - expect(() => layerSystem.addLayer(null)).toThrow(TypeError); - }); - - test("should remove a layer", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - layerSystem.removeLayer(0); - expect(layerSystem.getSize).toBe(1); - expect(layerSystem.getNameList).toEqual(["Layer 2"]); - }); - - test("should throw error when removing a layer from empty list", () => { - expect(() => layerSystem.removeLayer(0)).toThrow(RangeError); - }); - - test("should select a layer", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - layerSystem.selectLayer(1); - expect(layerSystem.getSelectedIndex).toBe(1); - }); - - test("should position the selected layer properly when adding or removing layers, and deselect if removed the selected layer", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - layerSystem.addLayer("Layer 3"); - layerSystem.addLayer("Layer 4"); - layerSystem.selectLayer(1); - expect(layerSystem.getSelectedIndex).toBe(1); - layerSystem.moveLayer(3, 2); // moves the last layer one layer up - expect(layerSystem.getNameList).toEqual([ - "Layer 1", - "Layer 2", // selected - "Layer 4", - "Layer 3", - ]); - expect(layerSystem.getSelectedIndex).toBe(1); - layerSystem.moveLayer(3, 0); // moves the last layer to the start - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 1", - "Layer 2", // selected - "Layer 4", - ]); - expect(layerSystem.getSelectedIndex).toBe(2); - layerSystem.addLayer("Layer 5"); - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 1", - "Layer 2", // selected - "Layer 4", - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(2); - layerSystem.moveLayer(2, 0); - expect(layerSystem.getNameList).toEqual([ - "Layer 2", // selected - "Layer 3", - "Layer 1", - "Layer 4", - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(0); - layerSystem.moveLayer(0, 2); - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 1", - "Layer 2", // selected - "Layer 4", - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(2); - layerSystem.removeLayer(1); - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 2", // selected - "Layer 4", - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(1); - layerSystem.removeLayer(2); - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 2", // selected - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(1); - layerSystem.removeLayer(1); // removed the selected layer - expect(layerSystem.getNameList).toEqual([ - "Layer 3", - "Layer 5", - ]); - expect(layerSystem.getSelectedIndex).toBe(-1); // deselected - }); - - test("should throw error when selecting a layer from empty list", () => { - expect(() => layerSystem.selectLayer(0)).toThrow(RangeError); - }); - - test("should move layer to other position", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - layerSystem.addLayer("Layer 3"); - layerSystem.moveLayer(0, 2); - expect(layerSystem.getNameList).toEqual([ - "Layer 2", - "Layer 3", - "Layer 1", - ]); - }); - - test("should throw error when changing index of a layer in empty list", () => { - expect(() => layerSystem.moveLayer(0, 1)).toThrow(RangeError); - }); - - test("should set layer name", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.setLayerName("New Layer 1", 0); - expect(layerSystem.getLayerName(0)).toBe("New Layer 1"); - }); - - test("should throw error when setting layer name with invalid index", () => { - layerSystem.addLayer("Layer 1"); - expect(() => layerSystem.setLayerName("New Layer 1", 1)).toThrow( - RangeError, - ); - }); - - test("should get layer name", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerName(0)).toBe("Layer 1"); - }); - - test("should throw a range error if tried to get anything from the layer list while it is empty", () => { - expect(() => layerSystem.getLayerName(0)).toThrow(RangeError); - expect(() => layerSystem.getLayerHistory(0)).toThrow(RangeError); - expect(() => layerSystem.getLayerCanvas(0)).toThrow(RangeError); - }); - - test("should return null when getting layer name with no selected layer", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerName()).toBeNull(); - }); - - test("should get layer canvas data", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerCanvas(0)).toBeInstanceOf(CanvasData); - }); - - test("should return null when getting layer canvas data with no selected layer", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerCanvas()).toBeNull(); - }); - - test("should get layer history system", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerHistory(0)).toBeInstanceOf( - HistorySystem, - ); - }); - - test("should return null when getting layer history system with no selected layer", () => { - layerSystem.addLayer("Layer 1"); - expect(layerSystem.getLayerHistory()).toBeNull(); - }); - - test("should get the number of layers", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - expect(layerSystem.getSize).toBe(2); - }); - - test("should get the list of layer names", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - expect(layerSystem.getNameList).toEqual(["Layer 1", "Layer 2"]); - }); - - test("should get the selected layer index", () => { - layerSystem.addLayer("Layer 1"); - layerSystem.addLayer("Layer 2"); - layerSystem.selectLayer(1); - expect(layerSystem.getSelectedIndex).toBe(1); - }); - }); -}); diff --git a/scripts/main.js b/tests/main.test.js similarity index 100% rename from scripts/main.js rename to tests/main.test.js diff --git a/tests/services/color.test.js b/tests/services/color.test.js new file mode 100644 index 0000000..1ca940b --- /dev/null +++ b/tests/services/color.test.js @@ -0,0 +1,306 @@ +import Color from '#services/color.js'; + +describe('Color Class', () => { + describe('Color Creation', () => { + describe('Constructor Restriction', () => { + test('should throw when using new Color() directly', () => { + expect(() => new Color()).toThrow('Use Color.create() instead'); + }); + }); + + describe('Valid Formats', () => { + test.each` + config | hex | rgb | alpha | description + ${{ hex: '#ff0000' }} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hex'} + ${{ hex: '#ff0000aa' }} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hex with alpha'} + ${{ rgb: [255, 0, 0] }} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'rgb'} + ${{ rgb: [255, 0, 0], alpha: 170 / 255 }} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'rgb with alpha'} + ${{ hsl: [0, 100, 50] }} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hsl'} + ${{ hsl: [0, 100, 50], alpha: 170 / 255 }} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hsl with alpha'} + ${{ hex: '#123' }} | ${'#112233'} | ${[17, 34, 51]} | ${1} | ${'shorthand hex'} + ${{ hex: '#f0ab' }} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'shorthand with alpha'} + ${{ hex: '#f0ab', alpha: 1.1 }} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'alpha option discarded when hex is provided'} + `('should create color object using $description', ({ config, hex, rgb, alpha }) => { + const color = Color.create(config); + expect(color.hex).toBe(hex); + expect(color.rgb).toEqual(rgb); + expect(color.alpha).toBeCloseTo(alpha, 3); + }); + + test('should parse uppercase hex correctly', () => { + const color = Color.create({ hex: '#FF00AABB' }); + expect(color.hex).toBe('#ff00aabb'); + expect(color.rgb).toEqual([255, 0, 170]); + expect(color.alpha).toBeCloseTo(187 / 255, 3); + }); + + test('should return same instance for identical colors', () => { + const color1 = Color.create({ rgb: [255, 0, 0] }); + const color2 = Color.create({ hex: '#ff0000' }); + expect(color1).toBe(color2); // Same instance + }); + + test('different representations with same hex share cache', () => { + const fromRGB = Color.create({ rgb: [255, 0, 0] }); + const fromHSL = Color.create({ hsl: [0, 100, 50] }); + const fromHex = Color.create({ hex: '#ff0000' }); + expect(fromRGB).toBe(fromHSL); + expect(fromHSL).toBe(fromHex); + }); + }); + + describe('Invalid Formats', () => { + test.each` + config | errorType | description + ${{ hex: 'non-color' }} | ${TypeError} | ${'invalid hex'} + ${{ rgb: 'non-array' }} | ${TypeError} | ${'invalid rgb'} + ${{ hsl: 'non-array' }} | ${TypeError} | ${'invalid hsl'} + ${{ rgb: [256, 0, 0] }} | ${RangeError}| ${'rgb out of bounds'} + ${{ hsl: [0, 101, 50] }} | ${RangeError}| ${'hsl out of bounds'} + ${{ rgb: [0, 0, 0], alpha: 1.1 }} | ${RangeError}| ${'alpha out of bounds'} + ${{}} | ${TypeError} | ${'no config'} + `('throws $errorType.name when $description', ({ config, errorType }) => { + expect(() => Color.create(config)).toThrow(errorType); + }); + + test('throws correct error message for invalid hex', () => { + expect(() => Color.create({ hex: 'invalid' })) + .toThrow('Invalid hex color format: invalid'); + }); + }); + }); + + describe('Immutable Operations', () => { + let color; + + beforeEach(() => { + color = Color.create({ hex: '#ff0000' }); + }); + + describe('withRGB()', () => { + test('should return new instance with modified RGB', () => { + const newColor = color.withRGB({ g: 255 }); + expect(newColor.hex).toBe('#ffff00'); + expect(color.hex).toBe('#ff0000'); // Original unchanged + }); + + test('should maintain alpha', () => { + const transparentRed = Color.create({ hex: '#ff000080' }); + const newColor = transparentRed.withRGB({ g: 255 }); + expect(newColor.hex).toBe('#ffff0080'); + }); + }); + + describe('withHSL()', () => { + test('should return new instance with modified HSL', () => { + const newColor = color.withHSL({ h: 120 }); + expect(newColor.hex).toBe('#00ff00'); + }); + + test('correctly rounds HSL conversion to RGB', () => { + const color = Color.create({ hsl: [180, 50, 50] }); + // Expected RGB from HSL(180,50%,50%) โ‰ˆ [64, 191, 191] + expect(color.rgb).toEqual([64, 191, 191]); + }); + }); + + describe('withAlpha()', () => { + test('should return new instance with modified alpha', () => { + const newColor = color.withAlpha(0.5); + expect(newColor.hex).toBe('#ff000080'); + expect(color.alpha).toBe(1); + }); + + test('handles alpha at 0 and 1 extremes', () => { + const alpha0 = color.withAlpha(0); + const alpha1 = color.withAlpha(1); + expect(alpha0.alpha).toBe(0); + expect(alpha1.alpha).toBe(1); + }); + }); + + test('color instances are immutable', () => { + expect(Object.isFrozen(color)).toBe(true); + expect(() => { color.newProp = 123 }).toThrow(); + }); + + test('toString omits alpha when fully opaque', () => { + const color = Color.create({ hex: '#aabbccff' }); + expect(color.toString()).toBe('#aabbcc'); + }); + }); + + describe('Color Analysis', () => { + describe('isSimilarTo()', () => { + test.each` + color1 | color2 | tolerance | includeAlpha | expected | description + ${'#ff8844cc'} | ${'#ff8845cd'} | ${2} | ${true} | ${true} | ${'under tolerance'} + ${'#ff8844cc'} | ${'#ff8846ce'} | ${2} | ${true} | ${true} | ${'within tolerance'} + ${'#ff8844cc'} | ${'#ff8847cf'} | ${2} | ${true} | ${false} | ${'over tolerance'} + ${'#ff8844cc'} | ${'#fe8843cf'} | ${2} | ${false} | ${true} | ${'ignore alpha'} + ${'#ff8844cc'} | ${'#fe8644cc'} | ${1} | ${false} | ${false} | ${'rgb over tolerance'} + `('$expected when $description', ({ color1, color2, tolerance, includeAlpha, expected }) => { + const c1 = Color.create({ hex: color1 }); + const c2 = Color.create({ hex: color2 }); + expect(c1.isSimilarTo(c2, tolerance, includeAlpha)).toBe(expected); + }); + }); + + describe('isEqualTo()', () => { + test.each` + color1 | color2 | includeAlpha | expected | description + ${'#aabbccdd'} | ${'#aabbccdd'} | ${true} | ${true} | ${'exact match'} + ${'#aabbccdd'} | ${'#aabbcc'} | ${true} | ${false} | ${'alpha difference'} + ${'#aabbccdd'} | ${'#aabbccdd'} | ${false} | ${true} | ${'ignore alpha'} + ${'#aabbccdd'} | ${'#aabbccde'} | ${false} | ${true} | ${'alpha ignored'} + ${'#aabbccdd'} | ${'#aabdccdd'} | ${true} | ${false} | ${'rgb difference'} + `('$expected when $description', ({ color1, color2, includeAlpha, expected }) => { + const c1 = Color.create({ hex: color1 }); + const c2 = Color.create({ hex: color2 }); + expect(c1.isEqualTo(c2, includeAlpha)).toBe(expected); + }); + + test('caches colors with similar alphas as same instance', () => { + const color1 = Color.create({ rgb: [255, 0, 0], alpha: 0.5 }); + const color2 = Color.create({ rgb: [255, 0, 0], alpha: 0.5000001 }); + expect(color1).toBe(color2); + expect(color1.hex).toBe('#ff000080'); + }); + }); + }); + + describe('Color Operations', () => { + describe('mix()', () => { + const red = Color.create({ hex: '#ff0000' }); + const blue = Color.create({ hex: '#0000ff' }); + + test('should mix colors in RGB space', () => { + const purple = red.mix(blue, 0.5, 'rgb'); + expect(purple.hex).toBe('#800080'); + expect(purple.alpha).toBe(1); + }); + + test('should mix colors in HSL space', () => { + const magenta = red.mix(blue, 0.5, 'hsl'); + expect(magenta.hex).toBe('#ff00ff'); + }); + + test('mixes HSL hues with wrapping correctly', () => { + const color1 = Color.create({ hsl: [350, 100, 50] }); // ~red + const color2 = Color.create({ hsl: [10, 100, 50] }); // ~red + const mixed = color1.mix(color2, 0.5, 'hsl'); + expect(mixed.hsl[0]).toBeCloseTo(0, 0); + expect(mixed.hex).toBe('#ff0000'); + }); + + test('mixing with weight 0 returns original color', () => { + const mixed = red.mix(blue, 0); + expect(mixed.isEqualTo(red)).toBe(true); + }); + + test('mixing with weight 1 returns second color', () => { + const mixed = red.mix(blue, 1); + expect(mixed.isEqualTo(blue)).toBe(true); + }); + + test('mixes alpha values correctly', () => { + const semiTransparent = Color.create({ hex: '#ff000080' }); + const opaque = Color.create({ hex: '#0000ff' }); + const mixed = semiTransparent.mix(opaque, 0.5); + expect(mixed.alpha).toBeCloseTo(0.75); + }); + }); + + describe('compositeOver()', () => { + test('should composite colors correctly', () => { + const red = Color.create({ rgb: [255, 0, 0], alpha: 0.5 }); + const blue = Color.create({ rgb: [0, 0, 255], alpha: 0.5 }); + + const composite1 = red.compositeOver(blue); + expect(composite1.rgb.map(Math.round)).toEqual([170, 0, 85]); + + const composite2 = blue.compositeOver(red); + expect(composite2.rgb.map(Math.round)).toEqual([85, 0, 170]); + }); + + test('composites correctly over transparent color', () => { + const topColor = Color.create({ rgb: [255, 0, 0], alpha: 0.5 }); + const composite = topColor.compositeOver(Color.TRANSPARENT); + expect(composite.isEqualTo(topColor)).toBe(true); + }); + + test('fully transparent color composites as invisible', () => { + const transparentRed = Color.create({ rgb: [255, 0, 0], alpha: 0 }); + const composite = transparentRed.compositeOver(Color.create({ hex: '#0000ff' })); + expect(composite.isEqualTo(Color.create({ hex: '#0000ff' }))).toBe(true); + }); + + test('compositing fully opaque color over any color returns top color', () => { + const opaqueRed = Color.create({ hex: '#ff0000' }); + const anyColor = Color.create({ hex: '#00ff00' }); + expect(opaqueRed.compositeOver(anyColor)).toBe(opaqueRed); + }); + }); + }); + + describe('Static Colors', () => { + test('TRANSPARENT should have alpha 0', () => { + expect(Color.TRANSPARENT.alpha).toBe(0); + }); + + test('static colors should use cache', () => { + const fromCache = Color.create({ hex: '#0000' }); + expect(fromCache.hex).toBe('#00000000'); + expect(Color.TRANSPARENT.hex).toBe('#00000000'); + expect(Color.TRANSPARENT).toBe(fromCache); // Same instance + }); + + test('static colors should be recreated after cache clear', () => { + const originalColor = Color.TRANSPARENT; + Color.clearCache(); + expect(Color.TRANSPARENT).not.toBe(originalColor); // New instance + expect(Color.TRANSPARENT.hex).toBe('#00000000'); // But same value + }); + }); + + describe('Color Cache', () => { + beforeEach(() => { + Color.clearCache(); // Ensure clean state for each test + }); + + test('should reuse instances for same color', () => { + const color1 = Color.create({ rgb: [255, 0, 0] }); + const color2 = Color.create({ hex: '#ff0000' }); + expect(color1).toBe(color2); // Same instance + expect(Color.cacheSize).toBe(2); // one for the created color and one for cached transparent + }); + + test('clearCache() should force new instances', () => { + const color1 = Color.create({ rgb: [255, 0, 0] }); + expect(Color.cacheSize).toBe(2); + + Color.clearCache(); + expect(Color.cacheSize).toBe(1); + + const color2 = Color.create({ rgb: [255, 0, 0] }); + expect(color1).not.toBe(color2); // Different instances + expect(Color.cacheSize).toBe(2); + }); + + test('cache should handle different color spaces', () => { + const fromRGB = Color.create({ rgb: [255, 0, 0] }); + const fromHSL = Color.create({ hsl: [0, 100, 50] }); + const fromHex = Color.create({ hex: '#ff0000' }); + expect(fromRGB).toBe(fromHSL); + expect(fromHSL).toBe(fromHex); + expect(Color.cacheSize).toBe(2); + }); + + test('cache should distinguish different alphas', () => { + const color1 = Color.create({ rgb: [255, 0, 0], alpha: 0.5 }); + const color2 = Color.create({ rgb: [255, 0, 0], alpha: 1 }); + expect(color1).not.toBe(color2); + expect(Color.cacheSize).toBe(3); + }); + }); +}); diff --git a/tests/services/history.test.js b/tests/services/history.test.js new file mode 100644 index 0000000..1451c35 --- /dev/null +++ b/tests/services/history.test.js @@ -0,0 +1,235 @@ +import History from "#services/history.js"; + +describe("History", () => { + + let ah; + + const assertRecord = (offset, expectedID = null, expectedData = null) => { + if (expectedID !== null) expect(ah.getRecordID(offset)).toBe(expectedID); + if (expectedData !== null) { + expect(ah.getRecordData(offset)).toStrictEqual(expectedData); + if (Array.isArray(expectedData)) { + expect(ah.getRecordData(offset)).not.toBe(expectedData); + } + } + }; + + describe("Constructor Validation", () => { + test.each([ + [undefined, TypeError], + [null, TypeError], + [[], TypeError], + ["5", TypeError], + [NaN, TypeError], + [Infinity, TypeError], + [13.01, TypeError] + ])("should throw %p when capacity is %p", (input, error) => { + expect(() => new History(input)).toThrow(error); + }); + + test.each([ + [-20, RangeError], + [0, RangeError], + [100, RangeError], + ])("should throw %p when capacity is %p (not between 1 and 64)", (input, error) => { + expect(() => new History(input)).toThrow(error); + }); + + test.each([1, 20, 64])("should accept valid capacity %p", (input) => { + expect(() => new History(input)).not.toThrow(); + }); + }); + + describe("Basic Functionality", () => { + + beforeEach(() => { + ah = new History(5); + }); + + + test("should initialize with empty buffer", () => { + expect(ah.bufferSize).toBe(0); + expect(ah.bufferCapacity).toBe(5); + }); + + test("should add records with incremental IDs", () => { + ah.addRecord(); + ah.addRecord(); + + assertRecord(0, 1); + assertRecord(-1, 0); + }); + + test("should handle undo/redo correctly", () => { + ah.addRecord(); + ah.setRecordData("first"); + ah.addRecord(); + ah.setRecordData("second"); + + expect(ah.undo()).toEqual("first"); + assertRecord(0, 0); + + expect(ah.redo()).toBe("second"); + assertRecord(0, 1); + }); + + test("should maintain buffer capacity", () => { + // Fill buffer + for (let i = 0; i < 6; i++) { + ah.addRecord(`record${i}`); + } + + expect(ah.bufferSize).toBe(5); + expect(ah.bufferCapacity).toBe(5); + }); + }); + + describe("Record Data Handling", () => { + beforeEach(() => { + ah = new History(5); + }); + + test("should reject adding data without active records", () => { + expect(() => ah.setRecordData("test")).toThrow("No record to add to"); + }); + + test("should store primitive data correctly", () => { + ah.addRecord(); + ah.setRecordData("test"); + + expect(ah.getRecordData(0)).toEqual("test"); + + ah.setRecordData(42); + + expect(ah.getRecordData(0)).toEqual(42); + }); + + test("should take reference of data stored in it (they are be shared)", () => { + const testArr = [1, 2, 3]; + + ah.addRecord(); + ah.setRecordData(testArr); + + const storedData = ah.getRecordData(0); + expect(storedData).toEqual(testArr); + expect(storedData).toBe(testArr); + }); + }); + + describe('Stress Testing', () => { + test('should handle 100+ consecutive undo/redo operations', () => { + ah = new History(10); + + for (let i = 0; i < 20; i++) { // populate history + ah.addRecord(`record${i}`); + } + + for (let i = 0; i < 15; i++) { // perform undos + expect(() => ah.undo()).not.toThrow(); + } + + for (let i = 0; i < 15; i++) { // perform redos + expect(() => ah.redo()).not.toThrow(); + } + + expect(ah.getRecordID()).toBe(19); + }); + + test('should complete 1000 operations under 100ms', () => { + const start = performance.now(); + // ... perform operations ... + expect(performance.now() - start).toBeLessThan(100); + }); + }); + + describe('Deep Objects Handling', () => { + test('should handle nested object references (objects are shared)', () => { + const ah = new History(3); + const nestedObj = { + a: 1, + b: { + c: [1, 2, { d: 3 }], + } + }; + + ah.addRecord(); + ah.setRecordData(nestedObj); + + nestedObj.b.c[2].d = 6; // modified + + expect(ah.getRecordData(0).b.c[2].d).toBe(6); + }); + }); + + describe('Empty Buffer Behavior', () => { + let ah; + beforeEach(() => { + ah = new History(3); + }); + + test('should handle operations on empty buffer', () => { + expect(ah.undo()).toBe(null); + expect(ah.redo()).toBe(null); + expect(ah.getRecordID(0)).toBe(-1); + expect(ah.bufferSize).toBe(0); + }); + }); + + describe("Edge Cases", () => { + beforeEach(() => { + ah = new History(3); // smaller buffer for easier testing + }); + + test("should handle buffer wraparound", () => { + // Fill buffer + ah.addRecord(); + ah.addRecord(); + ah.addRecord(); + ah.addRecord(); // should overwrite "first" + + expect(ah.bufferSize).toBe(3); + assertRecord(1, -1, -1); + assertRecord(0, 3); + assertRecord(-1, 2); + assertRecord(-2, 1); + assertRecord(-3, -1, -1); // should not exist (wrapped around) + }); + + test("should clear redo history when adding new action", () => { + ah.addRecord(); + ah.setRecordData("first"); + ah.addRecord(); + ah.setRecordData("second"); + ah.addRecord(); + ah.setRecordData("third"); + expect(ah.undo()).toBe("second"); + ah.addRecord(); + ah.setRecordData("forth"); + + expect(ah.redo()).toBe("forth"); // should only have "forth" available + + assertRecord(1, -1, -1); + assertRecord(0, 3); + assertRecord(-1, 1); + assertRecord(-2, 0); + assertRecord(-3, -1, -1); + }); + + test("should handle multiple undo/redo cycles", () => { + ah.addRecord(); + ah.setRecordData("first"); + ah.addRecord(); + ah.setRecordData("second"); + + expect(ah.undo()).toBe("first"); + expect(ah.undo()).toBe(null); + expect(ah.undo()).toBe(null); + expect(ah.redo()).toBe("first"); + expect(ah.redo()).toBe("second"); + expect(ah.redo()).toBe("second"); + + assertRecord(0, 1); + assertRecord(-1, 0); + }); + }); +}); diff --git a/tests/services/newFile.js b/tests/services/newFile.js new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/pixel-changes.test.js b/tests/services/pixel-changes.test.js new file mode 100644 index 0000000..339b01a --- /dev/null +++ b/tests/services/pixel-changes.test.js @@ -0,0 +1,136 @@ +import PixelChanges from '#services/pixel-changes.js'; + +describe('PixelChanges', () => { + + let cr; + + const createRectWithChanges = (changes) => { + const cr = new PixelChanges(); + changes.forEach(([x, y, after, before]) => + cr.setChange(x, y, after, before)); + return cr; + }; + + beforeEach(() => { + cr = new PixelChanges(); + }); + + describe('PixelChanges Creation', () => { + test('should create an empty dirty rectangle', () => { + cr = new PixelChanges(); + expect(cr.isEmpty).toBe(true); + }); + }); + + describe('Setting Changes', () => { + describe('Coordinate Handling', () => { + test.each` + input | expected + ${[1.2, 2.7]} | ${[1, 2]} + ${[-1.5, 3.9]} | ${[-2, 3]} + ${[5, 5]} | ${[5, 5]} + `('should floor input change $input to $expected', ({ input, expected }) => { + const [x, y] = input; + cr.setChange(x, y, "new" + x, "old" + y); + expect(cr.getChange(...expected)).toEqual({before: "old" + y, after: "new" + x}); + }); + }); + + describe('State Management', () => { + test('should preserve initial before state', () => { + cr.setChange(0, 0, 'after1', 'before1'); + cr.setChange(0, 0, 'updated1'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before1' }]); + cr.setChange(0, 2, 'after2', 'before2'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before1' }, { x: 0, y: 2, state: 'before2' }]); + }); + + test('should update after state on subsequent calls', () => { + cr.setChange(1, 1, 'v1', 'v0'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v1' }]); + + cr.setChange(1, 1, 'v2'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v2' }]); + + cr.setChange(1, 1, 'v3'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v3' }]); + }); + }); + + }); + + describe('PixelChanges Manipulation', () => { + + describe('Change Negation', () => { + test('should remove change if after state is the same as before state', () => { + cr.setChange(0, 0, 'original', 'original'); + expect(cr.getChange(0, 0)).toBe(null); + cr.setChange(0, 0, 'new', 'original'); + expect(cr.getChange(0, 0)).toEqual({before: "original", after: "new"}); + cr.setChange(0, 0, 'original'); + expect(cr.getChange(0, 0)).toBe(null); + }) + }); + + describe('Cloning', () => { + test('should produce independent copy', () => { + let equalTo = (a, b) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) + if (a[i] !== b[i]) return false; + return true; + }; + + let cr1 = new PixelChanges(equalTo); + cr1.setChange(0, 0, [1, 2, 4, 5], [5, 3]); + const cr2 = cr1.clone(); + + cr2.setChange(0, 0, [5, 3]); // should remove change because it's equal to the original + expect(cr1.getChange(0, 0)).toEqual({ after: [1, 2, 4, 5], before: [5, 3] }); + expect(cr2.getChange(0, 0)).toEqual(null); + + // Test independence + cr2.setChange(1, 1, [3, 1], []); + cr1.setChange(2, 2, [5, 4], []); + + expect(cr2.getChange(2, 2)).toEqual({ after: [3, 1], before: [] }); + expect(cr1.getChange(1, 1)).toEqual({ after: [5, 4], before: [] }); + }); + + test('should preserve all properties', () => { + cr.setChange(1, 2, 'state'); + const clone = cr.clone(); + + expect(clone.afterStates).toEqual(cr.afterStates); + }); + }); + + describe('Merging', () => { + test('should merge overlapping pixels correctly', () => { + const cr1 = new PixelChanges(); + cr1.setChange(0, 0, 'cr1-after', 'cr1-before'); + + const cr2 = new PixelChanges(); + cr2.setChange(0, 0, 'cr2-after', 'cr2-before'); + + let merge = cr1.merge(cr2); + + expect(merge.afterStates).toEqual([{ x: 0, y: 0, state: 'cr2-after' }]); + expect(merge.beforeStates).toEqual([{ x: 0, y: 0, state: 'cr1-before' }]); + }); + + test('should merge into the calling object without creating new one if called in-place merge', () => { + const cr1 = new PixelChanges(); + cr1.setChange(0, 0, 'state1'); + + const cr2 = new PixelChanges(); + cr2.setChange(5, 5, 'state2'); + + cr1.mergeInPlace(cr2); + expect(cr1.getChange(0, 0)).toEqual({ before: 'state1', after: 'state1' }); + expect(cr1.getChange(5, 5)).toEqual({ before: 'state2', after: 'state2' }); + expect(cr1.length).toEqual(2); + }); + }); + }); +}); diff --git a/tests/setup-jest.js b/tests/setup-jest.js index f3c1e07..fa8bc15 100644 --- a/tests/setup-jest.js +++ b/tests/setup-jest.js @@ -1 +1 @@ -import {validateNumber, validateColorArray} from "../scripts/validation.js"; +import {validateNumber, validateColorArray} from "#utils/validation.js"; diff --git a/tests/canvas-manager.test.js b/tests/ui/canvas.test.js similarity index 58% rename from tests/canvas-manager.test.js rename to tests/ui/canvas.test.js index a802aaf..23f1a8a 100644 --- a/tests/canvas-manager.test.js +++ b/tests/ui/canvas.test.js @@ -2,18 +2,17 @@ * @jest-environment jsdom */ -import CanvasManager from "../scripts/canvas-manager.js"; -import { validateNumber } from "../scripts/validation.js"; // Assuming this is where validateNumber is defined +import Canvas from "#ui/canvas.js"; -describe("CanvasManager", () => { - let canvasManager; +describe("Canvas", () => { + let canvas; let container; beforeEach(() => { // Set up a container element for the PixelBoard container = document.createElement('div'); document.body.appendChild(container); - canvasManager = new CanvasManager(container); + canvas = new Canvas(container); }); afterEach(() => { @@ -28,121 +27,121 @@ describe("CanvasManager", () => { }); test('should set the canvas size correctly', () => { - const canvas = canvasManager.getCanvas; + const canvas = canvas.getCanvas; container.style.width = "200px"; container.style.height = "200px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(1); + expect(canvas.getInitialScale).toEqual(1); container.style.width = "300px"; container.style.height = "400px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(1.5); + expect(canvas.getInitialScale).toEqual(1.5); container.style.width = "500px"; container.style.height = "400px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(2); + expect(canvas.getInitialScale).toEqual(2); container.style.width = "50px"; container.style.height = "40px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(0.2); + expect(canvas.getInitialScale).toEqual(0.2); // ------------- container.style.width = "100px"; container.style.height = "100px"; - canvasManager.createBlankCanvas(1, 1); + canvas.createBlankCanvas(1, 1); expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); - expect(canvasManager.getInitialScale).toEqual(100); + expect(canvas.getInitialScale).toEqual(100); container.style.width = "100px"; container.style.height = "100px"; - canvasManager.createBlankCanvas(1024, 1024); + canvas.createBlankCanvas(1024, 1024); expect(canvas.width).toBe(1024); expect(canvas.height).toBe(1024); - expect(canvasManager.getInitialScale).toEqual(100 / 1024); + expect(canvas.getInitialScale).toEqual(100 / 1024); }); test("should update the canvas inital scaling if refreshed after changing the container or canvas size", () => { - const canvas = canvasManager.getCanvas; + const canvas = canvas.getCanvas; container.style.width = "300px"; container.style.height = "400px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(1.5); + expect(canvas.getInitialScale).toEqual(1.5); container.style.width = "100px"; container.style.height = "100px"; - canvasManager.refresh(true); + canvas.refresh(true); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(0.5); + expect(canvas.getInitialScale).toEqual(0.5); - canvasManager.setDimensions(20, 10); + canvas.setDimensions(20, 10); expect(canvas.width).toBe(20); expect(canvas.height).toBe(10); - expect(canvasManager.getInitialScale).toEqual(0.5); // no effect without refreshing + expect(canvas.getInitialScale).toEqual(0.5); // no effect without refreshing - canvasManager.refresh(true); + canvas.refresh(true); - expect(canvasManager.getInitialScale).toEqual(5); + expect(canvas.getInitialScale).toEqual(5); }); test("should be able to scale the canvas up to pixel size and down to 0.5 and refresh to get the scaling effect", () => { - const canvas = canvasManager.getCanvas; + const canvas = canvas.getCanvas; container.style.width = "300px"; container.style.height = "400px"; - canvasManager.createBlankCanvas(200, 200); + canvas.createBlankCanvas(200, 200); expect(canvas.width).toBe(200); expect(canvas.height).toBe(200); - expect(canvasManager.getInitialScale).toEqual(1.5); - expect(canvasManager.getScale).toEqual(1); + expect(canvas.getInitialScale).toEqual(1.5); + expect(canvas.getScale).toEqual(1); expect(canvas.style.width).toBe("300px"); - canvasManager.setScale(2); + canvas.setScale(2); expect(canvas.style.width).toBe("300px"); // no effect expect(canvas.style.height).toBe("300px"); // no effect - expect(canvasManager.getScale).toEqual(2); + expect(canvas.getScale).toEqual(2); - canvasManager.refresh(); + canvas.refresh(); expect(canvas.style.width).toBe("600px"); expect(canvas.style.height).toBe("600px"); - expect(canvasManager.getScale).toEqual(2); + expect(canvas.getScale).toEqual(2); - canvasManager.setScale(200000000); - canvasManager.refresh(); + canvas.setScale(200000000); + canvas.refresh(); expect(canvas.style.width).toBe(`${200 * 300}px`); expect(canvas.style.height).toBe(`${200 * 300}px`); - expect(canvasManager.getScale).toEqual(200); + expect(canvas.getScale).toEqual(200); - canvasManager.setScale(-25235235); - canvasManager.refresh(); + canvas.setScale(-25235235); + canvas.refresh(); expect(canvas.style.width).toBe(`150px`); expect(canvas.style.height).toBe(`150px`); - expect(canvasManager.getScale).toEqual(0.5); + expect(canvas.getScale).toEqual(0.5); }); }); diff --git a/tests/utils/validation.test.js b/tests/utils/validation.test.js new file mode 100644 index 0000000..c055c4d --- /dev/null +++ b/tests/utils/validation.test.js @@ -0,0 +1,133 @@ +import { validateColorArray, validateNumber } from "#utils/validation.ts"; + +describe("validateNumber", () => { + describe("Happy Paths", () => { + test.each` + value | options | description + ${2} | ${{}} | ${"no constraints"} + ${2.4} | ${{}} | ${"decimal without constraints"} + ${5} | ${{ integerOnly: true }} | ${"integer with integerOnly"} + ${2} | ${{ start: 1 }} | ${"minimum bound only"} + ${3} | ${{ end: 4.1 }} | ${"maximum bound only"} + ${-3.3} | ${{ start: -5, end: 4.1 }} | ${"both bounds with decimal"} + ${0} | ${{ start: 0, end: 0, integerOnly: true }} | ${"exact match with bounds"} + `("accepts valid number: $description", ({ value, options }) => { + expect(() => validateNumber(value, "testVar", options)).not.toThrow(); + }); + }); + + describe("Type Validation", () => { + test("rejects non-number input", () => { + expect(() => validateNumber("2", "testVar")).toThrow( + "testVar must be defined finite number" + ); + }); + + test("rejects infinite numbers", () => { + expect(() => validateNumber(Infinity, "testVar")).toThrow( + "testVar must be defined finite number" + ); + }); + + test.each` + option | badValue | description + ${"start"} | ${"1"} | ${"non-number start"} + ${"end"} | ${[]} | ${"non-number end"} + ${"integerOnly"} | ${5} | ${"non-boolean integerOnly"} + `("rejects invalid $option: $description", ({ option, badValue }) => { + expect(() => validateNumber(2, "testVar", { [option]: badValue })).toThrow( + "Variable name or options are of invalid type" + ); + }); + + test("rejects non-string variable name", () => { + expect(() => validateNumber(2, 123)).toThrow( + "Variable name or options are of invalid type" + ); + }); + }); + + describe("Integer Validation", () => { + test("rejects decimals when integerOnly=true", () => { + expect(() => validateNumber(23.4, "testVar", { integerOnly: true })) + .toThrow("testVar must be integer"); + }); + }); + + describe("Range Validation", () => { + test("rejects when start > end", () => { + expect(() => validateNumber(12, "testVar", { start: 5, end: 0 })) + .toThrow("minimum can't be higher than maximum"); + }); + + test.each` + value | options | expectedError + ${-1} | ${{ start: 1.5 }} | ${"Minimum of: 1.5"} + ${50} | ${{ start: 55, end: 61 }} | ${"Minimum of: 55\nMaximum of: 61"} + ${2.2}| ${{ end: 1 }} | ${"Maximum of: 1"} + `("rejects out-of-range values: $value with $options", ({ value, options, expectedError }) => { + expect(() => validateNumber(value, "testVar", options)) + .toThrow(`testVar must have:\n${expectedError}`); + }); + }); +}); + +describe("validateColorArray", () => { + let originalWarn; + + beforeAll(() => { + originalWarn = console.warn; + console.warn = jest.fn(); + }); + + afterAll(() => { + console.warn = originalWarn; + }); + + describe("Happy Paths", () => { + test.each` + color | description + ${[255, 0, 0, 1]} | ${"max RGB, max alpha"} + ${[0, 0, 0, 0]} | ${"min values"} + ${[66.6, 55, 0.4, 0.5]} | ${"decimal values"} + ${[127, 127, 127, 0.5]} | ${"mid-range values"} + `("accepts valid color: $description", ({ color }) => { + expect(() => validateColorArray(color)).not.toThrow(); + }); + }); + + describe("Type Validation", () => { + test.each` + input | description + ${"variable"} | ${"string"} + ${true} | ${"boolean"} + ${2} | ${"number"} + ${[]} | ${"empty array"} + ${[2, 54, "a"]} | ${"array with non-number"} + ${[2, {}, [], 54]} | ${"array with objects"} + `("rejects non-color-array: $description", ({ input }) => { + expect(() => validateColorArray(input)) + .toThrow("Color must be in array containing 4 finite numbers"); + }); + }); + + describe("Range Validation", () => { + test.each` + color | expectedError + ${[256, 0, 0, 0.5]} | ${"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive"} + ${[0, -1, 0, 0.5]} | ${"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive"} + ${[0, 0, 256, 0.5]} | ${"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive"} + ${[0, 0, 0, 1.1]} | ${"Color alpha value (at index 3) must be between 0 and 1 inclusive"} + ${[0, 0, 0, -0.1]} | ${"Color alpha value (at index 3) must be between 0 and 1 inclusive"} + `("rejects out-of-range values: $color", ({ color, expectedError }) => { + expect(() => validateColorArray(color)).toThrow(expectedError); + }); + }); + + describe("Deprecation Warning", () => { + test("shows deprecation warning", () => { + validateColorArray([0, 0, 0, 0]); + expect(console.warn).toHaveBeenCalledWith("Deprecated - use new Color() instead"); + }); + }); +}); diff --git a/tests/validation.test.js b/tests/validation.test.js deleted file mode 100644 index 5015e95..0000000 --- a/tests/validation.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import { validateColorArray, validateNumber } from "../scripts/validation.js"; - -describe("validateNumber", () => { - test("Should pass if the condition is true", () => { - expect(() => validateNumber(2, "variable")).not.toThrow(); - expect(() => validateNumber(2.4, "variable")).not.toThrow(); - expect(() => validateNumber(5, "variable", {integerOnly: true})).not.toThrow(); - expect(() => validateNumber(2, "variable", {integerOnly: true, start: 1})).not.toThrow(); - expect(() => validateNumber(3, "variable", {integerOnly: true, end: 4.1})).not.toThrow(); - expect(() => validateNumber(-3.5, "variable", {start: -5, end: 4.1})).not.toThrow(); - }); - test("Should throw a type error if function parameters are of invalid type", () => { - expect(() => validateNumber(2, "variable", { end: {} })).toThrow( - "Variable name or options are of invalid type", - ); - expect(() => validateNumber(2, "variable", { start: [] })).toThrow( - "Variable name or options are of invalid type", - ); - expect(() => validateNumber(2, "variable", { integerOnly: 5 })).toThrow( - "Variable name or options are of invalid type", - ); - expect(() => validateNumber(2, 1)).toThrow( - "Variable name or options are of invalid type", - ); - expect(() => validateNumber("2", "varName")).toThrow( - "varName must be defined finite number", - ); - expect(() => validateNumber("a", "varName")).toThrow( - "varName must be defined finite number", - ); - }); - - test("Should throw a range error if start and end are specified but start is higher than end", () => { - expect(() => - validateNumber(12, "varName", { start: 5, end: 0 }), - ).toThrow(`minimum can't be higher than maximum`); - }); - - test("Should throw a type error if integerOnly option is true and given number is not an integer", () => { - expect(() => - validateNumber(23.4, "variable", { integerOnly: true }), - ).toThrow("variable must be integer"); - }); - - test("Should throw a range error when number is not in specified range", () => { - expect(() => validateNumber(-1, "variable", { start: 1.5 })).toThrow( - `variable must have: -Minimum of: 1.5 -`, - ); - expect(() => - validateNumber(50, "variable", { start: 55, end: 61 }), - ).toThrow( - `variable must have: -Minimum of: 55 -Maximum of: 61 -`, - ); - expect(() => validateNumber(2.2, "variable", { end: 1 })).toThrow( - `variable must have: -Maximum of: 1 -`, - ); - }); -}); - -describe("validateColorArray", () => { - test("Should pass if color is a valid [r, g, b, a] array", () => { - expect(() => validateColorArray([255, 0, 0, 1])).not.toThrow(); - expect(() => validateColorArray([66, 55, 0, 0])).not.toThrow(); - expect(() => validateColorArray([66.6, 55, 0.4, 0.5])).not.toThrow(); - }); - - test("Should throw a type error if color is not an array of size 4", () => { - expect(() => validateColorArray("variable")).toThrow( - "Color must be in array" - ); - expect(() => validateColorArray(true)).toThrow( - "Color must be in array" - ); - expect(() => validateColorArray(2)).toThrow( - "Color must be in array" - ); - expect(() => validateColorArray([])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray([2, 54, 'a'])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray([2, {}, [], 54, 'a'])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - }); - - test("Should throw a type error if the entries of the color array are not numbers", () => { - expect(() => validateColorArray([2, {}, [], 54])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray(['a', 2, 8, 1])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray([2, 'a', 8, 1])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray([2, 8, 'a', 1])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - expect(() => validateColorArray([2, 8, 1, 'a'])).toThrow( - "Color must be in array containing 4 finite numbers" - ); - }); - - test("Should throw a range error if first three entries are not between 0 and 255, the the fourth is between 0 and 1", () => { - expect(() => validateColorArray([2, 8, 1, 4])).toThrow( - "Color alpha value (at index 3) must be between 0 and 1 inclusive" - ); - expect(() => validateColorArray([2, 8, 1, -4])).toThrow( - "Color alpha value (at index 3) must be between 0 and 1 inclusive" - ); - expect(() => validateColorArray([-2, 8, 1, 0.4])).toThrow( -"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive" - ); - expect(() => validateColorArray([2, 428, 1, 0.4])).toThrow( -"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive" - ); - expect(() => validateColorArray([2, 1, 428, 0.4])).toThrow( -"Color rgb values (at indices 0, 1, 2) must be between 0 and 255 inclusive" - ); - }); -}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42c9d74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "outDir": "dist", + "rootDir": "src", + "module": "node16", + "noEmitOnError": true, + "paths": { + "@src/*": ["./src/*"] + } + } +}