@drizm/ng-whiteboard
Version:
A Canvas component for Angular which supports free drawing.
1,194 lines (1,181 loc) • 79.2 kB
JavaScript
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