import { fabric } from 'fabric'; import Component from '@/interface/component'; import { clamp } from '@/util'; import { componentNames, eventNames, keyCodes, zoomModes } from '@/consts'; const MOUSE_MOVE_THRESHOLD = 10; const DEFAULT_SCROLL_OPTION = { left: 0, top: 0, width: 0, height: 0, stroke: '#000000', strokeWidth: 0, fill: '#000000', opacity: 0.4, evented: false, selectable: false, hoverCursor: 'auto', }; const DEFAULT_VERTICAL_SCROLL_RATIO = { SIZE: 0.0045, MARGIN: 0.003, BORDER_RADIUS: 0.003, }; const DEFAULT_HORIZONTAL_SCROLL_RATIO = { SIZE: 0.0066, MARGIN: 0.0044, BORDER_RADIUS: 0.003, }; const DEFAULT_ZOOM_LEVEL = 1.0; const { ZOOM_CHANGED, ADD_TEXT, TEXT_EDITING, OBJECT_MODIFIED, KEY_DOWN, KEY_UP, HAND_STARTED, HAND_STOPPED, } = eventNames; /** * Zoom components * @param {Graphics} graphics - Graphics instance * @extends {Component} * @class Zoom * @ignore */ class Zoom extends Component { constructor(graphics) { super(componentNames.ZOOM, graphics); /** * zoomArea * @type {?fabric.Rect} * @private */ this.zoomArea = null; /** * Start point of zoom area * @type {?{x: number, y: number}} */ this._startPoint = null; /** * Center point of every zoom * @type {Array.<{prevZoomLevel: number, zoomLevel: number, x: number, y: number}>} */ this._centerPoints = []; /** * Zoom level (default: 100%(1.0), max: 400%(4.0)) * @type {number} */ this.zoomLevel = DEFAULT_ZOOM_LEVEL; /** * Zoom mode ('normal', 'zoom', 'hand') * @type {string} */ this.zoomMode = zoomModes.DEFAULT; /** * Listeners * @type {Object.} * @private */ this._listeners = { startZoom: this._onMouseDownWithZoomMode.bind(this), moveZoom: this._onMouseMoveWithZoomMode.bind(this), stopZoom: this._onMouseUpWithZoomMode.bind(this), startHand: this._onMouseDownWithHandMode.bind(this), moveHand: this._onMouseMoveWithHandMode.bind(this), stopHand: this._onMouseUpWithHandMode.bind(this), zoomChanged: this._changeScrollState.bind(this), keydown: this._startHandModeWithSpaceBar.bind(this), keyup: this._endHandModeWithSpaceBar.bind(this), }; const canvas = this.getCanvas(); /** * Width:Height ratio (ex. width=1.5, height=1 -> aspectRatio=1.5) * @private */ this.aspectRatio = canvas.width / canvas.height; /** * vertical scroll bar * @type {fabric.Rect} * @private */ this._verticalScroll = new fabric.Rect(DEFAULT_SCROLL_OPTION); /** * horizontal scroll bar * @type {fabric.Rect} * @private */ this._horizontalScroll = new fabric.Rect(DEFAULT_SCROLL_OPTION); canvas.on(ZOOM_CHANGED, this._listeners.zoomChanged); this.graphics.on(ADD_TEXT, this._startTextEditingHandler.bind(this)); this.graphics.on(TEXT_EDITING, this._startTextEditingHandler.bind(this)); this.graphics.on(OBJECT_MODIFIED, this._stopTextEditingHandler.bind(this)); } /** * Attach zoom keyboard events */ attachKeyboardZoomEvents() { fabric.util.addListener(document, KEY_DOWN, this._listeners.keydown); fabric.util.addListener(document, KEY_UP, this._listeners.keyup); } /** * Detach zoom keyboard events */ detachKeyboardZoomEvents() { fabric.util.removeListener(document, KEY_DOWN, this._listeners.keydown); fabric.util.removeListener(document, KEY_UP, this._listeners.keyup); } /** * Handler when you started editing text * @private */ _startTextEditingHandler() { this.isTextEditing = true; } /** * Handler when you stopped editing text * @private */ _stopTextEditingHandler() { this.isTextEditing = false; } /** * Handler who turns on hand mode when the space bar is down * @param {KeyboardEvent} e - Event object * @private */ _startHandModeWithSpaceBar(e) { if (this.withSpace || this.isTextEditing) { return; } if (e.keyCode === keyCodes.SPACE) { this.withSpace = true; this.startHandMode(); } } /** * Handler who turns off hand mode when space bar is up * @param {KeyboardEvent} e - Event object * @private */ _endHandModeWithSpaceBar(e) { if (e.keyCode === keyCodes.SPACE) { this.withSpace = false; this.endHandMode(); } } /** * Start zoom-in mode */ startZoomInMode() { if (this.zoomArea) { return; } this.endHandMode(); this.zoomMode = zoomModes.ZOOM; const canvas = this.getCanvas(); this._changeObjectsEventedState(false); this.zoomArea = new fabric.Rect({ left: 0, top: 0, width: 0.5, height: 0.5, stroke: 'black', strokeWidth: 1, fill: 'transparent', hoverCursor: 'zoom-in', }); canvas.discardActiveObject(); canvas.add(this.zoomArea); canvas.on('mouse:down', this._listeners.startZoom); canvas.selection = false; canvas.defaultCursor = 'zoom-in'; } /** * End zoom-in mode */ endZoomInMode() { this.zoomMode = zoomModes.DEFAULT; const canvas = this.getCanvas(); const { startZoom, moveZoom, stopZoom } = this._listeners; canvas.selection = true; canvas.defaultCursor = 'auto'; canvas.off({ 'mouse:down': startZoom, 'mouse:move': moveZoom, 'mouse:up': stopZoom, }); this._changeObjectsEventedState(true); canvas.remove(this.zoomArea); this.zoomArea = null; } /** * Start zoom drawing mode */ start() { this.zoomArea = null; this._startPoint = null; this._startHandPoint = null; } /** * Stop zoom drawing mode */ end() { this.endZoomInMode(); this.endHandMode(); } /** * Start hand mode */ startHandMode() { this.endZoomInMode(); this.zoomMode = zoomModes.HAND; const canvas = this.getCanvas(); this._changeObjectsEventedState(false); canvas.discardActiveObject(); canvas.off('mouse:down', this._listeners.startHand); canvas.on('mouse:down', this._listeners.startHand); canvas.selection = false; canvas.defaultCursor = 'grab'; canvas.fire(HAND_STARTED); } /** * Stop hand mode */ endHandMode() { this.zoomMode = zoomModes.DEFAULT; const canvas = this.getCanvas(); this._changeObjectsEventedState(true); canvas.off('mouse:down', this._listeners.startHand); canvas.selection = true; canvas.defaultCursor = 'auto'; this._startHandPoint = null; canvas.fire(HAND_STOPPED); } /** * onMousedown handler in fabric canvas * @param {{target: fabric.Object, e: MouseEvent}} fEvent - Fabric event * @private */ _onMouseDownWithZoomMode({ target, e }) { if (target) { return; } const canvas = this.getCanvas(); canvas.selection = false; this._startPoint = canvas.getPointer(e); this.zoomArea.set({ width: 0, height: 0 }); const { moveZoom, stopZoom } = this._listeners; canvas.on({ 'mouse:move': moveZoom, 'mouse:up': stopZoom, }); } /** * onMousemove handler in fabric canvas * @param {{e: MouseEvent}} fEvent - Fabric event * @private */ _onMouseMoveWithZoomMode({ e }) { const canvas = this.getCanvas(); const pointer = canvas.getPointer(e); const { x, y } = pointer; const { zoomArea, _startPoint } = this; const deltaX = Math.abs(x - _startPoint.x); const deltaY = Math.abs(y - _startPoint.y); if (deltaX + deltaY > MOUSE_MOVE_THRESHOLD) { canvas.remove(zoomArea); zoomArea.set(this._calcRectDimensionFromPoint(x, y)); canvas.add(zoomArea); } } /** * Get rect dimension setting from Canvas-Mouse-Position(x, y) * @param {number} x - Canvas-Mouse-Position x * @param {number} y - Canvas-Mouse-Position Y * @returns {{left: number, top: number, width: number, height: number}} * @private */ _calcRectDimensionFromPoint(x, y) { const canvas = this.getCanvas(); const canvasWidth = canvas.getWidth(); const canvasHeight = canvas.getHeight(); const { x: startX, y: startY } = this._startPoint; const { min } = Math; const left = min(startX, x); const top = min(startY, y); const width = clamp(x, startX, canvasWidth) - left; // (startX <= x(mouse) <= canvasWidth) - left const height = clamp(y, startY, canvasHeight) - top; // (startY <= y(mouse) <= canvasHeight) - top return { left, top, width, height }; } /** * onMouseup handler in fabric canvas * @private */ _onMouseUpWithZoomMode() { let { zoomLevel } = this; const { zoomArea } = this; const { moveZoom, stopZoom } = this._listeners; const canvas = this.getCanvas(); const center = this._getCenterPoint(); const { x, y } = center; if (!this._isMaxZoomLevel()) { this._centerPoints.push({ x, y, prevZoomLevel: zoomLevel, zoomLevel: zoomLevel + 1, }); zoomLevel += 1; canvas.zoomToPoint({ x, y }, zoomLevel); this._fireZoomChanged(canvas, zoomLevel); this.zoomLevel = zoomLevel; } canvas.off({ 'mouse:move': moveZoom, 'mouse:up': stopZoom, }); canvas.remove(zoomArea); this._startPoint = null; } /** * Get center point * @returns {{x: number, y: number}} * @private */ _getCenterPoint() { const { left, top, width, height } = this.zoomArea; const { x, y } = this._startPoint; const { aspectRatio } = this; if (width < MOUSE_MOVE_THRESHOLD && height < MOUSE_MOVE_THRESHOLD) { return { x, y }; } return width > height ? { x: left + (aspectRatio * height) / 2, y: top + height / 2 } : { x: left + width / 2, y: top + width / aspectRatio / 2 }; } /** * Zoom the canvas * @param {{x: number, y: number}} center - center of zoom * @param {?number} zoomLevel - zoom level */ zoom({ x, y }, zoomLevel = this.zoomLevel) { const canvas = this.getCanvas(); const centerPoints = this._centerPoints; for (let i = centerPoints.length - 1; i >= 0; i -= 1) { if (centerPoints[i].zoomLevel < zoomLevel) { break; } const { x: prevX, y: prevY, prevZoomLevel } = centerPoints.pop(); canvas.zoomToPoint({ x: prevX, y: prevY }, prevZoomLevel); this.zoomLevel = prevZoomLevel; } canvas.zoomToPoint({ x, y }, zoomLevel); if (!this._isDefaultZoomLevel(zoomLevel)) { this._centerPoints.push({ x, y, zoomLevel, prevZoomLevel: this.zoomLevel, }); } this.zoomLevel = zoomLevel; this._fireZoomChanged(canvas, zoomLevel); } /** * Zoom out one step */ zoomOut() { const centerPoints = this._centerPoints; if (!centerPoints.length) { return; } const canvas = this.getCanvas(); const point = centerPoints.pop(); const { x, y, prevZoomLevel } = point; if (this._isDefaultZoomLevel(prevZoomLevel)) { canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); } else { canvas.zoomToPoint({ x, y }, prevZoomLevel); } this.zoomLevel = prevZoomLevel; this._fireZoomChanged(canvas, this.zoomLevel); } /** * Zoom reset */ resetZoom() { const canvas = this.getCanvas(); canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); this.zoomLevel = DEFAULT_ZOOM_LEVEL; this._centerPoints = []; this._fireZoomChanged(canvas, this.zoomLevel); } /** * Whether zoom level is max (5.0) * @returns {boolean} * @private */ _isMaxZoomLevel() { return this.zoomLevel >= 5.0; } /** * Move point of zoom * @param {{x: number, y: number}} delta - move amount * @private */ _movePointOfZoom({ x: deltaX, y: deltaY }) { const centerPoints = this._centerPoints; if (!centerPoints.length) { return; } const canvas = this.getCanvas(); const { zoomLevel } = this; const point = centerPoints.pop(); const { x: originX, y: originY, prevZoomLevel } = point; const x = originX - deltaX; const y = originY - deltaY; canvas.zoomToPoint({ x: originX, y: originY }, prevZoomLevel); canvas.zoomToPoint({ x, y }, zoomLevel); centerPoints.push({ x, y, prevZoomLevel, zoomLevel }); this._fireZoomChanged(canvas, zoomLevel); } /** * onMouseDown handler in fabric canvas * @param {{target: fabric.Object, e: MouseEvent}} fEvent - Fabric event * @private */ _onMouseDownWithHandMode({ target, e }) { if (target) { return; } const canvas = this.getCanvas(); if (this.zoomLevel <= DEFAULT_ZOOM_LEVEL) { return; } canvas.selection = false; this._startHandPoint = canvas.getPointer(e); const { moveHand, stopHand } = this._listeners; canvas.on({ 'mouse:move': moveHand, 'mouse:up': stopHand, }); } /** * onMouseMove handler in fabric canvas * @param {{e: MouseEvent}} fEvent - Fabric event * @private */ _onMouseMoveWithHandMode({ e }) { const canvas = this.getCanvas(); const { x, y } = canvas.getPointer(e); const deltaX = x - this._startHandPoint.x; const deltaY = y - this._startHandPoint.y; this._movePointOfZoom({ x: deltaX, y: deltaY }); } /** * onMouseUp handler in fabric canvas * @private */ _onMouseUpWithHandMode() { const canvas = this.getCanvas(); const { moveHand, stopHand } = this._listeners; canvas.off({ 'mouse:move': moveHand, 'mouse:up': stopHand, }); this._startHandPoint = null; } /** * onChangeZoom handler in fabric canvas * @private */ _changeScrollState({ viewport, zoomLevel }) { const canvas = this.getCanvas(); canvas.remove(this._verticalScroll); canvas.remove(this._horizontalScroll); if (this._isDefaultZoomLevel(zoomLevel)) { return; } const canvasWidth = canvas.width; const canvasHeight = canvas.height; const { tl, tr, bl } = viewport; const viewportWidth = tr.x - tl.x; const viewportHeight = bl.y - tl.y; const horizontalScrollWidth = (viewportWidth * viewportWidth) / canvasWidth; const horizontalScrollHeight = viewportHeight * DEFAULT_HORIZONTAL_SCROLL_RATIO.SIZE; const horizontalScrollLeft = clamp( tl.x + (tl.x / canvasWidth) * viewportWidth, tl.x, tr.x - horizontalScrollWidth ); const horizontalScrollMargin = viewportHeight * DEFAULT_HORIZONTAL_SCROLL_RATIO.MARGIN; const horizontalScrollBorderRadius = viewportHeight * DEFAULT_HORIZONTAL_SCROLL_RATIO.BORDER_RADIUS; this._horizontalScroll.set({ left: horizontalScrollLeft, top: bl.y - horizontalScrollHeight - horizontalScrollMargin, width: horizontalScrollWidth, height: horizontalScrollHeight, rx: horizontalScrollBorderRadius, ry: horizontalScrollBorderRadius, }); const verticalScrollWidth = viewportWidth * DEFAULT_VERTICAL_SCROLL_RATIO.SIZE; const verticalScrollHeight = (viewportHeight * viewportHeight) / canvasHeight; const verticalScrollTop = clamp( tl.y + (tl.y / canvasHeight) * viewportHeight, tr.y, bl.y - verticalScrollHeight ); const verticalScrollMargin = viewportWidth * DEFAULT_VERTICAL_SCROLL_RATIO.MARGIN; const verticalScrollBorderRadius = viewportWidth * DEFAULT_VERTICAL_SCROLL_RATIO.BORDER_RADIUS; this._verticalScroll.set({ left: tr.x - verticalScrollWidth - verticalScrollMargin, top: verticalScrollTop, width: verticalScrollWidth, height: verticalScrollHeight, rx: verticalScrollBorderRadius, ry: verticalScrollBorderRadius, }); this._addScrollBar(); } /** * Change objects 'evented' state * @param {boolean} [evented=true] - objects 'evented' state */ _changeObjectsEventedState(evented = true) { const canvas = this.getCanvas(); canvas.forEachObject((obj) => { // {@link http://fabricjs.com/docs/fabric.Object.html#evented} obj.evented = evented; }); } /** * Add scroll bar and set remove timer */ _addScrollBar() { const canvas = this.getCanvas(); canvas.add(this._horizontalScroll); canvas.add(this._verticalScroll); if (this.scrollBarTid) { clearTimeout(this.scrollBarTid); } this.scrollBarTid = setTimeout(() => { canvas.remove(this._horizontalScroll); canvas.remove(this._verticalScroll); }, 3000); } /** * Check zoom level is default zoom level (1.0) * @param {number} zoomLevel - zoom level * @returns {boolean} - whether zoom level is 1.0 */ _isDefaultZoomLevel(zoomLevel) { return zoomLevel === DEFAULT_ZOOM_LEVEL; } /** * Fire 'zoomChanged' event * @param {fabric.Canvas} canvas - fabric canvas * @param {number} zoomLevel - 'zoomChanged' event params */ _fireZoomChanged(canvas, zoomLevel) { canvas.fire(ZOOM_CHANGED, { viewport: canvas.calcViewportBoundaries(), zoomLevel }); } /** * Get zoom mode */ get mode() { return this.zoomMode; } } export default Zoom;