diff --git a/package.json b/package.json index 587e01acdbe..0584ac580da 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:visual:coverage": "nyc --silent --no-clean qunit test/node_test_setup.js test/lib test/visual", "coverage:report": "nyc report --reporter=lcov --reporter=text", "lint": "eslint --config .eslintrc.json src", + "lint:fix": "eslint --fix --config .eslintrc.json src", "lint_tests": "eslint test/unit --config .eslintrc_tests && eslint test/visual --config .eslintrc_tests", "all": "npm run build && npm run test -- --all && npm run lint && npm run lint_tests && npm run export", "testem": "testem .", diff --git a/src/canvas.class.js b/src/canvas.class.js index bb64a6ab352..033aa56a49d 100644 --- a/src/canvas.class.js +++ b/src/canvas.class.js @@ -37,8 +37,42 @@ * @fires dragover * @fires dragenter * @fires dragleave - * @fires drop:before before drop event. same native event. This is added to handle edge cases + * @fires drag:enter object drag enter + * @fires drag:leave object drag leave + * @fires drop:before before drop event. Prepare for the drop event (same native event). * @fires drop + * @fires drop:after after drop event. Run logic on canvas after event has been accepted/declined (same native event). + * @example + * let a: fabric.Object, b: fabric.Object; + * let flag = false; + * canvas.add(a, b); + * a.on('drop:before', opt => { + * // we want a to accept the drop even though it's below b in the stack + * flag = this.canDrop(opt.e); + * }); + * b.canDrop = function(e) { + * !flag && this.callSuper('canDrop', e); + * } + * b.on('dragover', opt => b.set('fill', opt.dropTarget === b ? 'pink' : 'black')); + * a.on('drop', opt => { + * opt.e.defaultPrevented // drop occured + * opt.didDrop // drop occured on canvas + * opt.target // drop target + * opt.target !== a && a.set('text', 'I lost'); + * }); + * canvas.on('drop:after', opt => { + * // inform user who won + * if(!opt.e.defaultPrevented) { + * // no winners + * } + * else if(!opt.didDrop) { + * // my objects didn't win, some other lucky object + * } + * else { + * // we have a winner it's opt.target!! + * } + * }) + * * @fires after:render at the end of the render process, receives the context in the callback * @fires before:render at start the render process, receives the context in the callback * @@ -1024,6 +1058,7 @@ this._copyCanvasStyle(lowerCanvasEl, upperCanvasEl); this._applyCanvasStyle(upperCanvasEl); + upperCanvasEl.setAttribute('draggable', 'true'); this.contextTop = upperCanvasEl.getContext('2d'); }, diff --git a/src/mixins/canvas_events.mixin.js b/src/mixins/canvas_events.mixin.js index 8dea5193241..b6b798e2f47 100644 --- a/src/mixins/canvas_events.mixin.js +++ b/src/mixins/canvas_events.mixin.js @@ -50,6 +50,8 @@ functor(canvasElement, 'wheel', this._onMouseWheel); functor(canvasElement, 'contextmenu', this._onContextMenu); functor(canvasElement, 'dblclick', this._onDoubleClick); + functor(canvasElement, 'dragstart', this._onDragStart); + functor(canvasElement, 'dragend', this._onDragEnd); functor(canvasElement, 'dragover', this._onDragOver); functor(canvasElement, 'dragenter', this._onDragEnter); functor(canvasElement, 'dragleave', this._onDragLeave); @@ -103,9 +105,12 @@ this._onMouseEnter = this._onMouseEnter.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onDoubleClick = this._onDoubleClick.bind(this); + this._onDragStart = this._onDragStart.bind(this); + this._onDragEnd = this._onDragEnd.bind(this); + this._onDragProgress = this._onDragProgress.bind(this); this._onDragOver = this._onDragOver.bind(this); - this._onDragEnter = this._simpleEventHandler.bind(this, 'dragenter'); - this._onDragLeave = this._simpleEventHandler.bind(this, 'dragleave'); + this._onDragEnter = this._onDragEnter.bind(this); + this._onDragLeave = this._onDragLeave.bind(this); this._onDrop = this._onDrop.bind(this); this.eventsBound = true; }, @@ -206,27 +211,185 @@ this.__onLongPress && this.__onLongPress(e, self); }, + /** + * supports native like text dragging + * @private + * @param {DragEvent} e + */ + _onDragStart: function (e) { + var activeObject = this.getActiveObject(); + if (activeObject && typeof activeObject.onDragStart === 'function' && activeObject.onDragStart(e)) { + this._dragSource = activeObject; + var options = { e: e, target: activeObject }; + this.fire('dragstart', options); + activeObject.fire('dragstart', options); + addListener(this.upperCanvasEl, 'drag', this._onDragProgress); + return; + } + e.preventDefault(); + e.stopPropagation(); + }, + + /** + * @private + */ + _renderDragEffects: function (e, source, target) { + var ctx = this.contextTop; + if (source) { + source.clearContextTop(true); + source.renderDragSourceEffect(e); + } + if (target) { + if (target !== source) { + ctx.restore(); + ctx.save(); + target.clearContextTop(true); + } + target.renderDropTargetEffect(e); + } + ctx.restore(); + }, + + /** + * supports native like text dragging + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#finishing_a_drag + * @private + * @param {DragEvent} e + */ + _onDragEnd: function (e) { + var didDrop = e.dataTransfer.dropEffect !== 'none', + dropTarget = didDrop ? this._activeObject : undefined, + options = { + e: e, + target: this._dragSource, + subTargets: this.targets, + dragSource: this._dragSource, + didDrop: didDrop, + dropTarget: dropTarget + }; + removeListener(this.upperCanvasEl, 'drag', this._onDragProgress); + this.fire('dragend', options); + this._dragSource && this._dragSource.fire('dragend', options); + delete this._dragSource; + // we need to call mouse up synthetically because the browser won't + this._onMouseUp(e); + }, + + /** + * fire `drag` event on canvas and drag source + * @private + * @param {DragEvent} e + */ + _onDragProgress: function (e) { + var options = { + e: e, + dragSource: this._dragSource, + dropTarget: this._draggedoverTarget + }; + this.fire('drag', options); + this._dragSource && this._dragSource.fire('drag', options); + }, + /** * prevent default to allow drop event to be fired + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#specifying_drop_targets * @private * @param {Event} [e] Event object fired on Event.js shake */ - _onDragOver: function(e) { - e.preventDefault(); - var target = this._simpleEventHandler('dragover', e); - this._fireEnterLeaveEvents(target, e); + _onDragOver: function (e) { + var eventType = 'dragover', + target = this.findTarget(e), + targets = this.targets, + options = { + e: e, + target: target, + subTargets: targets, + dragSource: this._dragSource, + canDrop: false, + dropTarget: undefined + }, + dropTarget; + // fire on canvas + this.fire(eventType, options); + // make sure we fire dragenter events before dragover + // if dragleave is needed, object will not fire dragover so we don't need to trouble ourselves with it + this._fireEnterLeaveEvents(target, options); + if (target) { + // render drag selection before rendering target cursor for correct visuals + if (target.canDrop(e)) { dropTarget = target; }; + target.fire(eventType, options); + } + // propagate the event to subtargets + for (var i = 0; i < targets.length; i++) { + target = targets[i]; + // accept event only if previous targets didn't + if (!e.defaultPrevented && target.canDrop(e)) { + dropTarget = target; + } + target.fire(eventType, options); + } + // render drag effects now that relations between source and target is clear + this._renderDragEffects(e, this._dragSource, dropTarget); + }, + + /** + * fire `dragleave` on `dragover` targets + * @private + * @param {Event} [e] Event object fired on Event.js shake + */ + _onDragEnter: function (e) { + var target = this.findTarget(e); + var options = { + e: e, + target: target, + subTargets: this.targets, + dragSource: this._dragSource + }; + this.fire('dragenter', options); + // fire dragenter on targets + this._fireEnterLeaveEvents(target, options); + }, + + /** + * fire `dragleave` on `dragover` targets + * @private + * @param {Event} [e] Event object fired on Event.js shake + */ + _onDragLeave: function (e) { + var options = { + e: e, + target: this._draggedoverTarget, + subTargets: this.targets, + dragSource: this._dragSource + }; + this.fire('dragleave', options); + // fire dragleave on targets + this._fireEnterLeaveEvents(null, options); + // clear targets + this.targets = []; + this._hoveredTargets = []; }, /** - * `drop:before` is a an event that allow you to schedule logic + * `drop:before` is a an event that allows you to schedule logic * before the `drop` event. Prefer `drop` event always, but if you need * to run some drop-disabling logic on an event, since there is no way * to handle event handlers ordering, use `drop:before` + * @private * @param {Event} e */ _onDrop: function (e) { - this._simpleEventHandler('drop:before', e); - return this._simpleEventHandler('drop', e); + var options = this._simpleEventHandler('drop:before', e, { dragSource: this._dragSource }); + // will be set by the drop target + options.didDrop = false; + // will be set by the drop target, used in case options.target refuses the drop + options.dropTarget = undefined; + // fire `drop` + this._basicEventHandler('drop', options); + // inform canvas of the drop + // we do this because canvas was unaware of what happened at the time the `drop` event was fired on it + // use for side effects + this.fire('drop:after', options); }, /** @@ -234,12 +397,12 @@ * @param {Event} e Event object fired on mousedown */ _onContextMenu: function (e) { - this._simpleEventHandler('contextmenu:before', e); + var options = this._simpleEventHandler('contextmenu:before', e); if (this.stopContextMenu) { e.stopPropagation(); e.preventDefault(); } - this._simpleEventHandler('contextmenu', e); + this._basicEventHandler('contextmenu', options); return false; }, @@ -375,7 +538,9 @@ * @param {Event} e Event object fired on mousemove */ _onMouseMove: function (e) { - !this.allowTouchScrolling && e.preventDefault && e.preventDefault(); + var activeObject = this.getActiveObject(); + !this.allowTouchScrolling && (!activeObject || !activeObject.__isDragging) + && e.preventDefault && e.preventDefault(); this.__onMouseMove(e); }, @@ -511,25 +676,26 @@ * Handle event firing for target and subtargets * @param {Event} e event from mouse * @param {String} eventType event to fire (up, down or move) - * @return {Fabric.Object} target return the the target found, for internal reasons. + * @param {object} [data] event data overrides + * @return {object} options */ - _simpleEventHandler: function(eventType, e) { - var target = this.findTarget(e), - targets = this.targets, - options = { - e: e, - target: target, - subTargets: targets, - }; + _simpleEventHandler: function(eventType, e, data) { + var target = this.findTarget(e), subTargets = this.targets || []; + return this._basicEventHandler(eventType, Object.assign({}, { + e: e, + target: target, + subTargets: subTargets, + }, data)); + }, + + _basicEventHandler: function (eventType, options) { + var target = options.target, subTargets = options.subTargets; this.fire(eventType, options); target && target.fire(eventType, options); - if (!targets) { - return target; - } - for (var i = 0; i < targets.length; i++) { - targets[i].fire(eventType, options); + for (var i = 0; i < subTargets.length; i++) { + subTargets[i].fire(eventType, options); } - return target; + return options; }, /** @@ -829,7 +995,7 @@ _hoveredTargets = this._hoveredTargets, targets = this.targets, length = Math.max(_hoveredTargets.length, targets.length); - this.fireSyntheticInOutEvents(target, e, { + this.fireSyntheticInOutEvents(target, { e: e }, { oldTarget: _hoveredTarget, evtOut: 'mouseout', canvasEvtOut: 'mouse:out', @@ -837,7 +1003,7 @@ canvasEvtIn: 'mouse:over', }); for (var i = 0; i < length; i++){ - this.fireSyntheticInOutEvents(targets[i], e, { + this.fireSyntheticInOutEvents(targets[i], { e: e }, { oldTarget: _hoveredTargets[i], evtOut: 'mouseout', evtIn: 'mouseover', @@ -850,21 +1016,23 @@ /** * Manage the dragEnter, dragLeave events for the fabric objects on the canvas * @param {Fabric.Object} target the target where the target from the onDrag event - * @param {Event} e Event object fired on ondrag + * @param {Object} data Event object fired on dragover * @private */ - _fireEnterLeaveEvents: function(target, e) { + _fireEnterLeaveEvents: function(target, data) { var _draggedoverTarget = this._draggedoverTarget, _hoveredTargets = this._hoveredTargets, targets = this.targets, length = Math.max(_hoveredTargets.length, targets.length); - this.fireSyntheticInOutEvents(target, e, { + this.fireSyntheticInOutEvents(target, data, { oldTarget: _draggedoverTarget, evtOut: 'dragleave', evtIn: 'dragenter', + canvasEvtIn: 'drag:enter', + canvasEvtOut: 'drag:leave', }); for (var i = 0; i < length; i++) { - this.fireSyntheticInOutEvents(targets[i], e, { + this.fireSyntheticInOutEvents(targets[i], data, { oldTarget: _hoveredTargets[i], evtOut: 'dragleave', evtIn: 'dragenter', @@ -876,7 +1044,7 @@ /** * Manage the synthetic in/out events for the fabric objects on the canvas * @param {Fabric.Object} target the target where the target from the supported events - * @param {Event} e Event object fired + * @param {Object} data Event object fired * @param {Object} config configuration for the function to work * @param {String} config.targetName property on the canvas where the old target is stored * @param {String} [config.canvasEvtOut] name of the event to fire at canvas level for out @@ -885,12 +1053,12 @@ * @param {String} config.evtIn name of the event to fire for in * @private */ - fireSyntheticInOutEvents: function(target, e, config) { + fireSyntheticInOutEvents: function(target, data, config) { var inOpt, outOpt, oldTarget = config.oldTarget, outFires, inFires, targetChanged = oldTarget !== target, canvasEvtIn = config.canvasEvtIn, canvasEvtOut = config.canvasEvtOut; if (targetChanged) { - inOpt = { e: e, target: target, previousTarget: oldTarget }; - outOpt = { e: e, target: oldTarget, nextTarget: target }; + inOpt = Object.assign({}, data, { target: target, previousTarget: oldTarget }); + outOpt = Object.assign({}, data, { target: oldTarget, nextTarget: target }); } inFires = target && targetChanged; outFires = oldTarget && targetChanged; diff --git a/src/mixins/canvas_serialization.mixin.js b/src/mixins/canvas_serialization.mixin.js index 0709a529a55..17d1a66c177 100644 --- a/src/mixins/canvas_serialization.mixin.js +++ b/src/mixins/canvas_serialization.mixin.js @@ -3,9 +3,9 @@ fabric.util.object.extend(fabric.StaticCanvas.prototype, /** @lends fabric.Stati /** * Populates canvas with data from the specified JSON. * JSON format must conform to the one of {@link fabric.Canvas#toJSON} - * + * * **IMPORTANT**: It is recommended to abort loading tasks before calling this method to prevent race conditions and unnecessary networking - * + * * @param {String|Object} json JSON string or object * @param {Function} [reviver] Method for further parsing of JSON elements, called after each fabric object created. * @param {Object} [options] options @@ -23,7 +23,7 @@ fabric.util.object.extend(fabric.StaticCanvas.prototype, /** @lends fabric.Stati * }).then((canvas) => { * ... canvas is restored, add your code. * }); - * + * */ loadFromJSON: function (json, reviver, options) { if (!json) { diff --git a/src/mixins/itext_behavior.mixin.js b/src/mixins/itext_behavior.mixin.js index 275ca744503..a13c7caec87 100644 --- a/src/mixins/itext_behavior.mixin.js +++ b/src/mixins/itext_behavior.mixin.js @@ -11,6 +11,16 @@ this.initCursorSelectionHandlers(); this.initDoubleClickSimulation(); this.mouseMoveHandler = this.mouseMoveHandler.bind(this); + this.dragEnterHandler = this.dragEnterHandler.bind(this); + this.dragOverHandler = this.dragOverHandler.bind(this); + this.dragLeaveHandler = this.dragLeaveHandler.bind(this); + this.dragEndHandler = this.dragEndHandler.bind(this); + this.dropHandler = this.dropHandler.bind(this); + this.on('dragenter', this.dragEnterHandler); + this.on('dragover', this.dragOverHandler); + this.on('dragleave', this.dragLeaveHandler); + this.on('dragend', this.dragEndHandler); + this.on('drop', this.dropHandler); }, onDeselect: function() { @@ -87,10 +97,7 @@ * @private */ _animateCursor: function(obj, targetOpacity, duration, completeMethod) { - - var tickState; - - tickState = { + var tickState = { isAborted: false, abort: function() { this.isAborted = true; @@ -140,7 +147,6 @@ delay = restart ? 0 : this.cursorDelay; this.abortCursorAnimation(); - this._currentCursorOpacity = 1; if (delay) { this._cursorTimeout2 = setTimeout(function () { _this._tick(); @@ -152,24 +158,22 @@ }, /** - * Aborts cursor animation and clears all timeouts + * Aborts cursor animation, clears all timeouts and clear textarea context if necessary */ abortCursorAnimation: function() { - var shouldClear = this._currentTickState || this._currentTickCompleteState, - canvas = this.canvas; + var shouldClear = this._currentTickState || this._currentTickCompleteState; this._currentTickState && this._currentTickState.abort(); this._currentTickCompleteState && this._currentTickCompleteState.abort(); clearTimeout(this._cursorTimeout1); clearTimeout(this._cursorTimeout2); - this._currentCursorOpacity = 0; - // to clear just itext area we need to transform the context - // it may not be worth it - if (shouldClear && canvas) { - canvas.clearContext(canvas.contextTop || canvas.contextContainer); - } + this._currentCursorOpacity = 1; + // make sure we clear context even if instance is not editing + if (shouldClear) { + this.clearContextTop(); + } }, /** @@ -337,7 +341,6 @@ if (this.isEditing || !this.editable) { return; } - if (this.canvas) { this.canvas.calcOffset(); this.exitEditingOnOthers(this.canvas); @@ -417,6 +420,275 @@ } }, + /** + * Override to customize the drag image + * https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage + * @param {DragEvent} e + * @param {object} data + * @param {number} data.selectionStart + * @param {number} data.selectionEnd + * @param {string} data.text + * @param {string} data.value selected text + */ + setDragImage: function (e, data) { + var t = this.calcTransformMatrix(); + var flipFactor = new fabric.Point(this.flipX ? -1 : 1, this.flipY ? -1 : 1); + var boundaries = this._getCursorBoundaries(data.selectionStart); + var selectionPosition = new fabric.Point( + boundaries.left + boundaries.leftOffset, + boundaries.top + boundaries.topOffset + ).multiply(flipFactor); + var pos = fabric.util.transformPoint(selectionPosition, t); + var pointer = this.canvas.getPointer(e); + var diff = pointer.subtract(pos); + var enableRetinaScaling = this.canvas._isRetinaScaling(); + var retinaScaling = this.canvas.getRetinaScaling(); + var bbox = this.getBoundingRect(true); + var correction = pos.subtract(new fabric.Point(bbox.left, bbox.top)); + var offset = correction.add(diff).scalarMultiply(retinaScaling); + // prepare instance for drag image snapshot by making all non selected text invisible + var bgc = this.backgroundColor; + var styles = fabric.util.object.clone(this.styles, true); + delete this.backgroundColor; + var styleOverride = { + fill: 'transparent', + textBackgroundColor: 'transparent' + }; + this.setSelectionStyles(styleOverride, 0, data.selectionStart); + this.setSelectionStyles(styleOverride, data.selectionEnd, data.text.length); + var dragImage = this.toCanvasElement({ enableRetinaScaling: enableRetinaScaling }); + this.backgroundColor = bgc; + this.styles = styles; + // handle retina scaling + if (enableRetinaScaling && retinaScaling > 1) { + var c = fabric.util.createCanvasElement(); + c.width = dragImage.width / retinaScaling; + c.height = dragImage.height / retinaScaling; + var ctx = c.getContext('2d'); + ctx.scale(1 / retinaScaling, 1 / retinaScaling); + ctx.drawImage(dragImage, 0, 0); + dragImage = c; + } + this.__dragImageDisposer && this.__dragImageDisposer(); + this.__dragImageDisposer = function () { + dragImage.remove(); + }; + // position drag image offsecreen + fabric.util.setStyle(dragImage, { + position: 'absolute', + left: -dragImage.width + 'px', + border: 'none' + }); + fabric.document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, offset.x, offset.y); + }, + + /** + * support native like text dragging + * @private + * @param {DragEvent} e + * @returns {boolean} should handle event + */ + onDragStart: function (e) { + this.__dragStartFired = true; + if (this.__isDragging) { + var selection = this.__dragStartSelection = { + selectionStart: this.selectionStart, + selectionEnd: this.selectionEnd, + }; + var value = this._text.slice(selection.selectionStart, selection.selectionEnd).join(''); + var data = Object.assign({ text: this.text, value: value }, selection); + e.dataTransfer.setData('text/plain', value); + e.dataTransfer.setData('application/fabric', JSON.stringify({ + value: value, + styles: this.getSelectionStyles(selection.selectionStart, selection.selectionEnd, true) + })); + e.dataTransfer.effectAllowed = 'copyMove'; + this.setDragImage(e, data); + } + this.abortCursorAnimation(); + return this.__isDragging; + }, + + /** + * Override to customize drag and drop behavior + * @public + * @param {DragEvent} e + * @returns {boolean} + */ + canDrop: function (e) { + if (this.editable && !this.__corner) { + if (this.__isDragging && this.__dragStartSelection) { + // drag source trying to drop over itself + // allow dropping only outside of drag start selection + var index = this.getSelectionStartFromPointer(e); + var dragStartSelection = this.__dragStartSelection; + return index < dragStartSelection.selectionStart || index > dragStartSelection.selectionEnd; + } + return true; + } + return false; + }, + + /** + * support native like text dragging + * @private + * @param {object} options + * @param {DragEvent} options.e + */ + dragEnterHandler: function (options) { + var e = options.e; + var canDrop = !e.defaultPrevented && this.canDrop(e); + if (!this.__isDraggingOver && canDrop) { + this.__isDraggingOver = true; + } + }, + + /** + * support native like text dragging + * @private + * @param {object} options + * @param {DragEvent} options.e + */ + dragOverHandler: function (options) { + var e = options.e; + var canDrop = !e.defaultPrevented && this.canDrop(e); + if (!this.__isDraggingOver && canDrop) { + this.__isDraggingOver = true; + } + else if (this.__isDraggingOver && !canDrop) { + // drop state has changed + this.__isDraggingOver = false; + } + if (this.__isDraggingOver) { + // can be dropped, inform browser + e.preventDefault(); + // inform event subscribers + options.canDrop = true; + options.dropTarget = this; + // find cursor under the drag part. + } + }, + + /** + * support native like text dragging + * @private + */ + dragLeaveHandler: function () { + if (this.__isDraggingOver || this.__isDragging) { + this.__isDraggingOver = false; + } + }, + + /** + * support native like text dragging + * fired only on the drag source + * handle changes to the drag source in case of a drop on another object or a cancellation + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#finishing_a_drag + * @private + * @param {object} options + * @param {DragEvent} options.e + */ + dragEndHandler: function (options) { + var e = options.e; + if (this.__isDragging && this.__dragStartFired) { + // once the drop event finishes we check if we need to change the drag source + // if the drag source received the drop we bail out + if (this.__dragStartSelection) { + var selectionStart = this.__dragStartSelection.selectionStart; + var selectionEnd = this.__dragStartSelection.selectionEnd; + var dropEffect = e.dataTransfer.dropEffect; + if (dropEffect === 'none') { + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + this._updateTextarea(); + } + else { + this.clearContextTop(); + if (dropEffect === 'move') { + this.insertChars('', null, selectionStart, selectionEnd); + this.selectionStart = this.selectionEnd = selectionStart; + this.hiddenTextarea && (this.hiddenTextarea.value = this.text); + this._updateTextarea(); + this.fire('changed', { index: selectionStart, action: 'dragend' }); + this.canvas.fire('text:changed', { target: this }); + this.canvas.requestRenderAll(); + } + this.exitEditing(); + // disable mouse up logic + this.__lastSelected = false; + } + } + } + + this.__dragImageDisposer && this.__dragImageDisposer(); + delete this.__dragImageDisposer; + delete this.__dragStartSelection; + this.__isDraggingOver = false; + }, + + /** + * support native like text dragging + * + * Override the `text/plain | application/fabric` types of {@link DragEvent#dataTransfer} + * in order to change the drop value or to customize styling respectively, by listening to the `drop:before` event + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#performing_a_drop + * @private + * @param {object} options + * @param {DragEvent} options.e + */ + dropHandler: function (options) { + var e = options.e, didDrop = e.defaultPrevented; + this.__isDraggingOver = false; + // inform browser that the drop has been accepted + e.preventDefault(); + var insert = e.dataTransfer.getData('text/plain'); + if (insert && !didDrop) { + var insertAt = this.getSelectionStartFromPointer(e); + var data = e.dataTransfer.types.includes('application/fabric') ? + JSON.parse(e.dataTransfer.getData('application/fabric')) : + {}; + var styles = data.styles; + var trailing = insert[Math.max(0, insert.length - 1)]; + var selectionStartOffset = 0; + // drag and drop in same instance + if (this.__dragStartSelection) { + var selectionStart = this.__dragStartSelection.selectionStart; + var selectionEnd = this.__dragStartSelection.selectionEnd; + if (insertAt > selectionStart && insertAt <= selectionEnd) { + insertAt = selectionStart; + } + else if (insertAt > selectionEnd) { + insertAt -= selectionEnd - selectionStart; + } + this.insertChars('', null, selectionStart, selectionEnd); + // prevent `dragend` from handling event + delete this.__dragStartSelection; + } + // remove redundant line break + if (this._reNewline.test(trailing) + && (this._reNewline.test(this._text[insertAt]) || insertAt === this._text.length)) { + insert = insert.trimEnd(); + } + // inform subscribers + options.didDrop = true; + options.dropTarget = this; + // finalize + this.insertChars(insert, styles, insertAt); + // can this part be moved in an outside event? andrea to check. + this.canvas.setActiveObject(this); + this.enterEditing(); + this.selectionStart = Math.min(insertAt + selectionStartOffset, this._text.length); + this.selectionEnd = Math.min(this.selectionStart + insert.length, this._text.length); + this.hiddenTextarea && (this.hiddenTextarea.value = this.text); + this._updateTextarea(); + this.fire('changed', { index: insertAt + selectionStartOffset, action: 'drop' }); + this.canvas.fire('text:changed', { target: this }); + this.canvas.contextTopDirty = true; + this.canvas.requestRenderAll(); + } + }, + /** * @private */ @@ -621,7 +893,6 @@ this.hiddenTextarea = null; this.abortCursorAnimation(); this._restoreEditingProps(); - this._currentCursorOpacity = 0; if (this._shouldClearDimensionCache()) { this.initDimensions(); this.setCoords(); diff --git a/src/mixins/itext_click_behavior.mixin.js b/src/mixins/itext_click_behavior.mixin.js index b1bac2b8895..43074045ef9 100644 --- a/src/mixins/itext_click_behavior.mixin.js +++ b/src/mixins/itext_click_behavior.mixin.js @@ -97,10 +97,9 @@ fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.protot * current compositionMode. It will be set to false. */ _mouseDownHandler: function(options) { - if (!this.canvas || !this.editable || (options.e.button && options.e.button !== 1)) { + if (!this.canvas || !this.editable || this.__isDragging || (options.e.button && options.e.button !== 1)) { return; } - this.__isMousedown = true; if (this.selected) { @@ -129,6 +128,10 @@ fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.protot // we want to avoid that an object that was selected and then becomes unselectable, // may trigger editing mode in some way. this.selected = this === this.canvas._activeObject; + // text dragging logic + var newSelection = this.getSelectionStartFromPointer(options.e); + this.__isDragging = this.isEditing && newSelection >= this.selectionStart && newSelection <= this.selectionEnd + && this.selectionStart < this.selectionEnd; }, /** diff --git a/src/mixins/object_ancestry.mixin.js b/src/mixins/object_ancestry.mixin.js index 8c74ea48d2b..f066433fd07 100644 --- a/src/mixins/object_ancestry.mixin.js +++ b/src/mixins/object_ancestry.mixin.js @@ -40,16 +40,16 @@ fabric.util.object.extend(fabric.Object.prototype, /** @lends fabric.Object.prot /** * Returns an object that represent the ancestry situation. - * + * * @typedef {object} AncestryComparison * @property {Ancestors} common ancestors of `this` and `other` (may include `this` | `other`) * @property {Ancestors} fork ancestors that are of `this` only * @property {Ancestors} otherFork ancestors that are of `other` only - * + * * @param {fabric.Object} other * @param {boolean} [strict] finds only ancestors that are objects (without canvas) * @returns {AncestryComparison | undefined} - * + * */ findCommonAncestors: function (other, strict) { if (this === other) { diff --git a/src/mixins/object_interactivity.mixin.js b/src/mixins/object_interactivity.mixin.js index f752fb8b18f..d71280316db 100644 --- a/src/mixins/object_interactivity.mixin.js +++ b/src/mixins/object_interactivity.mixin.js @@ -279,6 +279,34 @@ return this; }, + /** + * Clears the canvas.contextTop in a specific area that corresponds to the object's bounding box + * that is in the canvas.contextContainer. + * This function is used to clear pieces of contextTop where we render ephemeral effects on top of the object. + * Example: blinking cursror text selection, drag effects. + * // TODO: discuss swapping restoreManually with a renderCallback, but think of async issues + * @param {Boolean} [restoreManually] When true won't restore the context after clear, in order to draw something else. + * @return {CanvasRenderingContext2D|undefined} canvas.contextTop that is either still transformed + * with the object transformMatrix, or restored to neutral transform + */ + clearContextTop: function(restoreManually) { + if (!this.canvas || !this.canvas.contextTop) { + return; + } + var ctx = this.canvas.contextTop, v = this.canvas.viewportTransform; + if (!ctx) { + return; + } + ctx.save(); + ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]); + this.transform(ctx); + // we add 4 pixel, to be sure to do not leave any pixel out + var width = this.width + 4, height = this.height + 4; + ctx.clearRect(-width / 2, -height / 2, width, height); + + restoreManually || ctx.restore(); + return ctx; + }, /** * This callback function is called every time _discardActiveObject or _setActiveObject @@ -299,6 +327,25 @@ */ onSelect: function() { // implemented by sub-classes, as needed. - } + }, + + /** + * Override to customize drag and drop behavior + * @public + * @param {DragEvent} e + * @returns {boolean} + */ + canDrop: function (e) { // eslint-disable-line no-unused-vars + return false; + }, + + renderDragSourceEffect: function() { + // for subclasses + }, + + renderDropTargetEffect: function(/* e */) { + // for subclasses + }, + }); })(); diff --git a/src/shapes/itext.class.js b/src/shapes/itext.class.js index c2623e51263..0fa717731e0 100644 --- a/src/shapes/itext.class.js +++ b/src/shapes/itext.class.js @@ -10,6 +10,12 @@ * @fires selection:changed * @fires editing:entered * @fires editing:exited + * @fires dragstart + * @fires drag drag event firing on the drag source + * @fires dragend + * @fires copy + * @fires cut + * @fires paste * * @return {fabric.IText} thisArg * @see {@link fabric.IText#initialize} for constructor definition @@ -152,23 +158,13 @@ /** * @private */ - _currentCursorOpacity: 0, + _currentCursorOpacity: 1, /** * @private */ _selectionDirection: null, - /** - * @private - */ - _abortCursorAnimation: false, - - /** - * @private - */ - __widthOfSpace: [], - /** * Helps determining when the text is in composition, so that the cursor * rendering is altered. @@ -258,7 +254,7 @@ * @private * @param {CanvasRenderingContext2D} ctx Context to render on */ - render: function(ctx) { + render: function (ctx) { this.clearContextTop(); this.callSuper('render', ctx); // clear the cursorOffsetCache, so we ensure to calculate once per renderCursor @@ -275,64 +271,53 @@ this.callSuper('_render', ctx); }, - /** - * Prepare and clean the contextTop - */ - clearContextTop: function(skipRestore) { - if (!this.isEditing || !this.canvas || !this.canvas.contextTop) { - return; - } - var ctx = this.canvas.contextTop, v = this.canvas.viewportTransform; - ctx.save(); - ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]); - this.transform(ctx); - this._clearTextArea(ctx); - skipRestore || ctx.restore(); - }, /** * Renders cursor or selection (depending on what exists) * it does on the contextTop. If contextTop is not available, do nothing. */ renderCursorOrSelection: function() { - if (!this.isEditing || !this.canvas || !this.canvas.contextTop) { + if (!this.isEditing) { + return; + } + var ctx = this.clearContextTop(true); + if (!ctx) { return; } - var boundaries = this._getCursorBoundaries(), - ctx = this.canvas.contextTop; - this.clearContextTop(true); + var boundaries = this._getCursorBoundaries(); if (this.selectionStart === this.selectionEnd) { - this.renderCursor(boundaries, ctx); + this.renderCursor(ctx, boundaries); } else { - this.renderSelection(boundaries, ctx); + this.renderSelection(ctx, boundaries); } ctx.restore(); }, - _clearTextArea: function(ctx) { - // we add 4 pixel, to be sure to do not leave any pixel out - var width = this.width + 4, height = this.height + 4; - ctx.clearRect(-width / 2, -height / 2, width, height); + /** + * Renders cursor on context Top, outside the animation cycle, on request + * Used for the drag/drop effect. + * If contextTop is not available, do nothing. + */ + renderCursorAt: function(selectionStart) { + var boundaries = this._getCursorBoundaries(selectionStart, true); + this._renderCursor(this.canvas.contextTop, boundaries, selectionStart); }, /** * Returns cursor boundaries (left, top, leftOffset, topOffset) + * left/top are left/top of entire text box + * leftOffset/topOffset are offset from that left/top point of a text box * @private - * @param {Array} chars Array of characters - * @param {String} typeOfBoundaries + * @param {number} [index] index from start + * @param {boolean} [skipCaching] */ - _getCursorBoundaries: function(position) { - - // left/top are left/top of entire text box - // leftOffset/topOffset are offset from that left/top point of a text box - - if (typeof position === 'undefined') { - position = this.selectionStart; + _getCursorBoundaries: function (index, skipCaching) { + if (typeof index === 'undefined') { + index = this.selectionStart; } - var left = this._getLeftOffset(), top = this._getTopOffset(), - offsets = this._getCursorBoundariesOffsets(position); + offsets = this._getCursorBoundariesOffsets(index, skipCaching); return { left: left, top: top, @@ -342,19 +327,34 @@ }, /** + * Caches and returns cursor left/top offset relative to instance's center point * @private + * @param {number} index index from start + * @param {boolean} [skipCaching] */ - _getCursorBoundariesOffsets: function(position) { + _getCursorBoundariesOffsets: function (index, skipCaching) { + if (skipCaching) { + return this.__getCursorBoundariesOffsets(index); + } if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) { return this.cursorOffsetCache; } + return this.cursorOffsetCache = this.__getCursorBoundariesOffsets(index); + }, + + /** + * Calcualtes cursor left/top offset relative to instance's center point + * @private + * @param {number} index index from start + */ + __getCursorBoundariesOffsets: function (index) { var lineLeftOffset, lineIndex, charIndex, topOffset = 0, leftOffset = 0, boundaries, - cursorPosition = this.get2DCursorLocation(position); + cursorPosition = this.get2DCursorLocation(index); charIndex = cursorPosition.charIndex; lineIndex = cursorPosition.lineIndex; for (var i = 0; i < lineIndex; i++) { @@ -381,8 +381,7 @@ boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0); } } - this.cursorOffsetCache = boundaries; - return this.cursorOffsetCache; + return boundaries; }, /** @@ -390,8 +389,12 @@ * @param {Object} boundaries * @param {CanvasRenderingContext2D} ctx transformed context to draw on */ - renderCursor: function(boundaries, ctx) { - var cursorLocation = this.get2DCursorLocation(), + renderCursor: function(ctx, boundaries) { + this._renderCursor(ctx, boundaries, this.selectionStart); + }, + + _renderCursor: function(ctx, boundaries, selectionStart) { + var cursorLocation = this.get2DCursorLocation(selectionStart), lineIndex = cursorLocation.lineIndex, charIndex = cursorLocation.charIndex > 0 ? cursorLocation.charIndex - 1 : 0, charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize'), @@ -403,7 +406,9 @@ - charHeight * (1 - this._fontSizeFraction); if (this.inCompositionMode) { - this.renderSelection(boundaries, ctx); + // TODO: investigate why there isn't a return inside the if, + // and why can't happe top of the function + this.renderSelection(ctx, boundaries); } ctx.fillStyle = this.cursorColor || this.getValueOfPropertyAt(lineIndex, charIndex, 'fill'); ctx.globalAlpha = this.__isMousedown ? 1 : this._currentCursorOpacity; @@ -419,10 +424,42 @@ * @param {Object} boundaries Object with left/top/leftOffset/topOffset * @param {CanvasRenderingContext2D} ctx transformed context to draw on */ - renderSelection: function(boundaries, ctx) { + renderSelection: function (ctx, boundaries) { + var selection = { + selectionStart: this.inCompositionMode ? this.hiddenTextarea.selectionStart : this.selectionStart, + selectionEnd: this.inCompositionMode ? this.hiddenTextarea.selectionEnd : this.selectionEnd + }; + this._renderSelection(ctx, selection, boundaries); + }, - var selectionStart = this.inCompositionMode ? this.hiddenTextarea.selectionStart : this.selectionStart, - selectionEnd = this.inCompositionMode ? this.hiddenTextarea.selectionEnd : this.selectionEnd, + /** + * Renders drag start text selection + */ + renderDragSourceEffect: function () { + if (this.__isDragging && this.__dragStartSelection && this.__dragStartSelection) { + this._renderSelection( + this.canvas.contextTop, + this.__dragStartSelection, + this._getCursorBoundaries(this.__dragStartSelection.selectionStart, true) + ); + } + }, + + renderDropTargetEffect: function(e) { + var dragSelection = this.getSelectionStartFromPointer(e); + this.renderCursorAt(dragSelection); + }, + + /** + * Renders text selection + * @private + * @param {{ selectionStart: number, selectionEnd: number }} selection + * @param {Object} boundaries Object with left/top/leftOffset/topOffset + * @param {CanvasRenderingContext2D} ctx transformed context to draw on + */ + _renderSelection: function (ctx, selection, boundaries) { + var selectionStart = selection.selectionStart, + selectionEnd = selection.selectionEnd, isJustify = this.textAlign.indexOf('justify') !== -1, start = this.get2DCursorLocation(selectionStart), end = this.get2DCursorLocation(selectionEnd), diff --git a/src/util/animate.js b/src/util/animate.js index 67b270944de..e66a500b49f 100644 --- a/src/util/animate.js +++ b/src/util/animate.js @@ -1,7 +1,7 @@ (function () { /** - * + * * @typedef {Object} AnimationOptions * Animation of a value or list of values. * @property {Function} [onChange] Callback; invoked on every value change @@ -132,7 +132,7 @@ * canvas.requestRenderAll(); * } * }); - * + * * @example * fabric.util.animate({ * startValue: 1, @@ -142,7 +142,7 @@ * canvas.requestRenderAll(); * } * }); - * + * * @returns {CancelFunction} cancel function */ function animate(options) { diff --git a/src/util/dom_event.js b/src/util/dom_event.js index 4b439499d53..8d5c5306688 100644 --- a/src/util/dom_event.js +++ b/src/util/dom_event.js @@ -38,10 +38,7 @@ var element = event.target, scroll = fabric.util.getScrollLeftTop(element), _evt = getTouchInfo(event); - return { - x: _evt.clientX + scroll.left, - y: _evt.clientY + scroll.top - }; + return new fabric.Point(_evt.clientX + scroll.left, _evt.clientY + scroll.top); }; fabric.util.isTouchEvent = function(event) { diff --git a/test/unit/canvas_events.js b/test/unit/canvas_events.js index 7fb02873036..8221f6701f5 100644 --- a/test/unit/canvas_events.js +++ b/test/unit/canvas_events.js @@ -548,21 +548,6 @@ }); }); - ['DragEnter', 'DragLeave', 'DragOver'].forEach(function(eventType) { - QUnit.test('Fabric event fired - ' + eventType, function(assert) { - var eventName = eventType.toLowerCase(); - var counter = 0; - var c = new fabric.Canvas(); - c.on(eventName, function() { - counter++; - }); - var event = fabric.document.createEvent('HTMLEvents'); - event.initEvent(eventName, true, true); - c.upperCanvasEl.dispatchEvent(event); - assert.equal(counter, 1, eventName + ' fabric event fired'); - }); - }); - QUnit.test('Fabric event fired - Drop', function (assert) { var eventNames = ['drop:before', 'drop']; var c = new fabric.Canvas(); @@ -578,28 +563,47 @@ assert.deepEqual(fired, eventNames, 'bad drop event fired'); }); - ['DragEnter', 'DragLeave', 'DragOver', 'Drop'].forEach(function(eventType) { - QUnit.test('_simpleEventHandler fires on object and canvas - ' + eventType, function(assert) { - var eventName = eventType.toLowerCase(); - var counter = 0; - var target; + QUnit.test('drag event cycle', function (assert) { + function testDragCycle(cycle, canDrop) { var c = new fabric.Canvas(); var rect = new fabric.Rect({ width: 10, height: 10 }); + rect.canDrop = function () { + return canDrop; + } c.add(rect); - rect.on(eventName, function() { - counter++; + var registery = [], canvasRegistry = []; + cycle.forEach(eventName => { + rect.once(eventName, function () { + registery.push(eventName); + }); + c.once(eventName, function (opt) { + assert.equal(opt.target, rect, eventName + ' on canvas has rect as a target'); + canvasRegistry.push(eventName); + }); + var event = fabric.document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + event.clientX = 5; + event.clientY = 5; + c.upperCanvasEl.dispatchEvent(event); }); - c.on(eventName, function(opt) { - target = opt.target; - }); - var event = fabric.document.createEvent('HTMLEvents'); - event.initEvent(eventName, true, true); - event.clientX = 5; - event.clientY = 5; - c.upperCanvasEl.dispatchEvent(event); - assert.equal(counter, 1, eventName + ' fabric event fired on rect'); - assert.equal(target, rect, eventName + ' on canvas has rect as a target'); - }); + c.dispose(); + assert.equal(canvasRegistry.length, cycle.length, 'should fire cycle on canvas'); + assert.deepEqual(canvasRegistry, cycle, 'should fire all events on canvas'); + return registery + } + var cycle, res; + cycle = ['dragenter', 'dragover', 'dragover', 'dragover', 'drop']; + res = testDragCycle(cycle, true); + assert.deepEqual(res, cycle, 'should fire all events on rect'); + cycle = ['dragenter', 'dragover', 'dragover', 'dragover', 'dragleave']; + res = testDragCycle(cycle, true); + assert.deepEqual(res, cycle, 'should fire all events on rect'); + cycle = ['dragenter', 'dragover', 'dragover', 'dragover', 'drop']; + res = testDragCycle(cycle); + assert.deepEqual(res, cycle, 'should fire all events on rect'); + cycle = ['dragenter', 'dragover', 'dragover', 'dragover', 'dragleave']; + res = testDragCycle(cycle); + assert.deepEqual(res, cycle, 'should fire all events on rect'); }); ['mousedown', 'mousemove', 'wheel', 'dblclick'].forEach(function(eventType) {