|
@@ -0,0 +1,1361 @@
|
|
|
+import {
|
|
|
+ Bounds,
|
|
|
+ getCommonBounds,
|
|
|
+ getDraggedElementsBounds,
|
|
|
+ getElementAbsoluteCoords,
|
|
|
+} from "./element/bounds";
|
|
|
+import { MaybeTransformHandleType } from "./element/transformHandles";
|
|
|
+import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
|
|
|
+import {
|
|
|
+ ExcalidrawElement,
|
|
|
+ NonDeletedExcalidrawElement,
|
|
|
+} from "./element/types";
|
|
|
+import { getMaximumGroups } from "./groups";
|
|
|
+import { KEYS } from "./keys";
|
|
|
+import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
|
|
|
+import { getVisibleAndNonSelectedElements } from "./scene/selection";
|
|
|
+import { AppState, KeyboardModifiersObject, Point } 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 = [Point, Point];
|
|
|
+
|
|
|
+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: [Point, Point];
|
|
|
+ endSide: [Point, Point];
|
|
|
+ overlap: [number, number];
|
|
|
+ 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: Point[];
|
|
|
+};
|
|
|
+
|
|
|
+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: Point[] | null = null;
|
|
|
+
|
|
|
+ private static visibleGaps: {
|
|
|
+ verticalGaps: Gap[];
|
|
|
+ horizontalGaps: Gap[];
|
|
|
+ } | null = null;
|
|
|
+
|
|
|
+ public static setReferenceSnapPoints = (snapPoints: Point[] | 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 isSnappingEnabled = ({
|
|
|
+ event,
|
|
|
+ appState,
|
|
|
+ selectedElements,
|
|
|
+}: {
|
|
|
+ appState: AppState;
|
|
|
+ event: KeyboardModifiersObject;
|
|
|
+ selectedElements: NonDeletedExcalidrawElement[];
|
|
|
+}) => {
|
|
|
+ if (event) {
|
|
|
+ return (
|
|
|
+ (appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
|
|
+ (!appState.objectsSnapModeEnabled &&
|
|
|
+ event[KEYS.CTRL_OR_CMD] &&
|
|
|
+ appState.gridSize === null)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // do not suggest snaps for an arrow to give way to binding
|
|
|
+ if (selectedElements.length === 1 && selectedElements[0].type === "arrow") {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return appState.objectsSnapModeEnabled;
|
|
|
+};
|
|
|
+
|
|
|
+export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
|
|
|
+ return Math.abs(a - b) <= precision;
|
|
|
+};
|
|
|
+
|
|
|
+export const getElementsCorners = (
|
|
|
+ elements: ExcalidrawElement[],
|
|
|
+ {
|
|
|
+ omitCenter,
|
|
|
+ boundingBoxCorners,
|
|
|
+ dragOffset,
|
|
|
+ }: {
|
|
|
+ omitCenter?: boolean;
|
|
|
+ boundingBoxCorners?: boolean;
|
|
|
+ dragOffset?: Vector2D;
|
|
|
+ } = {
|
|
|
+ omitCenter: false,
|
|
|
+ boundingBoxCorners: false,
|
|
|
+ },
|
|
|
+): Point[] => {
|
|
|
+ let result: Point[] = [];
|
|
|
+
|
|
|
+ if (elements.length === 1) {
|
|
|
+ const element = elements[0];
|
|
|
+
|
|
|
+ let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
|
|
+
|
|
|
+ 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 = rotatePoint(
|
|
|
+ [x1, y1 + halfHeight],
|
|
|
+ [cx, cy],
|
|
|
+ element.angle,
|
|
|
+ );
|
|
|
+ const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
|
|
|
+ const rightMid = rotatePoint(
|
|
|
+ [x2, y1 + halfHeight],
|
|
|
+ [cx, cy],
|
|
|
+ element.angle,
|
|
|
+ );
|
|
|
+ const bottomMid = rotatePoint(
|
|
|
+ [x1 + halfWidth, y2],
|
|
|
+ [cx, cy],
|
|
|
+ element.angle,
|
|
|
+ );
|
|
|
+ const center: Point = [cx, cy];
|
|
|
+
|
|
|
+ result = omitCenter
|
|
|
+ ? [leftMid, topMid, rightMid, bottomMid]
|
|
|
+ : [leftMid, topMid, rightMid, bottomMid, center];
|
|
|
+ } else {
|
|
|
+ const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
|
|
|
+ const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
|
|
|
+ const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
|
|
|
+ const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
|
|
|
+ const center: Point = [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: Point = [minX, minY];
|
|
|
+ const topRight: Point = [maxX, minY];
|
|
|
+ const bottomLeft: Point = [minX, maxY];
|
|
|
+ const bottomRight: Point = [maxX, maxY];
|
|
|
+ const center: Point = [minX + width / 2, minY + height / 2];
|
|
|
+
|
|
|
+ result = omitCenter
|
|
|
+ ? [topLeft, topRight, bottomLeft, bottomRight]
|
|
|
+ : [topLeft, topRight, bottomLeft, bottomRight, center];
|
|
|
+ }
|
|
|
+
|
|
|
+ return result.map((point) => [round(point[0]), round(point[1])] as Point);
|
|
|
+};
|
|
|
+
|
|
|
+const getReferenceElements = (
|
|
|
+ elements: readonly NonDeletedExcalidrawElement[],
|
|
|
+ selectedElements: NonDeletedExcalidrawElement[],
|
|
|
+ appState: AppState,
|
|
|
+) => {
|
|
|
+ const selectedFrames = selectedElements
|
|
|
+ .filter((element) => isFrameElement(element))
|
|
|
+ .map((frame) => frame.id);
|
|
|
+
|
|
|
+ return getVisibleAndNonSelectedElements(
|
|
|
+ elements,
|
|
|
+ selectedElements,
|
|
|
+ appState,
|
|
|
+ ).filter(
|
|
|
+ (element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export const getVisibleGaps = (
|
|
|
+ elements: readonly NonDeletedExcalidrawElement[],
|
|
|
+ selectedElements: ExcalidrawElement[],
|
|
|
+ appState: AppState,
|
|
|
+) => {
|
|
|
+ const referenceElements: ExcalidrawElement[] = getReferenceElements(
|
|
|
+ elements,
|
|
|
+ selectedElements,
|
|
|
+ appState,
|
|
|
+ );
|
|
|
+
|
|
|
+ const referenceBounds = getMaximumGroups(referenceElements)
|
|
|
+ .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([startMinY, startMaxY], [endMinY, endMaxY])
|
|
|
+ ) {
|
|
|
+ horizontalGaps.push({
|
|
|
+ startBounds,
|
|
|
+ endBounds,
|
|
|
+ startSide: [
|
|
|
+ [startMaxX, startMinY],
|
|
|
+ [startMaxX, startMaxY],
|
|
|
+ ],
|
|
|
+ endSide: [
|
|
|
+ [endMinX, endMinY],
|
|
|
+ [endMinX, endMaxY],
|
|
|
+ ],
|
|
|
+ length: endMinX - startMaxX,
|
|
|
+ overlap: rangeIntersection(
|
|
|
+ [startMinY, startMaxY],
|
|
|
+ [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([startMinX, startMaxX], [endMinX, endMaxX])
|
|
|
+ ) {
|
|
|
+ verticalGaps.push({
|
|
|
+ startBounds,
|
|
|
+ endBounds,
|
|
|
+ startSide: [
|
|
|
+ [startMinX, startMaxY],
|
|
|
+ [startMaxX, startMaxY],
|
|
|
+ ],
|
|
|
+ endSide: [
|
|
|
+ [endMinX, endMinY],
|
|
|
+ [endMaxX, endMinY],
|
|
|
+ ],
|
|
|
+ length: endMinY - startMaxY,
|
|
|
+ overlap: rangeIntersection(
|
|
|
+ [startMinX, startMaxX],
|
|
|
+ [endMinX, endMaxX],
|
|
|
+ )!,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ horizontalGaps,
|
|
|
+ verticalGaps,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const getGapSnaps = (
|
|
|
+ selectedElements: ExcalidrawElement[],
|
|
|
+ dragOffset: Vector2D,
|
|
|
+ appState: AppState,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+ nearestSnapsX: Snaps,
|
|
|
+ nearestSnapsY: Snaps,
|
|
|
+ minOffset: Vector2D,
|
|
|
+) => {
|
|
|
+ if (!isSnappingEnabled({ appState, 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([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([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,
|
|
|
+) => {
|
|
|
+ const referenceElements = getReferenceElements(
|
|
|
+ elements,
|
|
|
+ selectedElements,
|
|
|
+ appState,
|
|
|
+ );
|
|
|
+
|
|
|
+ return getMaximumGroups(referenceElements)
|
|
|
+ .filter(
|
|
|
+ (elementsGroup) =>
|
|
|
+ !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
|
|
|
+ )
|
|
|
+ .flatMap((elementGroup) => getElementsCorners(elementGroup));
|
|
|
+};
|
|
|
+
|
|
|
+const getPointSnaps = (
|
|
|
+ selectedElements: ExcalidrawElement[],
|
|
|
+ selectionSnapPoints: Point[],
|
|
|
+ appState: AppState,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+ nearestSnapsX: Snaps,
|
|
|
+ nearestSnapsY: Snaps,
|
|
|
+ minOffset: Vector2D,
|
|
|
+) => {
|
|
|
+ if (
|
|
|
+ !isSnappingEnabled({ appState, 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 = (
|
|
|
+ selectedElements: ExcalidrawElement[],
|
|
|
+ dragOffset: Vector2D,
|
|
|
+ appState: AppState,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+) => {
|
|
|
+ if (
|
|
|
+ !isSnappingEnabled({ appState, 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, {
|
|
|
+ dragOffset,
|
|
|
+ });
|
|
|
+
|
|
|
+ // get the nearest horizontal and vertical point and gap snaps
|
|
|
+ getPointSnaps(
|
|
|
+ selectedElements,
|
|
|
+ selectionPoints,
|
|
|
+ appState,
|
|
|
+ event,
|
|
|
+ nearestSnapsX,
|
|
|
+ nearestSnapsY,
|
|
|
+ minOffset,
|
|
|
+ );
|
|
|
+
|
|
|
+ getGapSnaps(
|
|
|
+ selectedElements,
|
|
|
+ dragOffset,
|
|
|
+ appState,
|
|
|
+ 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, {
|
|
|
+ dragOffset: newDragOffset,
|
|
|
+ }),
|
|
|
+ appState,
|
|
|
+ event,
|
|
|
+ nearestSnapsX,
|
|
|
+ nearestSnapsY,
|
|
|
+ minOffset,
|
|
|
+ );
|
|
|
+
|
|
|
+ getGapSnaps(
|
|
|
+ selectedElements,
|
|
|
+ newDragOffset,
|
|
|
+ appState,
|
|
|
+ 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: Point[]): Point[] => {
|
|
|
+ const map = new Map<string, Point>();
|
|
|
+
|
|
|
+ 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]: Point[] };
|
|
|
+ const snapsY = {} as { [key: string]: Point[] };
|
|
|
+
|
|
|
+ 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(
|
|
|
+ (point) => [round(point[0]), round(point[1])] as Point,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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(
|
|
|
+ (point) => [round(point[0]), round(point[1])] as Point,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return Object.entries(snapsX)
|
|
|
+ .map(([key, points]) => {
|
|
|
+ return {
|
|
|
+ type: "points",
|
|
|
+ points: dedupePoints(
|
|
|
+ points
|
|
|
+ .map((point) => {
|
|
|
+ return [Number(key), point[1]] as Point;
|
|
|
+ })
|
|
|
+ .sort((a, b) => a[1] - b[1]),
|
|
|
+ ),
|
|
|
+ } as PointSnapLine;
|
|
|
+ })
|
|
|
+ .concat(
|
|
|
+ Object.entries(snapsY).map(([key, points]) => {
|
|
|
+ return {
|
|
|
+ type: "points",
|
|
|
+ points: dedupePoints(
|
|
|
+ points
|
|
|
+ .map((point) => {
|
|
|
+ return [point[0], Number(key)] as Point;
|
|
|
+ })
|
|
|
+ .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(
|
|
|
+ [minY, maxY],
|
|
|
+ gapSnap.gap.overlap,
|
|
|
+ );
|
|
|
+
|
|
|
+ const horizontalGapIntersection = rangeIntersection(
|
|
|
+ [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: [
|
|
|
+ [gapSnap.gap.startSide[0][0], gapLineY],
|
|
|
+ [minX, gapLineY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "horizontal",
|
|
|
+ points: [
|
|
|
+ [maxX, gapLineY],
|
|
|
+ [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: [
|
|
|
+ [gapLineX, gapSnap.gap.startSide[0][1]],
|
|
|
+ [gapLineX, minY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "vertical",
|
|
|
+ points: [
|
|
|
+ [gapLineX, maxY],
|
|
|
+ [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: [
|
|
|
+ [startMaxX, gapLineY],
|
|
|
+ [endMinX, gapLineY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "horizontal",
|
|
|
+ points: [
|
|
|
+ [endMaxX, gapLineY],
|
|
|
+ [minX, gapLineY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "side_left": {
|
|
|
+ if (verticalIntersection) {
|
|
|
+ const gapLineY =
|
|
|
+ (verticalIntersection[0] + verticalIntersection[1]) / 2;
|
|
|
+
|
|
|
+ gapSnapLines.push(
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "horizontal",
|
|
|
+ points: [
|
|
|
+ [maxX, gapLineY],
|
|
|
+ [startMinX, gapLineY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "horizontal",
|
|
|
+ points: [
|
|
|
+ [startMaxX, gapLineY],
|
|
|
+ [endMinX, gapLineY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "side_top": {
|
|
|
+ if (horizontalGapIntersection) {
|
|
|
+ const gapLineX =
|
|
|
+ (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
|
|
|
+
|
|
|
+ gapSnapLines.push(
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "vertical",
|
|
|
+ points: [
|
|
|
+ [gapLineX, maxY],
|
|
|
+ [gapLineX, startMinY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "vertical",
|
|
|
+ points: [
|
|
|
+ [gapLineX, startMaxY],
|
|
|
+ [gapLineX, endMinY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "side_bottom": {
|
|
|
+ if (horizontalGapIntersection) {
|
|
|
+ const gapLineX =
|
|
|
+ (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
|
|
|
+
|
|
|
+ gapSnapLines.push(
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "vertical",
|
|
|
+ points: [
|
|
|
+ [gapLineX, startMaxY],
|
|
|
+ [gapLineX, endMinY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: "gap",
|
|
|
+ direction: "vertical",
|
|
|
+ points: [
|
|
|
+ [gapLineX, endMaxY],
|
|
|
+ [gapLineX, minY],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return dedupeGapSnapLines(
|
|
|
+ gapSnapLines.map((gapSnapLine) => {
|
|
|
+ return {
|
|
|
+ ...gapSnapLine,
|
|
|
+ points: gapSnapLine.points.map(
|
|
|
+ (point) => [round(point[0]), round(point[1])] as Point,
|
|
|
+ ) 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[],
|
|
|
+ appState: AppState,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+ dragOffset: Vector2D,
|
|
|
+ transformHandle: MaybeTransformHandleType,
|
|
|
+) => {
|
|
|
+ if (
|
|
|
+ !isSnappingEnabled({ event, selectedElements, appState }) ||
|
|
|
+ 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: Point[] = [];
|
|
|
+
|
|
|
+ if (transformHandle) {
|
|
|
+ switch (transformHandle) {
|
|
|
+ case "e": {
|
|
|
+ selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "w": {
|
|
|
+ selectionSnapPoints.push([minX, minY], [minX, maxY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "n": {
|
|
|
+ selectionSnapPoints.push([minX, minY], [maxX, minY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "s": {
|
|
|
+ selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "ne": {
|
|
|
+ selectionSnapPoints.push([maxX, minY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "nw": {
|
|
|
+ selectionSnapPoints.push([minX, minY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "se": {
|
|
|
+ selectionSnapPoints.push([maxX, maxY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "sw": {
|
|
|
+ selectionSnapPoints.push([minX, maxY]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const snapDistance = getSnapDistance(appState.zoom.value);
|
|
|
+
|
|
|
+ const minOffset = {
|
|
|
+ x: snapDistance,
|
|
|
+ y: snapDistance,
|
|
|
+ };
|
|
|
+
|
|
|
+ const nearestSnapsX: Snaps = [];
|
|
|
+ const nearestSnapsY: Snaps = [];
|
|
|
+
|
|
|
+ getPointSnaps(
|
|
|
+ selectedOriginalElements,
|
|
|
+ selectionSnapPoints,
|
|
|
+ appState,
|
|
|
+ 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: Point[] = [
|
|
|
+ [x1, y1],
|
|
|
+ [x1, y2],
|
|
|
+ [x2, y1],
|
|
|
+ [x2, y2],
|
|
|
+ ];
|
|
|
+
|
|
|
+ getPointSnaps(
|
|
|
+ selectedElements,
|
|
|
+ corners,
|
|
|
+ appState,
|
|
|
+ event,
|
|
|
+ nearestSnapsX,
|
|
|
+ nearestSnapsY,
|
|
|
+ minOffset,
|
|
|
+ );
|
|
|
+
|
|
|
+ const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
|
|
|
+
|
|
|
+ return {
|
|
|
+ snapOffset,
|
|
|
+ snapLines: pointSnapLines,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+export const snapNewElement = (
|
|
|
+ draggingElement: ExcalidrawElement,
|
|
|
+ appState: AppState,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+ origin: Vector2D,
|
|
|
+ dragOffset: Vector2D,
|
|
|
+) => {
|
|
|
+ if (
|
|
|
+ !isSnappingEnabled({ event, selectedElements: [draggingElement], appState })
|
|
|
+ ) {
|
|
|
+ return {
|
|
|
+ snapOffset: { x: 0, y: 0 },
|
|
|
+ snapLines: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const selectionSnapPoints: Point[] = [
|
|
|
+ [origin.x + dragOffset.x, origin.y + dragOffset.y],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const snapDistance = getSnapDistance(appState.zoom.value);
|
|
|
+
|
|
|
+ const minOffset = {
|
|
|
+ x: snapDistance,
|
|
|
+ y: snapDistance,
|
|
|
+ };
|
|
|
+
|
|
|
+ const nearestSnapsX: Snaps = [];
|
|
|
+ const nearestSnapsY: Snaps = [];
|
|
|
+
|
|
|
+ getPointSnaps(
|
|
|
+ [draggingElement],
|
|
|
+ selectionSnapPoints,
|
|
|
+ appState,
|
|
|
+ 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([draggingElement], {
|
|
|
+ boundingBoxCorners: true,
|
|
|
+ omitCenter: true,
|
|
|
+ });
|
|
|
+
|
|
|
+ getPointSnaps(
|
|
|
+ [draggingElement],
|
|
|
+ corners,
|
|
|
+ appState,
|
|
|
+ event,
|
|
|
+ nearestSnapsX,
|
|
|
+ nearestSnapsY,
|
|
|
+ minOffset,
|
|
|
+ );
|
|
|
+
|
|
|
+ const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
|
|
|
+
|
|
|
+ return {
|
|
|
+ snapOffset,
|
|
|
+ snapLines: pointSnapLines,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+export const getSnapLinesAtPointer = (
|
|
|
+ elements: readonly ExcalidrawElement[],
|
|
|
+ appState: AppState,
|
|
|
+ pointer: Vector2D,
|
|
|
+ event: KeyboardModifiersObject,
|
|
|
+) => {
|
|
|
+ if (!isSnappingEnabled({ event, selectedElements: [], appState })) {
|
|
|
+ return {
|
|
|
+ originOffset: { x: 0, y: 0 },
|
|
|
+ snapLines: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const referenceElements = getVisibleAndNonSelectedElements(
|
|
|
+ elements,
|
|
|
+ [],
|
|
|
+ appState,
|
|
|
+ );
|
|
|
+
|
|
|
+ const snapDistance = getSnapDistance(appState.zoom.value);
|
|
|
+
|
|
|
+ const minOffset = {
|
|
|
+ x: snapDistance,
|
|
|
+ y: snapDistance,
|
|
|
+ };
|
|
|
+
|
|
|
+ const horizontalSnapLines: PointerSnapLine[] = [];
|
|
|
+ const verticalSnapLines: PointerSnapLine[] = [];
|
|
|
+
|
|
|
+ for (const referenceElement of referenceElements) {
|
|
|
+ const corners = getElementsCorners([referenceElement]);
|
|
|
+
|
|
|
+ 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, [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, [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 === "rectangle" ||
|
|
|
+ activeToolType === "ellipse" ||
|
|
|
+ activeToolType === "diamond" ||
|
|
|
+ activeToolType === "frame" ||
|
|
|
+ activeToolType === "image"
|
|
|
+ );
|
|
|
+};
|