@drizm/ng-whiteboard
Version:
A Canvas component for Angular which supports free drawing.
1,061 lines • 161 kB
JavaScript
import { Component, Input, Output, EventEmitter, ViewChild, NgZone, ChangeDetectorRef } from '@angular/core';
import { CanvasWhiteboardUpdate, CanvasWhiteboardUpdateType } from './_classes/canvas-whiteboard-update.model';
import { CanvasWhiteboardService } from './_services/canvas-whiteboard.service';
import { CanvasWhiteboardPoint } from './_classes/canvas-whiteboard-point.model';
import { CanvasWhiteboardShapeService } from './_services/canvas-whiteboard-shape.service';
import { CanvasWhiteboardShapeOptions } from './_classes/shape/canvas-whiteboard-shape-options';
import { fromEvent } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { cloneDeep, isEqual } from 'lodash-es';
export 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);
});
}
}
// noinspection JSMethodCanBeStatic
/**
* Draws an image on the canvas
*
* @param context The context used to draw the image on the canvas.
* @param image The image to draw.
* @param x The X coordinate for the starting draw position.
* @param y The Y coordinate for the starting draw position.
* @param width The width of the image that will be drawn.
* @param height The height of the image that will be drawn.
* @param offsetX The offsetX if the image size is larger than the canvas (aspect Ratio)
* @param offsetY The offsetY if the image size is larger than the canvas (aspect Ratio)
*/
_drawImage(context, image, x, y, width, height, offsetX, offsetY) {
if (arguments.length === 2) {
x = y = 0;
width = context.canvas.width;
height = context.canvas.height;
}
if (offsetX < 0)
offsetX = 0;
else if (offsetX > 1)
offsetX = 1;
if (offsetY < 0)
offsetY = 0;
else if (offsetY > 1)
offsetY = 1;
const imageWidth = image.width;
const imageHeight = image.height;
const radius = Math.min(width / imageWidth, height / imageHeight);
let newWidth = imageWidth * radius;
let newHeight = imageHeight * radius;
let finalDrawX;
let finalDrawY;
let finalDrawWidth;
let finalDrawHeight;
let aspectRatio = 1;
// decide which gap to fill
if (newWidth < width) {
aspectRatio = width / newWidth;
}
if (Math.abs(aspectRatio - 1) < 1e-14 && newHeight < height) {
aspectRatio = height / newHeight;
}
newWidth *= aspectRatio;
newHeight *= aspectRatio;
// calculate source rectangle
finalDrawWidth = imageWidth / (newWidth / width);
finalDrawHeight = imageHeight / (newHeight / height);
finalDrawX = (imageWidth - finalDrawWidth) * offsetX;
finalDrawY = (imageHeight - finalDrawHeight) * offsetY;
// make sure the source rectangle is valid
if (finalDrawX < 0)
finalDrawX = 0;
if (finalDrawY < 0)
finalDrawY = 0;
if (finalDrawWidth > imageWidth)
finalDrawWidth = imageWidth;
if (finalDrawHeight > imageHeight)
finalDrawHeight = imageHeight;
// fill the image in destination rectangle
context.drawImage(image, finalDrawX, finalDrawY, finalDrawWidth, finalDrawHeight, x, y, width, height);
}
/**
* The HTMLCanvasElement.toDataURL() method returns a data URI containing a representation
* of the image in the format specified by the type parameter (defaults to PNG).
* The returned image is in a resolution of 96 dpi.
* If the height or width of the canvas is 0, the string "data:," is returned.
* If the requested type is not image/png, but the returned value starts with data:image/png, then the requested type is not supported.
* Chrome also supports the image/webp type.
*
* @param returnedDataType A DOMString indicating the image format. The default format type is image/png.
* @param returnedDataQuality A Number between 0 and 1 indicating image quality if the requested type
* is image/jpeg or image/webp.
* If this argument is anything else, the default value for image quality is used. The default value is 0.92.
* Other arguments are ignored.
*/
generateCanvasDataUrl(returnedDataType = 'image/png', returnedDataQuality = 1) {
return this.context.canvas.toDataURL(returnedDataType, returnedDataQuality);
}
/**
* Generate a Blob object representing the content drawn on the canvas.
* This file may be cached on the disk or stored in memory at the discretion of the user agent.
* If type is not specified, the image type is image/png. The created image is in a resolution of 96dpi.
* The third argument is used with image/jpeg images to specify the quality of the output.
*
* @param callbackFn The function that should be executed when the blob is created. Should accept a parameter Blob (for the result).
* @param returnedDataType A DOMString indicating the image format. The default type is image/png.
* @param returnedDataQuality A Number between 0 and 1 indicating image quality if the requested type is image/jpeg or image/webp.
* If this argument is anything else, the default value for image quality is used. Other arguments are ignored.
*/
generateCanvasBlob(callbackFn, returnedDataType = 'image/png', returnedDataQuality = 1) {
let toBlobMethod;
if (typeof this.context.canvas.toBlob !== 'undefined') {
toBlobMethod = this.context.canvas.toBlob.bind(this.context.canvas);
}
else if (typeof this.context.canvas.msToBlob !== 'undefined') {
// For IE
toBlobMethod = (callback) => {
if (callback)
callback(this.context.canvas.msToBlob());
};
}
if (toBlobMethod)
toBlobMethod((blob) => {
if (callbackFn && blob)
callbackFn(blob, returnedDataType);
}, returnedDataType, returnedDataQuality);
}
/**
* Generate a canvas image representation and download it locally
* The name of the image is canvas_drawing_ + the current local Date and Time the image was created
* Methods for standalone creation of the images in this method are left here for backwards compatibility
*
* @param returnedDataType A DOMString indicating the image format. The default type is image/png.
* @param downloadData? The created string or Blob (IE).
* @param customFileName? The name of the file that should be downloaded
*/
downloadCanvasImage(returnedDataType = 'image/png', downloadData, customFileName) {
if (window.navigator.msSaveOrOpenBlob === undefined) {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href', downloadData ? downloadData : this.generateCanvasDataUrl(returnedDataType));
const fileName = customFileName ? customFileName
: (this.downloadedFileName ? this.downloadedFileName : 'canvas_drawing_' + new Date().valueOf());
downloadLink.setAttribute('download', fileName + this._generateDataTypeString(returnedDataType));
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
else {
// IE-specific code
if (downloadData) {
this._saveCanvasBlob(downloadData, returnedDataType);
}
else {
this.generateCanvasBlob(this._saveCanvasBlob.bind(this), returnedDataType);
}
}
}
/**
* Save the canvas blob (IE) locally
*/
_saveCanvasBlob(blob, returnedDataType = 'image/png') {
window.navigator.msSaveOrOpenBlob(blob, 'canvas_drawing_' +
new Date().valueOf() + this._generateDataTypeString(returnedDataType));
}
/**
* This method generates a canvas url string or a canvas blob with the presented data type
* A callback function is then invoked since the blob creation must be done via a callback
*
*/
generateCanvasData(callback, returnedDataType = 'image/png', returnedDataQuality = 1) {
if (window.navigator.msSaveOrOpenBlob === undefined) {
if (callback)
callback(this.generateCanvasDataUrl(returnedDataType, returnedDataQuality));
}
else {
this.generateCanvasBlob(callback, returnedDataType, returnedDataQuality);
}
}
/**
* Local method to invoke saving of the canvas data when clicked on the canvas Save button
* This method will emit the generated data with the specified Event Emitter
*
*/
saveLocal(returnedDataType = 'image/png') {
this.generateCanvasData((generatedData) => {
this.save.emit(generatedData);
if (this.shouldDownloadDrawing) {
this.downloadCanvasImage(returnedDataType, generatedData);
}
});
}
_generateDataTypeString(returnedDataType) {
if (returnedDataType) {
return '.' + returnedDataType.split('/')[1];
}
return '';
}
/**
* Toggles the color picker window, delegating the showColorPicker Input to the ColorPickerComponent.
* If no value is supplied (null/undefined) the current value will be negated and used.
*/
toggleStrokeColorPicker(value) {
this.showStrokeColorPicker = !this._isNullOrUndefined(value) ? value : !this.showStrokeColorPicker;
}
/**
* Toggles the color picker window, delegating the showColorPicker Input to the ColorPickerComponent.
* If no value is supplied (null/undefined) the current value will be negated and used.
*/
toggleFillColorPicker(value) {
this.showFillColorPicker = !this._isNullOrUndefined(value) ? value : !this.showFillColorPicker;
}
/**
* Toggles the shape selector window, delegating the showShapeSelector Input to the CanvasWhiteboardShapeSelectorComponent.
* If no value is supplied (null/undefined) the current value will be negated and used.
*/
toggleShapeSelector(value) {
this.showShapeSelector = !this._isNullOrUndefined(value) ? value : !this.showShapeSelector;
}
selectShape(newShapeBlueprint) {
this.selectedShapeConstructor = newShapeBlueprint;
}
/**
* Returns a deep copy of the current drawing history for the canvas.
* The deep copy is returned because we don't want anyone to mutate the current history
*/
getDrawingHistory() {
return cloneDeep(this._updateHistory);
}
_setErasing(value) {
if (value) {
// Draw invisible lines to imitate erasing
this.context.globalCompositeOperation = 'destination-out';
this.cachedStrokeColor = (' ' + this.strokeColor).slice(1);
this.changeStrokeColor('rgba(255,255,255,1)');
this.changeDetectorRef.detectChanges();
}
else {
// Return to default values
this.context.globalCompositeOperation = 'source-over';
this.changeStrokeColor((' ' + this.cachedStrokeColor).slice(1));
this.cachedStrokeColor = '';
this.changeDetectorRef.detectChanges();
}
}
/**
* Unsubscribe from a given subscription if it is active
*/
_unsubscribe(subscription) {
if (subscription) {
subscription.unsubscribe();
}
}
_generateUUID() {
return this._random4() + this._random4() + '-' + this._random4() + '-' + this._random4() + '-' +
this._random4() + '-' + this._random4() + this._random4() + this._random4();
}
_random4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
// tslint:disable-next-line:adjacent-overload-signatures
get erasingEnabled() {
return this._erasingEnabled;
}
}
CanvasWhiteboardComponent.decorators = [
{ type: Component, args: [{
selector: 'drizm-whiteboard',
template: "<div class=\"canvas_wrapper_div\">\r\n <div class=\"canvas_whiteboard_buttons\" *ngIf=\"!customWhiteboardUi; else customWhiteboardUi\">\r\n <canvas-whiteboard-shape-selector\r\n *ngIf=\"shapeSelectorEnabled\"\r\n [showShapeSelector]=\"showShapeSelector\"\r\n [selectedShapeConstructor]=\"selectedShapeConstructor\"\r\n [shapeOptions]=\"generateShapePreviewOptions()\"\r\n (toggleShapeSelector)=\"toggleShapeSelector($event)\"\r\n (shapeSelected)=\"selectShape($event)\"></canvas-whiteboard-shape-selector>\r\n\r\n <canvas-whiteboard-colorpicker\r\n *ngIf=\"fillColorPickerEnabled\"\r\n [previewText]=\"fillColorPickerText\"\r\n [showColorPicker]=\"showFillColorPicker\"\r\n [selectedColor]=\"fillColor\"\r\n (toggleColorPicker)=\"toggleFillColorPicker($event)\"\r\n (colorSelected)=\"changeFillColor($event)\">\r\n </canvas-whiteboard-colorpicker>\r\n\r\n <canvas-whiteboard-colorpicker\r\n *ngIf=\"strokeColorPickerEnabled\"\r\n [previewText]=\"strokeColorPickerText\"\r\n [showColorPicker]=\"showStrokeColorPicker\"\r\n [selectedColor]=\"cachedStrokeColor || strokeColor\"\r\n (toggleColorPicker)=\"toggleStrokeColorPicker($event)\"\r\n (colorSelected)=\"changeStrokeColor($event)\">\r\n </canvas-whiteboard-colorpicker>\r\n\r\n\r\n <button *ngIf=\"drawButtonEnabled\" (click)=\"toggleDrawingEnabled()\"\r\n [class.canvas_whiteboard_button-draw_animated]=\"getDrawingEnabled()\"\r\n class=\"canvas_whiteboard_button canvas_whiteboard_button-draw\" type=\"button\">\r\n <i [class]=\"drawButtonClass\" aria-hidden=\"true\"></i> {{drawButtonText}}\r\n </button>\r\n\r\n <button *ngIf=\"clearButtonEnabled\" (click)=\"clearCanvasLocal()\" type=\"button\"\r\n class=\"canvas_whiteboard_button canvas_whiteboard_button-clear\">\r\n <i [class]=\"clearButtonClass\" aria-hidden=\"true\"></i> {{clearButtonText}}\r\n </button>\r\n\r\n <button *ngIf=\"undoButtonEnabled\" (click)=\"undoLocal()\" type=\"button\"\r\n class=\"canvas_whiteboard_button canvas_whiteboard_button-undo\">\r\n <i [class]=\"undoButtonClass\" aria-hidden=\"true\"></i> {{undoButtonText}}\r\n </button>\r\n\r\n <button *ngIf=\"redoButtonEnabled\" (click)=\"redoLocal()\" type=\"button\"\r\n class=\"canvas_whiteboard_button canvas_whiteboard_button-redo\">\r\n <i [class]=\"redoButtonClass\" aria-hidden=\"true\"></i> {{redoButtonText}}\r\n </button>\r\n <button *ngIf=\"saveDataButto