import { fabric } from 'fabric'; import extend from 'tui-code-snippet/object/extend'; import isExisty from 'tui-code-snippet/type/isExisty'; import forEach from 'tui-code-snippet/collection/forEach'; import Component from '@/interface/component'; import { stamp } from '@/util'; import { componentNames, eventNames as events, fObjectOptions } from '@/consts'; const defaultStyles = { fill: '#000000', left: 0, top: 0, }; const resetStyles = { fill: '#000000', fontStyle: 'normal', fontWeight: 'normal', textAlign: 'tie-text-align-left', underline: false, }; const DBCLICK_TIME = 500; /** * Text * @class Text * @param {Graphics} graphics - Graphics instance * @extends {Component} * @ignore */ class Text extends Component { constructor(graphics) { super(componentNames.TEXT, graphics); /** * Default text style * @type {Object} */ this._defaultStyles = defaultStyles; /** * Selected state * @type {boolean} */ this._isSelected = false; /** * Selected text object * @type {Object} */ this._selectedObj = {}; /** * Editing text object * @type {Object} */ this._editingObj = {}; /** * Listeners for fabric event * @type {Object} */ this._listeners = { mousedown: this._onFabricMouseDown.bind(this), select: this._onFabricSelect.bind(this), selectClear: this._onFabricSelectClear.bind(this), scaling: this._onFabricScaling.bind(this), textChanged: this._onFabricTextChanged.bind(this), }; /** * Textarea element for editing * @type {HTMLElement} */ this._textarea = null; /** * Ratio of current canvas * @type {number} */ this._ratio = 1; /** * Last click time * @type {Date} */ this._lastClickTime = new Date().getTime(); /** * Text object infos before editing * @type {Object} */ this._editingObjInfos = {}; /** * Previous state of editing * @type {boolean} */ this.isPrevEditing = false; } /** * Start input text mode */ start() { const canvas = this.getCanvas(); canvas.selection = false; canvas.defaultCursor = 'text'; canvas.on({ 'mouse:down': this._listeners.mousedown, 'selection:created': this._listeners.select, 'selection:updated': this._listeners.select, 'before:selection:cleared': this._listeners.selectClear, 'object:scaling': this._listeners.scaling, 'text:changed': this._listeners.textChanged, }); canvas.forEachObject((obj) => { if (obj.type === 'i-text') { this.adjustOriginPosition(obj, 'start'); } }); this.setCanvasRatio(); } /** * End input text mode */ end() { const canvas = this.getCanvas(); canvas.selection = true; canvas.defaultCursor = 'default'; canvas.forEachObject((obj) => { if (obj.type === 'i-text') { if (obj.text === '') { canvas.remove(obj); } else { this.adjustOriginPosition(obj, 'end'); } } }); canvas.off({ 'mouse:down': this._listeners.mousedown, 'selection:created': this._listeners.select, 'selection:updated': this._listeners.select, 'before:selection:cleared': this._listeners.selectClear, 'object:selected': this._listeners.select, 'object:scaling': this._listeners.scaling, 'text:changed': this._listeners.textChanged, }); } /** * Adjust the origin position * @param {fabric.Object} text - text object * @param {string} editStatus - 'start' or 'end' */ adjustOriginPosition(text, editStatus) { let [originX, originY] = ['center', 'center']; if (editStatus === 'start') { [originX, originY] = ['left', 'top']; } const { x: left, y: top } = text.getPointByOrigin(originX, originY); text.set({ left, top, originX, originY, }); text.setCoords(); } /** * Add new text on canvas image * @param {string} text - Initial input text * @param {Object} options - Options for generating text * @param {Object} [options.styles] Initial styles * @param {string} [options.styles.fill] Color * @param {string} [options.styles.fontFamily] Font type for text * @param {number} [options.styles.fontSize] Size * @param {string} [options.styles.fontStyle] Type of inclination (normal / italic) * @param {string} [options.styles.fontWeight] Type of thicker or thinner looking (normal / bold) * @param {string} [options.styles.textAlign] Type of text align (left / center / right) * @param {string} [options.styles.textDecoration] Type of line (underline / line-through / overline) * @param {{x: number, y: number}} [options.position] - Initial position * @returns {Promise} */ add(text, options) { return new Promise((resolve) => { const canvas = this.getCanvas(); let newText = null; let selectionStyle = fObjectOptions.SELECTION_STYLE; let styles = this._defaultStyles; this._setInitPos(options.position); if (options.styles) { styles = extend(styles, options.styles); } if (!isExisty(options.autofocus)) { options.autofocus = true; } newText = new fabric.IText(text, styles); selectionStyle = extend({}, selectionStyle, { originX: 'left', originY: 'top', }); newText.set(selectionStyle); newText.on({ mouseup: this._onFabricMouseUp.bind(this), }); canvas.add(newText); if (options.autofocus) { newText.enterEditing(); newText.selectAll(); } if (!canvas.getActiveObject()) { canvas.setActiveObject(newText); } this.isPrevEditing = true; resolve(this.graphics.createObjectProperties(newText)); }); } /** * Change text of activate object on canvas image * @param {Object} activeObj - Current selected text object * @param {string} text - Changed text * @returns {Promise} */ change(activeObj, text) { return new Promise((resolve) => { activeObj.set('text', text); this.getCanvas().renderAll(); resolve(); }); } /** * Set style * @param {Object} activeObj - Current selected text object * @param {Object} styleObj - Initial styles * @param {string} [styleObj.fill] Color * @param {string} [styleObj.fontFamily] Font type for text * @param {number} [styleObj.fontSize] Size * @param {string} [styleObj.fontStyle] Type of inclination (normal / italic) * @param {string} [styleObj.fontWeight] Type of thicker or thinner looking (normal / bold) * @param {string} [styleObj.textAlign] Type of text align (left / center / right) * @param {string} [styleObj.textDecoration] Type of line (underline / line-through / overline) * @returns {Promise} */ setStyle(activeObj, styleObj) { return new Promise((resolve) => { forEach( styleObj, (val, key) => { if (activeObj[key] === val && key !== 'fontSize') { styleObj[key] = resetStyles[key] || ''; } }, this ); if ('textDecoration' in styleObj) { extend(styleObj, this._getTextDecorationAdaptObject(styleObj.textDecoration)); } activeObj.set(styleObj); this.getCanvas().renderAll(); resolve(); }); } /** * Get the text * @param {Object} activeObj - Current selected text object * @returns {String} text */ getText(activeObj) { return activeObj.text; } /** * Set infos of the current selected object * @param {fabric.Text} obj - Current selected text object * @param {boolean} state - State of selecting */ setSelectedInfo(obj, state) { this._selectedObj = obj; this._isSelected = state; } /** * Whether object is selected or not * @returns {boolean} State of selecting */ isSelected() { return this._isSelected; } /** * Get current selected text object * @returns {fabric.Text} Current selected text object */ getSelectedObj() { return this._selectedObj; } /** * Set ratio value of canvas */ setCanvasRatio() { const canvasElement = this.getCanvasElement(); const cssWidth = parseInt(canvasElement.style.maxWidth, 10); const originWidth = canvasElement.width; this._ratio = originWidth / cssWidth; } /** * Get ratio value of canvas * @returns {number} Ratio value */ getCanvasRatio() { return this._ratio; } /** * Get text decoration adapt object * @param {string} textDecoration - text decoration option string * @returns {object} adapt object for override */ _getTextDecorationAdaptObject(textDecoration) { return { underline: textDecoration === 'underline', linethrough: textDecoration === 'line-through', overline: textDecoration === 'overline', }; } /** * Set initial position on canvas image * @param {{x: number, y: number}} [position] - Selected position * @private */ _setInitPos(position) { position = position || this.getCanvasImage().getCenterPoint(); this._defaultStyles.left = position.x; this._defaultStyles.top = position.y; } /** * Input event handler * @private */ _onInput() { const ratio = this.getCanvasRatio(); const obj = this._editingObj; const textareaStyle = this._textarea.style; textareaStyle.width = `${Math.ceil(obj.width / ratio)}px`; textareaStyle.height = `${Math.ceil(obj.height / ratio)}px`; } /** * Keydown event handler * @private */ _onKeyDown() { const ratio = this.getCanvasRatio(); const obj = this._editingObj; const textareaStyle = this._textarea.style; setTimeout(() => { obj.text(this._textarea.value); textareaStyle.width = `${Math.ceil(obj.width / ratio)}px`; textareaStyle.height = `${Math.ceil(obj.height / ratio)}px`; }, 0); } /** * Blur event handler * @private */ _onBlur() { const ratio = this.getCanvasRatio(); const editingObj = this._editingObj; const editingObjInfos = this._editingObjInfos; const textContent = this._textarea.value; let transWidth = editingObj.width / ratio - editingObjInfos.width / ratio; let transHeight = editingObj.height / ratio - editingObjInfos.height / ratio; if (ratio === 1) { transWidth /= 2; transHeight /= 2; } this._textarea.style.display = 'none'; editingObj.set({ left: editingObjInfos.left + transWidth, top: editingObjInfos.top + transHeight, }); if (textContent.length) { this.getCanvas().add(editingObj); const params = { id: stamp(editingObj), type: editingObj.type, text: textContent, }; this.fire(events.TEXT_CHANGED, params); } } /** * Scroll event handler * @private */ _onScroll() { this._textarea.scrollLeft = 0; this._textarea.scrollTop = 0; } /** * Fabric scaling event handler * @param {fabric.Event} fEvent - Current scaling event on selected object * @private */ _onFabricScaling(fEvent) { const obj = fEvent.target; obj.fontSize = obj.fontSize * obj.scaleY; obj.scaleX = 1; obj.scaleY = 1; } /** * textChanged event handler * @param {{target: fabric.Object}} props - changed text object * @private */ _onFabricTextChanged(props) { this.fire(events.TEXT_CHANGED, props.target); } /** * onSelectClear handler in fabric canvas * @param {{target: fabric.Object, e: MouseEvent}} fEvent - Fabric event * @private */ _onFabricSelectClear(fEvent) { const obj = this.getSelectedObj(); this.isPrevEditing = true; this.setSelectedInfo(fEvent.target, false); if (obj) { // obj is empty object at initial time, will be set fabric object if (obj.text === '') { this.getCanvas().remove(obj); } } } /** * onSelect handler in fabric canvas * @param {{target: fabric.Object, e: MouseEvent}} fEvent - Fabric event * @private */ _onFabricSelect(fEvent) { this.isPrevEditing = true; this.setSelectedInfo(fEvent.target, true); } /** * Fabric 'mousedown' event handler * @param {fabric.Event} fEvent - Current mousedown event on selected object * @private */ _onFabricMouseDown(fEvent) { const obj = fEvent.target; if (obj && !obj.isType('text')) { return; } if (this.isPrevEditing) { this.isPrevEditing = false; return; } this._fireAddText(fEvent); } /** * Fire 'addText' event if object is not selected. * @param {fabric.Event} fEvent - Current mousedown event on selected object * @private */ _fireAddText(fEvent) { const obj = fEvent.target; const e = fEvent.e || {}; const originPointer = this.getCanvas().getPointer(e); if (!obj) { this.fire(events.ADD_TEXT, { originPosition: { x: originPointer.x, y: originPointer.y, }, clientPosition: { x: e.clientX || 0, y: e.clientY || 0, }, }); } } /** * Fabric mouseup event handler * @param {fabric.Event} fEvent - Current mousedown event on selected object * @private */ _onFabricMouseUp(fEvent) { const { target } = fEvent; const newClickTime = new Date().getTime(); if (this._isDoubleClick(newClickTime) && !target.isEditing) { target.enterEditing(); } if (target.isEditing) { this.fire(events.TEXT_EDITING); // fire editing text event } this._lastClickTime = newClickTime; } /** * Get state of firing double click event * @param {Date} newClickTime - Current clicked time * @returns {boolean} Whether double clicked or not * @private */ _isDoubleClick(newClickTime) { return newClickTime - this._lastClickTime < DBCLICK_TIME; } } export default Text;