import paper from 'paper'; import { AbstractCanvas } from '../../CanvasInterface/Canvas'; import { Group } from './Objects/Group'; import { Image } from './Objects/Image'; import { Text } from './Objects/Text'; import { CanvasObject } from './Objects/CanvasObject'; import Logger from '../../Utils/Logger'; import { CanvasSizeMetric } from '../../CanvasInterface/CanvasSize'; import { useMask } from './Utils/UseMask'; import { transformTextToPath } from './Utils/TextToSVGRepository'; import { removePlaceholders } from './Utils/HTML2Paper'; import { fixSvgExportResult, prepareSvgStr, wrapWithViewGroup } from './Utils/SVGfix'; import { useColorLayer } from './Utils/UseColorLayer'; const logger = new Logger('Canvas'); export class Canvas extends AbstractCanvas { public static Image = Image; public static Text = Text; public static Group = Group; Image = Canvas.Image; Text = Canvas.Text; Group = Canvas.Group; protected _project: paper.Project; protected _view: paper.View; // prevents dragging out the object (by default lets dragging only up to the 2/3 of the object width) protected _boundTolerance: number = 1 / 2 - 2 / 3; protected _maskHandler: any | undefined = undefined; protected _colorOverlay: any | undefined = undefined; setBoundTolerance(value: number) { this._boundTolerance = value; } init(canvasElement?: HTMLCanvasElement, options?: any = {}) { const canvas = canvasElement || this.createCanvasElement(); this._project = paper.setup(canvas).project; this._view = this._project.view; this._project.activeLayer.name = 'designerLayer'; this.initControls(); super.init(this._view.element, options); } initControls() { this._view.onMouseDown = this.onMouseDown.bind(this); this._view.onMouseDrag = this.onMouseDrag.bind(this); this._view.onMouseUp = this.onMouseUp.bind(this); this._view.on('keydown', this.onKeyDown.bind(this)); } activate() { this._project && this._project.activate(); } update() { // for some older browsers the canvas has to be added to the dom to draw the fonts correctly const forceUpdate = this.addToContainer(); // important: at the time of this code was created, there was no way to force the update (redrawing of canvas items) // as a workaround the view._needsUpdate flag can be set to true // see https://github.com/paperjs/paper.js/commit/8174405924fde12488b73dbf58a70ceef4f57665 for updates this._view._needsUpdate = this._view._needsUpdate || forceUpdate; this._view.update(); forceUpdate && this.removeFromContainer(); return this; } dispose() { this._view.remove(); this._view = null; this._project.remove(); this._project = null; super.dispose(); } setSize(size: CanvasSizeMetric = this.getSize()): Canvas { super.setSize(size); this.updateViewSize(); return this; } setZoom(zoom: number): Canvas { super.setZoom(zoom); this.updateViewSize(); return this; } updateViewSize() { const { zoom, size: { width, height }, } = this; const w = width * zoom; const h = height * zoom; const size = new paper.Size(w, h); this._view.zoom = zoom; this._view.center = new paper.Point(this._view.size.divide(2)); this._view.viewSize = size; this._maskHandler && this._maskHandler.updateOutline(); this.updateObjectConstraints(); } saveViewBox() { const { viewSize, center, zoom } = this._view; return () => { this._view.viewSize = viewSize; this._view.center = center; this._view.zoom = zoom; }; } setViewBox({ width, height, x, y, zoom }) { this._view.viewSize = new paper.Size(width, height); this._view.center = new paper.Point((x + width / 2) / zoom, (y + height / 2) / zoom); this._view.zoom = zoom; this.update(); } async drawToContext( ctx: CanvasRenderingContext2D, dx: number, dy: number, dWidth: number, dHeight: number, options: any = {} ) { const TILE_SIZE = 512; // safe for ios const currentZoom = Math.min(Math.max(options.currentZoom || 1, 0.0001), 1); const restoreViewBox = this.saveViewBox(); const dstSize = TILE_SIZE; const srcSize = Math.round(TILE_SIZE * currentZoom); const zoom = (dWidth / this.getSize().width) * currentZoom; const framesX = Math.floor(dWidth / dstSize) + 1; const framesY = Math.floor(dHeight / dstSize) + 1; const tiles = []; this._maskHandler && this._maskHandler.updateOutline({ getZoomFactor: () => zoom }); for (let fx = 0; fx < framesX; fx++) { for (let fy = 0; fy < framesY; fy++) { tiles.push( new Promise(resolve => { window.requestAnimationFrame(() => { this.setViewBox({ width: srcSize, height: srcSize, x: srcSize * fx, y: srcSize * fy, zoom }); ctx.drawImage(this.canvasElement, dx + dstSize * fx, dy + dstSize * fy, dstSize, dstSize); resolve(); }); }) ); } } await Promise.all(tiles); restoreViewBox(); } setSelectedColor(color): Canvas { this._project && this._project.activeLayer.setSelectedColor(color); return this; } async useMask(maskData) { this._maskHandler = Object.assign(this._maskHandler || {}, await useMask(this._project, maskData)); return this._maskHandler; } getMaskHandler() { return this._maskHandler; } useColorOverlay(colorLayerData) { this._colorOverlay = useColorLayer(this._project, colorLayerData); return this._colorOverlay; } getColorOverlay() { return this._colorOverlay; } onMouseDown(event) { const { point } = event; const hitResult = this._project.hitTest(point); if (hitResult) { const { item } = hitResult; const wo = this.getWrapperObject(this.getTopParent(item)); if (this.selectedObject && wo !== this.selectedObject) { this.clearSelection(); } if (wo) { wo.selected = true; wo.grabbed = true; } } else if (this.selectedObject) { this.clearSelection(); } } onMouseDrag(event) { if (this.selectedObject && !this.selectedObject.locked && !this.selectedObject.autoFit) { const { x: dx, y: dy } = event.delta; const o = this.selectedObject; o.x += dx; o.y += dy; this.fitBounds(o); } } fitBounds(o: CanvasObject) { const { width: cw, height: ch } = this.size; const { width: ow, height: oh } = o.getScaledSize(); const leftMin = ow * this._boundTolerance; const leftMax = cw - leftMin; const topMin = oh * this._boundTolerance; const topMax = ch - topMin; if (o.x < leftMin) { o.x = leftMin; } else if (o.x > leftMax) { o.x = leftMax; } if (o.y < topMin) { o.y = topMin; } else if (o.y > topMax) { o.y = topMax; } } onMouseUp(event) { if (this.selectedObject) { this.selectedObject.grabbed = false; } } onKeyDown(event) { if (['INPUT', 'SELECT'].indexOf(event.event.target.nodeName) === -1 && this.selectedObject) { const o = this.selectedObject; if (event.event.ctrlKey) { switch (event.key) { case 'up': o.scaling += 0.1; break; case 'right': o.rotation += 1; break; case 'down': o.scaling -= 0.1; break; case 'left': o.rotation -= 1; break; } } else { switch (event.key) { // translate case 'up': o.y -= 1; break; case 'right': o.x += 1; break; case 'down': o.y += 1; break; case 'left': o.x -= 1; break; // remove case 'delete': o.remove(); break; } } } } getTopParent(item) { let el = item; while (el.parent && el.parent !== item.layer) { el = el.parent; } return el; } getElementSize(): any { if (this._view) { const { width, height } = this._view.getViewSize(); return { width, height }; } else { return { width: 0, height: 0, }; } } setObjectIndex(obj: CanvasObject, pos: number, overwrite = false) { super.setObjectIndex(obj, pos, overwrite); const layer = this._project.activeLayer; layer.removeChildren(layer.firstChild.isClipMask() ? 1 : 0); layer.addChildren(this.objects.map(o => o && o.wrappedObject)); } add(obj: CanvasObject) { super.add(obj); if (obj.wrappedObject) { // make sure the object is added to the active layer of the associated project obj.wrappedObject.addTo(this._project.activeLayer); } return this; } addText(options: any): Text { const text = new this.Text(options); this.add(text); return text; } async addImage(options: any): Promise { const { src } = options; const image = new this.Image(options); if (src) { await image.loadFromURL(src); } this.add(image); logger.log('Image has been added.', image); return image; } async addGroup(options: any): Promise { const { src } = options; const group = new this.Group(options); if (src) { await group.loadFromURL(src); } this.add(group); logger.log('Group has been added.', group); return group; } async addItemFromObject(options: any, index?: number, overwrite?: boolean): Promise { const { type, ...props } = options; let object; switch (type) { case 'Image': { object = await this.addImage(props); break; } case 'Group': { object = await this.addGroup(props); break; } case 'Text': { object = this.addText(props); break; } default: { logger.error('Unknown type: ' + type); return; } } if (typeof index === 'number') { object.setIndex(index, overwrite); } return object; } async getScreenshotsOfElements(fontToFileMap: any): Promise { const svgStr = await this.getScreenshot(fontToFileMap, { memoize: false }); const xmlDoc = new DOMParser().parseFromString(svgStr, 'text/xml'); const designerLayer = xmlDoc.getElementById('designerLayer'); if (!designerLayer) { return []; } const elements = [...designerLayer.children]; // remove all elements from the active layer for (const element of elements) { designerLayer.removeChild(element); } const xmlSerializer = new XMLSerializer(); const screenshots = []; // add elements one-by-one to export SVGs of them for (const element of elements) { designerLayer.appendChild(element); screenshots.push(xmlSerializer.serializeToString(xmlDoc)); designerLayer.removeChild(element); } return screenshots; } getScreenshot = (() => { // speed up repeated screenshot creation by memoizing the svg output of Paper.js let memoizedSvgStr: string; let result: string; return async (fontToFileMap: any, options: { memoize?: boolean } = {}) => { const { memoize = true } = options; const svgStr = prepareSvgStr( this._project.exportSVG({ asString: true, embedImages: false, }) ); if (memoize && memoizedSvgStr === svgStr && result) { return result; } result = ''; const xmlDoc = new DOMParser().parseFromString(svgStr, 'text/xml'); removePlaceholders(xmlDoc); await transformTextToPath(xmlDoc, fontToFileMap); wrapWithViewGroup(xmlDoc); if (this._maskHandler) { this._maskHandler.fixSVGExport(xmlDoc); } const xmlStr = new XMLSerializer().serializeToString(xmlDoc); result = fixSvgExportResult(xmlStr); if (memoize) { memoizedSvgStr = svgStr; } return result; }; })(); }