@avolutions/canvas-painter
Version:
CanvasPainter.js is a simple yet powerful JavaScript library for drawing basic shapes (rectangles, circles, etc.) on HTML5 Canvas with ease. Perfect for creating 2D graphics in your web projects.
643 lines (642 loc) • 25.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Canvas = void 0;
const ShapeState_js_1 = require("./common/ShapeState.js");
const CanvasOptions_js_1 = require("./options/CanvasOptions.js");
const CanvasStyle_js_1 = require("./styles/CanvasStyle.js");
const Mouse_js_1 = require("./types/Mouse.js");
const Point_js_1 = require("./types/Point.js");
/**
* Class representing a Canvas element that can manage and render shapes.
*/
class Canvas {
/**
* Constructs a new Canvas instance.
*
* @param canvas - The HTML canvas element.
* @param context - The 2D rendering context of the canvas.
* @param options - Optional configuration options for the canvas.
* @param style - Optional styling options for the canvas.
*/
constructor(canvas, context, options, style) {
/** List of shapes being watched for changes and re-rendered. */
this.watchedShapes = [];
/** Current zoom scale factor of the canvas. */
this._zoomScale = 1.0;
/** Indicates whether panning is currently active. */
this._isPanning = false;
/** The shape currently being hovered over by the mouse. */
this._hoverShape = null;
/** The shape currently being dragged. */
this._dragShape = null;
/**
* Handles the `contextmenu` event to prevent the default context menu from appearing.
*
* @param event - The mouse event that triggers the context menu.
*/
this.onContextMenu = (event) => {
// Prevent default context menu on right click
event.preventDefault();
};
/**
* Handles the `wheel` event for zooming in or out.
* When zooming, the current mouse position is used as the zoom center if panning is active.
*
* @param event - The wheel event that triggers the zoom action.
*/
this.onWheel = (event) => {
// If zooming is not activated, we do nothing
if (!this._options.zoomable || !this._options.zoom.useWheel) {
return;
}
// Prevent default wheel behavior
event.preventDefault();
// If pannable is active, we use current mouse position as zoom center
let mousePosition;
if (this._options.pannable) {
mousePosition = Mouse_js_1.Mouse.getOffsetPosition(event);
}
if (event.deltaY < 0) {
this.zoomIn(mousePosition);
}
else {
this.zoomOut(mousePosition);
}
};
/**
* Handles the `mousedown` event to start the panning action.
* The panning will only start if the pressed mouse button is configured for panning.
*
* @param event - The mouse event that triggers the panning action.
*/
this.onMouseDown = (event) => {
var _a, _b;
// Get mouse position in canvas
const mousePosition = Mouse_js_1.Mouse.getOffsetPosition(event);
// Handle dragging
if ((_a = this._hoverShape) === null || _a === void 0 ? void 0 : _a.isDraggable()) {
// Set currently dragged shape
this._dragShape = this._hoverShape;
// Set shape state to active
this._dragShape.state = ShapeState_js_1.ShapeState.Active;
// Set active style cursor
this._canvas.style.cursor = this._dragShape.stateStyle.cursor;
// Set drag start position
this._dragPosition = mousePosition;
return;
}
// Handle panning
if (this._options.pannable &&
this._options.pan.useMouse &&
((_b = this._options.pan.mouseButtons) === null || _b === void 0 ? void 0 : _b.includes(event.button))) {
// Start panning
this._isPanning = true;
this._panStart = new Point_js_1.Point(mousePosition.x - this.panOffset.x, mousePosition.y - this.panOffset.y);
// Set cursor for panning
this._canvas.style.cursor = this._style.cursor.panActive;
}
};
/**
* Handles the `mousemove` event to update the panning offset.
* This is triggered when the user drags the canvas while panning.
*
* @param event - The mouse event that triggers the pan movement.
*/
this.onMouseMove = (event) => {
const mousePosition = Mouse_js_1.Mouse.getOffsetPosition(event);
// Handle dragging
if (this._dragShape) {
// Get difference to dragPosition
const delta = new Point_js_1.Point((mousePosition.x - this._dragPosition.x) / this.zoomScale, (mousePosition.y - this._dragPosition.y) / this.zoomScale);
// Call onDrag() of currently dragged shape
this._dragShape.onDrag(delta);
// Set new dragPosition to current mousePosition
this._dragPosition = mousePosition;
return;
}
// Handle panning
if (this._isPanning) {
this.panOffset = new Point_js_1.Point(mousePosition.x - this._panStart.x, mousePosition.y - this._panStart.y);
return;
}
// Handle hover state
const mousePositionUntransformed = mousePosition.asUntransformed(this.panOffset, this.zoomScale);
let hoverSet = false;
this._hoverShape = null;
// Iterate watchedShapes backwards, to check highest layered shapes first
for (let i = this.watchedShapes.length - 1; i >= 0; i--) {
const shape = this.watchedShapes[i];
if (!hoverSet && shape.isMouseOver(mousePositionUntransformed)) {
// Set the first hovered shape to Hover state (if not already in it)
shape.state = ShapeState_js_1.ShapeState.Hover;
// Set the currently hovered shape
this._hoverShape = shape;
// Set hover style cursor
this._canvas.style.cursor = shape.stateStyle.cursor;
// Set the flag after finding the first hovered shape
hoverSet = true;
}
else {
// Reset cursor if currently hovered shape is no longer hovered
if (shape.state === ShapeState_js_1.ShapeState.Hover) {
this.resetCursor();
}
// Ensure all other shapes are in Default state if not hovered
shape.state = ShapeState_js_1.ShapeState.Default;
}
}
};
/**
* Handles the `mouseup` and `mouseleave` events to stop the panning action.
* This is triggered when the user releases the mouse or when the mouse leaves the canvas.
*
* @param event - The mouse event that triggers the end of the panning action.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
this.onMouseUp = (event) => {
// Stop dragging
if (this._dragShape) {
this._dragShape.state = ShapeState_js_1.ShapeState.Default;
this._dragShape = null;
this._dragPosition = new Point_js_1.Point(0, 0);
// Set default cursor
this.resetCursor();
}
// Stop panning
if (this._isPanning) {
this._isPanning = false;
// Set default cursor
this.resetCursor();
}
};
/**
* Handles the mouse leave event for the canvas.
* This method is triggered when the mouse leaves the canvas area,
* setting the state of all watched shapes to `Default`.
*
* @param event - The mouse event that triggered this handler.
*/
this.onMouseLeave = (event) => {
// If we were dragging, handle like a mouse up
if (this._dragShape) {
this.onMouseUp(event);
}
// If we were panning, handle like a mouse up
if (this._isPanning) {
this.onMouseUp(event);
}
this.setStateForAllShapes(ShapeState_js_1.ShapeState.Default);
};
/**
* Observes changes in shapes and triggers the canvas redraw process.
*/
this.observerRedraw = () => {
this.redraw();
};
this._canvas = canvas;
this._context = context;
// Create options
this._options = new CanvasOptions_js_1.CanvasOptions(options);
// Create style
this._style = new CanvasStyle_js_1.CanvasStyle(style);
// Determine width, set canvas width and update options with new value
const width = this.getWidth(options);
this._canvas.style.width = "auto"; // Overrides any CSS class-based width
this._canvas.width = width;
this._options.width = width;
// Determine height, set canvas width and update options with new value
const height = this.getHeight(options);
this._canvas.style.height = "auto"; // Overrides any CSS class-based width
this._canvas.height = height;
this._options.height = height;
// Apply styles to context
this.applyStyle(this._style);
// Initialize pan properties
this._panOffset = new Point_js_1.Point(0, 0);
this._panStart = new Point_js_1.Point(0, 0);
// Initialize drag properties
this._dragPosition = new Point_js_1.Point(0, 0);
// Register event listeners
this.addEventListener();
}
/**
* Adds event listeners for the canvas element.
*/
addEventListener() {
// Do not add event listener if canvas is not interactive
if (!this._options.interactive) {
return;
}
this._canvas.addEventListener('contextmenu', this.onContextMenu);
this._canvas.addEventListener('mousedown', this.onMouseDown);
this._canvas.addEventListener('mouseleave', this.onMouseLeave);
this._canvas.addEventListener('mousemove', this.onMouseMove);
this._canvas.addEventListener('mouseup', this.onMouseUp);
this._canvas.addEventListener('wheel', this.onWheel);
}
/**
* Removes all registered event listeners from the canvas element to prevent memory leaks and
* disable specific interactions.
*/
removeEventListener() {
this._canvas.removeEventListener('contextmenu', this.onContextMenu);
this._canvas.removeEventListener('mousedown', this.onMouseDown);
this._canvas.removeEventListener('mouseleave', this.onMouseLeave);
this._canvas.removeEventListener('mousemove', this.onMouseMove);
this._canvas.removeEventListener('mouseup', this.onMouseUp);
this._canvas.removeEventListener('wheel', this.onWheel);
}
/**
* Retrieves the height of the canvas element, prioritizing the provided options,
* then the HTML canvas element's height attribute, followed by CSS height, and finally
* falls back to the default height.
*
* @param options - Optional canvas options that may contain a height.
* @returns The height value based on the order of priority described.
*/
getHeight(options) {
// If height was provided as option
if (options === null || options === void 0 ? void 0 : options.height) {
return options.height;
}
// If height is specified as attribute of HTML canvas element
const htmlHeight = this._canvas.getAttribute('height');
if (htmlHeight) {
return parseFloat(htmlHeight);
}
// If height is specified in any CSS of the element
const cssHeight = this.getCssDimensions().height;
if (cssHeight) {
return cssHeight;
}
// Use default options as ultimate fallback
return CanvasOptions_js_1.CanvasOptions.DefaultOptions.height;
}
/**
* Retrieves the width of the canvas element, prioritizing the provided options,
* then the HTML canvas element's width attribute, followed by CSS width, and finally
* falls back to the default width.
*
* @param options - Optional canvas options that may contain a width.
* @returns The width value based on the order of priority described.
*/
getWidth(options) {
// If width was provided as option
if (options === null || options === void 0 ? void 0 : options.width) {
return options.width;
}
// If width is specified as attribute of HTML canvas element
const htmlWidth = this._canvas.getAttribute('width');
if (htmlWidth) {
return parseFloat(htmlWidth);
}
// If width is specified in any CSS of the element
const cssWidth = this.getCssDimensions().width;
if (cssWidth) {
return cssWidth;
}
// Use default options as ultimate fallback
return CanvasOptions_js_1.CanvasOptions.DefaultOptions.width;
}
/**
* Returns the center point of the canvas based on its width and height.
*
* @returns The center point of the canvas as a `Point` object.
*/
getCenter() {
const x = this._options.width / 2;
const y = this._options.height / 2;
return new Point_js_1.Point(x, y);
}
/**
* Retrieves the CSS width and height dimensions of the canvas element by
* computing the styles applied to it. If the values are not present, it returns
* undefined for both width and height.
*
* @returns An object containing the CSS width and height, if available.
*/
getCssDimensions() {
const computedStyle = window.getComputedStyle(this._canvas);
const width = computedStyle.width ? parseFloat(computedStyle.width) : undefined;
const height = computedStyle.height ? parseFloat(computedStyle.height) : undefined;
return {
width: width,
height: height,
};
}
/**
* Initializes a Canvas instance by retrieving the canvas element by ID and its context.
*
* @param id - The ID of the HTML canvas element.
* @param options - Optional configuration options for the canvas.
* @param style - Optional styling options for the canvas.
*
* @returns A new Canvas instance.
*
* @throws ReferenceError if the canvas element is not found.
* @throws TypeError if the element is not a valid canvas or can't get 2d context.
*/
static init(id, options, style) {
const canvas = document.getElementById(id);
if (!canvas) {
throw new ReferenceError(`Element with id '${id}' not found`);
}
if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError(`Element with id '${id}' is not a canvas`);
}
const context = canvas.getContext('2d');
if (!context) {
throw new TypeError(`Failed to get '2d' context from canvas`);
}
// Check if an instance already exists for this canvas element
if (Canvas.instances.has(canvas)) {
const instance = Canvas.instances.get(canvas);
// Remove event listener and delete instance
instance.removeEventListener();
Canvas.instances.delete(canvas);
}
// Create a new instance and store it in the WeakMap
const instance = new Canvas(canvas, context, options, style);
Canvas.instances.set(canvas, instance);
return instance;
}
/**
* Sets the canvas and context styles based on the provided CanvasStyle.
*
* @param style - The style settings to apply to the canvas and context.
*/
applyStyle(style) {
// Canvas
this._canvas.style.cursor = style.cursor.default;
// Context
this._context.fillStyle = style.color;
}
/**
* Registers a shape or an array of shapes to be watched for changes and renders them.
*
* @param shapeOrShapes - The shape(s) to watch and render on the canvas.
* @param redraw - Whether to immediately redraw the canvas after registering the shape(s).
*/
watch(shapeOrShapes, redraw = true) {
// If passed parameter was a single shape we convert it to array
const shapes = Array.isArray(shapeOrShapes) ? shapeOrShapes : [shapeOrShapes];
// Flag to track if at least one shape was added
let shapeAdded = false;
shapes.forEach((shape) => {
if (!this.watchedShapes.includes(shape)) {
// Add an observer to redraw the canvas when the shape changes
shape.addObserver(this.observerRedraw);
// Add to watched shapes
this.watchedShapes.push(shape);
shapeAdded = true;
}
});
if (shapeAdded && redraw) {
// Initial draw after at least one shape was added
this.redraw();
}
}
/**
* Unregisters a shape or an array of shapes from being watched and re-renders the canvas.
*
* @param shapeOrShapes - The shape(s) to stop watching.
* @param redraw - Whether to immediately redraw the canvas after unregistering the shape(s).
*/
unwatch(shapeOrShapes, redraw = true) {
// If passed parameter was a single shape we convert it to array
const shapes = Array.isArray(shapeOrShapes) ? shapeOrShapes : [shapeOrShapes];
// Flag to track if at least one shape was removed
let shapeRemoved = false;
shapes.forEach((shape) => {
const index = this.watchedShapes.indexOf(shape);
if (index !== -1) {
// Remove observer to not redraw anymore on changes of shape
shape.removeObserver(this.observerRedraw);
// Remove from watched shapes
this.watchedShapes.splice(index, 1);
shapeRemoved = true;
}
});
if (shapeRemoved && redraw) {
// Redraw canvas after at least one shape was removed
this.redraw();
}
}
/**
* Gets the 2D rendering context of the canvas.
*
* @returns The 2D context of the canvas.
*/
get context() {
return this._context;
}
/**
* Clears the canvas by removing all content.
*/
clear() {
this.context.clearRect(0, 0, this._canvas.width, this._canvas.height);
}
/**
* Clears the canvas and re-renders all watched and visible shapes.
*/
redraw() {
// Reset the transformation matrix to identity
this._context.resetTransform();
// Clear the whole canvas
this.clear();
// Apply the new transformation
this._context.transform(this.zoomScale, 0, 0, this.zoomScale, this._panOffset.x, this._panOffset.y);
// Render each watched shape
this.watchedShapes.forEach((shape) => this.draw(shape));
}
/**
* Renders the specified shape on the canvas if shape is visible.
*
* @param shape - The shape to render.
*/
draw(shape) {
if (shape.isVisible()) {
shape.render(this.context);
}
}
/**
* Zooms in on the canvas by increasing the zoom scale.
* If a position is provided, the zoom will be centered around that point.
* Otherwise, it defaults to zooming in on the center of the canvas.
*
* @param position - Optional position to center the zoom on.
*/
zoomIn(position) {
// Do nothing if canvas is not zoomable
if (!this._options.zoomable) {
return;
}
this.applyZoom(this._options.zoom.step, position);
}
/**
* Zooms out on the canvas by decreasing the zoom scale.
* If a position is provided, the zoom will be centered around that point.
* Otherwise, it defaults to zooming out from the center of the canvas.
*
* @param position - Optional position to center the zoom on.
*/
zoomOut(position) {
// Do nothing if canvas is not zoomable
if (!this._options.zoomable) {
return;
}
this.applyZoom(-this._options.zoom.step, position);
}
/**
* Applies the zoom step to the canvas. The zoom scale is adjusted by the
* given `zoomStep`, and the pan offset is recalculated to keep the zoom
* centered on the provided position (or the center of the canvas if no
* position is provided).
*
* @param zoomStep - The zoom step to apply (positive to zoom in, negative to zoom out).
* @param position - Optional position to center the zoom on.
*/
applyZoom(zoomStep = 0, position) {
// Do nothing if canvas is not zoomable
if (!this._options.zoomable) {
return;
}
// If no position was provided, zoom to center
if (!position) {
position = this.getCenter();
}
// Get mouse position in canvas space before zooming
const x = (position.x - this._panOffset.x) / this.zoomScale;
const y = (position.y - this._panOffset.y) / this.zoomScale;
const canvasMouse = new Point_js_1.Point(x, y);
// Update the zoom scale
const newZoomScale = this.zoomScale + zoomStep;
// Calculate the new pan values to keep the zoom centered on the mouse position
this._panOffset.x = position.x - canvasMouse.x * newZoomScale;
this._panOffset.y = position.y - canvasMouse.y * newZoomScale;
// Not using setter here to prevent loop
this._zoomScale = newZoomScale;
this.redraw();
}
/**
* Resets the zoom scale to its default value (1).
* If the canvas is both zoomable and pannable, it resets both zoom and pan.
*/
resetZoom() {
// Do nothing if canvas is not zoomable
if (!this._options.zoomable) {
return;
}
if (this._options.pannable) {
this.resetZoomPan();
return;
}
this.zoomScale = 1;
}
/**
* Resets the pan offset to its default value (0, 0).
*/
resetPan() {
// Do nothing if canvas is not pannable
if (!this._options.pannable) {
return;
}
this.panOffset = new Point_js_1.Point(0, 0);
}
/**
* Resets both the zoom scale and pan offset to their default values (1 for zoom scale and (0, 0) for pan).
*/
resetZoomPan() {
// Do nothing if canvas is not zoomable and pannable
if (!this._options.zoomable || !this._options.pannable) {
return;
}
this._panOffset = new Point_js_1.Point(0, 0);
this.zoomScale = 1;
}
/**
* Gets the current zoom scale of the canvas.
* A value of `1` represents 100% zoom. Values below `1` indicate zooming out,
* and values above `1` indicate zooming in.
*
* @returns The current zoom scale.
*/
get zoomScale() {
return this._zoomScale;
}
/**
* Sets the zoom scale of the canvas and applies the zoom.
*
* @param value - The new zoom scale to set.
*/
set zoomScale(value) {
// Do nothing if canvas is not zoomable
if (!this._options.zoomable) {
return;
}
this._zoomScale = value;
this.applyZoom();
}
/**
* Gets the current pan offset of the canvas.
*
* @returns The current pan offset as a `Point` object.
*/
get panOffset() {
return this._panOffset;
}
/**
* Enables or disables features like panning, zooming, and dragging shapes.
* Automatically adds or removes event listeners as needed.
*
* @param value - `true` to enable, `false` to disable interactivity.
*/
set interactive(value) {
// Only add/remove event listener and set value if it was changed
if (this._options.interactive === value) {
return;
}
// Set new value for interactive option
this._options.interactive = value;
// Add event listener if canvas is set to interactive
if (value) {
this.addEventListener();
}
// Remove event listener if canvas is not interactive anymore
if (!value) {
this.removeEventListener();
}
}
/**
* Sets the pan offset of the canvas and triggers a redraw.
*
* @param value - The new pan offset to set.
*/
set panOffset(value) {
// Do nothing if canvas is not pannable
if (!this._options.pannable) {
return;
}
this._panOffset = value;
this.redraw();
}
/**
* Sets a specified state for all shapes being watched.
*
* @param state - The state to set for all watched shapes.
*/
setStateForAllShapes(state) {
this.watchedShapes.forEach(shape => {
shape.state = state;
});
}
/**
* Resets the canvas cursor style to the default cursor.
*/
resetCursor() {
this._canvas.style.cursor = this._style.cursor.default;
}
}
exports.Canvas = Canvas;
/** Stores instances of `Canvas` associated with HTML canvas elements. */
Canvas.instances = new WeakMap();