fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
388 lines (369 loc) • 13.9 kB
text/typescript
import type {
DragEventData,
DropEventData,
TPointerEvent,
} from '../../EventTypeDefs';
import { Point } from '../../Point';
import type { IText } from './IText';
import { setStyle } from '../../util/dom_style';
import { cloneStyles } from '../../util/internals/cloneStyles';
import type { TextStyleDeclaration } from '../Text/StyledText';
import { getDocumentFromElement } from '../../util/dom_misc';
import { CHANGED, NONE } from '../../constants';
/**
* #### 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.getScenePoint(e);
const diff = pointer.subtract(pos);
const retinaScaling = target.getCanvasRetinaScaling();
const bbox = target.getBoundingRect();
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 = cloneStyles(target.styles);
target.backgroundColor = '';
const styleOverride = {
stroke: 'transparent',
fill: 'transparent',
textBackgroundColor: 'transparent',
};
target.setSelectionStyles(styleOverride, 0, selectionStart);
target.setSelectionStyles(styleOverride, selectionEnd, target.text.length);
target.dirty = true;
const dragImage = target.toCanvasElement({
enableRetinaScaling: canvas.enableRetinaScaling,
viewportTransform: true,
});
// restore values
target.backgroundColor = bgc;
target.styles = styles;
target.dirty = true;
// position drag image offscreen
setStyle(dragImage, {
position: 'fixed',
left: `${-dragImage.width}px`,
border: NONE,
width: `${dragImage.width / retinaScaling}px`,
height: `${dragImage.height / retinaScaling}px`,
});
this.__dragImageDisposer && this.__dragImageDisposer();
this.__dragImageDisposer = () => {
dragImage.remove();
};
getDocumentFromElement(
(e.target || this.target.hiddenTextarea)! as HTMLElement,
).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.getActiveControl() &&
!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.removeChars(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.removeChars(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();
}
}