aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/dragElements.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/dragElements.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/dragElements.ts')
-rw-r--r--packages/excalidraw/element/dragElements.ts286
1 files changed, 286 insertions, 0 deletions
diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts
new file mode 100644
index 0000000..bb8fd23
--- /dev/null
+++ b/packages/excalidraw/element/dragElements.ts
@@ -0,0 +1,286 @@
+import { updateBoundElements } from "./binding";
+import type { Bounds } from "./bounds";
+import { getCommonBounds } from "./bounds";
+import { mutateElement } from "./mutateElement";
+import { getPerfectElementSize } from "./sizeHelpers";
+import type { NonDeletedExcalidrawElement } from "./types";
+import type {
+ AppState,
+ NormalizedZoomValue,
+ NullableGridSize,
+ PointerDownState,
+} from "../types";
+import { getBoundTextElement } from "./textElement";
+import type Scene from "../scene/Scene";
+import {
+ isArrowElement,
+ isElbowArrow,
+ isFrameLikeElement,
+ isImageElement,
+ isTextElement,
+} from "./typeChecks";
+import { getFontString } from "../utils";
+import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
+import { getGridPoint } from "../snapping";
+import { getMinTextElementWidth } from "./textMeasurements";
+
+export const dragSelectedElements = (
+ pointerDownState: PointerDownState,
+ _selectedElements: NonDeletedExcalidrawElement[],
+ offset: { x: number; y: number },
+ scene: Scene,
+ snapOffset: {
+ x: number;
+ y: number;
+ },
+ gridSize: NullableGridSize,
+) => {
+ if (
+ _selectedElements.length === 1 &&
+ isElbowArrow(_selectedElements[0]) &&
+ (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
+ ) {
+ return;
+ }
+
+ const selectedElements = _selectedElements.filter((element) => {
+ if (isElbowArrow(element) && element.startBinding && element.endBinding) {
+ const startElement = _selectedElements.find(
+ (el) => el.id === element.startBinding?.elementId,
+ );
+ const endElement = _selectedElements.find(
+ (el) => el.id === element.endBinding?.elementId,
+ );
+
+ return startElement && endElement;
+ }
+
+ return true;
+ });
+
+ // we do not want a frame and its elements to be selected at the same time
+ // but when it happens (due to some bug), we want to avoid updating element
+ // in the frame twice, hence the use of set
+ const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
+ selectedElements,
+ );
+ const frames = selectedElements
+ .filter((e) => isFrameLikeElement(e))
+ .map((f) => f.id);
+
+ if (frames.length > 0) {
+ for (const element of scene.getNonDeletedElements()) {
+ if (element.frameId !== null && frames.includes(element.frameId)) {
+ elementsToUpdate.add(element);
+ }
+ }
+ }
+
+ const commonBounds = getCommonBounds(
+ Array.from(elementsToUpdate).map(
+ (el) => pointerDownState.originalElements.get(el.id) ?? el,
+ ),
+ );
+ const adjustedOffset = calculateOffset(
+ commonBounds,
+ offset,
+ snapOffset,
+ gridSize,
+ );
+
+ elementsToUpdate.forEach((element) => {
+ updateElementCoords(pointerDownState, element, adjustedOffset);
+ if (!isArrowElement(element)) {
+ // skip arrow labels since we calculate its position during render
+ const textElement = getBoundTextElement(
+ element,
+ scene.getNonDeletedElementsMap(),
+ );
+ if (textElement) {
+ updateElementCoords(pointerDownState, textElement, adjustedOffset);
+ }
+ updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
+ simultaneouslyUpdated: Array.from(elementsToUpdate),
+ });
+ }
+ });
+};
+
+const calculateOffset = (
+ commonBounds: Bounds,
+ dragOffset: { x: number; y: number },
+ snapOffset: { x: number; y: number },
+ gridSize: NullableGridSize,
+): { x: number; y: number } => {
+ const [x, y] = commonBounds;
+ let nextX = x + dragOffset.x + snapOffset.x;
+ let nextY = y + dragOffset.y + snapOffset.y;
+
+ if (snapOffset.x === 0 || snapOffset.y === 0) {
+ const [nextGridX, nextGridY] = getGridPoint(
+ x + dragOffset.x,
+ y + dragOffset.y,
+ gridSize,
+ );
+
+ if (snapOffset.x === 0) {
+ nextX = nextGridX;
+ }
+
+ if (snapOffset.y === 0) {
+ nextY = nextGridY;
+ }
+ }
+ return {
+ x: nextX - x,
+ y: nextY - y,
+ };
+};
+
+const updateElementCoords = (
+ pointerDownState: PointerDownState,
+ element: NonDeletedExcalidrawElement,
+ dragOffset: { x: number; y: number },
+) => {
+ const originalElement =
+ pointerDownState.originalElements.get(element.id) ?? element;
+
+ const nextX = originalElement.x + dragOffset.x;
+ const nextY = originalElement.y + dragOffset.y;
+
+ mutateElement(element, {
+ x: nextX,
+ y: nextY,
+ });
+};
+
+export const getDragOffsetXY = (
+ selectedElements: NonDeletedExcalidrawElement[],
+ x: number,
+ y: number,
+): [number, number] => {
+ const [x1, y1] = getCommonBounds(selectedElements);
+ return [x - x1, y - y1];
+};
+
+export const dragNewElement = ({
+ newElement,
+ elementType,
+ originX,
+ originY,
+ x,
+ y,
+ width,
+ height,
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ zoom,
+ widthAspectRatio = null,
+ originOffset = null,
+ informMutation = true,
+}: {
+ newElement: NonDeletedExcalidrawElement;
+ elementType: AppState["activeTool"]["type"];
+ originX: number;
+ originY: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ shouldMaintainAspectRatio: boolean;
+ shouldResizeFromCenter: boolean;
+ zoom: NormalizedZoomValue;
+ /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
+ true */
+ widthAspectRatio?: number | null;
+ originOffset?: {
+ x: number;
+ y: number;
+ } | null;
+ informMutation?: boolean;
+}) => {
+ if (shouldMaintainAspectRatio && newElement.type !== "selection") {
+ if (widthAspectRatio) {
+ height = width / widthAspectRatio;
+ } else {
+ // Depending on where the cursor is at (x, y) relative to where the starting point is
+ // (originX, originY), we use ONLY width or height to control size increase.
+ // This allows the cursor to always "stick" to one of the sides of the bounding box.
+ if (Math.abs(y - originY) > Math.abs(x - originX)) {
+ ({ width, height } = getPerfectElementSize(
+ elementType,
+ height,
+ x < originX ? -width : width,
+ ));
+ } else {
+ ({ width, height } = getPerfectElementSize(
+ elementType,
+ width,
+ y < originY ? -height : height,
+ ));
+ }
+
+ if (height < 0) {
+ height = -height;
+ }
+ }
+ }
+
+ let newX = x < originX ? originX - width : originX;
+ let newY = y < originY ? originY - height : originY;
+
+ if (shouldResizeFromCenter) {
+ width += width;
+ height += height;
+ newX = originX - width / 2;
+ newY = originY - height / 2;
+ }
+
+ let textAutoResize = null;
+
+ if (isTextElement(newElement)) {
+ height = newElement.height;
+ const minWidth = getMinTextElementWidth(
+ getFontString({
+ fontSize: newElement.fontSize,
+ fontFamily: newElement.fontFamily,
+ }),
+ newElement.lineHeight,
+ );
+ width = Math.max(width, minWidth);
+
+ if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
+ textAutoResize = {
+ autoResize: false,
+ };
+ }
+
+ newY = originY;
+ if (shouldResizeFromCenter) {
+ newX = originX - width / 2;
+ }
+ }
+
+ if (width !== 0 && height !== 0) {
+ let imageInitialDimension = null;
+ if (isImageElement(newElement)) {
+ imageInitialDimension = {
+ initialWidth: width,
+ initialHeight: height,
+ };
+ }
+
+ mutateElement(
+ newElement,
+ {
+ x: newX + (originOffset?.x ?? 0),
+ y: newY + (originOffset?.y ?? 0),
+ width,
+ height,
+ ...textAutoResize,
+ ...imageInitialDimension,
+ },
+ informMutation,
+ );
+ }
+};