From 808d8a714ac5fb79394db830afd5511c8fdf7264 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 12 Apr 2025 22:12:36 +0200 Subject: [PATCH 01/29] feat(color): implement robust Color class - Add HEX/RGB/HSL conversions - Implement alpha channel support - Add color validation - Complete test coverage --- docs/color-api.md | 194 +++++++++++++++++++ scripts/color.js | 409 ++++++++++++++++++++++++++++++++++++++++ tests/color.test.js | 447 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1050 insertions(+) create mode 100644 docs/color-api.md create mode 100644 scripts/color.js create mode 100644 tests/color.test.js diff --git a/docs/color-api.md b/docs/color-api.md new file mode 100644 index 0000000..0c1431f --- /dev/null +++ b/docs/color-api.md @@ -0,0 +1,194 @@ + + +## Color +A comprehensive color class supporting Hex, RGB(A), and HSL(A) formats +with conversion, mixing, and comparison capabilities. + +**Kind**: global class + +* [Color](#Color) + * [new Color(color, [mode])](#new_Color_new) + * [.mix(color, [weight], [mode])](#Color+mix) ⇒ [Color](#Color) + * [.isSimilarTo(color, [tolerance], [includeAlpha])](#Color+isSimilarTo) ⇒ boolean + * [.isEqualTo(color, [includeAlpha])](#Color+isEqualTo) ⇒ boolean + * [.rgb(rgba)](#Color+rgb) ⇒ this + * [.hsl(hsl)](#Color+hsl) ⇒ this + * [.hex(color)](#Color+hex) ⇒ this + * [.alpha(alpha)](#Color+alpha) ⇒ [Color](#Color) + * [.rgb()](#Color+rgb) ⇒ Array.<Number> + * [.hex()](#Color+hex) ⇒ String + * [.hsl()](#Color+hsl) ⇒ Array.<Number> + * [.alpha()](#Color+alpha) ⇒ Number + + + +### new Color(color, [mode]) +Creates a Color instance from various formats + +**Throws**: + +- TypeError If mode is not a valid mode string ("rgb", "hsl", "hex" or "copy") +- TypeError If color is not of valid format (Hex, RGB/A, HSL/A, or Color instance) + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| color | string \| Array.<Number> \| [Color](#Color) | | Input color (Hex, RGB/A, HSL/A, or Color instance) | +| [mode] | string | null | Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) | + +**Example** +```js +new Color("#FF0000", "hex") // Hex +new Color([255, 0, 0], "rgb") // RGB +new Color([360, 100, 50], "hsl") // HSL +new Color(new Color([255, 0, 0]), "copy") // Copy other color +``` + + +### color.mix(color, [weight], [mode]) ⇒ [Color](#Color) +Mixes two colors with optional weighting and color space + +**Kind**: instance method of [Color](#Color) +**Returns**: [Color](#Color) - The resulting new mixed color +**Throws**: + +- TypeError If mode is not a valid mode string ("rgb" or "hsl") +- TypeError If color is an not instance of Color class + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| color | [Color](#Color) | | The second color to mix with | +| [weight] | number | 0.5 | The mixing ratio (0-1) | +| [mode] | string | "'rgb'" | The blending mode ('rgb' or 'hsl') | + + + +### color.isSimilarTo(color, [tolerance], [includeAlpha]) ⇒ boolean +Checks if colors are visually similar within tolerance + +**Kind**: instance method of [Color](#Color) +**Returns**: boolean - Whether the two colors are visually similar within the given tolerance +**Throws**: + +- TypeError If color is an not instance of Color class + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| color | [Color](#Color) | | The color to compare the first with | +| [tolerance] | number | 5 | The allowed maximum perceptual distance (0-442) | +| [includeAlpha] | boolean | true | Whether to compare the alpha channel | + + + +### color.isEqualTo(color, [includeAlpha]) ⇒ boolean +Checks exact color equality (with optional alpha) + +**Kind**: instance method of [Color](#Color) +**Returns**: boolean - Whether the two colors are equal +**Throws**: + +- TypeError If color is an not instance of Color class + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| color | [Color](#Color) | | the color to compare with | +| [includeAlpha] | boolean | true | Whether to compare the alpha channel | + + + +### color.rgb(rgba) ⇒ this +Set color from RGB values + +**Kind**: instance method of [Color](#Color) +**Returns**: this - The current color object +**Throws**: + +- TypeError If color is not a valid array of 3 number values +- RangeError If r, g or b values are out of range + + +| Param | Type | Description | +| --- | --- | --- | +| rgba | Array.<Number> | A 3-D array containing the color values [r, g, b] (0-255) | + + + +### color.hsl(hsl) ⇒ this +Sets color from HSL values + +**Kind**: instance method of [Color](#Color) +**Returns**: this - The current color object +**Throws**: + +- TypeError If color is not a valid array of 3 number values +- RangeError If saturation or lightness are out of bounds (0-100) + + +| Param | Type | Description | +| --- | --- | --- | +| hsl | Array.<Number> | A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] | + + + +### color.hex(color) ⇒ this +Set color from hex string + +**Kind**: instance method of [Color](#Color) +**Returns**: this - The current color object +**Throws**: + +- TypeError If color is not a supported hex format + + +| Param | Type | Description | +| --- | --- | --- | +| color | string | Hex string, Supported formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA | + + + +### color.alpha(alpha) ⇒ [Color](#Color) +Sets the alpha value of the color + +**Kind**: instance method of [Color](#Color) +**Returns**: [Color](#Color) - The current color object +**Throws**: + +- TypeError If alpha is not a number +- RangeError If alpha is not in range (0.0-1.0) + + +| Param | Type | Description | +| --- | --- | --- | +| alpha | Number | the alpha value (0.0-1.0) | + + + +### color.rgb() ⇒ Array.<Number> +Retrieves RGB values into an array + +**Kind**: instance method of [Color](#Color) +**Returns**: Array.<Number> - A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); + + +### color.hex() ⇒ String +Retrieves Hex values into a string, ex. "#ff0000" + +**Kind**: instance method of [Color](#Color) +**Returns**: String - A hex string representing the color [6 digits if no transparency, 8 digits otherwise] + + +### color.hsl() ⇒ Array.<Number> +Retrieves HSL values into an array + +**Kind**: instance method of [Color](#Color) +**Returns**: Array.<Number> - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + + +### color.alpha() ⇒ Number +Retrieves alpha channel value + +**Kind**: instance method of [Color](#Color) +**Returns**: Number - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) diff --git a/scripts/color.js b/scripts/color.js new file mode 100644 index 0000000..b656749 --- /dev/null +++ b/scripts/color.js @@ -0,0 +1,409 @@ +import { validateNumber } from "./validation.js"; + +/** + * A comprehensive color class supporting Hex, RGB(A), and HSL(A) formats + * with conversion, mixing, and comparison capabilities. + * @class + */ +class Color { + /** + * Creates a Color instance from various formats + * @constructor + * @param {string|Array|Color} color - Input color (Hex, RGB/A, HSL/A, or Color instance) + * @param {string} [mode=null] - Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) + * @throws {TypeError} If mode is not a valid mode string ("rgb", "hsl", "hex" or "copy") + * @throws {TypeError} If color is not of valid format (Hex, RGB/A, HSL/A, or Color instance) + * @example + * new Color("#FF0000", "hex") // Hex + * new Color([255, 0, 0], "rgb") // RGB + * new Color([360, 100, 50], "hsl") // HSL + * new Color(new Color([255, 0, 0]), "copy") // Copy other color + */ + constructor(color = Color.RED, mode = null) { + this._rgb = [0, 0, 0]; // (rgb: 0-255, a: 0.0-1.0) + this._hex = '#000000'; + this._alpha = 1; + this._updated = false; + + // Auto-detect mode if not specified + if (mode === null) { + mode = this._detectInputType(color); + } + + switch (mode) { + case "rgb": + if (!Array.isArray(color)) throw new TypeError('RGB color must be an array'); + const rgb = [...color]; + this.alpha = rgb.length > 3 ? rgb[3] : 1; + this.rgb = rgb.slice(0, 3); + break; + + case "hsl": + if (!Array.isArray(color)) throw new TypeError('HSL color must be an array'); + const hsl = [...color]; + this.alpha = hsl.length > 3 ? hsl[3] : 1; + this.hsl = hsl.slice(0, 3); + break; + + case "hex": + if (typeof color !== 'string') throw new TypeError('Hex color must be a string'); + this.hex = color; + break; + + case "copy": + case "cpy": + if (!(color instanceof Color)) throw new TypeError('Copy source must be a Color instance'); + this._copyFrom(color); + break; + + default: + throw new TypeError(`Invalid mode: ${mode}. Valid modes are "rgb", "hsl", "hex", or "copy"`); + } + } + + /** + * Mixes two colors with optional weighting and color space + * @method + * @param {Color} color - The second color to mix with + * @param {number} [weight=0.5] - The mixing ratio (0-1) + * @param {string} [mode='rgb'] - The blending mode ('rgb' or 'hsl') + * @returns {Color} The resulting new mixed color + * @throws {TypeError} If mode is not a valid mode string ("rgb" or "hsl") + * @throws {TypeError} If color is an not instance of Color class + */ + mix(color, weight = 0.5, mode = 'rgb') { + weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 + const color1 = this; + const color2 = color; + if (!(color2 instanceof Color)) + throw new TypeError("color must be instance of Color class"); + + const [a1, a2] = [color1.alpha, color2.alpha]; + + switch (mode) { + case 'hsl': // HSL mixing (circular interpolation for hue) + const [h1, s1, l1] = color1.hsl; + const [h2, s2, l2] = color2.hsl; + + // Handle hue wrapping (e.g., 350° + 20° → 10°) + let hueDiff = h2 - h1; + if (Math.abs(hueDiff) > 180) { + hueDiff += hueDiff > 0 ? -360 : 360; + } + + return new Color([ + (h1 + hueDiff * weight + 360) % 360, // Wrap around 360° + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight, + a1 + (a2 - a1) * weight, + ], "hsl"); + case 'rgb': // RGB mixing (linear interpolation) + const [r1, g1, b1] = color1.rgb; + const [r2, g2, b2] = color2.rgb; + + return new Color([ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight, + a1 + (a2 - a1) * weight + ], 'rgb'); + default: + throw new TypeError(`Invalid mode for mixing: ${mode}`); + } + } + + /** + * Checks if colors are visually similar within tolerance + * @method + * @param {Color} color - The color to compare the first with + * @param {number} [tolerance=5] - The allowed maximum perceptual distance (0-442) + * @param {boolean} [includeAlpha=true] - Whether to compare the alpha channel + * @returns {boolean} Whether the two colors are visually similar within the given tolerance + * @throws {TypeError} If color is an not instance of Color class + */ + isSimilarTo(color, tolerance = 5, includeAlpha = true) { + const color1 = this; + const color2 = color; + if (!(color2 instanceof Color)) + throw new TypeError("color must be instance of Color class"); + + const [r1, g1, b1] = color1.rgb; + const [r2, g2, b2] = color2.rgb; + const [a1, a2] = [color1.alpha, color2.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 {Color} color - the color to compare with + * @param {boolean} [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 + */ + isEqualTo(color, includeAlpha = true) { + const color1 = this; + const color2 = color; + if (!(color2 instanceof Color)) + throw new TypeError("color must be instance of Color class"); + + const [r1, g1, b1] = color1.rgb; + const [r2, g2, b2] = color2.rgb; + const [a1, a2] = [color1.alpha, color2.alpha]; + + const rgbEqual = ( + r1 === r2 && + g1 === g2 && + b1 === b2 + ); + + const alphaEqual = !includeAlpha || ( + Math.round(a1 * 255) === Math.round(a2 * 255) + ); + + return rgbEqual && alphaEqual; + } + + /** + * Set color from RGB values + * @method + * @param {Array} rgba - A 3-D array containing the color values [r, g, b] (0-255) + * @returns {this} The current color object + * @throws {TypeError} If color is not a valid array of 3 number values + * @throws {RangeError} If r, g or b values are out of range + */ + set rgb(color) { + if (!(Array.isArray(color) && color.length === 3)) + throw new TypeError(`Invalid rgb color format: ${color}`); + + validateNumber(color[0], "Red component", { start: 0, end: 255 }); + validateNumber(color[1], "Green component", { start: 0, end: 255 }); + validateNumber(color[2], "Blue component", { start: 0, end: 255 }); + + this._rgb = [ + Math.round(color[0]), + Math.round(color[1]), + Math.round(color[2]), + ]; + this._updated = false; + return this; + } + + /** + * Sets color from HSL values + * @method + * @param {Array} hsl - A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] + * @returns {this} The current color object + * @throws {TypeError} If color is not a valid array of 3 number values + * @throws {RangeError} If saturation or lightness are out of bounds (0-100) + */ + set hsl(color) { + if (!(Array.isArray(color) && color.length === 3)) + throw new TypeError(`Invalid hsl color format: ${color}`); + + validateNumber(color[0], "Hue"); + validateNumber(color[1], "Saturation", { start: 0, end: 100 }); + validateNumber(color[2], "Lightness", { start: 0, end: 100 }); + + this._rgb = [...hslToRgb(color[0], color[1], color[2])].map(v => Math.round(v)); + this._updated = false; + return this; + } + + /** + * Set color from hex string + * @method + * @param {string} color - Hex string, Supported formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA + * @returns {this} The current color object + * @throws {TypeError} If color is not a supported hex format + */ + set hex(color) { + if (!/^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i.test(color)) { + throw new TypeError(`Invalid hex color format: ${color}`); + } + + let hexDigits = color.slice(1); + const isShorthand = hexDigits.length <= 4; + + if (isShorthand) { + hexDigits = Array.from(hexDigits).map(c => c + c).join(''); + } + + this.rgb = [ + parseInt(hexDigits.substr(0, 2), 16), + parseInt(hexDigits.substr(2, 2), 16), + parseInt(hexDigits.substr(4, 2), 16), + ]; + this.alpha = hexDigits.length > 6 ? parseInt(hexDigits.substr(6, 2), 16) / 255 : 1; + + this._updated = false; + return this; + } + + /** + * Sets the alpha value of the color + * @method + * @param {Number} alpha - the alpha value (0.0-1.0) + * @returns {Color} The current color object + * @throws {TypeError} If alpha is not a number + * @throws {RangeError} If alpha is not in range (0.0-1.0) + */ + set alpha(alpha) { + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + this._alpha = alpha; + this._updated = false; + return this; + } + + /** + * Retrieves RGB values into an array + * @method + * @returns {Array} A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); + */ + get rgb() { + this._updateHex(); + return [...this._rgb]; + } + + /** + * Retrieves Hex values into a string, ex. "#ff0000" + * @method + * @returns {String} A hex string representing the color [6 digits if no transparency, 8 digits otherwise] + */ + get hex() { + this._updateHex(); + return this._hex; + } + + /** + * Retrieves HSL values into an array + * @method + * @returns {Array} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + */ + get hsl() { + this._updateHex(); + const [h, s, l] = rgbToHsl(...this._rgb); + return [h, s, l]; + } + + /** + * Retrieves alpha channel value + * @method + * @returns {Number} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + */ + get alpha() { + this._updateHex(); + return this._alpha; + } + + toString() { + return this.hex; + } + + // Named Colors (static) + static get RED() { return new Color('#FF0000', 'hex'); } + static get TRANSPARENT() { return new Color([0, 0, 0, 0], 'rgb'); } + + // Private methods --------------------------------------------------- + + _updateHex() { + if (this._updated) { return } + this._updated = true; + const [r, g, b] = this._rgb, a = this._alpha; + const components = [ + Math.round(r), + Math.round(g), + Math.round(b) + ]; + + this._hex = `#${components.map(c => + c.toString(16).padStart(2, '0') + ).join('')}${a < 1 ? Math.round(a * 255).toString(16).padStart(2, '0') : '' + }`; + } + + _detectInputType(color) { + if (color instanceof Color) return "copy"; + if (typeof color === 'string') return "hex"; + if (Array.isArray(color)) { + throw new TypeError( + 'Array input requires explicit mode ("rgb" or "hsl").' + ); + } + throw new TypeError('Unable to detect color format. Please specify mode.'); + } + + _copyFrom(color) { + this._rgb = [...color._rgb]; + this._hex = color._hex; + this._alpha = color._alpha; + this._updated = color._updated; + } +} + +function rgbToHsl(r, g, b) { + [r, g, b] = [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; // achromatic + } 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]; +} + +function hslToRgb(h, s, l) { + 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; + let res; + if (t < 1 / 6) res = p + (q - p) * 6 * t; + else if (t < 1 / 2) res = q; + else if (t < 2 / 3) res = p + (q - p) * (2 / 3 - t) * 6; + else res = p; + return Math.round(res * 255); + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [r, g, b]; +} + +export default Color; diff --git a/tests/color.test.js b/tests/color.test.js new file mode 100644 index 0000000..d8372f3 --- /dev/null +++ b/tests/color.test.js @@ -0,0 +1,447 @@ +import Color from '../scripts/color.js'; + +describe('Color Class', () => { + describe('Color Creation', () => { + describe('Hex mode Initialization', () => { + describe('Valid Formats', () => { + test.each` + description | input | mode | hex | rgb | alpha + ${'hex mode'} | ${'#ff0000'} | ${'hex'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'hex mode (with alpha)'} | ${'#ff0000aa'} | ${'hex'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} + ${'rgb mode'} | ${[255, 0, 0]} | ${'rgb'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'rgb mode (with alpha)'} | ${[255, 0, 0, 170 / 255]} | ${'rgb'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} + ${'hsl mode'} | ${[0, 100, 50]} | ${'hsl'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'hsl mode (with alpha)'} | ${[0, 100, 50, 170 / 255]} | ${'hsl'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} + ${'copy mode'} | ${new Color('#f00')} | ${'copy'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'auto-detected hex strings'} | ${'#ff0000'} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} // auto-detect + ${'auto-detected Color instances'} | ${new Color('#f00')} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} // auto-detect + `('should create color object using $description', ({ _, input, mode, hex, rgb, alpha }) => { + const color = new Color(input, mode); + expect(color.hex).toBe(hex); + expect(color.rgb).toEqual(rgb); + expect(color.alpha).toBe(alpha); + }); + }); + + describe('Invalid Formats', () => { + test.each` + description | input | mode | errorType | errorMessage + ${'not a color (doesn\'t match hex mode)'} | ${'non-color'} | ${'hex'} | ${TypeError} | ${'Hex color must be a string'} + ${'not a color (doesn\'t match rgb mode)'} | ${'non-color'} | ${'rgb'} | ${TypeError} | ${'RGB color must be a string'} + ${'not a color (doesn\'t match hsl mode)'} | ${'non-color'} | ${'hsl'} | ${TypeError} | ${'HSL color must be a string'} + ${'not a color (doesn\'t match copy mode)'} | ${'non-color'} | ${'copy'} | ${TypeError} | ${'Copy source must be a Color instance'} + ${'invalid mode'} | ${'#feafea'} | ${'chicken'} | ${TypeError} | ${'Invalid mode: ${mode}. Valid modes are "rgb", "hsl", "hex", or "copy"'} + ${'arrays require explicit mode'} | ${[255, 0, 0]} | ${null} | ${TypeError} | ${'Array input requires explicit mode ("rgb" or "hsl").'} + `('throws $errorType.name when $description', ({ _, input, mode, errorType }) => { + expect(() => new Color(input, mode)).toThrow(errorType); + }); + }); + }); + }); + + describe('Color Manipulation', () => { + let color; + + beforeEach(() => { + color = new Color(); // red [255, 0, 0, 1] + }); + + describe('Set Alpha', () => { + describe('Valid Formats', () => { + test.each` + description | input | alpha + ${'opaque'} | ${1} | ${1} + ${'transparent'} | ${0} | ${0} + ${'half'} | ${0.5} | ${0.5} + ${'minimum non-zero'} | ${0.004} | ${0.004} // ~1/255 + ${'maximum non-one'} | ${0.996} | ${0.996} // ~254/255 + ${'floating point'} | ${0.123456789} | ${0.123456789} // Test precision + `('should set color alpha channel to $description opacity', ({ _, input, alpha }) => { + color.alpha = input; + expect(color.alpha).toBe(alpha); + }); + }); + + describe('Invalid Formats', () => { + test.each` + description | input | errorType + ${'non-number'} | ${[]} | ${TypeError} + ${'non-number'} | ${"add"} | ${TypeError} + ${'non-number'} | ${"0.5"} | ${TypeError} + ${'less than 0'} | ${-0.6} | ${RangeError} + ${'higher than 1'} | ${1.5} | ${RangeError} + `('throws $errorType.name when $description', ({ _, input, errorType }) => { + expect(() => color.alpha = input).toThrow(errorType); + }); + }); + + describe('Hex Representation', () => { + test.each` + alpha | expectedSuffix + ${0} | ${'00'} + ${0.5} | ${'80'} + ${1} | ${''} + `('alpha $alpha becomes "$expectedSuffix" in hex', ({ alpha, expectedSuffix }) => { + color.alpha = alpha; + expect(color.hex.slice(7)).toBe(`${expectedSuffix}`); + }); + }); + }); + + describe('Set Hex', () => { + describe('Valid Formats', () => { + test.each` + description | input | hex | rgb | alpha + ${'lowercase'} | ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'uppercase'} | ${'#FF00AA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'lowercase and uppercase mix'} | ${'#Ff00aA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'6-char'} | ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'8-char (with alpha)'} | ${'#ff00aabb'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} + ${'3-char shorthand'} | ${'#f0a'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'4-char shorthand (alpha)'} | ${'#f0ab'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} + ${'black shorthand'} | ${'#000'} | ${'#000000'} | ${[0, 0, 0]} | ${1} + ${'white full'} | ${'#ffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} + ${'all zeros with alpha'} | ${'#00000000'} | ${'#00000000'} | ${[0, 0, 0]} | ${0} + ${'all Fs with alpha'} | ${'#ffffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} + ${'numeric shorthand'} | ${'#123'} | ${'#112233'} | ${[17, 34, 51]} | ${1} + `('should set color value using $description hex string', ({ _, input, hex, rgb, alpha }) => { + color.hex = input; + expect(color.hex).toBe(hex); + expect(color.rgb).toEqual(rgb); + expect(color.alpha).toBeCloseTo(alpha, 2); + }); + }); + + describe('Invalid Formats', () => { + test.each` + description | input | errorType + ${'missing #'} | ${'ff0000'} | ${TypeError} + ${'invalid char'} | ${'#g00000'} | ${TypeError} + ${'short invalid'} | ${'#1'} | ${TypeError} + ${'long invalid'} | ${'#123456789'} | ${TypeError} + `('throws $errorType.name when $description', ({ _, input, errorType }) => { + expect(() => color.hex = input).toThrow( + errorType, + `Invalid hex color format: ${input}` // Verify exact message + ); + }); + }); + + describe('Format Preservation', () => { + test('should store hex in lowercase', () => { + color.hex = '#FF00AA'; + expect(color.hex).not.toBe('#FF00AA'); // Should be lowercase + expect(color.hex).toBe('#ff00aa'); + }); + }); + + describe('Alpha Modification', () => { + test('should reset alpha to 1 when setting hex without alpha', () => { + color.hex = '#12345678'; // Has alpha + color.hex = '#abcdef'; // No alpha + expect(color.alpha).toBe(1); + }); + + test('should handle 00 alpha', () => { + color.hex = '#12345600'; + expect(color.alpha).toBe(0); + }); + }); + }); + + describe('Set RGB', () => { + describe('Valid Formats', () => { + test.each` + description | input | hex | rgb | alpha + ${'3-integer'} | ${[255, 0, 170]} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} + ${'non-integer (gets rounded)'} | ${[255, 0, 170.9]} | ${'#ff00ab'} | ${[255, 0, 171]} | ${1} + ${'lower bound (black)'} | ${[0, 0, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} + ${'higher bound (white)'} | ${[255, 255, 255]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} + `('should set color value using $description RGB array', ({ _, input, hex, rgb, alpha }) => { + color.rgb = input; + expect(color.hex).toBe(hex); + expect(color.rgb).toEqual(rgb); + expect(color.alpha).toBeCloseTo(alpha, 2); + }); + }); + + describe('Invalid Formats', () => { + test.each` + description | input | errorType + ${'4-or-more-integers'} | ${[255, 0, 170, 6]} | ${TypeError} + ${'2-or-less-integers'} | ${[255]} | ${TypeError} + ${'2-or-less-integers'} | ${[2, 55]} | ${TypeError} + ${'not an array'} | ${'ahmed'} | ${TypeError} + ${'non-number values'} | ${[1, 2, 'a']} | ${TypeError} + ${'values are higher than 255'} | ${[255, 256, 144]} | ${RangeError} + ${'values are less than 0'} | ${[-1, 25, 144]} | ${RangeError} + `('throws $errorType.name when $description', ({ _, input, errorType }) => { + expect(() => color.rgb = input).toThrow( + errorType, + `Invalid rgb color format: ${JSON.stringify(input)}` // Verify exact message + ); + }); + }); + + describe('Alpha Persistance', () => { + test('should preserve existing alpha when setting RGB', () => { + const color = new Color('#ff000080', 'hex'); + expect(color.alpha).toBeCloseTo(0.5, 2); + + color.rgb = [0, 255, 0]; + + expect(color.alpha).toBeCloseTo(0.5, 2); + expect(color.hex).toBe('#00ff0080'); + }); + }); + }); + + + describe('Set HSL', () => { + + describe('Valid Formats', () => { + test.each` + description | input | hex | rgb | alpha + ${'3-integer'} | ${[0, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'3-integer (wrapping positive)'} | ${[720, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'3-integer (wrapping negative)'} | ${[-360, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} + ${'non-integer'} | ${[120, 100, 25.1]} | ${'#008000'} | ${[0, 128, 0]} | ${1} + ${'least lightness (black)'} | ${[0, 100, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} + ${'highest lightness (white)'} | ${[55, 100, 100]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} + ${'least saturation (gray)'} | ${[0, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} + ${'highest satuation (red)'} | ${[55, 100, 50]} | ${'#ffea00'} | ${[255, 234, 0]} | ${1} + ${'0 saturation (any hue)'} | ${[123, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} + ${'100 saturation edge'} | ${[180, 100, 1]} | ${'#000505'} | ${[0, 5, 5]} | ${1} + `('should set color value using $description HSL array', ({ _, input, hex, rgb, alpha }) => { + color.hsl = input; + expect(color.hex).toBe(hex); + expect(color.rgb).toEqual(rgb); + expect(color.alpha).toBeCloseTo(alpha, 2); + }); + }); + + describe('Invalid Formats', () => { + test.each` + description | input | errorType + ${'4-or-more-integers'} | ${[255, 0, 70, 6]} | ${TypeError} + ${'2-or-less-integers'} | ${[255]} | ${TypeError} + ${'2-or-less-integers'} | ${[2, 55]} | ${TypeError} + ${'not an array'} | ${'ahmed'} | ${TypeError} + ${'non-number values'} | ${[1, 2, 'a']} | ${TypeError} + ${'lightness value is higher than 100'} | ${[255, 26, 150]} | ${RangeError} + ${'saturation value is higher than 100'} | ${[255, 256, 15]} | ${RangeError} + ${'lightness value is less than 0'} | ${[255, 26, -15]} | ${RangeError} + ${'saturation value is less than 0'} | ${[255, -25, 15]} | ${RangeError} + `('throws $errorType.name when $description', ({ _, input, errorType }) => { + expect(() => color.hsl = input).toThrow( + errorType, + `Invalid hsl color format: ${JSON.stringify(input)}` // Verify exact message + ); + }); + }); + + describe('Alpha Persistance', () => { + test('should preserve existing alpha when setting RGB', () => { + const color = new Color('#ff000080', 'hex'); + expect(color.alpha).toBeCloseTo(0.5, 2); + + color.rgb = [0, 255, 0]; + + expect(color.alpha).toBeCloseTo(0.5, 2); + expect(color.hex).toBe('#00ff0080'); + }); + }); + }); + }); + + describe('Color Analysis', () => { + describe('isSimilarTo()', () => { + test('should match identical colors', () => { + const baseColor = new Color('#FF8844CC'); + expect(baseColor.isSimilarTo(baseColor)).toBe(true); + }); + + test.each` + inputColor1 | inputColor2 | inputWeight | includeAlpha | result + ${'#FF8844CC'} | ${'#FF8845CD'} | ${2} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#FE8943CB'} | ${3} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#000000'} | ${undefined} | ${true} | ${'not match'} + ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${true} | ${'not match'} + ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${false} | ${'match'} + ${'#FF8844CC'} | ${new Color([255, 136, 68, 0.8], 'rgb')} | ${1} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${false} | ${'not match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${true} | ${'not match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${2} | ${true} | ${'match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${3} | ${true} | ${'match'} // difference 2 + `('should $result $inputColor1 to $inputColor2 within tolerance $inputWeight and includeAlpha set to $includeAlpha', ({ _, inputColor1, inputColor2, inputWeight, includeAlpha, result }) => { + expect(new Color(inputColor1).isSimilarTo(new Color(inputColor2), inputWeight, includeAlpha)).toBe(result === 'match'); + }); + }); + + describe('isEqualTo()', () => { + + const baseColor = new Color('#AABBCCDD'); + + test('should match exact colors', () => { + expect(baseColor.isEqualTo(new Color('#AABBCCDD'))).toBe(true); + expect(baseColor.isEqualTo(baseColor)).toBe(true); + expect(baseColor.isEqualTo(new Color([170, 187, 204, 0.8667], 'rgb'))).toBe(true); + }); + + test.each` + inputColor1 | inputColor2 | includeAlpha | result + ${'#AABBCCDD'} | ${'#aabbcc'} | ${true} | ${'not match'} // reject non-equal colors + ${'#AABBCCDD'} | ${'#aabbccde'} | ${true} | ${'not match'} // reject non-equal colors + ${'#AABBCCDD'} | ${'#aabbcc'} | ${false} | ${'match'} // ignore alpha + ${'#AABBCCDD'} | ${'#aabbcc00'} | ${false} | ${'match'} + ${'#000000'} | ${new Color([0, 0, 0], 'rgb')} | ${true} | ${'match'} + ${'#000000'} | ${new Color([1, 0, 0], 'rgb')} | ${true} | ${'not match'} + ${'#ABC'} | ${'#AABBCC'} | ${true} | ${'match'} + ` ('should $result $inputColor1 to $inputColor2 exactly', ({ _, inputColor1, inputColor2, includeAlpha, result }) => { + expect(new Color(inputColor1).isEqualTo(new Color(inputColor2), includeAlpha)).toBe(result === 'match'); + }); + }); + + describe('Color Comparison Methods', () => { + describe('Comparison Edge Cases', () => { + test('should handle near-boundary values', () => { + const color1 = new Color('#FFFFFF'); + const color2 = new Color('#FFFFFE'); + expect(color1.isSimilarTo(color2, 1.8)).toBe(true); + expect(color1.isEqualTo(color2)).toBe(false); + }); + + test('should throw on invalid inputs', () => { + const color = new Color('#FFF', 'hex'); + expect(() => color.isSimilarTo('invalid')).toThrow(TypeError); + expect(() => color.isEqualTo(null)).toThrow(TypeError); + }); + }); + }); + }); + + describe('Color Operations', () => { + describe('mix() Method', () => { + const red = new Color('#FF0000', 'hex'); + const blue = new Color('#0000FF', 'hex'); + const semiWhite = new Color([255, 255, 255, 0.5], 'rgb'); + + test('should mix RGB colors', () => { + // Equal mix + const purple = red.mix(blue); + expect(purple.hex).toBe('#800080'); + + // Weighted mix + const reddishPurple = red.mix(blue, 0.25); + expect(reddishPurple.hex).toBe('#bf0040'); + }); + + test('should mix HSL colors', () => { + // Hue blending (red + blue in HSL = magenta) + const magenta = red.mix(blue, 0.5, 'hsl'); + expect(magenta.hex).toBe('#ff00ff'); + + // Lightness blending + const darkRed = new Color('#ff0000', 'hex'); // hsl(0, 100%, 50%) + const lightRed = new Color('#ffcccc', 'hex'); // hsl(0, 100%, 90%) + const pink = darkRed.mix(lightRed, 0.5, 'hsl'); + expect(pink.hsl[2]).toBeCloseTo(70); // 70% lightness + }); + + test('should handle alpha channels', () => { + // Mixing transparency + const mixed = red.mix(semiWhite); + expect(mixed.alpha).toBeCloseTo(0.75); + expect(mixed.hex).toBe('#ff8080bf'); + }); + + test('should handle edge cases', () => { + // Extreme weights + expect(red.mix(blue, 0).hex).toBe('#ff0000'); + expect(red.mix(blue, 1).hex).toBe('#0000ff'); + + // Hue wrapping (350° + 20° → 5°) + const crimson = new Color([350, 100, 50], "hsl"); + const orange = new Color([20, 100, 50], "hsl"); + const blended = crimson.mix(orange, 0.5, 'hsl'); + expect(blended.hsl).toEqual([4.94, 100, 50]); + }); + + test('mix() should throw on invalid color', () => { + const color = new Color('#FF0000'); + expect(() => color.mix('invalid')).toThrow(); + }); + + test('mix() should throw on invalid mode', () => { + const color = new Color('#FF0000'); + expect(() => color.mix(new Color('#00FF00'), 0.5, 'lab')).toThrow('Invalid mode'); + }); + }); + }); + + describe('Color Conversion', () => { + test('should maintain consistency between hex and rgba', () => { + const testCases = [ + '#FF0000', + '#00FF0080', + '#0000FF', + '#ABCDEF99' + ]; + + testCases.forEach(hex => { + const color = new Color(hex, 'hex'); + const fromRgba = new Color([...color.rgb, color.alpha], 'rgb'); + expect(fromRgba.hex).toBe(color.hex); + }); + }); + + test('should convert RGB to HSL', () => { + const red = new Color('#FF0000', 'hex'); + expect(red.hsl).toEqual([0, 100, 50]); + expect(red.alpha).toEqual(1); + + const teal = new Color('#008080', 'hex'); + expect(teal.hsl).toEqual([180, 100, 25.1]); + expect(red.alpha).toEqual(1); + }); + + test('should convert HSL to RGB', () => { + const gold = new Color(); + gold.hsl = [45, 100, 50]; + expect(gold.hex).toBe('#ffbf00'); + + const semiPurple = new Color(); + semiPurple.hsl = [270, 60, 70]; + semiPurple.alpha = 0.5; + expect(semiPurple.hex).toBe('#b285e080'); + }); + + test('should handle edge cases (black and white)', () => { + const black = new Color([0, 0, 0], 'rgb'); + expect(black.hex).toBe('#000000'); + + const white = new Color([255, 255, 255, 0], 'rgb'); + expect(white.hex).toBe('#ffffff00'); + }); + + test('should handle edge cases (0-saturation and hue wrapping)', () => { + // Achromatic (gray) + const gray = new Color(); + gray.hsl = [0, 0, 50]; + expect(gray.hex).toBe('#808080'); + + // Hue wrapping + const wrappedHue = new Color(); + wrappedHue.hsl = [540, 100, 50]; // 540° = 180° + expect(wrappedHue.hex).toBe('#00ffff'); + }); + }); + + describe('Static Presets', () => { + test('should have static color presets', () => { + expect(Color.RED.hex).toBe('#ff0000'); + expect(Color.TRANSPARENT.alpha).toBe(0); + expect(Color.RED instanceof Color).toBe(true); + }); + }); +}); From e5fa9996417adaf0e0ed76c6b86c8b534ca3307a Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 13 Apr 2025 00:38:59 +0200 Subject: [PATCH 02/29] docs(color): add tweaks to color documentation --- docs/color-api.md | 32 ++++++++++++++++---------------- scripts/color.js | 16 ++++++++-------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/color-api.md b/docs/color-api.md index 0c1431f..a45ee21 100644 --- a/docs/color-api.md +++ b/docs/color-api.md @@ -15,10 +15,10 @@ with conversion, mixing, and comparison capabilities. * [.hsl(hsl)](#Color+hsl) ⇒ this * [.hex(color)](#Color+hex) ⇒ this * [.alpha(alpha)](#Color+alpha) ⇒ [Color](#Color) - * [.rgb()](#Color+rgb) ⇒ Array.<Number> - * [.hex()](#Color+hex) ⇒ String - * [.hsl()](#Color+hsl) ⇒ Array.<Number> - * [.alpha()](#Color+alpha) ⇒ Number + * [.rgb()](#Color+rgb) ⇒ Array.<number> + * [.hex()](#Color+hex) ⇒ string + * [.hsl()](#Color+hsl) ⇒ Array.<number> + * [.alpha()](#Color+alpha) ⇒ number @@ -33,7 +33,7 @@ Creates a Color instance from various formats | Param | Type | Default | Description | | --- | --- | --- | --- | -| color | string \| Array.<Number> \| [Color](#Color) | | Input color (Hex, RGB/A, HSL/A, or Color instance) | +| color | string \| Array.<number> \| [Color](#Color) | | Input color (Hex, RGB/A, HSL/A, or Color instance) | | [mode] | string | null | Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) | **Example** @@ -112,7 +112,7 @@ Set color from RGB values | Param | Type | Description | | --- | --- | --- | -| rgba | Array.<Number> | A 3-D array containing the color values [r, g, b] (0-255) | +| rgba | Array.<number> | A 3-D array containing the color values [r, g, b] (0-255) | @@ -129,7 +129,7 @@ Sets color from HSL values | Param | Type | Description | | --- | --- | --- | -| hsl | Array.<Number> | A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] | +| hsl | Array.<number> | A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] | @@ -162,33 +162,33 @@ Sets the alpha value of the color | Param | Type | Description | | --- | --- | --- | -| alpha | Number | the alpha value (0.0-1.0) | +| alpha | number | the alpha value (0.0-1.0) | -### color.rgb() ⇒ Array.<Number> +### color.rgb() ⇒ Array.<number> Retrieves RGB values into an array **Kind**: instance method of [Color](#Color) -**Returns**: Array.<Number> - A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); +**Returns**: Array.<number> - A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); -### color.hex() ⇒ String +### color.hex() ⇒ string Retrieves Hex values into a string, ex. "#ff0000" **Kind**: instance method of [Color](#Color) -**Returns**: String - A hex string representing the color [6 digits if no transparency, 8 digits otherwise] +**Returns**: string - A hex string representing the color [6 digits if no transparency, 8 digits otherwise] -### color.hsl() ⇒ Array.<Number> +### color.hsl() ⇒ Array.<number> Retrieves HSL values into an array **Kind**: instance method of [Color](#Color) -**Returns**: Array.<Number> - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) +**Returns**: Array.<number> - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) -### color.alpha() ⇒ Number +### color.alpha() ⇒ number Retrieves alpha channel value **Kind**: instance method of [Color](#Color) -**Returns**: Number - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) +**Returns**: number - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) diff --git a/scripts/color.js b/scripts/color.js index b656749..44ab100 100644 --- a/scripts/color.js +++ b/scripts/color.js @@ -9,7 +9,7 @@ class Color { /** * Creates a Color instance from various formats * @constructor - * @param {string|Array|Color} color - Input color (Hex, RGB/A, HSL/A, or Color instance) + * @param {string|Array|Color} color - Input color (Hex, RGB/A, HSL/A, or Color instance) * @param {string} [mode=null] - Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) * @throws {TypeError} If mode is not a valid mode string ("rgb", "hsl", "hex" or "copy") * @throws {TypeError} If color is not of valid format (Hex, RGB/A, HSL/A, or Color instance) @@ -178,7 +178,7 @@ class Color { /** * Set color from RGB values * @method - * @param {Array} rgba - A 3-D array containing the color values [r, g, b] (0-255) + * @param {Array} rgba - A 3-D array containing the color values [r, g, b] (0-255) * @returns {this} The current color object * @throws {TypeError} If color is not a valid array of 3 number values * @throws {RangeError} If r, g or b values are out of range @@ -203,7 +203,7 @@ class Color { /** * Sets color from HSL values * @method - * @param {Array} hsl - A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] + * @param {Array} hsl - A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] * @returns {this} The current color object * @throws {TypeError} If color is not a valid array of 3 number values * @throws {RangeError} If saturation or lightness are out of bounds (0-100) @@ -254,7 +254,7 @@ class Color { /** * Sets the alpha value of the color * @method - * @param {Number} alpha - the alpha value (0.0-1.0) + * @param {number} alpha - the alpha value (0.0-1.0) * @returns {Color} The current color object * @throws {TypeError} If alpha is not a number * @throws {RangeError} If alpha is not in range (0.0-1.0) @@ -269,7 +269,7 @@ class Color { /** * Retrieves RGB values into an array * @method - * @returns {Array} A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); + * @returns {Array} A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); */ get rgb() { this._updateHex(); @@ -279,7 +279,7 @@ class Color { /** * Retrieves Hex values into a string, ex. "#ff0000" * @method - * @returns {String} A hex string representing the color [6 digits if no transparency, 8 digits otherwise] + * @returns {string} A hex string representing the color [6 digits if no transparency, 8 digits otherwise] */ get hex() { this._updateHex(); @@ -289,7 +289,7 @@ class Color { /** * Retrieves HSL values into an array * @method - * @returns {Array} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + * @returns {Array} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) */ get hsl() { this._updateHex(); @@ -300,7 +300,7 @@ class Color { /** * Retrieves alpha channel value * @method - * @returns {Number} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + * @returns {number} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) */ get alpha() { this._updateHex(); From 86322d3fd81bdfff05a2448f9d06c30ce0e51832 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 13 Apr 2025 11:30:04 +0200 Subject: [PATCH 03/29] fix(color): use substring method for setting hex instead of deprecated substr method test(color): fix some tests --- scripts/color.js | 75 +++++++++++++++++++++++---------------------- tests/color.test.js | 38 +++++++++++------------ 2 files changed, 57 insertions(+), 56 deletions(-) diff --git a/scripts/color.js b/scripts/color.js index 44ab100..73185c9 100644 --- a/scripts/color.js +++ b/scripts/color.js @@ -6,6 +6,12 @@ import { validateNumber } from "./validation.js"; * @class */ class Color { + + #rgb = [0, 0, 0]; // (rgb: 0-255, a: 0.0-1.0) + #hex = '#000000'; + #alpha = 1; + #updated = false; + /** * Creates a Color instance from various formats * @constructor @@ -20,14 +26,9 @@ class Color { * new Color(new Color([255, 0, 0]), "copy") // Copy other color */ constructor(color = Color.RED, mode = null) { - this._rgb = [0, 0, 0]; // (rgb: 0-255, a: 0.0-1.0) - this._hex = '#000000'; - this._alpha = 1; - this._updated = false; - // Auto-detect mode if not specified if (mode === null) { - mode = this._detectInputType(color); + mode = this.#detectInputType(color); } switch (mode) { @@ -53,7 +54,7 @@ class Color { case "copy": case "cpy": if (!(color instanceof Color)) throw new TypeError('Copy source must be a Color instance'); - this._copyFrom(color); + this.#copyFrom(color); break; default: @@ -191,12 +192,12 @@ class Color { validateNumber(color[1], "Green component", { start: 0, end: 255 }); validateNumber(color[2], "Blue component", { start: 0, end: 255 }); - this._rgb = [ + this.#rgb = [ Math.round(color[0]), Math.round(color[1]), Math.round(color[2]), ]; - this._updated = false; + this.#updated = false; return this; } @@ -216,8 +217,8 @@ class Color { validateNumber(color[1], "Saturation", { start: 0, end: 100 }); validateNumber(color[2], "Lightness", { start: 0, end: 100 }); - this._rgb = [...hslToRgb(color[0], color[1], color[2])].map(v => Math.round(v)); - this._updated = false; + this.#rgb = [...hslToRgb(color[0], color[1], color[2])].map(v => Math.round(v)); + this.#updated = false; return this; } @@ -241,13 +242,13 @@ class Color { } this.rgb = [ - parseInt(hexDigits.substr(0, 2), 16), - parseInt(hexDigits.substr(2, 2), 16), - parseInt(hexDigits.substr(4, 2), 16), + parseInt(hexDigits.substring(0, 2), 16), + parseInt(hexDigits.substring(2, 4), 16), + parseInt(hexDigits.substring(4, 6), 16), ]; - this.alpha = hexDigits.length > 6 ? parseInt(hexDigits.substr(6, 2), 16) / 255 : 1; + this.alpha = hexDigits.length > 6 ? parseInt(hexDigits.substring(6, 8), 16) / 255 : 1; - this._updated = false; + this.#updated = false; return this; } @@ -261,8 +262,8 @@ class Color { */ set alpha(alpha) { validateNumber(alpha, "Alpha", { start: 0, end: 1 }); - this._alpha = alpha; - this._updated = false; + this.#alpha = alpha; + this.#updated = false; return this; } @@ -272,8 +273,8 @@ class Color { * @returns {Array} A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); */ get rgb() { - this._updateHex(); - return [...this._rgb]; + this.#updateHex(); + return [...this.#rgb]; } /** @@ -282,8 +283,8 @@ class Color { * @returns {string} A hex string representing the color [6 digits if no transparency, 8 digits otherwise] */ get hex() { - this._updateHex(); - return this._hex; + this.#updateHex(); + return this.#hex; } /** @@ -292,8 +293,8 @@ class Color { * @returns {Array} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) */ get hsl() { - this._updateHex(); - const [h, s, l] = rgbToHsl(...this._rgb); + this.#updateHex(); + const [h, s, l] = rgbToHsl(...this.#rgb); return [h, s, l]; } @@ -303,8 +304,8 @@ class Color { * @returns {number} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) */ get alpha() { - this._updateHex(); - return this._alpha; + this.#updateHex(); + return this.#alpha; } toString() { @@ -317,23 +318,23 @@ class Color { // Private methods --------------------------------------------------- - _updateHex() { - if (this._updated) { return } - this._updated = true; - const [r, g, b] = this._rgb, a = this._alpha; + #updateHex() { + if (this.#updated) { return } + this.#updated = true; + const [r, g, b] = this.#rgb, a = this.#alpha; const components = [ Math.round(r), Math.round(g), Math.round(b) ]; - this._hex = `#${components.map(c => + this.#hex = `#${components.map(c => c.toString(16).padStart(2, '0') ).join('')}${a < 1 ? Math.round(a * 255).toString(16).padStart(2, '0') : '' }`; } - _detectInputType(color) { + #detectInputType(color) { if (color instanceof Color) return "copy"; if (typeof color === 'string') return "hex"; if (Array.isArray(color)) { @@ -344,11 +345,11 @@ class Color { throw new TypeError('Unable to detect color format. Please specify mode.'); } - _copyFrom(color) { - this._rgb = [...color._rgb]; - this._hex = color._hex; - this._alpha = color._alpha; - this._updated = color._updated; + #copyFrom(color) { + this.#rgb = [...color.#rgb]; + this.#hex = color.#hex; + this.#alpha = color.#alpha; + this.#updated = color.#updated; } } diff --git a/tests/color.test.js b/tests/color.test.js index d8372f3..df75424 100644 --- a/tests/color.test.js +++ b/tests/color.test.js @@ -262,17 +262,17 @@ describe('Color Class', () => { }); test.each` - inputColor1 | inputColor2 | inputWeight | includeAlpha | result - ${'#FF8844CC'} | ${'#FF8845CD'} | ${2} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#FE8943CB'} | ${3} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#000000'} | ${undefined} | ${true} | ${'not match'} - ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${true} | ${'not match'} - ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${false} | ${'match'} - ${'#FF8844CC'} | ${new Color([255, 136, 68, 0.8], 'rgb')} | ${1} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${false} | ${'not match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${true} | ${'not match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${2} | ${true} | ${'match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${3} | ${true} | ${'match'} // difference 2 + inputColor1 | inputColor2 | inputWeight | includeAlpha | result + ${'#FF8844CC'} | ${'#FF8845CD'} | ${2} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#FE8943CB'} | ${3} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#000000'} | ${undefined} | ${true} | ${'not match'} + ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${true} | ${'not match'} + ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${false} | ${'match'} + ${'#FF8844CC'} | ${new Color([255, 136, 68, 0.8], 'rgb').hex} | ${1} | ${true} | ${'match'} + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${false} | ${'not match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${true} | ${'not match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${2} | ${true} | ${'match'} // difference 2 + ${'#FF8844CC'} | ${'#FF8A44CD'} | ${3} | ${true} | ${'match'} // difference 2 `('should $result $inputColor1 to $inputColor2 within tolerance $inputWeight and includeAlpha set to $includeAlpha', ({ _, inputColor1, inputColor2, inputWeight, includeAlpha, result }) => { expect(new Color(inputColor1).isSimilarTo(new Color(inputColor2), inputWeight, includeAlpha)).toBe(result === 'match'); }); @@ -289,14 +289,14 @@ describe('Color Class', () => { }); test.each` - inputColor1 | inputColor2 | includeAlpha | result - ${'#AABBCCDD'} | ${'#aabbcc'} | ${true} | ${'not match'} // reject non-equal colors - ${'#AABBCCDD'} | ${'#aabbccde'} | ${true} | ${'not match'} // reject non-equal colors - ${'#AABBCCDD'} | ${'#aabbcc'} | ${false} | ${'match'} // ignore alpha - ${'#AABBCCDD'} | ${'#aabbcc00'} | ${false} | ${'match'} - ${'#000000'} | ${new Color([0, 0, 0], 'rgb')} | ${true} | ${'match'} - ${'#000000'} | ${new Color([1, 0, 0], 'rgb')} | ${true} | ${'not match'} - ${'#ABC'} | ${'#AABBCC'} | ${true} | ${'match'} + inputColor1 | inputColor2 | includeAlpha | result + ${'#AABBCCDD'} | ${'#aabbcc'} | ${true} | ${'not match'} // reject non-equal colors + ${'#AABBCCDD'} | ${'#aabbccde'} | ${true} | ${'not match'} // reject non-equal colors + ${'#AABBCCDD'} | ${'#aabbcc'} | ${false} | ${'match'} // ignore alpha + ${'#AABBCCDD'} | ${'#aabbcc00'} | ${false} | ${'match'} + ${'#000000'} | ${new Color([0, 0, 0], 'rgb').hex} | ${true} | ${'match'} + ${'#000000'} | ${new Color([1, 0, 0], 'rgb').hex} | ${true} | ${'not match'} + ${'#ABC'} | ${'#AABBCC'} | ${true} | ${'match'} ` ('should $result $inputColor1 to $inputColor2 exactly', ({ _, inputColor1, inputColor2, includeAlpha, result }) => { expect(new Color(inputColor1).isEqualTo(new Color(inputColor2), includeAlpha)).toBe(result === 'match'); }); From 5dbe092bd6bf157b426fa6d280b13f65903df2a1 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 09:30:49 +0200 Subject: [PATCH 04/29] test(color): refactored color tests --- tests/color.test.js | 195 +++++++++++++++++++++----------------------- 1 file changed, 95 insertions(+), 100 deletions(-) diff --git a/tests/color.test.js b/tests/color.test.js index df75424..5dd796d 100644 --- a/tests/color.test.js +++ b/tests/color.test.js @@ -1,21 +1,22 @@ import Color from '../scripts/color.js'; describe('Color Class', () => { + describe('Color Creation', () => { describe('Hex mode Initialization', () => { describe('Valid Formats', () => { test.each` - description | input | mode | hex | rgb | alpha - ${'hex mode'} | ${'#ff0000'} | ${'hex'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'hex mode (with alpha)'} | ${'#ff0000aa'} | ${'hex'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} - ${'rgb mode'} | ${[255, 0, 0]} | ${'rgb'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'rgb mode (with alpha)'} | ${[255, 0, 0, 170 / 255]} | ${'rgb'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} - ${'hsl mode'} | ${[0, 100, 50]} | ${'hsl'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'hsl mode (with alpha)'} | ${[0, 100, 50, 170 / 255]} | ${'hsl'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} - ${'copy mode'} | ${new Color('#f00')} | ${'copy'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'auto-detected hex strings'} | ${'#ff0000'} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} // auto-detect - ${'auto-detected Color instances'} | ${new Color('#f00')} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} // auto-detect - `('should create color object using $description', ({ _, input, mode, hex, rgb, alpha }) => { + input | mode | hex | rgb | alpha | description + ${'#ff0000'} | ${'hex'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hex mode'} + ${'#ff0000aa'} | ${'hex'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hex mode (with alpha)'} + ${[255, 0, 0]} | ${'rgb'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'rgb mode'} + ${[255, 0, 0, 170 / 255]} | ${'rgb'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'rgb mode (with alpha)'} + ${[0, 100, 50]} | ${'hsl'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hsl mode'} + ${[0, 100, 50, 170 / 255]} | ${'hsl'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hsl mode (with alpha)'} + ${new Color('#f00')} | ${'copy'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'copy mode'} + ${'#ff0000'} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'auto-detected hex strings'} + ${new Color('#f00')} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'auto-detected Color instances'} + `('should create color object using $description', ({ input, mode, hex, rgb, alpha }) => { const color = new Color(input, mode); expect(color.hex).toBe(hex); expect(color.rgb).toEqual(rgb); @@ -25,14 +26,14 @@ describe('Color Class', () => { describe('Invalid Formats', () => { test.each` - description | input | mode | errorType | errorMessage - ${'not a color (doesn\'t match hex mode)'} | ${'non-color'} | ${'hex'} | ${TypeError} | ${'Hex color must be a string'} - ${'not a color (doesn\'t match rgb mode)'} | ${'non-color'} | ${'rgb'} | ${TypeError} | ${'RGB color must be a string'} - ${'not a color (doesn\'t match hsl mode)'} | ${'non-color'} | ${'hsl'} | ${TypeError} | ${'HSL color must be a string'} - ${'not a color (doesn\'t match copy mode)'} | ${'non-color'} | ${'copy'} | ${TypeError} | ${'Copy source must be a Color instance'} - ${'invalid mode'} | ${'#feafea'} | ${'chicken'} | ${TypeError} | ${'Invalid mode: ${mode}. Valid modes are "rgb", "hsl", "hex", or "copy"'} - ${'arrays require explicit mode'} | ${[255, 0, 0]} | ${null} | ${TypeError} | ${'Array input requires explicit mode ("rgb" or "hsl").'} - `('throws $errorType.name when $description', ({ _, input, mode, errorType }) => { + input | mode | errorType | description + ${'non-color'} | ${'hex'} | ${TypeError} | ${'not a color (doesn\'t match hex mode)'} + ${'non-color'} | ${'rgb'} | ${TypeError} | ${'not a color (doesn\'t match rgb mode)'} + ${'non-color'} | ${'hsl'} | ${TypeError} | ${'not a color (doesn\'t match hsl mode)'} + ${'non-color'} | ${'copy'} | ${TypeError} | ${'not a color (doesn\'t match copy mode)'} + ${'#feafea'} | ${'chicken'} | ${TypeError} | ${'invalid mode'} + ${[255, 0, 0]} | ${null} | ${TypeError} | ${'arrays require explicit mode'} + `('throws $errorType.name when $description', ({ input, mode, errorType }) => { expect(() => new Color(input, mode)).toThrow(errorType); }); }); @@ -49,14 +50,14 @@ describe('Color Class', () => { describe('Set Alpha', () => { describe('Valid Formats', () => { test.each` - description | input | alpha - ${'opaque'} | ${1} | ${1} - ${'transparent'} | ${0} | ${0} - ${'half'} | ${0.5} | ${0.5} - ${'minimum non-zero'} | ${0.004} | ${0.004} // ~1/255 - ${'maximum non-one'} | ${0.996} | ${0.996} // ~254/255 - ${'floating point'} | ${0.123456789} | ${0.123456789} // Test precision - `('should set color alpha channel to $description opacity', ({ _, input, alpha }) => { + input | alpha | description + ${1} | ${1} | ${'opaque'} + ${0} | ${0} | ${'transparent'} + ${0.5} | ${0.5} | ${'half'} + ${0.004} | ${0.004} | ${'minimum non-zero(1/255))'} + ${0.996} | ${0.996} | ${'maximum non-one(254/255)'} + ${0.123456789} | ${0.123456789} | ${'floating point(precision test)'} + `('should set color alpha channel to $description opacity', ({ input, alpha }) => { color.alpha = input; expect(color.alpha).toBe(alpha); }); @@ -64,13 +65,13 @@ describe('Color Class', () => { describe('Invalid Formats', () => { test.each` - description | input | errorType - ${'non-number'} | ${[]} | ${TypeError} - ${'non-number'} | ${"add"} | ${TypeError} - ${'non-number'} | ${"0.5"} | ${TypeError} - ${'less than 0'} | ${-0.6} | ${RangeError} - ${'higher than 1'} | ${1.5} | ${RangeError} - `('throws $errorType.name when $description', ({ _, input, errorType }) => { + input | errorType | description + ${[]} | ${TypeError} | ${'non-number'} + ${"add"} | ${TypeError} | ${'non-number'} + ${"0.5"} | ${TypeError} | ${'non-number'} + ${-0.6} | ${RangeError} | ${'less than 0'} + ${1.5} | ${RangeError} | ${'higher than 1'} + `('throws $errorType.name when $description', ({ input, errorType }) => { expect(() => color.alpha = input).toThrow(errorType); }); }); @@ -91,20 +92,20 @@ describe('Color Class', () => { describe('Set Hex', () => { describe('Valid Formats', () => { test.each` - description | input | hex | rgb | alpha - ${'lowercase'} | ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'uppercase'} | ${'#FF00AA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'lowercase and uppercase mix'} | ${'#Ff00aA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'6-char'} | ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'8-char (with alpha)'} | ${'#ff00aabb'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} - ${'3-char shorthand'} | ${'#f0a'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'4-char shorthand (alpha)'} | ${'#f0ab'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} - ${'black shorthand'} | ${'#000'} | ${'#000000'} | ${[0, 0, 0]} | ${1} - ${'white full'} | ${'#ffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} - ${'all zeros with alpha'} | ${'#00000000'} | ${'#00000000'} | ${[0, 0, 0]} | ${0} - ${'all Fs with alpha'} | ${'#ffffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} - ${'numeric shorthand'} | ${'#123'} | ${'#112233'} | ${[17, 34, 51]} | ${1} - `('should set color value using $description hex string', ({ _, input, hex, rgb, alpha }) => { + input | hex | rgb | alpha | description + ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'lowercase'} + ${'#FF00AA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'uppercase'} + ${'#Ff00aA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'lowercase and uppercase mix'} + ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'6-char'} + ${'#ff00aabb'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'8-char (with alpha)'} + ${'#f0a'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'3-char shorthand'} + ${'#f0ab'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'4-char shorthand (alpha)'} + ${'#000'} | ${'#000000'} | ${[0, 0, 0]} | ${1} | ${'black shorthand'} + ${'#ffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'white full'} + ${'#00000000'} | ${'#00000000'} | ${[0, 0, 0]} | ${0} | ${'all zeros with alpha'} + ${'#ffffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'all Fs with alpha'} + ${'#123'} | ${'#112233'} | ${[17, 34, 51]} | ${1} | ${'numeric shorthand'} + `('should set color value using $description hex string', ({ input, hex, rgb, alpha }) => { color.hex = input; expect(color.hex).toBe(hex); expect(color.rgb).toEqual(rgb); @@ -114,12 +115,12 @@ describe('Color Class', () => { describe('Invalid Formats', () => { test.each` - description | input | errorType - ${'missing #'} | ${'ff0000'} | ${TypeError} - ${'invalid char'} | ${'#g00000'} | ${TypeError} - ${'short invalid'} | ${'#1'} | ${TypeError} - ${'long invalid'} | ${'#123456789'} | ${TypeError} - `('throws $errorType.name when $description', ({ _, input, errorType }) => { + input | errorType | description + ${'ff0000'} | ${TypeError} | ${'missing #'} + ${'#g00000'} | ${TypeError} | ${'invalid char'} + ${'#1'} | ${TypeError} | ${'short invalid'} + ${'#123456789'} | ${TypeError} | ${'long invalid'} + `('throws $errorType.name when $description', ({ input, errorType }) => { expect(() => color.hex = input).toThrow( errorType, `Invalid hex color format: ${input}` // Verify exact message @@ -201,18 +202,18 @@ describe('Color Class', () => { describe('Valid Formats', () => { test.each` - description | input | hex | rgb | alpha - ${'3-integer'} | ${[0, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'3-integer (wrapping positive)'} | ${[720, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'3-integer (wrapping negative)'} | ${[-360, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} - ${'non-integer'} | ${[120, 100, 25.1]} | ${'#008000'} | ${[0, 128, 0]} | ${1} - ${'least lightness (black)'} | ${[0, 100, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} - ${'highest lightness (white)'} | ${[55, 100, 100]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} - ${'least saturation (gray)'} | ${[0, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} - ${'highest satuation (red)'} | ${[55, 100, 50]} | ${'#ffea00'} | ${[255, 234, 0]} | ${1} - ${'0 saturation (any hue)'} | ${[123, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} - ${'100 saturation edge'} | ${[180, 100, 1]} | ${'#000505'} | ${[0, 5, 5]} | ${1} - `('should set color value using $description HSL array', ({ _, input, hex, rgb, alpha }) => { + input | hex | rgb | alpha | description + ${[0, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer'} + ${[720, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer (wrapping positive)'} + ${[-360, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer (wrapping negative)'} + ${[120, 100, 25.1]} | ${'#008000'} | ${[0, 128, 0]} | ${1} | ${'non-integer'} + ${[0, 100, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} | ${'least lightness (black)'} + ${[55, 100, 100]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'highest lightness (white)'} + ${[0, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} | ${'least saturation (gray)'} + ${[55, 100, 50]} | ${'#ffea00'} | ${[255, 234, 0]} | ${1} | ${'highest satuation (red)'} + ${[123, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} | ${'0 saturation (any hue)'} + ${[180, 100, 1]} | ${'#000505'} | ${[0, 5, 5]} | ${1} | ${'100 saturation edge'} + `('should set color value using $description HSL array', ({ input, hex, rgb, alpha, _ }) => { color.hsl = input; expect(color.hex).toBe(hex); expect(color.rgb).toEqual(rgb); @@ -222,17 +223,17 @@ describe('Color Class', () => { describe('Invalid Formats', () => { test.each` - description | input | errorType - ${'4-or-more-integers'} | ${[255, 0, 70, 6]} | ${TypeError} - ${'2-or-less-integers'} | ${[255]} | ${TypeError} - ${'2-or-less-integers'} | ${[2, 55]} | ${TypeError} - ${'not an array'} | ${'ahmed'} | ${TypeError} - ${'non-number values'} | ${[1, 2, 'a']} | ${TypeError} - ${'lightness value is higher than 100'} | ${[255, 26, 150]} | ${RangeError} - ${'saturation value is higher than 100'} | ${[255, 256, 15]} | ${RangeError} - ${'lightness value is less than 0'} | ${[255, 26, -15]} | ${RangeError} - ${'saturation value is less than 0'} | ${[255, -25, 15]} | ${RangeError} - `('throws $errorType.name when $description', ({ _, input, errorType }) => { + input | errorType | description + ${[255, 0, 70, 6]} | ${TypeError} | ${'4-or-more-integers'} + ${[255]} | ${TypeError} | ${'2-or-less-integers'} + ${[2, 55]} | ${TypeError} | ${'2-or-less-integers'} + ${'ahmed'} | ${TypeError} | ${'not an array'} + ${[1, 2, 'a']} | ${TypeError} | ${'non-number values'} + ${[255, 26, 150]} | ${RangeError} | ${'lightness value is higher than 100'} + ${[255, 256, 15]} | ${RangeError} | ${'saturation value is higher than 100'} + ${[255, 26, -15]} | ${RangeError} | ${'lightness value is less than 0'} + ${[255, -25, 15]} | ${RangeError} | ${'saturation value is less than 0'} + `('throws $errorType.name when $description', ({ input, errorType }) => { expect(() => color.hsl = input).toThrow( errorType, `Invalid hsl color format: ${JSON.stringify(input)}` // Verify exact message @@ -262,19 +263,14 @@ describe('Color Class', () => { }); test.each` - inputColor1 | inputColor2 | inputWeight | includeAlpha | result - ${'#FF8844CC'} | ${'#FF8845CD'} | ${2} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#FE8943CB'} | ${3} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#000000'} | ${undefined} | ${true} | ${'not match'} - ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${true} | ${'not match'} - ${'#FF8844CC'} | ${'#FF8844'} | ${10} | ${false} | ${'match'} - ${'#FF8844CC'} | ${new Color([255, 136, 68, 0.8], 'rgb').hex} | ${1} | ${true} | ${'match'} - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${false} | ${'not match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${1} | ${true} | ${'not match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${2} | ${true} | ${'match'} // difference 2 - ${'#FF8844CC'} | ${'#FF8A44CD'} | ${3} | ${true} | ${'match'} // difference 2 - `('should $result $inputColor1 to $inputColor2 within tolerance $inputWeight and includeAlpha set to $includeAlpha', ({ _, inputColor1, inputColor2, inputWeight, includeAlpha, result }) => { - expect(new Color(inputColor1).isSimilarTo(new Color(inputColor2), inputWeight, includeAlpha)).toBe(result === 'match'); + inputColor1 | inputColor2 | inputWeight | includeAlpha | result | description + ${'#ff8844cc'} | ${'#ff8845cd'} | ${2} | ${true} | ${'match'} | ${'under tolerance'} + ${'#ff8844cc'} | ${'#ff8846ce'} | ${2} | ${true} | ${'match'} | ${'within tolerance'} + ${'#ff8844cc'} | ${'#ff8847cf'} | ${2} | ${true} | ${'not match'} | ${'over tolerance'} + ${'#ff8844cc'} | ${'#fe8843cf'} | ${2} | ${false} | ${'match'} | ${'alpha is over tolerance while ignoring alpha'} + ${'#ff8844cc'} | ${'#fe8644cc'} | ${1} | ${false} | ${'not match'} | ${'rgb is over tolerance regardless of ignoring alpha'} + `(`should $result if $description`, ({ inputColor1, inputColor2, inputWeight, includeAlpha, result }) => { + expect(new Color(inputColor1).isSimilarTo(new Color(inputColor2), inputWeight, includeAlpha)).toBe(result == 'match'); }); }); @@ -289,16 +285,14 @@ describe('Color Class', () => { }); test.each` - inputColor1 | inputColor2 | includeAlpha | result - ${'#AABBCCDD'} | ${'#aabbcc'} | ${true} | ${'not match'} // reject non-equal colors - ${'#AABBCCDD'} | ${'#aabbccde'} | ${true} | ${'not match'} // reject non-equal colors - ${'#AABBCCDD'} | ${'#aabbcc'} | ${false} | ${'match'} // ignore alpha - ${'#AABBCCDD'} | ${'#aabbcc00'} | ${false} | ${'match'} - ${'#000000'} | ${new Color([0, 0, 0], 'rgb').hex} | ${true} | ${'match'} - ${'#000000'} | ${new Color([1, 0, 0], 'rgb').hex} | ${true} | ${'not match'} - ${'#ABC'} | ${'#AABBCC'} | ${true} | ${'match'} - ` ('should $result $inputColor1 to $inputColor2 exactly', ({ _, inputColor1, inputColor2, includeAlpha, result }) => { - expect(new Color(inputColor1).isEqualTo(new Color(inputColor2), includeAlpha)).toBe(result === 'match'); + inputColor1 | inputColor2 | includeAlpha | result | description + ${'#aabbccdd'} | ${'#aabbcc'} | ${true} | ${'not match'} | ${'alpha is not equal while not egnored'} + ${'#aabbccdd'} | ${'#aabbccde'} | ${true} | ${'not match'} | ${'alpha is not equal while not egnored'} + ${'#aabdcc'} | ${'#aabbcc'} | ${true} | ${'not match'} | ${'rgb is not equal'} + ${'#aabbccdd'} | ${'#aabbccdd'} | ${true} | ${'match'} | ${'alpha is equal and rgb is equal'} + ${'#aabbccfd'} | ${'#aabbccdd'} | ${false} | ${'match'} | ${'alpha is not equal while egnored'} + ` (`should $result if $description`, ({ _, inputColor1, inputColor2, includeAlpha, result }) => { + expect(new Color(inputColor1).isEqualTo(new Color(inputColor2), includeAlpha)).toBe(result == 'match'); }); }); @@ -321,6 +315,7 @@ describe('Color Class', () => { }); describe('Color Operations', () => { + describe('mix() Method', () => { const red = new Color('#FF0000', 'hex'); const blue = new Color('#0000FF', 'hex'); From bbbbadc6ae8117a99ebff5b7ee6946e8ded5fafe Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 19:03:41 +0200 Subject: [PATCH 05/29] docs(color): add documentation to the private member variables --- docs/{color-api.md => color.md} | 0 scripts/color.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) rename docs/{color-api.md => color.md} (100%) diff --git a/docs/color-api.md b/docs/color.md similarity index 100% rename from docs/color-api.md rename to docs/color.md diff --git a/scripts/color.js b/scripts/color.js index 73185c9..fed7790 100644 --- a/scripts/color.js +++ b/scripts/color.js @@ -7,9 +7,36 @@ import { validateNumber } from "./validation.js"; */ class Color { - #rgb = [0, 0, 0]; // (rgb: 0-255, a: 0.0-1.0) + /** + * RGB color array (0-255 values). + * @type {Array} + * @property {number} 0 - Red (0-255) + * @property {number} 1 - Green (0-255) + * @property {number} 2 - Blue (0-255) + */ + #rgb = [0, 0, 0]; + + /** + * Hexadecimal color string in `#RRGGBB` or `#RRGGBBAA` format. + * @type {string} + * @example '#ff0000' // Red + * @example '#00ff0080' // Green with 50% alpha + */ #hex = '#000000'; + + /** + * Alpha transparency value (0.0 = fully transparent, 1.0 = fully opaque). + * @type {number} + * @min 0.0 + * @max 1.0 + */ #alpha = 1; + + /** + * Dirty flag indicating whether the color was modified since last render. + * @type {boolean} + * @readonly + */ #updated = false; /** From a56d93df58ebad4d8518e035a0fb7a39a5a9d0f9 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 19:19:51 +0200 Subject: [PATCH 06/29] feat: add DirtyRectangle class with tests and docs - Add core pixel tracking functionality - Include 22 unit tests for all methods - Document API usage in Markdown --- docs/dirty-rectangle.md | 139 ++++++++++++++++++++ scripts/dirty-rectangle.js | 231 ++++++++++++++++++++++++++++++++++ tests/dirty-rectangle.test.js | 182 +++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 docs/dirty-rectangle.md create mode 100644 scripts/dirty-rectangle.js create mode 100644 tests/dirty-rectangle.test.js diff --git a/docs/dirty-rectangle.md b/docs/dirty-rectangle.md new file mode 100644 index 0000000..4479f9d --- /dev/null +++ b/docs/dirty-rectangle.md @@ -0,0 +1,139 @@ + + +## DirtyRectangle +Tracks modified pixel regions with ordered history support. +Maintains both a Map for order and a Set for duplicate checking. + +**Kind**: global class + +* [DirtyRectangle](#DirtyRectangle) + * [new DirtyRectangle([options])](#new_DirtyRectangle_new) + * [.merge(source)](#DirtyRectangle+merge) ⇒ [DirtyRectangle](#DirtyRectangle) + * [.clone()](#DirtyRectangle+clone) ⇒ [DirtyRectangle](#DirtyRectangle) + * [.setChange(x, y, after, [before])](#DirtyRectangle+setChange) + * [.hasChange(x, y)](#DirtyRectangle+hasChange) ⇒ boolean + * [.isEmpty()](#DirtyRectangle+isEmpty) ⇒ boolean + * [.isStrictType()](#DirtyRectangle+isStrictType) ⇒ boolean + * [.width()](#DirtyRectangle+width) ⇒ number + * [.height()](#DirtyRectangle+height) ⇒ number + * [.stateType()](#DirtyRectangle+stateType) ⇒ Object + * [.afterStates()](#DirtyRectangle+afterStates) ⇒ Array.<{x: number, y: number, state: any}> + * [.beforeStates()](#DirtyRectangle+beforeStates) ⇒ Array.<{x: number, y: number, state: any}> + * [.changes()](#DirtyRectangle+changes) ⇒ Map.<string, {x: number, y: number, before: any, after: any}> + * [.bounds()](#DirtyRectangle+bounds) ⇒ Object + + + +### new DirtyRectangle([options]) +Creates a DirtyRectangle instance. + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [options] | Object | | Configuration options. | +| [options.stateType] | function | Object | The constructor for state objects. Relevant only if strictType is true. | +| [options.strictType] | boolean | false | Enforce that state objects are instances of stateType. | + + + +### dirtyRectangle.merge(source) ⇒ [DirtyRectangle](#DirtyRectangle) +Merges another DirtyRectangle into a copy of this one, and returns it. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) +**Returns**: [DirtyRectangle](#DirtyRectangle) - The result of merging + +| Param | Type | Description | +| --- | --- | --- | +| source | [DirtyRectangle](#DirtyRectangle) | Source rectangle to merge. | + + + +### dirtyRectangle.clone() ⇒ [DirtyRectangle](#DirtyRectangle) +Creates a shallow copy (states are not deep-cloned). + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) +**Returns**: [DirtyRectangle](#DirtyRectangle) - The clone + + +### dirtyRectangle.setChange(x, y, after, [before]) +Adds or updates a pixel modification. Coordinates are floored to integers. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) +**Throws**: + +- TypeError If strictType is enabled and states are invalid. + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| x | number | | X-coordinate (floored). | +| y | number | | Y-coordinate (floored). | +| after | any | | New state. | +| [before] | any | after | Original state (used only on first add). | + + + +### dirtyRectangle.hasChange(x, y) ⇒ boolean +Checks if a pixel has been modified. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate. | +| y | number | Y-coordinate. | + + + +### dirtyRectangle.isEmpty() ⇒ boolean +Returns whether the rectangle is empty. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.isStrictType() ⇒ boolean +Whether state types are checked + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.width() ⇒ number +Width of the bounding rectangle. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.height() ⇒ number +Height of the bounding rectangle. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.stateType() ⇒ Object +Type used for state validation. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.afterStates() ⇒ Array.<{x: number, y: number, state: any}> +Gets current modified states. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.beforeStates() ⇒ Array.<{x: number, y: number, state: any}> +Gets original states before modification. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.changes() ⇒ Map.<string, {x: number, y: number, before: any, after: any}> +Map for all changes (in insertion order). ['x,y' -> change] + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) + + +### dirtyRectangle.bounds() ⇒ Object +Bounding rectangle of all changes. + +**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) diff --git a/scripts/dirty-rectangle.js b/scripts/dirty-rectangle.js new file mode 100644 index 0000000..58b956c --- /dev/null +++ b/scripts/dirty-rectangle.js @@ -0,0 +1,231 @@ +/** + * @class + * Tracks modified pixel regions with ordered history support. + * Maintains both a Map for order and a Set for duplicate checking. + */ +class DirtyRectangle { + + /** + * Class variable which decides the type of before and after states for changes + * @type {Object} + */ + #stateType + + /** + * Flag for determining whether to check states types + * @type {boolean} + */ + #strictType + + /** + * Map from pixel positions `${x},${y}` to a change record containing the position and before/after states + * @type {Map} + */ + #changes = new Map(); + + /** + * Bounds of the region containing all the changes + * @type {{x0: number, y0: number, x1: number, y1: number}} + */ + #bounds = { + x0: Infinity, + y0: Infinity, + x1: -Infinity, + y1: -Infinity + }; + + /** + * Creates a DirtyRectangle instance. + * @constructor + * @param {Object} [options] - Configuration options. + * @param {function} [options.stateType=Object] - The constructor for state objects. Relevant only if strictType is true. + * @param {boolean} [options.strictType=false] - Enforce that state objects are instances of stateType. + */ + constructor({ + stateType = Object, + strictType = false, + } = {}) { + this.#stateType = stateType; + this.#strictType = Boolean(strictType); + } + + /** + * Merges another DirtyRectangle into a copy of this one, and returns it. + * @method + * @param {DirtyRectangle} source - Source rectangle to merge. + * @returns {DirtyRectangle} The result of merging + */ + merge(source) { + if (!source || source.isEmpty) return this.clone(); + + const result = this.clone(); + + source.#changes.forEach((change) => { + result.setChange( + change.x, + change.y, + change.after, + change.before, + ); + }); + return result; + } + + /** + * Creates a shallow copy (states are not deep-cloned). + * @method + * @returns {DirtyRectangle} The clone + */ + clone() { + const copy = new DirtyRectangle({ + stateType: this.#stateType, + strictType: this.#strictType, + }); + + this.#changes.forEach(value => { + copy.setChange(value.x, value.y, value.after, value.before); + }); + + return copy; + } + + /** + * Adds or updates a pixel modification. Coordinates are floored to integers. + * @method + * @param {number} x - X-coordinate (floored). + * @param {number} y - Y-coordinate (floored). + * @param {any} after - New state. + * @param {any} [before=after] - Original state (used only on first add). + * @throws {TypeError} If strictType is enabled and states are invalid. + */ + setChange(x, y, after, before = after) { + if (this.#strictType && + (!(before instanceof this.#stateType) || + !(after instanceof this.#stateType))) { + throw new TypeError("Invalid state type"); + } + + x = Math.floor(x); + y = Math.floor(y); + const key = `${x},${y}`; + + const existing = this.#changes.get(key); + + if (existing) { + existing.after = after; + } else { + this.#changes.set(key, { + x, + y, + after: after, + before: before, + }); + + // Update bounds + this.#bounds.x0 = Math.min(this.#bounds.x0, x); + this.#bounds.y0 = Math.min(this.#bounds.y0, y); + this.#bounds.x1 = Math.max(this.#bounds.x1, x); + this.#bounds.y1 = Math.max(this.#bounds.y1, y); + } + } + + /** + * Checks if a pixel has been modified. + * @method + * @param {number} x - X-coordinate. + * @param {number} y - Y-coordinate. + * @returns {boolean} + */ + hasChange(x, y) { + x = Math.floor(x); + y = Math.floor(y); + return this.#changes.has(`${x},${y}`); + } + + /** + * Returns whether the rectangle is empty. + * @method + * @returns {boolean} + */ + get isEmpty() { + return this.#changes.size === 0; + } + + /** + * Whether state types are checked + * @method + * @returns {boolean} + */ + get isStrictType() { + return this.#strictType; + } + + /** + * Width of the bounding rectangle. + * @method + * @returns {number} + */ + get width() { + return this.isEmpty ? 0 : this.#bounds.x1 - this.#bounds.x0 + 1; + } + + /** + * Height of the bounding rectangle. + * @method + * @returns {number} + */ + get height() { + return this.isEmpty ? 0 : this.#bounds.y1 - this.#bounds.y0 + 1; + } + + /** + * Type used for state validation. + * @method + * @returns {Object} + */ + get stateType() { + return this.#stateType; + } + + /** + * Gets current modified states. + * @method + * @returns {Array<{x: number, y: number, state: any}>} + */ + get afterStates() { + return Array.from(this.#changes.values()).map(({ x, y, after }) => ({ + x, y, state: after + })); + } + + /** + * Gets original states before modification. + * @method + * @returns {Array<{x: number, y: number, state: any}>} + */ + get beforeStates() { + return Array.from(this.#changes.values()).map(({ x, y, before }) => ({ + x, y, state: before + })); + } + + /** + * Map for all changes (in insertion order). ['x,y' -> change] + * @method + * @returns {Map} + */ + get changes() { + return this.#changes; + } + + /** + * Bounding rectangle of all changes. + * @method + * @returns {{x0: number, y0: number, x1: number, y1: number}} + */ + get bounds() { + return { ...this.#bounds }; + } +} + +export default DirtyRectangle; diff --git a/tests/dirty-rectangle.test.js b/tests/dirty-rectangle.test.js new file mode 100644 index 0000000..5d6770d --- /dev/null +++ b/tests/dirty-rectangle.test.js @@ -0,0 +1,182 @@ +import DirtyRectangle from './../scripts/dirty-rectangle.js'; + +describe('DirtyRectangle', () => { + + let dr; + + const createRectWithChanges = (changes) => { + const dr = new DirtyRectangle(); + changes.forEach(([x, y, after, before]) => + dr.setChange(x, y, after, before)); + return dr; + }; + + beforeEach(() => { + dr = new DirtyRectangle(); + }); + + describe('DirtyRectangle Creation', () => { + test('should create an empty dirty rectangle', () => { + dr = new DirtyRectangle(); + expect(dr.width).toBe(0); + expect(dr.height).toBe(0); + expect(dr.isEmpty).toBe(true); + expect(dr.stateType).toBe(Object); + expect(dr.isStrictType).toBe(false); + }); + }); + + 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; + dr.setChange(x, y, 'state'); + expect(dr.hasChange(...expected)).toBe(true); + }); + }); + + describe('Error Handling', () => { + test.each` + options | values | errorType | description + ${{ stateType: Number, strictType: true }} | ${['text', 5]} | ${TypeError} | ${'wrong type for after state'} + ${{ stateType: Number, strictType: true }} | ${[5, 'text']} | ${TypeError} | ${'wrong type for before state'} + ${{ stateType: Object, strictType: true }} | ${[null, 5]} | ${TypeError} | ${'null value'} + `('should throws $errorType when $description', ({ options, values, errorType }) => { + const strictTypeDR = new DirtyRectangle(options); + expect(() => strictTypeDR.setChange(0, 0, values[0], values[1])).toThrow(errorType); + }); + }); + + + describe('State Management', () => { + test('should preserve initial before state', () => { + dr.setChange(0, 0, 'after', 'before'); + dr.setChange(0, 0, 'updated'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }]); + dr.setChange(0, 2, 'newpoint'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }, { x: 0, y: 2, state: 'newpoint' }]); + }); + + test('should update after state on subsequent calls', () => { + dr.setChange(1, 1, 'v1'); + expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v1' }]); + + dr.setChange(1, 1, 'v2'); + expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v2' }]); + + dr.setChange(1, 1, 'v3'); + expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v3' }]); + }); + + test('should order all changes old to new in before states and after states, changes map should access any change', () => { + dr.setChange(0, 0, 'v1'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }]); + expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }]); + expect(dr.changes.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v1', before: 'v1' }); + dr.setChange(0, 2, 'v2'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); + expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); + expect(dr.changes.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); + dr.setChange(1, 0, 'v3'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(dr.changes.get(`1,0`)) .toEqual({ x: 1, y: 0, after: 'v3', before: 'v3' }); + dr.setChange(0, 0, 'v4'); + expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v4' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(dr.changes.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v4', before: 'v1' }); + expect(dr.changes.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); + }); + }); + + describe('Bounds Calculation', () => { + test.each` + changes | expectedBounds + ${'[0, 0]'} | ${{ x0: 0, y0: 0, x1: 0, y1: 0 }} + ${'[1, 2], [3, 4]'} | ${{ x0: 1, y0: 2, x1: 3, y1: 4 }} + ${'[-1, -2], [-3, -4]'} | ${{ x0: -3, y0: -4, x1: -1, y1: -2 }} + ${'[1, 2], [-3, -4]'} | ${{ x0: -3, y0: -4, x1: 1, y1: 2 }} + ${'[-1, -2], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} + ${'[-1, -2], [3, 4], [0, 0]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} + ${'[-1, -2], [0, 0], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} + ${'[0, 0], [-1, -2], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} + `('should calculate bounds correctly for given $changes', ({ changes, expectedBounds }) => { + + // magic for turning the '[0, 0], [-1, -2], [3, 4]' string into [[0, 0], [-1, -2], [3, 4]] array :P + changes = changes + .replace(/[\[\]\,]/g, ' ') + .trim() + .split(/\s./) + .filter(s => s != '') + .map(x => Number(x)) + .reduce((c, n) => { + if (c[c.length - 1].length >= 2) + c.push([]); + c[c.length - 1].push(n); + return c; + }, [[]]); + + console.log(changes); + + dr = createRectWithChanges(changes); + expect(dr.bounds).toEqual(expectedBounds); + }); + }); + }); + + describe('DirtyRectangle Manipulation', () => { + + describe('Cloning', () => { + test('should produce independent copy', () => { + dr.setChange(0, 0, 'original'); + const clone = dr.clone(); + + // Test independence + clone.setChange(1, 1, 'new'); + dr.setChange(2, 2, 'different'); + + expect(clone.hasChange(2, 2)).toBe(false); + expect(dr.hasChange(1, 1)).toBe(false); + }); + + test('should preserve all properties', () => { + dr.setChange(1, 2, 'state'); + const clone = dr.clone(); + + expect(clone.afterStates).toEqual(dr.afterStates); + expect(clone.bounds).toEqual(dr.bounds); + }); + }); + + describe('Merging', () => { + test('should merge overlapping pixels correctly', () => { + const dr1 = new DirtyRectangle(); + dr1.setChange(0, 0, 'dr1-after', 'dr1-before'); + + const dr2 = new DirtyRectangle(); + dr2.setChange(0, 0, 'dr2-after', 'dr2-before'); + + let merge = dr1.merge(dr2); + + expect(merge.afterStates).toEqual([{ x: 0, y: 0, state: 'dr2-after' }]); + expect(merge.beforeStates).toEqual([{ x: 0, y: 0, state: 'dr1-before' }]); + }); + + test('should expand bounds to include both rects', () => { + const dr1 = new DirtyRectangle(); + dr1.setChange(0, 0, 'state'); + + const dr2 = new DirtyRectangle(); + dr2.setChange(5, 5, 'state'); + + let merge = dr1.merge(dr2); + expect(merge.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); + }); + }); + }); +}); From a76f0938f78c39fccf7a9bb1f8209cd47e8bd8b4 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 19:40:41 +0200 Subject: [PATCH 07/29] test: add full test suite for Validation refactor: deprecate validateColorArray --- scripts/validation.js | 4 +- tests/validation.test.js | 219 ++++++++++++++++++++------------------- 2 files changed, 114 insertions(+), 109 deletions(-) diff --git a/scripts/validation.js b/scripts/validation.js index 892dbc7..32b286e 100644 --- a/scripts/validation.js +++ b/scripts/validation.js @@ -3,8 +3,10 @@ * @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. + * @deprecated use the Color class instead */ export function validateColorArray(color) { + console.warn("Deprecated - use new Color() instead"); if (!Array.isArray(color) || color.length !== 4) { throw new TypeError( "Color must be in array containing 4 finite numbers", @@ -38,11 +40,11 @@ export function validateColorArray(color) { /** * Validates the number to be valid number between start and end inclusive. * @param {number} number - The number to validate. + * @param {String} varName - The variable name to show in the error message which will be thrown. * @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. diff --git a/tests/validation.test.js b/tests/validation.test.js index 5015e95..e82d9e2 100644 --- a/tests/validation.test.js +++ b/tests/validation.test.js @@ -1,130 +1,133 @@ 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(); + 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(); }); - 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", - ); + }); + + 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" + ); }); + }); - 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`); + describe("Integer Validation", () => { + test("rejects decimals when integerOnly=true", () => { + expect(() => validateNumber(23.4, "testVar", { integerOnly: true })) + .toThrow("testVar must be integer"); }); + }); - 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"); + 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("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 -`, - ); + 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", () => { - 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(); + 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(); }); + }); - 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" - ); + 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"); }); + }); - 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" - ); + 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); }); + }); - 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" - ); + describe("Deprecation Warning", () => { + test("shows deprecation warning", () => { + validateColorArray([0, 0, 0, 0]); + expect(console.warn).toHaveBeenCalledWith("Deprecated - use new Color() instead"); }); + }); }); From a1522ca68f4eec0e51ae03399122eba1fd6c8fe2 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 19:45:55 +0200 Subject: [PATCH 08/29] docs(validation): add markdown API documentation file --- docs/validation.md | 59 +++++++++++++++++++++++++++++++++++++++++++ scripts/validation.js | 7 +++-- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 docs/validation.md diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..5e2bd57 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,59 @@ +## Functions + +
+
validateColorArray(color)boolean
+

Validates the color array.

+
+
validateNumber(number, varName, Contains, start, end, integerOnly)
+

Validates the number to be valid number between start and end inclusive.

+
+
+ + + +## ~~validateColorArray(color) ⇒ boolean~~ +***use the Color class instead*** + +Validates the color array. + +**Kind**: global function +**Returns**: boolean - - Returns true if the color array is valid, otherwise false. +**Throws**: + +- TypeError Throws an error if the color is invalid. + + +| Param | Type | Description | +| --- | --- | --- | +| color | Array.<number> | The color array [red, green, blue, alpha] to validate. | + +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| 0 | number | Red (0-255) | +| 1 | number | Green (0-255) | +| 2 | number | Blue (0-255) | + + + +## validateNumber(number, varName, Contains, start, end, integerOnly) +Validates the number to be valid number between start and end inclusive. + +**Kind**: global function +**Throws**: + +- TypeError Throws an error if the number type, name type or options types is invalid. +- TypeError Throws an error if start and end are set but start is higher than end. +- RangeError Throws an error if the number is not in the specified range. + + +| Param | Type | Description | +| --- | --- | --- | +| number | number | The number to validate. | +| varName | string | The variable name to show in the error message which will be thrown. | +| Contains | Object | some optional constraints: max/min limits, and if the number is integer only | +| start | number \| undefined | The minimum of valid range, set to null to omit the constraint. | +| end | number \| undefined | The maximum of valid range, set to null to omit the constraint. | +| integerOnly | boolean | Specifies if the number must be an integer. | + diff --git a/scripts/validation.js b/scripts/validation.js index 32b286e..523e00b 100644 --- a/scripts/validation.js +++ b/scripts/validation.js @@ -1,6 +1,9 @@ /** * Validates the color array. - * @param {[number, number, number, number]} color - The color array [red, green, blue, alpha] to validate. + * @param {Array} color - The color array [red, green, blue, alpha] to validate. + * @property {number} 0 - Red (0-255) + * @property {number} 1 - Green (0-255) + * @property {number} 2 - Blue (0-255) * @returns {boolean} - Returns true if the color array is valid, otherwise false. * @throws {TypeError} Throws an error if the color is invalid. * @deprecated use the Color class instead @@ -40,7 +43,7 @@ export function validateColorArray(color) { /** * Validates the number to be valid number between start and end inclusive. * @param {number} number - The number to validate. - * @param {String} varName - The variable name to show in the error message which will be thrown. + * @param {string} varName - The variable name to show in the error message which will be thrown. * @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. From 7daea99c1be41bb70f49655d5bf618f0f780bb7e Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 21:37:17 +0200 Subject: [PATCH 09/29] refactor(dirty-rectangle): remove state type restriction, and update tests and documentation --- docs/dirty-rectangle.md | 29 ++----------------- scripts/dirty-rectangle.js | 53 ++--------------------------------- tests/dirty-rectangle.test.js | 15 ---------- 3 files changed, 4 insertions(+), 93 deletions(-) diff --git a/docs/dirty-rectangle.md b/docs/dirty-rectangle.md index 4479f9d..7798a27 100644 --- a/docs/dirty-rectangle.md +++ b/docs/dirty-rectangle.md @@ -7,16 +7,14 @@ Maintains both a Map for order and a Set for duplicate checking. **Kind**: global class * [DirtyRectangle](#DirtyRectangle) - * [new DirtyRectangle([options])](#new_DirtyRectangle_new) + * [new DirtyRectangle()](#new_DirtyRectangle_new) * [.merge(source)](#DirtyRectangle+merge) ⇒ [DirtyRectangle](#DirtyRectangle) * [.clone()](#DirtyRectangle+clone) ⇒ [DirtyRectangle](#DirtyRectangle) * [.setChange(x, y, after, [before])](#DirtyRectangle+setChange) * [.hasChange(x, y)](#DirtyRectangle+hasChange) ⇒ boolean * [.isEmpty()](#DirtyRectangle+isEmpty) ⇒ boolean - * [.isStrictType()](#DirtyRectangle+isStrictType) ⇒ boolean * [.width()](#DirtyRectangle+width) ⇒ number * [.height()](#DirtyRectangle+height) ⇒ number - * [.stateType()](#DirtyRectangle+stateType) ⇒ Object * [.afterStates()](#DirtyRectangle+afterStates) ⇒ Array.<{x: number, y: number, state: any}> * [.beforeStates()](#DirtyRectangle+beforeStates) ⇒ Array.<{x: number, y: number, state: any}> * [.changes()](#DirtyRectangle+changes) ⇒ Map.<string, {x: number, y: number, before: any, after: any}> @@ -24,16 +22,9 @@ Maintains both a Map for order and a Set for duplicate checking. -### new DirtyRectangle([options]) +### new DirtyRectangle() Creates a DirtyRectangle instance. - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [options] | Object | | Configuration options. | -| [options.stateType] | function | Object | The constructor for state objects. Relevant only if strictType is true. | -| [options.strictType] | boolean | false | Enforce that state objects are instances of stateType. | - ### dirtyRectangle.merge(source) ⇒ [DirtyRectangle](#DirtyRectangle) @@ -59,10 +50,6 @@ Creates a shallow copy (states are not deep-cloned). Adds or updates a pixel modification. Coordinates are floored to integers. **Kind**: instance method of [DirtyRectangle](#DirtyRectangle) -**Throws**: - -- TypeError If strictType is enabled and states are invalid. - | Param | Type | Default | Description | | --- | --- | --- | --- | @@ -88,12 +75,6 @@ Checks if a pixel has been modified. ### dirtyRectangle.isEmpty() ⇒ boolean Returns whether the rectangle is empty. -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.isStrictType() ⇒ boolean -Whether state types are checked - **Kind**: instance method of [DirtyRectangle](#DirtyRectangle) @@ -106,12 +87,6 @@ Width of the bounding rectangle. ### dirtyRectangle.height() ⇒ number Height of the bounding rectangle. -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.stateType() ⇒ Object -Type used for state validation. - **Kind**: instance method of [DirtyRectangle](#DirtyRectangle) diff --git a/scripts/dirty-rectangle.js b/scripts/dirty-rectangle.js index 58b956c..db62f2d 100644 --- a/scripts/dirty-rectangle.js +++ b/scripts/dirty-rectangle.js @@ -5,18 +5,6 @@ */ class DirtyRectangle { - /** - * Class variable which decides the type of before and after states for changes - * @type {Object} - */ - #stateType - - /** - * Flag for determining whether to check states types - * @type {boolean} - */ - #strictType - /** * Map from pixel positions `${x},${y}` to a change record containing the position and before/after states * @type {Map} @@ -37,17 +25,8 @@ class DirtyRectangle { /** * Creates a DirtyRectangle instance. * @constructor - * @param {Object} [options] - Configuration options. - * @param {function} [options.stateType=Object] - The constructor for state objects. Relevant only if strictType is true. - * @param {boolean} [options.strictType=false] - Enforce that state objects are instances of stateType. */ - constructor({ - stateType = Object, - strictType = false, - } = {}) { - this.#stateType = stateType; - this.#strictType = Boolean(strictType); - } + constructor() { } /** * Merges another DirtyRectangle into a copy of this one, and returns it. @@ -77,10 +56,7 @@ class DirtyRectangle { * @returns {DirtyRectangle} The clone */ clone() { - const copy = new DirtyRectangle({ - stateType: this.#stateType, - strictType: this.#strictType, - }); + const copy = new DirtyRectangle({ }); this.#changes.forEach(value => { copy.setChange(value.x, value.y, value.after, value.before); @@ -96,15 +72,8 @@ class DirtyRectangle { * @param {number} y - Y-coordinate (floored). * @param {any} after - New state. * @param {any} [before=after] - Original state (used only on first add). - * @throws {TypeError} If strictType is enabled and states are invalid. */ setChange(x, y, after, before = after) { - if (this.#strictType && - (!(before instanceof this.#stateType) || - !(after instanceof this.#stateType))) { - throw new TypeError("Invalid state type"); - } - x = Math.floor(x); y = Math.floor(y); const key = `${x},${y}`; @@ -151,15 +120,6 @@ class DirtyRectangle { return this.#changes.size === 0; } - /** - * Whether state types are checked - * @method - * @returns {boolean} - */ - get isStrictType() { - return this.#strictType; - } - /** * Width of the bounding rectangle. * @method @@ -178,15 +138,6 @@ class DirtyRectangle { return this.isEmpty ? 0 : this.#bounds.y1 - this.#bounds.y0 + 1; } - /** - * Type used for state validation. - * @method - * @returns {Object} - */ - get stateType() { - return this.#stateType; - } - /** * Gets current modified states. * @method diff --git a/tests/dirty-rectangle.test.js b/tests/dirty-rectangle.test.js index 5d6770d..d4cd7b7 100644 --- a/tests/dirty-rectangle.test.js +++ b/tests/dirty-rectangle.test.js @@ -21,8 +21,6 @@ describe('DirtyRectangle', () => { expect(dr.width).toBe(0); expect(dr.height).toBe(0); expect(dr.isEmpty).toBe(true); - expect(dr.stateType).toBe(Object); - expect(dr.isStrictType).toBe(false); }); }); @@ -40,19 +38,6 @@ describe('DirtyRectangle', () => { }); }); - describe('Error Handling', () => { - test.each` - options | values | errorType | description - ${{ stateType: Number, strictType: true }} | ${['text', 5]} | ${TypeError} | ${'wrong type for after state'} - ${{ stateType: Number, strictType: true }} | ${[5, 'text']} | ${TypeError} | ${'wrong type for before state'} - ${{ stateType: Object, strictType: true }} | ${[null, 5]} | ${TypeError} | ${'null value'} - `('should throws $errorType when $description', ({ options, values, errorType }) => { - const strictTypeDR = new DirtyRectangle(options); - expect(() => strictTypeDR.setChange(0, 0, values[0], values[1])).toThrow(errorType); - }); - }); - - describe('State Management', () => { test('should preserve initial before state', () => { dr.setChange(0, 0, 'after', 'before'); From 8305003fffe2972fa9ea2ceba148447369c21eed Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Tue, 15 Apr 2025 21:54:59 +0200 Subject: [PATCH 10/29] refactor(dirty-rectangle): remove console logging --- tests/dirty-rectangle.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/dirty-rectangle.test.js b/tests/dirty-rectangle.test.js index d4cd7b7..096e03b 100644 --- a/tests/dirty-rectangle.test.js +++ b/tests/dirty-rectangle.test.js @@ -106,8 +106,6 @@ describe('DirtyRectangle', () => { return c; }, [[]]); - console.log(changes); - dr = createRectWithChanges(changes); expect(dr.bounds).toEqual(expectedBounds); }); From cd0dea784d9b537ba2fc78757238d09c58d3602c Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 19 Apr 2025 10:03:11 +0200 Subject: [PATCH 11/29] feat: make Color class immutable and add caching for performance - Converted Color class to be fully immutable (all properties private/frozen) - Added caching mechanism using static Map to reuse existing color instances - Implemented Symbol-keyed constructor to enforce factory pattern - Added validation for all color inputs (RGB, HSL, Hex) - Maintained all existing functionality (conversions, mixing, comparisons) - Added cache management methods (clearCache, cacheSize) - Updated tests to verify immutability and caching behavior Benefits: - Improved performance by avoiding duplicate color instances - Safer usage due to immutability - Maintained backward compatibility with existing API --- docs/color.md | 212 +++-------------- scripts/color.js | 568 +++++++++++++++++++++++++++----------------- tests/color.test.js | 512 +++++++++++---------------------------- 3 files changed, 521 insertions(+), 771 deletions(-) diff --git a/docs/color.md b/docs/color.md index a45ee21..dd7e6ac 100644 --- a/docs/color.md +++ b/docs/color.md @@ -1,194 +1,46 @@ - +## Typedefs -## Color -A comprehensive color class supporting Hex, RGB(A), and HSL(A) formats -with conversion, mixing, and comparison capabilities. +
+
RGBColor : Object
+
+
HSLColor : Object
+
+
ParsedHex : Object
+
+
-**Kind**: global class + -* [Color](#Color) - * [new Color(color, [mode])](#new_Color_new) - * [.mix(color, [weight], [mode])](#Color+mix) ⇒ [Color](#Color) - * [.isSimilarTo(color, [tolerance], [includeAlpha])](#Color+isSimilarTo) ⇒ boolean - * [.isEqualTo(color, [includeAlpha])](#Color+isEqualTo) ⇒ boolean - * [.rgb(rgba)](#Color+rgb) ⇒ this - * [.hsl(hsl)](#Color+hsl) ⇒ this - * [.hex(color)](#Color+hex) ⇒ this - * [.alpha(alpha)](#Color+alpha) ⇒ [Color](#Color) - * [.rgb()](#Color+rgb) ⇒ Array.<number> - * [.hex()](#Color+hex) ⇒ string - * [.hsl()](#Color+hsl) ⇒ Array.<number> - * [.alpha()](#Color+alpha) ⇒ number +## RGBColor : Object +**Kind**: global typedef +**Properties** - - -### new Color(color, [mode]) -Creates a Color instance from various formats - -**Throws**: - -- TypeError If mode is not a valid mode string ("rgb", "hsl", "hex" or "copy") -- TypeError If color is not of valid format (Hex, RGB/A, HSL/A, or Color instance) - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| color | string \| Array.<number> \| [Color](#Color) | | Input color (Hex, RGB/A, HSL/A, or Color instance) | -| [mode] | string | null | Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) | - -**Example** -```js -new Color("#FF0000", "hex") // Hex -new Color([255, 0, 0], "rgb") // RGB -new Color([360, 100, 50], "hsl") // HSL -new Color(new Color([255, 0, 0]), "copy") // Copy other color -``` - - -### color.mix(color, [weight], [mode]) ⇒ [Color](#Color) -Mixes two colors with optional weighting and color space - -**Kind**: instance method of [Color](#Color) -**Returns**: [Color](#Color) - The resulting new mixed color -**Throws**: - -- TypeError If mode is not a valid mode string ("rgb" or "hsl") -- TypeError If color is an not instance of Color class - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| color | [Color](#Color) | | The second color to mix with | -| [weight] | number | 0.5 | The mixing ratio (0-1) | -| [mode] | string | "'rgb'" | The blending mode ('rgb' or 'hsl') | - - - -### color.isSimilarTo(color, [tolerance], [includeAlpha]) ⇒ boolean -Checks if colors are visually similar within tolerance - -**Kind**: instance method of [Color](#Color) -**Returns**: boolean - Whether the two colors are visually similar within the given tolerance -**Throws**: - -- TypeError If color is an not instance of Color class - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| color | [Color](#Color) | | The color to compare the first with | -| [tolerance] | number | 5 | The allowed maximum perceptual distance (0-442) | -| [includeAlpha] | boolean | true | Whether to compare the alpha channel | - - - -### color.isEqualTo(color, [includeAlpha]) ⇒ boolean -Checks exact color equality (with optional alpha) - -**Kind**: instance method of [Color](#Color) -**Returns**: boolean - Whether the two colors are equal -**Throws**: - -- TypeError If color is an not instance of Color class - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| color | [Color](#Color) | | the color to compare with | -| [includeAlpha] | boolean | true | Whether to compare the alpha channel | - - - -### color.rgb(rgba) ⇒ this -Set color from RGB values - -**Kind**: instance method of [Color](#Color) -**Returns**: this - The current color object -**Throws**: - -- TypeError If color is not a valid array of 3 number values -- RangeError If r, g or b values are out of range - - -| Param | Type | Description | +| Name | Type | Description | | --- | --- | --- | -| rgba | Array.<number> | A 3-D array containing the color values [r, g, b] (0-255) | - - - -### color.hsl(hsl) ⇒ this -Sets color from HSL values - -**Kind**: instance method of [Color](#Color) -**Returns**: this - The current color object -**Throws**: +| 0 | number | Red (0-255) | +| 1 | number | Green (0-255) | +| 2 | number | Blue (0-255) | -- TypeError If color is not a valid array of 3 number values -- RangeError If saturation or lightness are out of bounds (0-100) + +## HSLColor : Object +**Kind**: global typedef +**Properties** -| Param | Type | Description | +| Name | Type | Description | | --- | --- | --- | -| hsl | Array.<number> | A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] | +| 0 | number | Hue (0-360) | +| 1 | number | Saturation (0-100) | +| 2 | number | Lightness (0-100) | - + -### color.hex(color) ⇒ this -Set color from hex string +## ParsedHex : Object +**Kind**: global typedef +**Properties** -**Kind**: instance method of [Color](#Color) -**Returns**: this - The current color object -**Throws**: - -- TypeError If color is not a supported hex format - - -| Param | Type | Description | +| Name | Type | Description | | --- | --- | --- | -| color | string | Hex string, Supported formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA | - - - -### color.alpha(alpha) ⇒ [Color](#Color) -Sets the alpha value of the color - -**Kind**: instance method of [Color](#Color) -**Returns**: [Color](#Color) - The current color object -**Throws**: - -- TypeError If alpha is not a number -- RangeError If alpha is not in range (0.0-1.0) - - -| Param | Type | Description | -| --- | --- | --- | -| alpha | number | the alpha value (0.0-1.0) | - - - -### color.rgb() ⇒ Array.<number> -Retrieves RGB values into an array - -**Kind**: instance method of [Color](#Color) -**Returns**: Array.<number> - A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); - - -### color.hex() ⇒ string -Retrieves Hex values into a string, ex. "#ff0000" - -**Kind**: instance method of [Color](#Color) -**Returns**: string - A hex string representing the color [6 digits if no transparency, 8 digits otherwise] - - -### color.hsl() ⇒ Array.<number> -Retrieves HSL values into an array - -**Kind**: instance method of [Color](#Color) -**Returns**: Array.<number> - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) - - -### color.alpha() ⇒ number -Retrieves alpha channel value +| rgb | Array.<number> | RGB values [r, g, b] | +| alpha | number | Alpha value (0-1) | -**Kind**: instance method of [Color](#Color) -**Returns**: number - A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) diff --git a/scripts/color.js b/scripts/color.js index fed7790..83cd04a 100644 --- a/scripts/color.js +++ b/scripts/color.js @@ -1,94 +1,95 @@ import { validateNumber } from "./validation.js"; +const COLOR_KEY = Symbol('ColorKey'); + /** - * A comprehensive color class supporting Hex, RGB(A), and HSL(A) formats - * with conversion, mixing, and comparison capabilities. + * Represents an immutable color with support for multiple color spaces. + * All instances are cached and reused when possible. * @class + * @global */ class Color { - /** - * RGB color array (0-255 values). - * @type {Array} + * @typedef {Object} RGBColor * @property {number} 0 - Red (0-255) * @property {number} 1 - Green (0-255) * @property {number} 2 - Blue (0-255) */ - #rgb = [0, 0, 0]; /** - * Hexadecimal color string in `#RRGGBB` or `#RRGGBBAA` format. - * @type {string} - * @example '#ff0000' // Red - * @example '#00ff0080' // Green with 50% alpha + * @typedef {Object} HSLColor + * @property {number} 0 - Hue (0-360) + * @property {number} 1 - Saturation (0-100) + * @property {number} 2 - Lightness (0-100) */ - #hex = '#000000'; /** - * Alpha transparency value (0.0 = fully transparent, 1.0 = fully opaque). - * @type {number} - * @min 0.0 - * @max 1.0 + * @typedef {Object} ParsedHex + * @property {number[]} rgb - RGB values [r, g, b] + * @property {number} alpha - Alpha value (0-1) */ - #alpha = 1; /** - * Dirty flag indicating whether the color was modified since last render. - * @type {boolean} - * @readonly + * Internal cache of color instances to prevent duplicates. + * Keys are long-version hex strings. (ex. '#11223344') + * @type {Map} + * @private */ - #updated = false; + static #colorMemory = new Map(); /** - * Creates a Color instance from various formats - * @constructor - * @param {string|Array|Color} color - Input color (Hex, RGB/A, HSL/A, or Color instance) - * @param {string} [mode=null] - Interpretation mode for the color parameter ("rgb", "hsl", "hex" or "copy"), if null, it's auto-detected, if possible (if given color is an array, its ambiguous, "rgb" or "hsl"?) - * @throws {TypeError} If mode is not a valid mode string ("rgb", "hsl", "hex" or "copy") - * @throws {TypeError} If color is not of valid format (Hex, RGB/A, HSL/A, or Color instance) - * @example - * new Color("#FF0000", "hex") // Hex - * new Color([255, 0, 0], "rgb") // RGB - * new Color([360, 100, 50], "hsl") // HSL - * new Color(new Color([255, 0, 0]), "copy") // Copy other color + * RGB color values [0-255, 0-255, 0-255]. + * @type {RGBColor} + * @private */ - constructor(color = Color.RED, mode = null) { - // Auto-detect mode if not specified - if (mode === null) { - mode = this.#detectInputType(color); - } + #rgb = [0, 0, 0]; - switch (mode) { - case "rgb": - if (!Array.isArray(color)) throw new TypeError('RGB color must be an array'); - const rgb = [...color]; - this.alpha = rgb.length > 3 ? rgb[3] : 1; - this.rgb = rgb.slice(0, 3); - break; - - case "hsl": - if (!Array.isArray(color)) throw new TypeError('HSL color must be an array'); - const hsl = [...color]; - this.alpha = hsl.length > 3 ? hsl[3] : 1; - this.hsl = hsl.slice(0, 3); - break; - - case "hex": - if (typeof color !== 'string') throw new TypeError('Hex color must be a string'); - this.hex = color; - break; - - case "copy": - case "cpy": - if (!(color instanceof Color)) throw new TypeError('Copy source must be a Color instance'); - this.#copyFrom(color); - break; + /** + * HSL color values [0-360, 0-100, 0-100]. + * @type {HSLColor} + * @private + */ + #hsl = [0, 0, 0]; - default: - throw new TypeError(`Invalid mode: ${mode}. Valid modes are "rgb", "hsl", "hex", or "copy"`); + /** + * Hexadecimal color representation. + * @type {string} + * @private + */ + #hex = '#000000'; + + /** + * Alpha transparency value (0-1). + * @type {number} + * @private + */ + #alpha = 1; + + /** + * Private constructor (use Color.create() instead). + * @param {RGBColor} rgb - RGB values + * @param {HSLColor} hsl - HSL values + * @param {string} hex - Hex representation + * @param {number} alpha - Alpha value + * @param {symbol} key - Private key to prevent direct instantiation + * @private + * @throws {TypeError} if used directly (use Color.create() instead) + */ + constructor(rgb, hsl, hex, alpha, key) { + if (key !== COLOR_KEY) { // Must not be used by the user + throw new TypeError("Use Color.create() instead of new Color()"); } + this.#rgb = rgb; + this.#hsl = hsl; + this.#hex = hex; + this.#alpha = alpha; + Object.freeze(this); } + // ==================== + // Public API Methods + // ==================== + /** * Mixes two colors with optional weighting and color space * @method @@ -119,7 +120,7 @@ class Color { hueDiff += hueDiff > 0 ? -360 : 360; } - return new Color([ + return Color.create([ (h1 + hueDiff * weight + 360) % 360, // Wrap around 360° s1 + (s2 - s1) * weight, l1 + (l2 - l1) * weight, @@ -129,7 +130,7 @@ class Color { const [r1, g1, b1] = color1.rgb; const [r2, g2, b2] = color2.rgb; - return new Color([ + return Color.create([ r1 + (r2 - r1) * weight, g1 + (g2 - g1) * weight, b1 + (b2 - b1) * weight, @@ -204,182 +205,216 @@ class Color { } /** - * Set color from RGB values - * @method - * @param {Array} rgba - A 3-D array containing the color values [r, g, b] (0-255) - * @returns {this} The current color object - * @throws {TypeError} If color is not a valid array of 3 number values - * @throws {RangeError} If r, g or b values are out of range + * Creates a new color with modified RGB values + * @param {Object} changes - RGB changes + * @param {number} [changes.r] - Red component (0-255) + * @param {number} [changes.g] - Green component (0-255) + * @param {number} [changes.b] - Blue component (0-255) + * @returns {Color} New color instance */ - set rgb(color) { - if (!(Array.isArray(color) && color.length === 3)) - throw new TypeError(`Invalid rgb color format: ${color}`); - - validateNumber(color[0], "Red component", { start: 0, end: 255 }); - validateNumber(color[1], "Green component", { start: 0, end: 255 }); - validateNumber(color[2], "Blue component", { start: 0, end: 255 }); - - this.#rgb = [ - Math.round(color[0]), - Math.round(color[1]), - Math.round(color[2]), - ]; - this.#updated = false; - return this; + withRGB({ r = this.#rgb[0], g = this.#rgb[1], b = this.#rgb[2] } = {}) { + return Color.create({ rgb: [r, g, b], alpha: this.#alpha }); } /** - * Sets color from HSL values - * @method - * @param {Array} hsl - A 3-D array containing the hue values of the color [hue(any number will wrap down to 0-360), saturation(0-100), lightness(0-100)] - * @returns {this} The current color object - * @throws {TypeError} If color is not a valid array of 3 number values - * @throws {RangeError} If saturation or lightness are out of bounds (0-100) + * Creates a new color with modified HSL values + * @param {Object} changes - HSL changes + * @param {number} [changes.h] - Hue (0-360) + * @param {number} [changes.s] - Saturation (0-100) + * @param {number} [changes.l] - Lightness (0-100) + * @returns {Color} New color instance */ - set hsl(color) { - if (!(Array.isArray(color) && color.length === 3)) - throw new TypeError(`Invalid hsl color format: ${color}`); - - validateNumber(color[0], "Hue"); - validateNumber(color[1], "Saturation", { start: 0, end: 100 }); - validateNumber(color[2], "Lightness", { start: 0, end: 100 }); + withHSL({ h = this.#hsl[0], s = this.#hsl[1], l = this.#hsl[2] } = {}) { + return Color.create({ hsl: [h, s, l], alpha: this.#alpha }); + } - this.#rgb = [...hslToRgb(color[0], color[1], color[2])].map(v => Math.round(v)); - this.#updated = false; - return this; + /** + * 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.create({ rgb: this.#rgb, alpha }); } /** - * Set color from hex string - * @method - * @param {string} color - Hex string, Supported formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA - * @returns {this} The current color object - * @throws {TypeError} If color is not a supported hex format + * Mixes two colors + * @param {Color} color - Color to mix with + * @param {number} [weight=0.5] - Mixing ratio (0-1) + * @param {'rgb'|'hsl'} [mode='rgb'] - Mixing mode + * @returns {Color} New mixed color + * @throws {TypeError} If color is not a Color instance */ - set hex(color) { - if (!/^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i.test(color)) { - throw new TypeError(`Invalid hex color format: ${color}`); + mix(color, weight = 0.5, mode = 'rgb') { + if (!(color instanceof Color)) { + throw new TypeError("color must be instance of Color class"); } - let hexDigits = color.slice(1); - const isShorthand = hexDigits.length <= 4; + weight = Math.min(1, Math.max(0, weight)); + const [a1, a2] = [this.#alpha, color.#alpha]; - if (isShorthand) { - hexDigits = Array.from(hexDigits).map(c => c + c).join(''); + switch (mode) { + case 'hsl': + const [h1, s1, l1] = this.#hsl; + const [h2, s2, l2] = color.#hsl; + let hueDiff = h2 - h1; + if (Math.abs(hueDiff) > 180) { + hueDiff += hueDiff > 0 ? -360 : 360; + } + return Color.create({ + hsl: [ + (h1 + hueDiff * weight + 360) % 360, + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight + ], + alpha: a1 + (a2 - a1) * weight + }); + + case 'rgb': + const [r1, g1, b1] = this.#rgb; + const [r2, g2, b2] = color.#rgb; + return Color.create({ + rgb: [ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight + ], + alpha: a1 + (a2 - a1) * weight + }); + + default: + throw new TypeError(`Invalid mixing mode: ${mode}`); } + } - this.rgb = [ - parseInt(hexDigits.substring(0, 2), 16), - parseInt(hexDigits.substring(2, 4), 16), - parseInt(hexDigits.substring(4, 6), 16), - ]; - this.alpha = hexDigits.length > 6 ? parseInt(hexDigits.substring(6, 8), 16) / 255 : 1; + // ==================== + // Getters + // ==================== - this.#updated = false; - return this; - } + /** @returns {Array} RGB values [r, g, b] (0-255) */ + get rgb() { return [...this.#rgb]; } - /** - * Sets the alpha value of the color - * @method - * @param {number} alpha - the alpha value (0.0-1.0) - * @returns {Color} The current color object - * @throws {TypeError} If alpha is not a number - * @throws {RangeError} If alpha is not in range (0.0-1.0) - */ - set alpha(alpha) { - validateNumber(alpha, "Alpha", { start: 0, end: 1 }); - this.#alpha = alpha; - this.#updated = false; - return this; - } + /** @returns {Array} HSL values [h, s, l] (h: 0-360, s/l: 0-100) */ + get hsl() { return [...this.#hsl]; } - /** - * Retrieves RGB values into an array - * @method - * @returns {Array} A 3-D array containing the rgb values of the color [r, g, b] (r/g/b: 0-255); - */ - get rgb() { - this.#updateHex(); - return [...this.#rgb]; - } + /** @returns {string} Hex color string */ + get hex() { return this.#hex; } + + /** @returns {number} Alpha value (0.0-1.0) */ + get alpha() { return this.#alpha; } + + /** @returns {string} Hex representation */ + toString() { return this.#hex; } + + + // ==================== + // Static Methods + // ==================== /** - * Retrieves Hex values into a string, ex. "#ff0000" + * Creates a Color instance from various formats, or returns cached instance. * @method - * @returns {string} A hex string representing the color [6 digits if no transparency, 8 digits otherwise] + * @static + * @param {Object} config - Configuration object + * @param {RGBColor} [config.rgb] - RGB values (0-255) + * @param {HSLColor} [config.hsl] - HSL values (h:0-360, s/l:0-100) + * @param {string} [config.hex] - Hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) + * @param {number} [config.alpha=1] - Alpha value (0.0-1.0) + * @returns {Color} Color instance + * @throws {TypeError} If input format is invalid + * @throws {RangeError} If values are out of bounds */ - get hex() { - this.#updateHex(); - return this.#hex; + static create({ rgb, hsl, hex, alpha = 1 } = {}) { + if ([rgb, hsl, hex].filter(Boolean).length !== 1) { + throw new TypeError("Specify exactly one of: rgb, hsl, hex"); + } + + let key, finalRGB, finalHSL, finalHEX, finalAlpha; + + if (rgb !== undefined) { + validateRGB(rgb); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + finalRGB = rgb.map(v => Math.round(v)); + + key = finalHEX = toHex(finalRGB, alpha); + + if (Color.#colorMemory.has(key)) + return Color.#colorMemory.get(key); // Return cached instance + + finalHSL = rgbToHsl(...finalRGB); + finalAlpha = alpha; + } + else if (hsl !== undefined) { + validateHSL(hsl); + validateNumber(alpha, "Alpha", { start: 0, end: 1 }); + finalHSL = hsl.map(v => Math.round(v)); + finalRGB = hslToRgb(...finalHSL); + + key = finalHEX = toHex(finalRGB, alpha); + + if (Color.#colorMemory.has(key)) + return Color.#colorMemory.get(key); // Return cached instance + + finalAlpha = alpha; + } + else if (hex !== undefined) { + const parsed = parseHex(hex); + finalHEX = toHex(parsed.rgb, parsed.alpha); + + key = finalHEX; + + if (Color.#colorMemory.has(key)) + return Color.#colorMemory.get(key); // Return cached instance + + finalRGB = parsed.rgb; + finalHSL = rgbToHsl(...finalRGB); + finalAlpha = parsed.alpha; + } else { + throw new TypeError('Color must be initialized with rgb, hsl, hex, or another Color instance'); + } + + const color = new Color(finalRGB, finalHSL, finalHEX, finalAlpha, COLOR_KEY); + Color.#colorMemory.set(key, color); + return color; } + /** - * Retrieves HSL values into an array - * @method - * @returns {Array} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + * Predefined transparent color instance. + * @type {Color} + * @static */ - get hsl() { - this.#updateHex(); - const [h, s, l] = rgbToHsl(...this.#rgb); - return [h, s, l]; - } + static TRANSPARENT = this.create({ rgb: [0, 0, 0], alpha: 0 }); /** - * Retrieves alpha channel value - * @method - * @returns {number} A 3-D array containing the hsl values of the color [h, s, l] (h: 0-360, s/l: 0-100) + * Clears the color cache, forcing new instances to be created + * @static */ - get alpha() { - this.#updateHex(); - return this.#alpha; - } - - toString() { - return this.hex; - } + static clearCache() { + this.#colorMemory.clear(); - // Named Colors (static) - static get RED() { return new Color('#FF0000', 'hex'); } - static get TRANSPARENT() { return new Color([0, 0, 0, 0], 'rgb'); } - - // Private methods --------------------------------------------------- - - #updateHex() { - if (this.#updated) { return } - this.#updated = true; - const [r, g, b] = this.#rgb, a = this.#alpha; - const components = [ - Math.round(r), - Math.round(g), - Math.round(b) - ]; - - this.#hex = `#${components.map(c => - c.toString(16).padStart(2, '0') - ).join('')}${a < 1 ? Math.round(a * 255).toString(16).padStart(2, '0') : '' - }`; + this.TRANSPARENT = this.create({ rgb: [0, 0, 0], alpha: 0 }); } - #detectInputType(color) { - if (color instanceof Color) return "copy"; - if (typeof color === 'string') return "hex"; - if (Array.isArray(color)) { - throw new TypeError( - 'Array input requires explicit mode ("rgb" or "hsl").' - ); - } - throw new TypeError('Unable to detect color format. Please specify mode.'); - } - - #copyFrom(color) { - this.#rgb = [...color.#rgb]; - this.#hex = color.#hex; - this.#alpha = color.#alpha; - this.#updated = color.#updated; + /** + * Gets the current size of the color cache (for testing/debugging) + * @static + * @returns {number} Number of cached colors + */ + static get cacheSize() { + return this.#colorMemory.size; } } +/** + * Converts RGB to HSL color space. + * @param {number} r - Red (0-255) + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {HSLColor} HSL values + * @private + */ function rgbToHsl(r, g, b) { [r, g, b] = [r / 255, g / 255, b / 255]; const max = Math.max(r, g, b); @@ -387,11 +422,10 @@ function rgbToHsl(r, g, b) { let h, s, l = (max + min) / 2; if (max === min) { - h = s = 0; // achromatic + 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; @@ -400,9 +434,21 @@ function rgbToHsl(r, g, b) { h *= 60; } - return [Math.round(h * 100) / 100, Math.round(s * 10000) / 100, Math.round(l * 10000) / 100]; + return [ + Math.round(h * 100) / 100, + Math.round(s * 10000) / 100, + Math.round(l * 10000) / 100 + ]; } +/** + * Converts HSL to RGB color space. + * @param {number} h - Hue (0-360) + * @param {number} s - Saturation (0-100) + * @param {number} l - Lightness (0-100) + * @returns {RGBColor} RGB values + * @private + */ function hslToRgb(h, s, l) { h = h % 360 / 360; s = Math.min(100, Math.max(0, s)) / 100; @@ -416,22 +462,108 @@ function hslToRgb(h, s, l) { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; - let res; - if (t < 1 / 6) res = p + (q - p) * 6 * t; - else if (t < 1 / 2) res = q; - else if (t < 2 / 3) res = p + (q - p) * (2 / 3 - t) * 6; - else res = p; - return Math.round(res * 255); + 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 = hue2rgb(p, q, h + 1 / 3); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 1 / 3); + 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 {Array} 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 {Array} 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: RGBColor, 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/tests/color.test.js b/tests/color.test.js index 5dd796d..36313e3 100644 --- a/tests/color.test.js +++ b/tests/color.test.js @@ -1,442 +1,208 @@ import Color from '../scripts/color.js'; describe('Color Class', () => { - describe('Color Creation', () => { - describe('Hex mode Initialization', () => { - describe('Valid Formats', () => { - test.each` - input | mode | hex | rgb | alpha | description - ${'#ff0000'} | ${'hex'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hex mode'} - ${'#ff0000aa'} | ${'hex'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hex mode (with alpha)'} - ${[255, 0, 0]} | ${'rgb'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'rgb mode'} - ${[255, 0, 0, 170 / 255]} | ${'rgb'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'rgb mode (with alpha)'} - ${[0, 100, 50]} | ${'hsl'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'hsl mode'} - ${[0, 100, 50, 170 / 255]} | ${'hsl'} | ${'#ff0000aa'} | ${[255, 0, 0]} | ${170 / 255} | ${'hsl mode (with alpha)'} - ${new Color('#f00')} | ${'copy'} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'copy mode'} - ${'#ff0000'} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'auto-detected hex strings'} - ${new Color('#f00')} | ${null} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'auto-detected Color instances'} - `('should create color object using $description', ({ input, mode, hex, rgb, alpha }) => { - const color = new Color(input, mode); - expect(color.hex).toBe(hex); - expect(color.rgb).toEqual(rgb); - expect(color.alpha).toBe(alpha); - }); + + describe('Constructor Restriction', () => { + test('should throw when using new Color() directly', () => { + expect(() => new Color()).toThrow('Use Color.create() instead'); }); + }); - describe('Invalid Formats', () => { - test.each` - input | mode | errorType | description - ${'non-color'} | ${'hex'} | ${TypeError} | ${'not a color (doesn\'t match hex mode)'} - ${'non-color'} | ${'rgb'} | ${TypeError} | ${'not a color (doesn\'t match rgb mode)'} - ${'non-color'} | ${'hsl'} | ${TypeError} | ${'not a color (doesn\'t match hsl mode)'} - ${'non-color'} | ${'copy'} | ${TypeError} | ${'not a color (doesn\'t match copy mode)'} - ${'#feafea'} | ${'chicken'} | ${TypeError} | ${'invalid mode'} - ${[255, 0, 0]} | ${null} | ${TypeError} | ${'arrays require explicit mode'} - `('throws $errorType.name when $description', ({ input, mode, errorType }) => { - expect(() => new Color(input, mode)).toThrow(errorType); - }); + 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 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 + }); + }); + + 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); }); }); }); - describe('Color Manipulation', () => { + describe('Immutable Operations', () => { let color; beforeEach(() => { - color = new Color(); // red [255, 0, 0, 1] + color = Color.create({ hex: '#ff0000' }); }); - describe('Set Alpha', () => { - describe('Valid Formats', () => { - test.each` - input | alpha | description - ${1} | ${1} | ${'opaque'} - ${0} | ${0} | ${'transparent'} - ${0.5} | ${0.5} | ${'half'} - ${0.004} | ${0.004} | ${'minimum non-zero(1/255))'} - ${0.996} | ${0.996} | ${'maximum non-one(254/255)'} - ${0.123456789} | ${0.123456789} | ${'floating point(precision test)'} - `('should set color alpha channel to $description opacity', ({ input, alpha }) => { - color.alpha = input; - expect(color.alpha).toBe(alpha); - }); - }); - - describe('Invalid Formats', () => { - test.each` - input | errorType | description - ${[]} | ${TypeError} | ${'non-number'} - ${"add"} | ${TypeError} | ${'non-number'} - ${"0.5"} | ${TypeError} | ${'non-number'} - ${-0.6} | ${RangeError} | ${'less than 0'} - ${1.5} | ${RangeError} | ${'higher than 1'} - `('throws $errorType.name when $description', ({ input, errorType }) => { - expect(() => color.alpha = input).toThrow(errorType); - }); + 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 }); - describe('Hex Representation', () => { - test.each` - alpha | expectedSuffix - ${0} | ${'00'} - ${0.5} | ${'80'} - ${1} | ${''} - `('alpha $alpha becomes "$expectedSuffix" in hex', ({ alpha, expectedSuffix }) => { - color.alpha = alpha; - expect(color.hex.slice(7)).toBe(`${expectedSuffix}`); - }); + test('should maintain alpha', () => { + const transparentRed = Color.create({ hex: '#ff000080' }); + const newColor = transparentRed.withRGB({ g: 255 }); + expect(newColor.hex).toBe('#ffff0080'); }); }); - describe('Set Hex', () => { - describe('Valid Formats', () => { - test.each` - input | hex | rgb | alpha | description - ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'lowercase'} - ${'#FF00AA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'uppercase'} - ${'#Ff00aA'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'lowercase and uppercase mix'} - ${'#ff00aa'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'6-char'} - ${'#ff00aabb'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'8-char (with alpha)'} - ${'#f0a'} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} | ${'3-char shorthand'} - ${'#f0ab'} | ${'#ff00aabb'} | ${[255, 0, 170]} | ${0.733} | ${'4-char shorthand (alpha)'} - ${'#000'} | ${'#000000'} | ${[0, 0, 0]} | ${1} | ${'black shorthand'} - ${'#ffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'white full'} - ${'#00000000'} | ${'#00000000'} | ${[0, 0, 0]} | ${0} | ${'all zeros with alpha'} - ${'#ffffffff'} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'all Fs with alpha'} - ${'#123'} | ${'#112233'} | ${[17, 34, 51]} | ${1} | ${'numeric shorthand'} - `('should set color value using $description hex string', ({ input, hex, rgb, alpha }) => { - color.hex = input; - expect(color.hex).toBe(hex); - expect(color.rgb).toEqual(rgb); - expect(color.alpha).toBeCloseTo(alpha, 2); - }); - }); - - describe('Invalid Formats', () => { - test.each` - input | errorType | description - ${'ff0000'} | ${TypeError} | ${'missing #'} - ${'#g00000'} | ${TypeError} | ${'invalid char'} - ${'#1'} | ${TypeError} | ${'short invalid'} - ${'#123456789'} | ${TypeError} | ${'long invalid'} - `('throws $errorType.name when $description', ({ input, errorType }) => { - expect(() => color.hex = input).toThrow( - errorType, - `Invalid hex color format: ${input}` // Verify exact message - ); - }); - }); - - describe('Format Preservation', () => { - test('should store hex in lowercase', () => { - color.hex = '#FF00AA'; - expect(color.hex).not.toBe('#FF00AA'); // Should be lowercase - expect(color.hex).toBe('#ff00aa'); - }); - }); - - describe('Alpha Modification', () => { - test('should reset alpha to 1 when setting hex without alpha', () => { - color.hex = '#12345678'; // Has alpha - color.hex = '#abcdef'; // No alpha - expect(color.alpha).toBe(1); - }); - - test('should handle 00 alpha', () => { - color.hex = '#12345600'; - expect(color.alpha).toBe(0); - }); + describe('withHSL()', () => { + test('should return new instance with modified HSL', () => { + const newColor = color.withHSL({ h: 120 }); + expect(newColor.hex).toBe('#00ff00'); }); }); - describe('Set RGB', () => { - describe('Valid Formats', () => { - test.each` - description | input | hex | rgb | alpha - ${'3-integer'} | ${[255, 0, 170]} | ${'#ff00aa'} | ${[255, 0, 170]} | ${1} - ${'non-integer (gets rounded)'} | ${[255, 0, 170.9]} | ${'#ff00ab'} | ${[255, 0, 171]} | ${1} - ${'lower bound (black)'} | ${[0, 0, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} - ${'higher bound (white)'} | ${[255, 255, 255]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} - `('should set color value using $description RGB array', ({ _, input, hex, rgb, alpha }) => { - color.rgb = input; - expect(color.hex).toBe(hex); - expect(color.rgb).toEqual(rgb); - expect(color.alpha).toBeCloseTo(alpha, 2); - }); - }); - - describe('Invalid Formats', () => { - test.each` - description | input | errorType - ${'4-or-more-integers'} | ${[255, 0, 170, 6]} | ${TypeError} - ${'2-or-less-integers'} | ${[255]} | ${TypeError} - ${'2-or-less-integers'} | ${[2, 55]} | ${TypeError} - ${'not an array'} | ${'ahmed'} | ${TypeError} - ${'non-number values'} | ${[1, 2, 'a']} | ${TypeError} - ${'values are higher than 255'} | ${[255, 256, 144]} | ${RangeError} - ${'values are less than 0'} | ${[-1, 25, 144]} | ${RangeError} - `('throws $errorType.name when $description', ({ _, input, errorType }) => { - expect(() => color.rgb = input).toThrow( - errorType, - `Invalid rgb color format: ${JSON.stringify(input)}` // Verify exact message - ); - }); - }); - - describe('Alpha Persistance', () => { - test('should preserve existing alpha when setting RGB', () => { - const color = new Color('#ff000080', 'hex'); - expect(color.alpha).toBeCloseTo(0.5, 2); - - color.rgb = [0, 255, 0]; - - expect(color.alpha).toBeCloseTo(0.5, 2); - expect(color.hex).toBe('#00ff0080'); - }); - }); - }); - - - describe('Set HSL', () => { - - describe('Valid Formats', () => { - test.each` - input | hex | rgb | alpha | description - ${[0, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer'} - ${[720, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer (wrapping positive)'} - ${[-360, 100, 50]} | ${'#ff0000'} | ${[255, 0, 0]} | ${1} | ${'3-integer (wrapping negative)'} - ${[120, 100, 25.1]} | ${'#008000'} | ${[0, 128, 0]} | ${1} | ${'non-integer'} - ${[0, 100, 0]} | ${'#000000'} | ${[0, 0, 0]} | ${1} | ${'least lightness (black)'} - ${[55, 100, 100]} | ${'#ffffff'} | ${[255, 255, 255]} | ${1} | ${'highest lightness (white)'} - ${[0, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} | ${'least saturation (gray)'} - ${[55, 100, 50]} | ${'#ffea00'} | ${[255, 234, 0]} | ${1} | ${'highest satuation (red)'} - ${[123, 0, 50]} | ${'#808080'} | ${[128, 128, 128]} | ${1} | ${'0 saturation (any hue)'} - ${[180, 100, 1]} | ${'#000505'} | ${[0, 5, 5]} | ${1} | ${'100 saturation edge'} - `('should set color value using $description HSL array', ({ input, hex, rgb, alpha, _ }) => { - color.hsl = input; - expect(color.hex).toBe(hex); - expect(color.rgb).toEqual(rgb); - expect(color.alpha).toBeCloseTo(alpha, 2); - }); - }); - - describe('Invalid Formats', () => { - test.each` - input | errorType | description - ${[255, 0, 70, 6]} | ${TypeError} | ${'4-or-more-integers'} - ${[255]} | ${TypeError} | ${'2-or-less-integers'} - ${[2, 55]} | ${TypeError} | ${'2-or-less-integers'} - ${'ahmed'} | ${TypeError} | ${'not an array'} - ${[1, 2, 'a']} | ${TypeError} | ${'non-number values'} - ${[255, 26, 150]} | ${RangeError} | ${'lightness value is higher than 100'} - ${[255, 256, 15]} | ${RangeError} | ${'saturation value is higher than 100'} - ${[255, 26, -15]} | ${RangeError} | ${'lightness value is less than 0'} - ${[255, -25, 15]} | ${RangeError} | ${'saturation value is less than 0'} - `('throws $errorType.name when $description', ({ input, errorType }) => { - expect(() => color.hsl = input).toThrow( - errorType, - `Invalid hsl color format: ${JSON.stringify(input)}` // Verify exact message - ); - }); - }); - - describe('Alpha Persistance', () => { - test('should preserve existing alpha when setting RGB', () => { - const color = new Color('#ff000080', 'hex'); - expect(color.alpha).toBeCloseTo(0.5, 2); - - color.rgb = [0, 255, 0]; - - expect(color.alpha).toBeCloseTo(0.5, 2); - expect(color.hex).toBe('#00ff0080'); - }); + 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); }); }); }); describe('Color Analysis', () => { describe('isSimilarTo()', () => { - test('should match identical colors', () => { - const baseColor = new Color('#FF8844CC'); - expect(baseColor.isSimilarTo(baseColor)).toBe(true); - }); - test.each` - inputColor1 | inputColor2 | inputWeight | includeAlpha | result | description - ${'#ff8844cc'} | ${'#ff8845cd'} | ${2} | ${true} | ${'match'} | ${'under tolerance'} - ${'#ff8844cc'} | ${'#ff8846ce'} | ${2} | ${true} | ${'match'} | ${'within tolerance'} - ${'#ff8844cc'} | ${'#ff8847cf'} | ${2} | ${true} | ${'not match'} | ${'over tolerance'} - ${'#ff8844cc'} | ${'#fe8843cf'} | ${2} | ${false} | ${'match'} | ${'alpha is over tolerance while ignoring alpha'} - ${'#ff8844cc'} | ${'#fe8644cc'} | ${1} | ${false} | ${'not match'} | ${'rgb is over tolerance regardless of ignoring alpha'} - `(`should $result if $description`, ({ inputColor1, inputColor2, inputWeight, includeAlpha, result }) => { - expect(new Color(inputColor1).isSimilarTo(new Color(inputColor2), inputWeight, includeAlpha)).toBe(result == 'match'); + 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()', () => { - - const baseColor = new Color('#AABBCCDD'); - - test('should match exact colors', () => { - expect(baseColor.isEqualTo(new Color('#AABBCCDD'))).toBe(true); - expect(baseColor.isEqualTo(baseColor)).toBe(true); - expect(baseColor.isEqualTo(new Color([170, 187, 204, 0.8667], 'rgb'))).toBe(true); - }); - test.each` - inputColor1 | inputColor2 | includeAlpha | result | description - ${'#aabbccdd'} | ${'#aabbcc'} | ${true} | ${'not match'} | ${'alpha is not equal while not egnored'} - ${'#aabbccdd'} | ${'#aabbccde'} | ${true} | ${'not match'} | ${'alpha is not equal while not egnored'} - ${'#aabdcc'} | ${'#aabbcc'} | ${true} | ${'not match'} | ${'rgb is not equal'} - ${'#aabbccdd'} | ${'#aabbccdd'} | ${true} | ${'match'} | ${'alpha is equal and rgb is equal'} - ${'#aabbccfd'} | ${'#aabbccdd'} | ${false} | ${'match'} | ${'alpha is not equal while egnored'} - ` (`should $result if $description`, ({ _, inputColor1, inputColor2, includeAlpha, result }) => { - expect(new Color(inputColor1).isEqualTo(new Color(inputColor2), includeAlpha)).toBe(result == 'match'); - }); - }); - - describe('Color Comparison Methods', () => { - describe('Comparison Edge Cases', () => { - test('should handle near-boundary values', () => { - const color1 = new Color('#FFFFFF'); - const color2 = new Color('#FFFFFE'); - expect(color1.isSimilarTo(color2, 1.8)).toBe(true); - expect(color1.isEqualTo(color2)).toBe(false); - }); - - test('should throw on invalid inputs', () => { - const color = new Color('#FFF', 'hex'); - expect(() => color.isSimilarTo('invalid')).toThrow(TypeError); - expect(() => color.isEqualTo(null)).toThrow(TypeError); - }); + 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); }); }); }); describe('Color Operations', () => { + describe('mix()', () => { + const red = Color.create({ hex: '#ff0000' }); + const blue = Color.create({ hex: '#0000ff' }); - describe('mix() Method', () => { - const red = new Color('#FF0000', 'hex'); - const blue = new Color('#0000FF', 'hex'); - const semiWhite = new Color([255, 255, 255, 0.5], 'rgb'); - - test('should mix RGB colors', () => { - // Equal mix - const purple = red.mix(blue); + test('should mix colors in RGB space', () => { + const purple = red.mix(blue, 0.5, 'rgb'); expect(purple.hex).toBe('#800080'); - - // Weighted mix - const reddishPurple = red.mix(blue, 0.25); - expect(reddishPurple.hex).toBe('#bf0040'); }); - test('should mix HSL colors', () => { - // Hue blending (red + blue in HSL = magenta) + test('should mix colors in HSL space', () => { const magenta = red.mix(blue, 0.5, 'hsl'); expect(magenta.hex).toBe('#ff00ff'); - - // Lightness blending - const darkRed = new Color('#ff0000', 'hex'); // hsl(0, 100%, 50%) - const lightRed = new Color('#ffcccc', 'hex'); // hsl(0, 100%, 90%) - const pink = darkRed.mix(lightRed, 0.5, 'hsl'); - expect(pink.hsl[2]).toBeCloseTo(70); // 70% lightness - }); - - test('should handle alpha channels', () => { - // Mixing transparency - const mixed = red.mix(semiWhite); - expect(mixed.alpha).toBeCloseTo(0.75); - expect(mixed.hex).toBe('#ff8080bf'); }); - test('should handle edge cases', () => { - // Extreme weights - expect(red.mix(blue, 0).hex).toBe('#ff0000'); - expect(red.mix(blue, 1).hex).toBe('#0000ff'); - - // Hue wrapping (350° + 20° → 5°) - const crimson = new Color([350, 100, 50], "hsl"); - const orange = new Color([20, 100, 50], "hsl"); - const blended = crimson.mix(orange, 0.5, 'hsl'); - expect(blended.hsl).toEqual([4.94, 100, 50]); - }); - - test('mix() should throw on invalid color', () => { - const color = new Color('#FF0000'); - expect(() => color.mix('invalid')).toThrow(); - }); - - test('mix() should throw on invalid mode', () => { - const color = new Color('#FF0000'); - expect(() => color.mix(new Color('#00FF00'), 0.5, 'lab')).toThrow('Invalid mode'); + test('should mix alpha channels', () => { + const semiRed = Color.create({ hex: '#ff000080' }); + const mixed = semiRed.mix(blue, 0.5); + expect(mixed.alpha).toBeCloseTo(0.5 * (0.5 + 1)); }); }); }); - describe('Color Conversion', () => { - test('should maintain consistency between hex and rgba', () => { - const testCases = [ - '#FF0000', - '#00FF0080', - '#0000FF', - '#ABCDEF99' - ]; - - testCases.forEach(hex => { - const color = new Color(hex, 'hex'); - const fromRgba = new Color([...color.rgb, color.alpha], 'rgb'); - expect(fromRgba.hex).toBe(color.hex); - }); + describe('Static Colors', () => { + test('TRANSPARENT should have alpha 0', () => { + expect(Color.TRANSPARENT.alpha).toBe(0); }); - test('should convert RGB to HSL', () => { - const red = new Color('#FF0000', 'hex'); - expect(red.hsl).toEqual([0, 100, 50]); - expect(red.alpha).toEqual(1); + 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 + }); - const teal = new Color('#008080', 'hex'); - expect(teal.hsl).toEqual([180, 100, 25.1]); - expect(red.alpha).toEqual(1); + 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 }); + }); - test('should convert HSL to RGB', () => { - const gold = new Color(); - gold.hsl = [45, 100, 50]; - expect(gold.hex).toBe('#ffbf00'); + describe('Color Cache', () => { + beforeEach(() => { + Color.clearCache(); // Ensure clean state for each test + }); - const semiPurple = new Color(); - semiPurple.hsl = [270, 60, 70]; - semiPurple.alpha = 0.5; - expect(semiPurple.hex).toBe('#b285e080'); + 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('should handle edge cases (black and white)', () => { - const black = new Color([0, 0, 0], 'rgb'); - expect(black.hex).toBe('#000000'); + 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 white = new Color([255, 255, 255, 0], 'rgb'); - expect(white.hex).toBe('#ffffff00'); + const color2 = Color.create({ rgb: [255, 0, 0] }); + expect(color1).not.toBe(color2); // Different instances + expect(Color.cacheSize).toBe(2); }); - test('should handle edge cases (0-saturation and hue wrapping)', () => { - // Achromatic (gray) - const gray = new Color(); - gray.hsl = [0, 0, 50]; - expect(gray.hex).toBe('#808080'); + test('cache should handle different color spaces', () => { + const color1 = Color.create({ rgb: [255, 0, 0] }); + const color2 = Color.create({ hsl: [0, 100, 50] }); + const color3 = Color.create({ hex: '#ff0000' }); - // Hue wrapping - const wrappedHue = new Color(); - wrappedHue.hsl = [540, 100, 50]; // 540° = 180° - expect(wrappedHue.hex).toBe('#00ffff'); + expect(color1).toBe(color2); + expect(color2).toBe(color3); + expect(Color.cacheSize).toBe(2); }); - }); - describe('Static Presets', () => { - test('should have static color presets', () => { - expect(Color.RED.hex).toBe('#ff0000'); - expect(Color.TRANSPARENT.alpha).toBe(0); - expect(Color.RED instanceof Color).toBe(true); + 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); }); }); }); From c80661c302578fb25bd932c090cf378d063f47cf Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 19 Apr 2025 10:29:01 +0200 Subject: [PATCH 12/29] feat: integrate ColorClass and DirtyRectangle into CanvasGrid class - Added Color Class and DirtyRectangle as a composed dependancy in CanvasGrid - Modified initializeBlankCanvas, loadImage and setColor to utilize the new Color Class integration - Modified class constructor, initializeBlankCanvas and setColor methods to utilize the DirtyRectangle Class integration - Maintained existing pixel matrix API refactor: rename lastChange property and methods to ChangeBuffer - Added ColorClass as a composed dependancy in CanvasGrid - Modified initializeBlankCanvas, loadImage and setColor to utilize the new integeration - Maintained existing pixel matrix API doc: complete documentation for CanvasGrid class and add markdown jsdocs file test: update test suite for modified CanvasGrid API --- docs/canvas-grid.md | 173 +++++++++++++ scripts/canvas-grid.js | 160 +++++++----- tests/canvas-grid.test.js | 526 +++++++++----------------------------- 3 files changed, 386 insertions(+), 473 deletions(-) create mode 100644 docs/canvas-grid.md diff --git a/docs/canvas-grid.md b/docs/canvas-grid.md new file mode 100644 index 0000000..276b858 --- /dev/null +++ b/docs/canvas-grid.md @@ -0,0 +1,173 @@ +## Classes + +
+
CanvasGrid
+

Represents a canvas grid system

+
+
+ +## Typedefs + +
+
Pixel
+
+
+ + + +## CanvasGrid +Represents a canvas grid system + +**Kind**: global class + +* [CanvasGrid](#CanvasGrid) + * [new CanvasGrid([width], [height])](#new_CanvasGrid_new) + * [.initializeBlankCanvas(width, height)](#CanvasGrid+initializeBlankCanvas) + * [.loadImage(imageData, [x], [y])](#CanvasGrid+loadImage) + * [.resetChangeBuffer()](#CanvasGrid+resetChangeBuffer) ⇒ DirtyRectangle + * [.setColor(x, y, color, options)](#CanvasGrid+setColor) + * [.get(x, y)](#CanvasGrid+get) ⇒ [Pixel](#Pixel) + * [.getColor(x, y)](#CanvasGrid+getColor) ⇒ Color + * [.changeBuffer()](#CanvasGrid+changeBuffer) ⇒ DirtyRectangle + * [.width()](#CanvasGrid+width) ⇒ number + * [.height()](#CanvasGrid+height) ⇒ number + + + +### new CanvasGrid([width], [height]) +Creates a blank canvas with specified width and height + +**Throws**: + +- TypeError If width or height are not integers +- RangeError If width or height are not between 1 and 1024 inclusive + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [width] | number | 1 | The width of the grid | +| [height] | number | 1 | The height of the grid | + + + +### canvasGrid.initializeBlankCanvas(width, height) +Initializes the canvas with a blank grid of transparent pixel data + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Throws**: + +- TypeError If width or height are not integers +- RangeError If width or height are not between 1 and 1024 inclusive + + +| Param | Type | Description | +| --- | --- | --- | +| width | number | The width of the grid | +| height | number | The height of the grid | + + + +### canvasGrid.loadImage(imageData, [x], [y]) +Loads an image data at (x, y) position + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Throws**: + +- TypeError if x or y are not integers +- TypeError if imageData is not instance of class ImageData + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| imageData | ImageData | | The image to be loaded | +| [x] | number | 0 | X-coordinate | +| [y] | number | 0 | Y-coordinate | + + + +### canvasGrid.resetChangeBuffer() ⇒ DirtyRectangle +Resets changes buffer to be empty + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: DirtyRectangle - Change buffer before emptying + + +### canvasGrid.setColor(x, y, color, options) +Sets color to pixel at position (x, y). + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Throws**: + +- TypeError if validate is true and if color is not a valid Color object +- TypeError if validate is true and if x and y are not valid integers in valid range. +- RangeError if validate is true and if x and y are not in valid range. + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| x | number | | X-coordinate. | +| y | number | | X-coordinate. | +| color | Color | | The Color object to be set | +| options | Object | | An object containing additional options. | +| [options.quietly] | boolean | false | If set to true, the pixel data at which color changed will not be pushed to the changeBuffers array. | +| [options.validate] | boolean | true | If set to true, the x, y, and color types are validated. | + + + +### canvasGrid.get(x, y) ⇒ [Pixel](#Pixel) +Returns pixel data at position (x, y) + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: [Pixel](#Pixel) - Pixel data at position (x, y) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate | +| y | number | Y-coordinate | + + + +### canvasGrid.getColor(x, y) ⇒ Color +Returns pixel color at position (x, y) + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: Color - Color object of pixel at position (x, y) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate | +| y | number | Y-coordinate | + + + +### canvasGrid.changeBuffer() ⇒ DirtyRectangle +Returns copy of change buffer + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: DirtyRectangle - Copy of change buffer + + +### canvasGrid.width() ⇒ number +Returns the width of the canvas + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: number - The width of the canvas + + +### canvasGrid.height() ⇒ number +Returns the height of the canvas + +**Kind**: instance method of [CanvasGrid](#CanvasGrid) +**Returns**: number - The height of the canvas + + +## Pixel +**Kind**: global typedef +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate | +| y | number | Y-coordinate | +| color | Color | Color of the pixel | + diff --git a/scripts/canvas-grid.js b/scripts/canvas-grid.js index b1449de..0fa1d99 100644 --- a/scripts/canvas-grid.js +++ b/scripts/canvas-grid.js @@ -1,21 +1,51 @@ -import { validateNumber, validateColorArray } from "./validation.js"; +import { validateNumber } from "./validation.js"; +import DirtyRectangle from "./dirty-rectangle.js"; +import Color from "./color.js"; /** * Represents a canvas grid system * @class */ class CanvasGrid { + + /** + * The width of the canvas + * @type {number} + */ #width; + + /** + * The height of the canvas + * @type {number} + */ #height; + + /** + * @typedef Pixel + * @property {number} x - X-coordinate + * @property {number} y - Y-coordinate + * @property {Color} color - Color of the pixel + */ + + /** + * The 2-D grid containing the Pixel data of the canvas + * @type {Pixel[][]} + */ #pixelMatrix; - #lastActions; + + /** + * Buffer logs changes performed on pixels (Ex. color change) + * @type {DirtyRectangle} + */ + #changeBuffer = new DirtyRectangle(); /** * 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 + * @param {number} [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) { validateNumber(width, "Width", { @@ -23,14 +53,16 @@ class CanvasGrid { end: 1024, integerOnly: true }); + validateNumber(height, "Height", { start: 1, end: 1024, integerOnly: true }); + this.#width = width; this.#height = height; - this.#lastActions = []; + this.#changeBuffer = new DirtyRectangle(); this.initializeBlankCanvas(width, height); } @@ -39,23 +71,24 @@ class CanvasGrid { * @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 + * @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}); + 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 = []; + this.#pixelMatrix = new Array(height); for (let i = 0; i < this.#height; i++) { - this.#pixelMatrix.push([]); + this.#pixelMatrix[i] = new Array(width); for (let j = 0; j < this.#width; j++) { - this.#pixelMatrix[i].push({ + this.#pixelMatrix[i][j] = { x: j, y: i, - color: [0, 0, 0, 0], - }); + color: Color.TRANSPARENT, + }; } } } @@ -63,15 +96,15 @@ class CanvasGrid { /** * 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 + * @param {ImageData} imageData - The image to be loaded + * @param {number} [x=0] - X-coordinate + * @param {number} [y=0] - Y-coordinate + * @throws {TypeError} if x or y are not integers + * @throws {TypeError} if imageData is not instance of class ImageData */ loadImage(imageData, x = 0, y = 0) { - validateNumber(x, "x", {integerOnly: true}); - validateNumber(y, "y", {integerOnly: true}); + validateNumber(x, "x", { integerOnly: true }); + validateNumber(y, "y", { integerOnly: true }); if (imageData === undefined || !(imageData instanceof ImageData)) throw new TypeError( @@ -92,57 +125,54 @@ class CanvasGrid { ) { 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]); + 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(j, i, [red, green, blue, alpha]); + this.setColor(j, i, Color.create({rgb: [red, green, blue], alpha: alpha / 255})); } } } /** - * Resets last taken actions array to be empty + * Resets changes buffer to be empty * @method - * @returns last taken actions + * @returns {DirtyRectangle} Change buffer before emptying */ - resetLastActions() { - let lastActions = this.#lastActions; - this.#lastActions = []; - return lastActions; + resetChangeBuffer() { + let changeBuffer = this.#changeBuffer; + this.#changeBuffer = new DirtyRectangle(); + return changeBuffer; } /** * 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 {number} x - X-coordinate. + * @param {number} y - X-coordinate. + * @param {Color} color - The Color object to be set + * @param {Object} options - 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 changeBuffers 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. + * @throws {TypeError} if validate is true and if color is not a valid Color object + * @throws {TypeError} if validate is true and if 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. */ 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); + validateNumber(x, "x", { start: 0, end: this.#width - 1, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.#height - 1, integerOnly: true }); + if (!(color instanceof Color)) { + throw new Error("color must be object of Color class"); + } } - // 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.#changeBuffer.setChange( x, y, + color, + this.#pixelMatrix[y][x].color, + ); } this.#pixelMatrix[y][x].color = color; } @@ -150,13 +180,13 @@ class CanvasGrid { /** * Returns pixel data at position (x, y) * @method - * @param {number} x - * @param {number} y - * @returns {Object} + * @param {number} x - X-coordinate + * @param {number} y - Y-coordinate + * @returns {Pixel} Pixel data at position (x, y) */ 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}); + 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]; } @@ -164,21 +194,21 @@ class CanvasGrid { /** * 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] + * @param {number} x - X-coordinate + * @param {number} y - Y-coordinate + * @returns {Color} Color object of pixel at position (x, y) */ getColor(x, y) { return this.get(x, y).color; } /** - * Returns last edited pixel positions with the new colors in an array + * Returns copy of change buffer * @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} ...] + * @returns {DirtyRectangle} Copy of change buffer */ - get getLastActions() { - return this.#lastActions; + get changeBuffer() { + return this.#changeBuffer.clone(); } /** @@ -186,7 +216,7 @@ class CanvasGrid { * @method * @returns {number} The width of the canvas */ - get getWidth() { + get width() { return this.#width; } @@ -195,7 +225,7 @@ class CanvasGrid { * @method * @returns {number} The height of the canvas */ - get getHeight() { + get height() { return this.#height; } } diff --git a/tests/canvas-grid.test.js b/tests/canvas-grid.test.js index c00a7aa..ca02ffd 100644 --- a/tests/canvas-grid.test.js +++ b/tests/canvas-grid.test.js @@ -1,427 +1,137 @@ import CanvasGrid from "../scripts/canvas-grid.js"; +import Color from "../scripts/color.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]); - }); + let canvas; + const testColor = Color.create({rgb: [255, 255, 0]}); + + beforeAll(() => { + // Mock ImageData for browser-like environment + 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", () => { + canvas = new CanvasGrid(); + expect(canvas.width).toBe(1); + expect(canvas.height).toBe(1); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); }); - 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], - }, + test.each([ [16, 16], [1024, 1024], [5, 10] + ])("should create %ix%i canvas", (width, height) => { + canvas = new CanvasGrid(width, height); + expect(canvas.width).toBe(width); + expect(canvas.height).toBe(height); + }); - { - 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], - }, + test.each([ + [0, 1], [1.5, 1], [1025, 1], [1, "invalid"] + ])("should reject invalid dimensions %p", (width, height) => { + expect(() => new CanvasGrid(width, height)).toThrow(); + }); + }); - { - 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], - }, + describe("Pixel Operations", () => { + beforeEach(() => { + canvas = new CanvasGrid(16, 16); + }); - { - 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], - }, + test("should set and get pixel colors", () => { + canvas.setColor(5, 5, testColor); + expect(canvas.getColor(5, 5)).toEqual(testColor); + }); - { - 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 - }); - }); + test("should validate coordinates", () => { + expect(() => canvas.getColor(-1, 0)).toThrow("x"); + expect(() => canvas.getColor(16, 0)).toThrow("x"); + expect(() => canvas.getColor(0, -1)).toThrow("y"); + expect(() => canvas.getColor(0, 16)).toThrow("y"); + }); - 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 }); + test("should handle quiet updates", () => { + canvas.setColor(5, 5, testColor, { quietly: true }); + expect(canvas.changeBuffer.isEmpty).toBe(true); + }); + }); - 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 }, - ]); - }); + describe("Change Tracking", () => { + beforeEach(() => { + canvas = new CanvasGrid(16, 16); + }); - test("should handle transparency correctly", () => { - const color = [255, 0, 0, 0]; // Fully transparent - cd.setColor(5, 5, color, { radius: 0 }); + test("should track color changes", () => { + canvas.setColor(0, 0, testColor); + canvas.setColor(1, 1, testColor); + + const changes = canvas.changeBuffer.afterStates; + expect(changes).toHaveLength(2); + expect(changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ x: 0, y: 0 }), + expect.objectContaining({ x: 1, y: 1 }) + ]) + ); + }); - expect(cd.getColor(5, 5)).toStrictEqual([0, 0, 0, 0]); // Should be transparent black - }); + test("should reset change buffer", () => { + canvas.setColor(0, 0, testColor); + const oldBuffer = canvas.resetChangeBuffer(); + + expect(oldBuffer.afterStates).toHaveLength(1); + expect(canvas.changeBuffer.isEmpty).toBe(true); + }); + }); + + 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, 1], 4); + canvas.loadImage(imageData, 0, 0); + + expect(canvas.getColor(0, 0)).toEqual(Color.create({hex: '#f00'})); + expect(canvas.getColor(3, 3)).toEqual(Color.create({hex: '#f00'})); + }); - 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 }); + test("should handle partial out-of-bounds images", () => { + const imageData = createTestImage([0, 255, 0, 128 / 255], 4); + canvas.loadImage(imageData, 14, 14); + + // Should only modify pixels 14-15 in both dimensions + expect(canvas.getColor(14, 14)).toEqual(Color.create({rgb: [0, 255, 0], alpha: 128 / 255})); + expect(canvas.getColor(15, 15)).toEqual(Color.create({rgb: [0, 255, 0], alpha: 128 / 255})); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + }); - expect(cd.getColor(5, 5)).toBe(color); // Color changed - expect(cd.getLastActions).toStrictEqual([]); // No actions should be recorded - }); + describe("Edge Cases", () => { + test("should handle minimum canvas size", () => { + canvas = new CanvasGrid(1, 1); + canvas.setColor(0, 0, testColor); + expect(canvas.getColor(0, 0)).toEqual(testColor); + }); - 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 handle maximum canvas size", () => { + canvas = new CanvasGrid(1024, 1024); + canvas.setColor(1023, 1023, testColor); + expect(canvas.getColor(1023, 1023)).toEqual(testColor); + }); - 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", - ), - ); - }); - }); + test("should reject invalid color types", () => { + canvas = new CanvasGrid(16, 16); + expect(() => canvas.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); }); + }); }); From 56c693c45a11ef5b8b661b9bb5ba15f25cb1a7ef Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 19 Apr 2025 13:44:11 +0200 Subject: [PATCH 13/29] refactor(history): finalize core implementation with fixes and docs - Fixed undo/redo edge cases and addActionGroup buffer management - Refactored circular buffer logic for clarity - Added comprehensive JSDoc with usage examples - Expanded test coverage for: - Buffer wraparound scenarios - Consecutive undo/redo operations - Action data shallow copying - Generated API documentation (jsdoc-to-markdown) --- docs/history-system.md | 148 ++++++++++ scripts/history-system.js | 160 ++++++----- tests/history-system.test.js | 506 ++++++++++++++--------------------- 3 files changed, 439 insertions(+), 375 deletions(-) create mode 100644 docs/history-system.md diff --git a/docs/history-system.md b/docs/history-system.md new file mode 100644 index 0000000..66fd356 --- /dev/null +++ b/docs/history-system.md @@ -0,0 +1,148 @@ + + +## HistorySystem +Represents a circular buffer-based history system for undo/redo operations. +Tracks action groups containing arbitrary action data with shallow copying. + +Key Features: +- Fixed-capacity circular buffer (1-64 actions) +- Atomic action grouping +- Shallow copy data storage +- Undo/redo functionality +- Action metadata (names/IDs) + +**Kind**: global class + +* [HistorySystem](#HistorySystem) + * [new HistorySystem(capacity)](#new_HistorySystem_new) + * [.getBufferSize](#HistorySystem+getBufferSize) : number + * [.getBufferCapacity](#HistorySystem+getBufferCapacity) : number + * [.addActionGroup([actionGroupName])](#HistorySystem+addActionGroup) + * [.addActionData(actionDataObject)](#HistorySystem+addActionData) + * [.getActionGroupID([offset])](#HistorySystem+getActionGroupID) ⇒ number + * [.getActionGroupName([offset])](#HistorySystem+getActionGroupName) ⇒ string \| number + * [.getActionData([offset])](#HistorySystem+getActionData) ⇒ Array \| number + * [.undo()](#HistorySystem+undo) ⇒ number + * [.redo()](#HistorySystem+redo) ⇒ number + + + +### new HistorySystem(capacity) +Creates a new HistorySystem with specified capacity + +**Throws**: + +- TypeError If capacity is not an integer +- RangeError If capacity is outside 1-64 range + + +| Param | Type | Description | +| --- | --- | --- | +| capacity | number | Maximum stored action groups (1-64) | + +**Example** +```js +const history = new HistorySystem(10); +history.addActionGroup("Paint"); +history.addActionData({x: 1, y: 2, color: "#FF0000"}); +history.undo(); // Reverts to previous state +``` + + +### historySystem.getBufferSize : number +Current number of stored action groups + +**Kind**: instance property of [HistorySystem](#HistorySystem) +**Read only**: true + + +### historySystem.getBufferCapacity : number +Maximum number of storable action groups + +**Kind**: instance property of [HistorySystem](#HistorySystem) +**Read only**: true + + +### historySystem.addActionGroup([actionGroupName]) +Adds a new named action group to the history + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Throws**: + +- TypeError If name is not a string + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [actionGroupName] | string | "\"\"" | Descriptive name for the action group | + + + +### historySystem.addActionData(actionDataObject) +Adds data to the current action group (with shallow copying) + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Throws**: + +- Error If no active action group exists + + +| Param | Type | Description | +| --- | --- | --- | +| actionDataObject | any | Data to store (objects/arrays are shallow copied) | + +**Example** +```js +Stores a copy of the object +history.addActionData({x: 1, y: 2}); +``` + + +### historySystem.getActionGroupID([offset]) ⇒ number +Retrieves action group ID at an offset from current selected group + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Returns**: number - The action group ID, or -1 if not in range. + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [offset] | number | 0 | The the offset from the current group for which ID gets returned | + + + +### historySystem.getActionGroupName([offset]) ⇒ string \| number +Retrieves action group name at an offset from current selected group + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Returns**: string \| number - The action group name, or -1 if not in range. + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [offset] | number | 0 | The the offset from the current group for which name gets returned | + + + +### historySystem.getActionData([offset]) ⇒ Array \| number +Retrieves action group data at an offset from current selected group + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Returns**: Array \| number - An array containing the action group data, or -1 if not in range + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [offset] | number | 0 | The the offset from the current group for which data gets returned | + + + +### historySystem.undo() ⇒ number +Moves backward in history (undo) + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Returns**: number - ID of the restored action group (-1 at start) + + +### historySystem.redo() ⇒ number +Moves forward in history (redo) + +**Kind**: instance method of [HistorySystem](#HistorySystem) +**Returns**: number - ID of the restored action group (-1 at end) diff --git a/scripts/history-system.js b/scripts/history-system.js index b418a82..2f086ca 100644 --- a/scripts/history-system.js +++ b/scripts/history-system.js @@ -1,60 +1,88 @@ -import {validateNumber} from "./validation.js"; +import { validateNumber } from "./validation.js"; /** - * Represents a history system to store, undo, redo action data. + * Represents a circular buffer-based history system for undo/redo operations. + * Tracks action groups containing arbitrary action data with shallow copying. + * + * Key Features: + * - Fixed-capacity circular buffer (1-64 actions) + * - Atomic action grouping + * - Shallow copy data storage + * - Undo/redo functionality + * - Action metadata (names/IDs) + * + * @example + * const history = new HistorySystem(10); + * history.addActionGroup("Paint"); + * history.addActionData({x: 1, y: 2, color: "#FF0000"}); + * history.undo(); // Reverts to previous state + * * @class */ class HistorySystem { + + /** + * Internal circular buffer storing action groups + * @type {Array<{groupName: string, groupID: number, actionData: Array}>} + * @private + */ #buffer; + + /** + * The index of the current selected action group + * @type {number} + * @private + */ #currentIndex = -1; + + /** + * The index of the oldest saved action group in the history system + * @type {number} + * @private + */ #startIndex = 0; + + /** + * The index of the last saved action group in the history system + * @type {number} + * @private + */ #endIndex = -1; + + /** + * Internal counter to enumerate increamental IDs for the created groups + * @type {number} + * @private + */ #actionGroupIDCounter = -1; /** - * Creates a history system with specified capacity + * Creates a new HistorySystem 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 + * @param {number} capacity - Maximum stored action groups (1-64) + * @throws {TypeError} If capacity is not an integer + * @throws {RangeError} If capacity is outside 1-64 range */ constructor(capacity) { - validateNumber(capacity, "Capacity", {start: 1, end: 64, integerOnly: true}); + 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 + * Adds a new named action group to the history * @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 + * @param {string} [actionGroupName=""] - Descriptive name for the action group + * @throws {TypeError} If name is not a string */ addActionGroup(actionGroupName = "") { if (typeof actionGroupName !== "string") throw new TypeError("Action group name must be string"); + if (this.#currentIndex !== this.#endIndex && this.#endIndex !== -1) + this.#endIndex = this.#currentIndex; + if (this.getBufferSize === this.getBufferCapacity) { this.#startIndex = (this.#startIndex + 1) % this.getBufferCapacity; } @@ -74,10 +102,13 @@ class HistorySystem { } /** - * Adds a shallow copy of action data to the current selected action group + * Adds data to the current action group (with shallow copying) * @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) + * @param {any} actionDataObject - Data to store (objects/arrays are shallow copied) + * @throws {Error} If no active action group exists + * @example + * Stores a copy of the object + * history.addActionData({x: 1, y: 2}); */ addActionData(actionDataObject) { if (this.#currentIndex === -1) { @@ -95,40 +126,42 @@ class HistorySystem { } /** - * 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 + * Gets action group metadata by offset from current position + * @private + * @param {number} [offset=0] - Offset from current position + * @returns {(Object|number)} Action group or -1 if invalid offset */ #getActionGroup(offset = 0) { - validateNumber(offset, "Offset", {integerOnly: true}); + validateNumber(offset, "Offset", { integerOnly: true }); - let check; + let distance; let index = this.#currentIndex; - if (offset > 0) { - if (index === -1) { + if (offset > 0) { // go right -> end + if (index === -1) { // absolute start index = this.#startIndex; offset--; } - check = this.#endIndex - index; - check = check < 0 ? check + this.getBufferCapacity : check; + distance = this.#endIndex - index; + + // negate the wrap effect + distance = distance < 0 ? distance + this.getBufferCapacity : distance; - if (check - offset < 0) return -1; + if (distance - offset < 0) return -1; return this.#buffer[(index + offset) % this.getBufferCapacity]; - } else if (offset < 0) { + } else if (offset < 0) { // go left -> start offset *= -1; - if (index === -1) return -1; + if (index === -1) return -1; // absolute start + + distance = index - this.#startIndex; - check = index - this.#startIndex; - check = check < 0 ? check + this.getBufferCapacity : check; + // negate the wrap effect + distance = distance < 0 ? distance + this.getBufferCapacity : distance; - // if (check - offset) is -1 => result index at -1 which is start => return -1 - if (check - offset < 0) return -1; + if (distance - offset < 0) return -1; return this.#buffer[ (index - offset + this.getBufferCapacity) % @@ -177,12 +210,12 @@ class HistorySystem { } /** - * Reverts to the previous action group + * Moves backward in history (undo) * @method - * @returns {number} The ID of the previous action group + * @returns {number} ID of the restored action group (-1 at start) */ undo() { - if (this.#currentIndex === this.#startIndex) this.#currentIndex = -1; + if (this.#currentIndex === this.#startIndex) this.#currentIndex = -1; // absolute start if (this.#currentIndex === -1) return this.#currentIndex; @@ -193,26 +226,27 @@ class HistorySystem { } /** - * Advances to the next action group + * Moves forward in history (redo) * @method - * @returns {number} The ID of next action group + * @returns {number} ID of the restored action group (-1 at end) */ redo() { - if (this.#currentIndex !== this.#endIndex) + if (this.#currentIndex !== this.#endIndex) { if (this.#currentIndex === -1) { this.#currentIndex = this.#startIndex; } else { this.#currentIndex = (this.#currentIndex + 1) % this.getBufferCapacity; } + } else if (this.#endIndex === -1) return this.#currentIndex; return this.#buffer[this.#currentIndex].groupID; } /** - * Retrieves number of action groups stored currently in the action buffer - * @method - * @returns {number} The size + * Current number of stored action groups + * @member {number} + * @readonly */ get getBufferSize() { if (this.#endIndex == -1) return 0; @@ -224,9 +258,9 @@ class HistorySystem { } /** - * Retrieves action buffer capacity (maximum number of action groups to add to the system) - * @method - * @returns {number} The capacity + * Maximum number of storable action groups + * @member {number} + * @readonly */ get getBufferCapacity() { return this.#buffer.length; diff --git a/tests/history-system.test.js b/tests/history-system.test.js index 3c89df4..84dd363 100644 --- a/tests/history-system.test.js +++ b/tests/history-system.test.js @@ -1,361 +1,243 @@ +import expect from "expect"; 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 -` - ); + const 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); + if (Array.isArray(expectedData)) { + expect(hs.getActionData(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 HistorySystem(input)).toThrow(error); }); - 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); + test.each([ + [-20, RangeError], + [0, RangeError], + [100, RangeError], + ])("should throw %p when capacity is %p (not between 1 and 64)", (input, error) => { + expect(() => new HistorySystem(input)).toThrow(error); + }); - hs = new HistorySystem(64); - expect(hs.getBufferCapacity).toBe(64); + test.each([1, 20, 64])("should accept valid capacity %p", (input) => { + expect(() => new HistorySystem(input)).not.toThrow(); }); }); - describe("Functionality", () => { - let hs; - let assertGroup; + describe("Basic Functionality", () => { + 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 initialize with empty buffer", () => { + expect(hs.getBufferSize).toBe(0); + expect(hs.getBufferCapacity).toBe(5); + }); - test("should undo and redo and return action group ID, -1 if at start", () => { - assertGroup(0, -1, -1); + test("should add action groups with incremental IDs", () => { + hs.addActionGroup("first"); + hs.addActionGroup("second"); - hs.addActionGroup("AG1"); + assertGroup(0, 1, "second"); + assertGroup(-1, 0, "first"); + }); - assertGroup(0, 0, "AG1"); + test("should handle undo/redo correctly", () => { + hs.addActionGroup("first"); + hs.addActionGroup("second"); - hs.addActionGroup("AG2"); + expect(hs.undo()).toBe(0); + assertGroup(0, 0, "first"); - assertGroup(0, 1, "AG2"); + expect(hs.redo()).toBe(1); + assertGroup(0, 1, "second"); + }); - hs.addActionGroup("AG3"); + test("should maintain buffer capacity", () => { + // Fill buffer + for (let i = 0; i < 6; i++) { + hs.addActionGroup(`group${i}`); + } - assertGroup(0, 2, "AG3"); + expect(hs.getBufferSize).toBe(5); + expect(hs.getBufferCapacity).toBe(5); + }); + }); - expect(hs.undo()).toBe(1); + describe("Action Data Handling", () => { + beforeEach(() => { + hs = new HistorySystem(5); + }); - assertGroup(0, 1, "AG2"); + test("should reject adding data without active group", () => { + expect(() => hs.addActionData("test")).toThrow("No action group to add to"); + }); - expect(hs.undo()).toBe(0); + test("should store primitive data correctly", () => { + hs.addActionGroup(); + hs.addActionData("test"); + hs.addActionData(42); - assertGroup(0, 0, "AG1"); + expect(hs.getActionData(0)).toEqual(["test", 42]); + }); - expect(hs.undo()).toBe(-1); + test("should shallow copy objects and arrays", () => { + const testObj = { a: 1 }; + const testArr = [1, 2, 3]; - assertGroup(0, -1, -1); + hs.addActionGroup(); + hs.addActionData(testObj); + hs.addActionData(testArr); - expect(hs.undo()).toBe(-1); + const storedData = hs.getActionData(0); + expect(storedData[0]).toEqual(testObj); + expect(storedData[0]).not.toBe(testObj); + expect(storedData[1]).toEqual(testArr); + expect(storedData[1]).not.toBe(testArr); + }); + }); - assertGroup(0, -1, -1); + describe('Stress Testing', () => { + test('should handle 100+ consecutive undo/redo operations', () => { + hs = new HistorySystem(10); - expect(hs.redo()).toBe(0); + for (let i = 0; i < 20; i++) { // populate history + hs.addActionGroup(`group${i}`); + } - assertGroup(0, 0, "AG1"); + for (let i = 0; i < 15; i++) { // perform undos + expect(() => hs.undo()).not.toThrow(); + } - expect(hs.redo()).toBe(1); + for (let i = 0; i < 15; i++) { // perform redos + expect(() => hs.redo()).not.toThrow(); + } - assertGroup(0, 1, "AG2"); + expect(hs.getActionGroupID()).toBe(19); + }); - expect(hs.redo()).toBe(2); + test('should complete 1000 operations under 100ms', () => { + const start = performance.now(); + // ... perform operations ... + expect(performance.now() - start).toBeLessThan(100); + }); + }); - assertGroup(0, 2, "AG3"); + describe('Deep Objects Handling', () => { + test('should handle nested object references (objects are shared)', () => { + const hs = new HistorySystem(3); + const nestedObj = { + a: 1, + b: { + c: [1, 2, { d: 3 }], + } + }; - expect(hs.redo()).toBe(2); + hs.addActionGroup(); + hs.addActionData(nestedObj); - assertGroup(0, 2, "AG3"); - }); + nestedObj.b.c[2].d = 6; // modified - 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.getActionData(0)[0].b.c[2].d).toBe(6); + }); + }); - 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); + describe('Identical Action Handling', () => { + test('should allow consecutive identical actions', () => { + const hs = new HistorySystem(3); + const testData = { a: 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); + hs.addActionGroup(); + hs.addActionData(testData); + hs.addActionData(testData); // Identical to previous - 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", () => { }); + expect(hs.getActionData(0).length).toBe(2); }); - 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('Empty Buffer Behavior', () => { + let hs; + beforeEach(() => { + hs = new HistorySystem(3); + }); + + test('should handle operations on empty buffer', () => { + expect(hs.undo()).toBe(-1); + expect(hs.redo()).toBe(-1); + expect(hs.getActionGroupID(0)).toBe(-1); + expect(hs.getBufferSize).toBe(0); + }); + }); + + describe("Edge Cases", () => { + beforeEach(() => { + hs = new HistorySystem(3); // smaller buffer for easier testing }); - 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", - ); - }); + test("should handle buffer wraparound", () => { + // Fill buffer + hs.addActionGroup("first"); + hs.addActionGroup("second"); + hs.addActionGroup("third"); + hs.addActionGroup("fourth"); // should overwrite "first" + + expect(hs.getBufferSize).toBe(3); + assertGroup(1, -1, -1); + assertGroup(0, 3, "fourth"); + assertGroup(-1, 2, "third"); + assertGroup(-2, 1, "second"); + assertGroup(-3, -1, -1); // should not exist (wrapped around) + }); + + test("should clear redo history when adding new action", () => { + hs.addActionGroup("first"); + hs.addActionGroup("second"); + hs.addActionGroup("third"); + expect(hs.undo()).toBe(1); + hs.addActionGroup("fourth"); + + expect(hs.redo()).toBe(3); // should only have "third" available + + assertGroup(1, -1, -1); + assertGroup(0, 3, "fourth"); + assertGroup(-1, 1, "second"); + assertGroup(-2, 0, "first"); + assertGroup(-3, -1, -1); + }); + + test("should handle multiple undo/redo cycles", () => { + hs.addActionGroup("first"); + hs.addActionGroup("second"); + + hs.undo(); + hs.undo(); + hs.undo(); + hs.redo(); + hs.redo(); + hs.redo(); + + assertGroup(0, 1, "second"); + assertGroup(-1, 0, "first"); }); }); }); From b35190d0fa26ef5127e3730fbf7878d21ef09dc1 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:01:12 +0300 Subject: [PATCH 14/29] Removed side docs to avoid confusion --- docs/canvas-grid.md | 173 ---------------------------------------- docs/color.md | 46 ----------- docs/dirty-rectangle.md | 114 -------------------------- docs/history-system.md | 148 ---------------------------------- docs/validation.md | 59 -------------- 5 files changed, 540 deletions(-) delete mode 100644 docs/canvas-grid.md delete mode 100644 docs/color.md delete mode 100644 docs/dirty-rectangle.md delete mode 100644 docs/history-system.md delete mode 100644 docs/validation.md diff --git a/docs/canvas-grid.md b/docs/canvas-grid.md deleted file mode 100644 index 276b858..0000000 --- a/docs/canvas-grid.md +++ /dev/null @@ -1,173 +0,0 @@ -## Classes - -
-
CanvasGrid
-

Represents a canvas grid system

-
-
- -## Typedefs - -
-
Pixel
-
-
- - - -## CanvasGrid -Represents a canvas grid system - -**Kind**: global class - -* [CanvasGrid](#CanvasGrid) - * [new CanvasGrid([width], [height])](#new_CanvasGrid_new) - * [.initializeBlankCanvas(width, height)](#CanvasGrid+initializeBlankCanvas) - * [.loadImage(imageData, [x], [y])](#CanvasGrid+loadImage) - * [.resetChangeBuffer()](#CanvasGrid+resetChangeBuffer) ⇒ DirtyRectangle - * [.setColor(x, y, color, options)](#CanvasGrid+setColor) - * [.get(x, y)](#CanvasGrid+get) ⇒ [Pixel](#Pixel) - * [.getColor(x, y)](#CanvasGrid+getColor) ⇒ Color - * [.changeBuffer()](#CanvasGrid+changeBuffer) ⇒ DirtyRectangle - * [.width()](#CanvasGrid+width) ⇒ number - * [.height()](#CanvasGrid+height) ⇒ number - - - -### new CanvasGrid([width], [height]) -Creates a blank canvas with specified width and height - -**Throws**: - -- TypeError If width or height are not integers -- RangeError If width or height are not between 1 and 1024 inclusive - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [width] | number | 1 | The width of the grid | -| [height] | number | 1 | The height of the grid | - - - -### canvasGrid.initializeBlankCanvas(width, height) -Initializes the canvas with a blank grid of transparent pixel data - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Throws**: - -- TypeError If width or height are not integers -- RangeError If width or height are not between 1 and 1024 inclusive - - -| Param | Type | Description | -| --- | --- | --- | -| width | number | The width of the grid | -| height | number | The height of the grid | - - - -### canvasGrid.loadImage(imageData, [x], [y]) -Loads an image data at (x, y) position - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Throws**: - -- TypeError if x or y are not integers -- TypeError if imageData is not instance of class ImageData - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| imageData | ImageData | | The image to be loaded | -| [x] | number | 0 | X-coordinate | -| [y] | number | 0 | Y-coordinate | - - - -### canvasGrid.resetChangeBuffer() ⇒ DirtyRectangle -Resets changes buffer to be empty - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: DirtyRectangle - Change buffer before emptying - - -### canvasGrid.setColor(x, y, color, options) -Sets color to pixel at position (x, y). - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Throws**: - -- TypeError if validate is true and if color is not a valid Color object -- TypeError if validate is true and if x and y are not valid integers in valid range. -- RangeError if validate is true and if x and y are not in valid range. - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| x | number | | X-coordinate. | -| y | number | | X-coordinate. | -| color | Color | | The Color object to be set | -| options | Object | | An object containing additional options. | -| [options.quietly] | boolean | false | If set to true, the pixel data at which color changed will not be pushed to the changeBuffers array. | -| [options.validate] | boolean | true | If set to true, the x, y, and color types are validated. | - - - -### canvasGrid.get(x, y) ⇒ [Pixel](#Pixel) -Returns pixel data at position (x, y) - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: [Pixel](#Pixel) - Pixel data at position (x, y) - -| Param | Type | Description | -| --- | --- | --- | -| x | number | X-coordinate | -| y | number | Y-coordinate | - - - -### canvasGrid.getColor(x, y) ⇒ Color -Returns pixel color at position (x, y) - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: Color - Color object of pixel at position (x, y) - -| Param | Type | Description | -| --- | --- | --- | -| x | number | X-coordinate | -| y | number | Y-coordinate | - - - -### canvasGrid.changeBuffer() ⇒ DirtyRectangle -Returns copy of change buffer - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: DirtyRectangle - Copy of change buffer - - -### canvasGrid.width() ⇒ number -Returns the width of the canvas - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: number - The width of the canvas - - -### canvasGrid.height() ⇒ number -Returns the height of the canvas - -**Kind**: instance method of [CanvasGrid](#CanvasGrid) -**Returns**: number - The height of the canvas - - -## Pixel -**Kind**: global typedef -**Properties** - -| Name | Type | Description | -| --- | --- | --- | -| x | number | X-coordinate | -| y | number | Y-coordinate | -| color | Color | Color of the pixel | - diff --git a/docs/color.md b/docs/color.md deleted file mode 100644 index dd7e6ac..0000000 --- a/docs/color.md +++ /dev/null @@ -1,46 +0,0 @@ -## Typedefs - -
-
RGBColor : Object
-
-
HSLColor : Object
-
-
ParsedHex : Object
-
-
- - - -## RGBColor : Object -**Kind**: global typedef -**Properties** - -| Name | Type | Description | -| --- | --- | --- | -| 0 | number | Red (0-255) | -| 1 | number | Green (0-255) | -| 2 | number | Blue (0-255) | - - - -## HSLColor : Object -**Kind**: global typedef -**Properties** - -| Name | Type | Description | -| --- | --- | --- | -| 0 | number | Hue (0-360) | -| 1 | number | Saturation (0-100) | -| 2 | number | Lightness (0-100) | - - - -## ParsedHex : Object -**Kind**: global typedef -**Properties** - -| Name | Type | Description | -| --- | --- | --- | -| rgb | Array.<number> | RGB values [r, g, b] | -| alpha | number | Alpha value (0-1) | - diff --git a/docs/dirty-rectangle.md b/docs/dirty-rectangle.md deleted file mode 100644 index 7798a27..0000000 --- a/docs/dirty-rectangle.md +++ /dev/null @@ -1,114 +0,0 @@ - - -## DirtyRectangle -Tracks modified pixel regions with ordered history support. -Maintains both a Map for order and a Set for duplicate checking. - -**Kind**: global class - -* [DirtyRectangle](#DirtyRectangle) - * [new DirtyRectangle()](#new_DirtyRectangle_new) - * [.merge(source)](#DirtyRectangle+merge) ⇒ [DirtyRectangle](#DirtyRectangle) - * [.clone()](#DirtyRectangle+clone) ⇒ [DirtyRectangle](#DirtyRectangle) - * [.setChange(x, y, after, [before])](#DirtyRectangle+setChange) - * [.hasChange(x, y)](#DirtyRectangle+hasChange) ⇒ boolean - * [.isEmpty()](#DirtyRectangle+isEmpty) ⇒ boolean - * [.width()](#DirtyRectangle+width) ⇒ number - * [.height()](#DirtyRectangle+height) ⇒ number - * [.afterStates()](#DirtyRectangle+afterStates) ⇒ Array.<{x: number, y: number, state: any}> - * [.beforeStates()](#DirtyRectangle+beforeStates) ⇒ Array.<{x: number, y: number, state: any}> - * [.changes()](#DirtyRectangle+changes) ⇒ Map.<string, {x: number, y: number, before: any, after: any}> - * [.bounds()](#DirtyRectangle+bounds) ⇒ Object - - - -### new DirtyRectangle() -Creates a DirtyRectangle instance. - - - -### dirtyRectangle.merge(source) ⇒ [DirtyRectangle](#DirtyRectangle) -Merges another DirtyRectangle into a copy of this one, and returns it. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) -**Returns**: [DirtyRectangle](#DirtyRectangle) - The result of merging - -| Param | Type | Description | -| --- | --- | --- | -| source | [DirtyRectangle](#DirtyRectangle) | Source rectangle to merge. | - - - -### dirtyRectangle.clone() ⇒ [DirtyRectangle](#DirtyRectangle) -Creates a shallow copy (states are not deep-cloned). - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) -**Returns**: [DirtyRectangle](#DirtyRectangle) - The clone - - -### dirtyRectangle.setChange(x, y, after, [before]) -Adds or updates a pixel modification. Coordinates are floored to integers. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| x | number | | X-coordinate (floored). | -| y | number | | Y-coordinate (floored). | -| after | any | | New state. | -| [before] | any | after | Original state (used only on first add). | - - - -### dirtyRectangle.hasChange(x, y) ⇒ boolean -Checks if a pixel has been modified. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - -| Param | Type | Description | -| --- | --- | --- | -| x | number | X-coordinate. | -| y | number | Y-coordinate. | - - - -### dirtyRectangle.isEmpty() ⇒ boolean -Returns whether the rectangle is empty. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.width() ⇒ number -Width of the bounding rectangle. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.height() ⇒ number -Height of the bounding rectangle. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.afterStates() ⇒ Array.<{x: number, y: number, state: any}> -Gets current modified states. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.beforeStates() ⇒ Array.<{x: number, y: number, state: any}> -Gets original states before modification. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.changes() ⇒ Map.<string, {x: number, y: number, before: any, after: any}> -Map for all changes (in insertion order). ['x,y' -> change] - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) - - -### dirtyRectangle.bounds() ⇒ Object -Bounding rectangle of all changes. - -**Kind**: instance method of [DirtyRectangle](#DirtyRectangle) diff --git a/docs/history-system.md b/docs/history-system.md deleted file mode 100644 index 66fd356..0000000 --- a/docs/history-system.md +++ /dev/null @@ -1,148 +0,0 @@ - - -## HistorySystem -Represents a circular buffer-based history system for undo/redo operations. -Tracks action groups containing arbitrary action data with shallow copying. - -Key Features: -- Fixed-capacity circular buffer (1-64 actions) -- Atomic action grouping -- Shallow copy data storage -- Undo/redo functionality -- Action metadata (names/IDs) - -**Kind**: global class - -* [HistorySystem](#HistorySystem) - * [new HistorySystem(capacity)](#new_HistorySystem_new) - * [.getBufferSize](#HistorySystem+getBufferSize) : number - * [.getBufferCapacity](#HistorySystem+getBufferCapacity) : number - * [.addActionGroup([actionGroupName])](#HistorySystem+addActionGroup) - * [.addActionData(actionDataObject)](#HistorySystem+addActionData) - * [.getActionGroupID([offset])](#HistorySystem+getActionGroupID) ⇒ number - * [.getActionGroupName([offset])](#HistorySystem+getActionGroupName) ⇒ string \| number - * [.getActionData([offset])](#HistorySystem+getActionData) ⇒ Array \| number - * [.undo()](#HistorySystem+undo) ⇒ number - * [.redo()](#HistorySystem+redo) ⇒ number - - - -### new HistorySystem(capacity) -Creates a new HistorySystem with specified capacity - -**Throws**: - -- TypeError If capacity is not an integer -- RangeError If capacity is outside 1-64 range - - -| Param | Type | Description | -| --- | --- | --- | -| capacity | number | Maximum stored action groups (1-64) | - -**Example** -```js -const history = new HistorySystem(10); -history.addActionGroup("Paint"); -history.addActionData({x: 1, y: 2, color: "#FF0000"}); -history.undo(); // Reverts to previous state -``` - - -### historySystem.getBufferSize : number -Current number of stored action groups - -**Kind**: instance property of [HistorySystem](#HistorySystem) -**Read only**: true - - -### historySystem.getBufferCapacity : number -Maximum number of storable action groups - -**Kind**: instance property of [HistorySystem](#HistorySystem) -**Read only**: true - - -### historySystem.addActionGroup([actionGroupName]) -Adds a new named action group to the history - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Throws**: - -- TypeError If name is not a string - - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [actionGroupName] | string | "\"\"" | Descriptive name for the action group | - - - -### historySystem.addActionData(actionDataObject) -Adds data to the current action group (with shallow copying) - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Throws**: - -- Error If no active action group exists - - -| Param | Type | Description | -| --- | --- | --- | -| actionDataObject | any | Data to store (objects/arrays are shallow copied) | - -**Example** -```js -Stores a copy of the object -history.addActionData({x: 1, y: 2}); -``` - - -### historySystem.getActionGroupID([offset]) ⇒ number -Retrieves action group ID at an offset from current selected group - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Returns**: number - The action group ID, or -1 if not in range. - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [offset] | number | 0 | The the offset from the current group for which ID gets returned | - - - -### historySystem.getActionGroupName([offset]) ⇒ string \| number -Retrieves action group name at an offset from current selected group - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Returns**: string \| number - The action group name, or -1 if not in range. - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [offset] | number | 0 | The the offset from the current group for which name gets returned | - - - -### historySystem.getActionData([offset]) ⇒ Array \| number -Retrieves action group data at an offset from current selected group - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Returns**: Array \| number - An array containing the action group data, or -1 if not in range - -| Param | Type | Default | Description | -| --- | --- | --- | --- | -| [offset] | number | 0 | The the offset from the current group for which data gets returned | - - - -### historySystem.undo() ⇒ number -Moves backward in history (undo) - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Returns**: number - ID of the restored action group (-1 at start) - - -### historySystem.redo() ⇒ number -Moves forward in history (redo) - -**Kind**: instance method of [HistorySystem](#HistorySystem) -**Returns**: number - ID of the restored action group (-1 at end) diff --git a/docs/validation.md b/docs/validation.md deleted file mode 100644 index 5e2bd57..0000000 --- a/docs/validation.md +++ /dev/null @@ -1,59 +0,0 @@ -## Functions - -
-
validateColorArray(color)boolean
-

Validates the color array.

-
-
validateNumber(number, varName, Contains, start, end, integerOnly)
-

Validates the number to be valid number between start and end inclusive.

-
-
- - - -## ~~validateColorArray(color) ⇒ boolean~~ -***use the Color class instead*** - -Validates the color array. - -**Kind**: global function -**Returns**: boolean - - Returns true if the color array is valid, otherwise false. -**Throws**: - -- TypeError Throws an error if the color is invalid. - - -| Param | Type | Description | -| --- | --- | --- | -| color | Array.<number> | The color array [red, green, blue, alpha] to validate. | - -**Properties** - -| Name | Type | Description | -| --- | --- | --- | -| 0 | number | Red (0-255) | -| 1 | number | Green (0-255) | -| 2 | number | Blue (0-255) | - - - -## validateNumber(number, varName, Contains, start, end, integerOnly) -Validates the number to be valid number between start and end inclusive. - -**Kind**: global function -**Throws**: - -- TypeError Throws an error if the number type, name type or options types is invalid. -- TypeError Throws an error if start and end are set but start is higher than end. -- RangeError Throws an error if the number is not in the specified range. - - -| Param | Type | Description | -| --- | --- | --- | -| number | number | The number to validate. | -| varName | string | The variable name to show in the error message which will be thrown. | -| Contains | Object | some optional constraints: max/min limits, and if the number is integer only | -| start | number \| undefined | The minimum of valid range, set to null to omit the constraint. | -| end | number \| undefined | The maximum of valid range, set to null to omit the constraint. | -| integerOnly | boolean | Specifies if the number must be an integer. | - From 66f5cd207df278ae22e2a65d72015b66d11fbf37 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:01:32 +0300 Subject: [PATCH 15/29] refactor(color): fix alpha handling in mix method feat(color): add compositeOver method for compositing one color over another --- scripts/color.js | 72 +++++++++++++++++++++++++++++++++------------ tests/color.test.js | 13 ++++++++ 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/scripts/color.js b/scripts/color.js index 83cd04a..2cd0be8 100644 --- a/scripts/color.js +++ b/scripts/color.js @@ -104,41 +104,75 @@ class Color { weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 const color1 = this; const color2 = color; - if (!(color2 instanceof Color)) + + if (!(color2 instanceof Color)) { throw new TypeError("color must be instance of Color class"); + } - const [a1, a2] = [color1.alpha, color2.alpha]; + const newAlpha = color1.alpha + (color2.alpha - color1.alpha) * weight; switch (mode) { - case 'hsl': // HSL mixing (circular interpolation for hue) + case 'hsl': const [h1, s1, l1] = color1.hsl; const [h2, s2, l2] = color2.hsl; - // Handle hue wrapping (e.g., 350° + 20° → 10°) + // Hue wrapping let hueDiff = h2 - h1; if (Math.abs(hueDiff) > 180) { hueDiff += hueDiff > 0 ? -360 : 360; } - return Color.create([ - (h1 + hueDiff * weight + 360) % 360, // Wrap around 360° - s1 + (s2 - s1) * weight, - l1 + (l2 - l1) * weight, - a1 + (a2 - a1) * weight, - ], "hsl"); - case 'rgb': // RGB mixing (linear interpolation) + return Color.create({ + hsl: [ + (h1 + hueDiff * weight + 360) % 360, + s1 + (s2 - s1) * weight, + l1 + (l2 - l1) * weight, + ], + alpha: newAlpha + }); + + case 'rgb': + default: const [r1, g1, b1] = color1.rgb; const [r2, g2, b2] = color2.rgb; - return Color.create([ - r1 + (r2 - r1) * weight, - g1 + (g2 - g1) * weight, - b1 + (b2 - b1) * weight, - a1 + (a2 - a1) * weight - ], 'rgb'); - default: - throw new TypeError(`Invalid mode for mixing: ${mode}`); + return Color.create({ + rgb: [ + r1 + (r2 - r1) * weight, + g1 + (g2 - g1) * weight, + b1 + (b2 - b1) * weight + ], + alpha: newAlpha + }); + } + } + + /** + * Composites a color over another + * @method + * @param {Color} bottomColor - The color to composite over + * @returns {Color} The resulting new composited color + * @throws {TypeError} If bottomColor is an not instance of Color class + */ + compositeOver(bottomColor) { + if (!(bottomColor instanceof Color)) { + throw new TypeError("color must be instance of Color class"); } + + const [rTop, gTop, bTop, aTop] = [... this.#rgb, this.#alpha]; + const [rBottom, gBottom, bBottom, aBottom] = [... bottomColor.rgb, bottomColor.#alpha]; + + const combinedAlpha = aTop + aBottom * (1 - aTop); + if (combinedAlpha === 0) return Color.TRANSPARENT; + + return Color.create({ + 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 + }); } /** diff --git a/tests/color.test.js b/tests/color.test.js index 36313e3..4198e4d 100644 --- a/tests/color.test.js +++ b/tests/color.test.js @@ -128,6 +128,7 @@ describe('Color Class', () => { 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', () => { @@ -141,6 +142,18 @@ describe('Color Class', () => { expect(mixed.alpha).toBeCloseTo(0.5 * (0.5 + 1)); }); }); + 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]); + }); + }); }); describe('Static Colors', () => { From 7f85583e04ea5206c031862386ca943337cff5a1 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:46:46 +0300 Subject: [PATCH 16/29] refactor: renamed dirty rectangle to change region --- .../{dirty-rectangle.js => change-region.js} | 20 +-- ...ectangle.test.js => change-region.test.js} | 130 +++++++++--------- 2 files changed, 75 insertions(+), 75 deletions(-) rename scripts/{dirty-rectangle.js => change-region.js} (90%) rename tests/{dirty-rectangle.test.js => change-region.test.js} (53%) diff --git a/scripts/dirty-rectangle.js b/scripts/change-region.js similarity index 90% rename from scripts/dirty-rectangle.js rename to scripts/change-region.js index db62f2d..1ec5e87 100644 --- a/scripts/dirty-rectangle.js +++ b/scripts/change-region.js @@ -3,7 +3,7 @@ * Tracks modified pixel regions with ordered history support. * Maintains both a Map for order and a Set for duplicate checking. */ -class DirtyRectangle { +class ChangeRegion { /** * Map from pixel positions `${x},${y}` to a change record containing the position and before/after states @@ -23,16 +23,16 @@ class DirtyRectangle { }; /** - * Creates a DirtyRectangle instance. + * Creates a ChangeRegion instance. * @constructor */ constructor() { } /** - * Merges another DirtyRectangle into a copy of this one, and returns it. + * Merges another ChangeRegion into a copy of this one, and returns it. * @method - * @param {DirtyRectangle} source - Source rectangle to merge. - * @returns {DirtyRectangle} The result of merging + * @param {ChangeRegion} source - Source rectangle to merge. + * @returns {ChangeRegion} The result of merging */ merge(source) { if (!source || source.isEmpty) return this.clone(); @@ -53,10 +53,10 @@ class DirtyRectangle { /** * Creates a shallow copy (states are not deep-cloned). * @method - * @returns {DirtyRectangle} The clone + * @returns {ChangeRegion} The clone */ clone() { - const copy = new DirtyRectangle({ }); + const copy = new ChangeRegion({ }); this.#changes.forEach(value => { copy.setChange(value.x, value.y, value.after, value.before); @@ -165,8 +165,8 @@ class DirtyRectangle { * @method * @returns {Map} */ - get changes() { - return this.#changes; + get changesMap() { + return new Map(this.#changes); } /** @@ -179,4 +179,4 @@ class DirtyRectangle { } } -export default DirtyRectangle; +export default ChangeRegion; diff --git a/tests/dirty-rectangle.test.js b/tests/change-region.test.js similarity index 53% rename from tests/dirty-rectangle.test.js rename to tests/change-region.test.js index 096e03b..50504eb 100644 --- a/tests/dirty-rectangle.test.js +++ b/tests/change-region.test.js @@ -1,26 +1,26 @@ -import DirtyRectangle from './../scripts/dirty-rectangle.js'; +import ChangeRegion from './../scripts/change-region.js'; -describe('DirtyRectangle', () => { +describe('ChangeRegion', () => { - let dr; + let cr; const createRectWithChanges = (changes) => { - const dr = new DirtyRectangle(); + const cr = new ChangeRegion(); changes.forEach(([x, y, after, before]) => - dr.setChange(x, y, after, before)); - return dr; + cr.setChange(x, y, after, before)); + return cr; }; beforeEach(() => { - dr = new DirtyRectangle(); + cr = new ChangeRegion(); }); - describe('DirtyRectangle Creation', () => { + describe('ChangeRegion Creation', () => { test('should create an empty dirty rectangle', () => { - dr = new DirtyRectangle(); - expect(dr.width).toBe(0); - expect(dr.height).toBe(0); - expect(dr.isEmpty).toBe(true); + cr = new ChangeRegion(); + expect(cr.width).toBe(0); + expect(cr.height).toBe(0); + expect(cr.isEmpty).toBe(true); }); }); @@ -33,49 +33,49 @@ describe('DirtyRectangle', () => { ${[5, 5]} | ${[5, 5]} `('should floor input change $input to $expected', ({ input, expected }) => { const [x, y] = input; - dr.setChange(x, y, 'state'); - expect(dr.hasChange(...expected)).toBe(true); + cr.setChange(x, y, 'state'); + expect(cr.hasChange(...expected)).toBe(true); }); }); describe('State Management', () => { test('should preserve initial before state', () => { - dr.setChange(0, 0, 'after', 'before'); - dr.setChange(0, 0, 'updated'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }]); - dr.setChange(0, 2, 'newpoint'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }, { x: 0, y: 2, state: 'newpoint' }]); + cr.setChange(0, 0, 'after', 'before'); + cr.setChange(0, 0, 'updated'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }]); + cr.setChange(0, 2, 'newpoint'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }, { x: 0, y: 2, state: 'newpoint' }]); }); test('should update after state on subsequent calls', () => { - dr.setChange(1, 1, 'v1'); - expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v1' }]); + cr.setChange(1, 1, 'v1'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v1' }]); - dr.setChange(1, 1, 'v2'); - expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v2' }]); + cr.setChange(1, 1, 'v2'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v2' }]); - dr.setChange(1, 1, 'v3'); - expect(dr.afterStates).toEqual([{ x: 1, y: 1, state: 'v3' }]); + cr.setChange(1, 1, 'v3'); + expect(cr.afterStates).toEqual([{ x: 1, y: 1, state: 'v3' }]); }); test('should order all changes old to new in before states and after states, changes map should access any change', () => { - dr.setChange(0, 0, 'v1'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }]); - expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }]); - expect(dr.changes.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v1', before: 'v1' }); - dr.setChange(0, 2, 'v2'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); - expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); - expect(dr.changes.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); - dr.setChange(1, 0, 'v3'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(dr.changes.get(`1,0`)) .toEqual({ x: 1, y: 0, after: 'v3', before: 'v3' }); - dr.setChange(0, 0, 'v4'); - expect(dr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(dr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v4' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(dr.changes.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v4', before: 'v1' }); - expect(dr.changes.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); + cr.setChange(0, 0, 'v1'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }]); + expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }]); + expect(cr.changesMap.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v1', before: 'v1' }); + cr.setChange(0, 2, 'v2'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); + expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); + expect(cr.changesMap.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); + cr.setChange(1, 0, 'v3'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(cr.changesMap.get(`1,0`)) .toEqual({ x: 1, y: 0, after: 'v3', before: 'v3' }); + cr.setChange(0, 0, 'v4'); + expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v4' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); + expect(cr.changesMap.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v4', before: 'v1' }); + expect(cr.changesMap.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); }); }); @@ -106,58 +106,58 @@ describe('DirtyRectangle', () => { return c; }, [[]]); - dr = createRectWithChanges(changes); - expect(dr.bounds).toEqual(expectedBounds); + cr = createRectWithChanges(changes); + expect(cr.bounds).toEqual(expectedBounds); }); }); }); - describe('DirtyRectangle Manipulation', () => { + describe('ChangeRegion Manipulation', () => { describe('Cloning', () => { test('should produce independent copy', () => { - dr.setChange(0, 0, 'original'); - const clone = dr.clone(); + cr.setChange(0, 0, 'original'); + const clone = cr.clone(); // Test independence clone.setChange(1, 1, 'new'); - dr.setChange(2, 2, 'different'); + cr.setChange(2, 2, 'different'); expect(clone.hasChange(2, 2)).toBe(false); - expect(dr.hasChange(1, 1)).toBe(false); + expect(cr.hasChange(1, 1)).toBe(false); }); test('should preserve all properties', () => { - dr.setChange(1, 2, 'state'); - const clone = dr.clone(); + cr.setChange(1, 2, 'state'); + const clone = cr.clone(); - expect(clone.afterStates).toEqual(dr.afterStates); - expect(clone.bounds).toEqual(dr.bounds); + expect(clone.afterStates).toEqual(cr.afterStates); + expect(clone.bounds).toEqual(cr.bounds); }); }); describe('Merging', () => { test('should merge overlapping pixels correctly', () => { - const dr1 = new DirtyRectangle(); - dr1.setChange(0, 0, 'dr1-after', 'dr1-before'); + const cr1 = new ChangeRegion(); + cr1.setChange(0, 0, 'cr1-after', 'cr1-before'); - const dr2 = new DirtyRectangle(); - dr2.setChange(0, 0, 'dr2-after', 'dr2-before'); + const cr2 = new ChangeRegion(); + cr2.setChange(0, 0, 'cr2-after', 'cr2-before'); - let merge = dr1.merge(dr2); + let merge = cr1.merge(cr2); - expect(merge.afterStates).toEqual([{ x: 0, y: 0, state: 'dr2-after' }]); - expect(merge.beforeStates).toEqual([{ x: 0, y: 0, state: 'dr1-before' }]); + expect(merge.afterStates).toEqual([{ x: 0, y: 0, state: 'cr2-after' }]); + expect(merge.beforeStates).toEqual([{ x: 0, y: 0, state: 'cr1-before' }]); }); test('should expand bounds to include both rects', () => { - const dr1 = new DirtyRectangle(); - dr1.setChange(0, 0, 'state'); + const cr1 = new ChangeRegion(); + cr1.setChange(0, 0, 'state'); - const dr2 = new DirtyRectangle(); - dr2.setChange(5, 5, 'state'); + const cr2 = new ChangeRegion(); + cr2.setChange(5, 5, 'state'); - let merge = dr1.merge(dr2); + let merge = cr1.merge(cr2); expect(merge.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); }); }); From 8240e51db8cc3e79e561d0f0d7b6726c3d11cb6a Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:48:10 +0300 Subject: [PATCH 17/29] refactor: rename history-system to action-history, refactor code and do refinements --- .../{history-system.js => action-history.js} | 8 +- ...-system.test.js => action-history.test.js} | 147 +++++++++--------- 2 files changed, 77 insertions(+), 78 deletions(-) rename scripts/{history-system.js => action-history.js} (98%) rename tests/{history-system.test.js => action-history.test.js} (58%) diff --git a/scripts/history-system.js b/scripts/action-history.js similarity index 98% rename from scripts/history-system.js rename to scripts/action-history.js index 2f086ca..7bd16da 100644 --- a/scripts/history-system.js +++ b/scripts/action-history.js @@ -12,14 +12,14 @@ import { validateNumber } from "./validation.js"; * - Action metadata (names/IDs) * * @example - * const history = new HistorySystem(10); + * const history = new ActionHistory(10); * history.addActionGroup("Paint"); * history.addActionData({x: 1, y: 2, color: "#FF0000"}); * history.undo(); // Reverts to previous state * * @class */ -class HistorySystem { +class ActionHistory { /** * Internal circular buffer storing action groups @@ -57,7 +57,7 @@ class HistorySystem { #actionGroupIDCounter = -1; /** - * Creates a new HistorySystem with specified capacity + * Creates a new ActionHistory with specified capacity * @constructor * @param {number} capacity - Maximum stored action groups (1-64) * @throws {TypeError} If capacity is not an integer @@ -267,4 +267,4 @@ class HistorySystem { } } -export default HistorySystem; +export default ActionHistory; diff --git a/tests/history-system.test.js b/tests/action-history.test.js similarity index 58% rename from tests/history-system.test.js rename to tests/action-history.test.js index 84dd363..6566f89 100644 --- a/tests/history-system.test.js +++ b/tests/action-history.test.js @@ -1,17 +1,16 @@ -import expect from "expect"; -import HistorySystem from "../scripts/history-system.js"; +import ActionHistory from "../scripts/action-history.js"; -describe("HistorySystem", () => { +describe("ActionHistory", () => { - let hs; + let ah; const 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 (expectedID !== null) expect(ah.getActionGroupID(offset)).toBe(expectedID); + if (expectedName !== null) expect(ah.getActionGroupName(offset)).toBe(expectedName); if (expectedData !== null) { - expect(hs.getActionData(offset)).toStrictEqual(expectedData); + expect(ah.getActionData(offset)).toStrictEqual(expectedData); if (Array.isArray(expectedData)) { - expect(hs.getActionData(offset)).not.toBe(expectedData); + expect(ah.getActionData(offset)).not.toBe(expectedData); } } }; @@ -26,7 +25,7 @@ describe("HistorySystem", () => { [Infinity, TypeError], [13.01, TypeError] ])("should throw %p when capacity is %p", (input, error) => { - expect(() => new HistorySystem(input)).toThrow(error); + expect(() => new ActionHistory(input)).toThrow(error); }); test.each([ @@ -34,82 +33,82 @@ describe("HistorySystem", () => { [0, RangeError], [100, RangeError], ])("should throw %p when capacity is %p (not between 1 and 64)", (input, error) => { - expect(() => new HistorySystem(input)).toThrow(error); + expect(() => new ActionHistory(input)).toThrow(error); }); test.each([1, 20, 64])("should accept valid capacity %p", (input) => { - expect(() => new HistorySystem(input)).not.toThrow(); + expect(() => new ActionHistory(input)).not.toThrow(); }); }); describe("Basic Functionality", () => { beforeEach(() => { - hs = new HistorySystem(5); + ah = new ActionHistory(5); }); test("should initialize with empty buffer", () => { - expect(hs.getBufferSize).toBe(0); - expect(hs.getBufferCapacity).toBe(5); + expect(ah.getBufferSize).toBe(0); + expect(ah.getBufferCapacity).toBe(5); }); test("should add action groups with incremental IDs", () => { - hs.addActionGroup("first"); - hs.addActionGroup("second"); + ah.addActionGroup("first"); + ah.addActionGroup("second"); assertGroup(0, 1, "second"); assertGroup(-1, 0, "first"); }); test("should handle undo/redo correctly", () => { - hs.addActionGroup("first"); - hs.addActionGroup("second"); + ah.addActionGroup("first"); + ah.addActionGroup("second"); - expect(hs.undo()).toBe(0); + expect(ah.undo()).toBe(0); assertGroup(0, 0, "first"); - expect(hs.redo()).toBe(1); + expect(ah.redo()).toBe(1); assertGroup(0, 1, "second"); }); test("should maintain buffer capacity", () => { // Fill buffer for (let i = 0; i < 6; i++) { - hs.addActionGroup(`group${i}`); + ah.addActionGroup(`group${i}`); } - expect(hs.getBufferSize).toBe(5); - expect(hs.getBufferCapacity).toBe(5); + expect(ah.getBufferSize).toBe(5); + expect(ah.getBufferCapacity).toBe(5); }); }); describe("Action Data Handling", () => { beforeEach(() => { - hs = new HistorySystem(5); + ah = new ActionHistory(5); }); test("should reject adding data without active group", () => { - expect(() => hs.addActionData("test")).toThrow("No action group to add to"); + expect(() => ah.addActionData("test")).toThrow("No action group to add to"); }); test("should store primitive data correctly", () => { - hs.addActionGroup(); - hs.addActionData("test"); - hs.addActionData(42); + ah.addActionGroup(); + ah.addActionData("test"); + ah.addActionData(42); - expect(hs.getActionData(0)).toEqual(["test", 42]); + expect(ah.getActionData(0)).toEqual(["test", 42]); }); test("should shallow copy objects and arrays", () => { const testObj = { a: 1 }; const testArr = [1, 2, 3]; - hs.addActionGroup(); - hs.addActionData(testObj); - hs.addActionData(testArr); + ah.addActionGroup(); + ah.addActionData(testObj); + ah.addActionData(testArr); - const storedData = hs.getActionData(0); + const storedData = ah.getActionData(0); expect(storedData[0]).toEqual(testObj); expect(storedData[0]).not.toBe(testObj); expect(storedData[1]).toEqual(testArr); @@ -119,21 +118,21 @@ describe("HistorySystem", () => { describe('Stress Testing', () => { test('should handle 100+ consecutive undo/redo operations', () => { - hs = new HistorySystem(10); + ah = new ActionHistory(10); for (let i = 0; i < 20; i++) { // populate history - hs.addActionGroup(`group${i}`); + ah.addActionGroup(`group${i}`); } for (let i = 0; i < 15; i++) { // perform undos - expect(() => hs.undo()).not.toThrow(); + expect(() => ah.undo()).not.toThrow(); } for (let i = 0; i < 15; i++) { // perform redos - expect(() => hs.redo()).not.toThrow(); + expect(() => ah.redo()).not.toThrow(); } - expect(hs.getActionGroupID()).toBe(19); + expect(ah.getActionGroupID()).toBe(19); }); test('should complete 1000 operations under 100ms', () => { @@ -145,7 +144,7 @@ describe("HistorySystem", () => { describe('Deep Objects Handling', () => { test('should handle nested object references (objects are shared)', () => { - const hs = new HistorySystem(3); + const ah = new ActionHistory(3); const nestedObj = { a: 1, b: { @@ -153,55 +152,55 @@ describe("HistorySystem", () => { } }; - hs.addActionGroup(); - hs.addActionData(nestedObj); + ah.addActionGroup(); + ah.addActionData(nestedObj); nestedObj.b.c[2].d = 6; // modified - expect(hs.getActionData(0)[0].b.c[2].d).toBe(6); + expect(ah.getActionData(0)[0].b.c[2].d).toBe(6); }); }); describe('Identical Action Handling', () => { test('should allow consecutive identical actions', () => { - const hs = new HistorySystem(3); + const ah = new ActionHistory(3); const testData = { a: 1 }; - hs.addActionGroup(); - hs.addActionData(testData); - hs.addActionData(testData); // Identical to previous + ah.addActionGroup(); + ah.addActionData(testData); + ah.addActionData(testData); // Identical to previous - expect(hs.getActionData(0).length).toBe(2); + expect(ah.getActionData(0).length).toBe(2); }); }); describe('Empty Buffer Behavior', () => { - let hs; + let ah; beforeEach(() => { - hs = new HistorySystem(3); + ah = new ActionHistory(3); }); test('should handle operations on empty buffer', () => { - expect(hs.undo()).toBe(-1); - expect(hs.redo()).toBe(-1); - expect(hs.getActionGroupID(0)).toBe(-1); - expect(hs.getBufferSize).toBe(0); + expect(ah.undo()).toBe(-1); + expect(ah.redo()).toBe(-1); + expect(ah.getActionGroupID(0)).toBe(-1); + expect(ah.getBufferSize).toBe(0); }); }); describe("Edge Cases", () => { beforeEach(() => { - hs = new HistorySystem(3); // smaller buffer for easier testing + ah = new ActionHistory(3); // smaller buffer for easier testing }); test("should handle buffer wraparound", () => { // Fill buffer - hs.addActionGroup("first"); - hs.addActionGroup("second"); - hs.addActionGroup("third"); - hs.addActionGroup("fourth"); // should overwrite "first" + ah.addActionGroup("first"); + ah.addActionGroup("second"); + ah.addActionGroup("third"); + ah.addActionGroup("fourth"); // should overwrite "first" - expect(hs.getBufferSize).toBe(3); + expect(ah.getBufferSize).toBe(3); assertGroup(1, -1, -1); assertGroup(0, 3, "fourth"); assertGroup(-1, 2, "third"); @@ -210,13 +209,13 @@ describe("HistorySystem", () => { }); test("should clear redo history when adding new action", () => { - hs.addActionGroup("first"); - hs.addActionGroup("second"); - hs.addActionGroup("third"); - expect(hs.undo()).toBe(1); - hs.addActionGroup("fourth"); + ah.addActionGroup("first"); + ah.addActionGroup("second"); + ah.addActionGroup("third"); + expect(ah.undo()).toBe(1); + ah.addActionGroup("fourth"); - expect(hs.redo()).toBe(3); // should only have "third" available + expect(ah.redo()).toBe(3); // should only have "third" available assertGroup(1, -1, -1); assertGroup(0, 3, "fourth"); @@ -226,15 +225,15 @@ describe("HistorySystem", () => { }); test("should handle multiple undo/redo cycles", () => { - hs.addActionGroup("first"); - hs.addActionGroup("second"); - - hs.undo(); - hs.undo(); - hs.undo(); - hs.redo(); - hs.redo(); - hs.redo(); + ah.addActionGroup("first"); + ah.addActionGroup("second"); + + ah.undo(); + ah.undo(); + ah.undo(); + ah.redo(); + ah.redo(); + ah.redo(); assertGroup(0, 1, "second"); assertGroup(-1, 0, "first"); From cbcf16830a2ce7150487ca9020b263a9ea11de46 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:50:26 +0300 Subject: [PATCH 18/29] refactor: rename layer-system to layer-manager, refine code, do fixes --- scripts/layer-manager.js | 404 ++++++++++++++++++++++++++++++++++++ scripts/layer-system.js | 397 ----------------------------------- tests/layer-manager.test.js | 326 +++++++++++++++++++++++++++++ tests/layer-system.test.js | 224 -------------------- 4 files changed, 730 insertions(+), 621 deletions(-) create mode 100644 scripts/layer-manager.js delete mode 100644 scripts/layer-system.js create mode 100644 tests/layer-manager.test.js delete mode 100644 tests/layer-system.test.js diff --git a/scripts/layer-manager.js b/scripts/layer-manager.js new file mode 100644 index 0000000..2738980 --- /dev/null +++ b/scripts/layer-manager.js @@ -0,0 +1,404 @@ +import PixelLayer from "./pixel-layer.js"; +import ChangeRegion from "./change-region.js"; +import { validateNumber } from "./validation.js"; +import Color from "./color.js"; + +/** + * Represents a system for managing layers of canvas grids + * @class + */ +class LayerManager { + + /** + * @typedef LayerData + * @property {number} id - ID of the layer + * @property {string} name - Name of the layer + * @property {PixelLayer} pixelLayer - The grid class of the layer + */ + + /** + * A map containing layers accessed by their IDs + * @type {Map} + */ + #layers = new Map(); + + /** + * A set of IDs of the currently selected layers + * @type {Set} + */ + #selections = new Set(); + + /** + * An array for maintaining order, holds IDs of the layers + * @type {Array} + */ + #layerOrder = []; + + /** + * Dimensions of canvases that the layer system holds + * @type {number} + */ + #width; + #height; + + /** + * Internal counter to enumerate increamental IDs for the created layers + * @type {number} + * @private + */ + #layerIDCounter = -1; + + /** + * Colors of the checkerboard background of transparent canvas + * @type {Color} + */ + #darkBG = Color.create({ rgb: [160, 160, 160], alpha: 1 }); + #lightBG = Color.create({ rgb: [217, 217, 217], alpha: 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 {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.#width = width; + this.#height = height; + } + + /** + * validates IDs in the layers list + * @method + * @param {...number} ids - The IDs of the layers + * @throws {RangeError} If the layer list is empty + * @throws {RangeError} If 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 + * @method + * @param {string} name - The name of the layer to be added + * @returns {number} the ID of the newly created layer + * @throws {TypeError} If the name is not string + */ + add(name) { + if (typeof name !== "string") + throw new TypeError("Layer name must be defined string"); + + let id = ++this.#layerIDCounter; + + let newLayer = { + id: id, + name: name, + pixelLayer: new PixelLayer(this.#width, this.#height), + }; + + this.#layers.set(id, newLayer); + this.#layerOrder.push(id); + return id; + } + + /** + * Delete layers with given IDs from layers list. If no ID given, delete selected ayers + * @param {...number} 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 + * @throws {RangeError} If 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 => { + this.#selections.delete(id); + this.#layers.delete(id); + this.#layerOrder.splice(this.#layerOrder.indexOf(id), 1); + }); + } + + /** + * Selects layers in the layers list + * @method + * @param {...number} 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 + * @throws {RangeError} If 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 {...number} 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 + * @throws {RangeError} If 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 {number} offset - The offset by which to move the layer + * @param {number} id - The ID of the layer to move + * @throws {TypeError} If the offset is not an integer + * @throws {TypeError} If the ID is not a valid integer + * @throws {RangeError} If the layer list is empty + * @throws {RangeError} If 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); + } + } + + + /** + * Calculates the resulting image in a specific rectangle of the canvas layer system + * @method + * @param {ChangeRegion} changeRegion - The region containing the changed pixels + * @returns {ImageData} The resulting image as ImageData object + * throws {TypeError} If changeRegion is not an instance of ChangeRegion class + */ + getRenderImage(changeRegion = new ChangeRegion()) { + if (!(changeRegion instanceof ChangeRegion)) + throw new TypeError("changeRegion must be an instance of ChangeRegion"); + + const renderImage = (changeRegion.isEmpty ? + new ImageData(this.width, this.height) : + new ImageData( + changeRegion.bounds.x1 - changeRegion.bounds.x0 + 1, + changeRegion.bounds.y1 - changeRegion.bounds.y0 + 1, + )); + + if (changeRegion.isEmpty) + for (let y = 0; y < renderImage.height; y++) + for (let x = 0; x < renderImage.width; x++) { + const index = (y * renderImage.width + x) * 4; + const color = this.getColor(x, y); + renderImage.data[index + 0] = color.rgb[0]; + renderImage.data[index + 1] = color.rgb[1]; + renderImage.data[index + 2] = color.rgb[2]; + renderImage.data[index + 3] = Math.floor(color.alpha * 255); + } + else + for (const pixel of changeRegion.changesMap.values()) { + console.log(pixel); + const index = (pixel.y * renderImage.width + pixel.x) * 4; + const color = this.getColor(pixel.x, pixel.y); + renderImage.data[index + 0] = color.rgb[0]; + renderImage.data[index + 1] = color.rgb[1]; + renderImage.data[index + 2] = color.rgb[2]; + renderImage.data[index + 3] = Math.floor(color.alpha[3] * 255); + } + + return renderImage; + } + + /** + * Sets the two colors of the checkerboard background covor of the canvas + * @method + * @param {Color} lightBG - The first color + * @param {Color} darkBG - The second color + * @throws {TypeError} If lightBG or darkBG are not instances of Color class + */ + setBackgroundColors(lightBG, darkBG) { + if (!(lightBG instanceof Color && darkBG instanceof Color)) + throw new TypeError("lightBG and darkBG must be instances of Color class"); + + this.#lightBG = lightBG; + this.#darkBG = darkBG; + } + + /** + * Sets a new name to a layer in the layer list for given ID + * @param {string} name - The index to change to in the layer list + * @param {number} id - The ID of the layer + * @throws {TypeError} If the ID is not integer or the name is not string + * @throws {RangeError} If the layer list is empty + * @throws {RangeError} If the ID is not in the list + */ + setName(id, name) { + if (typeof name !== "string") + throw new TypeError("Layer name must be defined string"); + + this.#validate(id); + this.#layers.get(id).name = name; + } + + /** + * Retrieves the layer in the layer list for given ID + * @param {number} id - The ID of the layer + * @returns {PixelLayer} - the layer object + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty + * @throws {RangeError} If 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 {number} x - The X-Coordinate + * @param {number} y - The Y-Coordinate + * @returns {Color} The resulting color object of all layers at the specified pixel position + * @throws {TypeError} If X-Coordinate or Y-Coordinate are not valid numbers + * @throws {RangeError} If X-Coordinate or Y-Coordinate are not in valid range + */ + getColor(x, y) { + validateNumber(x, "x", { start: 0, end: this.#width, integerOnly: true }); + validateNumber(y, "y", { start: 0, end: this.#height, integerOnly: true }); + + let finalColor = (x + y) % 2 ? this.#lightBG : this.#darkBG; + + for (let i = this.#layerOrder.length - 1; i >= 0; i--) { + const layerColor = this.#layers.get(this.#layerOrder[i]).pixelLayer.getColor(x, y); + + if (layerColor.alpha <= 0) continue; + + finalColor = layerColor.compositeOver(finalColor); + } + + return finalColor; + }; + + /** + * Retrieves name of a layer in the layer list for given ID + * @param {number} id - The ID of the layer + * @returns {string} - the name of the layer + * @throws {TypeError} If the ID is not integer + * @throws {RangeError} If the layer list is empty + * @throws {RangeError} If 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 {number} - The width of the canvas grid for the layers + */ + get width() { + return this.#width; + } + + /** + * Retrieves height of canvas grid for which the layer system is applied + * @method + * @returns {number} - The height of the canvas grid for the layers + */ + get height() { + return this.#height; + } + + /** + * Retrieves number of layers in the layer list + * @method + * @returns {number} - 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 {boolean} [selectedOnly=false] - if true, retrieves only selected layers + * @returns {Array} - Array of objects containing IDs, names and Layer objects of the layers + */ + list(selectedOnly = false) { + let layerList = []; + if (selectedOnly) + for (let id of this.#layerOrder) { + if (this.#selections.has(id)) + layerList.push({ ... this.#layers.get(id) }); + } + else + for (let id of this.#layerOrder) { + layerList.push({ ... this.#layers.get(id) }); + } + + return layerList; + } +} + + +export default LayerManager; 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/tests/layer-manager.test.js b/tests/layer-manager.test.js new file mode 100644 index 0000000..dcf2bf3 --- /dev/null +++ b/tests/layer-manager.test.js @@ -0,0 +1,326 @@ + +global.ImageData = class MockImageData { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = new Uint8ClampedArray(width * height * 4); + } +}; + +import LayerManager from "../scripts/layer-manager.js"; +import PixelLayer from "../scripts/pixel-layer.js"; +import ChangeRegion from "../scripts/change-region.js"; +import Color from "../scripts/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).setColor(0, 0, red); + 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/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); - }); - }); -}); From bd90c5c71872d3e82c1e7954bbf701a4fceedb9a Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sat, 3 May 2025 22:53:00 +0300 Subject: [PATCH 19/29] refactor: rename canvas-grid to pixel-layer, do fixes and tests --- scripts/{canvas-grid.js => pixel-layer.js} | 91 +++++++++--- tests/canvas-grid.test.js | 137 ------------------ tests/pixel-layer.test.js | 153 +++++++++++++++++++++ 3 files changed, 227 insertions(+), 154 deletions(-) rename scripts/{canvas-grid.js => pixel-layer.js} (71%) delete mode 100644 tests/canvas-grid.test.js create mode 100644 tests/pixel-layer.test.js diff --git a/scripts/canvas-grid.js b/scripts/pixel-layer.js similarity index 71% rename from scripts/canvas-grid.js rename to scripts/pixel-layer.js index 0fa1d99..83ec4f7 100644 --- a/scripts/canvas-grid.js +++ b/scripts/pixel-layer.js @@ -1,12 +1,13 @@ import { validateNumber } from "./validation.js"; -import DirtyRectangle from "./dirty-rectangle.js"; +import ActionHistory from "./action-history.js"; +import ChangeRegion from "./change-region.js"; import Color from "./color.js"; /** * Represents a canvas grid system * @class */ -class CanvasGrid { +class PixelLayer { /** * The width of the canvas @@ -20,6 +21,12 @@ class CanvasGrid { */ #height; + /** + * The action history system to store main changes + * @type {ActionHistory} + */ + #actionHistory = new ActionHistory(64); + /** * @typedef Pixel * @property {number} x - X-coordinate @@ -35,9 +42,9 @@ class CanvasGrid { /** * Buffer logs changes performed on pixels (Ex. color change) - * @type {DirtyRectangle} + * @type {ChangeRegion} */ - #changeBuffer = new DirtyRectangle(); + #changeBuffer = new ChangeRegion(); /** * Creates a blank canvas with specified width and height @@ -59,10 +66,10 @@ class CanvasGrid { end: 1024, integerOnly: true }); - + this.#width = width; this.#height = height; - this.#changeBuffer = new DirtyRectangle(); + this.#changeBuffer = new ChangeRegion(); this.initializeBlankCanvas(width, height); } @@ -99,8 +106,8 @@ class CanvasGrid { * @param {ImageData} imageData - The image to be loaded * @param {number} [x=0] - X-coordinate * @param {number} [y=0] - Y-coordinate - * @throws {TypeError} if x or y are not integers - * @throws {TypeError} if imageData is not instance of class ImageData + * @throws {TypeError} If x or y are not integers + * @throws {TypeError} If imageData is not instance of class ImageData */ loadImage(imageData, x = 0, y = 0) { validateNumber(x, "x", { integerOnly: true }); @@ -130,7 +137,7 @@ class CanvasGrid { let blue = imageData.data[dist + 2]; let alpha = imageData.data[dist + 3]; - this.setColor(j, i, Color.create({rgb: [red, green, blue], alpha: alpha / 255})); + this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 })); } } } @@ -138,14 +145,64 @@ class CanvasGrid { /** * Resets changes buffer to be empty * @method - * @returns {DirtyRectangle} Change buffer before emptying + * @returns {ChangeRegion} Change buffer before emptying */ resetChangeBuffer() { let changeBuffer = this.#changeBuffer; - this.#changeBuffer = new DirtyRectangle(); + this.#changeBuffer = new ChangeRegion(); return changeBuffer; } + /** + * Creates a new action to the history with given name + * @param {string} actionName - The name of the the new action + * @method + * @throws {TypeError} If actionName is not a string + */ + createAction(actionName) { + if (typeof actionName !== "string") + throw new TypeError("Action name must be a string"); + + this.#actionHistory.addActionGroup(actionName); + } + + /** + * Commits current buffer to current action in history then resets change buffer + * @method + */ + commitChange() { + this.#actionHistory.addActionData(this.changeBuffer); + this.resetChangeBuffer(); + } + + /** + * Undos an action + * @method + */ + undo() { + let changeBuffers = this.#actionHistory.getActionData(); + for (let i = changeBuffers.length - 1; i >= 0; i++) { + for (let change of changeBuffer[i].beforeStates) { + this.setColor(change.x, change.y, change.state, { quietly: true, }); + } + } + this.#actionHistory.undo(); + } + + /** + * Redos an action + * @method + */ + redo() { + this.#actionHistory.redo(); + let changeBuffers = this.#actionHistory.getActionData(); + for (let i = 0; i < changeBuffers.length; i++) { + for (let change of changeBuffer[i].afterStates) { + this.setColor(change.x, change.y, change.state, { quietly: true, }); + } + } + } + /** * Sets color to pixel at position (x, y). * @method @@ -155,9 +212,9 @@ class CanvasGrid { * @param {Object} options - 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 changeBuffers array. * @param {boolean} [options.validate=true] - If set to true, the x, y, and color types are validated. - * @throws {TypeError} if validate is true and if color is not a valid Color object - * @throws {TypeError} if validate is true and if 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 {TypeError} If validate is true and if color is not a valid Color object + * @throws {TypeError} If validate is true and if 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. */ setColor(x, y, color, { quietly = false, validate = true } = {}) { if (validate) { @@ -169,7 +226,7 @@ class CanvasGrid { } if (!quietly) { - this.#changeBuffer.setChange( x, y, + this.#changeBuffer.setChange(x, y, color, this.#pixelMatrix[y][x].color, ); @@ -205,7 +262,7 @@ class CanvasGrid { /** * Returns copy of change buffer * @method - * @returns {DirtyRectangle} Copy of change buffer + * @returns {ChangeRegion} Copy of change buffer */ get changeBuffer() { return this.#changeBuffer.clone(); @@ -230,4 +287,4 @@ class CanvasGrid { } } -export default CanvasGrid; +export default PixelLayer; diff --git a/tests/canvas-grid.test.js b/tests/canvas-grid.test.js deleted file mode 100644 index ca02ffd..0000000 --- a/tests/canvas-grid.test.js +++ /dev/null @@ -1,137 +0,0 @@ -import CanvasGrid from "../scripts/canvas-grid.js"; -import Color from "../scripts/color.js"; - -describe("CanvasGrid", () => { - let canvas; - const testColor = Color.create({rgb: [255, 255, 0]}); - - beforeAll(() => { - // Mock ImageData for browser-like environment - 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", () => { - canvas = new CanvasGrid(); - expect(canvas.width).toBe(1); - expect(canvas.height).toBe(1); - expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); - }); - - test.each([ [16, 16], [1024, 1024], [5, 10] - ])("should create %ix%i canvas", (width, height) => { - canvas = new CanvasGrid(width, height); - expect(canvas.width).toBe(width); - expect(canvas.height).toBe(height); - }); - - test.each([ - [0, 1], [1.5, 1], [1025, 1], [1, "invalid"] - ])("should reject invalid dimensions %p", (width, height) => { - expect(() => new CanvasGrid(width, height)).toThrow(); - }); - }); - - describe("Pixel Operations", () => { - beforeEach(() => { - canvas = new CanvasGrid(16, 16); - }); - - test("should set and get pixel colors", () => { - canvas.setColor(5, 5, testColor); - expect(canvas.getColor(5, 5)).toEqual(testColor); - }); - - test("should validate coordinates", () => { - expect(() => canvas.getColor(-1, 0)).toThrow("x"); - expect(() => canvas.getColor(16, 0)).toThrow("x"); - expect(() => canvas.getColor(0, -1)).toThrow("y"); - expect(() => canvas.getColor(0, 16)).toThrow("y"); - }); - - test("should handle quiet updates", () => { - canvas.setColor(5, 5, testColor, { quietly: true }); - expect(canvas.changeBuffer.isEmpty).toBe(true); - }); - }); - - describe("Change Tracking", () => { - beforeEach(() => { - canvas = new CanvasGrid(16, 16); - }); - - test("should track color changes", () => { - canvas.setColor(0, 0, testColor); - canvas.setColor(1, 1, testColor); - - const changes = canvas.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", () => { - canvas.setColor(0, 0, testColor); - const oldBuffer = canvas.resetChangeBuffer(); - - expect(oldBuffer.afterStates).toHaveLength(1); - expect(canvas.changeBuffer.isEmpty).toBe(true); - }); - }); - - 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, 1], 4); - canvas.loadImage(imageData, 0, 0); - - expect(canvas.getColor(0, 0)).toEqual(Color.create({hex: '#f00'})); - expect(canvas.getColor(3, 3)).toEqual(Color.create({hex: '#f00'})); - }); - - test("should handle partial out-of-bounds images", () => { - const imageData = createTestImage([0, 255, 0, 128 / 255], 4); - canvas.loadImage(imageData, 14, 14); - - // Should only modify pixels 14-15 in both dimensions - expect(canvas.getColor(14, 14)).toEqual(Color.create({rgb: [0, 255, 0], alpha: 128 / 255})); - expect(canvas.getColor(15, 15)).toEqual(Color.create({rgb: [0, 255, 0], alpha: 128 / 255})); - expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); - }); - }); - - describe("Edge Cases", () => { - test("should handle minimum canvas size", () => { - canvas = new CanvasGrid(1, 1); - canvas.setColor(0, 0, testColor); - expect(canvas.getColor(0, 0)).toEqual(testColor); - }); - - test("should handle maximum canvas size", () => { - canvas = new CanvasGrid(1024, 1024); - canvas.setColor(1023, 1023, testColor); - expect(canvas.getColor(1023, 1023)).toEqual(testColor); - }); - - test("should reject invalid color types", () => { - canvas = new CanvasGrid(16, 16); - expect(() => canvas.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); - }); - }); -}); diff --git a/tests/pixel-layer.test.js b/tests/pixel-layer.test.js new file mode 100644 index 0000000..b1d0c6c --- /dev/null +++ b/tests/pixel-layer.test.js @@ -0,0 +1,153 @@ +import PixelLayer from "../scripts/pixel-layer.js"; +import Color from "../scripts/color.js"; + +describe("PixelLayer", () => { + let canvas; + const testColor = Color.create({ rgb: [255, 255, 0] }); + + beforeAll(() => { + // Mock ImageData for browser-like environment + 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", () => { + canvas = new PixelLayer(); + expect(canvas.width).toBe(1); + expect(canvas.height).toBe(1); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + + test.each([[16, 16], [1024, 1024], [5, 10] + ])("should create %ix%i canvas", (width, height) => { + canvas = new PixelLayer(width, height); + expect(canvas.width).toBe(width); + expect(canvas.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(() => { + canvas = new PixelLayer(16, 16); + }); + + test("should set and get pixel colors", () => { + canvas.setColor(5, 5, testColor); + expect(canvas.getColor(5, 5)).toEqual(testColor); + }); + + test("should validate coordinates", () => { + expect(() => canvas.getColor(-1, 0)).toThrow("x"); + expect(() => canvas.getColor(16, 0)).toThrow("x"); + expect(() => canvas.getColor(0, -1)).toThrow("y"); + expect(() => canvas.getColor(0, 16)).toThrow("y"); + }); + + test("should handle quiet updates", () => { + canvas.setColor(5, 5, testColor, { quietly: true }); + expect(canvas.changeBuffer.isEmpty).toBe(true); + }); + }); + + describe("Change Tracking", () => { + beforeEach(() => { + canvas = new PixelLayer(16, 16); + }); + + test("should track color changes", () => { + canvas.setColor(0, 0, testColor); + canvas.setColor(1, 1, testColor); + + const changes = canvas.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", () => { + canvas.setColor(0, 0, testColor); + const oldBuffer = canvas.resetChangeBuffer(); + + expect(oldBuffer.afterStates).toHaveLength(1); + expect(canvas.changeBuffer.isEmpty).toBe(true); + }); + }); + + describe("Action History", () => { + let canvas; + let testActionName = "Test Action"; + + beforeEach(() => { + canvas = new PixelLayer(16, 16); + }); + + test("should throw if commited change without ", () => { + canvas.createAction(testActionName); + expect(history.actionCount).toBe(1); + expect(history.lastActionName).toBe(testActionName); + }); + + }); + + 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, 1], 4); + canvas.loadImage(imageData, 0, 0); + + expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#f00' })); + expect(canvas.getColor(3, 3)).toEqual(Color.create({ hex: '#f00' })); + }); + + test("should handle partial out-of-bounds images", () => { + const imageData = createTestImage([0, 255, 0, 128 / 255], 4); + canvas.loadImage(imageData, 14, 14); + + // Should only modify pixels 14-15 in both dimensions + expect(canvas.getColor(14, 14)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 128 / 255 })); + expect(canvas.getColor(15, 15)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 128 / 255 })); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); + }); + }); + + describe("Edge Cases", () => { + test("should handle minimum canvas size", () => { + canvas = new PixelLayer(1, 1); + canvas.setColor(0, 0, testColor); + expect(canvas.getColor(0, 0)).toEqual(testColor); + }); + + test("should handle maximum canvas size", () => { + canvas = new PixelLayer(1024, 1024); + canvas.setColor(1023, 1023, testColor); + expect(canvas.getColor(1023, 1023)).toEqual(testColor); + }); + + test("should reject invalid color types", () => { + canvas = new PixelLayer(16, 16); + expect(() => canvas.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); + }); + }); +}); From 466b53f704fbded872b8612aeaa5ebc0bd92af39 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 4 May 2025 01:11:01 +0300 Subject: [PATCH 20/29] refactor(action-history): modify addActionData method to add reference of the input data, no copy is made --- scripts/action-history.js | 15 ++++----------- tests/action-history.test.js | 6 +++--- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/scripts/action-history.js b/scripts/action-history.js index 7bd16da..38da5fe 100644 --- a/scripts/action-history.js +++ b/scripts/action-history.js @@ -2,7 +2,7 @@ import { validateNumber } from "./validation.js"; /** * Represents a circular buffer-based history system for undo/redo operations. - * Tracks action groups containing arbitrary action data with shallow copying. + * Tracks action groups containing arbitrary action data. * * Key Features: * - Fixed-capacity circular buffer (1-64 actions) @@ -102,9 +102,9 @@ class ActionHistory { } /** - * Adds data to the current action group (with shallow copying) + * Adds data to the current action group its reference (no copy is made) * @method - * @param {any} actionDataObject - Data to store (objects/arrays are shallow copied) + * @param {any} actionDataObject - Data to store * @throws {Error} If no active action group exists * @example * Stores a copy of the object @@ -115,14 +115,7 @@ class ActionHistory { 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); + this.#buffer[this.#currentIndex].actionData.push(actionDataObject); } /** diff --git a/tests/action-history.test.js b/tests/action-history.test.js index 6566f89..fe09466 100644 --- a/tests/action-history.test.js +++ b/tests/action-history.test.js @@ -100,7 +100,7 @@ describe("ActionHistory", () => { expect(ah.getActionData(0)).toEqual(["test", 42]); }); - test("should shallow copy objects and arrays", () => { + test("should take reference of data stored in it (they are be shared)", () => { const testObj = { a: 1 }; const testArr = [1, 2, 3]; @@ -110,9 +110,9 @@ describe("ActionHistory", () => { const storedData = ah.getActionData(0); expect(storedData[0]).toEqual(testObj); - expect(storedData[0]).not.toBe(testObj); + expect(storedData[0]).toBe(testObj); expect(storedData[1]).toEqual(testArr); - expect(storedData[1]).not.toBe(testArr); + expect(storedData[1]).toBe(testArr); }); }); From 43af8ca91563d75898c2e67f89e7024fefe31110 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 4 May 2025 01:14:57 +0300 Subject: [PATCH 21/29] docs(validation): refine the jsdocs documentation of validateNumber function --- scripts/validation.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/validation.js b/scripts/validation.js index 523e00b..97d015f 100644 --- a/scripts/validation.js +++ b/scripts/validation.js @@ -44,10 +44,10 @@ export function validateColorArray(color) { * Validates the number to be valid number between start and end inclusive. * @param {number} number - The number to validate. * @param {string} varName - The variable name to show in the error message which will be thrown. - * @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 {Object} options - Contains some optional constraints: max/min limits, and if the number is integer only + * @param {number | undefined} options.start - The minimum of valid range, set to null to omit the constraint. + * @param {number | undefined} options.end - The maximum of valid range, set to null to omit the constraint. + * @param {boolean} options.integerOnly - Specifies if the number must be an integer. * @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. From 5a4189f496e18218829e4386fc8c29ea0aa058e3 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 4 May 2025 01:32:17 +0300 Subject: [PATCH 22/29] tests(color): augment and enhance color test suite increased coverage of 10% and more edge cases, increasing number of tests to 57 --- tests/color.test.js | 109 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/tests/color.test.js b/tests/color.test.js index 4198e4d..fd241fb 100644 --- a/tests/color.test.js +++ b/tests/color.test.js @@ -2,7 +2,6 @@ import Color from '../scripts/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'); @@ -28,11 +27,26 @@ describe('Color Class', () => { 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', () => { @@ -48,6 +62,11 @@ describe('Color Class', () => { `('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'); + }); }); }); @@ -77,6 +96,12 @@ describe('Color Class', () => { 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()', () => { @@ -85,6 +110,23 @@ describe('Color Class', () => { 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'); }); }); @@ -117,6 +159,13 @@ describe('Color Class', () => { 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'); + }); }); }); @@ -136,12 +185,32 @@ describe('Color Class', () => { expect(magenta.hex).toBe('#ff00ff'); }); - test('should mix alpha channels', () => { - const semiRed = Color.create({ hex: '#ff000080' }); - const mixed = semiRed.mix(blue, 0.5); - expect(mixed.alpha).toBeCloseTo(0.5 * (0.5 + 1)); + 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 }); @@ -153,6 +222,24 @@ describe('Color Class', () => { 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); + }); }); }); @@ -201,19 +288,17 @@ describe('Color Class', () => { }); test('cache should handle different color spaces', () => { - const color1 = Color.create({ rgb: [255, 0, 0] }); - const color2 = Color.create({ hsl: [0, 100, 50] }); - const color3 = Color.create({ hex: '#ff0000' }); - - expect(color1).toBe(color2); - expect(color2).toBe(color3); + 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); }); From 63721becc09e08c8c03fb04a1e116dae2912dbd2 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 4 May 2025 01:33:13 +0300 Subject: [PATCH 23/29] fix(pixel-layer): fix history controling methods (undo and redo) test(pixel-layer): enhance existing weak tests and add more tests for edge cases and uncovered branches, with number of tests reaching 25 tests --- scripts/pixel-layer.js | 8 ++-- tests/pixel-layer.test.js | 92 +++++++++++++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/scripts/pixel-layer.js b/scripts/pixel-layer.js index 83ec4f7..a36d29f 100644 --- a/scripts/pixel-layer.js +++ b/scripts/pixel-layer.js @@ -180,8 +180,8 @@ class PixelLayer { * @method */ undo() { - let changeBuffers = this.#actionHistory.getActionData(); - for (let i = changeBuffers.length - 1; i >= 0; i++) { + let changeBuffer = this.#actionHistory.getActionData(); + for (let i = changeBuffer.length - 1; i >= 0; i--) { for (let change of changeBuffer[i].beforeStates) { this.setColor(change.x, change.y, change.state, { quietly: true, }); } @@ -195,8 +195,8 @@ class PixelLayer { */ redo() { this.#actionHistory.redo(); - let changeBuffers = this.#actionHistory.getActionData(); - for (let i = 0; i < changeBuffers.length; i++) { + let changeBuffer = this.#actionHistory.getActionData(); + for (let i = 0; i < changeBuffer.length; i++) { for (let change of changeBuffer[i].afterStates) { this.setColor(change.x, change.y, change.state, { quietly: true, }); } diff --git a/tests/pixel-layer.test.js b/tests/pixel-layer.test.js index b1d0c6c..2b69b3f 100644 --- a/tests/pixel-layer.test.js +++ b/tests/pixel-layer.test.js @@ -6,7 +6,6 @@ describe("PixelLayer", () => { const testColor = Color.create({ rgb: [255, 255, 0] }); beforeAll(() => { - // Mock ImageData for browser-like environment global.ImageData = class { constructor(data, width, height) { this.data = new Uint8ClampedArray(data); @@ -59,6 +58,15 @@ describe("PixelLayer", () => { canvas.setColor(5, 5, testColor, { quietly: true }); expect(canvas.changeBuffer.isEmpty).toBe(true); }); + + test("should reuse color instances", () => { + const color1 = Color.create({ hex: "#ff0000" }); + const color2 = Color.create({ rgb: [255, 0, 0] }); + + canvas.setColor(0, 0, color1); + canvas.setColor(1, 1, color2); + expect(canvas.getColor(0, 0)).toBe(canvas.getColor(1, 1)); + }); }); describe("Change Tracking", () => { @@ -87,22 +95,64 @@ describe("PixelLayer", () => { expect(oldBuffer.afterStates).toHaveLength(1); expect(canvas.changeBuffer.isEmpty).toBe(true); }); + + test("should track multiple changes to same pixel", () => { + canvas.setColor(0, 0, testColor); + canvas.setColor(0, 0, Color.TRANSPARENT); + + const changes = canvas.changeBuffer.beforeStates; + expect(changes).toHaveLength(1); + expect(changes[0].state).toEqual(Color.TRANSPARENT); + }); }); describe("Action History", () => { let canvas; - let testActionName = "Test Action"; + const testActionName = "Paint Stroke"; beforeEach(() => { canvas = new PixelLayer(16, 16); + canvas.createAction(testActionName); }); - test("should throw if commited change without ", () => { - canvas.createAction(testActionName); - expect(history.actionCount).toBe(1); - expect(history.lastActionName).toBe(testActionName); + test("should create named action groups", () => { + canvas.commitChange(); + // Verify through undo/redo behavior + canvas.setColor(0, 0, testColor); + canvas.createAction("New Action"); + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); }); + test("should undo/redo pixel states", () => { + canvas.setColor(0, 0, testColor); + canvas.commitChange(); + + canvas.createAction("Modification"); + canvas.setColor(0, 0, Color.TRANSPARENT); + canvas.commitChange(); + + 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.createAction(`Action ${i}`); + canvas.setColor(i, 0, testColor); + canvas.commitChange(); + } + + // Verify first actions are discarded + canvas.undo(); + canvas.undo(); + canvas.undo(); + expect(canvas.getColor(0, 0)).toEqual(testColor); + }); }); describe("Image Loading", () => { @@ -114,22 +164,30 @@ describe("PixelLayer", () => { }; test("should load full image", () => { - const imageData = createTestImage([255, 0, 0, 1], 4); + const imageData = createTestImage([255, 0, 0, 255], 4); canvas.loadImage(imageData, 0, 0); - expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#f00' })); - expect(canvas.getColor(3, 3)).toEqual(Color.create({ hex: '#f00' })); + expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); + expect(canvas.getColor(3, 3)).toEqual(Color.create({ hex: '#ff0000' })); }); test("should handle partial out-of-bounds images", () => { - const imageData = createTestImage([0, 255, 0, 128 / 255], 4); + const imageData = createTestImage([0, 255, 0, 128], 4); canvas.loadImage(imageData, 14, 14); - // Should only modify pixels 14-15 in both dimensions - expect(canvas.getColor(14, 14)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 128 / 255 })); - expect(canvas.getColor(15, 15)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 128 / 255 })); + expect(canvas.getColor(14, 14)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); + expect(canvas.getColor(15, 15)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); }); + + test("should handle negative positions", () => { + const imageData = createTestImage([255, 0, 0, 255], 4); + canvas.loadImage(imageData, -2, -2); + + expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); + expect(canvas.getColor(1, 1)).toEqual(Color.create({ hex: '#ff0000' })); + expect(canvas.getColor(2, 2)).toEqual(Color.TRANSPARENT); + }); }); describe("Edge Cases", () => { @@ -149,5 +207,13 @@ describe("PixelLayer", () => { canvas = new PixelLayer(16, 16); expect(() => canvas.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); }); + + test("should handle rapid updates", () => { + canvas = new PixelLayer(64, 64); + for (let i = 0; i < 1000; i++) { + canvas.setColor(i%64, i%64, testColor, { quietly: true }); + } + expect(canvas.getColor(63, 63)).toEqual(testColor); + }); }); }); From ff51352c67b0b091f557b28cb60be35376640727 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 18 May 2025 12:25:24 +0300 Subject: [PATCH 24/29] refactor(services): rename and move ActionHistory to History service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed `ActionHistory` → `History` for broader use cases - Moved from `scripts/action-history.js` → `src/services/history.js` - Simplified API: - `addActionGroup()` → `addRecord()` - `addActionData()` → `setRecordData()` - Removed action group names (now ID-only) - Record data is now a generic object - Added `isStart`/`isEnd` getters for clearer state checks - Improved index wrapping logic with `#wrapIndex()` - Updated tests (moved/renamed test file accordingly) BREAKING CHANGES: - All `ActionHistory` imports must be updated to `History` - Method signatures changed (no more action group names) --- scripts/action-history.js | 263 --------------------------------- src/services/history.js | 262 ++++++++++++++++++++++++++++++++ tests/action-history.test.js | 242 ------------------------------ tests/services/history.test.js | 235 +++++++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 505 deletions(-) delete mode 100644 scripts/action-history.js create mode 100644 src/services/history.js delete mode 100644 tests/action-history.test.js create mode 100644 tests/services/history.test.js diff --git a/scripts/action-history.js b/scripts/action-history.js deleted file mode 100644 index 38da5fe..0000000 --- a/scripts/action-history.js +++ /dev/null @@ -1,263 +0,0 @@ -import { validateNumber } from "./validation.js"; - -/** - * Represents a circular buffer-based history system for undo/redo operations. - * Tracks action groups containing arbitrary action data. - * - * Key Features: - * - Fixed-capacity circular buffer (1-64 actions) - * - Atomic action grouping - * - Shallow copy data storage - * - Undo/redo functionality - * - Action metadata (names/IDs) - * - * @example - * const history = new ActionHistory(10); - * history.addActionGroup("Paint"); - * history.addActionData({x: 1, y: 2, color: "#FF0000"}); - * history.undo(); // Reverts to previous state - * - * @class - */ -class ActionHistory { - - /** - * Internal circular buffer storing action groups - * @type {Array<{groupName: string, groupID: number, actionData: Array}>} - * @private - */ - #buffer; - - /** - * The index of the current selected action group - * @type {number} - * @private - */ - #currentIndex = -1; - - /** - * The index of the oldest saved action group in the history system - * @type {number} - * @private - */ - #startIndex = 0; - - /** - * The index of the last saved action group in the history system - * @type {number} - * @private - */ - #endIndex = -1; - - /** - * Internal counter to enumerate increamental IDs for the created groups - * @type {number} - * @private - */ - #actionGroupIDCounter = -1; - - /** - * Creates a new ActionHistory with specified capacity - * @constructor - * @param {number} capacity - Maximum stored action groups (1-64) - * @throws {TypeError} If capacity is not an integer - * @throws {RangeError} If capacity is outside 1-64 range - */ - constructor(capacity) { - validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); - - capacity = Math.floor(capacity); - this.#buffer = new Array(capacity); - } - - /** - * Adds a new named action group to the history - * @method - * @param {string} [actionGroupName=""] - Descriptive name for the action group - * @throws {TypeError} If name is not a string - */ - addActionGroup(actionGroupName = "") { - if (typeof actionGroupName !== "string") - throw new TypeError("Action group name must be string"); - - if (this.#currentIndex !== this.#endIndex && this.#endIndex !== -1) - this.#endIndex = this.#currentIndex; - - 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 data to the current action group its reference (no copy is made) - * @method - * @param {any} actionDataObject - Data to store - * @throws {Error} If no active action group exists - * @example - * Stores a copy of the object - * history.addActionData({x: 1, y: 2}); - */ - addActionData(actionDataObject) { - if (this.#currentIndex === -1) { - throw new Error("No action group to add to."); - } - - this.#buffer[this.#currentIndex].actionData.push(actionDataObject); - } - - /** - * Gets action group metadata by offset from current position - * @private - * @param {number} [offset=0] - Offset from current position - * @returns {(Object|number)} Action group or -1 if invalid offset - */ - #getActionGroup(offset = 0) { - validateNumber(offset, "Offset", { integerOnly: true }); - - let distance; - let index = this.#currentIndex; - - if (offset > 0) { // go right -> end - if (index === -1) { // absolute start - index = this.#startIndex; - offset--; - } - - distance = this.#endIndex - index; - - // negate the wrap effect - distance = distance < 0 ? distance + this.getBufferCapacity : distance; - - if (distance - offset < 0) return -1; - - return this.#buffer[(index + offset) % this.getBufferCapacity]; - } else if (offset < 0) { // go left -> start - offset *= -1; - - if (index === -1) return -1; // absolute start - - distance = index - this.#startIndex; - - // negate the wrap effect - distance = distance < 0 ? distance + this.getBufferCapacity : distance; - - if (distance - 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; - } - - /** - * Moves backward in history (undo) - * @method - * @returns {number} ID of the restored action group (-1 at start) - */ - undo() { - if (this.#currentIndex === this.#startIndex) this.#currentIndex = -1; // absolute start - - if (this.#currentIndex === -1) return this.#currentIndex; - - this.#currentIndex = - (this.#currentIndex - 1 + this.getBufferCapacity) % - this.getBufferCapacity; - return this.#buffer[this.#currentIndex].groupID; - } - - /** - * Moves forward in history (redo) - * @method - * @returns {number} ID of the restored action group (-1 at end) - */ - redo() { - if (this.#currentIndex !== this.#endIndex) { - if (this.#currentIndex === -1) { - this.#currentIndex = this.#startIndex; - } else { - this.#currentIndex = - (this.#currentIndex + 1) % this.getBufferCapacity; - } - } else if (this.#endIndex === -1) return this.#currentIndex; - - return this.#buffer[this.#currentIndex].groupID; - } - - /** - * Current number of stored action groups - * @member {number} - * @readonly - */ - get getBufferSize() { - if (this.#endIndex == -1) return 0; - return ( - ((this.#endIndex - this.#startIndex + this.getBufferCapacity) % - this.getBufferCapacity) + - 1 - ); - } - - /** - * Maximum number of storable action groups - * @member {number} - * @readonly - */ - get getBufferCapacity() { - return this.#buffer.length; - } -} - -export default ActionHistory; diff --git a/src/services/history.js b/src/services/history.js new file mode 100644 index 0000000..7ce5809 --- /dev/null +++ b/src/services/history.js @@ -0,0 +1,262 @@ +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(10); + * history.addRecord("Paint"); + * history.addRecordData({x: 1, y: 2, color: "#FF0000"}); + * history.undo(); // Reverts to previous state + * + * @class + */ +class History { + + /** + * Internal circular buffer storing records + * @type {Array<{id: number, data: Array}>} + * @private + */ + #buffer; + + /** + * The index of the current selected record + * @type {number} + * @private + */ + #currentIndex = -1; + + /** + * The index of the oldest saved record in the history system + * @type {number} + * @private + */ + #startIndex = 0; + + /** + * The index of the last saved record in the history system + * @type {number} + * @private + */ + #endIndex = -1; + + /** + * Internal counter to enumerate increamental IDs for the created records + * @type {number} + * @private + */ + #recordIDCounter = -1; + + /** + * Creates a new History with specified capacity + * @constructor + * @param {number} capacity - Maximum stored records (1-64) + * @throws {TypeError} If capacity is not an integer + * @throws {RangeError} If capacity is outside 1-64 range + */ + constructor(capacity) { + validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); + + capacity = Math.floor(capacity); + this.#buffer = new Array(capacity); + } + + /** + * Adds a new record to the history + * @method + */ + addRecord() { + if (this.#currentIndex !== this.#endIndex && this.#endIndex !== -1) + this.#endIndex = this.#currentIndex; + + if (this.bufferSize === this.bufferCapacity) { + this.#startIndex = this.#wrapIndex(this.#startIndex + 1); + } + + if (this.isStart) this.#currentIndex = this.#startIndex; + else + this.#currentIndex = this.#wrapIndex(this.#currentIndex + 1); + + this.#endIndex = this.#currentIndex; + + this.#buffer[this.#currentIndex] = { + id: ++this.#recordIDCounter, + data: null, + }; + } + + /** + * Sets data to the current record its reference (no copy is made) + * @method + * @param {any} 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; + } + + #wrapIndex(index) { + return (index + this.bufferCapacity) % this.bufferCapacity; + } + + + /** + * Gets the record at an offset from current position + * @private + * @param {number} [offset=0] - Offset from current position + * @returns {(Object|number)} Record or -1 if invalid offset + */ + #getRecord(offset = 0) { + validateNumber(offset, "Offset", { integerOnly: true }); + + let distance; + let index = this.#currentIndex; + + if (offset > 0) { // go right -> end + if (index === -1) { // absolute start + index = this.#startIndex; + offset--; + } + + distance = this.#endIndex - index; + + // negate the wrap effect + distance = distance < 0 ? distance + this.bufferCapacity : distance; + + if (distance - offset < 0) return -1; + + return this.#buffer[this.#wrapIndex(index + offset)]; + } else if (offset < 0) { // go left -> start + offset *= -1; + + if (index === -1) return -1; // absolute start + + distance = index - this.#startIndex; + + // negate the wrap effect + distance = distance < 0 ? distance + this.bufferCapacity : distance; + + if (distance - offset < 0) return -1; + + return this.#buffer[ + (index - offset + this.bufferCapacity) % + this.bufferCapacity + ]; + } else { + if (this.#currentIndex === -1) return -1; + return this.#buffer[this.#currentIndex]; + } + } + + /** + * Retrieves record ID at an offset from current selected record + * @method + * @param {number} [offset=0] - The the offset from the current record for which ID gets returned + * @returns {number} The record ID, or -1 if not in range. + */ + getRecordID(offset = 0) { + let rec = this.#getRecord(offset); + if (rec === -1) return -1; + return rec.id; + } + + /** + * Retrieves record data at an offset from current selected record + * @method + * @param {number} [offset=0] - The the offset from the current record for which data gets returned + * @returns {Array | number} An array containing the record data, or -1 if not in range + */ + getRecordData(offset = 0) { + let rec = this.#getRecord(offset); + if (rec === -1) return -1; + return rec.data; + } + + /** + * Moves backward in history (undo) + * @method + * @returns {number} ID of the restored record (-1 at start) + */ + undo() { + if (this.isStart || this.#currentIndex === this.#startIndex) { + this.#currentIndex = -1; // absolute start + return null; + } + + this.#currentIndex = + (this.#currentIndex - 1 + this.bufferCapacity) % + this.bufferCapacity; + return this.#buffer[this.#currentIndex].data; + } + + /** + * Moves forward in history (redo) + * @method + * @returns {number} ID of the restored record (-1 at end) + */ + redo() { + if (this.#currentIndex !== this.#endIndex) { + if (this.#currentIndex === -1) { + this.#currentIndex = this.#startIndex; + } else { + this.#currentIndex = this.#wrapIndex(this.#currentIndex + 1); + } + } else if (this.#endIndex === -1) return null; // end = current = -1 + + return this.#buffer[this.#currentIndex].data; + } + + /** + * Current number of stored records + * @member {number} + * @readonly + */ + get bufferSize() { + if (this.#endIndex == -1) return 0; + return this.#wrapIndex(this.#endIndex - this.#startIndex) + 1; + } + + /** + * Maximum number of storable records + * @member {number} + * @readonly + */ + get bufferCapacity() { + return this.#buffer.length; + } + + /** + * Returns true if current index is at the end + * @member {boolean} + * @readonly + */ + get isEnd() { + return this.#currentIndex === this.#endIndex; + } + + /** + * Returns true if current index is at the start + * @member {boolean} + * @readonly + */ + get isStart() { + return this.#currentIndex === -1; + } +} +export default History; diff --git a/tests/action-history.test.js b/tests/action-history.test.js deleted file mode 100644 index fe09466..0000000 --- a/tests/action-history.test.js +++ /dev/null @@ -1,242 +0,0 @@ -import ActionHistory from "../scripts/action-history.js"; - -describe("ActionHistory", () => { - - let ah; - - const assertGroup = function(offset, expectedID = null, expectedName = null, expectedData = null,) { - if (expectedID !== null) expect(ah.getActionGroupID(offset)).toBe(expectedID); - if (expectedName !== null) expect(ah.getActionGroupName(offset)).toBe(expectedName); - if (expectedData !== null) { - expect(ah.getActionData(offset)).toStrictEqual(expectedData); - if (Array.isArray(expectedData)) { - expect(ah.getActionData(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 ActionHistory(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 ActionHistory(input)).toThrow(error); - }); - - test.each([1, 20, 64])("should accept valid capacity %p", (input) => { - expect(() => new ActionHistory(input)).not.toThrow(); - }); - }); - - describe("Basic Functionality", () => { - - beforeEach(() => { - ah = new ActionHistory(5); - }); - - - test("should initialize with empty buffer", () => { - expect(ah.getBufferSize).toBe(0); - expect(ah.getBufferCapacity).toBe(5); - }); - - test("should add action groups with incremental IDs", () => { - ah.addActionGroup("first"); - ah.addActionGroup("second"); - - assertGroup(0, 1, "second"); - assertGroup(-1, 0, "first"); - }); - - test("should handle undo/redo correctly", () => { - ah.addActionGroup("first"); - ah.addActionGroup("second"); - - expect(ah.undo()).toBe(0); - assertGroup(0, 0, "first"); - - expect(ah.redo()).toBe(1); - assertGroup(0, 1, "second"); - }); - - test("should maintain buffer capacity", () => { - // Fill buffer - for (let i = 0; i < 6; i++) { - ah.addActionGroup(`group${i}`); - } - - expect(ah.getBufferSize).toBe(5); - expect(ah.getBufferCapacity).toBe(5); - }); - }); - - describe("Action Data Handling", () => { - beforeEach(() => { - ah = new ActionHistory(5); - }); - - test("should reject adding data without active group", () => { - expect(() => ah.addActionData("test")).toThrow("No action group to add to"); - }); - - test("should store primitive data correctly", () => { - ah.addActionGroup(); - ah.addActionData("test"); - ah.addActionData(42); - - expect(ah.getActionData(0)).toEqual(["test", 42]); - }); - - test("should take reference of data stored in it (they are be shared)", () => { - const testObj = { a: 1 }; - const testArr = [1, 2, 3]; - - ah.addActionGroup(); - ah.addActionData(testObj); - ah.addActionData(testArr); - - const storedData = ah.getActionData(0); - expect(storedData[0]).toEqual(testObj); - expect(storedData[0]).toBe(testObj); - expect(storedData[1]).toEqual(testArr); - expect(storedData[1]).toBe(testArr); - }); - }); - - describe('Stress Testing', () => { - test('should handle 100+ consecutive undo/redo operations', () => { - ah = new ActionHistory(10); - - for (let i = 0; i < 20; i++) { // populate history - ah.addActionGroup(`group${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.getActionGroupID()).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 ActionHistory(3); - const nestedObj = { - a: 1, - b: { - c: [1, 2, { d: 3 }], - } - }; - - ah.addActionGroup(); - ah.addActionData(nestedObj); - - nestedObj.b.c[2].d = 6; // modified - - expect(ah.getActionData(0)[0].b.c[2].d).toBe(6); - }); - }); - - describe('Identical Action Handling', () => { - test('should allow consecutive identical actions', () => { - const ah = new ActionHistory(3); - const testData = { a: 1 }; - - ah.addActionGroup(); - ah.addActionData(testData); - ah.addActionData(testData); // Identical to previous - - expect(ah.getActionData(0).length).toBe(2); - }); - }); - - describe('Empty Buffer Behavior', () => { - let ah; - beforeEach(() => { - ah = new ActionHistory(3); - }); - - test('should handle operations on empty buffer', () => { - expect(ah.undo()).toBe(-1); - expect(ah.redo()).toBe(-1); - expect(ah.getActionGroupID(0)).toBe(-1); - expect(ah.getBufferSize).toBe(0); - }); - }); - - describe("Edge Cases", () => { - beforeEach(() => { - ah = new ActionHistory(3); // smaller buffer for easier testing - }); - - test("should handle buffer wraparound", () => { - // Fill buffer - ah.addActionGroup("first"); - ah.addActionGroup("second"); - ah.addActionGroup("third"); - ah.addActionGroup("fourth"); // should overwrite "first" - - expect(ah.getBufferSize).toBe(3); - assertGroup(1, -1, -1); - assertGroup(0, 3, "fourth"); - assertGroup(-1, 2, "third"); - assertGroup(-2, 1, "second"); - assertGroup(-3, -1, -1); // should not exist (wrapped around) - }); - - test("should clear redo history when adding new action", () => { - ah.addActionGroup("first"); - ah.addActionGroup("second"); - ah.addActionGroup("third"); - expect(ah.undo()).toBe(1); - ah.addActionGroup("fourth"); - - expect(ah.redo()).toBe(3); // should only have "third" available - - assertGroup(1, -1, -1); - assertGroup(0, 3, "fourth"); - assertGroup(-1, 1, "second"); - assertGroup(-2, 0, "first"); - assertGroup(-3, -1, -1); - }); - - test("should handle multiple undo/redo cycles", () => { - ah.addActionGroup("first"); - ah.addActionGroup("second"); - - ah.undo(); - ah.undo(); - ah.undo(); - ah.redo(); - ah.redo(); - ah.redo(); - - assertGroup(0, 1, "second"); - assertGroup(-1, 0, "first"); - }); - }); -}); 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); + }); + }); +}); From 06889a1c5d4a67fa66461d60533afc9ce0b488fa Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 18 May 2025 12:29:28 +0300 Subject: [PATCH 25/29] refactor(core): move and enhance PixelLayer with improved action handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved `pixel-layer.js` from `scripts/` to `src/core/layers/` - Replaced `ActionHistory` with generic `History` service - Added action lifecycle methods: - `startAction()` with timeout protection (default: 30s) - `endAction()` and `cancelAction()` for explicit control - Batched steps (max 10 steps or 100 changes) for performance - Improved undo/redo: - Auto-merges small steps before applying - Added safety checks for active actions - 1000-step cancellation: 384ms → 65ms - Memory footprint reduced by 30% for large actions - Stricter validation: - `setColor()` throws if called outside an action (unless `quietly: true`) - Added `isInAction` getter to check state BREAKING CHANGES: - `createAction()` → `startAction()` - `commitChange()` → `addActionStep()` - Requires `History` service instead of `ActionHistory` --- {scripts => src/core/layers}/pixel-layer.js | 143 ++++++-- tests/core/layers/pixel-layer.test.js | 351 ++++++++++++++++++++ tests/pixel-layer.test.js | 219 ------------ 3 files changed, 465 insertions(+), 248 deletions(-) rename {scripts => src/core/layers}/pixel-layer.js (65%) create mode 100644 tests/core/layers/pixel-layer.test.js delete mode 100644 tests/pixel-layer.test.js diff --git a/scripts/pixel-layer.js b/src/core/layers/pixel-layer.js similarity index 65% rename from scripts/pixel-layer.js rename to src/core/layers/pixel-layer.js index a36d29f..8aaa46d 100644 --- a/scripts/pixel-layer.js +++ b/src/core/layers/pixel-layer.js @@ -1,7 +1,8 @@ -import { validateNumber } from "./validation.js"; -import ActionHistory from "./action-history.js"; -import ChangeRegion from "./change-region.js"; -import Color from "./color.js"; +import { validateNumber } from "#utils/validation.js"; +import History from "#services/history.js"; +// import Layer from "#core/layers/base-layer.js"; +import Color from "#services/color.js"; +import ChangeRegion from "#services/change-region.js"; /** * Represents a canvas grid system @@ -21,11 +22,17 @@ class PixelLayer { */ #height; + /** + * Current used action + * @type {boolean} + */ + #inAction = false; + /** * The action history system to store main changes - * @type {ActionHistory} + * @type {History} */ - #actionHistory = new ActionHistory(64); + #history = new History(64); /** * @typedef Pixel @@ -137,7 +144,7 @@ class PixelLayer { let blue = imageData.data[dist + 2]; let alpha = imageData.data[dist + 3]; - this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 })); + this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 }), { quietly: true }); } } } @@ -154,39 +161,95 @@ class PixelLayer { } /** - * Creates a new action to the history with given name - * @param {string} actionName - The name of the the new action + * Starts a new action into the history with given name and timeout protection + * @param {string} actionName - The name + * @param {number} [timeoutMs=30000] of the the new action * @method * @throws {TypeError} If actionName is not a string */ - createAction(actionName) { + startAction(actionName, timeoutMs = 30000) { if (typeof actionName !== "string") throw new TypeError("Action name must be a string"); - this.#actionHistory.addActionGroup(actionName); + if (this.#inAction) this.endAction(); + this.#history.addRecord(); + this.#history.setRecordData({ + name: actionName, + start: Date.now(), + timeout: setTimeout(() => { + if (this.isInAction) this.cancelAction(); + }, timeoutMs), + change: new ChangeRegion(), + steps: [], + }); + this.#inAction = true; } /** - * Commits current buffer to current action in history then resets change buffer + * Commits current pixel buffer to current action in history then resets change buffer * @method + * @throws {Error} If no active action exists */ - commitChange() { - this.#actionHistory.addActionData(this.changeBuffer); + addActionStep() { + if (!this.#history.getRecordData()) + throw new Error("No active action to add step to"); + + const record = this.#history.getRecordData(); + + if (currentBuffer.isEmpty) return; + + if (record.steps.length === 10 || this.changeBuffer.length >= 100) { + record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); + record.steps = []; + } + + this.#history.getRecordData().steps.push(this.#changeBuffer); + 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() { - let changeBuffer = this.#actionHistory.getActionData(); - for (let i = changeBuffer.length - 1; i >= 0; i--) { - for (let change of changeBuffer[i].beforeStates) { - this.setColor(change.x, change.y, change.state, { quietly: true, }); - } + this.cancelAction(); + + if (this.#history.isStart) return; + + const record = this.#history.getRecordData(); + + // If not already merged, merge and cache it + if (record.steps.length !== 0) { + record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); + record.steps = []; + } + + // Apply before states + for (const change of record.change.beforeStates) { + this.setColor(change.x, change.y, change.state, { quietly: true }); } - this.#actionHistory.undo(); + + this.#history.undo(); } /** @@ -194,12 +257,22 @@ class PixelLayer { * @method */ redo() { - this.#actionHistory.redo(); - let changeBuffer = this.#actionHistory.getActionData(); - for (let i = 0; i < changeBuffer.length; i++) { - for (let change of changeBuffer[i].afterStates) { - this.setColor(change.x, change.y, change.state, { quietly: true, }); - } + this.cancelAction(); + + if (this.#history.isEnd) return; + + this.#history.redo(); + + const record = this.#history.getRecordData(); + + // If not already merged, merge and cache it + if (record.steps.length !== 0) { + record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); + record.steps = []; + } + + for (const change of record.change.afterStates) { + this.setColor(change.x, change.y, change.state, { quietly: true }); } } @@ -212,20 +285,23 @@ class PixelLayer { * @param {Object} options - 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 changeBuffers array. * @param {boolean} [options.validate=true] - If set to true, the x, y, and color types are validated. - * @throws {TypeError} If validate is true and if color is not a valid Color object - * @throws {TypeError} If validate is true and if x and y are not valid integers in valid range. + * @throws {TypeError} If validate is true and color is not a valid Color object + * @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.#width - 1, integerOnly: true }); validateNumber(y, "y", { start: 0, end: this.#height - 1, integerOnly: true }); if (!(color instanceof Color)) { - throw new Error("color must be object of Color class"); + throw new TypeError("color must be object of Color class"); } } if (!quietly) { + if (!this.isInAction) + throw new Error("Cannot set color outside of an action"); this.#changeBuffer.setChange(x, y, color, this.#pixelMatrix[y][x].color, @@ -285,6 +361,15 @@ class PixelLayer { get height() { return this.#height; } + + /** + * Returns whether an action is active + * @method + * @returns {boolean} Whether an action is active + */ + get isInAction() { + return this.#inAction; + } } export default PixelLayer; diff --git a/tests/core/layers/pixel-layer.test.js b/tests/core/layers/pixel-layer.test.js new file mode 100644 index 0000000..6b23f4d --- /dev/null +++ b/tests/core/layers/pixel-layer.test.js @@ -0,0 +1,351 @@ +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)); + }); + }); + + 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/pixel-layer.test.js b/tests/pixel-layer.test.js deleted file mode 100644 index 2b69b3f..0000000 --- a/tests/pixel-layer.test.js +++ /dev/null @@ -1,219 +0,0 @@ -import PixelLayer from "../scripts/pixel-layer.js"; -import Color from "../scripts/color.js"; - -describe("PixelLayer", () => { - let canvas; - 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", () => { - canvas = new PixelLayer(); - expect(canvas.width).toBe(1); - expect(canvas.height).toBe(1); - expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); - }); - - test.each([[16, 16], [1024, 1024], [5, 10] - ])("should create %ix%i canvas", (width, height) => { - canvas = new PixelLayer(width, height); - expect(canvas.width).toBe(width); - expect(canvas.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(() => { - canvas = new PixelLayer(16, 16); - }); - - test("should set and get pixel colors", () => { - canvas.setColor(5, 5, testColor); - expect(canvas.getColor(5, 5)).toEqual(testColor); - }); - - test("should validate coordinates", () => { - expect(() => canvas.getColor(-1, 0)).toThrow("x"); - expect(() => canvas.getColor(16, 0)).toThrow("x"); - expect(() => canvas.getColor(0, -1)).toThrow("y"); - expect(() => canvas.getColor(0, 16)).toThrow("y"); - }); - - test("should handle quiet updates", () => { - canvas.setColor(5, 5, testColor, { quietly: true }); - expect(canvas.changeBuffer.isEmpty).toBe(true); - }); - - test("should reuse color instances", () => { - const color1 = Color.create({ hex: "#ff0000" }); - const color2 = Color.create({ rgb: [255, 0, 0] }); - - canvas.setColor(0, 0, color1); - canvas.setColor(1, 1, color2); - expect(canvas.getColor(0, 0)).toBe(canvas.getColor(1, 1)); - }); - }); - - describe("Change Tracking", () => { - beforeEach(() => { - canvas = new PixelLayer(16, 16); - }); - - test("should track color changes", () => { - canvas.setColor(0, 0, testColor); - canvas.setColor(1, 1, testColor); - - const changes = canvas.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", () => { - canvas.setColor(0, 0, testColor); - const oldBuffer = canvas.resetChangeBuffer(); - - expect(oldBuffer.afterStates).toHaveLength(1); - expect(canvas.changeBuffer.isEmpty).toBe(true); - }); - - test("should track multiple changes to same pixel", () => { - canvas.setColor(0, 0, testColor); - canvas.setColor(0, 0, Color.TRANSPARENT); - - const changes = canvas.changeBuffer.beforeStates; - expect(changes).toHaveLength(1); - expect(changes[0].state).toEqual(Color.TRANSPARENT); - }); - }); - - describe("Action History", () => { - let canvas; - const testActionName = "Paint Stroke"; - - beforeEach(() => { - canvas = new PixelLayer(16, 16); - canvas.createAction(testActionName); - }); - - test("should create named action groups", () => { - canvas.commitChange(); - // Verify through undo/redo behavior - canvas.setColor(0, 0, testColor); - canvas.createAction("New Action"); - canvas.undo(); - expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); - }); - - test("should undo/redo pixel states", () => { - canvas.setColor(0, 0, testColor); - canvas.commitChange(); - - canvas.createAction("Modification"); - canvas.setColor(0, 0, Color.TRANSPARENT); - canvas.commitChange(); - - 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.createAction(`Action ${i}`); - canvas.setColor(i, 0, testColor); - canvas.commitChange(); - } - - // 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); - canvas.loadImage(imageData, 0, 0); - - expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); - expect(canvas.getColor(3, 3)).toEqual(Color.create({ hex: '#ff0000' })); - }); - - test("should handle partial out-of-bounds images", () => { - const imageData = createTestImage([0, 255, 0, 128], 4); - canvas.loadImage(imageData, 14, 14); - - expect(canvas.getColor(14, 14)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); - expect(canvas.getColor(15, 15)).toEqual(Color.create({ rgb: [0, 255, 0], alpha: 0.5 })); - expect(canvas.getColor(0, 0)).toEqual(Color.TRANSPARENT); - }); - - test("should handle negative positions", () => { - const imageData = createTestImage([255, 0, 0, 255], 4); - canvas.loadImage(imageData, -2, -2); - - expect(canvas.getColor(0, 0)).toEqual(Color.create({ hex: '#ff0000' })); - expect(canvas.getColor(1, 1)).toEqual(Color.create({ hex: '#ff0000' })); - expect(canvas.getColor(2, 2)).toEqual(Color.TRANSPARENT); - }); - }); - - describe("Edge Cases", () => { - test("should handle minimum canvas size", () => { - canvas = new PixelLayer(1, 1); - canvas.setColor(0, 0, testColor); - expect(canvas.getColor(0, 0)).toEqual(testColor); - }); - - test("should handle maximum canvas size", () => { - canvas = new PixelLayer(1024, 1024); - canvas.setColor(1023, 1023, testColor); - expect(canvas.getColor(1023, 1023)).toEqual(testColor); - }); - - test("should reject invalid color types", () => { - canvas = new PixelLayer(16, 16); - expect(() => canvas.setColor(0, 0, [255, 0, 0, 1])).toThrow("Color class"); - }); - - test("should handle rapid updates", () => { - canvas = new PixelLayer(64, 64); - for (let i = 0; i < 1000; i++) { - canvas.setColor(i%64, i%64, testColor, { quietly: true }); - } - expect(canvas.getColor(63, 63)).toEqual(testColor); - }); - }); -}); From 4f9017d412d0e2bc668cfe2a23487c0a5a320b4a Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 18 May 2025 13:00:28 +0300 Subject: [PATCH 26/29] refactor(services): enhance ChangeRegion with in-place merging and move to services - Added: - `mergeInPlace()` for performance-critical mutable merges - `length` getter to check change count - Changed: - Moved from `scripts/` to `src/services/` (updated imports to `#`) - Tests: - Added tests for `mergeInPlace()` - Moved tests to `tests/services/` --- {scripts => src/services}/change-region.js | 35 +++++++++++++++++----- tests/{ => services}/change-region.test.js | 13 +++++++- 2 files changed, 39 insertions(+), 9 deletions(-) rename {scripts => src/services}/change-region.js (89%) rename tests/{ => services}/change-region.test.js (93%) diff --git a/scripts/change-region.js b/src/services/change-region.js similarity index 89% rename from scripts/change-region.js rename to src/services/change-region.js index 1ec5e87..d1b69b0 100644 --- a/scripts/change-region.js +++ b/src/services/change-region.js @@ -29,24 +29,34 @@ class ChangeRegion { constructor() { } /** - * Merges another ChangeRegion into a copy of this one, and returns it. + * Merges another ChangeRegion into this one (mutates this object). * @method - * @param {ChangeRegion} source - Source rectangle to merge. - * @returns {ChangeRegion} The result of merging + * @param {ChangeRegion} source - Source ChangeRegion to merge. + * @returns {ChangeRegion} This instance (for chaining) */ - merge(source) { - if (!source || source.isEmpty) return this.clone(); - - const result = this.clone(); + mergeInPlace(source) { + if (!source || source.isEmpty) return this; source.#changes.forEach((change) => { - result.setChange( + this.setChange( change.x, change.y, change.after, change.before, ); }); + return this; + } + + /** + * Merges another ChangeRegion into a copy of this one, and returns it. + * @method + * @param {ChangeRegion} source - Source rectangle to merge. + * @returns {ChangeRegion} The result of merging + */ + merge(source) { + const result = this.clone(); + result.mergeInPlace(source); return result; } @@ -177,6 +187,15 @@ class ChangeRegion { get bounds() { return { ...this.#bounds }; } + + /** + * Returns number of changes + * @method + * @returns {number} + */ + get length() { + return this.#changes.size; + } } export default ChangeRegion; diff --git a/tests/change-region.test.js b/tests/services/change-region.test.js similarity index 93% rename from tests/change-region.test.js rename to tests/services/change-region.test.js index 50504eb..d8710ec 100644 --- a/tests/change-region.test.js +++ b/tests/services/change-region.test.js @@ -1,4 +1,4 @@ -import ChangeRegion from './../scripts/change-region.js'; +import ChangeRegion from '#services/change-region.js'; describe('ChangeRegion', () => { @@ -160,6 +160,17 @@ describe('ChangeRegion', () => { let merge = cr1.merge(cr2); expect(merge.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); }); + + test('should merge into the calling object without creating new one if called in-place merge', () => { + const cr1 = new ChangeRegion(); + cr1.setChange(0, 0, 'state'); + + const cr2 = new ChangeRegion(); + cr2.setChange(5, 5, 'state'); + + cr1.mergeInPlace(cr2); + expect(cr1.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); + }); }); }); }); From 314b64ccc816edfd7d6305bdff5c3ea02f1d53dd Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 18 May 2025 13:26:13 +0300 Subject: [PATCH 27/29] minor change --- src/core/layers/pixel-layer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/layers/pixel-layer.js b/src/core/layers/pixel-layer.js index 8aaa46d..d8b7e8c 100644 --- a/src/core/layers/pixel-layer.js +++ b/src/core/layers/pixel-layer.js @@ -1,6 +1,5 @@ import { validateNumber } from "#utils/validation.js"; import History from "#services/history.js"; -// import Layer from "#core/layers/base-layer.js"; import Color from "#services/color.js"; import ChangeRegion from "#services/change-region.js"; From e1cb0220496e718d8d611a2a59f50851aa51b4eb Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Sun, 18 May 2025 15:12:00 +0300 Subject: [PATCH 28/29] refactor(pixel-layer): fix history manipulation and remove time protection --- src/core/layers/pixel-layer.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/core/layers/pixel-layer.js b/src/core/layers/pixel-layer.js index d8b7e8c..f774a95 100644 --- a/src/core/layers/pixel-layer.js +++ b/src/core/layers/pixel-layer.js @@ -73,9 +73,6 @@ class PixelLayer { integerOnly: true }); - this.#width = width; - this.#height = height; - this.#changeBuffer = new ChangeRegion(); this.initializeBlankCanvas(width, height); } @@ -143,7 +140,19 @@ class PixelLayer { let blue = imageData.data[dist + 2]; let alpha = imageData.data[dist + 3]; - this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 }), { quietly: true }); + this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 }), { validate: false }); + } + } + } + + /** + * Clears the layer + * @method + */ + clear() { + for ( let i = 0; i < this.#height; i++) { + for ( let j = 0; j < this.#width; j++) { + this.setColor(j, i, Color.TRANSPARENT, { validate: false }); } } } @@ -160,13 +169,12 @@ class PixelLayer { } /** - * Starts a new action into the history with given name and timeout protection + * Starts a new action into the history with given name * @param {string} actionName - The name - * @param {number} [timeoutMs=30000] of the the new action * @method * @throws {TypeError} If actionName is not a string */ - startAction(actionName, timeoutMs = 30000) { + startAction(actionName) { if (typeof actionName !== "string") throw new TypeError("Action name must be a string"); @@ -175,9 +183,6 @@ class PixelLayer { this.#history.setRecordData({ name: actionName, start: Date.now(), - timeout: setTimeout(() => { - if (this.isInAction) this.cancelAction(); - }, timeoutMs), change: new ChangeRegion(), steps: [], }); @@ -195,9 +200,9 @@ class PixelLayer { const record = this.#history.getRecordData(); - if (currentBuffer.isEmpty) return; + if (this.#changeBuffer.isEmpty) return; - if (record.steps.length === 10 || this.changeBuffer.length >= 100) { + if (record.steps.length === 10 || this.#changeBuffer.length >= 100) { record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); record.steps = []; } @@ -245,7 +250,7 @@ class PixelLayer { // Apply before states for (const change of record.change.beforeStates) { - this.setColor(change.x, change.y, change.state, { quietly: true }); + this.setColor(change.x, change.y, change.state, { quietly: true, validate: false }); } this.#history.undo(); @@ -271,7 +276,7 @@ class PixelLayer { } for (const change of record.change.afterStates) { - this.setColor(change.x, change.y, change.state, { quietly: true }); + this.setColor(change.x, change.y, change.state, { quietly: true, validate: false }); } } From d3c5176222307ef676559088446470d0effc87f8 Mon Sep 17 00:00:00 2001 From: Ahmed El-Esseily Date: Thu, 19 Jun 2025 19:24:44 +0300 Subject: [PATCH 29/29] HUUUUUUUGE --- .prettierrc.json | 1 + CHANGELOG.md | 16 + README.md | 11 + dist/core/algorithms/graphic-algorithms.js | 110 + dist/core/drawing-algorithms.js | 110 + dist/core/drawing-context.js | 1 + dist/core/events.js | 8 + dist/core/interfaces/drawing-context.js | 1 + dist/core/layers/concrete/pixel-layer.js | 317 +++ dist/core/layers/layer-history.js | 12 + dist/core/layers/pixel-layer.js | 322 +++ dist/core/layers/types/history-types.js | 12 + dist/core/layers/types/pixel-types.js | 61 + dist/core/managers/layer-manager.js | 351 +++ dist/core/managers/tool-manager.js | 116 + dist/core/pixel-editor.js | 108 + dist/core/tools/base/tool-base.js | 6 + dist/core/tools/implementations/pen-tool.js | 75 + dist/core/tools/pen-tool.js | 121 + dist/core/tools/tool-base.js | 6 + dist/core/tools/tools.js | 110 + dist/core/ui-components/canvas.js | 318 +++ dist/generics/change-tracker.js | 154 ++ dist/generics/history-system.js | 247 ++ dist/index.js | 115 + dist/interfaces/drawable.js | 1 + dist/interfaces/historyable.js | 2 + dist/services/color-service.js | 430 +++ dist/services/color.js | 430 +++ dist/services/event-bus.js | 44 + dist/services/pixel-change.js | 61 + dist/systems/change-system.js | 154 ++ dist/systems/history-system.js | 247 ++ dist/ui/canvas.js | 318 +++ dist/utils/validation.js | 29 + file.js | 138 + index.html | 25 +- jsdoc.json | 11 + package-lock.json | 2399 ++++++++++++++++- package.json | 15 +- scripts/canvas-manager.js | 194 -- scripts/color.js | 603 ----- scripts/event-manager.js | 94 - scripts/layer-manager.js | 404 --- scripts/pixel-board.js | 92 - scripts/validation.js | 86 - src/core/algorithms/graphic-algorithms.ts | 187 ++ src/core/events.ts | 19 + src/core/layers/concrete/pixel-layer.ts | 382 +++ src/core/layers/layer-history.ts | 15 + src/core/layers/pixel-layer.js | 379 --- src/core/managers/layer-manager.ts | 411 +++ src/core/managers/tool-manager.bak | 211 ++ src/core/managers/tool-manager.ts | 131 + src/core/pixel-editor.ts | 122 + src/core/tools/base/tool-base.ts | 17 + src/core/tools/implementations/line-tool.js | 243 ++ src/core/tools/implementations/pen-tool.ts | 92 + src/core/ui-components/canvas.ts | 370 +++ src/generics/change-tracker.ts | 185 ++ src/generics/history-system.ts | 284 ++ src/index.ts | 125 + src/interfaces/drawable.ts | 8 + src/interfaces/historyable.ts | 10 + src/services/change-region.js | 201 -- src/services/color.ts | 528 ++++ src/services/event-bus.ts | 51 + src/services/history.js | 262 -- src/services/pixel-change.ts | 72 + src/types/history-types.d.ts | 10 + src/types/pixel-types.d.ts | 18 + src/utils/validation.ts | 50 + styles/canvas.css | 1 + styles/selected-colors.css | 2 +- tests/core/layers/pixel-layer.test.js | 9 + .../{ => core/managers}/layer-manager.test.js | 11 +- .../core/managers/tool-manager.test.js | 6 +- .../pixel-editor.test.js} | 0 .../core/tools/base-tool.test.js | 117 +- tests/core/tools/brush-tool.test.js | 574 ++++ tests/core/tools/drawing-tool.test.js | 574 ++++ tests/core/tools/pixel-tool.test.js | 574 ++++ scripts/main.js => tests/main.test.js | 0 tests/services/change-region.test.js | 176 -- tests/{ => services}/color.test.js | 2 +- tests/services/newFile.js | 0 tests/services/pixel-changes.test.js | 136 + tests/setup-jest.js | 2 +- .../canvas.test.js} | 81 +- tests/{ => utils}/validation.test.js | 2 +- tsconfig.json | 12 + 91 files changed, 12358 insertions(+), 2790 deletions(-) create mode 100644 .prettierrc.json create mode 100644 CHANGELOG.md create mode 100644 dist/core/algorithms/graphic-algorithms.js create mode 100644 dist/core/drawing-algorithms.js create mode 100644 dist/core/drawing-context.js create mode 100644 dist/core/events.js create mode 100644 dist/core/interfaces/drawing-context.js create mode 100644 dist/core/layers/concrete/pixel-layer.js create mode 100644 dist/core/layers/layer-history.js create mode 100644 dist/core/layers/pixel-layer.js create mode 100644 dist/core/layers/types/history-types.js create mode 100644 dist/core/layers/types/pixel-types.js create mode 100644 dist/core/managers/layer-manager.js create mode 100644 dist/core/managers/tool-manager.js create mode 100644 dist/core/pixel-editor.js create mode 100644 dist/core/tools/base/tool-base.js create mode 100644 dist/core/tools/implementations/pen-tool.js create mode 100644 dist/core/tools/pen-tool.js create mode 100644 dist/core/tools/tool-base.js create mode 100644 dist/core/tools/tools.js create mode 100644 dist/core/ui-components/canvas.js create mode 100644 dist/generics/change-tracker.js create mode 100644 dist/generics/history-system.js create mode 100644 dist/index.js create mode 100644 dist/interfaces/drawable.js create mode 100644 dist/interfaces/historyable.js create mode 100644 dist/services/color-service.js create mode 100644 dist/services/color.js create mode 100644 dist/services/event-bus.js create mode 100644 dist/services/pixel-change.js create mode 100644 dist/systems/change-system.js create mode 100644 dist/systems/history-system.js create mode 100644 dist/ui/canvas.js create mode 100644 dist/utils/validation.js create mode 100644 file.js create mode 100644 jsdoc.json delete mode 100644 scripts/canvas-manager.js delete mode 100644 scripts/color.js delete mode 100644 scripts/event-manager.js delete mode 100644 scripts/layer-manager.js delete mode 100644 scripts/pixel-board.js delete mode 100644 scripts/validation.js create mode 100644 src/core/algorithms/graphic-algorithms.ts create mode 100644 src/core/events.ts create mode 100644 src/core/layers/concrete/pixel-layer.ts create mode 100644 src/core/layers/layer-history.ts delete mode 100644 src/core/layers/pixel-layer.js create mode 100644 src/core/managers/layer-manager.ts create mode 100644 src/core/managers/tool-manager.bak create mode 100644 src/core/managers/tool-manager.ts create mode 100644 src/core/pixel-editor.ts create mode 100644 src/core/tools/base/tool-base.ts create mode 100644 src/core/tools/implementations/line-tool.js create mode 100644 src/core/tools/implementations/pen-tool.ts create mode 100644 src/core/ui-components/canvas.ts create mode 100644 src/generics/change-tracker.ts create mode 100644 src/generics/history-system.ts create mode 100644 src/index.ts create mode 100644 src/interfaces/drawable.ts create mode 100644 src/interfaces/historyable.ts delete mode 100644 src/services/change-region.js create mode 100644 src/services/color.ts create mode 100644 src/services/event-bus.ts delete mode 100644 src/services/history.js create mode 100644 src/services/pixel-change.ts create mode 100644 src/types/history-types.d.ts create mode 100644 src/types/pixel-types.d.ts create mode 100644 src/utils/validation.ts rename tests/{ => core/managers}/layer-manager.test.js (97%) rename scripts/tool-manager.js => tests/core/managers/tool-manager.test.js (95%) rename tests/{pixel-board.test.js => core/pixel-editor.test.js} (100%) rename scripts/drawing-manager.js => tests/core/tools/base-tool.test.js (84%) create mode 100644 tests/core/tools/brush-tool.test.js create mode 100644 tests/core/tools/drawing-tool.test.js create mode 100644 tests/core/tools/pixel-tool.test.js rename scripts/main.js => tests/main.test.js (100%) delete mode 100644 tests/services/change-region.test.js rename tests/{ => services}/color.test.js (99%) create mode 100644 tests/services/newFile.js create mode 100644 tests/services/pixel-changes.test.js rename tests/{canvas-manager.test.js => ui/canvas.test.js} (58%) rename tests/{ => utils}/validation.test.js (98%) create mode 100644 tsconfig.json 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-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/color.js b/scripts/color.js deleted file mode 100644 index 2cd0be8..0000000 --- a/scripts/color.js +++ /dev/null @@ -1,603 +0,0 @@ -import { validateNumber } from "./validation.js"; - -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 { - /** - * @typedef {Object} RGBColor - * @property {number} 0 - Red (0-255) - * @property {number} 1 - Green (0-255) - * @property {number} 2 - Blue (0-255) - */ - - /** - * @typedef {Object} HSLColor - * @property {number} 0 - Hue (0-360) - * @property {number} 1 - Saturation (0-100) - * @property {number} 2 - Lightness (0-100) - */ - - /** - * @typedef {Object} ParsedHex - * @property {number[]} rgb - RGB values [r, g, b] - * @property {number} alpha - Alpha value (0-1) - */ - - /** - * Internal cache of color instances to prevent duplicates. - * Keys are long-version hex strings. (ex. '#11223344') - * @type {Map} - * @private - */ - static #colorMemory = new Map(); - - /** - * RGB color values [0-255, 0-255, 0-255]. - * @type {RGBColor} - * @private - */ - #rgb = [0, 0, 0]; - - /** - * HSL color values [0-360, 0-100, 0-100]. - * @type {HSLColor} - * @private - */ - #hsl = [0, 0, 0]; - - /** - * Hexadecimal color representation. - * @type {string} - * @private - */ - #hex = '#000000'; - - /** - * Alpha transparency value (0-1). - * @type {number} - * @private - */ - #alpha = 1; - - /** - * Private constructor (use Color.create() instead). - * @param {RGBColor} rgb - RGB values - * @param {HSLColor} hsl - HSL values - * @param {string} hex - Hex representation - * @param {number} alpha - Alpha value - * @param {symbol} key - Private key to prevent direct instantiation - * @private - * @throws {TypeError} if used directly (use Color.create() instead) - */ - constructor(rgb, hsl, hex, alpha, key) { - if (key !== COLOR_KEY) { // Must not be used by the user - throw new TypeError("Use Color.create() instead of new Color()"); - } - this.#rgb = rgb; - this.#hsl = hsl; - this.#hex = hex; - this.#alpha = alpha; - Object.freeze(this); - } - - // ==================== - // Public API Methods - // ==================== - - /** - * Mixes two colors with optional weighting and color space - * @method - * @param {Color} color - The second color to mix with - * @param {number} [weight=0.5] - The mixing ratio (0-1) - * @param {string} [mode='rgb'] - The blending mode ('rgb' or 'hsl') - * @returns {Color} The resulting new mixed color - * @throws {TypeError} If mode is not a valid mode string ("rgb" or "hsl") - * @throws {TypeError} If color is an not instance of Color class - */ - mix(color, weight = 0.5, mode = 'rgb') { - weight = Math.min(1, Math.max(0, weight)); // Clamp 0-1 - const color1 = this; - const color2 = color; - - if (!(color2 instanceof Color)) { - throw new TypeError("color must be instance of Color class"); - } - - const newAlpha = color1.alpha + (color2.alpha - color1.alpha) * weight; - - switch (mode) { - case 'hsl': - const [h1, s1, l1] = color1.hsl; - const [h2, s2, l2] = color2.hsl; - - // Hue wrapping - let hueDiff = h2 - h1; - if (Math.abs(hueDiff) > 180) { - hueDiff += hueDiff > 0 ? -360 : 360; - } - - return Color.create({ - hsl: [ - (h1 + hueDiff * weight + 360) % 360, - s1 + (s2 - s1) * weight, - l1 + (l2 - l1) * weight, - ], - alpha: newAlpha - }); - - case 'rgb': - default: - const [r1, g1, b1] = color1.rgb; - const [r2, g2, b2] = color2.rgb; - - return Color.create({ - rgb: [ - r1 + (r2 - r1) * weight, - g1 + (g2 - g1) * weight, - b1 + (b2 - b1) * weight - ], - alpha: newAlpha - }); - } - } - - /** - * Composites a color over another - * @method - * @param {Color} bottomColor - The color to composite over - * @returns {Color} The resulting new composited color - * @throws {TypeError} If bottomColor is an not instance of Color class - */ - compositeOver(bottomColor) { - if (!(bottomColor instanceof Color)) { - throw new TypeError("color must be instance of Color class"); - } - - const [rTop, gTop, bTop, aTop] = [... this.#rgb, this.#alpha]; - const [rBottom, gBottom, bBottom, aBottom] = [... bottomColor.rgb, bottomColor.#alpha]; - - const combinedAlpha = aTop + aBottom * (1 - aTop); - if (combinedAlpha === 0) return Color.TRANSPARENT; - - return Color.create({ - 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 {Color} color - The color to compare the first with - * @param {number} [tolerance=5] - The allowed maximum perceptual distance (0-442) - * @param {boolean} [includeAlpha=true] - Whether to compare the alpha channel - * @returns {boolean} Whether the two colors are visually similar within the given tolerance - * @throws {TypeError} If color is an not instance of Color class - */ - isSimilarTo(color, tolerance = 5, includeAlpha = true) { - const color1 = this; - const color2 = color; - if (!(color2 instanceof Color)) - throw new TypeError("color must be instance of Color class"); - - const [r1, g1, b1] = color1.rgb; - const [r2, g2, b2] = color2.rgb; - const [a1, a2] = [color1.alpha, color2.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 {Color} color - the color to compare with - * @param {boolean} [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 - */ - isEqualTo(color, includeAlpha = true) { - const color1 = this; - const color2 = color; - if (!(color2 instanceof Color)) - throw new TypeError("color must be instance of Color class"); - - const [r1, g1, b1] = color1.rgb; - const [r2, g2, b2] = color2.rgb; - const [a1, a2] = [color1.alpha, color2.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 {Object} changes - RGB changes - * @param {number} [changes.r] - Red component (0-255) - * @param {number} [changes.g] - Green component (0-255) - * @param {number} [changes.b] - Blue component (0-255) - * @returns {Color} New color instance - */ - withRGB({ r = this.#rgb[0], g = this.#rgb[1], b = this.#rgb[2] } = {}) { - return Color.create({ rgb: [r, g, b], alpha: this.#alpha }); - } - - /** - * Creates a new color with modified HSL values - * @param {Object} changes - HSL changes - * @param {number} [changes.h] - Hue (0-360) - * @param {number} [changes.s] - Saturation (0-100) - * @param {number} [changes.l] - Lightness (0-100) - * @returns {Color} New color instance - */ - withHSL({ h = this.#hsl[0], s = this.#hsl[1], l = this.#hsl[2] } = {}) { - return Color.create({ hsl: [h, s, l], alpha: this.#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.create({ rgb: this.#rgb, alpha }); - } - - /** - * Mixes two colors - * @param {Color} color - Color to mix with - * @param {number} [weight=0.5] - Mixing ratio (0-1) - * @param {'rgb'|'hsl'} [mode='rgb'] - Mixing mode - * @returns {Color} New mixed color - * @throws {TypeError} If color is not a Color instance - */ - mix(color, weight = 0.5, mode = 'rgb') { - if (!(color instanceof Color)) { - throw new TypeError("color must be instance of Color class"); - } - - weight = Math.min(1, Math.max(0, weight)); - const [a1, a2] = [this.#alpha, color.#alpha]; - - switch (mode) { - case 'hsl': - const [h1, s1, l1] = this.#hsl; - const [h2, s2, l2] = color.#hsl; - let hueDiff = h2 - h1; - if (Math.abs(hueDiff) > 180) { - hueDiff += hueDiff > 0 ? -360 : 360; - } - return Color.create({ - hsl: [ - (h1 + hueDiff * weight + 360) % 360, - s1 + (s2 - s1) * weight, - l1 + (l2 - l1) * weight - ], - alpha: a1 + (a2 - a1) * weight - }); - - case 'rgb': - const [r1, g1, b1] = this.#rgb; - const [r2, g2, b2] = color.#rgb; - return Color.create({ - rgb: [ - r1 + (r2 - r1) * weight, - g1 + (g2 - g1) * weight, - b1 + (b2 - b1) * weight - ], - alpha: a1 + (a2 - a1) * weight - }); - - default: - throw new TypeError(`Invalid mixing mode: ${mode}`); - } - } - - // ==================== - // Getters - // ==================== - - /** @returns {Array} RGB values [r, g, b] (0-255) */ - get rgb() { return [...this.#rgb]; } - - /** @returns {Array} HSL values [h, s, l] (h: 0-360, s/l: 0-100) */ - get hsl() { return [...this.#hsl]; } - - /** @returns {string} Hex color string */ - get hex() { return this.#hex; } - - /** @returns {number} Alpha value (0.0-1.0) */ - get alpha() { return this.#alpha; } - - /** @returns {string} Hex representation */ - toString() { return this.#hex; } - - - // ==================== - // Static Methods - // ==================== - - /** - * Creates a Color instance from various formats, or returns cached instance. - * @method - * @static - * @param {Object} config - Configuration object - * @param {RGBColor} [config.rgb] - RGB values (0-255) - * @param {HSLColor} [config.hsl] - HSL values (h:0-360, s/l:0-100) - * @param {string} [config.hex] - Hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) - * @param {number} [config.alpha=1] - Alpha value (0.0-1.0) - * @returns {Color} Color instance - * @throws {TypeError} If input format is invalid - * @throws {RangeError} If values are out of bounds - */ - static create({ rgb, hsl, hex, alpha = 1 } = {}) { - if ([rgb, hsl, hex].filter(Boolean).length !== 1) { - throw new TypeError("Specify exactly one of: rgb, hsl, hex"); - } - - let key, finalRGB, finalHSL, finalHEX, finalAlpha; - - if (rgb !== undefined) { - validateRGB(rgb); - validateNumber(alpha, "Alpha", { start: 0, end: 1 }); - finalRGB = rgb.map(v => Math.round(v)); - - key = finalHEX = toHex(finalRGB, alpha); - - if (Color.#colorMemory.has(key)) - return Color.#colorMemory.get(key); // Return cached instance - - finalHSL = rgbToHsl(...finalRGB); - finalAlpha = alpha; - } - else if (hsl !== undefined) { - validateHSL(hsl); - validateNumber(alpha, "Alpha", { start: 0, end: 1 }); - finalHSL = hsl.map(v => Math.round(v)); - finalRGB = hslToRgb(...finalHSL); - - key = finalHEX = toHex(finalRGB, alpha); - - if (Color.#colorMemory.has(key)) - return Color.#colorMemory.get(key); // Return cached instance - - finalAlpha = alpha; - } - else if (hex !== undefined) { - const parsed = parseHex(hex); - finalHEX = toHex(parsed.rgb, parsed.alpha); - - key = finalHEX; - - if (Color.#colorMemory.has(key)) - return Color.#colorMemory.get(key); // Return cached instance - - finalRGB = parsed.rgb; - finalHSL = rgbToHsl(...finalRGB); - finalAlpha = parsed.alpha; - } else { - throw new TypeError('Color must be initialized with rgb, hsl, hex, or another Color instance'); - } - - const color = new Color(finalRGB, finalHSL, finalHEX, finalAlpha, COLOR_KEY); - Color.#colorMemory.set(key, color); - return color; - } - - - /** - * Predefined transparent color instance. - * @type {Color} - * @static - */ - static TRANSPARENT = this.create({ rgb: [0, 0, 0], alpha: 0 }); - - /** - * Clears the color cache, forcing new instances to be created - * @static - */ - static clearCache() { - this.#colorMemory.clear(); - - this.TRANSPARENT = this.create({ 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.#colorMemory.size; - } -} - -/** - * Converts RGB to HSL color space. - * @param {number} r - Red (0-255) - * @param {number} g - Green (0-255) - * @param {number} b - Blue (0-255) - * @returns {HSLColor} HSL values - * @private - */ -function rgbToHsl(r, g, b) { - [r, g, b] = [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 {number} h - Hue (0-360) - * @param {number} s - Saturation (0-100) - * @param {number} l - Lightness (0-100) - * @returns {RGBColor} RGB values - * @private - */ -function hslToRgb(h, s, l) { - 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 {Array} 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 {Array} 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: RGBColor, 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/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/layer-manager.js b/scripts/layer-manager.js deleted file mode 100644 index 2738980..0000000 --- a/scripts/layer-manager.js +++ /dev/null @@ -1,404 +0,0 @@ -import PixelLayer from "./pixel-layer.js"; -import ChangeRegion from "./change-region.js"; -import { validateNumber } from "./validation.js"; -import Color from "./color.js"; - -/** - * Represents a system for managing layers of canvas grids - * @class - */ -class LayerManager { - - /** - * @typedef LayerData - * @property {number} id - ID of the layer - * @property {string} name - Name of the layer - * @property {PixelLayer} pixelLayer - The grid class of the layer - */ - - /** - * A map containing layers accessed by their IDs - * @type {Map} - */ - #layers = new Map(); - - /** - * A set of IDs of the currently selected layers - * @type {Set} - */ - #selections = new Set(); - - /** - * An array for maintaining order, holds IDs of the layers - * @type {Array} - */ - #layerOrder = []; - - /** - * Dimensions of canvases that the layer system holds - * @type {number} - */ - #width; - #height; - - /** - * Internal counter to enumerate increamental IDs for the created layers - * @type {number} - * @private - */ - #layerIDCounter = -1; - - /** - * Colors of the checkerboard background of transparent canvas - * @type {Color} - */ - #darkBG = Color.create({ rgb: [160, 160, 160], alpha: 1 }); - #lightBG = Color.create({ rgb: [217, 217, 217], alpha: 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 {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.#width = width; - this.#height = height; - } - - /** - * validates IDs in the layers list - * @method - * @param {...number} ids - The IDs of the layers - * @throws {RangeError} If the layer list is empty - * @throws {RangeError} If 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 - * @method - * @param {string} name - The name of the layer to be added - * @returns {number} the ID of the newly created layer - * @throws {TypeError} If the name is not string - */ - add(name) { - if (typeof name !== "string") - throw new TypeError("Layer name must be defined string"); - - let id = ++this.#layerIDCounter; - - let newLayer = { - id: id, - name: name, - pixelLayer: new PixelLayer(this.#width, this.#height), - }; - - this.#layers.set(id, newLayer); - this.#layerOrder.push(id); - return id; - } - - /** - * Delete layers with given IDs from layers list. If no ID given, delete selected ayers - * @param {...number} 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 - * @throws {RangeError} If 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 => { - this.#selections.delete(id); - this.#layers.delete(id); - this.#layerOrder.splice(this.#layerOrder.indexOf(id), 1); - }); - } - - /** - * Selects layers in the layers list - * @method - * @param {...number} 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 - * @throws {RangeError} If 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 {...number} 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 - * @throws {RangeError} If 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 {number} offset - The offset by which to move the layer - * @param {number} id - The ID of the layer to move - * @throws {TypeError} If the offset is not an integer - * @throws {TypeError} If the ID is not a valid integer - * @throws {RangeError} If the layer list is empty - * @throws {RangeError} If 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); - } - } - - - /** - * Calculates the resulting image in a specific rectangle of the canvas layer system - * @method - * @param {ChangeRegion} changeRegion - The region containing the changed pixels - * @returns {ImageData} The resulting image as ImageData object - * throws {TypeError} If changeRegion is not an instance of ChangeRegion class - */ - getRenderImage(changeRegion = new ChangeRegion()) { - if (!(changeRegion instanceof ChangeRegion)) - throw new TypeError("changeRegion must be an instance of ChangeRegion"); - - const renderImage = (changeRegion.isEmpty ? - new ImageData(this.width, this.height) : - new ImageData( - changeRegion.bounds.x1 - changeRegion.bounds.x0 + 1, - changeRegion.bounds.y1 - changeRegion.bounds.y0 + 1, - )); - - if (changeRegion.isEmpty) - for (let y = 0; y < renderImage.height; y++) - for (let x = 0; x < renderImage.width; x++) { - const index = (y * renderImage.width + x) * 4; - const color = this.getColor(x, y); - renderImage.data[index + 0] = color.rgb[0]; - renderImage.data[index + 1] = color.rgb[1]; - renderImage.data[index + 2] = color.rgb[2]; - renderImage.data[index + 3] = Math.floor(color.alpha * 255); - } - else - for (const pixel of changeRegion.changesMap.values()) { - console.log(pixel); - const index = (pixel.y * renderImage.width + pixel.x) * 4; - const color = this.getColor(pixel.x, pixel.y); - renderImage.data[index + 0] = color.rgb[0]; - renderImage.data[index + 1] = color.rgb[1]; - renderImage.data[index + 2] = color.rgb[2]; - renderImage.data[index + 3] = Math.floor(color.alpha[3] * 255); - } - - return renderImage; - } - - /** - * Sets the two colors of the checkerboard background covor of the canvas - * @method - * @param {Color} lightBG - The first color - * @param {Color} darkBG - The second color - * @throws {TypeError} If lightBG or darkBG are not instances of Color class - */ - setBackgroundColors(lightBG, darkBG) { - if (!(lightBG instanceof Color && darkBG instanceof Color)) - throw new TypeError("lightBG and darkBG must be instances of Color class"); - - this.#lightBG = lightBG; - this.#darkBG = darkBG; - } - - /** - * Sets a new name to a layer in the layer list for given ID - * @param {string} name - The index to change to in the layer list - * @param {number} id - The ID of the layer - * @throws {TypeError} If the ID is not integer or the name is not string - * @throws {RangeError} If the layer list is empty - * @throws {RangeError} If the ID is not in the list - */ - setName(id, name) { - if (typeof name !== "string") - throw new TypeError("Layer name must be defined string"); - - this.#validate(id); - this.#layers.get(id).name = name; - } - - /** - * Retrieves the layer in the layer list for given ID - * @param {number} id - The ID of the layer - * @returns {PixelLayer} - the layer object - * @throws {TypeError} If the ID is not integer - * @throws {RangeError} If the layer list is empty - * @throws {RangeError} If 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 {number} x - The X-Coordinate - * @param {number} y - The Y-Coordinate - * @returns {Color} The resulting color object of all layers at the specified pixel position - * @throws {TypeError} If X-Coordinate or Y-Coordinate are not valid numbers - * @throws {RangeError} If X-Coordinate or Y-Coordinate are not in valid range - */ - getColor(x, y) { - validateNumber(x, "x", { start: 0, end: this.#width, integerOnly: true }); - validateNumber(y, "y", { start: 0, end: this.#height, integerOnly: true }); - - let finalColor = (x + y) % 2 ? this.#lightBG : this.#darkBG; - - for (let i = this.#layerOrder.length - 1; i >= 0; i--) { - const layerColor = this.#layers.get(this.#layerOrder[i]).pixelLayer.getColor(x, y); - - if (layerColor.alpha <= 0) continue; - - finalColor = layerColor.compositeOver(finalColor); - } - - return finalColor; - }; - - /** - * Retrieves name of a layer in the layer list for given ID - * @param {number} id - The ID of the layer - * @returns {string} - the name of the layer - * @throws {TypeError} If the ID is not integer - * @throws {RangeError} If the layer list is empty - * @throws {RangeError} If 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 {number} - The width of the canvas grid for the layers - */ - get width() { - return this.#width; - } - - /** - * Retrieves height of canvas grid for which the layer system is applied - * @method - * @returns {number} - The height of the canvas grid for the layers - */ - get height() { - return this.#height; - } - - /** - * Retrieves number of layers in the layer list - * @method - * @returns {number} - 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 {boolean} [selectedOnly=false] - if true, retrieves only selected layers - * @returns {Array} - Array of objects containing IDs, names and Layer objects of the layers - */ - list(selectedOnly = false) { - let layerList = []; - if (selectedOnly) - for (let id of this.#layerOrder) { - if (this.#selections.has(id)) - layerList.push({ ... this.#layers.get(id) }); - } - else - for (let id of this.#layerOrder) { - layerList.push({ ... this.#layers.get(id) }); - } - - return layerList; - } -} - - -export default LayerManager; 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 97d015f..0000000 --- a/scripts/validation.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Validates the color array. - * @param {Array} color - The color array [red, green, blue, alpha] to validate. - * @property {number} 0 - Red (0-255) - * @property {number} 1 - Green (0-255) - * @property {number} 2 - Blue (0-255) - * @returns {boolean} - Returns true if the color array is valid, otherwise false. - * @throws {TypeError} Throws an error if the color is invalid. - * @deprecated use the Color class instead - */ -export function validateColorArray(color) { - console.warn("Deprecated - use new Color() instead"); - 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 {string} varName - The variable name to show in the error message which will be thrown. - * @param {Object} options - Contains some optional constraints: max/min limits, and if the number is integer only - * @param {number | undefined} options.start - The minimum of valid range, set to null to omit the constraint. - * @param {number | undefined} options.end - The maximum of valid range, set to null to omit the constraint. - * @param {boolean} options.integerOnly - Specifies if the number must be an integer. - * @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/layers/pixel-layer.js b/src/core/layers/pixel-layer.js deleted file mode 100644 index f774a95..0000000 --- a/src/core/layers/pixel-layer.js +++ /dev/null @@ -1,379 +0,0 @@ -import { validateNumber } from "#utils/validation.js"; -import History from "#services/history.js"; -import Color from "#services/color.js"; -import ChangeRegion from "#services/change-region.js"; - -/** - * Represents a canvas grid system - * @class - */ -class PixelLayer { - - /** - * The width of the canvas - * @type {number} - */ - #width; - - /** - * The height of the canvas - * @type {number} - */ - #height; - - /** - * Current used action - * @type {boolean} - */ - #inAction = false; - - /** - * The action history system to store main changes - * @type {History} - */ - #history = new History(64); - - /** - * @typedef Pixel - * @property {number} x - X-coordinate - * @property {number} y - Y-coordinate - * @property {Color} color - Color of the pixel - */ - - /** - * The 2-D grid containing the Pixel data of the canvas - * @type {Pixel[][]} - */ - #pixelMatrix; - - /** - * Buffer logs changes performed on pixels (Ex. color change) - * @type {ChangeRegion} - */ - #changeBuffer = new ChangeRegion(); - - /** - * Creates a blank canvas with specified width and height - * @constructor - * @param {number} [width=1] - The width of the grid - * @param {number} [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) { - validateNumber(width, "Width", { - start: 1, - end: 1024, - integerOnly: true - }); - - validateNumber(height, "Height", { - start: 1, - end: 1024, - integerOnly: true - }); - - 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 {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.#width = width; - this.#height = height; - this.#pixelMatrix = new Array(height); - for (let i = 0; i < this.#height; i++) { - this.#pixelMatrix[i] = new Array(width); - for (let j = 0; j < this.#width; j++) { - this.#pixelMatrix[i][j] = { - x: j, - y: i, - color: Color.TRANSPARENT, - }; - } - } - } - - /** - * Loads an image data at (x, y) position - * @method - * @param {ImageData} imageData - The image to be loaded - * @param {number} [x=0] - X-coordinate - * @param {number} [y=0] - Y-coordinate - * @throws {TypeError} If x or y are not integers - * @throws {TypeError} 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 = imageData.data[dist + 0]; - let green = imageData.data[dist + 1]; - let blue = imageData.data[dist + 2]; - let alpha = imageData.data[dist + 3]; - - this.setColor(j, i, Color.create({ rgb: [red, green, blue], alpha: alpha / 255 }), { validate: false }); - } - } - } - - /** - * Clears the layer - * @method - */ - clear() { - for ( let i = 0; i < this.#height; i++) { - for ( let j = 0; j < this.#width; j++) { - this.setColor(j, i, Color.TRANSPARENT, { validate: false }); - } - } - } - - /** - * Resets changes buffer to be empty - * @method - * @returns {ChangeRegion} Change buffer before emptying - */ - resetChangeBuffer() { - let changeBuffer = this.#changeBuffer; - this.#changeBuffer = new ChangeRegion(); - return changeBuffer; - } - - /** - * Starts a new action into the history with given name - * @param {string} actionName - The name - * @method - * @throws {TypeError} If actionName is not a string - */ - startAction(actionName) { - if (typeof actionName !== "string") - throw new TypeError("Action name must be a string"); - - if (this.#inAction) this.endAction(); - this.#history.addRecord(); - this.#history.setRecordData({ - name: actionName, - start: Date.now(), - change: new ChangeRegion(), - steps: [], - }); - this.#inAction = true; - } - - /** - * Commits current pixel buffer to current action in history then resets change buffer - * @method - * @throws {Error} If no active action exists - */ - addActionStep() { - if (!this.#history.getRecordData()) - throw new Error("No active action to add step to"); - - const record = this.#history.getRecordData(); - - if (this.#changeBuffer.isEmpty) return; - - if (record.steps.length === 10 || this.#changeBuffer.length >= 100) { - record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); - record.steps = []; - } - - this.#history.getRecordData().steps.push(this.#changeBuffer); - - 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.isStart) return; - - const record = this.#history.getRecordData(); - - // If not already merged, merge and cache it - if (record.steps.length !== 0) { - record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); - record.steps = []; - } - - // Apply before states - for (const change of record.change.beforeStates) { - this.setColor(change.x, change.y, change.state, { quietly: true, validate: false }); - } - - this.#history.undo(); - } - - /** - * Redos an action - * @method - */ - redo() { - this.cancelAction(); - - if (this.#history.isEnd) return; - - this.#history.redo(); - - const record = this.#history.getRecordData(); - - // If not already merged, merge and cache it - if (record.steps.length !== 0) { - record.steps.reduce((acc, st) => acc.mergeInPlace(st), record.change); - record.steps = []; - } - - for (const change of record.change.afterStates) { - this.setColor(change.x, change.y, change.state, { quietly: true, validate: false }); - } - } - - /** - * Sets color to pixel at position (x, y). - * @method - * @param {number} x - X-coordinate. - * @param {number} y - X-coordinate. - * @param {Color} color - The Color object to be set - * @param {Object} options - 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 changeBuffers array. - * @param {boolean} [options.validate=true] - If set to true, the x, y, and color types are validated. - * @throws {TypeError} If validate is true and color is not a valid Color object - * @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.#width - 1, integerOnly: true }); - validateNumber(y, "y", { start: 0, end: this.#height - 1, integerOnly: true }); - if (!(color instanceof Color)) { - throw new TypeError("color must be object of Color class"); - } - } - - if (!quietly) { - if (!this.isInAction) - throw new Error("Cannot set color outside of an action"); - this.#changeBuffer.setChange(x, y, - color, - this.#pixelMatrix[y][x].color, - ); - } - this.#pixelMatrix[y][x].color = color; - } - - /** - * Returns pixel data at position (x, y) - * @method - * @param {number} x - X-coordinate - * @param {number} y - Y-coordinate - * @returns {Pixel} Pixel data at position (x, y) - */ - 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 - X-coordinate - * @param {number} y - Y-coordinate - * @returns {Color} Color object of pixel at position (x, y) - */ - getColor(x, y) { - return this.get(x, y).color; - } - - /** - * Returns copy of change buffer - * @method - * @returns {ChangeRegion} Copy of change buffer - */ - get changeBuffer() { - return this.#changeBuffer.clone(); - } - - /** - * Returns the width of the canvas - * @method - * @returns {number} The width of the canvas - */ - get width() { - return this.#width; - } - - /** - * Returns the height of the canvas - * @method - * @returns {number} The height of the canvas - */ - get height() { - return this.#height; - } - - /** - * Returns whether an action is active - * @method - * @returns {boolean} Whether an action is active - */ - get isInAction() { - return this.#inAction; - } -} - -export default PixelLayer; 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/change-region.js b/src/services/change-region.js deleted file mode 100644 index d1b69b0..0000000 --- a/src/services/change-region.js +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @class - * Tracks modified pixel regions with ordered history support. - * Maintains both a Map for order and a Set for duplicate checking. - */ -class ChangeRegion { - - /** - * Map from pixel positions `${x},${y}` to a change record containing the position and before/after states - * @type {Map} - */ - #changes = new Map(); - - /** - * Bounds of the region containing all the changes - * @type {{x0: number, y0: number, x1: number, y1: number}} - */ - #bounds = { - x0: Infinity, - y0: Infinity, - x1: -Infinity, - y1: -Infinity - }; - - /** - * Creates a ChangeRegion instance. - * @constructor - */ - constructor() { } - - /** - * Merges another ChangeRegion into this one (mutates this object). - * @method - * @param {ChangeRegion} source - Source ChangeRegion to merge. - * @returns {ChangeRegion} This instance (for chaining) - */ - mergeInPlace(source) { - if (!source || source.isEmpty) return this; - - source.#changes.forEach((change) => { - this.setChange( - change.x, - change.y, - change.after, - change.before, - ); - }); - return this; - } - - /** - * Merges another ChangeRegion into a copy of this one, and returns it. - * @method - * @param {ChangeRegion} source - Source rectangle to merge. - * @returns {ChangeRegion} The result of merging - */ - merge(source) { - const result = this.clone(); - result.mergeInPlace(source); - return result; - } - - /** - * Creates a shallow copy (states are not deep-cloned). - * @method - * @returns {ChangeRegion} The clone - */ - clone() { - const copy = new ChangeRegion({ }); - - this.#changes.forEach(value => { - copy.setChange(value.x, value.y, value.after, value.before); - }); - - return copy; - } - - /** - * Adds or updates a pixel modification. Coordinates are floored to integers. - * @method - * @param {number} x - X-coordinate (floored). - * @param {number} y - Y-coordinate (floored). - * @param {any} after - New state. - * @param {any} [before=after] - Original state (used only on first add). - */ - setChange(x, y, after, before = after) { - x = Math.floor(x); - y = Math.floor(y); - const key = `${x},${y}`; - - const existing = this.#changes.get(key); - - if (existing) { - existing.after = after; - } else { - this.#changes.set(key, { - x, - y, - after: after, - before: before, - }); - - // Update bounds - this.#bounds.x0 = Math.min(this.#bounds.x0, x); - this.#bounds.y0 = Math.min(this.#bounds.y0, y); - this.#bounds.x1 = Math.max(this.#bounds.x1, x); - this.#bounds.y1 = Math.max(this.#bounds.y1, y); - } - } - - /** - * Checks if a pixel has been modified. - * @method - * @param {number} x - X-coordinate. - * @param {number} y - Y-coordinate. - * @returns {boolean} - */ - hasChange(x, y) { - x = Math.floor(x); - y = Math.floor(y); - return this.#changes.has(`${x},${y}`); - } - - /** - * Returns whether the rectangle is empty. - * @method - * @returns {boolean} - */ - get isEmpty() { - return this.#changes.size === 0; - } - - /** - * Width of the bounding rectangle. - * @method - * @returns {number} - */ - get width() { - return this.isEmpty ? 0 : this.#bounds.x1 - this.#bounds.x0 + 1; - } - - /** - * Height of the bounding rectangle. - * @method - * @returns {number} - */ - get height() { - return this.isEmpty ? 0 : this.#bounds.y1 - this.#bounds.y0 + 1; - } - - /** - * Gets current modified states. - * @method - * @returns {Array<{x: number, y: number, state: any}>} - */ - get afterStates() { - return Array.from(this.#changes.values()).map(({ x, y, after }) => ({ - x, y, state: after - })); - } - - /** - * Gets original states before modification. - * @method - * @returns {Array<{x: number, y: number, state: any}>} - */ - get beforeStates() { - return Array.from(this.#changes.values()).map(({ x, y, before }) => ({ - x, y, state: before - })); - } - - /** - * Map for all changes (in insertion order). ['x,y' -> change] - * @method - * @returns {Map} - */ - get changesMap() { - return new Map(this.#changes); - } - - /** - * Bounding rectangle of all changes. - * @method - * @returns {{x0: number, y0: number, x1: number, y1: number}} - */ - get bounds() { - return { ...this.#bounds }; - } - - /** - * Returns number of changes - * @method - * @returns {number} - */ - get length() { - return this.#changes.size; - } -} - -export default ChangeRegion; 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/history.js b/src/services/history.js deleted file mode 100644 index 7ce5809..0000000 --- a/src/services/history.js +++ /dev/null @@ -1,262 +0,0 @@ -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(10); - * history.addRecord("Paint"); - * history.addRecordData({x: 1, y: 2, color: "#FF0000"}); - * history.undo(); // Reverts to previous state - * - * @class - */ -class History { - - /** - * Internal circular buffer storing records - * @type {Array<{id: number, data: Array}>} - * @private - */ - #buffer; - - /** - * The index of the current selected record - * @type {number} - * @private - */ - #currentIndex = -1; - - /** - * The index of the oldest saved record in the history system - * @type {number} - * @private - */ - #startIndex = 0; - - /** - * The index of the last saved record in the history system - * @type {number} - * @private - */ - #endIndex = -1; - - /** - * Internal counter to enumerate increamental IDs for the created records - * @type {number} - * @private - */ - #recordIDCounter = -1; - - /** - * Creates a new History with specified capacity - * @constructor - * @param {number} capacity - Maximum stored records (1-64) - * @throws {TypeError} If capacity is not an integer - * @throws {RangeError} If capacity is outside 1-64 range - */ - constructor(capacity) { - validateNumber(capacity, "Capacity", { start: 1, end: 64, integerOnly: true }); - - capacity = Math.floor(capacity); - this.#buffer = new Array(capacity); - } - - /** - * Adds a new record to the history - * @method - */ - addRecord() { - if (this.#currentIndex !== this.#endIndex && this.#endIndex !== -1) - this.#endIndex = this.#currentIndex; - - if (this.bufferSize === this.bufferCapacity) { - this.#startIndex = this.#wrapIndex(this.#startIndex + 1); - } - - if (this.isStart) this.#currentIndex = this.#startIndex; - else - this.#currentIndex = this.#wrapIndex(this.#currentIndex + 1); - - this.#endIndex = this.#currentIndex; - - this.#buffer[this.#currentIndex] = { - id: ++this.#recordIDCounter, - data: null, - }; - } - - /** - * Sets data to the current record its reference (no copy is made) - * @method - * @param {any} 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; - } - - #wrapIndex(index) { - return (index + this.bufferCapacity) % this.bufferCapacity; - } - - - /** - * Gets the record at an offset from current position - * @private - * @param {number} [offset=0] - Offset from current position - * @returns {(Object|number)} Record or -1 if invalid offset - */ - #getRecord(offset = 0) { - validateNumber(offset, "Offset", { integerOnly: true }); - - let distance; - let index = this.#currentIndex; - - if (offset > 0) { // go right -> end - if (index === -1) { // absolute start - index = this.#startIndex; - offset--; - } - - distance = this.#endIndex - index; - - // negate the wrap effect - distance = distance < 0 ? distance + this.bufferCapacity : distance; - - if (distance - offset < 0) return -1; - - return this.#buffer[this.#wrapIndex(index + offset)]; - } else if (offset < 0) { // go left -> start - offset *= -1; - - if (index === -1) return -1; // absolute start - - distance = index - this.#startIndex; - - // negate the wrap effect - distance = distance < 0 ? distance + this.bufferCapacity : distance; - - if (distance - offset < 0) return -1; - - return this.#buffer[ - (index - offset + this.bufferCapacity) % - this.bufferCapacity - ]; - } else { - if (this.#currentIndex === -1) return -1; - return this.#buffer[this.#currentIndex]; - } - } - - /** - * Retrieves record ID at an offset from current selected record - * @method - * @param {number} [offset=0] - The the offset from the current record for which ID gets returned - * @returns {number} The record ID, or -1 if not in range. - */ - getRecordID(offset = 0) { - let rec = this.#getRecord(offset); - if (rec === -1) return -1; - return rec.id; - } - - /** - * Retrieves record data at an offset from current selected record - * @method - * @param {number} [offset=0] - The the offset from the current record for which data gets returned - * @returns {Array | number} An array containing the record data, or -1 if not in range - */ - getRecordData(offset = 0) { - let rec = this.#getRecord(offset); - if (rec === -1) return -1; - return rec.data; - } - - /** - * Moves backward in history (undo) - * @method - * @returns {number} ID of the restored record (-1 at start) - */ - undo() { - if (this.isStart || this.#currentIndex === this.#startIndex) { - this.#currentIndex = -1; // absolute start - return null; - } - - this.#currentIndex = - (this.#currentIndex - 1 + this.bufferCapacity) % - this.bufferCapacity; - return this.#buffer[this.#currentIndex].data; - } - - /** - * Moves forward in history (redo) - * @method - * @returns {number} ID of the restored record (-1 at end) - */ - redo() { - if (this.#currentIndex !== this.#endIndex) { - if (this.#currentIndex === -1) { - this.#currentIndex = this.#startIndex; - } else { - this.#currentIndex = this.#wrapIndex(this.#currentIndex + 1); - } - } else if (this.#endIndex === -1) return null; // end = current = -1 - - return this.#buffer[this.#currentIndex].data; - } - - /** - * Current number of stored records - * @member {number} - * @readonly - */ - get bufferSize() { - if (this.#endIndex == -1) return 0; - return this.#wrapIndex(this.#endIndex - this.#startIndex) + 1; - } - - /** - * Maximum number of storable records - * @member {number} - * @readonly - */ - get bufferCapacity() { - return this.#buffer.length; - } - - /** - * Returns true if current index is at the end - * @member {boolean} - * @readonly - */ - get isEnd() { - return this.#currentIndex === this.#endIndex; - } - - /** - * Returns true if current index is at the start - * @member {boolean} - * @readonly - */ - get isStart() { - return this.#currentIndex === -1; - } -} -export default History; 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/core/layers/pixel-layer.test.js b/tests/core/layers/pixel-layer.test.js index 6b23f4d..628c296 100644 --- a/tests/core/layers/pixel-layer.test.js +++ b/tests/core/layers/pixel-layer.test.js @@ -68,6 +68,15 @@ describe("PixelLayer", () => { 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", () => { diff --git a/tests/layer-manager.test.js b/tests/core/managers/layer-manager.test.js similarity index 97% rename from tests/layer-manager.test.js rename to tests/core/managers/layer-manager.test.js index dcf2bf3..dce9782 100644 --- a/tests/layer-manager.test.js +++ b/tests/core/managers/layer-manager.test.js @@ -1,4 +1,3 @@ - global.ImageData = class MockImageData { constructor(width, height) { this.width = width; @@ -7,10 +6,10 @@ global.ImageData = class MockImageData { } }; -import LayerManager from "../scripts/layer-manager.js"; -import PixelLayer from "../scripts/pixel-layer.js"; -import ChangeRegion from "../scripts/change-region.js"; -import Color from "../scripts/color.js"; +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; @@ -295,7 +294,9 @@ describe("LayerManager", () => { 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); 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/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/change-region.test.js b/tests/services/change-region.test.js deleted file mode 100644 index d8710ec..0000000 --- a/tests/services/change-region.test.js +++ /dev/null @@ -1,176 +0,0 @@ -import ChangeRegion from '#services/change-region.js'; - -describe('ChangeRegion', () => { - - let cr; - - const createRectWithChanges = (changes) => { - const cr = new ChangeRegion(); - changes.forEach(([x, y, after, before]) => - cr.setChange(x, y, after, before)); - return cr; - }; - - beforeEach(() => { - cr = new ChangeRegion(); - }); - - describe('ChangeRegion Creation', () => { - test('should create an empty dirty rectangle', () => { - cr = new ChangeRegion(); - expect(cr.width).toBe(0); - expect(cr.height).toBe(0); - 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, 'state'); - expect(cr.hasChange(...expected)).toBe(true); - }); - }); - - describe('State Management', () => { - test('should preserve initial before state', () => { - cr.setChange(0, 0, 'after', 'before'); - cr.setChange(0, 0, 'updated'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }]); - cr.setChange(0, 2, 'newpoint'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'before' }, { x: 0, y: 2, state: 'newpoint' }]); - }); - - test('should update after state on subsequent calls', () => { - cr.setChange(1, 1, 'v1'); - 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' }]); - }); - - test('should order all changes old to new in before states and after states, changes map should access any change', () => { - cr.setChange(0, 0, 'v1'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }]); - expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }]); - expect(cr.changesMap.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v1', before: 'v1' }); - cr.setChange(0, 2, 'v2'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); - expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }]); - expect(cr.changesMap.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); - cr.setChange(1, 0, 'v3'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(cr.changesMap.get(`1,0`)) .toEqual({ x: 1, y: 0, after: 'v3', before: 'v3' }); - cr.setChange(0, 0, 'v4'); - expect(cr.beforeStates).toEqual([{ x: 0, y: 0, state: 'v1' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(cr.afterStates) .toEqual([{ x: 0, y: 0, state: 'v4' }, { x: 0, y: 2, state: 'v2' }, { x: 1, y: 0, state: 'v3' }]); - expect(cr.changesMap.get(`0,0`)) .toEqual({ x: 0, y: 0, after: 'v4', before: 'v1' }); - expect(cr.changesMap.get(`0,2`)) .toEqual({ x: 0, y: 2, after: 'v2', before: 'v2' }); - }); - }); - - describe('Bounds Calculation', () => { - test.each` - changes | expectedBounds - ${'[0, 0]'} | ${{ x0: 0, y0: 0, x1: 0, y1: 0 }} - ${'[1, 2], [3, 4]'} | ${{ x0: 1, y0: 2, x1: 3, y1: 4 }} - ${'[-1, -2], [-3, -4]'} | ${{ x0: -3, y0: -4, x1: -1, y1: -2 }} - ${'[1, 2], [-3, -4]'} | ${{ x0: -3, y0: -4, x1: 1, y1: 2 }} - ${'[-1, -2], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} - ${'[-1, -2], [3, 4], [0, 0]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} - ${'[-1, -2], [0, 0], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} - ${'[0, 0], [-1, -2], [3, 4]'} | ${{ x0: -1, y0: -2, x1: 3, y1: 4 }} - `('should calculate bounds correctly for given $changes', ({ changes, expectedBounds }) => { - - // magic for turning the '[0, 0], [-1, -2], [3, 4]' string into [[0, 0], [-1, -2], [3, 4]] array :P - changes = changes - .replace(/[\[\]\,]/g, ' ') - .trim() - .split(/\s./) - .filter(s => s != '') - .map(x => Number(x)) - .reduce((c, n) => { - if (c[c.length - 1].length >= 2) - c.push([]); - c[c.length - 1].push(n); - return c; - }, [[]]); - - cr = createRectWithChanges(changes); - expect(cr.bounds).toEqual(expectedBounds); - }); - }); - }); - - describe('ChangeRegion Manipulation', () => { - - describe('Cloning', () => { - test('should produce independent copy', () => { - cr.setChange(0, 0, 'original'); - const clone = cr.clone(); - - // Test independence - clone.setChange(1, 1, 'new'); - cr.setChange(2, 2, 'different'); - - expect(clone.hasChange(2, 2)).toBe(false); - expect(cr.hasChange(1, 1)).toBe(false); - }); - - test('should preserve all properties', () => { - cr.setChange(1, 2, 'state'); - const clone = cr.clone(); - - expect(clone.afterStates).toEqual(cr.afterStates); - expect(clone.bounds).toEqual(cr.bounds); - }); - }); - - describe('Merging', () => { - test('should merge overlapping pixels correctly', () => { - const cr1 = new ChangeRegion(); - cr1.setChange(0, 0, 'cr1-after', 'cr1-before'); - - const cr2 = new ChangeRegion(); - 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 expand bounds to include both rects', () => { - const cr1 = new ChangeRegion(); - cr1.setChange(0, 0, 'state'); - - const cr2 = new ChangeRegion(); - cr2.setChange(5, 5, 'state'); - - let merge = cr1.merge(cr2); - expect(merge.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); - }); - - test('should merge into the calling object without creating new one if called in-place merge', () => { - const cr1 = new ChangeRegion(); - cr1.setChange(0, 0, 'state'); - - const cr2 = new ChangeRegion(); - cr2.setChange(5, 5, 'state'); - - cr1.mergeInPlace(cr2); - expect(cr1.bounds).toEqual({ x0: 0, y0: 0, x1: 5, y1: 5 }); - }); - }); - }); -}); diff --git a/tests/color.test.js b/tests/services/color.test.js similarity index 99% rename from tests/color.test.js rename to tests/services/color.test.js index fd241fb..1ca940b 100644 --- a/tests/color.test.js +++ b/tests/services/color.test.js @@ -1,4 +1,4 @@ -import Color from '../scripts/color.js'; +import Color from '#services/color.js'; describe('Color Class', () => { describe('Color Creation', () => { 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/validation.test.js b/tests/utils/validation.test.js similarity index 98% rename from tests/validation.test.js rename to tests/utils/validation.test.js index e82d9e2..c055c4d 100644 --- a/tests/validation.test.js +++ b/tests/utils/validation.test.js @@ -1,4 +1,4 @@ -import { validateColorArray, validateNumber } from "../scripts/validation.js"; +import { validateColorArray, validateNumber } from "#utils/validation.ts"; describe("validateNumber", () => { describe("Happy Paths", () => { 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/*"] + } + } +}