247 lines
7.7 KiB
TypeScript
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);
|
|
};
|
|
}
|