diff --git a/CHANGELOG.md b/CHANGELOG.md index d67444fb3af..6c60c64497f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - fix(): regression of canvas migration with pointer and sendPointToPlane [#8563](https://github.com/fabricjs/fabric.js/pull/8563) \- chore(TS): Add declare in front of properties that are type definitions. [#8574](https://github.com/fabricjs/fabric.js/pull/8574) - refactor(Animation): modernize IText cursor animation based on animation API changes (and fix minor regression) plus leftovers from #8547 [#8583](https://github.com/fabricjs/fabric.js/pull/8583) +- refactor(IText): extract draggable text logic to a delegate [#8598](https://github.com/fabricjs/fabric.js/pull/8598) - chore(TS): Update StaticCanvas to remove ts-nocheck [#8606](https://github.com/fabricjs/fabric.js/pull/8606) - chore(TS): Update filters to remove ts-nocheck and added types where missing [#8609](https://github.com/fabricjs/fabric.js/pull/8609) - chore(TS): Intersection class, finalize TS [#8603](https://github.com/fabricjs/fabric.js/pull/8603) diff --git a/src/canvas/canvas.class.ts b/src/canvas/canvas.class.ts index e2767561082..627ebe0556d 100644 --- a/src/canvas/canvas.class.ts +++ b/src/canvas/canvas.class.ts @@ -127,7 +127,7 @@ type TDestroyedCanvas = Omit< * flag = this.canDrop(opt.e); * }); * b.canDrop = function(e) { - * !flag && this.callSuper('canDrop', e); + * !flag && this.draggableTextDelegate.canDrop(e); * } * b.on('dragover', opt => b.set('fill', opt.dropTarget === b ? 'pink' : 'black')); * a.on('drop', opt => { diff --git a/src/canvas/canvas_events.ts b/src/canvas/canvas_events.ts index be50137f59a..be8ad0b7338 100644 --- a/src/canvas/canvas_events.ts +++ b/src/canvas/canvas_events.ts @@ -460,10 +460,10 @@ export class Canvas extends SelectableCanvas { // propagate the event to subtargets for (let i = 0; i < targets.length; i++) { const subTarget = targets[i]; - // accept event only if previous targets didn't + // accept event only if previous targets didn't (the accepting target calls `preventDefault` to inform that the event is taken) // TODO: verify if those should loop in inverse order then? // what is the order of subtargets? - if (!e.defaultPrevented && subTarget.canDrop(e)) { + if (subTarget.canDrop(e)) { dropTarget = subTarget; } subTarget.fire(eventType, options); @@ -747,8 +747,9 @@ export class Canvas extends SelectableCanvas { !this.allowTouchScrolling && (!activeObject || // a drag event sequence is started by the active object flagging itself on mousedown / mousedown:before - // we must not prevent the event's default behavior in order for the window to start the drag event sequence - !activeObject.__isDragging) && + // we must not prevent the event's default behavior in order for the window to start dragging + (isFabricObjectWithDragSupport(activeObject) && + !activeObject.shouldStartDragging())) && e.preventDefault && e.preventDefault(); this.__onMouseMove(e); diff --git a/src/mixins/DraggableTextDelegate.ts b/src/mixins/DraggableTextDelegate.ts new file mode 100644 index 00000000000..a9f1869706c --- /dev/null +++ b/src/mixins/DraggableTextDelegate.ts @@ -0,0 +1,390 @@ +import type { Canvas } from '../canvas/canvas_events'; +import { getEnv } from '../env'; +import { DragEventData, DropEventData, TPointerEvent } from '../EventTypeDefs'; +import { Point } from '../point.class'; +import type { IText } from '../shapes/itext.class'; +import { setStyle } from '../util/dom_style'; +import { clone } from '../util/lang_object'; +import { createCanvasElement } from '../util/misc/dom'; +import { isIdentityMatrix } from '../util/misc/matrix'; +import { TextStyleDeclaration } from './text_style.mixin'; + +/** + * #### Dragging IText/Textbox Lifecycle + * - {@link start} is called from `mousedown` {@link IText#_mouseDownHandler} and determines if dragging should start by testing {@link isPointerOverSelection} + * - if true `mousedown` {@link IText#_mouseDownHandler} is blocked to keep selection + * - if the pointer moves, canvas fires numerous mousemove {@link Canvas#_onMouseMove} that we make sure **aren't** prevented ({@link IText#shouldStartDragging}) in order for the window to start a drag session + * - once/if the session starts canvas calls {@link onDragStart} on the active object to determine if dragging should occur + * - canvas fires relevant drag events that are handled by the handlers defined in this scope + * - {@link end} is called from `mouseup` {@link IText#mouseUpHandler}, blocking IText default click behavior + * - in case the drag session didn't occur, {@link end} handles a click, since logic to do so was blocked during `mousedown` + */ +export class DraggableTextDelegate { + readonly target: IText; + private __mouseDownInPlace = false; + private __dragStartFired = false; + private __isDraggingOver = false; + private __dragStartSelection?: { + selectionStart: number; + selectionEnd: number; + }; + private __dragImageDisposer?: VoidFunction; + private _dispose?: () => void; + + constructor(target: IText) { + this.target = target; + const disposers = [ + this.target.on('dragenter', this.dragEnterHandler.bind(this)), + this.target.on('dragover', this.dragOverHandler.bind(this)), + this.target.on('dragleave', this.dragLeaveHandler.bind(this)), + this.target.on('dragend', this.dragEndHandler.bind(this)), + this.target.on('drop', this.dropHandler.bind(this)), + ]; + this._dispose = () => { + disposers.forEach((d) => d()); + this._dispose = undefined; + }; + } + + isPointerOverSelection(e: TPointerEvent) { + const target = this.target; + const newSelection = target.getSelectionStartFromPointer(e); + return ( + target.isEditing && + newSelection >= target.selectionStart && + newSelection <= target.selectionEnd && + target.selectionStart < target.selectionEnd + ); + } + + /** + * @public override this method to disable dragging and default to mousedown logic + */ + start(e: TPointerEvent) { + return (this.__mouseDownInPlace = this.isPointerOverSelection(e)); + } + + /** + * @public override this method to disable dragging without discarding selection + */ + isActive() { + return this.__mouseDownInPlace; + } + + /** + * Ends interaction and sets cursor in case of a click + * @returns true if was active + */ + end(e: TPointerEvent) { + const active = this.isActive(); + if (active && !this.__dragStartFired) { + // mousedown has been blocked since `active` is true => cursor has not been set. + // `__dragStartFired` is false => dragging didn't occur, pointer didn't move and is over selection. + // meaning this is actually a click, `active` is a false positive. + this.target.setCursorByClick(e); + this.target.initDelayedCursor(true); + } + this.__mouseDownInPlace = false; + this.__dragStartFired = false; + this.__isDraggingOver = false; + return active; + } + + getDragStartSelection() { + return this.__dragStartSelection; + } + + /** + * Override to customize the drag image + * https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage + */ + setDragImage( + e: DragEvent, + { + selectionStart, + selectionEnd, + }: { + selectionStart: number; + selectionEnd: number; + } + ) { + const target = this.target; + const canvas = target.canvas!; + const flipFactor = new Point(target.flipX ? -1 : 1, target.flipY ? -1 : 1); + const boundaries = target._getCursorBoundaries(selectionStart); + const selectionPosition = new Point( + boundaries.left + boundaries.leftOffset, + boundaries.top + boundaries.topOffset + ).multiply(flipFactor); + const pos = selectionPosition.transform(target.calcTransformMatrix()); + const pointer = canvas.getPointer(e); + const diff = pointer.subtract(pos); + const enableRetinaScaling = canvas._isRetinaScaling(); + const retinaScaling = target.getCanvasRetinaScaling(); + const bbox = target.getBoundingRect(true); + const correction = pos.subtract(new Point(bbox.left, bbox.top)); + const vpt = canvas.viewportTransform; + const offset = correction.add(diff).transform(vpt, true); + // prepare instance for drag image snapshot by making all non selected text invisible + const bgc = target.backgroundColor; + const styles = clone(target.styles, true); + target.backgroundColor = ''; + const styleOverride = { + stroke: 'transparent', + fill: 'transparent', + textBackgroundColor: 'transparent', + }; + target.setSelectionStyles(styleOverride, 0, selectionStart); + target.setSelectionStyles(styleOverride, selectionEnd, target.text.length); + let dragImage = target.toCanvasElement({ + enableRetinaScaling, + }); + // restore values + target.backgroundColor = bgc; + target.styles = styles; + // handle retina scaling and vpt + if (retinaScaling > 1 || !isIdentityMatrix(vpt)) { + const dragImageCanvas = createCanvasElement(); + const size = new Point(dragImage.width, dragImage.height) + .scalarDivide(retinaScaling) + .transform(vpt, true); + dragImageCanvas.width = size.x; + dragImageCanvas.height = size.y; + const ctx = dragImageCanvas.getContext('2d')!; + ctx.scale(1 / retinaScaling, 1 / retinaScaling); + const [a, b, c, d] = vpt; + ctx.transform(a, b, c, d, 0, 0); + ctx.drawImage(dragImage, 0, 0); + dragImage = dragImageCanvas; + } + this.__dragImageDisposer && this.__dragImageDisposer(); + this.__dragImageDisposer = () => { + dragImage.remove(); + }; + // position drag image offscreen + setStyle(dragImage, { + position: 'absolute', + left: -dragImage.width + 'px', + border: 'none', + }); + getEnv().document.body.appendChild(dragImage); + e.dataTransfer?.setDragImage(dragImage, offset.x, offset.y); + } + + /** + * @returns {boolean} determines whether {@link target} should/shouldn't become a drag source + */ + onDragStart(e: DragEvent): boolean { + this.__dragStartFired = true; + const target = this.target; + const active = this.isActive(); + if (active && e.dataTransfer) { + const selection = (this.__dragStartSelection = { + selectionStart: target.selectionStart, + selectionEnd: target.selectionEnd, + }); + const value = target._text + .slice(selection.selectionStart, selection.selectionEnd) + .join(''); + const data = { text: target.text, value, ...selection }; + e.dataTransfer.setData('text/plain', value); + e.dataTransfer.setData( + 'application/fabric', + JSON.stringify({ + value: value, + styles: target.getSelectionStyles( + selection.selectionStart, + selection.selectionEnd, + true + ), + }) + ); + e.dataTransfer.effectAllowed = 'copyMove'; + this.setDragImage(e, data); + } + target.abortCursorAnimation(); + return active; + } + + /** + * use {@link targetCanDrop} to respect overriding + * @returns {boolean} determines whether {@link target} should/shouldn't become a drop target + */ + canDrop(e: DragEvent): boolean { + if (this.target.editable && !this.target.__corner && !e.defaultPrevented) { + if (this.isActive() && this.__dragStartSelection) { + // drag source trying to drop over itself + // allow dropping only outside of drag start selection + const index = this.target.getSelectionStartFromPointer(e); + const dragStartSelection = this.__dragStartSelection; + return ( + index < dragStartSelection.selectionStart || + index > dragStartSelection.selectionEnd + ); + } + return true; + } + return false; + } + + /** + * in order to respect overriding {@link IText#canDrop} we call that instead of calling {@link canDrop} directly + */ + protected targetCanDrop(e: DragEvent) { + return this.target.canDrop(e); + } + + dragEnterHandler({ e }: DragEventData) { + const canDrop = this.targetCanDrop(e); + if (!this.__isDraggingOver && canDrop) { + this.__isDraggingOver = true; + } + } + + dragOverHandler(ev: DragEventData) { + const { e } = ev; + const canDrop = this.targetCanDrop(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 + ev.canDrop = true; + ev.dropTarget = this.target; + } + } + + dragLeaveHandler() { + if (this.__isDraggingOver || this.isActive()) { + this.__isDraggingOver = false; + } + } + + /** + * 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 + */ + dropHandler(ev: DropEventData) { + const { e } = ev; + const didDrop = e.defaultPrevented; + this.__isDraggingOver = false; + // inform browser that the drop has been accepted + e.preventDefault(); + let insert = e.dataTransfer?.getData('text/plain'); + if (insert && !didDrop) { + const target = this.target; + const canvas = target.canvas!; + let insertAt = target.getSelectionStartFromPointer(e); + const { styles } = ( + e.dataTransfer!.types.includes('application/fabric') + ? JSON.parse(e.dataTransfer!.getData('application/fabric')) + : {} + ) as { styles: TextStyleDeclaration[] }; + const trailing = insert[Math.max(0, insert.length - 1)]; + const selectionStartOffset = 0; + // drag and drop in same instance + if (this.__dragStartSelection) { + const selectionStart = this.__dragStartSelection.selectionStart; + const selectionEnd = this.__dragStartSelection.selectionEnd; + if (insertAt > selectionStart && insertAt <= selectionEnd) { + insertAt = selectionStart; + } else if (insertAt > selectionEnd) { + insertAt -= selectionEnd - selectionStart; + } + target.insertChars('', undefined, selectionStart, selectionEnd); + // prevent `dragend` from handling event + delete this.__dragStartSelection; + } + // remove redundant line break + if ( + target._reNewline.test(trailing) && + (target._reNewline.test(target._text[insertAt]) || + insertAt === target._text.length) + ) { + insert = insert.trimEnd(); + } + // inform subscribers + ev.didDrop = true; + ev.dropTarget = target; + // finalize + target.insertChars(insert, styles, insertAt); + // can this part be moved in an outside event? andrea to check. + canvas.setActiveObject(target); + target.enterEditing(e); + target.selectionStart = Math.min( + insertAt + selectionStartOffset, + target._text.length + ); + target.selectionEnd = Math.min( + target.selectionStart + insert.length, + target._text.length + ); + target.hiddenTextarea!.value = target.text; + target._updateTextarea(); + target.hiddenTextarea!.focus(); + target.fire('changed', { + index: insertAt + selectionStartOffset, + action: 'drop', + }); + canvas.fire('text:changed', { target }); + canvas.contextTopDirty = true; + canvas.requestRenderAll(); + } + } + + /** + * fired only on the drag source after drop (if occurred) + * 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 + */ + dragEndHandler({ e }: DragEventData) { + if (this.isActive() && 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 since the drop handler has already handled logic + if (this.__dragStartSelection) { + const target = this.target; + const canvas = this.target.canvas!; + const { selectionStart, selectionEnd } = this.__dragStartSelection; + const dropEffect = e.dataTransfer?.dropEffect || 'none'; + if (dropEffect === 'none') { + // pointer is back over selection + target.selectionStart = selectionStart; + target.selectionEnd = selectionEnd; + target._updateTextarea(); + target.hiddenTextarea!.focus(); + } else { + target.clearContextTop(); + if (dropEffect === 'move') { + target.insertChars('', undefined, selectionStart, selectionEnd); + target.selectionStart = target.selectionEnd = selectionStart; + target.hiddenTextarea && + (target.hiddenTextarea.value = target.text); + target._updateTextarea(); + target.fire('changed', { + index: selectionStart, + action: 'dragend', + }); + canvas.fire('text:changed', { target }); + canvas.requestRenderAll(); + } + target.exitEditing(); + } + } + } + + this.__dragImageDisposer && this.__dragImageDisposer(); + delete this.__dragImageDisposer; + delete this.__dragStartSelection; + this.__isDraggingOver = false; + } + + dispose() { + this._dispose && this._dispose(); + } +} diff --git a/src/mixins/itext_behavior.mixin.ts b/src/mixins/itext_behavior.mixin.ts index 2cc5161f825..e379949bb1b 100644 --- a/src/mixins/itext_behavior.mixin.ts +++ b/src/mixins/itext_behavior.mixin.ts @@ -1,23 +1,13 @@ -// @ts-nocheck +/// @ts-nocheck import { getEnv } from '../env'; -import { - DragEventData, - DropEventData, - ObjectEvents, - TEvent, - TPointerEvent, -} from '../EventTypeDefs'; +import { ObjectEvents, TPointerEvent } from '../EventTypeDefs'; import { Point } from '../point.class'; import type { FabricObject } from '../shapes/Object/Object'; import { Text } from '../shapes/text.class'; import { animate } from '../util/animation/animate'; import { TOnAnimationChangeCallback } from '../util/animation/types'; import type { ValueAnimation } from '../util/animation/ValueAnimation'; -import { setStyle } from '../util/dom_style'; -import { clone } from '../util/lang_object'; -import { createCanvasElement } from '../util/misc/dom'; -import { isIdentityMatrix } from '../util/misc/matrix'; import { TextStyleDeclaration } from './text_style.mixin'; // extend this regex to support non english languages @@ -51,16 +41,8 @@ export abstract class ITextBehaviorMixin< protected declare _currentCursorOpacity: number; private declare _textBeforeEdit: string; protected declare __selectionStartOnMouseDown: number; - private declare __dragImageDisposer: VoidFunction; - private declare __dragStartFired: boolean; - protected declare __isDragging: boolean; - protected declare __dragStartSelection: { - selectionStart: number; - selectionEnd: number; - }; - protected declare __isDraggingOver: boolean; + protected declare selected: boolean; - protected declare __lastSelected: boolean; protected declare cursorOffsetCache: { left?: number; top?: number }; protected declare _savedProps: { hasControls: boolean; @@ -75,8 +57,6 @@ export abstract class ITextBehaviorMixin< protected declare _selectionDirection: 'left' | 'right' | null; abstract initHiddenTextarea(): void; - abstract initCursorSelectionHandlers(): void; - abstract initDoubleClickSimulation(): void; abstract _fireSelectionChanged(): void; abstract renderCursorOrSelection(): void; abstract getSelectionStartFromPointer(e: TPointerEvent): number; @@ -94,22 +74,10 @@ export abstract class ITextBehaviorMixin< * Initializes all the interactive behavior of IText */ initBehavior() { - this.initCursorSelectionHandlers(); - this.initDoubleClickSimulation(); this._tick = this._tick.bind(this); this._onTickComplete = this._onTickComplete.bind(this); this.updateSelectionOnMouseMove = this.updateSelectionOnMouseMove.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(options?: { e?: TPointerEvent; object?: FabricObject }) { @@ -387,7 +355,7 @@ export abstract class ITextBehaviorMixin< this.isEditing = true; - this.initHiddenTextarea(e); + this.initHiddenTextarea(); this.hiddenTextarea.focus(); this.hiddenTextarea.value = this.text; this._updateTextarea(); @@ -396,10 +364,10 @@ export abstract class ITextBehaviorMixin< this._textBeforeEdit = this.text; this._tick(); - this.fire('editing:entered'); + this.fire('editing:entered', { e }); this._fireSelectionChanged(); if (this.canvas) { - this.canvas.fire('text:editing:entered', { target: this }); + this.canvas.fire('text:editing:entered', { target: this, e }); this.canvas.requestRenderAll(); } } @@ -439,309 +407,6 @@ export abstract class ITextBehaviorMixin< } } - /** - * Override to customize the drag image - * https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage - */ - setDragImage( - e: DragEvent, - { - selectionStart, - selectionEnd, - }: { - selectionStart: number; - selectionEnd: number; - } - ) { - const flipFactor = new Point(this.flipX ? -1 : 1, this.flipY ? -1 : 1); - const boundaries = this._getCursorBoundaries(selectionStart); - const selectionPosition = new Point( - boundaries.left + boundaries.leftOffset, - boundaries.top + boundaries.topOffset - ).multiply(flipFactor); - const pos = selectionPosition.transform(this.calcTransformMatrix()); - const pointer = this.canvas.getPointer(e); - const diff = pointer.subtract(pos); - const enableRetinaScaling = this.canvas._isRetinaScaling(); - const retinaScaling = this.getCanvasRetinaScaling(); - const bbox = this.getBoundingRect(true); - const correction = pos.subtract(new Point(bbox.left, bbox.top)); - const vpt = this.canvas.viewportTransform; - const offset = correction.add(diff).transform(vpt, true); - // prepare instance for drag image snapshot by making all non selected text invisible - const bgc = this.backgroundColor; - const styles = clone(this.styles, true); - delete this.backgroundColor; - const styleOverride = { - stroke: 'transparent', - fill: 'transparent', - textBackgroundColor: 'transparent', - }; - this.setSelectionStyles(styleOverride, 0, selectionStart); - this.setSelectionStyles(styleOverride, selectionEnd, this.text.length); - let dragImage = this.toCanvasElement({ - enableRetinaScaling, - }); - // restore values - this.backgroundColor = bgc; - this.styles = styles; - // handle retina scaling and vpt - if (retinaScaling > 1 || !isIdentityMatrix(vpt)) { - const dragImageCanvas = createCanvasElement(); - const size = new Point(dragImage.width, dragImage.height) - .scalarDivide(retinaScaling) - .transform(vpt, true); - dragImageCanvas.width = size.x; - dragImageCanvas.height = size.y; - const ctx = dragImageCanvas.getContext('2d'); - ctx.scale(1 / retinaScaling, 1 / retinaScaling); - const [a, b, c, d] = vpt; - ctx.transform(a, b, c, d, 0, 0); - ctx.drawImage(dragImage, 0, 0); - dragImage = dragImageCanvas; - } - this.__dragImageDisposer && this.__dragImageDisposer(); - this.__dragImageDisposer = () => { - dragImage.remove(); - }; - // position drag image offscreen - setStyle(dragImage, { - position: 'absolute', - left: -dragImage.width + 'px', - border: 'none', - }); - getEnv().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(e: DragEvent): boolean { - this.__dragStartFired = true; - if (this.__isDragging) { - const selection = (this.__dragStartSelection = { - selectionStart: this.selectionStart, - selectionEnd: this.selectionEnd, - }); - const value = this._text - .slice(selection.selectionStart, selection.selectionEnd) - .join(''); - const data = { text: this.text, 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(e: DragEvent): boolean { - 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 - const index = this.getSelectionStartFromPointer(e); - const 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({ e }: DragEventData) { - const 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(ev: DragEventData) { - const { e } = ev; - const 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 - ev.canDrop = true; - ev.dropTarget = this; - } - } - - /** - * support native like text dragging - * @private - */ - dragLeaveHandler() { - 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({ e }: DragEventData) { - 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 since the drop handler has already handled logic - if (this.__dragStartSelection) { - const selectionStart = this.__dragStartSelection.selectionStart; - const selectionEnd = this.__dragStartSelection.selectionEnd; - const dropEffect = e.dataTransfer.dropEffect; - if (dropEffect === 'none') { - // pointer is back over selection - this.selectionStart = selectionStart; - this.selectionEnd = selectionEnd; - this._updateTextarea(); - this.hiddenTextarea.focus(); - } 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 - */ - dropHandler(ev: DropEventData) { - const { e } = ev; - const didDrop = e.defaultPrevented; - this.__isDraggingOver = false; - // inform browser that the drop has been accepted - e.preventDefault(); - let insert = e.dataTransfer.getData('text/plain'); - if (insert && !didDrop) { - let insertAt = this.getSelectionStartFromPointer(e); - const { styles } = e.dataTransfer.types.includes('application/fabric') - ? JSON.parse(e.dataTransfer.getData('application/fabric')) - : {}; - const trailing = insert[Math.max(0, insert.length - 1)]; - const selectionStartOffset = 0; - // drag and drop in same instance - if (this.__dragStartSelection) { - const selectionStart = this.__dragStartSelection.selectionStart; - const 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 - ev.didDrop = true; - ev.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.hiddenTextarea.focus(); - this.fire('changed', { - index: insertAt + selectionStartOffset, - action: 'drop', - }); - this.canvas.fire('text:changed', { target: this }); - this.canvas.contextTopDirty = true; - this.canvas.requestRenderAll(); - } - } - /** * @private */ @@ -1160,7 +825,7 @@ export abstract class ITextBehaviorMixin< lineIndex: number, charIndex: number, quantity: number, - copiedStyle: TextStyleDeclaration[] + copiedStyle?: TextStyleDeclaration[] ) { if (!this.styles) { this.styles = {}; @@ -1221,7 +886,7 @@ export abstract class ITextBehaviorMixin< insertNewStyleBlock( insertedText: string[], start: number, - copiedStyle: TextStyleDeclaration[] + copiedStyle?: TextStyleDeclaration[] ) { let cursorLoc = this.get2DCursorLocation(start, true), addedLines = [0], @@ -1317,13 +982,10 @@ export abstract class ITextBehaviorMixin< */ insertChars( text: string, - style: TextStyleDeclaration[], + style: TextStyleDeclaration[] | undefined, start: number, - end: number + end: number = start ) { - if (typeof end === 'undefined') { - end = start; - } if (end > start) { this.removeStyleFromTo(start, end); } diff --git a/src/mixins/itext_click_behavior.mixin.ts b/src/mixins/itext_click_behavior.mixin.ts index df6916949a6..8cafb75dda2 100644 --- a/src/mixins/itext_click_behavior.mixin.ts +++ b/src/mixins/itext_click_behavior.mixin.ts @@ -1,31 +1,66 @@ //@ts-nocheck import { ObjectEvents } from '../EventTypeDefs'; import { IPoint, Point } from '../point.class'; +import type { DragMethods } from '../shapes/Object/InteractiveObject'; import { TPointerEvent, TransformEvent } from '../typedefs'; import { stopEvent } from '../util/dom_event'; import { invertTransform, transformPoint } from '../util/misc/matrix'; +import { DraggableTextDelegate } from './DraggableTextDelegate'; import { ITextKeyBehaviorMixin } from './itext_key_behavior.mixin'; -export abstract class ITextClickBehaviorMixin< - EventSpec extends ObjectEvents -> extends ITextKeyBehaviorMixin { +export abstract class ITextClickBehaviorMixin + extends ITextKeyBehaviorMixin + implements DragMethods +{ + private declare __lastSelected: boolean; private declare __lastClickTime: number; private declare __lastLastClickTime: number; private declare __lastPointer: IPoint | Record; private declare __newClickTime: number; - /** - * Initializes "dbclick" event handler - */ - initDoubleClickSimulation() { - this.__lastClickTime = +new Date(); + protected draggableTextDelegate: DraggableTextDelegate; + + initBehavior() { + // Initializes event handlers related to cursor or selection + this.initMousedownHandler(); + this.initMouseupHandler(); + this.initClicks(); + // Initializes "dbclick" event handler + this.__lastClickTime = +new Date(); // for triple click this.__lastLastClickTime = +new Date(); - this.__lastPointer = {}; - this.on('mousedown', this.onMouseDown); + + // TODO: replace this with a standard assignment `clone` is removed + Object.defineProperty(this, 'draggableTextDelegate', { + value: new DraggableTextDelegate(this), + configurable: false, + enumerable: false, + writable: true, + }); + + super.initBehavior(); + } + + shouldStartDragging() { + return this.draggableTextDelegate.isActive(); + } + + /** + * @public override this method to control whether instance should/shouldn't become a drag source, @see also {@link DraggableTextDelegate#isActive} + * @returns {boolean} should handle event + */ + onDragStart(e: DragEvent) { + return this.draggableTextDelegate.onDragStart(e); + } + + /** + * @public override this method to control whether instance should/shouldn't become a drop target + */ + canDrop(e: DragEvent) { + return this.draggableTextDelegate.canDrop(e); } /** @@ -57,15 +92,6 @@ export abstract class ITextClickBehaviorMixin< ); } - /** - * Initializes event handlers related to cursor or selection - */ - initCursorSelectionHandlers() { - this.initMousedownHandler(); - this.initMouseupHandler(); - this.initClicks(); - } - /** * Default handler for double click, select a word */ @@ -102,13 +128,12 @@ export abstract class ITextClickBehaviorMixin< * initializing a mousedDown on a text area will cancel fabricjs knowledge of * current compositionMode. It will be set to false. */ - _mouseDownHandler(options: TransformEvent) { - if ( - !this.canvas || - !this.editable || - this.__isDragging || - (options.e.button && options.e.button !== 1) - ) { + _mouseDownHandler({ e }: TransformEvent) { + if (!this.canvas || !this.editable || (e.button && e.button !== 1)) { + return; + } + + if (this.draggableTextDelegate.start(e)) { return; } @@ -116,7 +141,7 @@ export abstract class ITextClickBehaviorMixin< if (this.selected) { this.inCompositionMode = false; - this.setCursorByClick(options.e); + this.setCursorByClick(e); } if (this.isEditing) { @@ -133,24 +158,13 @@ export abstract class ITextClickBehaviorMixin< * can be overridden to do something different. * Scope of this implementation is: verify the object is already selected when mousing down */ - _mouseDownHandlerBefore(options: TransformEvent) { - if ( - !this.canvas || - !this.editable || - (options.e.button && options.e.button !== 1) - ) { + _mouseDownHandlerBefore({ e }: TransformEvent) { + if (!this.canvas || !this.editable || (e.button && e.button !== 1)) { return; } // 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 - const newSelection = this.getSelectionStartFromPointer(options.e); - this.__isDragging = - this.isEditing && - newSelection >= this.selectionStart && - newSelection <= this.selectionEnd && - this.selectionStart < this.selectionEnd; } /** @@ -173,8 +187,7 @@ export abstract class ITextClickBehaviorMixin< * @private */ mouseUpHandler(options: TransformEvent) { - const shouldSetCursor = this.__isDragging && options.isClick; // false positive drag event, is actually a click - this.__isDragging = false; + const didDrag = this.draggableTextDelegate.end(options.e); if (this.canvas) { this.canvas.textEditingManager.unregister(this); @@ -190,16 +203,12 @@ export abstract class ITextClickBehaviorMixin< !this.editable || (this.group && !this.group.interactive) || (options.transform && options.transform.actionPerformed) || - (options.e.button && options.e.button !== 1) + (options.e.button && options.e.button !== 1) || + didDrag ) { return; } - // mousedown is going to early return if isDragging is true. - // this is here to recover the setCursorByClick in case the - // isDragging is a false positive. - shouldSetCursor && this.setCursorByClick(options.e); - if (this.__lastSelected && !this.__corner) { this.selected = false; this.__lastSelected = false; diff --git a/src/shapes/Object/InteractiveObject.ts b/src/shapes/Object/InteractiveObject.ts index cbdad6bd2d3..ac437a9fa69 100644 --- a/src/shapes/Object/InteractiveObject.ts +++ b/src/shapes/Object/InteractiveObject.ts @@ -32,9 +32,13 @@ type TStyleOverride = ControlRenderingStyleOverride & forActiveSelection: boolean; } >; -export type FabricObjectWithDragSupport = InteractiveFabricObject & { - onDragStart: (e: DragEvent) => boolean; -}; + +export interface DragMethods { + shouldStartDragging(): boolean; + onDragStart(e: DragEvent): boolean; +} + +export type FabricObjectWithDragSupport = InteractiveFabricObject & DragMethods; export class InteractiveFabricObject< EventSpec extends ObjectEvents = ObjectEvents @@ -103,15 +107,6 @@ export class InteractiveFabricObject< */ declare isMoving?: boolean; - /** - * internal boolean to signal the code that the object is - * part of the draggin action. - * @TODO: discuss isMoving and isDragging being not adequate enough - * they need to be either both private or more generic - * Canvas class needs to see this variable - */ - declare __isDragging?: boolean; - /** * A boolean used from the gesture module to keep tracking of a scaling * action when there is no scaling transform in place. @@ -619,7 +614,7 @@ export class InteractiveFabricObject< * @param {DragEvent} e * @returns {boolean} */ - canDrop(e?: DragEvent): boolean { + canDrop(e: DragEvent): boolean { return false; } diff --git a/src/shapes/itext.class.ts b/src/shapes/itext.class.ts index d7b032efe5b..848c35536fd 100644 --- a/src/shapes/itext.class.ts +++ b/src/shapes/itext.class.ts @@ -1,6 +1,10 @@ // @ts-nocheck import { Canvas } from '../canvas/canvas_events'; -import { ObjectEvents, TPointerEventInfo } from '../EventTypeDefs'; +import { + ObjectEvents, + TPointerEvent, + TPointerEventInfo, +} from '../EventTypeDefs'; import { ITextClickBehaviorMixin } from '../mixins/itext_click_behavior.mixin'; import { ctrlKeysMapDown, @@ -13,9 +17,9 @@ import { classRegistry } from '../util/class_registry'; export type ITextEvents = ObjectEvents & { 'selection:changed': never; - changed: never; + changed: never | { index: number; action: string }; tripleclick: TPointerEventInfo; - 'editing:entered': never; + 'editing:entered': never | { e: TPointerEvent }; 'editing:exited': never; }; @@ -346,12 +350,15 @@ export class IText extends ITextClickBehaviorMixin { * @param {number} index index from start * @param {boolean} [skipCaching] */ - _getCursorBoundariesOffsets(index: number, skipCaching?: boolean) { + _getCursorBoundariesOffsets( + index: number, + skipCaching?: boolean + ): { left: number; top: number } { if (skipCaching) { return this.__getCursorBoundariesOffsets(index); } if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) { - return this.cursorOffsetCache; + return this.cursorOffsetCache as { left: number; top: number }; } return (this.cursorOffsetCache = this.__getCursorBoundariesOffsets(index)); } @@ -476,10 +483,12 @@ export class IText extends ITextClickBehaviorMixin { * Renders drag start text selection */ renderDragSourceEffect(this: AssertKeys) { + const dragStartSelection = + this.draggableTextDelegate.getDragStartSelection()!; this._renderSelection( this.canvas.contextTop, - this.__dragStartSelection, - this._getCursorBoundaries(this.__dragStartSelection.selectionStart, true) + dragStartSelection, + this._getCursorBoundaries(dragStartSelection.selectionStart, true) ); } @@ -618,6 +627,7 @@ export class IText extends ITextClickBehaviorMixin { dispose() { this._exitEditing(); + this.draggableTextDelegate.dispose(); super.dispose(); } } diff --git a/src/util/types.ts b/src/util/types.ts index 9b33b330356..557653f5a91 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -71,6 +71,8 @@ export const isFabricObjectWithDragSupport = ( return ( !!fabricObject && typeof (fabricObject as FabricObjectWithDragSupport).onDragStart === + 'function' && + typeof (fabricObject as FabricObjectWithDragSupport).shouldStartDragging === 'function' ); }; diff --git a/test/unit/draggable_text.js b/test/unit/draggable_text.js index 4be8b68b971..cf8739036ed 100644 --- a/test/unit/draggable_text.js +++ b/test/unit/draggable_text.js @@ -29,6 +29,10 @@ function assertDragEventStream(name, a, b) { assert.equal(cursorState, active, `cursor animation state should be ${active}`); } + function wait(ms = 32) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + [true, false].forEach(enableRetinaScaling => { QUnit.module(`enableRetinaScaling = ${enableRetinaScaling}`, function (hooks) { let canvas, eventData, iText, iText2, eventStream, renderEffects; @@ -132,20 +136,25 @@ function assertDragEventStream(name, a, b) { }; } - QUnit.test('click sets cursor', function (assert) { + QUnit.test('click sets cursor', async function (assert) { assert.equal(count, 0, 'selection:changed fired'); assert.equal(countCanvas, 0, 'text:selection:changed fired'); let called = 0; // sinon spy!! // iText.setCursorByClick = () => called++; canvas._onMouseDown(eventData); - assert.ok(iText.__isDragging, 'flagged as dragging'); + assert.ok(iText.draggableTextDelegate.isActive(), 'flagged as dragging'); + assert.ok(iText.shouldStartDragging(), 'flagged as dragging'); + + await wait(); + assertCursorAnimation(assert, iText); // assert.equal(called, 0, 'should not set cursor on mouse up'); canvas._onMouseUp(eventData); - assert.ok(!iText.__isDragging, 'unflagged as dragging'); + assert.ok(!iText.draggableTextDelegate.isActive(), 'unflagged as dragging'); + assert.ok(!iText.shouldStartDragging(), 'unflagged as dragging'); // assert.equal(called, 1, 'should set cursor on mouse up'); - assert.equal(iText.selectionStart, 2, 'Itext set the selectionStart'); - assert.equal(iText.selectionEnd, 2, 'Itext set the selectionend'); + assert.equal(iText.selectionStart, 2, 'set the selectionStart'); + assert.equal(iText.selectionEnd, 2, 'set the selectionend'); assertCursorAnimation(assert, iText, true); assert.equal(count, 1, 'selection:changed fired'); assert.equal(countCanvas, 1, 'text:selection:changed fired'); @@ -192,6 +201,40 @@ function assertDragEventStream(name, a, b) { assert.deepEqual(eventStream.canvas, eventStream.source, 'events should match'); }); + QUnit.test('disable drag start: onDragStart', async function (assert) { + iText.onDragStart = () => false; + const e = startDragging(eventData); + assert.equal(iText.shouldStartDragging(), true, 'should flag dragging'); + assert.equal(iText.selectionStart, 0, 'selectionStart is kept'); + assert.equal(iText.selectionEnd, 4, 'selectionEnd is kept'); + assert.deepEqual(e.dataTransfer.data, {}, 'should not set dataTransfer'); + assert.equal(e.dataTransfer.effectAllowed, undefined, 'should not set effectAllowed'); + assert.deepEqual(e.dataTransfer.dragImageData, undefined, 'should not set dataTransfer'); + }); + + QUnit.test('disable drag start: start', async function (assert) { + iText.draggableTextDelegate.start = () => false; + const e = startDragging(eventData); + assert.equal(iText.shouldStartDragging(), false, 'should not flag dragging'); + assert.equal(iText.selectionStart, 2, 'selectionStart is set'); + assert.equal(iText.selectionEnd, 2, 'selectionEnd is set'); + assert.deepEqual(e.dataTransfer.data, {}, 'should not set dataTransfer'); + assert.equal(e.dataTransfer.effectAllowed, undefined, 'should not set effectAllowed'); + assert.deepEqual(e.dataTransfer.dragImageData, undefined, 'should not set dataTransfer'); + }); + + QUnit.test('disable drag start: isActive', async function (assert) { + iText.draggableTextDelegate.isActive = () => false; + const e = startDragging(eventData); + assert.equal(iText.shouldStartDragging(), false, 'should not flag dragging'); + assert.equal(iText.selectionStart, 0, 'selectionStart is kept'); + assert.equal(iText.selectionEnd, 4, 'selectionEnd is kept'); + assertCursorAnimation(assert, iText); + assert.deepEqual(e.dataTransfer.data, {}, 'should not set dataTransfer'); + assert.equal(e.dataTransfer.effectAllowed, undefined, 'should not set effectAllowed'); + assert.deepEqual(e.dataTransfer.dragImageData, undefined, 'should not set dataTransfer'); + }); + QUnit.test('drag over: source', function (assert) { const e = startDragging(eventData); const dragEvents = []; @@ -498,6 +541,49 @@ function assertDragEventStream(name, a, b) { ]); assert.equal(fabric.getDocument().activeElement, iText2.hiddenTextarea, 'should have focused hiddenTextarea'); }); + + QUnit.test('disable drop', function (assert) { + iText2.canDrop = () => false; + const e = startDragging(eventData); + const dragEvents = []; + let index; + for (index = 200; index < 210; index = index + 5) { + const dragOverEvent = createDragEvent(eventData.clientX + index * canvas.getRetinaScaling()); + canvas._onDragOver(dragOverEvent); + dragEvents.push(dragOverEvent); + } + const drop = createDragEvent(eventData.clientX + index * canvas.getRetinaScaling(), undefined, { dropEffect: 'none' }); + // the window will not invoke a drop event so we call drag end to simulate correctly + canvas._onDragEnd(drop); + assert.equal(iText2.text, 'test2 test2', 'text after drop'); + assert.equal(iText2.selectionStart, 0, 'selection after drop'); + assert.equal(iText2.selectionEnd, 0, 'selection after drop'); + assertDragEventStream('drop', eventStream.target, [ + { + e: dragEvents[0], + target: iText2, + type: 'dragenter', + subTargets: [], + dragSource: iText, + dropTarget: undefined, + canDrop: false, + pointer: new fabric.Point(230, 15), + absolutePointer: new fabric.Point(230, 15), + isClick: false, + previousTarget: undefined + }, + ...dragEvents.slice(0, 2).map(e => ({ + e, + target: iText2, + type: 'dragover', + subTargets: [], + dragSource: iText, + dropTarget: undefined, + canDrop: false + })), + ]); + assert.equal(fabric.getDocument().activeElement, iText.hiddenTextarea, 'should have focused hiddenTextarea'); + }); }); }); }); \ No newline at end of file diff --git a/test/visual/text.js b/test/visual/text.js index cec22636565..3445d94d48a 100644 --- a/test/visual/text.js +++ b/test/visual/text.js @@ -509,7 +509,7 @@ } } }; - text.setDragImage(dragEventStub, { + text.draggableTextDelegate.setDragImage(dragEventStub, { selectionStart: 3, selectionEnd: 20 });