diff --git a/src/atoms/forms/canvas-sketch/canvas-sketch.ts b/src/atoms/forms/canvas-sketch/canvas-sketch.ts new file mode 100644 index 0000000..48e31cd --- /dev/null +++ b/src/atoms/forms/canvas-sketch/canvas-sketch.ts @@ -0,0 +1,507 @@ +import { ImageSettings, ImageCanvasTool } from "./tools/image-canvas-tool"; +import { CanvasToolSettings, CanvasTool, ToolConfig } from "./tools/base-canvas-tool"; +import { LineCanvasDrawTool } from "./tools/line-canvas-draw-tool"; +import { PencilCanvasDrawTool } from "./tools/pencil-canvas-draw-tool"; +import { PanCanvasTool } from "./tools/pan-canvas-tool"; +import { DrawToolUiSettings, DrawToolConfig, CanvasDrawToolSettings, CanvasDrawTool } from "./tools/base-canvas-draw-tool"; +import { CanvasToolType } from "./enums/canvas-tool-type"; +import { CanvasObjectType } from "./enums/canvas-object-type"; +import { CoreUtils } from "../../../utilities/core-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface CanvasSketchConfig { + backgroundImage?: ImageSettings; + backgroundImageCanvas?: HTMLCanvasElement; + currentObjectIndex: number; + panX: number; + panY: number; + scaleFactor: number; + sketchCanvas: HTMLCanvasElement; + objectStack: CanvasToolSettings[]; +} + +// #endregion Interfaces + +/** + * Binds to the provided HTML canvases element in the contructor and provides access to switch + * the selected tool and selected tool options like color and width through it's API + */ +class CanvasSketch { + + public backgroundImageTool!: ImageCanvasTool; + public imageTool!: ImageCanvasTool; + public lineDrawTool!: LineCanvasDrawTool; + public panTool!: PanCanvasTool; + public pencilDrawTool!: PencilCanvasDrawTool; + public selectedTool!: CanvasTool; + + private _config: CanvasSketchConfig; + private _backgroundImageContext!: CanvasRenderingContext2D; + private _drawToolUiSettings: DrawToolUiSettings; + private _drawToolConfig!: DrawToolConfig; + private _isBrowserSupported!: boolean; + private _sketchContext!: CanvasRenderingContext2D; + private _toolConfig!: ToolConfig; + + /** + * Tracks + * + * @constructor + * @param {CanvasSketchConfig} canvasSketchConfig - configuration of object + */ + constructor(canvasSketchConfig: CanvasSketchConfig) { + this._drawToolUiSettings = { + color: "FFFFFF", // default + width: 1, // default + }; + + this._config = { ...canvasSketchConfig }; + + // initialize local canvas contexts references + this._initializeCanvasContexts(); + + if (!this._isBrowserSupported) { + // browser doesn't support the canvas... bail + return; + } + + // order here matters... handlers passed into tools will be bound to those tools + CoreUtils.bindAll(this); + + // initialize all tools used by the sketchpad + this._initializeTools(); + + // initialize current drawing + this._initializeCurrentDrawing(); + } + + // --------------------------------------------------------------------------------------------- + // #region Public Methods + // --------------------------------------------------------------------------------------------- + + /** + * Disposes of any active bound events through the selected tool and anything else that this + * object may need to clean up + */ + public dispose(): void { + if (!this._isBrowserSupported) { + return; + } + + this.selectedTool.dispose(); + } + + /** + * Gets the appropriate canva tool based on the provided tool type + * + * @param {CanvasToolType} toolType - The tool type of the tool caller is looking to use + */ + public getTool(toolType: CanvasToolType): CanvasTool | null { + if (!this._isBrowserSupported) { + return null; + } + + switch (toolType) { + case this.pencilDrawTool.toolType: + return this.pencilDrawTool; + case this.lineDrawTool.toolType: + return this.lineDrawTool; + case this.imageTool.toolType: + return this.imageTool; + case this.panTool.toolType: + return this.panTool; + default: + return null; + } + } + + /** + * Redraws the background image based on the URL provided + * + * @param {string} backgroundImageUrl - The URL of the background image to draw + */ + public redrawBackgroundImageUsing(backgroundImageUrl: string): void { + if (this._config.backgroundImage == null) { + return; + } + this._config.backgroundImage.src = backgroundImageUrl; + this._redrawBackgroundImage(); + } + + /** + * Forces a redraw of the current state of the canvases. This is useful in cases where the + * caller needs to redraw on demand + */ + public redrawCurrentState(): void { + this._redrawBackgroundImage(); + this.redrawSketch(); + } + + /** + * Clears the sketch canvas and redraws all current stack objects to it + */ + public redrawSketch(): void { + if (!this._isBrowserSupported) { + return; + } + + this._clearSketchCanvas(); + + const stackToDraw = this._getStackToDraw(); + + this._drawObjects(stackToDraw); + } + + /** + * Allows the caller to redraw the sketchpad at any point within the provided stack and + * point in time within that stack + * + * @param {CanvasToolSettings[]} objects - The entire stack of drawn objects + * @param {number} newCurrentObjectIndex - The point in time / point in stack to draw + */ + public redrawSketchAt(objects: CanvasToolSettings[], newCurrentObjectIndex: number): void { + if (!this._isBrowserSupported) { + return; + } + + if (objects == null) { + console.error(`Please provide a new stroke stack`); + return; + } + if (!Array.isArray(objects)) { + console.error(`Please provide a valid array`); + return; + } + if (newCurrentObjectIndex + 1 > objects.length) { + console.error(`Drawing history only contains ${objects.length} objects. Cannot set object index to ${newCurrentObjectIndex}`); + return; + } + if (newCurrentObjectIndex < -1) { + console.error(`Cannot set new object index below -1. -1 should be used when nothing is to be drawn`); + return; + } + + this._config.objectStack = objects; + this._config.currentObjectIndex = newCurrentObjectIndex; + + this.redrawSketch(); + } + + /** + * Allows the caller to select one of the availalbe tool type + * + * @param {CanvasToolType} toolType - The tool to be selected + */ + public setSelectedTool(toolType: CanvasToolType): void { + if (!this._isBrowserSupported) { + return; + } + + if (this.selectedTool != null) { + this.selectedTool.dispose(); + } + + const selectedTool = this.getTool(toolType); + if (selectedTool == null) { + return; + } + this.selectedTool = selectedTool; + if (this.selectedTool != null) { + this.selectedTool.initialize(); + } + } + + /** + * Allows the caller to define the color being used by the tool + * + * @param {string} color - The color to be selected (NOTE: must be in hex format with hash) + * @example + * canvasSketch.setToolColor('#5eeb34'); + */ + public setToolColor(color: string): void { + if (!this._isBrowserSupported) { + return; + } + + this._drawToolUiSettings.color = color; + } + + /** + * Allows the caller to define what happens when a tool successfully adds a stroke to the + * drawn stack of objects + * + * @param {(strokeSettings: CanvasDrawToolSettings) => void} onAddedStroke - The function that + * is called when a new drawn object is added to the stack. Returns the settings of that drawn + * object including things like color, width, and the type of tool used + */ + public setOnAddedToolStroke(onAddedStroke: (strokeSettings: CanvasDrawToolSettings) => void): void { + if (!this._isBrowserSupported) { + return; + } + + this._drawToolConfig.onFinishStroke = (strokeSettings: CanvasDrawToolSettings) => { + + // make sure to track stroke in stack + this._onAddStroke(strokeSettings); + + if (typeof (onAddedStroke) === "function") { + // call + onAddedStroke(strokeSettings); + } + }; + } + + /** + * Allows the caller to define the width being used by the tool + * + * @param {number} width - The width to be seelcted + * @example + * canvasSketch.setToolWidth(10); + */ + public setToolWidth(width: number): void { + if (!this._isBrowserSupported) { + return; + } + + this._drawToolUiSettings.width = width; + } + + // #endregion Public Methods + + + // --------------------------------------------------------------------------------------------- + // #region Private Methods + // --------------------------------------------------------------------------------------------- + + /** + * Removes the background image from the background image canvas so that it is no longer visible + */ + private _clearBackgroundImageCanvas(): void { + if (!this._isBrowserSupported) { + return; + } + + if (this._config.backgroundImageCanvas == null) { + return; + } + + this._clearCanvas(this._config.backgroundImageCanvas, this._backgroundImageContext); + } + + /** + * Removes all drawing on the provided canvas and context + * + * @param {HTMLCanvasElement} canvas - The canvas that will have its drawing removed + * @param {CanvasRenderingContext2D} context - The context of the canvas to remove the drawing + */ + private _clearCanvas(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D): void { + context.clearRect(0, 0, canvas.width, canvas.height); + } + + /** + * Removes all drawing from the sketch canvas so that it is no longer visible + */ + private _clearSketchCanvas(): void { + if (!this._isBrowserSupported) { + return; + } + + this._clearCanvas(this._config.sketchCanvas, this._sketchContext); + } + + /** + * Draws the provided objects onto the sketch canvas + * + * @param {CanvasToolSettings[]} objectStack - The provided objects to be drawn + */ + private _drawObjects(objectStack: CanvasToolSettings[]): void { + if (!this._isBrowserSupported) { + return; + } + + // Draw the pencil strokes + const pencilStrokes = objectStack.filter((value: CanvasToolSettings) => value.type === CanvasObjectType.path) as CanvasDrawToolSettings[]; + this.pencilDrawTool.drawStrokes(pencilStrokes); + + // Draw the line strokes + const lineStrokes = objectStack.filter((value: CanvasToolSettings) => value.type === CanvasObjectType.line) as CanvasDrawToolSettings[]; + this.lineDrawTool.drawStrokes(lineStrokes); + + // Draw the images + const images = objectStack.filter((value: CanvasToolSettings) => value.type === CanvasObjectType.image) as any as ImageSettings[]; + this.imageTool.drawImages(images); + } + + /** + * Handler bound to tools drawing so capture the drawn stroke int he canvas sketch stack + * + * @param {CanvasDrawToolSettings} strokeSettings - The settings of the drawn stroke that was just added + */ + private _onAddStroke(strokeSettings: CanvasDrawToolSettings): void { + // track the changes in it's own stack.. making sure to consider the current object index (history in view currently) + this._config.objectStack = this._getStackToDraw(); + this._config.objectStack.push(strokeSettings); + + this._config.currentObjectIndex = this._config.currentObjectIndex + 1; + } + + /** + * Returns an immutable stack of drawn strokes to draw at the point / point in time based on the current object index + */ + private _getStackToDraw(): CanvasToolSettings[] { + const stackToDraw = [...this._config.objectStack].slice(0, this._config.currentObjectIndex + 1); + return stackToDraw; + } + + /** + * Determines if the provided canvas tool is a drawable tool + * + * @param {CanvasTool} tool - The tool to evaluate + */ + private _isInstanceOfDrawTool(tool: CanvasTool): tool is CanvasDrawTool { + return "color" in tool + && "width" in tool + && "drawStrokes" in tool; + } + + /** + * Uses the current canvas instances to establish the sketch and background image contexts and + * while determining browser compatability with canvas at the same time + */ + private _initializeCanvasContexts(): void { + if (this._config.sketchCanvas.getContext != null) { + // browser supports the canvas tag, get the 2d drawing context for this canvas + const sketchContext = this._config.sketchCanvas.getContext("2d"); + if (sketchContext == null) { + return; + } + this._sketchContext = sketchContext; + this._isBrowserSupported = true; + } + else { + // browser does not support the canvas tag, bail + this._isBrowserSupported = false; + return; + } + + if (this._config.backgroundImageCanvas != null && this._config.backgroundImageCanvas.getContext != null) { + // browser supports the canvas tag, get the 2d drawing context for this canvas + const backgroundImageContext = this._config.backgroundImageCanvas.getContext("2d"); + if (backgroundImageContext == null) { + return; + } + this._backgroundImageContext = backgroundImageContext; + this._isBrowserSupported = true; + } + else { + // browser does not support the canvas tag, bail + this._isBrowserSupported = false; + return; + } + } + + /** + * Uses the current object stack (if provided) to redraw the strokes onto the sketch canvas + */ + private _initializeCurrentDrawing(): void { + if (this._config.objectStack?.length > 0) { + // default the current stroke based on the initialized stroke stack's last object + const lastObjectIndex = this._config.objectStack.length - 1; + + if (this._config.currentObjectIndex != null && lastObjectIndex !== this._config.currentObjectIndex) { + // caller wants the index to be something different... overwrite with caller's demand + this._config.currentObjectIndex = lastObjectIndex; + } + + const stackToDraw = this._getStackToDraw(); + + this._drawObjects(stackToDraw); + } + } + + /** + * Uses the current sketch canvas and context to set up all available tools to the caller + */ + private _initializeTools(): void { + // setup default config settings for all tools + this._toolConfig = { + canvas: this._config.sketchCanvas, + context: this._sketchContext, + redraw: this.redrawSketch, + }; + this._drawToolConfig = Object.assign({ + onFinishStroke: (strokeSettings: CanvasDrawToolSettings) => { }, + uiSettings: this._drawToolUiSettings, + }, this._toolConfig); + + this.imageTool = new ImageCanvasTool(Object.assign({}, this._toolConfig, { redraw: this.redrawSketch })); + this.lineDrawTool = new LineCanvasDrawTool(this._drawToolConfig); + this.pencilDrawTool = new PencilCanvasDrawTool(this._drawToolConfig); + this.panTool = new PanCanvasTool(Object.assign({}, this._toolConfig, { panTo: this._panTo })); + + if (this._config.backgroundImage?.src != null && this._config.backgroundImageCanvas != null) { + const toolConfig: ToolConfig = { + canvas: this._config.backgroundImageCanvas, + context: this._backgroundImageContext, + redraw: this._redrawBackgroundImage, + }; + this.backgroundImageTool = new ImageCanvasTool(toolConfig); + + // default the dimension of the destination rectangle for the background image to fit center + const defaultBackgroundImageSettings: ImageSettings = { + destRecEndX: this._config.backgroundImageCanvas.width, + destRecEndY: this._config.backgroundImageCanvas.height, + destRecStartX: 0, + destRecStartY: 0, + src: '', + }; + Object.assign(defaultBackgroundImageSettings, this._config.backgroundImage); + + // draw the background image + this.backgroundImageTool.drawImage(defaultBackgroundImageSettings); + } + } + + /** + * Positions the canvases (background image and sketch) to the provided coordinate values + * + * @param {number} panXDelta - The left pixel number + * @param {number} panYDelta - The top pixel number + */ + private _panTo(panXDelta: number, panYDelta: number): void { + const newPanX = this._config.panX + panXDelta; + const newPanY = this._config.panY + panYDelta; + this._config.panX = newPanX; + this._config.panY = newPanY; + + this._config.sketchCanvas.style.top = newPanY + "px"; + this._config.sketchCanvas.style.left = newPanX + "px"; + + if (this._config.backgroundImageCanvas != null) { + this._config.backgroundImageCanvas.style.top = newPanY + "px"; + this._config.backgroundImageCanvas.style.left = newPanX + "px"; + } + } + + /** + * Clears the background image canvas and redraws the background image to it + */ + private _redrawBackgroundImage(): void { + if (!this._isBrowserSupported) { + return; + } + + this._clearBackgroundImageCanvas(); + + if (this.backgroundImageTool != null && this._config.backgroundImage != null) { + this.backgroundImageTool.drawImage(this._config.backgroundImage); + } + } + + // #endregion Public Methods +} + +export { CanvasSketch }; diff --git a/src/atoms/forms/canvas-sketch/enums/canvas-object-type.ts b/src/atoms/forms/canvas-sketch/enums/canvas-object-type.ts new file mode 100644 index 0000000..3a969ee --- /dev/null +++ b/src/atoms/forms/canvas-sketch/enums/canvas-object-type.ts @@ -0,0 +1,5 @@ +export enum CanvasObjectType { + image = "image", + line = "line", + path = "path", +} diff --git a/src/atoms/forms/canvas-sketch/enums/canvas-tool-type.ts b/src/atoms/forms/canvas-sketch/enums/canvas-tool-type.ts new file mode 100644 index 0000000..bcbf8c5 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/enums/canvas-tool-type.ts @@ -0,0 +1,6 @@ +export enum CanvasToolType { + image = "Image-Draw-Tool", + line = "Line-Canvas-Draw-Tool", + pan = "Pan-Canvas-Object", + pencil = "Pencil-Canvas-Draw-Tool", +}; diff --git a/src/atoms/forms/canvas-sketch/interfaces/pointer-position.ts b/src/atoms/forms/canvas-sketch/interfaces/pointer-position.ts new file mode 100644 index 0000000..d6a20df --- /dev/null +++ b/src/atoms/forms/canvas-sketch/interfaces/pointer-position.ts @@ -0,0 +1,4 @@ +export interface PointerPosition { + x: number; + y: number; +}; diff --git a/src/atoms/forms/canvas-sketch/react-canvas-sketch.stories.tsx b/src/atoms/forms/canvas-sketch/react-canvas-sketch.stories.tsx new file mode 100644 index 0000000..dd7184a --- /dev/null +++ b/src/atoms/forms/canvas-sketch/react-canvas-sketch.stories.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { text, number, select, boolean } from "@storybook/addon-knobs"; +import { ReactCanvasSketch, ReactCanvasSketchValue } from "./react-canvas-sketch"; +import { CanvasDrawToolSettings } from "./tools/base-canvas-draw-tool"; +import { CanvasToolType } from "./enums/canvas-tool-type"; + +export default { + component: ReactCanvasSketch, + title: "Atoms | Forms / Canvas Sketch", +}; + +const value: ReactCanvasSketchValue = { + currentObjectIndex: -1, + objects: [] +} + +const onAddedStroke: (strokeSettings: CanvasDrawToolSettings) => void = + (strokeSettings: CanvasDrawToolSettings) => { console.log(`STORYBOOK MESSAGE - onAddedStroke: ${JSON.stringify(strokeSettings)}`) }; + +const canvasToolTypes = [ + CanvasToolType.line, + CanvasToolType.pan, + CanvasToolType.pencil, +]; + +export const reactCanvasSketch = () => ( + +); diff --git a/src/atoms/forms/canvas-sketch/react-canvas-sketch.tsx b/src/atoms/forms/canvas-sketch/react-canvas-sketch.tsx new file mode 100644 index 0000000..2cb4b72 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/react-canvas-sketch.tsx @@ -0,0 +1,300 @@ +import { + CanvasSketch, + CanvasSketchConfig +} from "./canvas-sketch"; +import * as React from "react"; +import { CanvasToolType } from "./enums/canvas-tool-type"; +import { CanvasDrawToolSettings } from "./tools/base-canvas-draw-tool"; +import { useEffect } from "react"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +/** + * Represents the objects containing all data necessary to redraw the sketch canvas at a point in + * the history of the stack of drawing objects + */ +export interface ReactCanvasSketchValue { + /** + * The current point/index in the history stack of drawing objects + */ + currentObjectIndex: number; + /** + * The drawn objects stack + */ + objects: CanvasDrawToolSettings[]; +} + +/** + * Represents all properties supported by the component + */ +export interface ReactCanvasSketchProps { + /** + * The URL of the background image to be drawn to the sketchpad + */ + backgroundImageUrl: string; + /** + * The height of the canvas element + */ + canvasHeight: number; + /** + * The width of the canvas element + */ + canvasWidth: number; + /** + * The className that will be appended to the sketchpad's outer most container + */ + className: string; + /** + * The height of the container wrapping the sketchpad + * NOTE: This can be smaller than the canvasHeight value since the sketchpad supports panning + */ + containerHeight: number; + /** + * The width of the container wrapping the sketchpad + * NOTE: This can be smaller than the canvasWidth value since the sketchpad supports panning + */ + containerWidth: number; + /** + * Handler to allow caller to track added stroke settings whenever a tool draws a new object + * to the drawn objects stack + */ + onAddedStroke: (strokeSettings: CanvasDrawToolSettings) => void; + /** + * Allows the caller to redraw the sketchpad by changing the numeric value provided + */ + redrawIncrement: number; + /** + * The tool to be selected by the canvas sketch library + */ + canvasToolType: CanvasToolType; + /** + * When true, displays a dashed border around the canvas to identify drawable area. When false, + * hides the dashed border around the canvas. + */ + showCanvasBorder: boolean; + /** + * The width of the selected tool for use by the selected tool + */ + toolWidth: number; + /** + * The color of the selected tool for use by the selected tool + */ + toolColor: string; + /** + * The object literal containing all information necessary to redraw the objects containing all + * data necessary to redraw the sketch canvas at a point in the history of the stack of drawing + * objects + */ + value: ReactCanvasSketchValue; +} + +// #endregion Interfaces + + +// ------------------------------------------------------------------------------------------------- +// #region Component +// ------------------------------------------------------------------------------------------------- + +const ReactCanvasSketch: React.FunctionComponent = ( + props: React.PropsWithChildren +) => { + + // track the ref of the canvas elements + const htmlCanvasBackgroundImage = React.createRef(); + const htmlCanvasSketch = React.createRef(); + + // initialize state + const canvasSketchDefaultInstance: CanvasSketch = null as any; + const [isInitialized, setIsInitialized] = React.useState(false); + const [canvasSketch, setCanvasSketch] = React.useState(canvasSketchDefaultInstance); + + // --------------------------------------------------------------------------------------------- + // #region Effect Hooks + // --------------------------------------------------------------------------------------------- + + // initialization of canvas sketch - NOTE: Must be before other effects using canvasSketch! + useEffect(() => { + if (isInitialized) { + // already initialized, bail + return; + } + const { + backgroundImageUrl, + value, + } = { ...props }; + + // set up the default options for the background image + const canvasSketchConfig: CanvasSketchConfig = { + backgroundImage: { src: backgroundImageUrl }, + backgroundImageCanvas: htmlCanvasBackgroundImage.current as HTMLCanvasElement, + currentObjectIndex: value.currentObjectIndex, + objectStack: value.objects, + panX: 0, + panY: 0, + scaleFactor: 1, + sketchCanvas: htmlCanvasSketch.current as HTMLCanvasElement, + }; + + // initialize the canvas sketch object + const newCanvasSketch = new CanvasSketch(canvasSketchConfig); + + // set state + setCanvasSketch(newCanvasSketch); + setIsInitialized(true); + }, [ + canvasSketch, + htmlCanvasBackgroundImage, + htmlCanvasSketch, + isInitialized, + props + ]); + + // cleanup of canvas sketch when unmounting component + useEffect(() => { + return () => { + // cleanup on unmount + if (canvasSketch != null) { + canvasSketch.dispose(); + } + } + }, [canvasSketch]); + + // redraws current state when dimensions of container or canvas change + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.redrawCurrentState(); + }, [ + canvasSketch, + props.canvasHeight, + props.canvasWidth, + props.containerHeight, + props.containerWidth, + ]); + + // redraw the sketch canvas when current object index or objects changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.redrawSketchAt(props.value.objects, props.value.currentObjectIndex); + }, [ + canvasSketch, + props.value.currentObjectIndex, + props.value.objects, + ]); + + // redraw the sketch canvas when redraw increment changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.redrawSketch(); + }, [ + canvasSketch, + props.redrawIncrement, + ]); + + // redraw the background image when it changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.redrawBackgroundImageUsing(props.backgroundImageUrl); + }, [ + props.backgroundImageUrl, + canvasSketch + ]); + + // set on added stroke when changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.setOnAddedToolStroke(props.onAddedStroke); + }, [ + props.onAddedStroke, + canvasSketch + ]); + + // set tool color when it changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.setToolColor(props.toolColor); + }, [ + props.toolColor, + canvasSketch + ]); + + // set tool width when it changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.setToolWidth(props.toolWidth); + }, [ + props.toolWidth, + canvasSketch + ]); + + // set selected tool when it changes + useEffect(() => { + if (canvasSketch == null) { + return; + } + canvasSketch.setSelectedTool(props.canvasToolType); + }, [ + props.canvasToolType, + canvasSketch + ]); + + // #endregion Effect Hooks + + + // configure styles for elemtns + const canvasContainerStyles: React.CSSProperties = { + height: props.canvasHeight, + position: "relative", + width: props.canvasWidth, + }; + const sketchStyles: React.CSSProperties = { + height: props.containerHeight, + width: props.containerWidth, + }; + const canvasStyles: React.CSSProperties = { + position: "absolute", + }; + if (props.showCanvasBorder) { + canvasStyles.border = "1px dashed black"; + } + + return ( +
+
+ + + Sorry, Canvas HTML5 element is not supported by your browser :( + +
+
+ ); +} + +export { ReactCanvasSketch }; diff --git a/src/atoms/forms/canvas-sketch/tools/base-canvas-draw-tool.ts b/src/atoms/forms/canvas-sketch/tools/base-canvas-draw-tool.ts new file mode 100644 index 0000000..e6a2f9d --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/base-canvas-draw-tool.ts @@ -0,0 +1,75 @@ +import { CanvasToolSettings, ToolConfig } from "./base-canvas-tool"; +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { PointerPosition } from "../interfaces/pointer-position"; +import { CoreUtils } from "../../../../utilities/core-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface CanvasDrawTool { + dispose: () => void; + drawStrokes: (strokes: CanvasDrawToolSettings[]) => void; + initialize: () => void; + toolType: CanvasToolType; +} + +export interface CanvasDrawToolSettings extends CanvasToolSettings { + stroke: string; // color + strokeWidth: number; +} + +export interface DrawToolUiSettings { + color: string; + width: number; +} + +export interface DrawToolConfig extends ToolConfig { + onFinishStroke: (strokeSettings: CanvasDrawToolSettings) => void; + uiSettings: DrawToolUiSettings; +} + +// #endregion Interfaces + +class BaseCanvasDrawTool { + protected _canvas: HTMLCanvasElement; + protected _color: string; + protected _config: DrawToolConfig; + protected _context: CanvasRenderingContext2D; + protected _currentPosition: PointerPosition; + protected _isPointerActive: boolean; + protected _previousPosition: PointerPosition; + protected _uiSettings: DrawToolUiSettings; + protected _width: number; + + constructor(config: DrawToolConfig) { + this._uiSettings = config.uiSettings; + + this._canvas = config.canvas; + this._color = "FFFFFF"; // default + this._config = config; + this._context = config.context; + this._currentPosition = { x: 0, y: 0 }; // default + this._previousPosition = { x: 0, y: 0 }; // default + this._width = 1; // default + + this._isPointerActive = false; + + CoreUtils.bindAll(this); + } + + // --------------------------------------------------------------------------------------------- + // #region Protected Methods + // --------------------------------------------------------------------------------------------- + + protected _onFinishStroke(strokeSettings: CanvasDrawToolSettings): void { + if (this._config.onFinishStroke) { + this._config.onFinishStroke(strokeSettings); + } + } + + // #endregion Public Methods + +} + +export { BaseCanvasDrawTool }; diff --git a/src/atoms/forms/canvas-sketch/tools/base-canvas-tool.ts b/src/atoms/forms/canvas-sketch/tools/base-canvas-tool.ts new file mode 100644 index 0000000..c3b8353 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/base-canvas-tool.ts @@ -0,0 +1,41 @@ +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { CanvasObjectType } from "../enums/canvas-object-type"; +import { CoreUtils } from "../../../../utilities/core-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface CanvasTool { + dispose: () => void; + initialize: () => void; + toolType: CanvasToolType; +} + +export interface CanvasToolSettings { + type: CanvasObjectType; +} + +export interface ToolConfig { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + redraw: () => void; +} + +// #endregion Interfaces + +class BaseCanvasTool { + protected _canvas: HTMLCanvasElement; + protected _config: ToolConfig; + protected _context: CanvasRenderingContext2D; + + constructor(toolConfig: ToolConfig) { + this._canvas = toolConfig.canvas; + this._config = toolConfig; + this._context = toolConfig.context; + + CoreUtils.bindAll(this); + } +} + +export { BaseCanvasTool }; diff --git a/src/atoms/forms/canvas-sketch/tools/image-canvas-tool.ts b/src/atoms/forms/canvas-sketch/tools/image-canvas-tool.ts new file mode 100644 index 0000000..959eb37 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/image-canvas-tool.ts @@ -0,0 +1,170 @@ +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { CoreUtils } from "../../../../utilities/core-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface ImageSettings { + destRecEndX?: number; + destRecEndY?: number; + destRecStartX?: number; + destRecStartY?: number; + src: string; // URL +} + +export interface ImageConfig { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; +} + +// #endregion Interfaces + +class ImageCanvasTool { + public toolType: CanvasToolType; + + private _config: ImageConfig; + private _shouldCenterInDestRectangle: boolean; + private _shouldFitInCanvas: boolean; + private _shouldScaleToFitDestRectangle: boolean; + + + constructor(config: ImageConfig) { + this._config = config; + this._shouldCenterInDestRectangle = false; + this._shouldFitInCanvas = false; + this._shouldScaleToFitDestRectangle = false; + + this.toolType = CanvasToolType.image; + + CoreUtils.bindAll(this); + } + + // --------------------------------------------------------------------------------------------- + // #region Public Methods + // --------------------------------------------------------------------------------------------- + + public dispose(): void { + } + + public drawImages(images: ImageSettings[]): void { + (images as ImageSettings[]).forEach((image: ImageSettings, imageI: number) => { + this.drawImage(image); + }); + } + + public drawImage(image: ImageSettings): void { + this._drawImage( + image.destRecEndX, + image.destRecEndY, + image.destRecStartX, + image.destRecStartY, + image.src); + } + + public initialize(): void { } + + public setShouldCenterInDestRectangle(shouldCenterInDestRectangle: boolean): void { + this._shouldCenterInDestRectangle = shouldCenterInDestRectangle; + } + + public setShouldFitInCanvas(shouldFitInCanvas: boolean): void { + this._shouldFitInCanvas = shouldFitInCanvas; + } + + public setShouldScaleToFitDestRectangle(shouldScaleToFitDestRectangle: boolean): void { + this._shouldScaleToFitDestRectangle = shouldScaleToFitDestRectangle; + } + + // #endregion Public Methods + + // --------------------------------------------------------------------------------------------- + // #region Private Methods + // --------------------------------------------------------------------------------------------- + + /** + * Draws the image url to the canvas + * + * @param url + * @param destRecStartX + * @param destRecStartY + * @param destRecEndX + * @param destRecEndY + */ + private _drawImage( + destRecEndX?: number, + destRecEndY?: number, + destRecStartX?: number, + destRecStartY?: number, + url?: string, + ): void { + + const image = new Image(); + image.onload = () => { + + if (destRecStartX == null) { + destRecStartX = 0; + } + if (destRecStartY == null) { + destRecStartY = 0; + } + if (destRecEndX == null) { + destRecEndX = image.width; + } + if (destRecEndY == null) { + destRecEndY = image.height; + } + + // get destination rectangle dimensions and aspect ratio + const destRectWidth = destRecEndX - destRecStartX; + const destRectHeight = destRecEndY - destRecStartY; + + let newDestRecStartX: number = destRecStartX; + let newDestRecStartY: number = destRecStartY; + + let newImageWidth = image.width; + let newImageHeight = image.height; + + if (this._shouldFitInCanvas && + (image.width > this._config.canvas.width || image.height > this._config.canvas.height)) { + // scale down the image dimensions to fit inside the canvas + const canvasAspectRatio = this._config.canvas.width / this._config.canvas.height; + newImageWidth = image.width / canvasAspectRatio; + newImageHeight = image.height / canvasAspectRatio; + } + + if (this._shouldScaleToFitDestRectangle && + (image.width > destRectWidth || image.height > destRectHeight)) { + // scale down the image dimension to fit the destination rectangle canvas space + const destRectAspectRatio = destRectWidth / destRectHeight; + newImageWidth = image.width / destRectAspectRatio; + newImageHeight = image.height / destRectAspectRatio; + } + + if (this._shouldCenterInDestRectangle) { + // define the new rect space in order to center the image in the canvas + newDestRecStartX = destRecStartX + ((destRectWidth - newImageWidth) / 2); + newDestRecStartY = destRecStartY + ((destRectHeight - newImageHeight) / 2); + } + + this._config.context.drawImage(image, + 0, // start of image clipping X + 0, // start of image clipping Y + image.width, // finish of image clipping X + image.height, // finish of image clipping Y + newDestRecStartX, // start of destination rectangle X + newDestRecStartY, // start of destination rectangle Y + newImageWidth, // new rectangle width + newImageHeight); // new rectangle height + }; + + if (url != null) { + image.src = url; + } + } + + // #endregion Private Methods + +} + +export { ImageCanvasTool }; diff --git a/src/atoms/forms/canvas-sketch/tools/line-canvas-draw-tool.ts b/src/atoms/forms/canvas-sketch/tools/line-canvas-draw-tool.ts new file mode 100644 index 0000000..4987f6d --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/line-canvas-draw-tool.ts @@ -0,0 +1,259 @@ +import { CanvasDrawToolSettings, CanvasDrawTool, DrawToolConfig, BaseCanvasDrawTool } from "./base-canvas-draw-tool"; +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { CoreUtils } from "../../../../utilities/core-utils"; +import { PointerPosition } from "../interfaces/pointer-position"; +import { CanvasObjectType } from "../enums/canvas-object-type"; +import { PositionUtils } from "../utils/position-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface LineStrokeSettings extends CanvasDrawToolSettings { + endX: number; + endY: number; + startX: number; + startY: number; +} + +// #endregion Interfaces + +class LineCanvasDrawTool extends BaseCanvasDrawTool implements CanvasDrawTool { + public toolType: CanvasToolType; + + constructor(drawToolConfig: DrawToolConfig) { + super(drawToolConfig); + + this.toolType = CanvasToolType.line; + + CoreUtils.bindAll(this); + } + + // --------------------------------------------------------------------------------------------- + // #region Public Methods + // --------------------------------------------------------------------------------------------- + + public dispose(): void { + this._removeEventListeners(); + } + + public drawStrokes(strokes: CanvasDrawToolSettings[]): void { + (strokes as LineStrokeSettings[]).forEach((stroke: LineStrokeSettings) => { + const startX: number = stroke.startX; + const endX: number = stroke.endX + const startY: number = stroke.startY; + const endY: number = stroke.endY; + + const color = stroke.stroke; + const width = stroke.strokeWidth; + + this._drawStroke(startX, startY, endX, endY, color, width); + }); + } + + public initialize(): void { + this._addEventListeners(); + } + + // #endregion Private Methods + + // --------------------------------------------------------------------------------------------- + // #region Private Methods + // --------------------------------------------------------------------------------------------- + + /** + * Binds the necessary mouse and touch events + */ + private _addEventListeners(): void { + this._canvas.addEventListener("mousedown", this._onMouseDownCanvas, false); + this._canvas.addEventListener("mousemove", this._onMouseMoveCanvas, false); + window.addEventListener("mouseup", this._onMouseUpWindow, false); + + this._canvas.addEventListener("touchstart", this._onTouchStartCanvas, false); + this._canvas.addEventListener("touchmove", this._onTouchMoveCanvas, false); + window.addEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Draws the current state of the interaction + */ + private _drawInteraction(): void { + this._drawStroke( + this._previousPosition.x, + this._previousPosition.y, + this._currentPosition.x, + this._currentPosition.y, + this._uiSettings.color, + this._uiSettings.width); + } + + /** + * Draws the interaction based on the provided state + * + * @param startX + * @param startY + * @param endX + * @param endY + * @param color + * @param width + */ + private _drawStroke( + startX: number, + startY: number, + endX: number, + endY: number, + color: string, + width: number): void { + this._context.beginPath(); + + // Draw a line between two points + this._context.strokeStyle = color; + this._context.moveTo(startX, startY); + this._context.lineCap = "round"; + this._context.lineWidth = width; + this._context.lineTo(endX, endY); + + if (endX !== startX || endY !== startY) { + // only draw the line if the mouse actually moved + this._context.stroke(); + } + + this._context.closePath(); + } + + /** + * Finalizes the entire stroke interaction + */ + private _finishStroke(): void { + if (!this._isPointerActive) { + // currently not active... bail + return; + } + + this._isPointerActive = false; + + const strokeSettings = this._getStrokeSettings(); + + this._onFinishStroke(strokeSettings); + } + + /** + * Returns the stroke settings for the entire interaction including the stroke, color, and width + */ + private _getStrokeSettings(): LineStrokeSettings { + // Put together tool stroke here + return { + endX: this._currentPosition.x, + endY: this._currentPosition.y, + startX: this._previousPosition.x, + startY: this._previousPosition.y, + stroke: this._uiSettings.color, + strokeWidth: this._uiSettings.width, + type: CanvasObjectType.line, + }; + } + + /** + * Handles the move interaction while drawing + * + * @param newCurrentPosition + */ + private _move(newCurrentPosition: PointerPosition): void { + if (newCurrentPosition == null) { + // null checking - being defensive + return; + } + + // If the pointer is active... draw! + if (this._isPointerActive) { + // Undo the last interaction by redrawing the canvas strokes + this._config.redraw(); + + this._currentPosition = newCurrentPosition; + this._drawInteraction(); + } + } + + /** + * Removed the bound mouse and touch events + */ + private _removeEventListeners(): void { + this._canvas.removeEventListener("mousedown", this._onMouseDownCanvas, false); + this._canvas.removeEventListener("mousemove", this._onMouseMoveCanvas, false); + window.removeEventListener("mouseup", this._onMouseUpWindow, false); + + this._canvas.removeEventListener("touchstart", this._onTouchStartCanvas, false); + this._canvas.removeEventListener("touchmove", this._onTouchMoveCanvas, false); + window.removeEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Captures the starting position and begins the entire stroke interaction + * + * @param startingPosition + */ + private _startStroke(startingPosition: PointerPosition): void { + // Start the path of the stroke + this._previousPosition = startingPosition; + this._isPointerActive = true; + this._currentPosition = startingPosition; + + // Draw! + this._drawInteraction(); + } + + // #endregion Private Methods + + // --------------------------------------------------------------------------------------------- + // #region Event Handlers + // --------------------------------------------------------------------------------------------- + + private _onMouseDownCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._startStroke(mousePosition); + } + } + + private _onMouseMoveCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._move(mousePosition); + } + } + + private _onMouseUpWindow(): void { + this._finishStroke(); + } + + private _onTouchEndWindow(e: TouchEvent): void { + this._finishStroke(); + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchMoveCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._move(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchStartCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._startStroke(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + // #endregion Event Handlers +} + +export { LineCanvasDrawTool }; diff --git a/src/atoms/forms/canvas-sketch/tools/pan-canvas-tool.ts b/src/atoms/forms/canvas-sketch/tools/pan-canvas-tool.ts new file mode 100644 index 0000000..f7004e9 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/pan-canvas-tool.ts @@ -0,0 +1,178 @@ +import { PointerPosition } from "../interfaces/pointer-position"; +import { CoreUtils } from "../../../../utilities/core-utils"; +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { PositionUtils } from "../utils/position-utils"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +export interface PanConfig { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + panTo: (moveX: number, moveY: number) => void; +} + +// #endregion Interfaces + +class PanCanvasTool { + public toolType: CanvasToolType; + + private _config: PanConfig; + private _isPointerActive: boolean; + private _lastPosition: PointerPosition; + + constructor(panConfig: PanConfig) { + this._config = panConfig; + this._isPointerActive = false; + this._lastPosition = { x: 0, y: 0 }; + this.toolType = CanvasToolType.pan; + + CoreUtils.bindAll(this); + } + + // --------------------------------------------------------------------------------------------- + // #region Public Methods + // --------------------------------------------------------------------------------------------- + + public dispose(): void { + this._removeEventListeners(); + this._removeCursor(); + } + + public initialize(): void { + this._addEventListeners(); + this._addCursor(); + } + + // #endregion Public Methods + + // --------------------------------------------------------------------------------------------- + // #region Private Methods + // --------------------------------------------------------------------------------------------- + + private _addCursor(): void { + this._config.canvas.style.cursor = "move"; + } + + /** + * Binds the necessary mouse and touch events + */ + private _addEventListeners(): void { + this._config.canvas.addEventListener("mousedown", this._onMouseDownCanvas, false); + this._config.canvas.addEventListener("mousemove", this._onMouseMoveCanvas, false); + window.addEventListener("mouseup", this._onMouseUpWindow, false); + + this._config.canvas.addEventListener("touchstart", this._onTouchStartCanvas, false); + this._config.canvas.addEventListener("touchmove", this._onTouchMoveCanvas, false); + window.addEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Completes the panning interaction + */ + private _finishPan(): void { + // Finish the path of the stroke + this._lastPosition = { x: 0, y: 0 }; + this._isPointerActive = false; + } + + /** + * Tracks the point of interaction and moves to it + * + * @param newCurrentPosition + */ + private _pan(newCurrentPosition: PointerPosition): void { + // If the pointer is active... draw! + if (this._isPointerActive) { + + const newPanX = newCurrentPosition.x - this._lastPosition.x; + const newPanY = newCurrentPosition.y - this._lastPosition.y; + + this._config.panTo(newPanX, newPanY); + } + } + + private _removeCursor(): void { + this._config.canvas.style.cursor = "default"; + } + + /** + * Removed the bound mouse and touch events + */ + private _removeEventListeners(): void { + this._config.canvas.removeEventListener("mousedown", this._onMouseDownCanvas, false); + this._config.canvas.removeEventListener("mousemove", this._onMouseMoveCanvas, false); + window.removeEventListener("mouseup", this._onMouseUpWindow, false); + + this._config.canvas.removeEventListener("touchstart", this._onTouchStartCanvas, false); + this._config.canvas.removeEventListener("touchmove", this._onTouchMoveCanvas, false); + window.removeEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Captures the starting position and begins the entire interaction + * + * @param startingPosition + */ + private _startPan(startingPosition: PointerPosition): void { + // Start the path of the stroke + this._lastPosition = startingPosition; + this._isPointerActive = true; + } + + // #endregion Private Methods + + // --------------------------------------------------------------------------------------------- + // #region Event Handlers + // --------------------------------------------------------------------------------------------- + + private _onMouseDownCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._startPan(mousePosition); + } + } + + private _onMouseMoveCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._pan(mousePosition); + } + } + + private _onMouseUpWindow(): void { + this._finishPan(); + } + + private _onTouchEndWindow(e: TouchEvent): void { + this._finishPan(); + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchMoveCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._pan(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchStartCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._startPan(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + // #endregion Event Handlers +} + +export { PanCanvasTool }; diff --git a/src/atoms/forms/canvas-sketch/tools/pencil-canvas-draw-tool.ts b/src/atoms/forms/canvas-sketch/tools/pencil-canvas-draw-tool.ts new file mode 100644 index 0000000..f06ef85 --- /dev/null +++ b/src/atoms/forms/canvas-sketch/tools/pencil-canvas-draw-tool.ts @@ -0,0 +1,313 @@ +import { CanvasDrawToolSettings, BaseCanvasDrawTool, CanvasDrawTool, DrawToolConfig } from "./base-canvas-draw-tool"; +import { CanvasToolType } from "../enums/canvas-tool-type"; +import { PointerPosition } from "../interfaces/pointer-position"; +import { CoreUtils } from "../../../../utilities/core-utils"; +import { PositionUtils } from "../utils/position-utils"; +import { CanvasObjectType } from "../enums/canvas-object-type"; + +// ------------------------------------------------------------------------------------------------- +// #region Interfaces +// ------------------------------------------------------------------------------------------------- + +enum PathType { + Finishing = "F", + Moving = "M", + Starting = "S", +} + +interface PencilStrokeSettings extends CanvasDrawToolSettings { + path: [PathType, number, number][]; +} + +// #endregion Interfaces + +class PencilCanvasDrawTool extends BaseCanvasDrawTool implements CanvasDrawTool { + public toolType: CanvasToolType; + + + protected _path: PointerPosition[]; + + constructor(drawToolConfig: DrawToolConfig) { + super(drawToolConfig); + + this._path = []; + + this.toolType = CanvasToolType.pencil; + + CoreUtils.bindAll(this); + } + + // --------------------------------------------------------------------------------------------- + // #region Public Methods + // --------------------------------------------------------------------------------------------- + + public dispose(): void { + this._removeEventListeners(); + this._removeCursor(); + } + + public drawStrokes(strokes: CanvasDrawToolSettings[]): void { + (strokes as PencilStrokeSettings[]).forEach((stroke: PencilStrokeSettings, strokeI: number) => { + let lastX: number = 0; + let lastY: number = 0; + stroke.path.forEach((path: [PathType, number, number], pathI: number) => { + const type = path[0]; + const color = stroke.stroke; + const width = stroke.strokeWidth; + if (type === PathType.Starting) { + // started stroke + this._drawStroke(path[1], path[2], path[1], path[2], color, width); + lastX = path[1]; + lastY = path[2]; + } + if (type === PathType.Moving) { + // moving + this._drawStroke(lastX, lastY, path[1], path[2], color, width); + lastX = path[1]; + lastY = path[2]; + } + if (type === PathType.Finishing) { + // ended stroke + this._drawStroke(lastX, lastY, path[1], path[2], color, width); + lastX = path[1]; + lastY = path[2]; + } + }); + }); + } + + public initialize(): void { + this._addEventListeners(); + this._addCursor(); + } + + // #endregion Private Methods + + // --------------------------------------------------------------------------------------------- + // #region Private Methods + // --------------------------------------------------------------------------------------------- + + private _addCursor(): void { + this._canvas.style.cursor = "crosshair"; + } + + /** + * Binds the necessary mouse and touch events + */ + private _addEventListeners(): void { + this._canvas.addEventListener("mousedown", this._onMouseDownCanvas, false); + this._canvas.addEventListener("mousemove", this._onMouseMoveCanvas, false); + window.addEventListener("mouseup", this._onMouseUpWindow, false); + + this._canvas.addEventListener("touchstart", this._onTouchStartCanvas, false); + this._canvas.addEventListener("touchmove", this._onTouchMoveCanvas, false); + window.addEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Draws the current state of the interaction + */ + private _drawInteraction(): void { + this._drawStroke( + this._previousPosition.x, + this._previousPosition.y, + this._currentPosition.x, + this._currentPosition.y, + this._uiSettings.color, + this._uiSettings.width); + } + + /** + * Draws the interaction based on the provided state + * + * @param startX + * @param startY + * @param endX + * @param endY + * @param color + * @param width + */ + private _drawStroke( + startX: number, + startY: number, + endX: number, + endY: number, + color: string, + width: number): void { + this._context.beginPath(); + + // Draw a line between two points + this._context.strokeStyle = color; + this._context.moveTo(startX, startY); + this._context.lineCap = "round"; + this._context.lineWidth = width; + this._context.lineTo(endX, endY); + this._context.stroke(); + + this._context.closePath(); + } + + /** + * Finalizes the entire stroke interaction + */ + private _finishStroke(): void { + if (!this._isPointerActive) { + // currently not active... bail + return; + } + + this._isPointerActive = false; + + const strokeSettings = this._getStrokeSettings(); + + // Clear the current path + this._path = []; + + this._onFinishStroke(strokeSettings); + } + + /** + * Returns the path of the entire stroke that can then be persisted for later use + */ + private _getPath(): [PathType, number, number][] { + const reformattedPath: [PathType, number, number][] = []; + this._path.forEach((value: PointerPosition, index: number) => { + if (index === 0) { + // starting point + reformattedPath.push([PathType.Starting, value.x, value.y]); + } + else if (index + 1 === this._path.length) { + // ending point + reformattedPath.push([PathType.Finishing, value.x, value.y]); + } + else { + // moving point + reformattedPath.push([PathType.Moving, value.x, value.y]); + } + }); + + return reformattedPath; + } + + /** + * Returns the stroke settings for the entire interaction including the stroke, color, and width + */ + private _getStrokeSettings(): PencilStrokeSettings { + // Put together tool stroke here + return { + path: this._getPath(), + stroke: this._uiSettings.color, + strokeWidth: this._uiSettings.width, + type: CanvasObjectType.path, + }; + } + + /** + * Handles the move interaction while drawing + * + * @param newCurrentPosition + */ + private _move(newCurrentPosition: PointerPosition): void { + // If the pointer is active... draw! + if (this._isPointerActive) { + // Update the mouse coordinates when moved + this._previousPosition = this._currentPosition; + this._path.push(newCurrentPosition); + this._currentPosition = newCurrentPosition; + this._drawInteraction(); + } + } + + private _removeCursor(): void { + this._canvas.style.cursor = "default"; + } + + /** + * Removed the bound mouse and touch events + */ + private _removeEventListeners(): void { + this._canvas.removeEventListener("mousedown", this._onMouseDownCanvas, false); + this._canvas.removeEventListener("mousemove", this._onMouseMoveCanvas, false); + window.removeEventListener("mouseup", this._onMouseUpWindow, false); + + this._canvas.removeEventListener("touchstart", this._onTouchStartCanvas, false); + this._canvas.removeEventListener("touchmove", this._onTouchMoveCanvas, false); + window.removeEventListener("touchend", this._onTouchEndWindow, false); + } + + /** + * Captures the starting position and begins the entire stroke interaction + * + * @param startingPosition + */ + private _startStroke(startingPosition: PointerPosition): void { + if (startingPosition == null) { + // null checking - being defensive + return; + } + + // Start the path of the stroke + this._path.push(startingPosition); + this._previousPosition = startingPosition; + this._isPointerActive = true; + this._currentPosition = startingPosition; + + // Draw! + this._drawInteraction(); + } + + // #endregion Private Methods + + // --------------------------------------------------------------------------------------------- + // #region Event Handlers + // --------------------------------------------------------------------------------------------- + + private _onMouseDownCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._startStroke(mousePosition); + } + } + + private _onMouseMoveCanvas(e: MouseEvent): void { + const mousePosition = PositionUtils.getMousePosition(e); + if (mousePosition != null) { + this._move(mousePosition); + } + } + + private _onMouseUpWindow(): void { + this._finishStroke(); + } + + private _onTouchEndWindow(e: TouchEvent): void { + this._finishStroke(); + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchMoveCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._move(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + private _onTouchStartCanvas(e: TouchEvent): void { + const touchPosition = PositionUtils.getTouchPosition(e, this._config.canvas); + if (touchPosition != null) { + this._startStroke(touchPosition); + } + + // Don't allow touch events to be called + e.preventDefault(); + } + + // #endregion Event Handlers +} + +export { PencilCanvasDrawTool }; diff --git a/src/atoms/forms/canvas-sketch/utils/position-utils.ts b/src/atoms/forms/canvas-sketch/utils/position-utils.ts new file mode 100644 index 0000000..700a70c --- /dev/null +++ b/src/atoms/forms/canvas-sketch/utils/position-utils.ts @@ -0,0 +1,56 @@ +import { PointerPosition } from "../interfaces/pointer-position"; + +/** + * Get the current mouse position relative to the top-left of the canvas + * + * @param e The mouse event + */ +const getMousePosition = (e: MouseEvent): PointerPosition | null => { + if (!e) { + // is this necessary? + // e = event as MouseEvent; + } + + if (e.offsetX) { + return { + x: e.offsetX, + y: e.offsetY, + }; + } + else if ((e as any).layerX) { // fallback if mousing outside canvas + return { + x: (e as any).layerX, + y: (e as any).layerY, + }; + } + + return null; +}; + +/** + * Gets the current touch position relative to the top-left of the canvas + * + * @param e The touch event + */ +const getTouchPosition = (e: TouchEvent, canvas: HTMLCanvasElement): PointerPosition | null => { + if (!e) { + // is this necessary? + // e = event as TouchEvent; + } + + if (e.touches) { + + const viewportOffset = canvas.getBoundingClientRect(); + return { + x: (e.touches[0].clientX - viewportOffset.left), + y: (e.touches[0].clientY - viewportOffset.top), + }; + } + + return null; +}; + +export const PositionUtils = { + getMousePosition, + getTouchPosition, +}; diff --git a/src/index.ts b/src/index.ts index f50f99a..53112ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { Image } from "./atoms/images/image"; export { ProgressBar } from "./atoms/progress-bar/progress-bar"; // Forms +export { ReactCanvasSketch } from "./atoms/forms/canvas-sketch/react-canvas-sketch"; export { CheckboxButton } from "./atoms/forms/checkbox-button"; export { CheckboxInput } from "./atoms/forms/checkbox-input"; export { InputCharacterCount } from "./atoms/forms/input-character-count"; diff --git a/src/utilities/core-utils.ts b/src/utilities/core-utils.ts new file mode 100644 index 0000000..e65fa0a --- /dev/null +++ b/src/utilities/core-utils.ts @@ -0,0 +1,33 @@ +// ----------------------------------------------------------------------------------------- +// #region Functions +// ----------------------------------------------------------------------------------------- + +/** + * Automatically binds all of an object's functions to itself + * @param obj Object for which to bind + */ +const _bindAll = (obj: any) => { + for (const key of Object.getOwnPropertyNames(obj.constructor.prototype)) { + const val = obj[key]; + + if (key !== "constructor" && typeof val === "function") { + obj[key] = val.bind(obj); + } + } + + return obj; +}; + +// #endregion Functions + +// ----------------------------------------------------------------------------------------- +// #region Exports +// ----------------------------------------------------------------------------------------- + +const CoreUtils = { + bindAll: _bindAll, +}; + +export { CoreUtils }; + +// #endregion Exports diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..e69de29