| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361 |
- 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"
- );
- };
|