diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/snapping.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/snapping.ts')
| -rw-r--r-- | packages/excalidraw/snapping.ts | 1425 |
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]; +}; |
