diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ad834b7..a7a8d14 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,11 +14,17 @@ export function pickColor( y: number, imageData?: Uint8ClampedArray | undefined, ): HEX { - // If we do not have cached image data, we use offscreen canvas context to get underlying pixel data + /** + * If we do not have a cached image data, we use offscreen canvas context to get underlying pixel data and convert it to HEX color code. + */ if (!imageData) { return rgbToHex(context.getImageData(x, y, 1, 1).data); } - // Calculate the index of the underlying pixel in the imageData array + /** + * Otherwise, read the pixel data from the cache to avoid heavy context.getImageData(x, y, 1, 1) operation. + * 1. Calculate the index of the underlying pixel in the imageData array. + * 2. Convert the RGB to HEX color code. + */ const index = (Math.floor(y) * canvas.width + Math.floor(x)) * 4; return rgbToHex([imageData[index], imageData[index + 1], imageData[index + 2]]); } @@ -31,6 +37,10 @@ export function getMaxPixelRatio( ): number { if (typeof window === 'undefined') return target; + /** + * Canvas-size runs tests using a set of predefined size values for a variety of browser and platform combinations. + * Tests validate the ability to read pixel data from canvas element of the predefined dimension by decreasing canvas height and/or width until a test succeeds. + */ while (!canvasSize.test({ sizes: [[width * target, height * target]] })) { target -= decrement; } diff --git a/src/model/GeometryManager.ts b/src/model/GeometryManager.ts index ac597b6..adc3b2e 100644 --- a/src/model/GeometryManager.ts +++ b/src/model/GeometryManager.ts @@ -22,7 +22,7 @@ export class GeometryManager { getCoordinates(e: OriginalEvent): Point { if (window.TouchEvent && e instanceof TouchEvent) { return this.getTouchCoordinates(e); - } else if (e instanceof MouseEvent || e instanceof PointerEvent) { + } else if (e instanceof MouseEvent) { return this.getMouseCoordinates(e); } @@ -50,7 +50,7 @@ export class GeometryManager { if (window.TouchEvent && e instanceof TouchEvent) { return this.getTouchPosition(e, rect); - } else if (e instanceof MouseEvent || e instanceof PointerEvent) { + } else if (e instanceof MouseEvent) { return this.getMousePosition(e, rect); } diff --git a/src/model/RenderManager.ts b/src/model/RenderManager.ts index e593ffb..b40e72f 100644 --- a/src/model/RenderManager.ts +++ b/src/model/RenderManager.ts @@ -3,6 +3,7 @@ import { createHitCanvas, GeometryManager, type CursorState, + type HEX, type HitCanvasRenderingContext2D, type LayerId, type OriginalEvent, @@ -23,7 +24,7 @@ export class RenderManager { needsRedraw: boolean; needsCacheImage: boolean; - selectedColor: Writable = writable(BLACK); + selectedColor: Writable = writable(BLACK); cursor: Writable = writable({ x: 0, y: 0, color: BLACK }); constructor(geometryManager: GeometryManager) { @@ -54,6 +55,9 @@ export class RenderManager { this.drawers.delete(layerId); } + /** + * The main render function which is responsible for drawing, clearing and canvas's transformation matrix adjustment. + * */ render() { const context = this.context!; const width = this.width!; @@ -77,11 +81,18 @@ export class RenderManager { } } + /** + * Forces canvas's transformation matrix adjustment to scale drawings according to the new width, height or device's pixel ratio. + * Forces caching image data representing the underlying pixel data for the entire canvas. + */ redraw() { this.needsRedraw = true; this.needsCacheImage = true; } + /** + * Handles "click" event on canvas to get the underlying pixel data and convert it to HEX color code. + */ handlePick(e: OriginalEvent) { if (!this.imageData) return; @@ -90,6 +101,9 @@ export class RenderManager { this.selectedColor.set(hexCode); } + /** + * Handles "move" event on canvas to get the underlying pixel data and convert it to HEX color code. + */ handleMove(e: OriginalEvent) { if (!this.imageData) return; diff --git a/src/model/RenderWorker.ts b/src/model/RenderWorker.ts index 758fa21..cbecf45 100644 --- a/src/model/RenderWorker.ts +++ b/src/model/RenderWorker.ts @@ -25,6 +25,10 @@ export class RenderWorker { cursor: Writable = writable({ x: 0, y: 0, color: BLACK }); constructor(geometryManager: GeometryManager) { + /** + * Register a worker allowing to perform intensive operations without blocking the main thread. + * But data transfering (image data back to the main thread frequently) can introduce overhead and latency due to the serialization and deserialization of data. + */ this.worker = new Worker(); this.geometryManager = geometryManager; @@ -33,6 +37,10 @@ export class RenderWorker { }); } + /** + * Creates the offscreen canvas, transfer it to a worker and subscribe to the worker events. + * Since ownership of the main canvas is transferred, it becomes available only to the worker. + */ init(canvas: HTMLCanvasElement, _contextSettings: CanvasRenderingContext2DSettings | undefined) { const offscreenCanvas = canvas.transferControlToOffscreen(); @@ -40,7 +48,7 @@ export class RenderWorker { { action: WorkerActionEnum.INIT, canvas: offscreenCanvas, - drawers: JSONfn.stringify(Array.from(get(this.drawers))), + drawers: this.stringifyDrawers(), width: this.width, height: this.height, pixelRatio: this.pixelRatio, @@ -48,12 +56,14 @@ export class RenderWorker { [offscreenCanvas], ); - this.worker.onmessage = (event: MessageEvent) => { - if (event.data.action === WorkerActionEnum.GET_COLOR) { - const point = event.data.cursorPosition; - this.cursor.set({ x: point.x, y: point.y, color: event.data.color }); - } else if (event.data.action === WorkerActionEnum.PICK_COLOR) { - this.selectedColor.set(event.data.color); + this.worker.onmessage = (e: MessageEvent) => { + const { action, color, cursorPosition } = e.data; + + if (action === WorkerActionEnum.GET_COLOR) { + const { x, y } = cursorPosition; + this.cursor.set({ x, y, color }); + } else if (action === WorkerActionEnum.PICK_COLOR) { + this.selectedColor.set(color); } }; } @@ -93,15 +103,24 @@ export class RenderWorker { }); } + /** + * Forces canvas's transformation matrix adjustment to scale drawings according to the new width, height or device's pixel ratio. + */ redraw() { this.resize(); } + /** + * Handles "click" event on main canvas and sends the corresponding event to get the underlying pixel data from the offscreen canvas. + */ handlePick(e: OriginalEvent) { const { x, y } = this.geometryManager.calculatePosition(e); this.worker.postMessage({ action: WorkerActionEnum.PICK_COLOR, x, y, cursorPosition: { x, y } }); } + /** + * Handles "move" event on the main canvas and sends the corresponding event to get the underlying pixel data from the offscreen canvas. + */ handleMove(e: OriginalEvent) { const { x, y } = this.geometryManager.calculatePosition(e); const cursorPosition = this.geometryManager.getCoordinates(e); diff --git a/src/model/createHitCanvas.ts b/src/model/createHitCanvas.ts index 93c9126..e98c4f1 100644 --- a/src/model/createHitCanvas.ts +++ b/src/model/createHitCanvas.ts @@ -1,17 +1,26 @@ import { type HEX, type HitCanvasRenderingContext2D } from '.'; import { pickColor } from '../lib'; +/** + * Offscreen canvas settings for rendering optimization + */ const settings: CanvasRenderingContext2DSettings = { willReadFrequently: true, alpha: false, }; +/** A list of canvas context setters that we do not need to use on the offscreen canvas, so this allows to optimize rendering */ const EXCLUDED_SETTERS: Array = [ 'shadowBlur', 'globalCompositeOperation', 'globalAlpha', ]; +/** + * Under the hood, we proxy all CanvasRenderingContext2D methods to a second, offscreen canvas. + * When an event occurs on the main canvas, the color of the pixel at the event coordinates is read from the offscreen canvas and converted to HEX color code. + * This approach can also be useful for identifying the corresponding layer using a unique fill and stroke color and then re-dispatch an event to the Layer component. + */ export function createHitCanvas( canvas: HTMLCanvasElement, contextSettings: CanvasRenderingContext2DSettings | undefined, diff --git a/src/model/model.ts b/src/model/model.ts index 4957e45..81b9720 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -11,7 +11,7 @@ export type LayerId = string; export type Point = { x: number; y: number }; export type RGB = [number, number, number]; export type HEX = string; -export type OriginalEvent = MouseEvent | PointerEvent | TouchEvent; +export type OriginalEvent = MouseEvent | PointerEvent; export interface Render { (props: RenderProps): void; diff --git a/src/model/worker.ts b/src/model/worker.ts index ebb6884..f7ccc96 100644 --- a/src/model/worker.ts +++ b/src/model/worker.ts @@ -12,6 +12,9 @@ let pixelRatio: number | null = null; let frame: number | null = null; let needsRedraw = true; +/** + * Offscreen canvas settings for rendering optimization + */ const settings: CanvasRenderingContext2DSettings = { willReadFrequently: true, }; @@ -21,6 +24,9 @@ function startRenderLoop() { frame = requestAnimationFrame(() => startRenderLoop()); } +/** + * The main render function which is responsible for drawing, clearing and canvas's transformation matrix adjustment. + * */ function render() { if (!context) return; @@ -50,12 +56,13 @@ self.onmessage = function (e: MessageEvent) { switch (action) { case WorkerActionEnum.INIT: offscreenCanvas = e.data.canvas; + context = offscreenCanvas.getContext('2d', settings); + drawers = parseDrawers(e.data.drawers); width = e.data.width; height = e.data.height; pixelRatio = e.data.pixelRatio; - context = offscreenCanvas.getContext('2d', settings); startRenderLoop(); break; diff --git a/src/ui/Canvas.svelte b/src/ui/Canvas.svelte index 7b67f15..47f71fb 100644 --- a/src/ui/Canvas.svelte +++ b/src/ui/Canvas.svelte @@ -3,13 +3,27 @@ import { type OriginalEvent, type AppContext, type ResizeEvent } from '../model'; import { getMaxPixelRatio, KEY } from '../lib'; + /** + * When unset, the canvas will use its clientWidth property. + */ export let width: number | null = null; + /** + * When unset, the canvas will use its clientHeight property. + */ export let height: number | null = null; + /** + * If pixelRatio is unset, the canvas uses devicePixelRatio binding to match the window’s pixel dens. + * If pixelRatio is set to "auto", the canvas-size library is used to automatically calculate the maximum supported pixel ratio based on the browser and canvas size. + * This can be particularly useful when rendering large canvases on iOS Safari (https://pqina.nl/blog/canvas-area-exceeds-the-maximum-limit/) + */ export let pixelRatio: 'auto' | number | null = null; export let contextSettings: CanvasRenderingContext2DSettings | undefined = undefined; export let isActive = true; export let style = ''; + /** + * Returns a reference to the canvas DOM element in the parent component + */ export const getCanvasElement = (): HTMLCanvasElement => canvasRef; const { renderManager } = getContext(KEY); @@ -33,6 +47,7 @@ canvasWidth = contentRect.width; canvasHeight = contentRect.height; }); + canvasObserver.observe(node); return { @@ -51,21 +66,44 @@ $: _width = width ?? canvasWidth ?? 0; $: _height = height ?? canvasHeight ?? 0; + /** + * If pixelRatio is set to "auto", we will calculate the maximum supported pixel ratio based on the browser and canvas size. + * Calculate a new maxPixelRatio each time _width, _height or devicePixelRatio change. + */ $: if (devicePixelRatio && pixelRatio === 'auto') { maxPixelRatio = getMaxPixelRatio(_width, _height, devicePixelRatio); } else { maxPixelRatio = undefined; } + /** + * _pixelRatio parameter allows to prevent canvas items from appearing blurry on higher-resolution displays. + * To do this, we scale canvas for high resolution displays: + * 1. Set the "actual" size of the canvas: + canvas.width = _width * _pixelRatio + canvas.height = _height * _pixelRatio + * 2. Set the "drawn" size of the canvas: + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + */ $: _pixelRatio = maxPixelRatio ?? pixelRatio ?? devicePixelRatio ?? 2; + /** + * Update app state each time _width, _height or _pixelRatio values of the canvas change + */ $: renderManager.width = _width; $: renderManager.height = _height; $: renderManager.pixelRatio = _pixelRatio; $: geometryManager.pixelRatio = _pixelRatio; + /** + * Adjust canvas's transformation matrix to scale drawings according to the device's pixel ratio + */ $: _width, _height, _pixelRatio, renderManager.redraw(); + /** + * Dispatch "resize" event to the parent component + */ $: dispatch('resize', { width: _width, height: _height, diff --git a/src/ui/Layer.svelte b/src/ui/Layer.svelte index eff28ae..e3ca7ff 100644 --- a/src/ui/Layer.svelte +++ b/src/ui/Layer.svelte @@ -4,6 +4,11 @@ import { type Render, type AppContext } from '../model'; import { KEY } from '../lib'; + /** + * The Layer component encapsulates a piece of canvas rendering logic. + * It is a renderless component that accepts only render function and registers a new layer on the canvas. + */ + export let render: Render; const { renderManager } = getContext(KEY);