UNPKG

@drizm/ng-whiteboard

Version:

A Canvas component for Angular which supports free drawing.

1,194 lines (1,181 loc) 79.2 kB
import { Injectable, EventEmitter, Component, NgZone, ChangeDetectorRef, Input, Output, ViewChild, ElementRef, HostListener, NgModule } from '@angular/core'; import { Subject, BehaviorSubject, fromEvent } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { isEqual, cloneDeep } from 'lodash-es'; import { CommonModule } from '@angular/common'; var CanvasWhiteboardUpdateType; (function (CanvasWhiteboardUpdateType) { CanvasWhiteboardUpdateType[CanvasWhiteboardUpdateType["START"] = 0] = "START"; CanvasWhiteboardUpdateType[CanvasWhiteboardUpdateType["DRAG"] = 1] = "DRAG"; CanvasWhiteboardUpdateType[CanvasWhiteboardUpdateType["STOP"] = 2] = "STOP"; })(CanvasWhiteboardUpdateType || (CanvasWhiteboardUpdateType = {})); class CanvasWhiteboardUpdate { constructor(x, y, type, UUID, selectedShape, selectedShapeOptions) { this.x = x; this.y = y; this.type = type; this.UUID = UUID; this.selectedShape = selectedShape; this.selectedShapeOptions = selectedShapeOptions; } static deserializeJson(json) { let parsedJson; try { parsedJson = JSON.parse(json); return new CanvasWhiteboardUpdate(parsedJson.x, parsedJson.y, parsedJson.type, parsedJson.uuid, parsedJson.selectedShape, parsedJson.selectedShapeOptions); } catch (e) { console.error('The canvas whiteboard update is not parseable'); return null; } } stringify() { var _a, _b; const objectToSerialize = { x: (_a = this.x) === null || _a === void 0 ? void 0 : _a.toFixed(3), y: (_b = this.y) === null || _b === void 0 ? void 0 : _b.toFixed(3), type: this.type, uuid: this.UUID, selectedShape: this.selectedShape, selectedShapeOptions: this.selectedShapeOptions || undefined }; return JSON.stringify(objectToSerialize); } } class CanvasWhiteboardService { constructor() { this._canvasDrawSubject = new Subject(); this.canvasDrawSubject$ = this._canvasDrawSubject.asObservable(); this._canvasClearSubject = new Subject(); this.canvasClearSubject$ = this._canvasClearSubject.asObservable(); this._canvasUndoSubject = new Subject(); this.canvasUndoSubject$ = this._canvasUndoSubject.asObservable(); this._canvasRedoSubject = new Subject(); this.canvasRedoSubject$ = this._canvasRedoSubject.asObservable(); } drawCanvas(updates) { this._canvasDrawSubject.next(updates); } clearCanvas() { this._canvasClearSubject.next(); } undoCanvas(updateUUD) { this._canvasUndoSubject.next(updateUUD); } redoCanvas(updateUUD) { this._canvasRedoSubject.next(updateUUD); } } CanvasWhiteboardService.decorators = [ { type: Injectable } ]; class CanvasWhiteboardPoint { constructor(x, y) { this.x = x; this.y = y; } } class CanvasWhiteboardShapeOptions { constructor() { this.shouldFillShape = false; this.fillStyle = undefined; this.strokeStyle = 'rgba(0, 0, 0, 1)'; this.lineWidth = 2; this.lineJoin = 'round'; this.lineCap = 'round'; } } class CanvasWhiteboardShape { constructor(positionPoint, options) { this.positionPoint = positionPoint || new CanvasWhiteboardPoint(0, 0); this.options = options || new CanvasWhiteboardShapeOptions(); this.isVisible = true; } // noinspection TsLint onStopReceived(update) { } } class CircleShape extends CanvasWhiteboardShape { constructor(positionPoint, options, radius) { super(positionPoint, options); this.radius = radius || 0; } getShapeName() { return 'CircleShape'; } draw(context) { context.beginPath(); context.arc(this.positionPoint.x, this.positionPoint.y, this.radius, 0, Math.PI * 2, false); Object.assign(context, this.options); context.stroke(); if (this.options.shouldFillShape) { context.fill(); } context.closePath(); } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(context.canvas.width / 2, context.canvas.height / 2); this.radius = this.calculateRadius(context.canvas.width - 2, context.canvas.height / 2); this.draw(context); } onUpdateReceived(update) { this.radius = this.calculateRadius(update.x, update.y); } calculateRadius(x, y) { return Math.sqrt(Math.pow(x - this.positionPoint.x, 2) + Math.pow(y - this.positionPoint.y, 2)); } } class RectangleShape extends CanvasWhiteboardShape { constructor(positionPoint, options, width, height) { super(positionPoint, options); this.width = width || 0; this.height = height || 0; } getShapeName() { return 'RectangleShape'; } draw(context) { if (!this.width || !this.height) { return; } context.beginPath(); Object.assign(context, this.options); context.rect(this.positionPoint.x, this.positionPoint.y, this.width, this.height); context.stroke(); if (this.options.shouldFillShape) { context.fill(); } context.closePath(); } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(2, 2); this.width = context.canvas.width - 4; this.height = context.canvas.height - 4; this.draw(context); } onUpdateReceived(update) { this.width = update.x - this.positionPoint.x; this.height = update.y - this.positionPoint.y; } } class FreeHandShape extends CanvasWhiteboardShape { constructor(positionPoint, options) { super(positionPoint, options); this.linePositions = []; } getShapeName() { return 'FreeHandShape'; } draw(context) { Object.assign(context, this.options); context.beginPath(); context.moveTo(this.positionPoint.x, this.positionPoint.y); // Draw a dot context.lineTo(this.positionPoint.x + 1, this.positionPoint.y + 1); // Normal fastest free hand drawing // this.linePositions.forEach((linePosition) => { // context.lineTo(linePosition.x, linePosition.y); // }); // Quadratic curves drawing let i = 0; while (i < this.linePositions.length) { if (this.linePositions.length - i > 2) { const controlPoint1 = this.linePositions[i]; const controlPoint2 = this.linePositions[i + 1]; const endPoint = this.linePositions[i + 2]; context.bezierCurveTo(controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y, endPoint.x, endPoint.y); i += 2; } else { const linePosition = this.linePositions[i]; context.lineTo(linePosition.x, linePosition.y); i += 1; } } context.stroke(); } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(2, 2); this.linePositions = [ new CanvasWhiteboardPoint(context.canvas.width - 5, context.canvas.height * 0.3), // new CanvasWhiteboardPoint(context.canvas.width * 0.4, context.canvas.height * 0.6), new CanvasWhiteboardPoint(context.canvas.width * 0.2, context.canvas.height * 0.4), new CanvasWhiteboardPoint(context.canvas.width * 0.6, context.canvas.height * 0.8), new CanvasWhiteboardPoint(context.canvas.width, context.canvas.height) ]; this.draw(context); } onUpdateReceived(update) { this.linePositions.push(new CanvasWhiteboardPoint(update.x, update.y)); } } class SmileyShape extends CanvasWhiteboardShape { constructor(positionPoint, options, radius) { super(positionPoint, options); this.options.shouldFillShape = true; this.options.fillStyle = this.options.fillStyle || 'yellow'; this.radius = radius || 0; } getShapeName() { return 'SmileyShape'; } draw(context) { context.beginPath(); Object.assign(context, this.options); context.arc(this.positionPoint.x, this.positionPoint.y, this.radius, 0, Math.PI * 2, false); context.fill(); context.stroke(); context.beginPath(); const leftEyeX = this.positionPoint.x - this.radius * 0.3; const rightEyeX = this.positionPoint.x + this.radius * 0.3; const eyesY = this.positionPoint.y - this.radius * 0.2; const eyeSize = this.radius * 0.1; context.arc(leftEyeX, eyesY, eyeSize, 0, 2 * Math.PI, false); context.arc(rightEyeX, eyesY, eyeSize, 0, 2 * Math.PI, false); context.fillStyle = this.options.strokeStyle; context.fill(); // draw the mouth context.beginPath(); context.arc(this.positionPoint.x, this.positionPoint.y, this.radius * 0.7, 0, Math.PI, false); context.stroke(); context.closePath(); } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(context.canvas.width / 2, context.canvas.height / 2); this.radius = this.calculateRadius(context.canvas.width - 2, context.canvas.height / 2); this.draw(context); } onUpdateReceived(update) { this.radius = this.calculateRadius(update.x, update.y); } calculateRadius(x, y) { return Math.sqrt(Math.pow(x - this.positionPoint.x, 2) + Math.pow(y - this.positionPoint.y, 2)); } } class StarShape extends CanvasWhiteboardShape { constructor(positionPoint, options, radius, spikes) { super(positionPoint, options); this.radius = radius || 0; this.spikes = spikes || 5; } getShapeName() { return 'StarShape'; } draw(context) { Object.assign(context, this.options); let rotation = Math.PI / 2 * 3; let spikeX = this.positionPoint.x; let spikeY = this.positionPoint.y; const step = Math.PI / this.spikes; context.beginPath(); context.moveTo(this.positionPoint.x, this.positionPoint.y - this.radius); for (let i = 0; i < this.spikes; i++) { spikeX = this.positionPoint.x + Math.cos(rotation) * this.radius; spikeY = this.positionPoint.y + Math.sin(rotation) * this.radius; context.lineTo(spikeX, spikeY); rotation += step; spikeX = this.positionPoint.x + Math.cos(rotation) * (this.radius * 0.4); spikeY = this.positionPoint.y + Math.sin(rotation) * (this.radius * 0.4); context.lineTo(spikeX, spikeY); rotation += step; context.stroke(); } context.lineTo(this.positionPoint.x, this.positionPoint.y - this.radius); context.closePath(); context.stroke(); if (this.options.shouldFillShape) { context.fill(); } } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(context.canvas.width / 2, context.canvas.height / 2); this.radius = this.calculateRadius(context.canvas.width - 2, context.canvas.height / 2); this.draw(context); } onUpdateReceived(update) { this.radius = this.calculateRadius(update.x, update.y); } calculateRadius(x, y) { return Math.sqrt(Math.pow(x - this.positionPoint.x, 2) + Math.pow(y - this.positionPoint.y, 2)); } } class LineShape extends CanvasWhiteboardShape { constructor(positionPoint, options, endPosition) { super(positionPoint, options); this.endPosition = endPosition || new CanvasWhiteboardPoint(this.positionPoint.x, this.positionPoint.y); } getShapeName() { return 'LineShape'; } draw(context) { if (!this.endPosition) { return; } context.beginPath(); Object.assign(context, this.options); context.moveTo(this.positionPoint.x, this.positionPoint.y); context.lineTo(this.endPosition.x, this.endPosition.y); context.closePath(); context.stroke(); } drawPreview(context) { this.positionPoint = new CanvasWhiteboardPoint(0, 0); this.endPosition = new CanvasWhiteboardPoint(context.canvas.width, context.canvas.height); this.draw(context); } onUpdateReceived(update) { this.endPosition = new CanvasWhiteboardPoint(update.x, update.y); } } class CanvasWhiteboardShapeService { constructor() { this.registeredShapesSubject = new BehaviorSubject([ FreeHandShape, LineShape, RectangleShape, CircleShape, StarShape, SmileyShape ]); this.registeredShapes$ = this.registeredShapesSubject.asObservable(); } getShapeConstructorFromShapeName(shapeName) { return this.getCurrentRegisteredShapes().find((shape) => (new shape()).getShapeName() === shapeName); } getCurrentRegisteredShapes() { return this.registeredShapesSubject.getValue(); } isRegisteredShape(shape) { return this.getCurrentRegisteredShapes().indexOf(shape) !== -1; } registerShape(shape) { if (this.isRegisteredShape(shape)) { console.warn(`You tried to register a shape:${shape}, but is has already been registered.`); return; } const registeredShapes = this.getCurrentRegisteredShapes(); registeredShapes.push(shape); this.registeredShapesSubject.next(registeredShapes); } registerShapes(shapes) { this.registeredShapesSubject.next(this.getCurrentRegisteredShapes() .concat(shapes.filter((shape) => { if (this.isRegisteredShape(shape)) { console.warn(`You tried to register a shape:${shape}, but is has already been registered.`); return false; } return true; }))); } unregisterShape(shape) { this.registeredShapesSubject.next(this.getCurrentRegisteredShapes().filter(registeredShape => registeredShape !== shape)); } unregisterShapes(shapes) { this.registeredShapesSubject.next(this.getCurrentRegisteredShapes().filter(shape => shapes.indexOf(shape) === -1)); } } CanvasWhiteboardShapeService.decorators = [ { type: Injectable } ]; CanvasWhiteboardShapeService.ctorParameters = () => []; class CanvasWhiteboardComponent { constructor(ngZone, changeDetectorRef, canvasWhiteboardService, canvasWhiteboardShapeService) { this.ngZone = ngZone; this.changeDetectorRef = changeDetectorRef; this.canvasWhiteboardService = canvasWhiteboardService; this.canvasWhiteboardShapeService = canvasWhiteboardShapeService; // Number of ms to wait before sending out the updates as an array this.batchUpdateTimeoutDuration = 100; this.drawButtonText = ''; this.clearButtonText = ''; this.eraseButtonText = ''; this.undoButtonText = ''; this.redoButtonText = ''; this.saveDataButtonText = ''; this.strokeColorPickerText = 'Stroke'; this.fillColorPickerText = 'Fill'; this.drawButtonEnabled = true; this.clearButtonEnabled = true; this.eraseButtonEnabled = true; this.undoButtonEnabled = false; this.redoButtonEnabled = false; this.saveDataButtonEnabled = false; this.shouldDownloadDrawing = true; this.strokeColorPickerEnabled = false; this.fillColorPickerEnabled = false; this.lineWidth = 2; this.eraserLineWidth = 10; this.strokeColor = 'rgba(0, 0, 0, 1)'; this.startingColor = '#fff'; this.scaleFactor = 0; this.drawingEnabled = false; this.showStrokeColorPicker = false; this.showFillColorPicker = false; this.lineJoin = 'round'; this.lineCap = 'round'; this.shapeSelectorEnabled = true; this.showShapeSelector = false; this.fillColor = 'rgba(0,0,0,0)'; this.clear = new EventEmitter(); this.undo = new EventEmitter(); this.redo = new EventEmitter(); this.batchUpdate = new EventEmitter(); this.imageLoaded = new EventEmitter(); this.save = new EventEmitter(); this.cachedStrokeColor = ''; this._canDraw = true; this._erasingEnabled = false; this._clientDragging = false; this._updateHistory = []; this._undoStack = []; // Stores the value of start and count for each continuous stroke this._redoStack = []; this._batchUpdates = []; this._updatesNotDrawn = []; this._canvasWhiteboardServiceSubscriptions = []; this._shapesMap = new Map(); this._incompleteShapesMap = new Map(); this.canvasWhiteboardShapePreviewOptions = this.generateShapePreviewOptions(); } set imageUrl(imageUrl) { this._imageUrl = imageUrl; this._imageElement = null; this._redrawHistory(); } get imageUrl() { return this._imageUrl; } set erasingEnabled(value) { this._erasingEnabled = value; this._setErasing(value); } /** * Initialize the canvas drawing context. If we have an aspect ratio set up, the canvas will resize * according to the aspect ratio. */ ngOnInit() { this._initInputsFromOptions(this.options); this._initCanvasEventListeners(); this._initCanvasServiceObservables(); this.context = this.canvas.nativeElement.getContext('2d'); this._incompleteShapesCanvasContext = this._incompleteShapesCanvas.nativeElement.getContext('2d'); } /** * If an image exists and it's url changes, we need to redraw the new image on the canvas. */ ngOnChanges(changes) { if (changes.options && !isEqual(changes.options.currentValue, changes.options.previousValue)) { this._initInputsFromOptions(changes.options.currentValue); } } /** * Recalculate the width and height of the canvas after the view has been fully initialized */ ngAfterViewInit() { this._calculateCanvasWidthAndHeight(); this._redrawHistory(); } /** * Unsubscribe from the service observables */ ngOnDestroy() { this._unsubscribe(this._resizeSubscription); this._unsubscribe(this._registeredShapesSubscription); this._canvasWhiteboardServiceSubscriptions.forEach(subscription => this._unsubscribe(subscription)); } /** * This method reads the options which are helpful since they can be really long when specified in HTML * This method is also called everytime the options object changes * For security reasons we must check each item on its own since if we iterate the keys * we may be injected with malicious values */ _initInputsFromOptions(options) { if (options) { if (!this._isNullOrUndefined(options.eraserLineWidth)) { this.eraserLineWidth = options.eraserLineWidth; } if (!this._isNullOrUndefined(options.eraseButtonClass)) { this.eraseButtonClass = options.eraseButtonClass; } if (!this._isNullOrUndefined(options.eraseButtonText)) { this.eraseButtonText = options.eraseButtonText; } if (!this._isNullOrUndefined(options.erasingEnabled)) { this.erasingEnabled = options.erasingEnabled; } if (!this._isNullOrUndefined(options.customWhiteboardUi)) { this.customWhiteboardUi = options.customWhiteboardUi; } if (!this._isNullOrUndefined(options.batchUpdateTimeoutDuration)) { this.batchUpdateTimeoutDuration = options.batchUpdateTimeoutDuration; } if (!this._isNullOrUndefined(options.imageUrl)) { this.imageUrl = options.imageUrl; } if (!this._isNullOrUndefined(options.aspectRatio)) { this.aspectRatio = options.aspectRatio; } if (!this._isNullOrUndefined(options.drawButtonClass)) { this.drawButtonClass = options.drawButtonClass; } if (!this._isNullOrUndefined(options.clearButtonClass)) { this.clearButtonClass = options.clearButtonClass; } if (!this._isNullOrUndefined(options.undoButtonClass)) { this.undoButtonClass = options.undoButtonClass; } if (!this._isNullOrUndefined(options.redoButtonClass)) { this.redoButtonClass = options.redoButtonClass; } if (!this._isNullOrUndefined(options.saveDataButtonClass)) { this.saveDataButtonClass = options.saveDataButtonClass; } if (!this._isNullOrUndefined(options.drawButtonText)) { this.drawButtonText = options.drawButtonText; } if (!this._isNullOrUndefined(options.clearButtonText)) { this.clearButtonText = options.clearButtonText; } if (!this._isNullOrUndefined(options.undoButtonText)) { this.undoButtonText = options.undoButtonText; } if (!this._isNullOrUndefined(options.redoButtonText)) { this.redoButtonText = options.redoButtonText; } if (!this._isNullOrUndefined(options.saveDataButtonText)) { this.saveDataButtonText = options.saveDataButtonText; } if (!this._isNullOrUndefined(options.strokeColorPickerText)) { this.strokeColorPickerText = options.strokeColorPickerText; } if (!this._isNullOrUndefined(options.fillColorPickerText)) { this.fillColorPickerText = options.fillColorPickerText; } if (!this._isNullOrUndefined(options.drawButtonEnabled)) { this.drawButtonEnabled = options.drawButtonEnabled; } if (!this._isNullOrUndefined(options.clearButtonEnabled)) { this.clearButtonEnabled = options.clearButtonEnabled; } if (!this._isNullOrUndefined(options.undoButtonEnabled)) { this.undoButtonEnabled = options.undoButtonEnabled; } if (!this._isNullOrUndefined(options.redoButtonEnabled)) { this.redoButtonEnabled = options.redoButtonEnabled; } if (!this._isNullOrUndefined(options.saveDataButtonEnabled)) { this.saveDataButtonEnabled = options.saveDataButtonEnabled; } if (!this._isNullOrUndefined(options.strokeColorPickerEnabled)) { this.strokeColorPickerEnabled = options.strokeColorPickerEnabled; } if (!this._isNullOrUndefined(options.fillColorPickerEnabled)) { this.fillColorPickerEnabled = options.fillColorPickerEnabled; } if (!this._isNullOrUndefined(options.lineWidth)) { this.lineWidth = options.lineWidth; } if (!this._isNullOrUndefined(options.strokeColor)) { this.strokeColor = options.strokeColor; } if (!this._isNullOrUndefined(options.shouldDownloadDrawing)) { this.shouldDownloadDrawing = options.shouldDownloadDrawing; } if (!this._isNullOrUndefined(options.startingColor)) { this.startingColor = options.startingColor; } if (!this._isNullOrUndefined(options.scaleFactor)) { this.scaleFactor = options.scaleFactor; } if (!this._isNullOrUndefined(options.drawingEnabled)) { this.drawingEnabled = options.drawingEnabled; } if (!this._isNullOrUndefined(options.downloadedFileName)) { this.downloadedFileName = options.downloadedFileName; } if (!this._isNullOrUndefined(options.lineJoin)) { this.lineJoin = options.lineJoin; } if (!this._isNullOrUndefined(options.lineCap)) { this.lineCap = options.lineCap; } if (!this._isNullOrUndefined(options.shapeSelectorEnabled)) { this.shapeSelectorEnabled = options.shapeSelectorEnabled; } if (!this._isNullOrUndefined(options.showShapeSelector)) { this.showShapeSelector = options.showShapeSelector; } if (!this._isNullOrUndefined(options.fillColor)) { this.fillColor = options.fillColor; } if (!this._isNullOrUndefined(options.showStrokeColorPicker)) { this.showStrokeColorPicker = options.showStrokeColorPicker; } if (!this._isNullOrUndefined(options.showFillColorPicker)) { this.showFillColorPicker = options.showFillColorPicker; } } } // noinspection JSMethodCanBeStatic _isNullOrUndefined(property) { return property === null || property === undefined; } /** * Init global window listeners like resize and keydown */ _initCanvasEventListeners() { this.ngZone.runOutsideAngular(() => { this._resizeSubscription = fromEvent(window, 'resize') .pipe(debounceTime(200), distinctUntilChanged()).subscribe(() => { this.ngZone.run(() => { this._redrawCanvasOnResize(); }); }); }); window.addEventListener('keydown', this._canvasKeyDown.bind(this), false); } /** * Subscribes to new signals in the canvas whiteboard service and executes methods accordingly * Because of circular publishing and subscribing, the canvas methods do not use the service when * local actions are completed (Ex. clicking undo from the button inside this component) */ _initCanvasServiceObservables() { this._canvasWhiteboardServiceSubscriptions.push(this.canvasWhiteboardService.canvasDrawSubject$ .subscribe(updates => this.drawUpdates(updates))); this._canvasWhiteboardServiceSubscriptions.push(this.canvasWhiteboardService.canvasClearSubject$ .subscribe(() => this.clearCanvas())); this._canvasWhiteboardServiceSubscriptions.push(this.canvasWhiteboardService.canvasUndoSubject$ .subscribe((updateUUD) => this._undoCanvas(updateUUD))); this._canvasWhiteboardServiceSubscriptions.push(this.canvasWhiteboardService.canvasRedoSubject$ .subscribe((updateUUD) => this._redoCanvas(updateUUD))); this._registeredShapesSubscription = this.canvasWhiteboardShapeService.registeredShapes$.subscribe((shapes) => { if (!this.selectedShapeConstructor || !this.canvasWhiteboardShapeService.isRegisteredShape(this.selectedShapeConstructor)) { this.selectedShapeConstructor = shapes[0]; } }); } /** * Calculate the canvas width and height from it's parent container width and height (use aspect ratio if needed) */ _calculateCanvasWidthAndHeight() { this.context.canvas.width = this.canvas.nativeElement.parentNode.clientWidth; if (this.aspectRatio) { this.context.canvas.height = this.canvas.nativeElement.parentNode.clientWidth * this.aspectRatio; } else { this.context.canvas.height = this.canvas.nativeElement.parentNode.clientHeight; } this._incompleteShapesCanvasContext.canvas.width = this.context.canvas.width; this._incompleteShapesCanvasContext.canvas.height = this.context.canvas.height; } /** * Load an image and draw it on the canvas (if an image exists) * @param callbackFn A function that is called after the image loading is finished * @return Emits a value when the image has been loaded. */ _loadImage(callbackFn) { this._canDraw = false; // If we already have the image there is no need to acquire it if (this._imageElement) { this._canDraw = true; if (callbackFn) callbackFn(); return; } this._imageElement = new Image(); this._imageElement.addEventListener('load', () => { this._canDraw = true; if (callbackFn) callbackFn(); this.imageLoaded.emit(true); }); this._imageElement.src = this.imageUrl; } /** * Sends a notification after clearing the canvas * This method should only be called from the clear button in this component since it will emit an clear event * If the client calls this method he may create a circular clear action which may cause danger. */ clearCanvasLocal() { this.clearCanvas(); this.clear.emit(true); } /** * Clears all content on the canvas. */ clearCanvas() { this._removeCanvasData(); this._redoStack = []; } /** * This method resets the state of the canvas and redraws it. * It calls a callback function after redrawing */ _removeCanvasData(callbackFn) { this._shapesMap = new Map(); this._clientDragging = false; this._updateHistory = []; this._undoStack = []; this._redrawBackground(callbackFn); } /** * Clears the canvas and redraws the image if the url exists. * @param callbackFn A function that is called after the background is redrawn * @return Emits a value when the clearing is finished */ _redrawBackground(callbackFn) { if (this.context) { if (this.imageUrl) { this._loadImage(() => { this.context.save(); this._drawImage(this.context, this._imageElement, 0, 0, this.context.canvas.width, this.context.canvas.height, 0.5, 0.5); this.context.restore(); this._drawMissingUpdates(); if (callbackFn) callbackFn(); }); } else { this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); this._drawStartingColor(); if (callbackFn) callbackFn(); } } } _drawStartingColor() { const previousFillStyle = this.context.fillStyle; this.context.save(); this.context.fillStyle = this.startingColor; this.context.fillRect(0, 0, this.context.canvas.width, this.context.canvas.height); this.context.fillStyle = previousFillStyle; this.context.restore(); } /** * Returns a value of whether the user clicked the draw button on the canvas. */ getDrawingEnabled() { return this.drawingEnabled; } /** * Toggles drawing on the canvas. It is called via the draw button on the canvas. */ toggleDrawingEnabled() { this.drawingEnabled = !this.drawingEnabled; if (this.drawingEnabled && this.erasingEnabled) { this.erasingEnabled = false; } } /** * Set if drawing is enabled from the client using the canvas */ setDrawingEnabled(drawingEnabled) { this.drawingEnabled = drawingEnabled; if (this.drawingEnabled && this.erasingEnabled) { this.erasingEnabled = false; } } /** * Toggles erasing on the canvas. It is called via the erase button on the canvas. */ toggleErasingEnabled() { this.erasingEnabled = !this._erasingEnabled; if (this.drawingEnabled && this.erasingEnabled) { this.setDrawingEnabled(false); } } /** * Replaces the drawing color with a new color * The format should be ("#ffffff" or "rgb(r,g,b,a?)") * This method is public so that anyone can access the canvas and change the stroke color * * @param newStrokeColor The new stroke color */ changeStrokeColor(newStrokeColor) { this.strokeColor = newStrokeColor; this.canvasWhiteboardShapePreviewOptions = this.generateShapePreviewOptions(); this.changeDetectorRef.detectChanges(); } /** * Replaces the fill color with a new color * The format should be ("#ffffff" or "rgb(r,g,b,a?)") * This method is public so that anyone can access the canvas and change the fill color * * @param newFillColor The new fill color */ changeFillColor(newFillColor) { this.fillColor = newFillColor; this.canvasWhiteboardShapePreviewOptions = this.generateShapePreviewOptions(); this.changeDetectorRef.detectChanges(); } /** * This method is invoked by the undo button on the canvas screen * It calls the global undo method and emits a notification after undoing. * This method should only be called from the undo button in this component since it will emit an undo event * If the client calls this method he may create a circular undo action which may cause danger. */ undoLocal() { this.doUndo((updateUUID) => { this._redoStack.push(updateUUID); this.undo.emit(updateUUID); }); } /** * This methods selects the last uuid prepares it for undoing (making the whole update sequence invisible) * This method can be called if the canvas component is a ViewChild of some other component. * This method will work even if the undo button has been disabled */ doUndo(callbackFn) { if (!this._undoStack.length) { return; } // FIXME is casting to string correct here? const updateUUID = this._undoStack.pop(); this._undoCanvas(updateUUID); if (callbackFn) callbackFn(updateUUID); } /** * This method takes an UUID for an update, and redraws the canvas by making all updates with that uuid invisible */ _undoCanvas(updateUUID) { if (this._shapesMap.has(updateUUID)) { const shape = this._shapesMap.get(updateUUID); if (shape) shape.isVisible = false; this.drawAllShapes(); } } /** * This method is invoked by the redo button on the canvas screen * It calls the global redo method and emits a notification after redoing * This method should only be called from the redo button in this component since it will emit an redo event * If the client calls this method he may create a circular redo action which may cause danger. */ redoLocal() { this.doRedo((updateUUID) => { this._undoStack.push(updateUUID); this.redo.emit(updateUUID); }); } /** * This methods selects the last uuid prepares it for redoing (making the whole update sequence visible) * This method can be called if the canvas component is a ViewChild of some other component. * This method will work even if the redo button has been disabled */ doRedo(callbackFn) { if (!this._redoStack.length) { return; } // FIXME is casting to string correct here? const updateUUID = this._redoStack.pop(); this._redoCanvas(updateUUID); if (callbackFn) callbackFn(updateUUID); } /** * This method takes an UUID for an update, and redraws the canvas by making all updates with that uuid visible */ _redoCanvas(updateUUID) { if (this._shapesMap.has(updateUUID)) { const shape = this._shapesMap.get(updateUUID); if (shape) shape.isVisible = true; this.drawAllShapes(); } } /** * Catches the Mouse and Touch events made on the canvas. * If drawing is disabled (If an image exists but it's not loaded, or the user did not click Draw), * this function does nothing. * * If a "mousedown | touchstart" event is triggered, dragging will be set to true and an CanvasWhiteboardUpdate object * of type "start" will be drawn and then sent as an update to all receiving ends. * * If a "mousemove | touchmove" event is triggered and the client is dragging, an CanvasWhiteboardUpdate object * of type "drag" will be drawn and then sent as an update to all receiving ends. * * If a "mouseup, mouseout | touchend, touchcancel" event is triggered, dragging will be set to false and * an CanvasWhiteboardUpdate object of type "stop" will be drawn and then sent as an update to all receiving ends. * */ canvasUserEvents(event) { // Ignore all if we don't have drawing/erasing enabled or the image did not load if ((!this.drawingEnabled && !this._erasingEnabled) || !this._canDraw) { return; } // Ignore mouse move Events if we're not dragging if (!this._clientDragging && (event.type === 'mousemove' || event.type === 'touchmove' || event.type === 'mouseout' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'touchend' || event.type === 'mouseout')) { return; } // Ignore the middle mouse button if (event.button === 1) { return; } if (event.target === this._incompleteShapesCanvas.nativeElement || event.target === this.canvas.nativeElement) { event.preventDefault(); } let update; let updateType; const eventPosition = this._getCanvasEventPosition(event); update = new CanvasWhiteboardUpdate(eventPosition.x, eventPosition.y); switch (event.type) { case 'mousedown': case 'touchstart': this._clientDragging = true; this._lastUUID = this._generateUUID(); updateType = CanvasWhiteboardUpdateType.START; this._redoStack = []; this._addCurrentShapeDataToAnUpdate(update); break; case 'mousemove': case 'touchmove': if (!this._clientDragging) { return; } updateType = CanvasWhiteboardUpdateType.DRAG; break; case 'touchcancel': case 'mouseup': case 'touchend': case 'mouseout': this._clientDragging = false; updateType = CanvasWhiteboardUpdateType.STOP; this._undoStack.push(this._lastUUID); break; } update.UUID = this._lastUUID; update.type = updateType; this._draw(update); this._prepareToSendUpdate(update); } /** * Get the coordinates (x,y) from a given event * If it is a touch event, get the touch positions * If we released the touch, the position will be placed in the changedTouches object * If it is not a touch event, use the original mouse event received */ _getCanvasEventPosition(eventData) { const canvasBoundingRect = this.context.canvas.getBoundingClientRect(); let hasTouches = (eventData.touches && eventData.touches.length) ? eventData.touches[0] : null; if (!hasTouches) { hasTouches = (eventData.changedTouches && eventData.changedTouches.length) ? eventData.changedTouches[0] : null; } const event = hasTouches || eventData; const scaleWidth = canvasBoundingRect.width / this.context.canvas.width; const scaleHeight = canvasBoundingRect.height / this.context.canvas.height; let xPosition = (event.clientX - canvasBoundingRect.left); let yPosition = (event.clientY - canvasBoundingRect.top); xPosition /= this.scaleFactor || scaleWidth; yPosition /= this.scaleFactor || scaleHeight; return new CanvasWhiteboardPoint(xPosition / this.context.canvas.width, yPosition / this.context.canvas.height); } /** * The update coordinates on the canvas are mapped so that all receiving ends * can reverse the mapping and get the same position as the one that * was drawn on this update. * * @param update The CanvasWhiteboardUpdate object. */ _prepareToSendUpdate(update) { this._prepareUpdateForBatchDispatch(update); } /** * Catches the Key Up events made on the canvas. * If the ctrlKey or commandKey(macOS) was held and the keyCode is 90 (z), an undo action will be performed * If the ctrlKey or commandKey(macOS) was held and the keyCode is 89 (y), a redo action will be performed * If the ctrlKey or commandKey(macOS) was held and the keyCode is 83 (s) or 115(S), a save action will be performed * * @param event The event that occurred. */ _canvasKeyDown(event) { if (event.ctrlKey || event.metaKey) { if (event.keyCode === 90 && this.undoButtonEnabled) { event.preventDefault(); this.doUndo(); } if (event.keyCode === 89 && this.redoButtonEnabled) { event.preventDefault(); this.doRedo(); } if (event.keyCode === 83 || event.keyCode === 115) { event.preventDefault(); this.saveLocal(); } } } /** * On window resize, recalculate the canvas dimensions and redraw the history */ _redrawCanvasOnResize() { this._calculateCanvasWidthAndHeight(); this._redrawHistory(); } /** * Redraw the saved history after resetting the canvas state */ _redrawHistory() { const updatesToDraw = [...this._updateHistory]; this._removeCanvasData(() => { updatesToDraw.forEach((update) => { this._draw(update); }); }); } /** * Draws a CanvasWhiteboardUpdate object on the canvas. * The coordinates are first reverse mapped so that they can be drawn in the proper place. The update * is afterwards added to the undoStack so that it can be * * If the CanvasWhiteboardUpdate Type is "start", a new "selectedShape" is created. * If the CanvasWhiteboardUpdate Type is "drag", the shape is taken from the shapesMap and then it's updated. * Afterwards the context is used to draw the shape on the canvas. * This function saves the last X and Y coordinates that were drawn. * * @param update The update object. */ _draw(update) { this._updateHistory.push(update); // map the canvas coordinates to our canvas size since they are scaled. // FIXME should the x and y be casted to number? update = Object.assign(new CanvasWhiteboardUpdate(), update, { x: update.x * this.context.canvas.width, y: update.y * this.context.canvas.height }); let shape; switch (update.type) { case CanvasWhiteboardUpdateType.START: const updateShapeConstructor = this.canvasWhiteboardShapeService .getShapeConstructorFromShapeName(update.selectedShape); if (updateShapeConstructor) { // FIXME should the x and y be casted to number? shape = new updateShapeConstructor(new CanvasWhiteboardPoint(update.x, update.y), Object.assign(new CanvasWhiteboardShapeOptions(), update.selectedShapeOptions)); } // FIXME should the UUID be casted to string? this._incompleteShapesMap.set(update.UUID, shape); this._drawIncompleteShapes(); break; case CanvasWhiteboardUpdateType.DRAG: shape = this._incompleteShapesMap.get(update.UUID); if (shape) shape.onUpdateReceived(update); this._drawIncompleteShapes(); break; case CanvasWhiteboardUpdateType.STOP: shape = this._incompleteShapesMap.get(update.UUID); shape.onStopReceived(update); this._shapesMap.set(update.UUID, shape); this._incompleteShapesMap.delete(update.UUID); this._swapCompletedShapeToActualCanvas(shape); break; } } _drawIncompleteShapes() { this._resetIncompleteShapeCanvas(); this._incompleteShapesMap.forEach((shape) => { if (shape.isVisible) { shape.draw(this._incompleteShapesCanvasContext); } }); } _swapCompletedShapeToActualCanvas(shape) { this._drawIncompleteShapes(); if (shape.isVisible) { shape.draw(this.context); } } _resetIncompleteShapeCanvas() { this._incompleteShapesCanvasContext.clearRect(0, 0, this._incompleteShapesCanvasContext.canvas.width, this._incompleteShapesCanvasContext.canvas.height); this._incompleteShapesCanvasContext.fillStyle = 'transparent'; this._incompleteShapesCanvasContext.fillRect(0, 0, this._incompleteShapesCanvasContext.canvas.width, this._incompleteShapesCanvasContext.canvas.height); } /** * Delete everything from the screen, redraw the background, and then redraw all the shapes from the shapesMap */ drawAllShapes() { this._redrawBackground(() => { this._shapesMap.forEach((shape) => { if (shape.isVisible) { shape.draw(this.context); } }); }); } _addCurrentShapeDataToAnUpdate(update) { if (!update.selectedShape && this.selectedShapeConstructor) { update.selectedShape = (new this.selectedShapeConstructor()).getShapeName(); } if (!update.selectedShapeOptions) { // Make a deep copy since we don't want some Shape implementation to change something by accident update.selectedShapeOptions = Object.assign(new CanvasWhiteboardShapeOptions(), this.generateShapePreviewOptions(), { lineWidth: this.erasingEnabled ? this.eraserLineWidth : this.lineWidth }); } } generateShapePreviewOptions() { return Object.assign(new CanvasWhiteboardShapeOptions(), { shouldFillShape: !!this.fillColor, fillStyle: this.fillColor, strokeStyle: this.strokeColor, lineWidth: this.lineWidth, lineJoin: this.lineJoin, lineCap: this.lineCap }); } /** * Sends the update to all receiving ends as an Event emit. This is done as a batch operation (meaning * multiple updates are sent at the same time). If this method is called, after 100 ms all updates * that were made at that time will be packed up together and sent to the receiver. * * @param update The update object. * @return Emits an Array of Updates when the batch. */ _prepareUpdateForBatchDispatch(update) { this._batchUpdates.push(cloneDeep(update)); if (!this._updateTimeout) { this._updateTimeout = setTimeout(() => { this.batchUpdate.emit(this._batchUpdates); this._batchUpdates = []; this._updateTimeout = null; }, this.batchUpdateTimeoutDuration); } } /** * Draws an Array of Updates on the canvas. * * @param updates The array with Updates. */ drawUpdates(updates) { if (this._canDraw) { this._drawMissingUpdates(); updates.forEach((update) => { this._draw(update); }); } else { this._updatesNotDrawn = this._updatesNotDrawn.concat(updates); } } /** * Draw any missing updates that were received before the image was loaded */ _drawMissingUpdates() { if (this._updatesNotDrawn.length > 0) { const updatesToDraw = this._updatesNotDrawn; this._updatesNotDrawn = []; updatesToDraw.forEach((update) => { this._draw(update); }); } } // noinsp