import isPlainObject from 'lodash/isPlainObject'; import isEqual from 'lodash/isEqual'; import { AbstractCanvasObject, CanvasObjectEvents } from './Objects/CanvasObject'; import { Observable } from './Observable'; import { TextInterface } from './Objects/Text'; import { ImageInterface } from './Objects/Image'; import { GroupInterface } from './Objects/Group'; import { copyImageResampled } from '../Utils/Image'; import { CanvasSize, CanvasSizeMetric } from './CanvasSize'; import { mmsToPixels, pixelsToMms } from '../Utils/UnitConversion'; import { getScreenDPI } from '../Utils/Device'; import Logger from '../Utils/Logger'; const logger = new Logger('AbstractCanvas'); export enum CanvasEvents { OnChange = 'onChange', OnObjectChange = 'onObjectChange', OnSizeChange = 'onSizeChange', } export abstract class AbstractCanvas { objects: AbstractCanvasObject[] = []; zoom = 1; canvasElement: HTMLCanvasElement; selectedObject: AbstractCanvasObject = null; size: CanvasSizeMetric = { width: 0, height: 0, dpi: 0 }; _objectEventsEnabled = true; private _observable: Observable = new Observable(this); constructor(canvasElement?: HTMLCanvasElement) { if (canvasElement) { const { width, height } = canvasElement; this.init(canvasElement, { canvasSize: { width, height } }); } } init(canvasElement?: HTMLCanvasElement, options?: { canvasSize: CanvasSizeMetric }) { if (canvasElement instanceof HTMLCanvasElement) { this.canvasElement = canvasElement; } else if (isPlainObject(canvasElement)) { this.canvasElement = this.createCanvasElement(canvasElement); } if (typeof options === 'object') { const { canvasSize } = options; if (canvasSize) { this.setSize(canvasSize); } } // there can be a previously called fromObject function, so we can call it here after initializations // note: the fromObject implementation in the canvas adapter may work only after the canvas is available // (like in Paper.js, the added objects are visible only when the canvas is already available) this._fromObject && this._fromObject(); return this; } dispose() { // remove canvas reference this.canvasElement = null; } createCanvasElement({ width, height }: { width: number; height: number } = { width: 0, height: 0 }) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; return canvas; } add(obj: AbstractCanvasObject) { this.objects.push(obj); obj.canvas = this; obj.on(CanvasObjectEvents.OnChange, this.triggerObjectChangeEvent); return this; } remove(obj: AbstractCanvasObject) { const idx = this.objects.indexOf(obj); if (idx !== -1) { if (this.selectedObject === obj) { this.deselectObject(obj); } this.objects.splice(idx, 1); obj.off(CanvasObjectEvents.OnChange, this.triggerObjectChangeEvent); this.triggerObjectChangeEvent(obj); } } clear() { // restore the canvas state this.clearSelection(); // remove all objects while (this.objects.length > 0) { this.objects.pop().remove(); } } indexOfObject(obj: AbstractCanvasObject) { return this.objects.indexOf(obj); } setObjectIndex(obj: AbstractCanvasObject, newIdx: number, overwrite = false) { const idx = this.objects.indexOf(obj); if (idx !== -1) { // remove the object from its current position this.objects.splice(idx, 1); if (overwrite) { // overwrite mode this.objects[newIdx] = obj; } else { // insert mode const newIdxFixed = Math.max(Math.min(newIdx, this.objects.length), 0); this.objects.splice(newIdxFixed, 0, obj); } } } triggerObjectChangeEvent = (target, eventData) => { this._objectEventsEnabled && this._observable.triggerEvent(CanvasEvents.OnObjectChange, { changeEvent: { target, eventData } }); }; getSize() { return this.size; } getSizeMetric() { const dpi = this.size.dpi || getScreenDPI(); return { width: pixelsToMms(this.size.width, dpi), height: pixelsToMms(this.size.height, dpi), }; } setZoom(zoom: number): AbstractCanvas { this.zoom = zoom; return this; } getZoom(): number { return this.zoom; } setSize(size: CanvasSizeMetric = this.getSize()): AbstractCanvas { if (size.isMetric) { return this.setSizeMetric(size); } if (!isEqual(this.size, size)) { this.size = size; this._observable.triggerEvent(CanvasEvents.OnSizeChange); } return this; } setSizeMetric(metricSize: CanvasSize): AbstractCanvas { const { width, height, dpi } = metricSize; const size = { width: mmsToPixels(width, dpi), height: mmsToPixels(height, dpi), dpi, }; if (!isEqual(this.size, size)) { this.size = size; this._observable.triggerEvent(CanvasEvents.OnSizeChange); } return this; } updateObjectConstraints(): void { for (const o of this.objects) { o.updateConstraints(); } } update() { return this; } getWrapperObject(wrappedObj: any): AbstractCanvasObject { for (const o of this.objects) { if (o.wrappedObject === wrappedObj) { return o; } } return false; } setSelectedObject(object: any) { this.selectedObject = object; this._observable.triggerEvent(CanvasEvents.OnChange); } deselectObject(object: any) { this.selectedObject = null; this._observable.triggerEvent(CanvasEvents.OnChange); } clearSelection() { if (this.selectedObject) { this.selectedObject.selected = false; this.selectedObject = null; this.update(); // update immediately after clearing selection } } getState(): any { const { selectedObject, size } = this; return { selectedObject: selectedObject ? selectedObject.getState() : null, size, }; } on(eventName, callback) { this._observable.on(eventName, callback); } off(eventName, callback) { this._observable.off(eventName, callback); } getLength() { return this.objects.length; } abstract getElementSize(): any; abstract addText(options: any): TextInterface; abstract addImage(options: any): ImageInterface; abstract addGroup(options: any): GroupInterface; toObject( stringify = false, { includeObjects, includeWrappedObject, }: { includeObjects?: boolean; includeWrappedObject?: boolean; } = {} ) { const canvasObj = {}; if (includeObjects) { const objects = []; for (const o of this.objects) { objects.push(o.toObject(includeWrappedObject)); } canvasObj.objects = objects; } return stringify ? JSON.stringify(canvasObj) : canvasObj; } toObjectWithMetric(stringify = false, { includeWrappedObject }: { includeWrappedObject?: boolean } = {}) { // include all objects with metric position and size const objects = []; for (const o of this.objects) { const obj = o.toObject(includeWrappedObject); const { width, height } = o.getScaledSize(); obj.metric = { x: pixelsToMms(obj.x), y: pixelsToMms(obj.y), width: pixelsToMms(width), height: pixelsToMms(height), }; objects.push(obj); } const size = this.getSize(); const canvasObj = { ...this.toObject(stringify, { includeObjects: false }), objects, size, }; return stringify ? JSON.stringify(canvasObj) : canvasObj; } async updateFromObject(canvasObject: any) { this._objectEventsEnabled = false; const { objects } = canvasObject; const wait = []; if (objects && objects.length > 0) { for (let i = 0; i < objects.length; i++) { const rawObject = objects[i]; const currentObject = this.objects.find(({ uuid }) => uuid === rawObject.uuid); if (currentObject) { wait.push(currentObject.updateOptions(rawObject)); } else { logger.error('Object not found!', rawObject); } } } await Promise.all(wait); this._objectEventsEnabled = true; this._observable.triggerEvent(CanvasEvents.OnChange); } async fromObject(canvasObject: any) { // if the canvasElement is not ready skip the update and create a callback // that can do it when the canvas becomes available // FIXME: there might be a better solution for this eg. with deferred let _this; if (!this.canvasElement) { _this = this; return new Promise(resolve => { _this._fromObject = async () => { delete _this._fromObject; const result = await _this.fromObject(canvasObject); resolve(result); }; }); } this._objectEventsEnabled = false; this.clear(); if (!canvasObject) { return Promise.resolve(); } const { objects } = canvasObject; const wait = []; if (objects && objects.length > 0) { for (let i = 0; i < objects.length; i++) { wait.push(this.addItemFromObject(objects[i], i, true)); } } const addedObjects = await Promise.all(wait); this._objectEventsEnabled = true; return addedObjects; } abstract addItemFromObject(obj: any, index?: number, overwrite?: boolean): Promise; drawRasterizedImage( ctx: CanvasRenderingContext2D, dx: number, dy: number, dWidth: number, dHeight: number ) { this.update(); copyImageResampled(this.canvasElement, ctx, dx, dy, dWidth, dHeight); } abstract async drawToContext( ctx: CanvasRenderingContext2D, dx: number, dy: number, dWidth: number, dHeight: number ); /** * Appends the canvas to the target container or to the body if the target is not specified * @param {HTMLElement} [containerElement] */ addToContainer(containerElement: HTMLElement = document.body) { const { canvasElement } = this; if (containerElement === document.body && canvasElement.parentNode) { // the canvas is already added to the DOM return false; } containerElement.appendChild(canvasElement); this.removeFromContainer = () => { containerElement.removeChild(canvasElement); this.removeFromContainer = () => { //do nothing }; }; return true; } /** * A placeholder function that is assigned in addToContainer above */ removeFromContainer() { //do nothing } abstract getScreenshot(fontToFileMap: any): Promise; abstract getScreenshotsOfElements(fontToFileMap: any): Promise; }