Files
upstream-excalidraw/packages/excalidraw/components/App.toolDrag.ts
2026-09-16 05:31:04 +05:00

247 lines
7.7 KiB
TypeScript

import {
DEFAULT_STICKY_NOTE_SIZE,
DRAGGING_THRESHOLD,
EVENT,
KEYS,
getGridPoint,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import { isStickyNoteElement, newStickyNoteElement } from "@excalidraw/element";
import type {
ExcalidrawNonSelectionElement,
NonDeleted,
} from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import type App from "./App";
import type { ToolType } from "../types";
type ScenePoint = { x: number; y: number };
/** what a drag places: any element the new-element canvas can preview */
type DraggedElement = NonDeleted<ExcalidrawNonSelectionElement>;
/**
* A tool the user can drag out of the toolbar to drop a default-sized element
* where the pointer is released.
*/
type DraggableTool = {
/** the element the drop creates, centered on the pointer */
createElement: (app: App, center: ScenePoint) => DraggedElement;
/** what the tool does once its element is on the canvas (e.g. start editing) */
onDrop?: (app: App, element: DraggedElement) => void;
};
const centered = (center: ScenePoint, width: number, height: number) => ({
x: center.x - width / 2,
y: center.y - height / 2,
width,
height,
});
/**
* The tools that can be dragged out of the toolbar. Add an entry to make a
* tool's button draggable — the gesture, preview and drop are shared.
*/
export const DRAGGABLE_TOOLS: Partial<Record<ToolType, DraggableTool>> = {
stickynote: {
createElement: (app, center) => {
const size = DEFAULT_STICKY_NOTE_SIZE;
const { state } = app;
return newStickyNoteElement({
type: "stickynote",
...centered(center, size, size),
baseHeight: size,
strokeColor: state.currentItemStickynoteStrokeColor,
backgroundColor: state.currentItemStickynoteBackgroundColor,
fillStyle: state.currentItemFillStyle,
strokeWidth: app.getCurrentItemStrokeWidth("stickynote"),
strokeStyle: state.currentItemStrokeStyle,
roughness: state.currentItemRoughness,
opacity: state.currentItemOpacity,
roundness: app.getCurrentItemRoundness("stickynote"),
locked: false,
frameId: app.getTopLayerFrameAtSceneCoords(center)?.id ?? null,
});
},
onDrop: (app, element) => {
if (isStickyNoteElement(element)) {
app.startTextEditing({
sceneX: element.x + element.width / 2,
sceneY: element.y + element.height / 2,
container: element,
});
}
},
},
};
/** CSS opacity of the preview canvas while dragging */
export const TOOL_DRAG_PREVIEW_OPACITY = 0.5;
/**
* Dragging a tool out of the toolbar. The preview element exists only here
* until the drop — it is not in the scene, the store or the history, so
* collaborators never see it and an aborted drag leaves nothing behind. It
* is painted by the `NewElementCanvas`, drawn exactly as it will land, with
* the canvas itself made translucent.
*
* The button arms on pointerdown; the drag starts once the pointer has
* moved `DRAGGING_THRESHOLD`, so a plain click still selects the tool. The
* gesture lives on the owner window (the pointer leaves the button at once),
* like the other pointer gestures in `App`.
*/
export class AppToolDrag {
constructor(private app: App) {}
/** the element under the pointer while dragging, `null` otherwise */
public preview: DraggedElement | null = null;
private armed: {
type: ToolType;
pointerId: number;
clientX: number;
clientY: number;
} | null = null;
private tool: DraggableTool | null = null;
private type: ToolType | null = null;
static isDraggable = (type: ToolType) => type in DRAGGABLE_TOOLS;
isDragging = () => this.preview !== null;
/**
* Arm a drag from a tool button's pointerdown. Returns whether the tool is
* draggable (the caller lets a non-draggable press fall through).
*/
handleButtonPointerDown = (type: ToolType, event: PointerEvent) => {
const tool = DRAGGABLE_TOOLS[type];
if (!tool || event.button !== 0 || !this.app.isInteractionEnabled()) {
return false;
}
this.cancel();
this.armed = {
type,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
};
this.tool = tool;
this.type = type;
this.addListeners();
return true;
};
private onPointerMove = (event: PointerEvent) => {
if (!this.armed || event.pointerId !== this.armed.pointerId) {
return;
}
if (!this.preview) {
const moved = Math.hypot(
event.clientX - this.armed.clientX,
event.clientY - this.armed.clientY,
);
if (moved < DRAGGING_THRESHOLD) {
return;
}
trackEvent("toolbar", this.armed.type, "drag");
}
const preview = this.tool!.createElement(
this.app,
viewportCoordsToSceneCoords(event, this.app.state),
);
// Snap after the tool centers its element, just like click-to-place.
const [x, y] = getGridPoint(
preview.x,
preview.y,
event[KEYS.CTRL_OR_CMD] ? null : this.app.getEffectiveGridSize(),
);
this.preview = { ...preview, x, y };
this.app.triggerRender();
};
private onPointerUp = (event: PointerEvent) => {
if (!this.armed || event.pointerId !== this.armed.pointerId) {
return;
}
const { preview, tool, type } = this;
// the button's own click handles a press that never became a drag
if (!preview) {
this.cancel();
return;
}
this.cancel();
if (!this.isOverCanvas(event)) {
return;
}
const { app } = this;
app.insertNewElement(preview);
app.store.scheduleCapture();
app.setState({
selectedElementIds: { [preview.id]: true },
selectedGroupIds: {},
});
// the drop is a one-off placement: whatever tool was active (an arrow,
// say) must not stay armed under the dropped element or its editor
app.setActiveTool(
{ type: app.state.preferredSelectionTool.type },
{ keepSelection: true },
);
trackEvent("toolbar", type!, "drop");
tool!.onDrop?.(app, preview);
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.stopPropagation();
this.cancel();
}
};
/** drop the gesture without a trace */
cancel = () => {
const wasDragging = this.preview !== null;
this.armed = null;
this.tool = null;
this.type = null;
this.preview = null;
this.removeListeners();
if (wasDragging) {
this.app.triggerRender();
}
};
private isOverCanvas = (event: PointerEvent) => {
// the element under the pointer. `elementFromPoint` sees through the
// implicit capture a touch gives the button; jsdom doesn't implement it,
// so fall back to the event's target
const target = (this.app.ownerDocument.elementFromPoint?.(
event.clientX,
event.clientY,
) ?? event.target) as Element | null;
return !!target?.closest?.(".excalidraw__canvas");
};
private addListeners = () => {
const { ownerWindow } = this.app;
ownerWindow.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
ownerWindow.addEventListener(EVENT.POINTER_UP, this.onPointerUp);
ownerWindow.addEventListener(EVENT.POINTER_CANCEL, this.cancel);
ownerWindow.addEventListener(EVENT.BLUR, this.cancel);
ownerWindow.addEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
};
private removeListeners = () => {
const { ownerWindow } = this.app;
ownerWindow.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
ownerWindow.removeEventListener(EVENT.POINTER_UP, this.onPointerUp);
ownerWindow.removeEventListener(EVENT.POINTER_CANCEL, this.cancel);
ownerWindow.removeEventListener(EVENT.BLUR, this.cancel);
ownerWindow.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
};
}