aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/snapping.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/snapping.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/snapping.ts')
-rw-r--r--packages/excalidraw/snapping.ts1425
1 files changed, 1425 insertions, 0 deletions
diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts
new file mode 100644
index 0000000..1b66151
--- /dev/null
+++ b/packages/excalidraw/snapping.ts
@@ -0,0 +1,1425 @@
+import type { InclusiveRange } from "@excalidraw/math";
+import {
+ pointFrom,
+ pointRotateRads,
+ rangeInclusive,
+ rangeIntersection,
+ rangesOverlap,
+ type GlobalPoint,
+} from "@excalidraw/math";
+import { TOOL_TYPE } from "./constants";
+import type { Bounds } from "./element/bounds";
+import {
+ getCommonBounds,
+ getDraggedElementsBounds,
+ getElementAbsoluteCoords,
+} from "./element/bounds";
+import type { MaybeTransformHandleType } from "./element/transformHandles";
+import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+} from "./element/types";
+import { getMaximumGroups } from "./groups";
+import { KEYS } from "./keys";
+import {
+ getSelectedElements,
+ getVisibleAndNonSelectedElements,
+} from "./scene/selection";
+import type {
+ AppClassProperties,
+ AppState,
+ KeyboardModifiersObject,
+ NullableGridSize,
+} from "./types";
+
+const SNAP_DISTANCE = 8;
+
+// do not comput more gaps per axis than this limit
+// TODO increase or remove once we optimize
+const VISIBLE_GAPS_LIMIT_PER_AXIS = 99999;
+
+// snap distance with zoom value taken into consideration
+export const getSnapDistance = (zoomValue: number) => {
+ return SNAP_DISTANCE / zoomValue;
+};
+
+type Vector2D = {
+ x: number;
+ y: number;
+};
+
+type PointPair = [GlobalPoint, GlobalPoint];
+
+export type PointSnap = {
+ type: "point";
+ points: PointPair;
+ offset: number;
+};
+
+export type Gap = {
+ // start side ↓ length
+ // ┌───────────┐◄───────────────►
+ // │ │-----------------┌───────────┐
+ // │ start │ ↑ │ │
+ // │ element │ overlap │ end │
+ // │ │ ↓ │ element │
+ // └───────────┘-----------------│ │
+ // └───────────┘
+ // ↑ end side
+ startBounds: Bounds;
+ endBounds: Bounds;
+ startSide: [GlobalPoint, GlobalPoint];
+ endSide: [GlobalPoint, GlobalPoint];
+ overlap: InclusiveRange;
+ length: number;
+};
+
+export type GapSnap = {
+ type: "gap";
+ direction:
+ | "center_horizontal"
+ | "center_vertical"
+ | "side_left"
+ | "side_right"
+ | "side_top"
+ | "side_bottom";
+ gap: Gap;
+ offset: number;
+};
+
+export type GapSnaps = GapSnap[];
+
+export type Snap = GapSnap | PointSnap;
+export type Snaps = Snap[];
+
+export type PointSnapLine = {
+ type: "points";
+ points: GlobalPoint[];
+};
+
+export type PointerSnapLine = {
+ type: "pointer";
+ points: PointPair;
+ direction: "horizontal" | "vertical";
+};
+
+export type GapSnapLine = {
+ type: "gap";
+ direction: "horizontal" | "vertical";
+ points: PointPair;
+};
+
+export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
+
+// -----------------------------------------------------------------------------
+
+export class SnapCache {
+ private static referenceSnapPoints: GlobalPoint[] | null = null;
+
+ private static visibleGaps: {
+ verticalGaps: Gap[];
+ horizontalGaps: Gap[];
+ } | null = null;
+
+ public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => {
+ SnapCache.referenceSnapPoints = snapPoints;
+ };
+
+ public static getReferenceSnapPoints = () => {
+ return SnapCache.referenceSnapPoints;
+ };
+
+ public static setVisibleGaps = (
+ gaps: {
+ verticalGaps: Gap[];
+ horizontalGaps: Gap[];
+ } | null,
+ ) => {
+ SnapCache.visibleGaps = gaps;
+ };
+
+ public static getVisibleGaps = () => {
+ return SnapCache.visibleGaps;
+ };
+
+ public static destroy = () => {
+ SnapCache.referenceSnapPoints = null;
+ SnapCache.visibleGaps = null;
+ };
+}
+
+// -----------------------------------------------------------------------------
+
+export const isGridModeEnabled = (app: AppClassProperties): boolean =>
+ app.props.gridModeEnabled ?? app.state.gridModeEnabled;
+
+export const isSnappingEnabled = ({
+ event,
+ app,
+ selectedElements,
+}: {
+ app: AppClassProperties;
+ event: KeyboardModifiersObject;
+ selectedElements: NonDeletedExcalidrawElement[];
+}) => {
+ if (event) {
+ return (
+ (app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
+ (!app.state.objectsSnapModeEnabled &&
+ event[KEYS.CTRL_OR_CMD] &&
+ !isGridModeEnabled(app))
+ );
+ }
+
+ // do not suggest snaps for an arrow to give way to binding
+ if (selectedElements.length === 1 && selectedElements[0].type === "arrow") {
+ return false;
+ }
+ return app.state.objectsSnapModeEnabled;
+};
+
+export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
+ return Math.abs(a - b) <= precision;
+};
+
+export const getElementsCorners = (
+ elements: ExcalidrawElement[],
+ elementsMap: ElementsMap,
+ {
+ omitCenter,
+ boundingBoxCorners,
+ dragOffset,
+ }: {
+ omitCenter?: boolean;
+ boundingBoxCorners?: boolean;
+ dragOffset?: Vector2D;
+ } = {
+ omitCenter: false,
+ boundingBoxCorners: false,
+ },
+): GlobalPoint[] => {
+ let result: GlobalPoint[] = [];
+
+ if (elements.length === 1) {
+ const element = elements[0];
+
+ let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+
+ if (dragOffset) {
+ x1 += dragOffset.x;
+ x2 += dragOffset.x;
+ cx += dragOffset.x;
+
+ y1 += dragOffset.y;
+ y2 += dragOffset.y;
+ cy += dragOffset.y;
+ }
+
+ const halfWidth = (x2 - x1) / 2;
+ const halfHeight = (y2 - y1) / 2;
+
+ if (
+ (element.type === "diamond" || element.type === "ellipse") &&
+ !boundingBoxCorners
+ ) {
+ const leftMid = pointRotateRads<GlobalPoint>(
+ pointFrom(x1, y1 + halfHeight),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const topMid = pointRotateRads<GlobalPoint>(
+ pointFrom(x1 + halfWidth, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const rightMid = pointRotateRads<GlobalPoint>(
+ pointFrom(x2, y1 + halfHeight),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const bottomMid = pointRotateRads<GlobalPoint>(
+ pointFrom(x1 + halfWidth, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const center = pointFrom<GlobalPoint>(cx, cy);
+
+ result = omitCenter
+ ? [leftMid, topMid, rightMid, bottomMid]
+ : [leftMid, topMid, rightMid, bottomMid, center];
+ } else {
+ const topLeft = pointRotateRads<GlobalPoint>(
+ pointFrom(x1, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const topRight = pointRotateRads<GlobalPoint>(
+ pointFrom(x2, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const bottomLeft = pointRotateRads<GlobalPoint>(
+ pointFrom(x1, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const bottomRight = pointRotateRads<GlobalPoint>(
+ pointFrom(x2, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const center = pointFrom<GlobalPoint>(cx, cy);
+
+ result = omitCenter
+ ? [topLeft, topRight, bottomLeft, bottomRight]
+ : [topLeft, topRight, bottomLeft, bottomRight, center];
+ }
+ } else if (elements.length > 1) {
+ const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+ elements,
+ dragOffset ?? { x: 0, y: 0 },
+ );
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ const topLeft = pointFrom<GlobalPoint>(minX, minY);
+ const topRight = pointFrom<GlobalPoint>(maxX, minY);
+ const bottomLeft = pointFrom<GlobalPoint>(minX, maxY);
+ const bottomRight = pointFrom<GlobalPoint>(maxX, maxY);
+ const center = pointFrom<GlobalPoint>(minX + width / 2, minY + height / 2);
+
+ result = omitCenter
+ ? [topLeft, topRight, bottomLeft, bottomRight]
+ : [topLeft, topRight, bottomLeft, bottomRight, center];
+ }
+
+ return result.map((p) => pointFrom(round(p[0]), round(p[1])));
+};
+
+const getReferenceElements = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ selectedElements: NonDeletedExcalidrawElement[],
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const selectedFrames = selectedElements
+ .filter((element) => isFrameLikeElement(element))
+ .map((frame) => frame.id);
+
+ return getVisibleAndNonSelectedElements(
+ elements,
+ selectedElements,
+ appState,
+ elementsMap,
+ ).filter(
+ (element) => !(element.frameId && selectedFrames.includes(element.frameId)),
+ );
+};
+
+export const getVisibleGaps = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ selectedElements: ExcalidrawElement[],
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const referenceElements: ExcalidrawElement[] = getReferenceElements(
+ elements,
+ selectedElements,
+ appState,
+ elementsMap,
+ );
+
+ const referenceBounds = getMaximumGroups(referenceElements, elementsMap)
+ .filter(
+ (elementsGroup) =>
+ !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
+ )
+ .map(
+ (group) =>
+ getCommonBounds(group).map((bound) =>
+ round(bound),
+ ) as unknown as Bounds,
+ );
+
+ const horizontallySorted = referenceBounds.sort((a, b) => a[0] - b[0]);
+
+ const horizontalGaps: Gap[] = [];
+
+ let c = 0;
+
+ horizontal: for (let i = 0; i < horizontallySorted.length; i++) {
+ const startBounds = horizontallySorted[i];
+
+ for (let j = i + 1; j < horizontallySorted.length; j++) {
+ if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
+ break horizontal;
+ }
+
+ const endBounds = horizontallySorted[j];
+
+ const [, startMinY, startMaxX, startMaxY] = startBounds;
+ const [endMinX, endMinY, , endMaxY] = endBounds;
+
+ if (
+ startMaxX < endMinX &&
+ rangesOverlap(
+ rangeInclusive(startMinY, startMaxY),
+ rangeInclusive(endMinY, endMaxY),
+ )
+ ) {
+ horizontalGaps.push({
+ startBounds,
+ endBounds,
+ startSide: [
+ pointFrom(startMaxX, startMinY),
+ pointFrom(startMaxX, startMaxY),
+ ],
+ endSide: [pointFrom(endMinX, endMinY), pointFrom(endMinX, endMaxY)],
+ length: endMinX - startMaxX,
+ overlap: rangeIntersection(
+ rangeInclusive(startMinY, startMaxY),
+ rangeInclusive(endMinY, endMaxY),
+ )!,
+ });
+ }
+ }
+ }
+
+ const verticallySorted = referenceBounds.sort((a, b) => a[1] - b[1]);
+
+ const verticalGaps: Gap[] = [];
+
+ c = 0;
+
+ vertical: for (let i = 0; i < verticallySorted.length; i++) {
+ const startBounds = verticallySorted[i];
+
+ for (let j = i + 1; j < verticallySorted.length; j++) {
+ if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
+ break vertical;
+ }
+ const endBounds = verticallySorted[j];
+
+ const [startMinX, , startMaxX, startMaxY] = startBounds;
+ const [endMinX, endMinY, endMaxX] = endBounds;
+
+ if (
+ startMaxY < endMinY &&
+ rangesOverlap(
+ rangeInclusive(startMinX, startMaxX),
+ rangeInclusive(endMinX, endMaxX),
+ )
+ ) {
+ verticalGaps.push({
+ startBounds,
+ endBounds,
+ startSide: [
+ pointFrom(startMinX, startMaxY),
+ pointFrom(startMaxX, startMaxY),
+ ],
+ endSide: [pointFrom(endMinX, endMinY), pointFrom(endMaxX, endMinY)],
+ length: endMinY - startMaxY,
+ overlap: rangeIntersection(
+ rangeInclusive(startMinX, startMaxX),
+ rangeInclusive(endMinX, endMaxX),
+ )!,
+ });
+ }
+ }
+ }
+
+ return {
+ horizontalGaps,
+ verticalGaps,
+ };
+};
+
+const getGapSnaps = (
+ selectedElements: ExcalidrawElement[],
+ dragOffset: Vector2D,
+ app: AppClassProperties,
+ event: KeyboardModifiersObject,
+ nearestSnapsX: Snaps,
+ nearestSnapsY: Snaps,
+ minOffset: Vector2D,
+) => {
+ if (!isSnappingEnabled({ app, event, selectedElements })) {
+ return [];
+ }
+
+ if (selectedElements.length === 0) {
+ return [];
+ }
+
+ const visibleGaps = SnapCache.getVisibleGaps();
+
+ if (visibleGaps) {
+ const { horizontalGaps, verticalGaps } = visibleGaps;
+
+ const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+ selectedElements,
+ dragOffset,
+ ).map((bound) => round(bound));
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+
+ for (const gap of horizontalGaps) {
+ if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) {
+ continue;
+ }
+
+ // center gap
+ const gapMidX = gap.startSide[0][0] + gap.length / 2;
+ const centerOffset = round(gapMidX - centerX);
+ const gapIsLargerThanSelection = gap.length > maxX - minX;
+
+ if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
+ if (Math.abs(centerOffset) < minOffset.x) {
+ nearestSnapsX.length = 0;
+ }
+ minOffset.x = Math.abs(centerOffset);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "center_horizontal",
+ gap,
+ offset: centerOffset,
+ };
+
+ nearestSnapsX.push(snap);
+ continue;
+ }
+
+ // side gap, from the right
+ const [, , endMaxX] = gap.endBounds;
+ const distanceToEndElementX = minX - endMaxX;
+ const sideOffsetRight = round(gap.length - distanceToEndElementX);
+
+ if (Math.abs(sideOffsetRight) <= minOffset.x) {
+ if (Math.abs(sideOffsetRight) < minOffset.x) {
+ nearestSnapsX.length = 0;
+ }
+ minOffset.x = Math.abs(sideOffsetRight);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "side_right",
+ gap,
+ offset: sideOffsetRight,
+ };
+ nearestSnapsX.push(snap);
+ continue;
+ }
+
+ // side gap, from the left
+ const [startMinX, , ,] = gap.startBounds;
+ const distanceToStartElementX = startMinX - maxX;
+ const sideOffsetLeft = round(distanceToStartElementX - gap.length);
+
+ if (Math.abs(sideOffsetLeft) <= minOffset.x) {
+ if (Math.abs(sideOffsetLeft) < minOffset.x) {
+ nearestSnapsX.length = 0;
+ }
+ minOffset.x = Math.abs(sideOffsetLeft);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "side_left",
+ gap,
+ offset: sideOffsetLeft,
+ };
+ nearestSnapsX.push(snap);
+ continue;
+ }
+ }
+ for (const gap of verticalGaps) {
+ if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) {
+ continue;
+ }
+
+ // center gap
+ const gapMidY = gap.startSide[0][1] + gap.length / 2;
+ const centerOffset = round(gapMidY - centerY);
+ const gapIsLargerThanSelection = gap.length > maxY - minY;
+
+ if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) {
+ if (Math.abs(centerOffset) < minOffset.y) {
+ nearestSnapsY.length = 0;
+ }
+ minOffset.y = Math.abs(centerOffset);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "center_vertical",
+ gap,
+ offset: centerOffset,
+ };
+
+ nearestSnapsY.push(snap);
+ continue;
+ }
+
+ // side gap, from the top
+ const [, startMinY, ,] = gap.startBounds;
+ const distanceToStartElementY = startMinY - maxY;
+ const sideOffsetTop = round(distanceToStartElementY - gap.length);
+
+ if (Math.abs(sideOffsetTop) <= minOffset.y) {
+ if (Math.abs(sideOffsetTop) < minOffset.y) {
+ nearestSnapsY.length = 0;
+ }
+ minOffset.y = Math.abs(sideOffsetTop);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "side_top",
+ gap,
+ offset: sideOffsetTop,
+ };
+ nearestSnapsY.push(snap);
+ continue;
+ }
+
+ // side gap, from the bottom
+ const [, , , endMaxY] = gap.endBounds;
+ const distanceToEndElementY = round(minY - endMaxY);
+ const sideOffsetBottom = gap.length - distanceToEndElementY;
+
+ if (Math.abs(sideOffsetBottom) <= minOffset.y) {
+ if (Math.abs(sideOffsetBottom) < minOffset.y) {
+ nearestSnapsY.length = 0;
+ }
+ minOffset.y = Math.abs(sideOffsetBottom);
+
+ const snap: GapSnap = {
+ type: "gap",
+ direction: "side_bottom",
+ gap,
+ offset: sideOffsetBottom,
+ };
+ nearestSnapsY.push(snap);
+ continue;
+ }
+ }
+ }
+};
+
+export const getReferenceSnapPoints = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ selectedElements: ExcalidrawElement[],
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const referenceElements = getReferenceElements(
+ elements,
+ selectedElements,
+ appState,
+ elementsMap,
+ );
+ return getMaximumGroups(referenceElements, elementsMap)
+ .filter(
+ (elementsGroup) =>
+ !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
+ )
+ .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap));
+};
+
+const getPointSnaps = (
+ selectedElements: ExcalidrawElement[],
+ selectionSnapPoints: GlobalPoint[],
+ app: AppClassProperties,
+ event: KeyboardModifiersObject,
+ nearestSnapsX: Snaps,
+ nearestSnapsY: Snaps,
+ minOffset: Vector2D,
+) => {
+ if (
+ !isSnappingEnabled({ app, event, selectedElements }) ||
+ (selectedElements.length === 0 && selectionSnapPoints.length === 0)
+ ) {
+ return [];
+ }
+
+ const referenceSnapPoints = SnapCache.getReferenceSnapPoints();
+
+ if (referenceSnapPoints) {
+ for (const thisSnapPoint of selectionSnapPoints) {
+ for (const otherSnapPoint of referenceSnapPoints) {
+ const offsetX = otherSnapPoint[0] - thisSnapPoint[0];
+ const offsetY = otherSnapPoint[1] - thisSnapPoint[1];
+
+ if (Math.abs(offsetX) <= minOffset.x) {
+ if (Math.abs(offsetX) < minOffset.x) {
+ nearestSnapsX.length = 0;
+ }
+
+ nearestSnapsX.push({
+ type: "point",
+ points: [thisSnapPoint, otherSnapPoint],
+ offset: offsetX,
+ });
+
+ minOffset.x = Math.abs(offsetX);
+ }
+
+ if (Math.abs(offsetY) <= minOffset.y) {
+ if (Math.abs(offsetY) < minOffset.y) {
+ nearestSnapsY.length = 0;
+ }
+
+ nearestSnapsY.push({
+ type: "point",
+ points: [thisSnapPoint, otherSnapPoint],
+ offset: offsetY,
+ });
+
+ minOffset.y = Math.abs(offsetY);
+ }
+ }
+ }
+ }
+};
+
+export const snapDraggedElements = (
+ elements: ExcalidrawElement[],
+ dragOffset: Vector2D,
+ app: AppClassProperties,
+ event: KeyboardModifiersObject,
+ elementsMap: ElementsMap,
+) => {
+ const appState = app.state;
+ const selectedElements = getSelectedElements(elements, appState);
+ if (
+ !isSnappingEnabled({ app, event, selectedElements }) ||
+ selectedElements.length === 0
+ ) {
+ return {
+ snapOffset: {
+ x: 0,
+ y: 0,
+ },
+ snapLines: [],
+ };
+ }
+ dragOffset.x = round(dragOffset.x);
+ dragOffset.y = round(dragOffset.y);
+ const nearestSnapsX: Snaps = [];
+ const nearestSnapsY: Snaps = [];
+ const snapDistance = getSnapDistance(appState.zoom.value);
+ const minOffset = {
+ x: snapDistance,
+ y: snapDistance,
+ };
+
+ const selectionPoints = getElementsCorners(selectedElements, elementsMap, {
+ dragOffset,
+ });
+
+ // get the nearest horizontal and vertical point and gap snaps
+ getPointSnaps(
+ selectedElements,
+ selectionPoints,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ getGapSnaps(
+ selectedElements,
+ dragOffset,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ // using the nearest snaps to figure out how
+ // much the elements need to be offset to be snapped
+ // to some reference elements
+ const snapOffset = {
+ x: nearestSnapsX[0]?.offset ?? 0,
+ y: nearestSnapsY[0]?.offset ?? 0,
+ };
+
+ // once the elements are snapped
+ // and moved to the snapped position
+ // we want to use the element's snapped position
+ // to update nearest snaps so that we can create
+ // point and gap snap lines correctly without any shifting
+
+ minOffset.x = 0;
+ minOffset.y = 0;
+ nearestSnapsX.length = 0;
+ nearestSnapsY.length = 0;
+ const newDragOffset = {
+ x: round(dragOffset.x + snapOffset.x),
+ y: round(dragOffset.y + snapOffset.y),
+ };
+
+ getPointSnaps(
+ selectedElements,
+ getElementsCorners(selectedElements, elementsMap, {
+ dragOffset: newDragOffset,
+ }),
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ getGapSnaps(
+ selectedElements,
+ newDragOffset,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+ const gapSnapLines = createGapSnapLines(
+ selectedElements,
+ newDragOffset,
+ [...nearestSnapsX, ...nearestSnapsY].filter(
+ (snap) => snap.type === "gap",
+ ) as GapSnap[],
+ );
+
+ return {
+ snapOffset,
+ snapLines: [...pointSnapLines, ...gapSnapLines],
+ };
+};
+
+const round = (x: number) => {
+ const decimalPlaces = 6;
+ return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
+};
+
+const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
+ const map = new Map<string, GlobalPoint>();
+
+ for (const point of points) {
+ const key = point.join(",");
+
+ if (!map.has(key)) {
+ map.set(key, point);
+ }
+ }
+
+ return Array.from(map.values());
+};
+
+const createPointSnapLines = (
+ nearestSnapsX: Snaps,
+ nearestSnapsY: Snaps,
+): PointSnapLine[] => {
+ const snapsX = {} as { [key: string]: GlobalPoint[] };
+ const snapsY = {} as { [key: string]: GlobalPoint[] };
+
+ if (nearestSnapsX.length > 0) {
+ for (const snap of nearestSnapsX) {
+ if (snap.type === "point") {
+ // key = thisPoint.x
+ const key = round(snap.points[0][0]);
+ if (!snapsX[key]) {
+ snapsX[key] = [];
+ }
+ snapsX[key].push(
+ ...snap.points.map((p) =>
+ pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
+ ),
+ );
+ }
+ }
+ }
+
+ if (nearestSnapsY.length > 0) {
+ for (const snap of nearestSnapsY) {
+ if (snap.type === "point") {
+ // key = thisPoint.y
+ const key = round(snap.points[0][1]);
+ if (!snapsY[key]) {
+ snapsY[key] = [];
+ }
+ snapsY[key].push(
+ ...snap.points.map((p) =>
+ pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
+ ),
+ );
+ }
+ }
+ }
+
+ return Object.entries(snapsX)
+ .map(([key, points]) => {
+ return {
+ type: "points",
+ points: dedupePoints(
+ points
+ .map((p) => {
+ return pointFrom<GlobalPoint>(Number(key), p[1]);
+ })
+ .sort((a, b) => a[1] - b[1]),
+ ),
+ } as PointSnapLine;
+ })
+ .concat(
+ Object.entries(snapsY).map(([key, points]) => {
+ return {
+ type: "points",
+ points: dedupePoints(
+ points
+ .map((p) => {
+ return pointFrom<GlobalPoint>(p[0], Number(key));
+ })
+ .sort((a, b) => a[0] - b[0]),
+ ),
+ } as PointSnapLine;
+ }),
+ );
+};
+
+const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => {
+ const map = new Map<string, GapSnapLine>();
+
+ for (const gapSnapLine of gapSnapLines) {
+ const key = gapSnapLine.points
+ .flat()
+ .map((point) => [round(point)])
+ .join(",");
+
+ if (!map.has(key)) {
+ map.set(key, gapSnapLine);
+ }
+ }
+
+ return Array.from(map.values());
+};
+
+const createGapSnapLines = (
+ selectedElements: ExcalidrawElement[],
+ dragOffset: Vector2D,
+ gapSnaps: GapSnap[],
+): GapSnapLine[] => {
+ const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
+ selectedElements,
+ dragOffset,
+ );
+
+ const gapSnapLines: GapSnapLine[] = [];
+
+ for (const gapSnap of gapSnaps) {
+ const [startMinX, startMinY, startMaxX, startMaxY] =
+ gapSnap.gap.startBounds;
+ const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
+
+ const verticalIntersection = rangeIntersection(
+ rangeInclusive(minY, maxY),
+ gapSnap.gap.overlap,
+ );
+
+ const horizontalGapIntersection = rangeIntersection(
+ rangeInclusive(minX, maxX),
+ gapSnap.gap.overlap,
+ );
+
+ switch (gapSnap.direction) {
+ case "center_horizontal": {
+ if (verticalIntersection) {
+ const gapLineY =
+ (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [
+ pointFrom(gapSnap.gap.startSide[0][0], gapLineY),
+ pointFrom(minX, gapLineY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [
+ pointFrom(maxX, gapLineY),
+ pointFrom(gapSnap.gap.endSide[0][0], gapLineY),
+ ],
+ },
+ );
+ }
+ break;
+ }
+ case "center_vertical": {
+ if (horizontalGapIntersection) {
+ const gapLineX =
+ (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [
+ pointFrom(gapLineX, gapSnap.gap.startSide[0][1]),
+ pointFrom(gapLineX, minY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [
+ pointFrom(gapLineX, maxY),
+ pointFrom(gapLineX, gapSnap.gap.endSide[0][1]),
+ ],
+ },
+ );
+ }
+ break;
+ }
+ case "side_right": {
+ if (verticalIntersection) {
+ const gapLineY =
+ (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [
+ pointFrom(startMaxX, gapLineY),
+ pointFrom(endMinX, gapLineY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [pointFrom(endMaxX, gapLineY), pointFrom(minX, gapLineY)],
+ },
+ );
+ }
+ break;
+ }
+ case "side_left": {
+ if (verticalIntersection) {
+ const gapLineY =
+ (verticalIntersection[0] + verticalIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [
+ pointFrom(maxX, gapLineY),
+ pointFrom(startMinX, gapLineY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "horizontal",
+ points: [
+ pointFrom(startMaxX, gapLineY),
+ pointFrom(endMinX, gapLineY),
+ ],
+ },
+ );
+ }
+ break;
+ }
+ case "side_top": {
+ if (horizontalGapIntersection) {
+ const gapLineX =
+ (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [
+ pointFrom(gapLineX, maxY),
+ pointFrom(gapLineX, startMinY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [
+ pointFrom(gapLineX, startMaxY),
+ pointFrom(gapLineX, endMinY),
+ ],
+ },
+ );
+ }
+ break;
+ }
+ case "side_bottom": {
+ if (horizontalGapIntersection) {
+ const gapLineX =
+ (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
+
+ gapSnapLines.push(
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [
+ pointFrom(gapLineX, startMaxY),
+ pointFrom(gapLineX, endMinY),
+ ],
+ },
+ {
+ type: "gap",
+ direction: "vertical",
+ points: [pointFrom(gapLineX, endMaxY), pointFrom(gapLineX, minY)],
+ },
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ return dedupeGapSnapLines(
+ gapSnapLines.map((gapSnapLine) => {
+ return {
+ ...gapSnapLine,
+ points: gapSnapLine.points.map((p) =>
+ pointFrom(round(p[0]), round(p[1])),
+ ) as PointPair,
+ };
+ }),
+ );
+};
+
+export const snapResizingElements = (
+ // use the latest elements to create snap lines
+ selectedElements: ExcalidrawElement[],
+ // while using the original elements to appy dragOffset to calculate snaps
+ selectedOriginalElements: ExcalidrawElement[],
+ app: AppClassProperties,
+ event: KeyboardModifiersObject,
+ dragOffset: Vector2D,
+ transformHandle: MaybeTransformHandleType,
+) => {
+ if (
+ !isSnappingEnabled({ event, selectedElements, app }) ||
+ selectedElements.length === 0 ||
+ (selectedElements.length === 1 &&
+ !areRoughlyEqual(selectedElements[0].angle, 0))
+ ) {
+ return {
+ snapOffset: { x: 0, y: 0 },
+ snapLines: [],
+ };
+ }
+
+ let [minX, minY, maxX, maxY] = getCommonBounds(selectedOriginalElements);
+
+ if (transformHandle) {
+ if (transformHandle.includes("e")) {
+ maxX += dragOffset.x;
+ } else if (transformHandle.includes("w")) {
+ minX += dragOffset.x;
+ }
+
+ if (transformHandle.includes("n")) {
+ minY += dragOffset.y;
+ } else if (transformHandle.includes("s")) {
+ maxY += dragOffset.y;
+ }
+ }
+
+ const selectionSnapPoints: GlobalPoint[] = [];
+
+ if (transformHandle) {
+ switch (transformHandle) {
+ case "e": {
+ selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY));
+ break;
+ }
+ case "w": {
+ selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY));
+ break;
+ }
+ case "n": {
+ selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY));
+ break;
+ }
+ case "s": {
+ selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY));
+ break;
+ }
+ case "ne": {
+ selectionSnapPoints.push(pointFrom(maxX, minY));
+ break;
+ }
+ case "nw": {
+ selectionSnapPoints.push(pointFrom(minX, minY));
+ break;
+ }
+ case "se": {
+ selectionSnapPoints.push(pointFrom(maxX, maxY));
+ break;
+ }
+ case "sw": {
+ selectionSnapPoints.push(pointFrom(minX, maxY));
+ break;
+ }
+ }
+ }
+
+ const snapDistance = getSnapDistance(app.state.zoom.value);
+
+ const minOffset = {
+ x: snapDistance,
+ y: snapDistance,
+ };
+
+ const nearestSnapsX: Snaps = [];
+ const nearestSnapsY: Snaps = [];
+
+ getPointSnaps(
+ selectedOriginalElements,
+ selectionSnapPoints,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ const snapOffset = {
+ x: nearestSnapsX[0]?.offset ?? 0,
+ y: nearestSnapsY[0]?.offset ?? 0,
+ };
+
+ // again, once snap offset is calculated
+ // reset to recompute for creating snap lines to be rendered
+ minOffset.x = 0;
+ minOffset.y = 0;
+ nearestSnapsX.length = 0;
+ nearestSnapsY.length = 0;
+
+ const [x1, y1, x2, y2] = getCommonBounds(selectedElements).map((bound) =>
+ round(bound),
+ );
+
+ const corners: GlobalPoint[] = [
+ pointFrom(x1, y1),
+ pointFrom(x1, y2),
+ pointFrom(x2, y1),
+ pointFrom(x2, y2),
+ ];
+
+ getPointSnaps(
+ selectedElements,
+ corners,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+ return {
+ snapOffset,
+ snapLines: pointSnapLines,
+ };
+};
+
+export const snapNewElement = (
+ newElement: ExcalidrawElement,
+ app: AppClassProperties,
+ event: KeyboardModifiersObject,
+ origin: Vector2D,
+ dragOffset: Vector2D,
+ elementsMap: ElementsMap,
+) => {
+ if (!isSnappingEnabled({ event, selectedElements: [newElement], app })) {
+ return {
+ snapOffset: { x: 0, y: 0 },
+ snapLines: [],
+ };
+ }
+
+ const selectionSnapPoints: GlobalPoint[] = [
+ pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y),
+ ];
+
+ const snapDistance = getSnapDistance(app.state.zoom.value);
+
+ const minOffset = {
+ x: snapDistance,
+ y: snapDistance,
+ };
+
+ const nearestSnapsX: Snaps = [];
+ const nearestSnapsY: Snaps = [];
+
+ getPointSnaps(
+ [newElement],
+ selectionSnapPoints,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ const snapOffset = {
+ x: nearestSnapsX[0]?.offset ?? 0,
+ y: nearestSnapsY[0]?.offset ?? 0,
+ };
+
+ minOffset.x = 0;
+ minOffset.y = 0;
+ nearestSnapsX.length = 0;
+ nearestSnapsY.length = 0;
+
+ const corners = getElementsCorners([newElement], elementsMap, {
+ boundingBoxCorners: true,
+ omitCenter: true,
+ });
+
+ getPointSnaps(
+ [newElement],
+ corners,
+ app,
+ event,
+ nearestSnapsX,
+ nearestSnapsY,
+ minOffset,
+ );
+
+ const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
+
+ return {
+ snapOffset,
+ snapLines: pointSnapLines,
+ };
+};
+
+export const getSnapLinesAtPointer = (
+ elements: readonly ExcalidrawElement[],
+ app: AppClassProperties,
+ pointer: Vector2D,
+ event: KeyboardModifiersObject,
+ elementsMap: ElementsMap,
+) => {
+ if (!isSnappingEnabled({ event, selectedElements: [], app })) {
+ return {
+ originOffset: { x: 0, y: 0 },
+ snapLines: [],
+ };
+ }
+
+ const referenceElements = getVisibleAndNonSelectedElements(
+ elements,
+ [],
+ app.state,
+ elementsMap,
+ );
+
+ const snapDistance = getSnapDistance(app.state.zoom.value);
+
+ const minOffset = {
+ x: snapDistance,
+ y: snapDistance,
+ };
+
+ const horizontalSnapLines: PointerSnapLine[] = [];
+ const verticalSnapLines: PointerSnapLine[] = [];
+
+ for (const referenceElement of referenceElements) {
+ const corners = getElementsCorners([referenceElement], elementsMap);
+
+ for (const corner of corners) {
+ const offsetX = corner[0] - pointer.x;
+
+ if (Math.abs(offsetX) <= Math.abs(minOffset.x)) {
+ if (Math.abs(offsetX) < Math.abs(minOffset.x)) {
+ verticalSnapLines.length = 0;
+ }
+
+ verticalSnapLines.push({
+ type: "pointer",
+ points: [corner, pointFrom(corner[0], pointer.y)],
+ direction: "vertical",
+ });
+
+ minOffset.x = offsetX;
+ }
+
+ const offsetY = corner[1] - pointer.y;
+
+ if (Math.abs(offsetY) <= Math.abs(minOffset.y)) {
+ if (Math.abs(offsetY) < Math.abs(minOffset.y)) {
+ horizontalSnapLines.length = 0;
+ }
+
+ horizontalSnapLines.push({
+ type: "pointer",
+ points: [corner, pointFrom(pointer.x, corner[1])],
+ direction: "horizontal",
+ });
+
+ minOffset.y = offsetY;
+ }
+ }
+ }
+
+ return {
+ originOffset: {
+ x:
+ verticalSnapLines.length > 0
+ ? verticalSnapLines[0].points[0][0] - pointer.x
+ : 0,
+ y:
+ horizontalSnapLines.length > 0
+ ? horizontalSnapLines[0].points[0][1] - pointer.y
+ : 0,
+ },
+ snapLines: [...verticalSnapLines, ...horizontalSnapLines],
+ };
+};
+
+export const isActiveToolNonLinearSnappable = (
+ activeToolType: AppState["activeTool"]["type"],
+) => {
+ return (
+ activeToolType === TOOL_TYPE.rectangle ||
+ activeToolType === TOOL_TYPE.ellipse ||
+ activeToolType === TOOL_TYPE.diamond ||
+ activeToolType === TOOL_TYPE.frame ||
+ activeToolType === TOOL_TYPE.magicframe ||
+ activeToolType === TOOL_TYPE.image ||
+ activeToolType === TOOL_TYPE.text
+ );
+};
+
+// TODO: Rounding this point causes some shake when free drawing
+export const getGridPoint = (
+ x: number,
+ y: number,
+ gridSize: NullableGridSize,
+): [number, number] => {
+ if (gridSize) {
+ return [
+ Math.round(x / gridSize) * gridSize,
+ Math.round(y / gridSize) * gridSize,
+ ];
+ }
+ return [x, y];
+};