Skip to content

Commit

Permalink
add comments
Browse files Browse the repository at this point in the history
  • Loading branch information
rejth committed May 21, 2024
1 parent 5c0eca2 commit d0c6931
Show file tree
Hide file tree
Showing 9 changed files with 116 additions and 14 deletions.
14 changes: 12 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]]);
}
Expand All @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/model/GeometryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
16 changes: 15 additions & 1 deletion src/model/RenderManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createHitCanvas,
GeometryManager,
type CursorState,
type HEX,
type HitCanvasRenderingContext2D,
type LayerId,
type OriginalEvent,
Expand All @@ -23,7 +24,7 @@ export class RenderManager {
needsRedraw: boolean;
needsCacheImage: boolean;

selectedColor: Writable<string> = writable(BLACK);
selectedColor: Writable<HEX> = writable(BLACK);
cursor: Writable<CursorState> = writable({ x: 0, y: 0, color: BLACK });

constructor(geometryManager: GeometryManager) {
Expand Down Expand Up @@ -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!;
Expand All @@ -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;

Expand All @@ -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;

Expand Down
33 changes: 26 additions & 7 deletions src/model/RenderWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export class RenderWorker {
cursor: Writable<CursorState> = 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;

Expand All @@ -33,27 +37,33 @@ 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();

this.worker.postMessage(
{
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,
},
[offscreenCanvas],
);

this.worker.onmessage = (event: MessageEvent<WorkerEvent>) => {
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<WorkerEvent>) => {
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);
}
};
}
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/model/createHitCanvas.ts
Original file line number Diff line number Diff line change
@@ -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<keyof HitCanvasRenderingContext2D> = [
'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,
Expand Down
2 changes: 1 addition & 1 deletion src/model/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/model/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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;

Expand Down Expand Up @@ -50,12 +56,13 @@ self.onmessage = function (e: MessageEvent<WorkerEvent>) {
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;
Expand Down
38 changes: 38 additions & 0 deletions src/ui/Canvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppContext>(KEY);
Expand All @@ -33,6 +47,7 @@
canvasWidth = contentRect.width;
canvasHeight = contentRect.height;
});
canvasObserver.observe(node);
return {
Expand All @@ -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 ?? <number>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,
Expand Down
5 changes: 5 additions & 0 deletions src/ui/Layer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppContext>(KEY);
Expand Down

0 comments on commit d0c6931

Please sign in to comment.